Coming soon: Premium System Design Courses

Design Gmail

60 minHard
Haider KhanHaider Khan, Lead Engineer, Meta, ex-Apple

Understanding the Problem

What is an Email Client?

A mobile e-mail client synchronises multiple server accounts, stores a working subset of messages locally, drafts offline, queues sends when radio is down, and surfaces push notifications for new mail. Canonical examples: Apple Mail, Gmail, Outlook.

Mobile-specific pain points:

  • SQLite schema tuned for 250 k cached messages.
  • .emlx or .mboxpb file blobs mapped directly into view renderers.
  • Send / sync queues that tolerate crashes and loss of connectivity.
  • Compact wire formats → Protobuf + HTTP/2.

Clarifying Questions

  1. 1

    How many daily-active iOS devices?

    Google publicly quotes ≈ 300 M DAU; we’ll size disk & push fan-out for that.
  2. 2

    Peak simultaneous foreground users?

    Use 2 % of DAU ≈ 6 M phones; sets upper bound for HTTP/2 connection pools.
  3. 3

    Average new messages per user per day?

    120 mails → about 18 items / silent push.
  4. 4

    Typical history-list diff size?

    18 × 50 B ≈ 0.9 KB JSON per delta; fits our ≤ 2 MB/day data SLO.
  5. 5

    Message size distribution?

    P50 ≈ 32 KB, P95 ≈ 400 KB (from Gmail SRE talks). Attachments > 4 KB are detached.
  6. 6

    Offline cache window?

    90 days or 5 GB, whiche­ver comes first, with weekly WAL-checkpoint & pruning.

Functional Requirements

Let's define some common functionalities of what an email client that functions similarly to Gmail should do:

  • Send / receive mail
  • Threaded conversations
  • Offline viewing
  • Draft autosave
  • Queue when offline
  • Push for new mail
  • Accessibility / localisation
  • Multiple accounts

Staff Tip

Dig a little deeper into a specific functional requirement to give a strong signal of depth.

For example for Send / Receive mail: “First, I’ll guarantee exactly-once delivery. That means persisting the draft before network, ULID Message-ID for dedupe, and idempotent server writes.”

Non-Functional Requirements

These requirements define how the system should behave and are more about meeting needs at scale:

  • Scroll: 60 fps, ≤ 4 ms CPU/frame.
  • Search: local match ≤ 200 ms.
  • Cold-start: first paint ≤ 1.5 s (Google KPI).
  • Battery: background ≤ 1 %/h.
  • Sync: delta only, ≤ 2 MB/day.
  • Storage: support 250 k msgs, 10 GB atts, prune beyond 5 GB.

Staff Tip

State each non-functional requirement, then immediately say how you’ll validate it (metrics, traces, CI perf test). Show ownership beyond code.

For example to address cold-start: “We ship an InboxTop50 snapshot (15 KB) in app-group cache, paint immediately, then fire SyncActor. Startup never waits on network.”

Out-Of-Scope

These mechanisms are out of scope for this question as they would fit within the time limit and could be their own system design question on their own:

  • TLS / S/MIME key-management details.
  • Full attachment CDN & resumable-upload service.
  • OAuth flow & refresh-token lifecycle.

Rough capacity planning for each core entity on launch-day (mobile only):

  • Active devices ≈ 300 M DAU
    logged-in phones/tablets that open the app at least once per day
  • Incoming mail ≈ 36 B messages per day
    120 messages × 300 M users
  • Outgoing mail ≈ 4 B messages per day
    ~12 sends / user; heavy skew toward power users
  • Threads touched ≈ 20 B per day
    conversation containers fetched/updated
  • Attachments downloaded ≈ 14 B per day
    ~40 % of messages carry ≥ 1 attachment; detached > 4 KB parts only
  • Push notifications ≈ 5.4 B per day
    18 silent pushes × 300 M devices
  • Local cache window — 90 days or ≤ 5 GB
    cap SQLite + blob store; older blobs pruned
  • Sync API read/write ratio ≈ 150 : 1
    foreground reads, background delta pulls dwarf user-initiated writes

API (REST facade ⇄ gRPC)

POST/api/v1/users/{uid}/messages:sendUpload a complete RFC-822 email. gRPC: `SendMessage`.

Request Body:

{
  "raw": "RGF0ZTogV2VkLCA2IEp1biAyMDI1IDA5OjAwOjAwICswMDAwDQpTdWJqZWN0OiBIaSEgVGhpcyBpcyBhIHRlc3QgbWFpbA0KUHJlZmVyZW5jZTogPGYxQDE4ZDkxLjFlZTg4YzQuMTJhZUBtYWlsLmdtYWlsLmNvbT4NCkZyb206IGFsaWNlQGV4YW1wbGUuY29tDQpUbzogYm9iQGV4YW1wbGUuY29tDQoNCkhpIEJvYiENCg0KVGhpcyBpcyBqdXN0IGEgdGVzdCBtZXNzYWdlLi4uIg
}
GET/api/v1/users/{uid}/historyDelta list (`start`, `pageSize`). gRPC: `ListHistory`.
GET/api/v1/users/{uid}/messages/{mid}Fetch body (`format=raw`). gRPC: `GetMessage`.
POST/api/v1/users/{uid}/draftsCreate / update draft. gRPC: `CreateDraft`.

Request Body:

{
  "message": {
    "raw": "RGF0ZTogLi4u",
    "labelIds": [
      "DRAFT"
    ]
  }
}
POST/upload/api/v1/users/{uid}/attachments?uploadType=resumableBegin resumable attachment upload. gRPC: `BeginResumableUpload`.

Request Body:

{
  "name": "photo.jpg",
  "mimeType": "image/jpeg",
  "size": 1048576  // bytes
}
POST/api/v1/users/{uid}/messages/batchModifyArchive / label mutations. gRPC: `BatchModify`.

Request Body:

{
  "ids": [
    "18d904f3ac5c6ab7e",
    "18d904f3ac5c6ab7f"
  ],
  "addLabelIds": [
    "STARRED"
  ],
  "removeLabelIds": [
    "UNREAD"
  ]
}

Why choose gRPC for mobile?

  • Schema-Driven
    Protobuf enforces compile time safety.
  • HTTP/2 Multiplexing
    Many concurrent RPCs on one TLS socket → radio wakes once.
  • Binary Payloads
    ≈ 30 % smaller than JSON, cheaper on metered data.
  • Streaming
    Resumable attachment uploads/downloads without extra control calls.

High Level Design

UI (SwiftUI) MailboxSidebarView ThreadListView MessageDetailView ComposeDraftView KVO / @Observable Presentation View‑Models (MainActor) MailboxVM ThreadListVM MessageVM ComposeVM async/await calls Domain Actors (Concurrency) SyncActor SearchActor DraftActor AttachmentActor await persistence Repositories (BG Core‑Data ctx) MailRepo DraftRepo AttachmentRepo SearchFTSRepo WAL, mmap, FTS5 Core‑Data gmail.db tables Mailbox Thread Message Draft ActionQueue network sync Transport Clients (isolated actors) GmailGRPCClient APNsHandler UploadSvcClient AuthRefresh
System Architecture
  1. 1

    UI Flow & State

    UI Layer in SwiftUI or Jetpack Compose with an Observable Snapshot of the UI and State. Most of these process on the main thread.
    • MailboxSidebarView
    • ThreadListView
    • MessageDetailView
    • ComposeDraftView
  2. 2

    Presentation (ViewModel)

    These are KVO objects which might process things off the main thread through the Domain layer (via dependency injection) but mutate the UI state on the main thread.
    • MailboxViewModel
    • ThreadListViewModel
    • MessageViewModel
    • ComposeViewModel
  3. 3

    Domain Actors

    These are serial executors which operate on their own queue, therefore are thread safe and orchestrate IO, Business Logic, Idempotency, etc.
    • SyncActor
    • OutboxTaskGroup
    • DraftActor
    • SearchActor
    • AttachmentActor
  4. 4

    Repositories

    Async CRUD helpers with CoreData background context (shared connection pool + scratch-pad).
    • MailboxRepository
    • DraftRepository
    • AttachmentRepository
  5. 5

    Persistence

    Hot metadata read path, raw bodies for attachments, blobs mmap'd at render time.
    • SyncActor
    • OutboxTaskGroup
    • DraftActor
    • SearchActor
    • AttachmentActor
  6. 6

    Network Transport

    Networking, token refresh, and resumable uploads.
    • GRPCClient (HTTP/2)
    • PushNotificationHandler
    • UploadManager
  7. 7

    System Helpers

    Background CPU, secure credential store.
    • BGAppRefreshTask
    • BGProcessingTask
    • Keychain
    • Secure Enclave

UI Layer

The UI Layer of an iOS email client must present potentially thousands of messages and threads in a smooth intuitive way. Modern iOS apps can leverage SwiftUI for declarative UI or UIKit for a more traditional approach. In either case, the goal is a responsive, fluid interface even as data updates in the background.

Challenges:

  • Displaying large lists (inbox with hundreds or thousands of emails) with smooth scrolling and minimal memory overhead.
  • Navigating between screens (inbox, email thread view, compose screen) without delays, even while syncing data in the background.
  • Reflecting real-time updates (new eails, read / unread status changes) in the UI consistently across multiple views (e.g. updating an email cell in the list when the user reads that email in detail view).
  • Ensuring that heavy work (parsing email content, loading attachments, etc.) doesn't block the main thread or freeze the UI.

Implementation Strategies:

  • SwiftUI with Observation:
    Use SwiftUI views backed by observable view models (@Observable or ObservableObject classes) to automatically propagate data changes to the UI. For example, a MailListViewModel can publish a list of emails, and the MailListView (SwiftUI) will re-render whenever that list changes. This decouples UI from data fetching - the view simply declares it displays mailListViewModel.emails. Swift's Observation API (iOS 17+) can be leveraged for simpler reactive state management without third-party frameworks.
  • Efficient List:
    Utilize SwiftUI's List or LazyVStack (or UITableView with diffable data sources in UIKit) to efficiently reuse UI cells and only render elements on-screen. SwiftUI's lazy loading ensures off-screen emails aren't instantiated until needed, which is crucial for large inboxes. Scrolling performance can be optimized by using simplified preview data (e.g. a snippet of the email body and a thumbnail of attachments) in the list, deferring full content rendering until the email is opened.
  • Asynchronous UI Updates:
    Load images and attachments asynchronously using async / await with Swift Concurrency. For instance, an email cell can display a placeholder image and use an AsyncImage or a custom concurrency task to fetch the sender's avatar or an email preview thumbnail. This prevents blocking the UI. Swift concurrency's structured approach helps avoid callback hell - each image load or network fetch can simply be an await call inside a SwiftUI .task modifier on the view, which keeps the code readable.
  • Main-Thread Affinity:
    Keep all UI updates on the main thread. SwiftUI largely handles this for you (State updates must happen on main thread by default), but if using background threads (e.g. an actor updating the model), ensure to marshal back to MainActor for UI-critical state. This avoids race conditions and flicker.
  • Modular UI Components:
    Structure the UI in layers - for example, a MailListView observing a MailListViewModel, which in turn calls a MailRepository. This separation (often MVVM architecture) means the view model can be tested independently and can handle when to fetch or refresh data, while the SwiftUI view focuses only on presentation.

