Building MailPilot: A SwiftUI Prototype for Intelligent Mail Triage

Building MailPilot: A SwiftUI Prototype for Intelligent Mail Triage

MailPilot

Mail is one of those product surfaces where engineering quality is felt immediately. A good inbox does not just display messages. It helps people understand what matters, decide what needs action, and reply without losing their train of thought.

I built MailPilot as a compact SwiftUI prototype for that problem space. The app demonstrates an intelligent mail workflow with local fixture data, explainable categorization, summaries, priority signals, search, and tone-aware reply drafting.

The project does not connect to a real mailbox. That is intentional. For a portfolio project, I wanted the core experience to be reviewable without OAuth, personal email, server setup, or privacy concerns. The engineering challenge becomes: how do you make a mail intelligence feature feel product-shaped, testable, and Apple-platform native while keeping the demo safe to run?

MailPilot focuses on five workflows:

  • Categorizing an inbox into meaningful groups
  • Summarizing a message into key points and suggested action
  • Explaining why a message was classified a certain way
  • Drafting replies in different tones
  • Showing follow-up and priority signals across the inbox

Product Goal

MailPilot is a small iOS app for exploring intelligent mail triage. The goal is not to build a full mail client or train a model. Instead, the goal is to show how intelligence can be shaped into a calm, understandable mail experience.

The app has four tabs:

  • Inbox: a searchable message list with category chips.
  • Insights: category mix, follow-up queue, and priority queue.
  • Compose Lab: a reusable drafting surface for reply generation.
  • Settings: privacy and architecture notes for the demo.

That structure separates the core reading workflow from aggregate intelligence and writing assistance. It also makes the app easy to demo: start with the inbox, open one message, show the summary and reply draft, then zoom out to the insights view.

Core Data Model

The project uses small value models for messages, summaries, and drafts. The mail category enum is deliberately fixed to five user-facing groups:

1
2
3
4
5
6
7
8
9
enum MailCategory: String, CaseIterable, Codable, Identifiable {
case primary = "Primary"
case transactions = "Transactions"
case updates = "Updates"
case promotions = "Promotions"
case followUp = "Follow Up"

var id: String { rawValue }
}

Those categories mirror common inbox expectations: direct personal mail, receipts, announcements, marketing, and messages that likely need a response.

The message model keeps the demo data close to what a mail UI actually needs:

1
2
3
4
5
6
7
8
9
10
11
struct MailMessage: Identifiable, Codable, Equatable {
let id: String
let sender: String
let senderEmail: String
let subject: String
let receivedAt: Date
let body: String
let isUnread: Bool
let hasAttachment: Bool
let importance: Int
}

The summary model is where the product behavior becomes visible:

1
2
3
4
5
6
7
8
9
10
struct MessageSummary: Identifiable, Equatable {
let id: String
let messageID: String
let category: MailCategory
let headline: String
let bullets: [String]
let suggestedAction: String
let explanation: String
let urgencyScore: Int
}

I included explanation because classification without reasoning can feel mysterious. In a mail product, trust matters as much as automation. If the app says a message is a follow-up, the user should be able to see why.

Fixture-Driven Mail Data

MailPilot loads bundled JSON fixtures instead of live email:

1
2
3
protocol MailRepository {
func loadMessages() async throws -> [MailMessage]
}

The production demo repository reads MailFixtures.json from the app bundle:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct FixtureMailRepository: MailRepository {
private let decoder: JSONDecoder

init() {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
self.decoder = decoder
}

func loadMessages() async throws -> [MailMessage] {
guard let url = Bundle.main.url(forResource: "MailFixtures", withExtension: "json") else {
return SampleMailFactory.messages
}

let data = try Data(contentsOf: url)
return try decoder.decode([MailMessage].self, from: data)
.sorted { $0.receivedAt > $1.receivedAt }
}
}

This gives the app a realistic inbox with more than twenty messages while keeping the demo deterministic. A reviewer can run the project and see the same results every time.

It also creates a clean future boundary. A real IMAP, Gmail, or on-device mail store adapter could conform to MailRepository without changing the SwiftUI screens.

Intelligence as a Replaceable Service

The intelligence layer is expressed as a protocol:

1
2
3
4
protocol MailIntelligenceService {
func summarize(_ message: MailMessage) async -> MessageSummary
func draftReply(to message: MailMessage, tone: ReplyTone) async -> ReplyDraft
}

The current implementation is MockMailIntelligenceService. It is deterministic on purpose. The goal is to demonstrate product behavior and architecture, not depend on a network model during a portfolio review.

