DEV Community

Nic Luther
Nic Luther

Posted on

Building a Native Mac Unified Inbox with SwiftUI: Email, Slack & GitHub in One Window

Why I Built a Unified Inbox for Mac

I was context-switching 50+ times a day between Gmail, Slack, and GitHub. Each notification would pull me out of flow, and important messages got buried across multiple apps.

After trying every productivity app on the market (Shift, Wavebox, Rambox), I realized they all had the same fundamental problem: they're Electron apps that just wrap web pages. Heavy, slow, and not truly native to macOS.

So I built HeyRobyn - a native SwiftUI unified inbox for Mac.

Technical Decisions

Why SwiftUI Over Electron?

The performance difference is night and day:

  • Memory: 150MB vs 800MB+ for Electron competitors
  • Battery: Native APIs mean better power efficiency
  • Speed: No web view overhead - instant UI updates
  • Mac-native feel: Proper keyboard shortcuts, Touch Bar support, macOS design patterns

Architecture Overview

┌─────────────────────┐
│   SwiftUI Views     │  ← Native UI layer
├─────────────────────┤
│  Core Data Models   │  ← Local-first storage
├─────────────────────┤
│   API Integrations  │  ← Gmail, Slack, GitHub SDKs
│   (OAuth2 + PKCE)   │
└─────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Everything runs on-device. No cloud sync means:

  • Privacy: Your emails never touch our servers
  • Speed: No network latency for UI operations
  • Offline: Full functionality without internet

The Hardest Part: OAuth2 Flow

Getting OAuth2 working natively in SwiftUI (without a web view) was surprisingly complex. Here's the key pattern I landed on:

// Simplified OAuth2 PKCE flow
class AuthManager: ObservableObject {
    @Published var isAuthenticated = false

    func authenticate(service: Service) async throws {
        // 1. Generate PKCE challenge
        let codeVerifier = generateCodeVerifier()
        let codeChallenge = generateChallenge(from: codeVerifier)

        // 2. Open system browser for consent
        let authURL = buildAuthURL(challenge: codeChallenge)
        NSWorkspace.shared.open(authURL)

        // 3. Listen for callback via custom URL scheme
        let authCode = try await waitForCallback()

        // 4. Exchange code for tokens
        let tokens = try await exchangeCodeForTokens(
            code: authCode, 
            verifier: codeVerifier
        )

        // 5. Store securely in Keychain
        try KeychainService.store(tokens, for: service)
        isAuthenticated = true
    }
}
Enter fullscreen mode Exit fullscreen mode

This pattern works for Gmail (Google OAuth), Slack, and GitHub.

Unified Inbox Logic

The core challenge: how do you merge 3 different message types into one chronological feed?

I created a protocol-oriented approach:

protocol UnifiedMessage: Identifiable {
    var timestamp: Date { get }
    var sender: String { get }
    var preview: String { get }
    var source: MessageSource { get } // .email, .slack, .github
    var priority: Priority { get }
}

// Each integration implements this protocol
struct EmailMessage: UnifiedMessage { ... }
struct SlackMessage: UnifiedMessage { ... }
struct GitHubNotification: UnifiedMessage { ... }

// Unified feed is just a sorted array
var feed: [UnifiedMessage] = [
    emails, slackMessages, githubNotifications
].flatMap { $0 }
 .sorted { $0.timestamp > $1.timestamp }
Enter fullscreen mode Exit fullscreen mode

AI Triage: The Secret Sauce

I integrated local ML models (Core ML) to auto-categorize and prioritize messages:

  • Urgent: Mentions from your manager, security alerts, build failures
  • Important: PR reviews, direct messages, project updates
  • Low priority: Marketing emails, automated notifications, FYIs

This runs entirely on-device using Apple's Natural Language framework:

import NaturalLanguage

func categorizePriority(for message: UnifiedMessage) -> Priority {
    let tagger = NLTagger(tagSchemes: [.sentimentScore])
    tagger.string = message.content

    // Check for urgency keywords
    if message.content.containsUrgentKeywords() {
        return .urgent
    }

    // Analyze sender importance
    if importantContacts.contains(message.sender) {
        return .important
    }

    return .low
}
Enter fullscreen mode Exit fullscreen mode

Lessons Learned

1. SwiftUI is Amazing (But Has Sharp Edges)

  • List performance: For 1000+ items, use LazyVStack + custom diffing, not List
  • State management: Combine's @Published + ObservableObject works great
  • Navigation: SwiftUI's NavigationStack is still buggy - sometimes need UIKit bridges

2. Privacy-First Is Hard But Worth It

Users are extremely sensitive about email privacy. Going 100% local-first was the right call:

  • No backend to maintain or secure
  • No GDPR compliance headaches
  • Users trust the app more (visible in feedback)

3. Distribution on Mac Is Unique

  • App Store: 30% fee + sandboxing restrictions (can't access Mail.app data)
  • Direct distribution: Code signing costs $99/year, notarization takes time
  • Pricing: Mac users expect either $10 one-time OR $5-15/mo subscription

I went with direct distribution + subscription ($12.50/mo early access).

What's Next

I'm launching HeyRobyn publicly on March 18, 2026. Early access waitlist: heyrobyn.ai

Planning to add:

  • Linear integration (project management)
  • Discord (for open source communities)
  • Custom integrations via plugin API

Would love feedback from the community:

  • What other integrations would you want?
  • Any concerns about the privacy model?
  • Mac developers: what's your unified inbox setup?

Tech stack: SwiftUI, Core Data, Combine, Core ML, OAuth2 PKCE

Waitlist: https://heyrobyn.ai

Top comments (0)