To illustrate the UI architecture, consider the flow of data from the model to the screen:

SwiftUI View (InboxView) (binds via @Observable or @StateObject) ViewModel / Controller (InboxViewModel) (fetches and updates) Repository / Data Store (MailRepository) (reads / writes) SQLite Cache & Local Models (syncs via network) Gmail Server API
UI Data Flow

In this design, the SwiftUI InboxView displays a list of emails from an InboxViewModel. The view model, marked as @Observable (or an ObservableObject), provides published properties like emails and handles the user actions (pull-to-refresh, delete swipe) by invoking the Repository. The repository abstracts data access: it fetches from a local SQLite cache (for quick UI updates) and triggers the Sync Engine to fetch newer data from Gmail's servers. The UI is thus updated from the local cache immediately (for a snappy feel), and any new data from the server will later flow into the view model's emails property, causing the UI list to refresh automatically. This reactive update cycle keeps the UI in sync with underlying data without manual refreshing.

SwiftUI's advantage here is that state changes (like an email's read status) automatically cause UI recomposition. For example, marking an email as read in the detail view could update the shared state, and the list view's row will automatically reflect the "read" style change. By using a single source of truth for an email's state (e.g. a unified MailStore or the SQLite database), we avoid inconsistent between screens.

State Management

Robust state management ensures that the email client's data (emails, threads, user settings, etc.) remains consistent and accessibly across the app's views and features. Unlike a simple app, an email client deals with complex state: a message can appear in multiple contexts (inbox list, thread view, search results) and user actions (like archiving or labeling an email) should instantly reflect everywhere. We must handle state in a way that is both performant and thread-safe, especially since network and sync operations will update state asynchronously.

Challenges:

  • Maintaining a single source of truth for emails and related data so that updates propagate uniformly (to avoid a scenario where an email appears unread in one view but read in another).
  • Sharing state between different parts of the app (for example, the badge count for unread emails might be needed in multiple views or in app icons).
  • Handling state changes coming from background sync (e.g. a push notification indicates new mail – this should update state and UI seamlessly even if the user is in a different mailbox view).
  • Avoiding race conditions and ensuring thread safety when state is modified by multiple concurrent tasks (for instance, incoming sync updating an email while the user concurrently marks it as read).
  • Organizing state for multiple accounts (if the app supports it) or multiple mailboxes (Inbox, Sent, etc.) in a clean way.

Implementation Strategies:

  • Central Store / Singleton:
    Use a central MailStore (a singleton or environment object) that holds the master copy of state such as the list of emails, threads, etc. In SwiftUI, this could be an @Observable class MailStore injected into the environment for the app. Views or view models then read from this store. The MailStore can be an actor or use locking internally to ensure thread-safe writes (Swift actors are a convenient way to serialize access to shared state). This ensures all parts of the app talk to the same data store.
  • Undirectional Data Flow:
    Adopt a unidirectional flow (inspired by Redux/TCA architecture, but you can implement a lightweight version with Swift concurrency). This means UI events dispatch intent actions (e.g. “mark email X as read”) into the system, perhaps handled by the MailStore or a reducer, which then updates state and triggers side effects (like informing the sync engine). State changes then flow back to the UI via the observable bindings. Keeping this clear flow makes it easier to reason about state changes and avoids spaghetti update logic.
  • Swift Concurrency for Mutations:
    Use structured concurrency to manage state mutations. For example, if an async task fetches new emails from the server, have it call an await mailStore.insert(emails: [Email]) function. That function could be isolated to an actor (the MailStore itself, if it’s an actor), so it safely updates the state on a single thread. This avoids needing explicit locks and prevents simultaneous writes corrupting state. An example scenario: the sync engine fetches new messages while the user deletes an email – by funneling both through a single actor, these operations queue up, preventing inconsistent ordering or “database locked” errors. (SQLite allows only one writer at a time, so serializing via an actor is prudent).
  • State Categorization:
    Separate ephemeral UI state from persistent data state. UI state (like “currently selected filter” or “is compose sheet open”) can live in view-specific @State or in the view model, whereas persistent state (emails, contacts, etc.) lives in the central store or database. This prevents ephemeral toggles from polluting the global state and keeps global state focused on the data that must be consistent app-wide.
  • Environment and Dependency Injection:
    Pass the state store or relevant slices of state into views using SwiftUI’s @EnvironmentObject or through initializer injection. For instance, a ThreadView (showing an email thread) might be given a reference to the MailStore or just the specific thread object to display. By observing an @Observable thread model, that view will update if, say, one of the messages in the thread changes (e.g., if it gets marked as read by another device and our sync updates it).
  • Avoid Combine (Use Async / Await):
    Instead of using Combine publishers for state updates, prefer Swift’s async/await and AsyncSequence for any asynchronous streams of data. For example, if we wanted to continuously listen for new emails, we could utilize an AsyncStream in an actor that yields new Email objects as they come in from the server. The UI layer could consume this via .task loops or by updating the shared state accordingly. This approach uses Swift Concurrency under the hood, aligning with modern patterns and avoiding the added complexity of Combine.

Example - using an Observable Object: In practice, we might have something like:

//  ┌─────────────┐   async/await   ┌───────────────┐
//  │   MailCore  │◀───────────────▶│   SyncEngine  │
//  │   (actor)   │                 └───────────────┘
//  │   holds     │
//  │  *true*     │  MainActor.sync
//  └─────▲───────┘        │
//        │ snapshot       ▼
//  ┌─────────────┐   drives UI
//  │  MailStore  │   (@Observable  @MainActor)
//  └─────────────┘
//        │
//  ┌─────────────┐
//  │ SwiftUI App │
//  └─────────────┘
//

import SwiftUI
import Observation

//───────────────────────────────────────────────────────────────
// MARK: 1. Domain model
//───────────────────────────────────────────────────────────────

/// A single e-mail message.
struct Email: Identifiable, Hashable {
    let id:      UUID
    var subject: String
    var preview: String
    var date:    Date
    var isRead:  Bool = false
}

//───────────────────────────────────────────────────────────────
// MARK: 2. Global actor for mailbox state
//───────────────────────────────────────────────────────────────

/// All `MailCore` work is funnelled through this executor.
@globalActor
final actor MailCoreActor {
    static let shared = MailCoreActor()
}

//───────────────────────────────────────────────────────────────
// MARK: 3. Infrastructure actor (network / persistence)
//───────────────────────────────────────────────────────────────

/// Pretend network layer with latency.
actor SyncEngine {

    /// Singleton for convenience (swap with DI if preferred).
    static let shared = SyncEngine()

    /// Fetch a page of dummy messages.
    func fetchInbox() async throws -> [Email] {
        try await Task.sleep(for: .milliseconds(500))
        return (0..<20).map { i in
            Email(
                id:      .init(),
                subject: "Message \(i)",
                preview: "Lorem ipsum dolor sit amet, consectetur…",
                date:    .now.addingTimeInterval(-Double(i) * 3_600)
            )
        }
    }

    /// Pretend to mark a message read on the server.
    func markRead(_ id: UUID) async {
        try? await Task.sleep(for: .milliseconds(300))
    }
}

//───────────────────────────────────────────────────────────────
// MARK: 4. Data engine — isolated by the global actor
//───────────────────────────────────────────────────────────────

/// Owns canonical mailbox state; synchronous members run on `MailCoreActor`.
@MailCoreActor
final class MailCore {

    /// Dependency on the infrastructure actor.
    private let sync: SyncEngine

    /// Mutable mailbox cache (actor-protected via the global actor).
    private var inbox = [Email]()

    /// Non-isolated constructor so MainActor contexts can instantiate directly.
    nonisolated init(sync: SyncEngine = .shared) {
        self.sync = sync
    }

    /// Return a defensive copy of the current inbox.
    func snapshot() -> [Email] {
        inbox
    }

    /// Replace local cache with server data.
    func refresh() async throws {
        inbox = try await sync.fetchInbox()
    }

    /// Toggle read flag locally and persist remotely.
    func markAsRead(_ id: UUID) async {
        guard let idx = inbox.firstIndex(where: { $0.id == id }) else { return }
        inbox[idx].isRead = true
        await sync.markRead(id)
    }

    /// Insert messages that arrived via push notification.
    func insert(_ new: [Email]) {
        inbox.insert(contentsOf: new, at: 0)
    }
}

//───────────────────────────────────────────────────────────────
// MARK: 5. UI façade — MainActor + Observable
//───────────────────────────────────────────────────────────────

/// Publishes *snapshots* from `MailCore` to SwiftUI.
@MainActor
@Observable
final class MailStore {

    /// Latest view-layer copy of the inbox.
    var inbox: [Email] = []

    /// Derived badge counter.
    var unreadCount: Int {
        inbox.filter { !$0.isRead }.count
    }

    /// Bridge to the global-actor-isolated engine.
    private let core: MailCore

    /// Default initialiser spins up its own core.
    init(core: MailCore = .init()) {
        self.core = core
    }

    /// Pull data and publish it to SwiftUI.
    func refresh() async {
        do {
            inbox = await core.snapshot()        // quick optimistic draw
            try await core.refresh()             // network call
            inbox = await core.snapshot()        // final copy
        } catch {
            print("Refresh failed:", error)
        }
    }

    /// Mark one message read, then update snapshot.
    func markAsRead(_ email: Email) async {
        await core.markAsRead(email.id)
        inbox = await core.snapshot()
    }

    /// Insert new messages (e.g. from APNs).
    func insert(_ new: [Email]) async {
        await core.insert(new)
        inbox = await core.snapshot()
    }
}

//───────────────────────────────────────────────────────────────
// MARK: 6. SwiftUI entry point
//───────────────────────────────────────────────────────────────

@main
struct MailSampleApp: App {

    /// `MailStore` lives on MainActor → regular stored property is fine.
    @State
    private var store = MailStore()

    var body: some Scene {
        WindowGroup {
            InboxView()
                .environment(store)        // Observation injection
        }
    }
}

//───────────────────────────────────────────────────────────────
// MARK: 7. Inbox list view
//───────────────────────────────────────────────────────────────

/// Displays the list of messages.
struct InboxView: View {

    /// Read-only façade from the environment.
    @Environment(MailStore.self)
    private var store

    /// Progress overlay flag.
    @State
    private var isLoading = false

    var body: some View {
        NavigationStack {
            List {
                ForEach(store.inbox) { email in
                    EmailRow(email: email)
                        .onTapGesture {
                            Task { await store.markAsRead(email) }
                        }
                }
            }
            .overlay { if isLoading { ProgressView() } }
            .navigationTitle("Inbox")
            .toolbar {
                if store.unreadCount > 0 {
                    Label("\(store.unreadCount)", systemImage: "envelope.badge")
                }
            }
            .task { await loadOnce() }         // first fetch
            .refreshable { await loadOnce() }  // pull-to-refresh
        }
    }

    /// Prevent overlapping fetches and drive the spinner.
    @MainActor
    private func loadOnce() async {
        guard !isLoading else { return }
        isLoading = true
        await store.refresh()
        isLoading = false
    }
}

