Email Port — Hexagonal Architecture with Dual Adapters
Context
joelclaw needs email capabilities: inbox triage, draft replies, archive/tag, and eventually agent-initiated email. Joel uses Front (collaborative inbox) for all email at egghead.io. Gmail (joelhooks@gmail.com) is a secondary account for personal/sending.
The system also needs a calendar port with a similar shape — Google Calendar via gog CLI. And future integrations (Slack, Discord, SMS) will follow the same pattern. Rather than couple directly to any provider’s API, we need a port/adapter architecture that keeps the application logic provider-agnostic.
This follows Principle 3 (Ports and Adapters) from AGENTS.md — Alistair Cockburn’s Hexagonal Architecture (2005).
Decision
Define an EmailPort interface (the port) with two adapters: Front (primary inbox/triage) and Gmail via gog CLI (search/delivery). A factory selects the adapter based on use case. The port lives in a standalone package (packages/email/).
Port Interface
// packages/email/src/port/types.ts
interface EmailPort {
// Read
listInboxes(): Promise<Inbox[]>;
listConversations(inboxId: string, opts?: ListOpts): Promise<Conversation[]>;
getConversation(id: string): Promise<ConversationDetail>;
// Triage
archive(conversationId: string): Promise<void>;
tag(conversationId: string, tagId: string): Promise<void>;
untag(conversationId: string, tagId: string): Promise<void>;
assign(conversationId: string, assigneeId: string): Promise<void>;
markRead(conversationId: string): Promise<void>;
// Compose
createDraft(params: DraftParams): Promise<Draft>;
listDrafts(conversationId?: string): Promise<Draft[]>;
deleteDraft(draftId: string): Promise<void>;
}Adapters
Front adapter (src/adapters/front.ts): Wraps @skillrecordings/front-sdk. Used for inbox triage, conversation management, tagging, drafts. Front has native draft API support and collaborative features.
Gmail adapter (src/adapters/gmail.ts): Wraps gog CLI with -j (JSON output). Used for search across Gmail, sending from joelhooks@gmail.com, calendar-adjacent email operations. gog requires GOG_KEYRING_PASSWORD env var and --account flag.
Factory
function createEmailAdapter(provider: "front" | "gmail"): EmailPort {
switch (provider) {
case "front": return new FrontAdapter(process.env.FRONT_API_TOKEN!);
case "gmail": return new GmailAdapter("joelhooks@gmail.com");
}
}Package Structure
packages/email/
├── src/
│ ├── port/
│ │ └── types.ts # EmailPort interface + domain types
│ ├── adapters/
│ │ ├── front.ts # Front SDK adapter
│ │ └── gmail.ts # gog CLI adapter
│ └── index.ts # Factory + re-exports
├── package.json
└── tsconfig.jsonWhy Standalone Package
Per Principle 3, the port is owned by the hexagon, not by the adapter. packages/email/ is consumed by:
- Worker (
packages/system-bus/) — Inngest functions that triage email - CLI (
packages/cli/) —joelclaw email inbox,joelclaw email triage - Gateway — agent can query email state during conversations
None of these should know whether Front or Gmail is backing the operation.
Alternatives Considered
A: Front SDK directly in worker
Call Front API directly from Inngest functions. No port abstraction.
Rejected — violates hexagonal principle. When (not if) we add another email provider, we’d need to modify application logic.
B: Gmail only via gog
Skip Front, use Gmail for everything via gog CLI.
Rejected — Joel uses Front for all email collaboration. Front has team features (assignment, tags, shared drafts) that Gmail lacks. gog is better for search and personal sending.
C: Single adapter that switches internally
One adapter class with if/else for Front vs Gmail.
Rejected — this is the adapter pattern done wrong. Each provider gets its own adapter. The factory selects.
Consequences
Positive
- Provider-agnostic email — application logic never mentions Front or Gmail
- Testable — in-memory adapter for testing (third adapter, per Principle 3)
- Extensible — Outlook, Fastmail, etc. are one adapter each
- Pattern template — same shape applies to calendar, tasks, notifications
- Draft-then-approve — agent creates drafts, Joel approves in Front before send
Negative
- Indirection — port interface adds a layer between caller and provider
- Lowest common denominator — port can only expose features all adapters support (or adapters throw “not supported”)
- Two secrets to manage —
front_api_tokenandgog_keyring_password
Implementation Status
✅ Scaffolded + Front Webhooks Verified (2026-02-18)
packages/email/created withpackage.json,tsconfig.jsonEmailPortinterface defined atsrc/port/types.ts- Front adapter started at
src/adapters/front.ts(uses@skillrecordings/front-sdk) - Gmail adapter started at
src/adapters/gmail.ts(wrapsgogCLI) - Front webhooks fully working: Rules webhook →
front/message.receivedevents flowing. Real emails (newsletters, Vercel notifications) confirmed E2E through gateway. front_api_tokenandfront_rules_webhook_secretstored in agent-secrets- Worker
start.shleases both secrets +TODOIST_*secrets on startup
⬜ Front adapter complete
- Finish all
EmailPortmethods against Front API - Integration test with real Front account
⬜ Gmail adapter complete
- Auth:
gog auth add joel@joelhooks.com --services gmail - Wrap
gog gmailsubcommands (search, get, thread, send)
⬜ CLI commands
joelclaw email inbox— list conversationsjoelclaw email triage— agent-assisted triagejoelclaw email draft— create draft reply
⬜ Inngest functions
- Email triage function triggered by Front webhooks
- Agent drafts reply, pushes to gateway for approval
⬜ Calendar port (future ADR)
- Same pattern:
CalendarPortinterface, Google Calendar adapter viagog calendar - Will need ADR-0053