Classification is simple but explainable:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
private func categorize(_ message: MailMessage) -> MailCategory {
let text = searchableText(for: message)

if containsAny(text, ["receipt", "invoice", "payment", "order", "statement", "subscription", "purchase"]) {
return .transactions
}

if containsAny(text, ["sale", "discount", "promo", "offer", "save", "deal", "coupon"]) {
return .promotions
}

if containsAny(text, ["newsletter", "release", "update", "digest", "announcement", "webinar"]) {
return .updates
}

if containsAny(text, ["follow up", "reply", "deadline", "friday", "tomorrow", "review", "decision", "action"]) {
return .followUp
}

return .primary
}

The rules are intentionally small, but the boundary is the important part. In a production version, this service could call an on-device model, an Apple framework, or a private classification engine. The views would not need to know which implementation is behind the protocol.

Explaining Classification

MailPilot does not just show a category chip. It generates a reason:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
private func explanation(for message: MailMessage, category: MailCategory) -> String {
let text = searchableText(for: message)
let matchedTerms: [String]

switch category {
case .transactions:
matchedTerms = ["receipt", "invoice", "payment", "order"].filter { text.contains($0) }
case .updates:
matchedTerms = ["newsletter", "release", "update", "digest"].filter { text.contains($0) }
case .promotions:
matchedTerms = ["sale", "discount", "promo", "offer"].filter { text.contains($0) }
case .followUp:
matchedTerms = ["follow up", "reply", "deadline", "review", "action"].filter { text.contains($0) }
case .primary:
matchedTerms = ["sender relationship", "direct message"]
}

if let term = matchedTerms.first {
return "Classified as \(category.rawValue) because the message contains “\(term)” signals."
}

return "Classified as \(category.rawValue) from sender, subject, and body context."
}

This is a small detail, but it changes the feel of the product. The app is not asking the user to blindly trust a label. It gives a compact explanation that can be scanned in the detail view.

App State with Observation

The app uses a single MailStore as the shared state owner:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Observable
final class MailStore {
enum LoadingState: Equatable {
case idle
case loading
case loaded
case failed(String)
}

private let repository: MailRepository
private let intelligenceService: MailIntelligenceService

private(set) var messages: [MailMessage] = []
private(set) var summaries: [String: MessageSummary] = [:]
private(set) var state: LoadingState = .idle
var searchText = ""
var selectedCategory: MailCategory?
var selectedTone: ReplyTone = .professional
}

The root app creates the store once and injects it into the tab hierarchy:

1
2
3
4
5
6
7
8
9
10
11
12
13
@main
struct MailPilotApp: App {
@State private var store = MailStore(
repository: FixtureMailRepository(),
intelligenceService: MockMailIntelligenceService()
)

var body: some Scene {
WindowGroup {
ContentView(store: store)
}
}
}

This keeps ownership clear. The app owns the repository and intelligence service; the views read state and trigger user actions.

Loading and Summarizing

Loading runs asynchronously and builds summaries after fixture messages are decoded:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func load() async {
guard state == .idle else { return }
state = .loading

do {
let loadedMessages = try await repository.loadMessages()
var loadedSummaries: [String: MessageSummary] = [:]

for message in loadedMessages {
loadedSummaries[message.id] = await intelligenceService.summarize(message)
}

messages = loadedMessages
summaries = loadedSummaries
state = .loaded
} catch {
state = .failed(error.localizedDescription)
}
}

Even though the mock service returns quickly, the async shape is useful. It makes loading, future cancellation, and eventual real model integration natural.

Inbox Filtering

The inbox supports both text search and category filtering:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var filteredMessages: [MailMessage] {
messages.filter { message in
let matchesCategory = selectedCategory.map { summaries[message.id]?.category == $0 } ?? true
let matchesSearch = searchText.isEmpty || [
message.sender,
message.senderEmail,
message.subject,
message.body,
summaries[message.id]?.headline ?? ""
]
.joined(separator: " ")
.localizedCaseInsensitiveContains(searchText)

return matchesCategory && matchesSearch
}
}

Including the generated summary headline in search is a small product decision. If a message is summarized as “Follow-up likely needed,” that context should help the user retrieve it.

Category Chips

The inbox uses horizontal chips for fast triage:

1
2
3
4
5
6
7
8
9
CategoryChip(
title: item.category.rawValue,
symbolName: item.category.symbolName,
count: item.count,
tint: item.category.tint,
isSelected: selectedCategory == item.category
) {
selectedCategory = item.category
}

Each category carries its own symbol and color. The visual design is intentionally restrained: enough color to orient the user, not so much that the inbox becomes noisy.

Message Detail

The detail view is organized around the user’s likely next decision:

  1. Who sent this?
  2. What is this about?
  3. Does it need action?
  4. Can I reply quickly?

The summary card answers the middle two questions:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
struct SummaryCard: View {
let summary: MessageSummary

var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Label(summary.category.rawValue, systemImage: summary.category.symbolName)
.font(.headline)
.foregroundStyle(summary.category.tint)

Spacer()

Text("Priority \(summary.urgencyScore)/10")
.font(.caption.weight(.semibold))
}