//───────────────────────────────────────────────────────────────
// MARK: 8. Single row
//───────────────────────────────────────────────────────────────

/// One message preview cell.
struct EmailRow: View {

    /// Immutable snapshot provided by `List`.
    let email: Email

    /// Need store reference for swipe actions.
    @Environment(MailStore.self)
    private var store

    var body: some View {
        VStack(alignment: .leading, spacing: 4) {
            Text(email.subject)
                .fontWeight(email.isRead ? .regular : .bold)

            Text(email.preview)
                .font(.subheadline)
                .foregroundStyle(.secondary)

            Text(email.date.formatted(date: .omitted, time: .shortened))
                .font(.caption2)
                .foregroundStyle(.tertiary)
        }
        .padding(.vertical, 4)
        .swipeActions(edge: .leading) {
            Button("Read") {
                Task { await store.markAsRead(email) }
            }
            .tint(.blue)
        }
    }
}

This pseudo-code illustrates a simple global store. The MailStore holds the inbox emails array and computes derived state (like unreadCount). When the sync engine fetches new emails, it can call mailStore.insertNewEmails(fetchedEmails) which will update the array – thanks to SwiftUI’s observation, any view showing inboxEmails (or unreadCount) will refresh automatically. Conversely, when the user marks an email as read via the UI, the view model calls await mailStore.markAsRead(email), which updates local state immediately and concurrently triggers the sync engine to notify the server (perhaps via an async call internally). The use of async in markAsRead suggests it might call out to an actor or some network call and thus can be awaited by the UI (we could show a loading indicator if needed). This pattern ensures the app state is updated optimistically and kept in sync with the backend in the background.

Managing state in this disciplined way keeps the mail client consistent and robust. It also makes features like multi-select and bulk actions easier: you can have the state store perform bulk updates (mark many emails read, etc.) in one go and the UI will update in one pass, rather than updating each cell individually with separate network calls.

Sync Engine

The Sync Engine is the heart of the email client’s interaction with the server. It’s responsible for fetching new emails, sending outgoing emails or user actions to the server, and reconciling differences between the local database and the Gmail backend. On iOS, the sync engine must operate within the platform’s constraints on background activity and network usage, so careful design is needed to keep data fresh without draining battery or violating iOS background execution policies.

Challenges:

  • Keeping the inbox up-to-date in near real-time. Unlike a web app that can receive push updates continuously, an iOS app in the background has limited opportunities to run. The sync engine needs to handle push notifications (if available) or schedule periodic refreshes, all while respecting system limits.
  • Performing large data syncs efficiently. The first sync of an account or resync after being offline might involve downloading thousands of emails. This must be done incrementally to avoid huge bursts of network traffic or memory usage.
  • Ensuring changes are bi-directional: new server emails come down, and user actions (send, delete, archive, label) go up to the server. The engine must queue and retry operations for reliability (especially if the network is spotty).
  • Handling multiple accounts (if supported) and multiple folders (Inbox, Sent, Drafts, etc.), possibly concurrently.
  • Working within iOS background task constraints: the system might give only ~30 seconds in a background refresh task to perform an update, so the sync logic must prioritize critical updates first. Longer tasks might only run when the device is charging or on user demand.
  • Avoiding race conditions between simultaneous sync operations (e.g. not trying to fetch new emails at the exact same time as applying a batch of local changes).
  • Minimizing battery and data usage - e.g., avoid polling too frequently or downloading unnecessary data (like not fetching the entire message body or attachments until needed).

Implementation Strategies:

  • Background Fetch & Push Notifications:
    Leverage APNs (Apple Push Notification service) to get real-time alerts for new emails. Gmail’s server can send a silent push notification (content-available) when a new email arrives. This would wake the app briefly in the background so the sync engine can fetch the new message. For periodic refresh (in case push is not available or as a fallback), use BGAppRefreshTask from iOS’s BackgroundTasks framework. Register a background refresh task to periodically check for updates. Keep the work short and focused (e.g. fetch just the latest email headers or changes since last sync) to fit within the allowed timeframe (often on the order of seconds). For heavier sync (like syncing many emails or large attachments), use BGProcessingTask which can run for several minutes when the device is charging or idle.
  • Sync Scheduling:
    Implement a scheduling system within the app. The sync engine could maintain a queue of tasks: e.g., “Fetch latest 50 emails”, “Send queued deletions”, “Upload draft”. When the app is foregrounded, it can run tasks immediately; when backgrounded, tasks either wait for a background opportunity or are triggered by push events. If multiple accounts are present, interleave their tasks or handle them serially to avoid contention.
  • Incremental Fetching:
    Use Gmail’s APIs (or IMAP if using standard protocols) to fetch data incrementally. For example, Gmail’s REST API allows retrieving messages by ID or querying for changes since a given history ID. The sync engine can keep track of a cursor or timestamp of last sync. It can fetch only new or updated messages (e.g., “give me all new messages since ID XYZ”) rather than pulling the entire inbox each time. Similarly, older emails can be fetched on-demand: the app might initially sync the last, say, 100 emails for offline use, and only fetch older emails when the user scrolls down. This on-demand loading prevents wasting resources on data the user may never see.
  • Swift Concurrency & Actors:
    Implement the sync engine using Swift’s concurrency to manage asynchronous calls cleanly. For instance, create a SyncEngine actor that serializes critical sections (like writing to the database). The sync engine can spawn multiple concurrent tasks for different activities – e.g., one task listening for push notifications, another periodically syncing, another handling user-initiated refresh – but coordinate them through shared state or an actor to avoid conflicts. Using async/await for network calls (with URLSession.data(from:)) makes the code straightforward. We might write something like:
func syncNewEmails() async throws {
    let latestID = database.lastSyncedId()
    let url = URL(string: "\(gmailAPI)/messages?since=\(latestID)")!
    let (data, _) = try await URLSession.shared.data(from: url)
    let newMessages = try JSONDecoder().decode([GmailMessage].self, from: data)
    await database.save(messages: newMessages)  // assume database is an actor
    await MainActor.run { mailStore.insertNewEmails(newMessages) }
}

Here, we fetch new messages from the Gmail API and save them. The database.save could be an actor call (ensuring thread-safe writes to SQLite), and then we use MainActor.run to update the in-memory store and UI on the main thread. This structured approach avoids callback nesting and makes error handling simpler (just use throws and do/catch). If multiple such sync functions exist (for contacts, for sending, etc.), they can be called in sequence or parallel depending on needs, using a TaskGroup if appropriate to fetch in parallel but not too many at once.

  • Networking:
    Utilize URLSession with HTTP/2 keep-alive or streaming if Gmail provides a streaming API. Gmail’s proprietary API also supports batch requests – if available, the sync engine can bundle multiple operations (e.g., fetch 10 message bodies in one request) to reduce overhead. Set appropriate URLRequest.cachePolicy to leverage caching for static resources (like images in emails might be cached by URLSession automatically, reducing re-downloads).
  • Prioritization:
    Design the sync tasks with priorities. For example, new incoming emails might be high priority (user should see them ASAP), whereas syncing older emails or pre-fetching attachments can be low priority. Swift’s concurrency Task priority can be used, or simply schedule accordingly. If a background task only has e.g. 30 seconds, fetch headers first (so user at least knows a new email arrived) and defer downloading the full body until the app is opened or another background opportunity.
  • Background Execution Constraints:
    Always call BGTaskScheduler to schedule the next background refresh after finishing one, as iOS requires re-scheduling. Also handle expiration: if the system cuts short the background task, ensure the sync engine saves progress and cancels cleanly. Use notifications or app badge updates sparingly in background – for instance, after a background fetch, you might update the app icon’s unread badge count to reflect new mail. But be careful: do this only after saving state, and possibly use UNUserNotificationCenter to show a notification for new mail (if user granted permission). That notification can be configured to launch the app or just inform the user.

Integration with UI/State: The sync engine will typically not directly manipulate UI, but it will update the Storage (database) and/or the State Management layer, which in turn updates UI. For example, after fetching new emails, as in the code above, the sync logic updated the mailStore on the main thread, causing SwiftUI to refresh. Similarly, when a user triggers a manual refresh (pull-to-refresh gesture), the UI can call a sync function and perhaps show a loading spinner until it completes.

Push Notification Flow Example: Suppose a new email arrives. Google’s server sends a push notification to the device. The iOS system wakes the app in the background (if the notification is configured with content-available: 1). The app’s push handler informs the sync engine (which might be an actor or a singleton service) that there’s new mail. The sync engine then performs a quick sync (maybe just fetch the single new message by ID). It saves the message to SQLite and updates the MailStore. The MailStore’s published state changes – if the app is backgrounded, maybe nothing visible happens, but if the user later opens the app, the new email is already in their inbox list. If the app was in foreground (user actively in the inbox), they might see the new email appear in the list in real time (since the state update triggers the SwiftUI list to show the new item at the top). This delivers a seamless experience akin to Gmail: new mail appears almost instantly, even on mobile.

Data Modeling

The data model defines how emails and related entities (threads, attachments, labels, etc.) are represented in the app. A Gmail-like client has to model Gmail’s concepts of conversations (threads) and labels, which differ from a traditional IMAP folder model. Crafting a good schema and data representation is critical for both correctness and performance (especially when storing locally in SQLite).

Challenges:

  • Email threads:
    Gmail groups emails into conversations by thread ID. The data model should represent a thread containing multiple messages. We need to efficiently query all messages in a thread to display a conversation view.
  • Labels vs Folders:
    Gmail uses labels (tags) rather than moving messages into distinct folders. A single message can have multiple labels (e.g., “Inbox” and “Work” and “Important”). The model must support a many-to-many relationship between messages (or threads) and labels.
  • Attachments:
    Emails can have attachments of various types. We must decide how to represent attachments (metadata in the database? File paths to downloaded files?) and link them to messages.
  • Metadata:
    Each email has data like sender, recipients, subject, snippet, body (possibly both plain text and HTML), timestamps, read/unread status, starred (flagged) status, etc. We should store the essential metadata needed for list display (sender, subject, snippet, date, flags) separately from the full content (which might be large) for performance.
  • Size and Indexing:
    Users can have tens of thousands of emails. The model and indexes should allow quick lookup by things like thread, date, or if we implement search, by keywords.
  • Drafts and Outbox:
    Draft emails (composed but not sent yet) and Outbox (emails queued to send) need representation as well, possibly separate from “received” emails. But they may share fields (subject, body, etc.).
  • Contacts:
    Possibly model email contacts or at least recently used emails for autocomplete in compose. This might be out of scope for core, but a real client would handle it – likely via separate contact suggestions logic (or using iOS Contacts integration).

SQLite Schema Design: Design a relational schema that mirrors these relationships. For example, one schema could be:

  • Account

    One signed-in Gmail identity stored locally. accountId is the global root foreign key for every other table; survives iCloud/Time-Machine restores. Stores OAuth refreshToken (only a keychain reference in the Database), lastHistoryId for incremental sync and small prefs like cacheWindowDays.
    • accountId: UUID (primary key – unique across backups / merges)
    • email: string
    • refreshToken: string (Keychain ref)
    • lastHistoryId: int64
    • cacheWindowDays: int16
    • createdAt: timestamp
    • lastSyncAt: timestamp
  • Mailbox

    Represents the left-nav label / folder list (Inbox, Starred, custom labels), type enum lets UI group "System" vs. "User" vs. "Smart". Denormalized unreadCount / totalCount give badge numbers instantly without counting children, remoteLabelId keeps the Gmail serverPermId for sync; null for purely local smart folders.
    • mailboxId: INTEGER PRIMARY KEY (SQLite rowid, auto-increments)
    • accountId: UUID (foreign key)
    • name: string
    • type: MailboxType (enum - inbox, sent, custom, ...)
    • remoteLabelId: string nullable
    • unreadCount: int32
    • totalCount: int32
  • Thread

    One entry per Gmail thread (conversation). It could store an overall snippet or subject (perhaps from the latest message) for quick display in list. The labelMask (bit-field) mirrors frequently-tested labels (INBOX, STARRED, IMPORTANT) to skip a JOIN during list draw. An optional mailboxHintId helps jump straight to a label row when the thread is opened.
    • threadId: string(17) (primary key)
    • accountId: UUID (foreign key)
    • subject: string(250)
    • lastMsgDate: timestamp
    • labelMask: int64 (bit-field cache)
    • mailboxHintId: int nullable
  • Label

    Store label metadata (id might be Gmail’s label ID or just names). Type could differentiate system labels (Inbox, Sent, etc.) vs user labels. Mirrors Gmail's system + custom labels, plus local "Smart" labels like "Today". UI uses the colorHex for chips; sync engine maps labelId <--> remoteLabelId. The visibility enum drives settings > labels toggle without extra preferences table.
    • labelId: string (prmiary key - Gmail server_perm_id)
    • accountId: UUID (foreign key)
    • name: string
    • colorHex: string(7) nullable
    • isSystem: bool
    • visibility: LabelVisibility (enum - hidden / visible)
  • MessageLabelJoin

    A join table since each message can have many labels. This table might need indexes on labelId and messageId for quick lookups (e.g., to query all messages with a given label for implementing label-based views).
    • messageId: string (foreign key)
    • labelId: string (foreign key)
    • primary key on (messageId, labelId)
  • Message

    The threadId links to Thread. The body could be stored here (as text), or if HTML and large, possibly store only a portion or a reference. The flags packs read/starred/answered etc. so one UPDATE flips bits without schema churn.
    • messageId: string(17) (primary key from Gmail)
    • threadId: string(17) (foreign key)
    • accountId: UUID (foreign key)
    • senderEmail: string(254)
    • snippet: string(120)
    • internalDateMs: int64
    • flags: int32 (bit-field: read, starred, ...)
    • sizeHeader: int32
    • sizeBody: int32
    • blobRef: string(32) (mmap filename)
    • protoZip: blob (gzip protobuf)
  • MessageAddress

    Normalizes potentially hundreds of recipients per message. The role enum (FROM / TO / CC / BCC) means you can pull "all unique TO addresses in last month" for auto-complete stats. Creates a single place to enforce case-insensitive email collation.
    • messageId: string(17) (foreign key)
    • email: string(254)
    • displayName: string(120) nullable
    • role: AddressRole (enum)
  • AttachmentMeta

    Each attachment linked to a message. localURL can be null if not downloaded yet, or point to file in storage if downloaded. downloadState might indicate if it’s fully downloaded, partially, or just metadata. All parts > 4 KB are detached from SQLite to keep pages tiny. The sha256 lets background tasks de-dupe identical files across conversations.
    • cid: string(64) (primary key inside message)
    • messageId: string(17) (foreign key)
    • sha256: string(44)
    • mime: string(64)
    • size: int32
    • localURL: string(255) nullable
    • downloadState: AttachmentState (enum)
    • createdAt: timestamp
  • Draft

    Holds unsent or queued messages. The tiny jsonBlob carries compose fields (to / cc / bcc / subject, body delta, in-reply-to) - this is cheaper than storing full MIME during edits. The remoteId filled after first / drafts.insert so later edits PATCH the same server object. A retryCount + exponential back-off keeps offline sends from draining battery.
    • draftId: INTEGER PRIMARY KEY (rowid–OK, never merges across devices)
    • accountId: UUID (foreign key)
    • state: DraftState (enum)
    • jsonBlob: binary (< 2 KB)
    • updatedAt: timestamp
    • remoteId: string(17) nullable
    • retryCount: int16
  • ActionQueue

    Linear log of offline "intents": (addLabel, markRead, delete, sendDraft). The nextRetryAt schedules background-task attempts; failures just bump the time and retryCount. This is processed in FIFO order to preserve user expectations (e.g. star then archive).
    • opId: INTEGER PRIMARY KEY
    • accountId: UUID (foreign key)
    • type: ActionType
    • targetThreadId: string(17) nullable
    • targetMessageId: string(17) nullable
    • payload: string(256)
    • retryCount: int16
    • nextRetryAt: timestamp
    • createdAt: timestamp
  • SyncState

    One row per syncable feed (history, threads, labels, settings). Keeps continuation tokens for chunked endpoints. The lastSyncAt powers "pull-to-refresh" freshness checks and sync-gap alarms.
    • entityName: string (primary key)
    • accountId: UUID (foreign key)
    • lowWatermark: string
    • highWatermark: string
    • continuationToken: string nullable
    • lastSyncAt: timestamp
  • LabelCounts

    Shadow-copy of Gmail's per-label counters; updated via /labels incremental feed. Lets home screen widgets show counts without opening the DB for heavy computation. Separate from Mailbox so counts can sync even if user hides that mailbox row.
    • labelId: string (primary key)
    • accountId: UUID (foreign key)
    • unreadCount: int32
    • totalCount: int32
    • unseenCount: int32
    • updatedAt: timestamp
  • AddressBookCache

    Self-building cache for auto-complete (no round-trip to Contacts framework). The rankScore = f(recency, frequency) -> ORDER BY during typing. Pruned with LRU every few thousand inserts to keep the DB small.
    • email: string(254) (primary key)
    • name: string(120) nullable
    • lastSeen: timestamp
    • rankScore: float

Extra Engineering Notes:

  • Denormalization vs. Purity:
    Thread stores snippet + unreadCount to let Inbox render with zero JOINs; triggers keep them in sync on message insert/delete.
  • BLOB vs. File:
    Big HTML & inline images move to files; DB just carries path + checksum. Improves VACUUM time and WAL size.
  • SQLite Pragmas:
    PRAGMA journal_mode = WAL; synchronous = NORMAL; provides parallel reads during sync.
  • Full Text Search:
    If you enable FTS5(message_body) you can populate it lazily from blobRef so first sync stays light.

Gmail for iOS keeps the raw RFC-822 message exactly once, inside a zipped_message_proto blob on disk. All higher-level Swift structs (Message, Thread, AttachmentMeta, …) are merely indexes or cache rows pointing back to this immutable protobuf payload.

  • Space:
    var-int, field tags, and a final GZIP shave ~45 % off JSON size — critical for 30 k messages on a 64 GB phone.
  • Forward Compatability:
    Unknown tags are ignored, so older app versions keep working when Gmail back-end adds ampBody tomorrow.
  • Zero-copy I/O:
    We can mmap the blob and decode only the fields needed for the current UI frame (e.g., snippet in a list cell).
  • Schema discipline:
    Enums give us compile-time safety. A rogue “SPAM” flag becomes an enum case instead of a magic string.
  • GmailMessage

    The canonical MIME wrapper; every SQLite row ultimately points back to this blob. It carries a repeated from list (multiple “on-behalf-of” senders), a unified recipients array holding To/Cc/Bcc with per-entry roles, a UTF-8 subject, and a body BodyContainer that can host HTML, plain text, AMP, plus inline CSS. A server-generated snippet (≈150 chars) powers thread previews; messageId preserves the RFC-822 ID; delivery (DeliveryInfo) embeds spam verdicts and list-unsubscribe hints; sender records the SMTP envelope-from when it diverges from header From. The server-authoritative internalDateMs is the sort key, while status (Status) caches the primary system label (INBOX, SENT, ...). Finally, serverPermId (uint64) is the immutable join key used in “items” tables, and language (ISO 639) feeds on-device Smart Reply ML.
    • from: repeated Address
    • recipients: repeated Address
    • subject: string
    • body: BodyContainer
    • snippet: string
    • messageId: string
    • delivery: DeliveryInfo
    • sender: Address
    • internalDateMs: int64
    • status: Status
    • serverPermId: uint64
    • language: string
  • Address

    A participant descriptor used in both headers and envelopes. The type enum distinguishes a normal mailbox (SMTP_ADDRESS) from a Google Group (GROUP), letting the UI swap avatars or chip styles. The actual address stores “alice@example.com”, and the already-decoded displayName shows “Alice Example” without MIME gymnastics.
    • type: Type (enum - SMTP_ADDRESS, GROUP)
    • address: string
    • displayName: string
  • BodyContainer

    A versioned bucket for every visual representation of a message body. Most reading UIs show the htmlPart (BodyPart), but the structure also keeps a plain-text fallback and room for AMP as formats evolve. The formatVersion bumps whenever the server changes its sanitizer, invalidating cached layout heights; viewUrl is a safety valve that opens a webview for giant or exotic messages. The nested css block lets the renderer inject styles without re-parsing HTML, contentId ties inline images together, and twin booleans isHTML / hasInlines provide O(1) answers to “should I run the MIME walker?”
    • htmlPart: BodyPart
    • formatVersion: int32
    • viewUrl: string
    • css: CssBlock
    • contentId: string
    • isHTML: bool
    • hasInlines: bool
  • BodyPart

    A single MIME leaf; it records a mimeType enum (TEXT_HTML or TEXT_PLAIN), the raw contentHTML (or plain text), and the receivedMs timestamp so clients can delta-render if only part of a long thread is new. Anything heavier than 350 KB or binary (PDFs, PNGs) ends up in AttachmentMeta instead.
    • mimeType: MimeType (enum - TEXT_HTML, TEXT_PLAIN)
    • contentHTML: string
    • receivedMs: int64
  • CssBlock

    A tiny helper wrapper containing one field, inlineCss, that holds a sanitized style sheet. Storing it out-of-line lets HTML parsers skip over styles quickly and keeps attack surfaces smaller.
    • inlineCss: string
  • DeliveryInfo

    The back-end’s verdict and list metadata: recipientDomain powers “mailed-by” badges; returnPathDomain helps anti-spoof checks; booleans isMailingList, isMarketing, and isSpam drive tabbed inbox placement and warning banners. An unsubscribeUrl surfaces in the three-dot menu, while listDisplayName shows human-readable list names. A reserved dummyMax field soaks up future bit-packed experiments without schema breaks.
    • dummyMax: uint64
    • recipientDomain: string
    • returnPathDomain: string
    • isMailingList: bool
    • unsubscribeUrl: string
    • listDisplayName: string
    • isMarketing: bool
    • isSpam: bool
  • Status

    A minimal message whose lone label enum stores the system folder Gmail assigned (INBOX, ARCHIVED, SENT). Clients persist the raw int32 so new enum values won’t crash older builds and can still round-trip to the server.
    • label: Label (enum - INBOX, ARCHIVED, SENT)