Text(summary.headline)
.font(.title3.weight(.semibold))
}
}
}

The full view also shows bullets, suggested action, and explanation. The summary card is designed to be useful even before reading the original message body.

Writing Tools

The reply flow supports three tones:

1
2
3
4
5
6
7
enum ReplyTone: String, CaseIterable, Identifiable {
case concise = "Concise"
case friendly = "Friendly"
case professional = "Professional"

var id: String { rawValue }
}

The service produces a draft for the selected tone:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
func draftReply(to message: MailMessage, tone: ReplyTone) async -> ReplyDraft {
let subject = message.subject.lowercased().hasPrefix("re:")
? message.subject
: "Re: \(message.subject)"

let body: String
switch tone {
case .concise:
body = """
Thanks for the note. I will review this and follow up with the next step shortly.

Best,
Huajing
"""
case .friendly:
body = """
Hi \(firstName(from: message.sender)),

Thanks for sending this over. I will take a closer look and get back to you with a thoughtful update soon.

Best,
Huajing
"""
case .professional:
body = """
Hi \(firstName(from: message.sender)),

Thank you for the context. I will review the details, confirm the action items, and follow up with a clear next step.

Best regards,
Huajing
"""
}
}

The current implementation is rule-based, but the interaction model is what matters: a user can move from triage to response without context switching.

Insights View

The Insights tab shows aggregate state derived from the same summaries:

1
2
3
4
5
6
7
8
9
10
11
var followUpMessages: [MailMessage] {
messages.filter { summaries[$0.id]?.category == .followUp }
}

var topPriorityMessages: [MailMessage] {
messages.sorted {
(summaries[$0.id]?.urgencyScore ?? $0.importance) > (summaries[$1.id]?.urgencyScore ?? $1.importance)
}
.prefix(5)
.map { $0 }
}

This view is useful in a demo because it shows that classification is not just decoration. It changes how the inbox can be navigated.

Privacy-Friendly Demo Design

MailPilot avoids live mailbox integration for three reasons:

  1. A reviewer should not need account setup to understand the app.
  2. The demo should never expose personal email.
  3. The intelligence behavior should be deterministic for testing.

The Settings tab makes that explicit with three statements:

  • No account sign-in
  • Fixture data only
  • Local deterministic intelligence

That is not just legal safety. It is product judgment. Mail is deeply personal, so a mail intelligence prototype should communicate privacy boundaries clearly.

Testing Strategy

The unit tests focus on app-owned behavior:

  • Transaction classification
  • Follow-up classification and urgency scoring
  • Tone-specific reply drafting
  • Search filtering
  • Empty inbox behavior

Example:

1
2
3
4
5
6
7
8
9
10
11
func testCategorizesFollowUpMessages() async {
let message = makeMessage(
subject: "Action requested",
body: "Please reply before tomorrow's deadline."
)
let summary = await service.summarize(message)

XCTAssertEqual(summary.category, .followUp)
XCTAssertGreaterThanOrEqual(summary.urgencyScore, 7)
XCTAssertEqual(summary.suggestedAction, "Reply today")
}

The UI sanity test covers the main demo path: launch, search, open a message, verify summary content, regenerate a draft, and confirm the draft editor appears.

1
2
3
4
5
6
7
8
9
10
11
12
13
func testLaunchSearchAndOpenSummary() throws {
let app = XCUIApplication()
app.launch()

XCTAssertTrue(app.tabBars.buttons["Inbox"].waitForExistence(timeout: 5))

app.searchFields.firstMatch.tap()
app.typeText("Recruiting")

let subject = "Action requested: availability for next step"
let message = app.staticTexts[subject]
XCTAssertTrue(message.waitForExistence(timeout: 3))
}

The test suite is intentionally focused. It does not try to test SwiftUI itself. It verifies the behavior MailPilot owns.

What This Project Demonstrates

MailPilot is compact, but it demonstrates several production-relevant iOS skills:

  • SwiftUI app structure with TabView and NavigationStack
  • Observation-based shared state
  • Async loading and service boundaries
  • Local JSON fixtures
  • Explainable categorization
  • Summary and priority modeling
  • Tone-aware reply drafting
  • Search and filtering behavior
  • Privacy-first demo design
  • Unit and UI testing

The project is ultimately about product taste as much as code. Intelligent mail features should not feel like a chatbot pasted into an inbox. They should help the user understand, decide, and act with less friction.

That is the design center for MailPilot: a native-feeling mail experience where intelligence is visible, explainable, and useful.


Building MailPilot: A SwiftUI Prototype for Intelligent Mail Triage
http://runningcoconut.com/2026/04/07/Building-MailPilot-A-SwiftUI-Prototype-for-Intelligent-Mail-Triage/
Author
Huajing Lu
Posted on
April 7, 2026
Licensed under