Model Structs/Classes: In Swift, define model types corresponding to these. These model objects can be plain Swift structs or classes. If using SwiftUI, you might make them reference types that conform to ObservableObject or @Observable if individual message changes need to update UI. However, often a simpler approach is to treat them as data and use the higher-level store to publish changes. As an example:

struct EmailThread { 
    let id: String
    var messages: [EmailMessage]
    // ... 
}

struct EmailMessage { 
    let id: String
    let threadID: String
    var sender: Contact
    var subject: String
    var body: String
    // ...
} 

Normalization vs Convenience: Decide how much to denormalize data for performance. For instance, storing a snippet or preview of the latest message directly in the Thread record can save a join when displaying the inbox (which is by thread). Gmail’s inbox shows the sender and snippet of the latest message in the thread – if we have to gather that from the Message table each time, it could be slower. We could update the Thread record whenever a new message arrives. Another example: store an unreadCount per thread to quickly show if a thread has unread messages. These derived fields must be kept in sync, but greatly speed up read operations.

Use of JSON/Blob for Flexibility: Gmail messages have a lot of headers and metadata (message-ID, in-reply-to, etc.). We might not need all that in structured columns. One approach (which some projects use) is to store the raw JSON or MIME of the message in a blob column, and extract only key fields into separate columns for indexing. For example, keep a headersJSON column and have generated columns for subject or from. This allows adding new fields later without altering schema, at the cost of some storage overhead. If we expect to need full-text search, we might use SQLite’s FTS5 extension on the body or even on combined fields.

Indexing: Create indexes on important fields: e.g., index on threadId in Message table (so retrieving all messages for a thread is fast), index on date maybe for sorting, index on isRead if we often query unread count, etc. For labels, index on labelId in the mapping table for quick label -> messages lookup. Also consider a composite index on (labelId, date) if we list a label’s messages sorted by date. Proper indexing ensures queries remain snappy as data grows.

SQLite Considerations: Use SQLite pragmas that help performance, like enabling Write-Ahead Logging (WAL) mode for concurrency and speed. WAL allows simultaneous reads during writes which is helpful for our pattern (reading emails while syncing in background). However, we still serialize writes to avoid conflicts. SQLite can easily handle the volume of email data (tens of thousands of rows) as long as queries are indexed. The storage footprint for pure text is also manageable, though large attachments should be stored as files rather than in the DB.

Data Model Example Diagram:

[ Thread ]   1 <---> * [ Message ] (one thread has many messages)
[ Message ]  1 <---> * [ Attachment ] (one message can have multiple attachments)
[ Message ]  * <---> * [ Label ] via [ MessageLabel ] mapping table 

In this entity-relationship diagram, a Thread can contain many Messages, and each Message belongs to one Thread (except perhaps orphan drafts). A Message can have multiple Attachments (or none). A Message can have multiple Labels, and each Label can tag many Messages – implemented via a join table.

For example, consider a Gmail thread with ID ABC123 that has 3 messages. In the Thread table we have one row id="ABC123", subject="Meeting Updates", snippet="Sure, let's reschedule...", lastUpdated=<date>. In the Message table, we have 3 rows, each with thread_id="ABC123" and their own data: one from Alice to Bob, one reply from Bob, one reply from Alice, each with their own id (Gmail message IDs are unique strings). Each message row stores if it’s read, its body text, etc. If the second message had an attachment “agenda.pdf”, we have an Attachment row linking to that message. If the thread is in Inbox and marked Important, we have entries in MessageLabel like (msg1, Inbox), (msg2, Inbox), (msg3, Inbox) and similarly for Important label. (Alternatively, we might label at the thread level – Gmail’s IMAP interface treats labels per message, but in UI Gmail shows threads in Inbox if any message is in Inbox. We could simplify by labeling threads instead of individual messages for UI purposes, though to be faithful to Gmail we track per message.)

Group Thread threadId subject snippet lastUpdated ABC123 Meeting Updates Sure, let's resche… 1696291200 Messages msgId threadId senderEmail dateReceived flags sizeBody blobRef MSG1 ABC123 alice@ex.com 1696288000000 0x0001 12 KB …A1… MSG2 ABC123 bob@ex.com 1696288600000 0x0000 18 KB …B2… MSG3 ABC123 alice@ex.com 1696290000000 0x0000 19 KB …C3… AttachmentMeta cid msgId sha256 mime size localURL <cid-pdf> MSG2 …cafetext… application/pdf 123 KB file://agenda MessageLabelJoin msgId labelId MSG1 INBOX MSG2 INBOX MSG3 INBOX MSG1 IMPORTANT MSG2 IMPORTANT MSG3 IMPORTANT 1:N 1:N 1:N
Schema Relationship

ASCII Example:

(1) Thread  ── 1 ⟶ N ──▼─────────────────────────────────────────────────────────────┐
┌─────────────────────────────────────────────────────────────────────────────────────┐
│ threadId │     subject      │        snippet         │ lastUpdated (epoch-s)       │
├──────────┼──────────────────┼────────────────────────┼─────────────────────────────┤
│ ABC123   │ Meeting Updates  │ Sure, let's resche…    │ 1696291200                  │
└──────────┴──────────────────┴────────────────────────┴─────────────────────────────┘
                                               ▲
                                               │ “inbox list” rows
                                               │

(2) Messages  (FK threadId → Thread)   1 ⟶ N ▼────────────────────────────────────────┐
┌────────┬──────────┬────────────────┬───────────────┬────────┬─────────┬────────────┐
│ msgId  │ threadId │  senderEmail   │ dateReceived  │ flags  │ sizeBody│  blobRef   │
├────────┼──────────┼────────────────┼───────────────┼────────┼─────────┼────────────┤
│ MSG1   │ ABC123   │ alice@ex.com   │ 1696288000000 │ 0x0001 │  12 KB  │ …A1…       │
│ MSG2   │ ABC123   │ bob@ex.com     │ 1696288600000 │ 0x0000 │  18 KB  │ …B2…       │
│ MSG3   │ ABC123   │ alice@ex.com   │ 1696290000000 │ 0x0000 │  19 KB  │ …C3…       │
└────────┴──────────┴────────────────┴───────────────┴────────┴─────────┴────────────┘
           │
           │ 1 ⟶ N  (attachments per message)
           ▼

(3) AttachmentMeta  (FK msgId → Message)──────────────────────────────────────────────┐
┌──────────────┬────────┬─────────────────┬────────────────┬────────┬────────────────┐
│     cid      │ msgId  │     sha256      │      mime      │  size  │    localURL    │
├──────────────┼────────┼─────────────────┼────────────────┼────────┼────────────────┤
│ <cid-pdf>    │ MSG2   │ …cafebabe…      │ application/pdf│ 123 KB │ file://agenda  │
└──────────────┴────────┴─────────────────┴────────────────┴────────┴────────────────┘
           │
           │ N ⟶ N  (labels per message)
           ▼

(4) MessageLabelJoin  (composite PK msgId+labelId)────────────────────────────────────┐
┌────────┬───────────┐
│ msgId  │  labelId  │
├────────┼───────────┤
│ MSG1   │ INBOX     │
│ MSG2   │ INBOX     │
│ MSG3   │ INBOX     │
│ MSG1   │ IMPORTANT │
│ MSG2   │ IMPORTANT │
│ MSG3   │ IMPORTANT │
└────────┴───────────┘

In-memory vs Persistent Models: We might choose to keep the model mostly in the SQLite database and not hold all emails in memory (to reduce RAM usage). The app can query on demand, e.g., fetch the list of threads for Inbox from the database when rendering the inbox view. With SQLite, this is fast and can be done on a background thread. Using an ORM like Core Data or GRDB (a popular Swift SQLite wrapper) can help manage object graphs. Apple’s Core Data, for example, would let us define Thread and Message as NSManagedObject and fetch with predicates. However, Core Data has its own learning curve and quirks; many performance-sensitive apps like Gmail might opt for a direct SQLite approach for predictability. For our design, we assume SQLite with perhaps a lightweight wrapper for convenience.

Example Query Use Cases:

  • Displaying the inbox:
    This gives latest threads for inbox. The app then for each thread may show the snippet and maybe the senders (which might be part of snippet or a separate field if needed).
SELECT 
    thread_id, 
    subject,
    snippet,
    lastUpdated,
    -- (maybe unread count)
FROM Thread
WHERE label=‘INBOX’ 
ORDER BY lastUpdated DESC 
LIMIT 50
  • Opening a thread:
    Query to get all messages in that conversation. This should be fast if indexed by thread_id.
SELECT
    *
FROM Message
WHERE thread_id = ‘ABC123’
ORDER BY date ASC
  • Searching locally:
    If implemented, one could utilize SQLite’s full-text search on messages’ body or subject. Alternatively, defer to server search via API (less ideal offline).
-- Weighted BM25 ranking: subject gets 10× the score of body.
-- Notes that we’ll favor a match in subject 10 × more than a match in body.
-- bm25(MessageFTS, 10.0, 1.0):
--   SQLite-FTS5 BM25 scorer with per-column weights.
--   First indexed column (subject) gets weight 10.0,
--   second column (body) gets weight 1.0.
--   Subject matches therefore rank 10× higher than body matches.
SELECT
    m.messageId,
    m.threadId,
    m.snippet,
    m.internalDateMs,
    bm25(MessageFTS, 10.0, 1.0) AS rank
FROM MessageFTS
JOIN Message AS m ON m.rowid = MessageFTS.rowid
WHERE MessageFTS MATCH ? -- Runs the full-text search.
ORDER BY rank -- lowest score = best match
LIMIT 50;

Having a solid data model backed by a relational store ensures we can perform these queries efficiently. It also makes it easier to implement features like mark all as read (an SQL UPDATE on messages where isRead=false for a given label), or delete thread (DELETE all messages for thread and related attachments, etc.).

In summary, the data model for an iOS Gmail-like client should capture Gmail’s core abstractions (threads and labels) in a way that’s optimized for on-device storage and retrieval. With SQLite and a careful schema, we can handle large mailboxes and complex queries, all while maintaining data integrity and consistency.

Storage

Closely tied to data modeling is the Storage layer – how and where the app stores data on the device. For a mail client, local storage is critical for performance (caching) and offline use. We will focus on SQLite-based storage for structured data, plus file storage for large blobs, and other iOS-specific storage aspects.

Challenges:

  • Data Size Management
    Emails (especially with attachments) can consume a lot of space. We must manage the local cache size, perhaps only storing a rolling window of emails (e.g., last 30 days) or only metadata for older emails. Storage strategy might need to evict or compress older content if space is low.
  • Concurrency and Data Consistency
    Multiple parts of the app (or background threads) may access the database. SQLite on iOS is file-based and supports concurrent reads but only one write at a time. Mismanagement can lead to “database locked” errors or slowdowns if writes block reads.
  • Caching Strategy
    Deciding what to cache locally. Likely all email headers and bodies for recent emails, and perhaps bodies of emails the user read (so they don’t have to fetch again), plus any attachments the user opened. But caching every attachment might be infeasible, so we might store only on-demand and allow user to explicitly download all for offline if needed.
  • Storage Format
    Whether to use the raw SQLite directly, use an abstraction like Core Data, or a hybrid. Core Data brings convenience (object graph, change tracking) but can have overhead; direct SQLite gives more control.
  • Encryption and Security
    Ensuring sensitive email data is stored securely (we’ll cover details in Security section, but it influences how we use storage - e.g., enabling SQLCipher or using iOS file protection).
  • Backup and Persistence
    By default, on iOS, data in Documents or Core Data will be backed up to iCloud. We may not want to back up all emails (since they can be fetched from server, and large backup size is undesirable). We might designate the data as non-backup (using .doNotBackup file attribute or storing in Library/Caches which is not backed up). Similarly, we should handle what happens on app reinstall or device change – likely the user will just log in and re-sync rather than restoring from backup.

Implementation Strategies:

  • SQLite Database
    Use a SQLite database for structured data. SQLite is lightweight, file-based, and well-suited for mobile. It operates embedded in the app process (no separate server), which is ideal . We can package an empty database schema with the app or create it on first run. Access it via the SQLite C API, or better, use a Swift wrapper. Many teams use libraries like GRDB.swift or SQLite.swift to safely execute queries and map to Swift types. These wrappers often also handle some concurrency (GRDB, for example, can use multiple reader connections and one writer connection internally). Alternatively, Apple’s Core Data framework could be used; it ultimately uses SQLite under the hood for persistence. Core Data provides an ORM-like experience, which can speed up development but might add overhead or unpredictability in complex syncing scenarios. Given Gmail’s scale, a direct SQLite approach might be preferable for fine-tuned control.
  • Serializing Access
    Ensure that write operations to SQLite are serialized, as concurrent writes will conflict. One approach is to funnel all database writes through a single dedicated dispatch queue or actor. For instance, have a DatabaseManager actor with methods like saveMessages(_:), deleteMessage(id:) that internally perform SQLite operations. Readers (queries) can often run concurrently on separate read connections as long as no write is happening; but to keep it simple, one can also use the same queue/actor for reads to avoid any chance of race (the cost might be minimal if queries are fast). The WAL mode in SQLite allows readers not to block writers (reads see a snapshot of the DB), which is good for performance.
  • File Storage for Attachments/Images
    Store large binary data (attachments, email images) as files in the app’s sandbox, rather than inside the SQLite database. SQLite can store BLOBs, but huge blobs can bloat the database file and slow down queries (especially if you have to vacuum or backup). A common pattern is: store a reference (file path or identifier) in the DB for an attachment, and save the actual file in, say, the app’s Documents or Caches directory. For example, an attachment “report.pdf” could be saved as /Documents/Attachments/<messageID>-<attachID>.pdf. The database’s Attachment table has the metadata and that path. Only when the user tries to view/download the attachment do we actually download the file and save it there (and update the downloadStatus). If needed, implement a cache eviction for attachments (maybe auto-delete attachments not accessed in last X months to save space, since they can be re-downloaded).
  • Persistent Key-Value Storage
    Use UserDefaults for simple preferences (like “last sync time” or user settings such as notification preferences). Avoid using UserDefaults for storing email data or large lists, as it’s not suited for that. It’s fine for small flags or maybe caching the last opened mailbox etc.
  • Keychain
    For storing credentials (OAuth tokens, refresh tokens), use the iOS Keychain which is a secure, encrypted storage managed by the OS. This ensures sensitive tokens aren’t exposed in plain text on the file system. The Keychain is designed exactly for things like login tokens. We might store something like “authToken_gmail” entry for the Gmail API token, so that even if the app’s container is extracted, the token remains encrypted (accessible only by the app with the correct entitlements).
  • Directory Choices
    Likely store the SQLite database in the Application Support or Caches directory. The Caches directory is typically not backed up and may be wiped by the OS if space is needed, but since our data can be recovered from the server, Caches is actually appropriate (and it avoids bloating iCloud backups). If we consider some data important to persist across reinstalls without resync (not likely necessary for email), we could keep it in Application Support (which is backed up), but it’s safer to assume we can always re-sync from server. So marking the database and attachments as “do not backup” is wise. (This can be done with URL.setResourceValue(_, forKey: .isExcludedFromBackupKey) on the file URLs).
  • Size Management
    Implement a strategy to cap storage. For instance, only keep the latest N thousand emails per mailbox offline. If a user has 100k emails, perhaps only cache 10k most recent locally. If they need older ones, they can search or scroll which triggers a fetch. We can periodically purge very old emails from the DB (or at least remove bodies/attachments for them to save space). This could be done with a background task (BGProcessingTask) when charging – e.g. “cleanup old cache” task that runs and prunes data beyond a threshold. Provide settings for users if possible (like Gmail offline on web allows selecting 7, 30, or 90 days of email to store).
  • Testing and Migration
    Design the schema with potential migrations in mind. Use versioned migrations to alter tables or add indexes as the app evolves (SQLite ALTER TABLE or just recreate as needed). During development, test with a large dataset (tens of thousands of dummy emails) to ensure performance scales.

Example: On app launch, open or create the SQLite database. Set PRAGMA foreign_keys = ON to enforce relationships (so deleting a Message can auto-delete its attachments via foreign key constraint, if set up). Also set PRAGMA journal_mode = WAL for better concurrency. Most queries will be parameterized SQL statements. For instance, fetching messages for thread might look like:

func messages(inThread id: String) -> [Message] {
    let stmt = db.prepare("SELECT * FROM message WHERE thread_id = ? ORDER BY date ASC")
    ... bind id, execute, map to Message structs ...
}

If using an ORM or query builder, it might be even simpler (GRDB would let you do Message.filter(threadID == id).order(by: Message.Columns.date).fetchAll(db), etc.).

By using SQLite and file storage smartly, our app achieves fast access to emails (since most UI operations become DB lookups instead of network calls) and robustness offline. The trade-off is that we must carefully handle data consistency (no corrupting the DB on crashes, etc., though SQLite is ACID compliant so it’s reliable if used correctly). We also accept that our local data is a cache of server data - so we might implement periodic full sync or verification to ensure no divergence (for example, if some emails were deleted on server while the app was offline for a long time, we should detect and remove them locally on next sync).

In summary, the storage layer, centered on SQLite, gives the Gmail-like app a solid foundation for caching and managing the user’s mailbox on-device, striking a balance between performance and data volume.

Conflict Resolution

In a distributed system like email, conflict resolution refers to handling discrepancies between the client’s local state and the server’s state that arise due to concurrent modifications or offline activity. In an email client, conflicts can occur when the user takes actions offline (or in quick succession on multiple devices) that collide with other changes. While email is not collaborative editing, there are still scenarios to consider, such as two devices marking an email read/unread differently, or a user deleting an email on one device that is edited (e.g. moved to a folder or replied to) on another. Our system must reconcile these with minimal confusion to the user.

Challenges:

  • Offline Actions vs Server Updates
    The user might perform operations while offline – e.g. archive an email – but meanwhile, on the server (or another client), that same email could have been deleted or modified. When coming back online, our app may attempt to apply an action that is no longer valid (e.g., archive an email that the server says is already deleted).
  • Concurrent Updates from Multiple Clients
    Even if not offline, two clients could act near-simultaneously. Gmail’s backend might accept both changes, but one might override the other. For instance, one device marks an email as read while another marks it as important at roughly the same time. There’s no direct conflict since those are separate fields (both changes should persist), but if two devices edit the same field (e.g., label changes or read status) the last one should win, presumably.
  • Draft Syncing
    A special case is draft emails – a user might edit a draft on phone while also editing on web. Without careful sync, one version might overwrite the other. (Gmail usually saves drafts server-side, so ideally the server merges or picks one – the client should fetch latest on resume to avoid showing stale draft).
  • Stale Local Data
    If the app was inactive for a long time, the local cache might have many differences from server (moves, deletions). The first sync could see many conflicts (emails in local DB not on server and vice versa). Need to detect those and update accordingly (clean up local deletions, etc.).
  • User Expectations
    Generally, users expect that actions they took will eventually be applied. They might not even realize a conflict happened (which is ideal). In tricky cases (like a draft conflict), user might need to be informed or given a choice, but for simpler things (like deletion vs move conflict), the app should resolve it transparently.

Implementation Strategies:

  • Last-Write-Wins (LWW)
    A common conflict resolution policy is last write wins . We determine an authoritative source or timestamp for changes. For example, the server can be considered the source of truth – if the server has a more recent update timestamp for an email than the client’s, we accept the server state. Conversely, if the client made a change offline at time T, and on reconnect the server’s version has an older timestamp, we upload the client’s change. This requires timestamps or version IDs. Gmail’s API often provides a historyId or update time for messages. We can compare those to decide which change is newer.
  • Operation Queues
    Instead of directly overwriting data, track user actions as operations in a queue (e.g. “delete message X”, “mark Y read”). When syncing, process these operations in order. If an operation fails due to the item’s state (e.g., trying to delete a message that’s already deleted), catch that error and treat it as resolved (we can simply remove the item from local cache if not already). By sequencing operations, we ensure deterministic application of changes. For instance, if the user quickly archives and then immediately unarchives a message while offline, we queue both. When back online, if we sent only the second operation (unarchive) without the first, it would be wrong – but by queueing, we apply archive then unarchive in order, and the net effect is no change, which matches user intent.
  • Server Conflict Feedback
    If using Gmail’s API, some operations might return a success even if redundant (deleting an already-deleted email might return a 200 but no effect). However, in some cases, the server might reject an update because of a conflict (though email servers typically don’t have complex merging logic – they just accept latest state changes). If we get an error (say, a 404 Not Found when attempting to modify an email), it likely means our local data was stale (email no longer exists on server). In that case, we handle it gracefully: remove the email locally as well (the conflict is resolved by acknowledging the server’s deletion).
  • Merging Non-conflicting Changes
    Some changes can be merged without conflict. For instance, if offline the user adds a label “Travel” to an email, and on the server (via web) they added a label “Important” to the same email, both can coexist. Our sync should detect that the server’s label set is {Important} and our local pending label is {Travel}; the merged result should be {Important, Travel}. So when syncing, we might fetch the current server labels, apply our local additions or removals, and then send the updated label list back (or individual add/remove calls). In doing so, we preserve both changes. Similarly, marking as read vs starring an email are orthogonal – one doesn’t override the other.
  • Draft Conflict Handling
    For drafts, consider using server-generated draft IDs and timestamps. If a conflict is detected (server draft updated after local edit started), the app could warn the user or present a choice (“This email was edited on another device. Which version do you want to keep?”). This is a more interactive conflict resolution and hopefully rare. A simpler approach is to always fetch the latest draft when opening it if internet is available, which reduces the chance of diverging edits.
  • Deletion vs Modification Priority
    If an email was deleted on one side and modified (e.g., labeled or read) on the other, usually deletion should take precedence (the email is gone, so we drop any attempted modifications to it). The sync logic can handle this by, for example, checking if a local pending operation’s target email still exists on server before executing it. If not, skip that operation and update local state to remove the email.
  • User Notification of Conflicts
    As much as possible, hide the conflict resolution from the user by automatically doing the sensible thing (which is usually last action wins). However, in cases where automatic resolution could cause confusion, consider informing the user. For example, if an outgoing email couldn’t be sent because it was too large and got stuck, the user should be alerted. For received emails, conflicts are seldom directly shown to the user – they’re resolved by the data just updating (e.g., an email disappears because it was deleted elsewhere). As long as these updates happen relatively quickly, users accept it.

Example Conflict Scenario:

  • Offline “Archive” ⇄ Server Adds New Reply
    User is offline and archives an email (moves from Inbox to All Mail in Gmail terms). We mark it archived locally (remove “Inbox” label). Unknown to the user, perhaps on the server (or another client), that email was also just replied to, which puts it back in Inbox (say another collaborator responded and it’s a threaded conversation, or perhaps a server rule moved it). When our app comes online, it will sync. Suppose the server now shows the email still in Inbox (because of the new reply). Our local operation wants to remove Inbox label. Resolution: We compare timestamps: the new reply on server likely updated the thread after our archive action. So server’s state is newer; we might decide to discard the local archive operation. The email remains in Inbox in our app (matching server). Alternatively, we could apply our archive (remove Inbox) which would counteract the server’s addition. But that might hide the new reply from the user’s inbox. Usually better to take server’s latest. This kind of scenario shows why recency (or a hierarchy of actions) matters. Archiving vs new incoming mail – new mail wins to ensure the user sees it.
Phone (offline) Archive email @ T₁ Server New reply → Inbox @ T₂ Sync Result Keep **Inbox** (T₂ > T₁)
Offline Archive
  • Read ⇄ Unread Flag Race (LWW)
    The user marks an email as read on the phone while offline. On the web, they mark it as unread again (maybe they clicked it then decided to mark unread). Now both devices have opposite states once they reconnect. If both have timestamps, whichever happened last chronologically should prevail. If that was the web marking unread later, then during sync we see server says “unread” and local says “read” but server’s timestamp is newer – so we update local back to unread. If the phone’s action was later, we send an update to mark it read on server. In either case, eventually both align. This is straightforward LWW on a single flag.
Phone Mark **Read** @ T₁ Web Mark **Unread** @ T₂ Sync Result Keep **Unread** (T₂ > T₁)
Read / Unread Conflict Resolutiion
  • Duplicate Drafts After Offline Composition
    Suppose a user composed a draft on phone offline, and also on a laptop created a draft of the same reply. When online, two drafts exist. Gmail might treat them as separate drafts (if both were saved to server with different IDs). There’s no silent conflict unless we specifically wanted to merge them. Typically, the user would have to manually reconcile by choosing one version – the app can just sync both drafts; the user will see duplicates and decide which to keep (or the system could auto-delete older draft after sending one). This is an edge case but mentionable in design.
Phone (offline) Create **Draft A** Laptop (web) Create **Draft B** Sync Result Both drafts exist → user decides
Read / Unread Conflict Resolutiion

By implementing conflict resolution primarily via timelines and well-ordered operations (and deferring to server truth when unsure), the email client can avoid most sticky issues. Importantly, because email operations are usually idempotent or additive (adding a label, marking read, etc.), simply replaying a series of operations eventually gets you to a consistent state with the server. We just have to drop or adjust those that no longer apply. The use of robust sync logic and a queue for offline actions means the app will eventually converge with the server state, which is the goal of conflict resolution: eventual consistency with minimal user-visible hiccups.

Performance & Caching

Performance is paramount in a mobile app - users expect instantaneous opening of their inbox, smooth scrolling, and quick response to actions. We’ve touched on performance in earlier sections (UI virtualization, background tasks, etc.); here we summarize key performance and caching considerations specific to an email client and how to address them.

Challenges:

  • Startup latency
    When the user launches the app, it should load the inbox immediately. If the app has to fetch from the network on cold start, it will feel slow. Thus, caching recent emails locally is vital so the inbox appears at once (even if slightly stale).
  • Scrolling through email lists
    The inbox list could be very long. We need to ensure that rendering each cell is efficient—text sizing, avatars, preview icons, etc. If each cell triggers heavy work (like decoding images or HTML), scrolling will stutter.
  • Email rendering
    Opening an email thread, especially with HTML content and multiple messages, can be heavy. Rendering HTML in a WKWebView may be required for complex emails (costly). Alternatively, converting HTML to NSAttributedString or using SwiftUI text works for simpler cases. Re-use webviews when possible and defer image loading to manage performance.
  • Network usage and latency
    Reducing how often we hit the network improves perceived performance. Using caching strategies (our SQLite cache plus URLSession caching) avoids redundant requests. If the mailbox hasn’t changed since the last sync a minute ago, skip the server call.
  • Batching operations
    Doing actions one-by-one can be slow. If marking 100 emails as read, don’t call the server 100 times—use a batch update if the API allows. Likewise, batch writes in a single transaction rather than 100 individual SQLite writes.
  • Memory constraints
    Loading very large emails or many high-resolution images can spike memory. iOS may kill the app if memory is exhausted. Release resources promptly—e.g., clear attachments from memory after viewing, or stream large files in chunks.
  • Background performance
    During background sync, be efficient so tasks finish within the allotted time and don’t drain battery. Use appropriate QoS (.background) and yield if the system signals throttling.

Implementation Strategies:

  • Instant Inbox Load
    On app launch, immediately show the inbox from the local SQLite cache. This could mean using the data from the last session. If using Core Data or similar, the objects might even be cached in memory between launches (if not purged). Showing something, even if slightly stale, is better than a blank screen with a spinner. Then kick off a background refresh to update the list, giving the impression of a fast load.
  • Lazy Loading & Pagination
    Don’t load all emails at once. Display the first 50 threads; as the user scrolls near the end, fetch the next chunk from the database (or network if not cached). SwiftUI’s List handles lazy loading, or attach .onAppear to list items to trigger more loads. For very long threads, load only recent messages and show “Show earlier messages” for the rest.
  • Image and Attachment Caching
    Use an in-memory cache (NSCache) for avatars and inline images; it auto-purges under pressure. Configure URLSession with a 50 MB URLCache for disk caching of HTTP images. Attachments saved locally open straight from file, and Quick Look can render many formats efficiently.
  • Pre-fetching
    Anticipate user actions: while they read one email, pre-fetch the next few bodies; when new mail arrives, pre-fetch the bodies or thumbnails in the background. Throttle carefully so it runs only when resources are available.
  • Optimized Rendering
    Keep list-cell layouts lightweight; reuse SwiftUI views or UITableView cells. Use Text / AttributedString where possible and reserve WKWebView for complex HTML (disable JavaScript if unnecessary). You can even start loading the next email in an off-screen webview to hide latency.
  • Concurrency for Heavy Tasks
    Parse large MIME messages, downsample big images, or do any heavy work in a background task (Task.detached {}) and await the result. Never block the main thread beyond trivial work.
  • Database Performance
    Batch multiple inserts/updates in one transaction; use prepared statements and parameterized queries. Run VACUUM periodically in the background. Add an FTS5 table for full-text search. We get a larger DB, but much faster queries.
  • Memory Management
    Downsample oversized images to screen resolution, stream huge files in chunks, and defer remote-image loads until the user taps (good for privacy, memory, and network). For HTML, consider blocking external images by default.
  • Analytics for Performance
    Instrument launch time, sync duration, scrolling FPS, and email-open latency. Use Time Profiler to catch slow paths and move heavy string/HTML preprocessing to background sync so the UI stays snappy.

Caching Summary: Essentially, our approach is to treat the local SQLite database as a cache of the mailbox (with possibly some limits on size) and to treat downloaded files similarly as a cache. By doing so, we minimize redundant fetches. The system should also gracefully handle cache misses - e.g., if user searches for an old email not in cache, we fetch from server and then store it locally (so next time it’s there). As an optimization, we might proactively cache emails the user is likely to need (like calendar invites or recent conversations) while purging those likely not needed (very old newsletters perhaps).

Performance is a cross-cutting concern that affects all layers: UI (must be smooth), Sync (must be efficient), Storage (must be quick). By caching data, using concurrency, and optimizing rendering, we can achieve a silky experience akin to the real Gmail app, where things feel instantaneous after the initial sync.

Offline Support

One of the hallmarks of a robust mobile email client is offline support - the ability for users to read and compose emails even without network connectivity, and have everything sync up later. We’ve touched on offline handling in other sections, but here we’ll focus on how the app explicitly supports offline mode and the strategies to ensure a seamless experience.

Challenges:

  • Read offline
    Users expect that previously downloaded emails (and their attachments if opened before) are accessible offline with no connection. We need to cache enough content so that offline reading is useful, but it’s impossible to store everything for a huge mailbox by default. Choosing what and how much to store is a challenge (e.g., all emails in the last 2 weeks, plus any email the user opened before should have its body cached, etc.).
  • Compose and send offline
    Users may write replies or new emails offline. These need to be saved and later sent automatically. The UI should reassure the user that the message will be sent when possible, and the email shouldn’t just disappear. Usually, it’s placed in an “Outbox” until sending succeeds.
  • User actions offline
    Archiving, deleting, marking read, etc., should all be allowed offline. The app must record these actions and apply them locally (optimistically updating the UI), then sync them to the server when back online. This ties into conflict resolution if the server state changed in the meantime.
  • Sync on reconnect
    As soon as connectivity is restored (or the app is opened after connectivity), the app should synchronize: send any pending actions and fetch new mail. This must happen automatically, but carefully. Don’t flood the network if there’s a huge backlog, and handle failures gracefully.
  • Indicating offline status
    The user should be aware when they’re offline (so they understand why new mails aren’t arriving or why an email is in Outbox unsent). The app might show an “Offline” banner or indicator. Actions like Send could show a small label such as “Queued”, or move the item to Outbox with a special icon. Clear communication avoids confusion.

Implementation Strategies:

  • Local Queue for Outgoing
    Implement an Outbox mechanism. When the user hits “Send” on an email and there is no network (or the send fails), we don’t discard the email. Instead, save it to the local database (e.g., a Draft marked as Outbox) and/or to a flat file (some apps store unsent mail as an .eml file). Display it in an “Outbox” folder so the user can see it’s waiting. The Sync Engine should periodically attempt to send those when connectivity is available, using background tasks or a timer when the app becomes active. When a send eventually succeeds, remove it from Outbox, move it to Sent, and optionally show a notification or toast “Sent”.
  • Offline Actions Tracking
    For actions like delete, archive, mark read, update local state immediately and record the action in a pending-actions list. A persistent approach might use a table such as pending_action(id INTEGER PRIMARY KEY, message_id TEXT, actionType TEXT, parameters BLOB, timestamp) so nothing is lost across restarts. On the next sync, the engine executes these actions against the server; if successful, remove them from the table. This works like a transactional outbox for state changes.
  • Background Sync on Connectivity
    iOS lacks a direct ‘on connectivity change’ callback, so rely on foreground launches or BackgroundTasks. With push notifications and background app refresh enabled, a queued push (or the next refresh window) will wake the app. While running, use Network.framework’s NWPathMonitor, once it reports satisfied, trigger sync (if the app is active or holds a background task). If the app was killed while offline, nothing happens until the user re-opens it.
  • UI Indicators
    Expose connectivity via SwiftUI state, e.g. @State var isOnline. If false, show a banner “You are offline. Some actions will be queued.” Mark unsent emails in Outbox with a cloud icon or subtitle “Will send when online”. When the user pulls to refresh while offline, immediately end the spinner and show “No internet connection.” Aim for graceful degradation.
  • Reading Offline
    Cache email bodies after they’re opened online. If we skipped bodies of very old emails to save space and the user opens one offline, show “Email not downloaded. Connect to the internet to view this email.” Optionally let users choose “Download all for offline” in settings. Consider pre-downloading the first N KB of each body. For attachments, display “Attachment not downloaded” with a disabled button when offline; let users tap a download icon while online to save for offline use.
  • Conflict Resolution on Sync
    When back online, process queued actions first to honor the user’s offline intent before fetching new server changes. For example, if the user deleted emails offline, attempt those deletions immediately; if they succeed, great, if the server already deleted them, handle per the conflict policy. Sequencing queued operations before pull avoids briefly resurfacing items the user already deleted.

Offline Search: A tricky aspect, if user tries to search emails offline, we can only search what’s cached. We’d have to surface that limitation (maybe show “searching offline” and results only from stored emails, and possibly offer to search server when back online). Implementing offline full-text search would rely on having indexed those emails’ content (with FTS as mentioned). It could be a nice feature but not strictly required in an MVP.

Offline Send: Let’s illustrate the flow for sending an email offline using a simplified sequence:

User (Offline) App (Mail Client) Server (Gmail) Compose email save as Draft / Outbox (local DB) Tap “Send” mark “Outbox”, show in UI offline period detect connectivity POST /send 200 OK move to Sent, remove from Outbox UI refresh – mail in Sent
Offline Send Flow

In the above, the App queued the email locally and later sent it. During the offline period, the email sits in Outbox; the user could even open Outbox to view it or edit (maybe editing triggers replacing the draft). When connectivity returns, either the user opens the app (triggering sync), or if the app is in background, a BGProcessingTask might run (if scheduled) to flush Outbox. The end result is that the email is delivered without the user manually retrying.

Demonstrating an offline user action (Archive):

User (Offline) App (Local DB) Server Tap “Archive” remove Inbox label locally queue action “archive X” offline period sync on reconnect remove INBOX label 200 OK delete pending entry
Offline User Action (Archive)

If the server had that email deleted entirely meanwhile, the remove label request might fail with “not found”. In that case, we would interpret it as the email is gone and just remove it locally (which we already did anyway). Net effect to user: email gone, as expected.

By carefully designing these offline flows and testing various scenarios, we can ensure the app works gracefully without connectivity, which is a huge plus (especially for users on planes or with spotty network). It builds user trust that they can always access and organize their email, and the app will smartly sync everything when possible.

Security & Privacy

Security and privacy are crucial for an email client, given that emails often contain sensitive personal and business information. We must safeguard the data on the device, in transit, and respect user privacy regarding what data is collected or how content (like images) is handled. Let’s outline the main considerations and solutions.

Challenges:

  • Data at rest protection
    Emails stored on the device must be protected from unauthorized access. If someone gains physical access to the phone or an unencrypted backup, could they read the emails? iOS devices are generally encrypted when locked, but additional measures can help.
  • Authentication security
    Storing the user’s credentials or tokens needs to be done securely to prevent theft of account access.
  • Data in transit
    All communication with the email server should use secure channels (TLS). This is usually given (Gmail API requires HTTPS), but any communication must be verified.
  • Privacy of remote content
    Emails often contain remote images or links that, when loaded, can inform the sender that the email was read (this is a privacy leak). Gmail proxies images to mitigate this, but a third-party client should be mindful. Perhaps provide a setting “ask before loading external images” like Apple Mail does, to prevent tracking pixels from auto-loading.
  • Third-party data sharing
    If our app uses any analytics or crash logging, we must ensure no email content or personal data leaks. Since this is a client for Gmail, it should only talk to Google’s servers for mail (and maybe anonymous telemetry if included), but be transparent. Apple’s privacy labels require disclosing data usage.
  • Malicious content
    Emails can have malicious attachments or phishing links. While filtering is mostly server-side (Google spam filtering), the client should still treat content carefully. For example, when rendering HTML, disable JavaScript to avoid potential XSS. Also be cautious with URI schemes, ensure tapping links or loading images doesn’t expose local data.
  • User’s personal data
    The app may access Contacts (autocomplete) or Photos (attachments). Request the proper iOS permissions and ensure those datasets aren’t uploaded or misused.
  • Keychain and OAuth
    Gmail login likely uses OAuth 2. Store refresh/access tokens in Keychain, not plaintext. Token refresh should occur only against Google endpoints, and consider certificate pinning if the threat model warrants. iOS ATS (App Transport Security) already enforces HTTPS and modern TLS.
  • Device security integration
    Leverage iOS file-protection classes. Mark SQLite DB and attachment files with Complete Until First User Authentication (encrypted until the first unlock). For very sensitive data, Complete protection blocks access while locked, but may hinder background tasks.
  • Encryption
    Rely on iOS hardware AES for disk encryption. Add SQLCipher for the DB only if compliance requires extra protection; it demands key management even though most apps simply trust the OS encryption layer.
  • Logging
    Always sanitize logs. Never print email content or PII in debug or crash logs. If using external services (e.g., Firebase Crashlytics), ensure sensitive data is stripped. Disable verbose networking logs in release builds.
  • Minimal Permissions
    Request only what’s necessary: Notifications, maybe Contacts, perhaps Calendar. Avoid unrelated permissions (e.g., location) to respect privacy and keep user trust.

Implementation Strategies:

  • Keychain for Credentials
    Use the Keychain Services API to store the Gmail OAuth tokens. For example, after the OAuth web flow, we get an accessToken (short-lived) and refreshToken (longer-lived). Store the refresh token in Keychain with an accessibility level like kSecAttrAccessibleAfterFirstUnlock ( .afterFirstUnlock ) so that background refresh can use it if needed, but it stays encrypted while the device is locked. The access token can be stored in memory or Keychain as well; since it expires maybe hourly, we can just fetch a new one using the refresh token as needed. The Keychain is encrypted and only our app (matching bundle ID & entitlements) can access those items.
  • APIs and Certificates
    Ensure all network calls use HTTPS. This is the default with NSURLSession unless you explicitly allow insecure requests. Don’t disable ATS. Optionally implement certificate pinning: since Gmail endpoints are well-known (e.g. https://gmail.googleapis.com/... or imap.gmail.com), we could pin to Google Trust Services CA. Pinning blocks MITM attacks via rogue CAs, but Google’s certs may rotate and some corporate proxies break, so treat it as an opt-in trade-off.
  • Handling External Images
    Provide a setting to not automatically load external images in emails. If enabled, when rendering an email that has <img src="http://…">, we do not load them (or we show a placeholder). The user can tap “Load Images” for that message. This prevents tracking pixels from confirming the user’s IP/location. If images are loaded, ensure it’s over HTTPS; ATS already blocks plain HTTP images unless you relax it, which we shouldn’t. Routing through a proxy like Gmail’s is possible but complex, blocking by default or letting the user choose is simpler.
  • HTML Content Sanitization
    If we render HTML emails in WKWebView, the web view sandbox blocks file access and JavaScript by default if configured. We should explicitly disable JavaScript (configuration.preferences.javaScriptEnabled = false) unless absolutely necessary, as most email clients never execute JS. This prevents malicious scripts from running. CSS and images are fine, but JS is a no-go. If a newsletter truly needs JS, consider case-by-case enabling, but safer to avoid it altogether.
  • Phishing and Fraud Indicators
    Gmail servers can annotate emails suspected of phishing or from untrusted domains. Our client can likewise inspect headers such as X-Google-Suspicious and display a warning if the user clicks a suspicious link. Any link opened should use SFSafariViewController or UIApplication.openURL, so the content renders in Safari’s sandbox, and the URL bar clearly shows the domain—helping users notice sketchy links.
  • Privacy Policy and Data Usage
    Ensure compliance with privacy laws and be transparent about data usage. Emphasize that no email content is sent to third-party servers besides Gmail’s. All sync happens directly between the app and Gmail. If analytics is included, verify it does not contain email identifiers or content.

Security Feature Recap:

  • Device encryption by iOS (automatic when device locked)
  • Keychain for tokens/passwords (protected by device authentication) 
  • TLS 1.2+ for all network calls (ATS compliance)
  • Optionally, certificate pinning for your specific domain
  • File protection for stored data (so it’s inaccessible if device locked or until first unlock)
  • Encryption: Possibly encrypt database at application level (considering iOS already does, might skip)
  • Sandboxing: iOS sandbox already prevents other apps from reading our files. Unless the device is jailbroken, another app cannot read our SQLite or attachments. Jailbreak is a different threat model, if jailbroken, all bets are off (the user implicitly trusts more risk or should use device encryption password strongly). We can note that risk, but typically rely on iOS.
  • Safe Content Handling: do not auto-execute or share data without consent.

In code, using the Keychain might look like:

let tokenData = refreshToken.data(using: .utf8)!
let query: [String: Any] = [
    kSecClass as String: kSecClassGenericPassword,
    kSecAttrService as String: "GmailAuth",
    kSecAttrAccount as String: userEmail,
    kSecValueData as String: tokenData,
    kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
]
SecItemAdd(query as CFDictionary, nil)

This stores the token under service “GmailAuth”. To retrieve, you’d query with kSecReturnData. Storing and retrieving like this ensures the token is encrypted on disk (actually in the Keychain database which is encrypted by iOS). Even if someone dumped the app container, Keychain items are not in that container; they’re in a system keychain db tied to the device key.

Privacy UI: We could also integrate iOS’s Mail Privacy Protection features (Apple Mail loads images through proxies to hide IP). That’s complex to fully replicate, but we have addressed it partially by optionally not auto-loading or by cautioning on remote content.

Finally, ensure that if the user logs out, we securely wipe any locally stored email data (delete the SQLite file, attachments, and Keychain tokens). So that handing the phone to someone else logged out doesn’t leave data behind.

By adhering to these security practices, our Gmail-like iOS client will keep user data safe and private. The result is an app that not only performs well but also earns user trust.

Potential Deep Dives

In a mobile system design interview, you may be asked to deep dive into specific components:

  • 1) End to End Outbox Queue Walkthrough

    From the instant a draft flips from .pending to sent.
    • ComposeDraftView → DraftActor
    • OutboxQueue (a serial TaskGroup inside DraftActor)
    • HeaderBuilder & MIMEAssembler
    • AttachmentActor
    • S/MIME Encryptor (optional)
    • AuthActor
    • SendEnvelopeBuilder
    • GRPCClient
    • OutboxQueue (update to sent)
  • 2) Implement offline functionality

    The app should degrade gracefully.
    • Local DB for feed
    • Queue actions offline
    • Sync on reconnect
    • Conflict resolution
    • Offline UI indicators
  • 3) Optimise battery usage

    Mobile apps must conserve power.
    • Batch network requests
    • Efficient background tasks
    • Adaptive polling
    • Reduce wake‑locks
    • Optimise image transforms