@ordinatio/email
v1.8.0
Published
Multi-provider email engine with OAEM protocol
Readme
@ordinatio/email
A standalone, multi-provider email engine with OAuth + IMAP/SMTP support, auto-discovery, templates, scheduled delivery, and OAEM protocol integration.
Built as a Canonical module (C-03) under the Ordinatio architecture. Zero app-layer dependencies — all side effects (activity logging, events, contact resolution) injected via callbacks.
Table of Contents
- Architecture
- Quick Start
- Providers
- Auto-Discovery
- Email Operations
- Templates
- Scheduled Emails
- OAEM Protocol Integration
- Callback Injection Pattern
- Error System
- Validation Schemas
- Testing
- API Reference
Architecture
┌──────────────────────────────────────────────────┐
│ @ordinatio/email │
│ │
│ ┌──────────┐ ┌──────────┐ ┌───────────────┐ │
│ │ Providers │ │ Services │ │ Discovery │ │
│ │ │ │ │ │ │ │
│ │ Gmail │ │ Account │ │ MX resolver │ │
│ │ Outlook │ │ Messages │ │ Autoconfig │ │
│ │ IMAP │ │ Sync │ │ SRV records │ │
│ │ │ │ Archive │ │ Port prober │ │
│ │ │ │ Content │ │ Intelligence │ │
│ │ │ │ Template │ │ │ │
│ │ │ │ Schedule │ │ │ │
│ └─────┬─────┘ └─────┬────┘ └───────┬───────┘ │
│ │ │ │ │
│ ┌─────┴──────────────┴───────────────┴───────┐ │
│ │ types / errors / schemas │ │
│ └─────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────┘
↕ PrismaClient ↕ Callbacks
(database injected) (side effects injected)Key design principles:
- Provider-agnostic — Gmail, Outlook, and any IMAP/SMTP server through a unified interface
- Callback injection — No imports from the host application; activity logging, event emission, contact resolution, and OAEM protocol support are injected at runtime
- Database-injected — Accepts a
PrismaClientinstance; works with any tenant database - Error-coded — 179 error codes with timestamped references, severity levels, and diagnosis steps (Rule 8)
- Schema-validated — 11 Zod schemas for all inputs
Quick Start
# Install (within pnpm workspace)
pnpm add @ordinatio/email
# Or reference as workspace dependency
# package.json: "@ordinatio/email": "workspace:*"import {
getProvider,
getActiveAccount,
syncEmails,
getInboxEmails,
replyToEmail,
discoverProvider,
} from '@ordinatio/email';
// Get the active email provider
const account = await getActiveAccount(db);
const provider = getProvider(account.provider.toLowerCase());
// Sync emails from provider
await syncEmails(db, {
onActivity: (action, desc, data) => console.log(action, desc),
onEmailSynced: async (email) => { /* extract context */ },
});
// Read inbox
const { emails, total } = await getInboxEmails(db, {
status: 'INBOX',
maxResults: 50,
});
// Auto-discover provider settings for a new email address
const discovery = await discoverProvider('[email protected]');
// → { domain: 'acmecorp.com', providers: [{ type: 'imap', settings: {...} }] }Providers
Three built-in providers, plus a registry for custom providers.
Gmail (OAuth)
import { GmailProvider } from '@ordinatio/email';
const gmail = new GmailProvider();
gmail.authType; // 'oauth'
gmail.getCapabilities(); // { supportsLabels: true, supportsNativeThreading: true, ... }
// OAuth flow
const authUrl = gmail.getAuthUrl('state-token');
const tokens = await gmail.exchangeCodeForTokens(code);
// Token refresh — use getValidAccessToken(db, accountId) instead of calling this directly.
// It handles the 5-minute buffer, token rotation capture, and TokenRevokedError.
const refreshed = await gmail.refreshAccessToken(tokens.refreshToken);
// Operations
const { messages, nextCursor } = await gmail.listMessages(tokens.accessToken, {
maxResults: 50,
after: new Date('2026-01-01'),
});
const full = await gmail.getMessage(tokens.accessToken, messageId);
await gmail.archiveMessage(tokens.accessToken, messageId);
await gmail.sendMessage(tokens.accessToken, { to, subject, bodyHtml });Requests the single gmail.modify scope (enough for every call this provider makes; see
src/providers/gmail-scopes.test.ts). If the user completes Google's consent screen but unticks it,
exchangeCodeForTokens throws GmailScopeNotGrantedError (test with isGmailScopeNotGrantedError(err);
also exported from @ordinatio/email/client). Nothing is stored or revoked: a revoke at Google acts on
the whole authorization and could kill an existing live connection. The token Google issued for that rejected
grant is simply discarded unused; the user can remove the app at myaccount.google.com/permissions. Grants made under earlier versions' three scopes keep working.
Environment variables:
GOOGLE_CLIENT_ID=xxx
GOOGLE_CLIENT_SECRET=xxx
GOOGLE_REDIRECT_URI=http://localhost:3000/api/email/callback/gmailOutlook (OAuth)
import { OutlookProvider } from '@ordinatio/email';
const outlook = new OutlookProvider();
outlook.authType; // 'oauth'
outlook.getCapabilities(); // { supportsFolders: true, maxAttachmentSize: 157286400, ... }
// OAuth flow (Microsoft MSAL)
const authUrl = outlook.getAuthUrl('state-token');
const tokens = await outlook.exchangeCodeForTokens(code);
// Operations use Microsoft Graph API
const { messages } = await outlook.listMessages(tokens.accessToken);
await outlook.archiveMessage(tokens.accessToken, messageId); // Moves to Archive folderEnvironment variables:
MICROSOFT_CLIENT_ID=xxx
MICROSOFT_CLIENT_SECRET=xxx
MICROSOFT_REDIRECT_URI=http://localhost:3000/api/email/callback/outlookIMAP/SMTP (Credentials)
Works with any email provider that supports IMAP and SMTP.
import { ImapSmtpProvider } from '@ordinatio/email';
import type { ImapSmtpCredentials } from '@ordinatio/email';
const imap = new ImapSmtpProvider();
imap.authType; // 'credentials'
// Test connection before saving
const credentials: ImapSmtpCredentials = {
imapHost: 'imap.fastmail.com',
imapPort: 993,
imapSecurity: 'ssl',
smtpHost: 'smtp.fastmail.com',
smtpPort: 465,
smtpSecurity: 'ssl',
username: '[email protected]',
password: 'app-password-here',
};
const test = await imap.testConnection(credentials);
// → { success: true, imapConnected: true, smtpConnected: true, folderCount: 12, messageCount: 1847 }
// Operations
const { messages } = await imap.listMessages(JSON.stringify(credentials));Custom Providers
Register your own provider implementation:
import { registerProvider } from '@ordinatio/email';
import type { EmailProvider } from '@ordinatio/email';
class ProtonMailProvider implements EmailProvider {
readonly providerId = 'protonmail';
readonly authType = 'credentials' as const;
// ... implement required methods
}
registerProvider('protonmail', () => new ProtonMailProvider());Provider Capabilities
Each provider declares its capabilities:
interface ProviderCapabilities {
supportsOAuth: boolean;
supportsPushNotifications: boolean;
supportsNativeArchive: boolean;
supportsNativeThreading: boolean;
supportsServerSearch: boolean;
supportsLabels: boolean; // Gmail-specific
supportsFolders: boolean; // IMAP-specific
archiveAction: 'label_remove' | 'move_folder' | 'flag';
maxAttachmentSize: number; // bytes
}| Capability | Gmail | Outlook | IMAP/SMTP |
|-----------|-------|---------|-----------|
| OAuth | Yes | Yes | No |
| Push notifications | Yes | Yes (webhooks) | No |
| Native threading | Yes (threadId) | Yes (conversationId) | No |
| Native archive | Yes (remove INBOX label) | Yes (move to Archive folder) | Flag-based |
| Server search | Yes (full-text) | Yes (OData filter) | IMAP SEARCH |
| Max attachment | 25 MB | 150 MB | Varies |
Revoking Google access
When a mailbox is deleted, the stored sign-in goes with it, but the grant at Google stays listed under the user's Google account permissions until it is revoked. revokeGoogleToken asks Google to revoke it:
import { revokeGoogleToken } from '@ordinatio/email';
const outcome = await revokeGoogleToken(refreshToken, { tokenKind: 'refresh' });
// outcome.result: 'revoked' | 'already-gone' | 'failed'- It never throws, never logs, and never puts the token in the outcome (only Google's short error code, status or an error name), so the outcome is safe to log and to store in an audit entry.
- Revoke the REFRESH token when you have one: it ends the whole grant. An access token is only a fallback (
tokenKind: 'access'); Google'sinvalid_tokenreply to one proves nothing about the grant, so it is reported asfailed, neveralready-gone. - Bounded by a 4s timeout by default (
timeoutMsto change it), so a slow Google cannot stall a deletion. - Decide before calling whether another connection to the same Google account and OAuth client exists elsewhere: a revoke acts on the user-and-app authorization as a whole and can cut that connection off too.
- Decrypting the stored token is the caller's job; this function takes a plain token.
Sync planning
planSync, runPages, syncResultData, isRejectedPageToken and arrivalsToLog decide how to sync one mailbox and what to save afterwards. They have no database, network or clock of their own: you pass the time and supply storage, locking and the provider calls (runPages only calls the callbacks you give it).
import { planSync, runPages, syncResultData, arrivalsToLog, isRejectedPageToken, getProvider } from '@ordinatio/email';
import type { ProviderType } from '@ordinatio/email';
const startedAt = Date.now();
const emailProvider = getProvider(account.provider.toLowerCase() as ProviderType); // 'imap' | 'gmail' | 'outlook'
const plan = planSync({ provider: account.provider, syncCursor: account.syncCursor, lastSyncAt: account.lastSyncAt }, new Date());
const listing = await runPages({
plan,
// accessToken: whatever your provider's listMessages takes (for IMAP, the serialized credentials string).
listPage: (cursor) => emailProvider.listMessages(accessToken, { ...plan.listOptions, cursor }),
storePage: async (messages) => storeIdempotently(messages), // count stored, or null if the account changed meanwhile
elapsedMs: () => Date.now() - startedAt,
perCallTimeoutMs: 20_000, // worst case for ONE listing
budgetMs: 55_000, // how long the whole sync may take (e.g. your lock's lifetime minus a reserve)
onFirstPageFailure: async (error) => { if (isRejectedPageToken(error)) await clearSavedPageToken(account.id); },
});
if (!listing.discarded) {
await saveAccount(account.id, syncResultData(plan, listing.nextCursor, new Date(startedAt))); // the time the sync BEGAN
const newMail = arrivalsToLog({ skipped: false, isBackfill: plan.isBackfill, created: listing.created });
}Inputs (SyncAccountState):
provider:'IMAP' | 'GMAIL' | 'OUTLOOK', either case. Any other value is treated as a cursor-model provider.syncCursor: opaque and provider-specific: the provider's own cursor for IMAP/Outlook, a JSON scan state (written bysyncResultData) for Gmail. Never mix them. A Gmail scan state on a non-Gmail account, or a non-scan cursor on a Gmail account, is ignored; reset the cursor on reconnect all the same.lastSyncAt: when the last COMPLETED sync (Gmail: scan) started;null= never synced. An invalid date counts asnull; a future one is clamped tonow.
How it works:
- IMAP and Outlook: one page of up to 500 from the stored cursor. Gmail: a bounded
after:window (first sync: last 30 days, never further), 100 per page, up to 2 pages per call, resumed across calls through the JSON kept insyncCursor. Gmail cannot use the cursor model: its listing is newest-first and costs one API call per message. runPagesstores each page before fetching the next, so a later failure keeps earlier progress (optionalonLaterPageFailureis told; with none, the error name is logged); it only fetches another page while the worst case fitsbudgetMs; if yourstorePagereturnsnull(the account changed mid-sync) it stops and setsdiscarded: do not write sync state then.- Your
storePagemust be idempotent (insert if absent): re-listing overlapping mail is expected. Throttling repeat syncs is yours: each Gmail message listed costs an API call. lastSyncAtmeans "when the last COMPLETED scan started" for Gmail. A reconnect should reset it and the cursor (the next sync is then a backfill andarrivalsToLogreports nothing).isRejectedPageTokenis true only when Google REJECTED a saved page token: an HTTP 400 (onstatus,response.statusorcode) whose message names the page token, or, for an error with no status at all, a message that says the page token is invalid. A timeout, 429 or 5xx is never one, so a long scan's progress is not thrown away. It relies on Google's error wording naming the token; see the comment in the source.- Known gaps: the Gmail scan lists by internal date, so mail that enters the inbox later with an OLDER date (moved out of spam, unarchived) is not seen, and mail older than the 30-day window that arrived while the mailbox sat idle is never imported. After an idle gap longer than the window the next scan is not a backfill: mail from the last 30 days that you did not already have is stored and reported by
arrivalsToLogas new. - Relationship to
syncEmails: the package's bundled single-accountsyncEmailskeeps its own simpler model (50 per page, its own page-token check) and is unchanged. Use this planner when you own storage and need Gmail scans that are bounded and resumable.
People: every email gets a Person
Every stored email corresponds to a Person: the SENDER of received mail, the first recipient of mail you sent. If no Person exists for the address, one is created. A message links to ONE Person (the first counterparty); every other recipient of mail you sent still becomes a Person (up to the cap below). The logic lives here; your app keeps its tables, tenancy and locking, and plugs in through a small PeopleStore.
import { tryLinkMessagesToPeople, type PeopleStore } from '@ordinatio/email';
// One store per mailbox, so this package never sees your tenant or organization ids.
const store: PeopleStore = makeMyStore(organizationId, accountId);
// After a page of mail is stored (outside the transaction that stored it):
const { linked, created } = await tryLinkMessagesToPeople(store, { createMissing: true }, { ownAddress: account.email });
// After the FIRST sync's page (backfill): link to people that already exist, create none.
await tryLinkMessagesToPeople(store, { createMissing: false, providerIds }, { ownAddress: account.email });tryLinkMessagesToPeople is time-boxed (default 5s) and never throws: a failure or a slow database cannot fail a sync or a send that already happened. Whatever it did not finish stays unstamped and the next pass retries it. Only error NAMES are logged (a database error can quote people's addresses). The result is { linked, created, failed }: failed is true when the pass threw or ran out of its time, so a caller can tell "nothing to link" from "could not finish" (1.8.0).
What your database needs. Messages: a nullable link to a Person (called contactId here; it is simply the Person's id) and a "checked" stamp (a nullable timestamp or boolean, null/false until a pass has finished with the message). People: a unique address per tenant, stored lower-cased. A message, as the logic reads it (LinkableMessage): id, fromEmail, fromName, toEmail (the FIRST recipient), toEmailFull (every recipient as an address list, or null), ccEmail (an address list or null). Mail you sent is recognised by fromEmail being the mailbox's own address.
The store contract (PeopleStore, one instance per mailbox: every method must be scoped to that mailbox's tenant and account):
| Method | Must |
|---|---|
| findUnlinkedMessages(mode, limit) | Return at most limit messages, newest first, with the LinkableMessage fields exactly as stored. { createMissing: true } (new mail): all messages that are unlinked AND unstamped. { createMissing: false, providerIds } (backfill): only those provider ids that are unlinked, stamped or not. |
| findPeopleByEmail(emails) | Map of lower-cased address to person id, for those that exist. |
| insertPeople(people) | Insert-if-absent (concurrent passes must not create the same Person twice); return how many were CREATED; throw if the database refuses a row. Add your own defaults (stage, source) here, and keep it cheap: after a failed batch it can be called up to twice per address inside a 3-second budget, so do not re-read settings on every call. |
| linkMessages(links) | ONE atomic step: (1) lock the Person rows first, then the message rows, each in id order, so concurrent passes and a delete that takes Person-then-message cannot deadlock; (2) link only messages with no link yet (never overwrite a hand-made link); (3) stamp every message it touched as checked, in the same step; (4) if a Person was deleted meanwhile, still stamp the message but leave it unlinked (a deleted Person is never brought back from old mail). Return how many ended up linked. |
| markChecked(ids) | Stamp the messages the pass is finished with (nobody to link, or quarantined). The pass only calls this in new-mail mode; a backfill pass never stamps (its rows are stamped at insert). |
Two rules for the rest of your app, because the design depends on them: (a) deleting a Person must clear the links to it but keep the messages' stamps, otherwise the next sweep re-reads that mail and recreates the Person; (b) every other code path that sets a message's link (a hand-made "link to person" action, an import) must also stamp it, for the same reason: a stamped message is never swept again, an unstamped one that loses its link later is.
Prove your store. definePeopleStoreContract({ describe, it, expect }, createHarness) runs a ready-made suite against your real store. The harness wraps a FRESH, empty store per test:
import { describe, it, expect } from 'vitest';
import { definePeopleStoreContract } from '@ordinatio/email';
definePeopleStoreContract({ describe, it, expect }, async () => ({
store: makeMyStore(testTenant, testAccount),
addMessage: async (m) => { /* insert a message row; defaults if omitted: providerId = id, emailDate 0, stamped false, contactId null, fromEmail '[email protected]', fromName null, toEmail '[email protected]', toEmailFull null, ccEmail null */ },
addPerson: async (email) => { /* insert a person, return its id */ },
deletePerson: async (id) => { /* your app's REAL delete: clears links, keeps stamps */ },
getMessage: async (id) => ({ contactId, stamped }),
getPersonName: async (id) => null, // the stored display name, or null
refuseInserts: async (email) => { /* only if you pass { refuseInserts: true }: make your database refuse later inserts of this address */ },
// (emailDate in addMessage is a number: larger means newer.)
personExists: async (id) => true,
countPeople: async () => 0,
cleanup: async () => { /* remove everything the test created */ },
}));It checks: findPeopleByEmail returning existing people and omitting unknown ones; insertPeople being insert-if-absent with an exact created count, surviving a concurrent race, storing names and never overwriting an existing name, and (opt in with { refuseInserts: true } plus harness.refuseInserts) throwing when a row is refused; findUnlinkedMessages in both modes (the six returned message fields, extras allowed; ordering newest first; limits; backfill including stamped and unstamped rows); linkMessages linking, stamping in the same step, counting only what it linked, linking an already-stamped unlinked message, never overwriting a link, leaving a deleted Person's message stamped but unlinked, and stamping only the messages it was asked to; markChecked stamping only what it is given and never touching a link; empty inputs; deleting a Person keeping the stamps; and no dangling link when linking races a delete (either order) or when overlapping links run with opposite id orders. It does NOT check tenant/mailbox isolation (test that with a second mailbox in your own database) or app rule (b) above (the suite has no other link path to call), and atomicity, lock order and real races (a store that links and stamps in separate writes, or locks in input order, can pass in memory) are only observable against a real database under real concurrency: run it there. createMemoryPeopleStore() is the in-memory reference implementation (not for production) and a ready fake for your own tests.
Behaviour worth knowing:
- Addresses are matched exactly after lower-casing, so lower-case the address on EVERY path in your app that writes a Person.
- Junk never becomes a Person: control characters, header punctuation, more than 254 characters are rejected (
normalizeAddress). The mailbox's own address is never a Person. No-reply senders are NOT skipped, and there is no option to skip senders: filtering insidefindUnlinkedMessageswould leave that mail unlinked and unstamped, re-read on every pass. - One address the database refuses cannot block the rest: the batch insert falls back to one by one, and an address is only quarantined (stamped, left unlinked) when another insert in the same fallback worked AND a second try of that address fails. If everything fails (an outage) nothing is quarantined.
- Caps:
maxPeoplePerPass(default 1000; it bounds the distinct addresses a pass considers, People that already exist included),maxMessagesPerPass(default 500),fallbackBudgetMs(default 3000). In new-mail mode, mail cut off by a cap is left unstamped and the next pass retries it. A backfill pass only ever sees its ownproviderIdsand its rows are already stamped, so backfill mail cut off by a cap stays unlinked and is not retried (backfill linking is best-effort by design). Likewise, secondary recipients of a message pastmaxPeoplePerPassdo not become People: that message is already linked and stamped, so it is not revisited. - Known limits: a lone poison address (one the database refuses, with no other insert in that pass succeeding) is never quarantined, because that pattern is indistinguishable from an outage, so it is re-read on every pass; if more than
maxMessagesPerPasssuch messages accumulate they fill every pass's window (newest first) and crowd out OLDER unlinked mail, and an app that expects this should delete or stamp them itself. BCC addresses are not stored on a message, so BCC-only recipients of mail you sent do not become Persons.ownAddressis a single address and the package knows nothing of send-as aliases, so mail from an alias makes a Person of the alias; there is no hook to exclude addresses today.
Three patterns this design depends on (so a new app can reuse them):
- Two locks for sync, not one. A running-lock held only while a sync runs (released when it ends) and a separate TTL-only throttle for repeat syncs. One lock doing both makes a mailbox look busy, and refuses a delete, for the whole throttle period after every sync.
- Stamp at insert for backfill. Rows imported by a first sync are stamped as already checked WHEN THEY ARE INSERTED, so no pass that may create People (a concurrent send's catch-up sweep, a later sync, a retry) ever reads them. Stamping afterwards leaves a window in which they can be swept into People.
- Delete, then clear, with an explicit lock order. Deleting a Person deletes it FIRST and then clears the links pointing at it (keeping the messages' stamps), in one transaction, taking the Person lock before the message locks, the same order
linkMessagesuses. A link made concurrently either sees the Person (and the delete waits for it, then clears the link) or does not (and leaves the message unlinked).
Auto-Discovery
Automatically detect email provider settings from an email address.
import { discoverProvider } from '@ordinatio/email';
const result = await discoverProvider('[email protected]', {
// Optional: query your own intelligence database
queryIntelligence: async (domain) => {
return db.emailProviderDiscovery.findFirst({ where: { domain } });
},
// Optional: record successful connections for future users
recordIntelligence: async (domain, settings) => {
await db.emailProviderDiscovery.upsert({ ... });
},
});
console.log(result);
// {
// domain: 'acmecorp.com',
// providers: [{
// type: 'imap',
// displayName: 'Acme Corp Mail',
// authMethod: 'password',
// settings: { imapHost: 'imap.acmecorp.com', imapPort: 993, ... },
// confidence: 0.9
// }],
// source: 'mozilla_autoconfig',
// durationMs: 340
// }Discovery Pipeline
Strategies are tried in order, stopping on first success:
| Priority | Strategy | Speed | Confidence |
|----------|----------|-------|------------|
| 1 | Provider Intelligence — learned from past connections | Instant | Very high |
| 2 | Known Provider Match — MX records (*google* = Gmail) | Fast | High |
| 3 | Mozilla Autoconfig — autoconfig.{domain}/mail/config-v1.1.xml | Medium | High |
| 4 | RFC 6186 SRV Records — _imaps._tcp.{domain} | Medium | Medium |
| 5 | Port Probing — TCP/TLS on ports 993, 143, 587, 465 | Slow | Low |
Provider Intelligence is cross-tenant shared — when one user connects to @acmecorp.com, all future users on that domain get instant detection.
Email Operations
Account Management
import { getActiveAccount, connectAccount, disconnectAccount, getValidAccessToken } from '@ordinatio/email';
// Get the currently active account
const account = await getActiveAccount(db);
// → { id, email, provider, isActive, lastSyncAt, ... }
// Connect a new account (OAuth providers)
const { id, email } = await connectAccount(db, 'gmail', code, '[email protected]', callbacks);
// Refresh an expired OAuth token
const freshToken = await getValidAccessToken(db, accountId);
// Disconnect and clean up
await disconnectAccount(db, accountId, callbacks);Inbox & Messages
import { getInboxEmails, getInboxThreads, getEmail, getClientEmails } from '@ordinatio/email';
// List inbox messages (paginated)
const { emails, total } = await getInboxEmails(db, {
status: 'INBOX', // or 'ARCHIVED'
maxResults: 50,
cursor: 'last-id', // cursor-based pagination
search: 'invoice', // full-text search
});
// Get threaded conversations
const threads = await getInboxThreads(db, { maxResults: 20 });
// Get all messages in a thread (for Gmail-style conversation view)
const threadMessages = await getThreadMessages(db, threadId);
// → messages ordered oldest-to-newest, with body content auto-fetched
// Get a single email with full body
const email = await getEmail(db, emailId);
// Get all emails linked to a client
const clientEmails = await getClientEmails(db, clientId);Sync
import { syncEmails, logSyncFailure } from '@ordinatio/email';
// Sync emails from all active accounts
await syncEmails(db, {
onActivity: (action, desc, data) => { /* log to activity feed */ },
onEvent: (event) => { /* emit automation trigger */ },
resolveContact: async (email, name) => { /* find or create contact */ },
onEmailSynced: async (emailData) => { /* extract context for LLM */ },
sanitizeHtml: (html) => { /* strip scripts */ },
oaem: {
parseCapsule: async (ctx) => { /* extract OAEM capsule */ },
onCapsuleVerified: async (result) => { /* persist to ledger */ },
},
});
// Log sync failures with error codes
await logSyncFailure(error, db);Archive
import { archiveEmail } from '@ordinatio/email';
await archiveEmail(db, emailId, userId, callbacks);
// Provider-aware: removes INBOX label (Gmail), moves to Archive folder (Outlook), flags (IMAP)
// Thread-aware: archives ALL messages in the thread, not just the selected oneOptimistic Archive Pattern
For instant UI feedback, navigate away immediately and archive in the background:
// 1. Navigate immediately — the user sees instant response
router.push('/inbox?archived=' + emailId);
// 2. Fire-and-forget: archive on server in background
fetch(`/api/email/messages/${emailId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'archive' }),
}).catch(() => {}); // Swallow errors — inbox page filters by archived param
// 3. On inbox page: track archived IDs client-side to hide them from cached list
const archivedIds = new Set<string>();
const archived = searchParams.get('archived');
if (archived) archivedIds.add(archived);
// Filter cached inbox data
const visibleEmails = emails.filter(e => !archivedIds.has(e.id));This gives sub-100ms perceived archive time regardless of server latency.
Reply & Compose
import { replyToEmail } from '@ordinatio/email';
await replyToEmail(db, {
emailId: 'msg-123',
bodyHtml: '<p>Thanks for your order!</p>',
accessToken: token,
}, callbacks);Content Fetching
import { fetchEmailContent } from '@ordinatio/email';
// Fetch full body + attachments (lazy-loaded on first access)
const content = await fetchEmailContent(db, emailId, {
sanitizeHtml: (html) => sanitize(html),
});Templates
Email templates with variable rendering, categories, and default seeding.
Template CRUD
import { createTemplate, updateTemplate, removeTemplate, listTemplates } from '@ordinatio/email';
// Create
const template = await createTemplate(db, {
name: 'Order Confirmation',
category: 'order',
subject: 'Your order {{orderNumber}} is confirmed',
bodyHtml: '<p>Dear {{clientName}}, your {{garmentType}} order is confirmed.</p>',
}, callbacks);
// List by category
const templates = await listTemplates(db, { category: 'order' });
// Update
await updateTemplate(db, template.id, { subject: 'Updated subject' }, callbacks);
// Delete (default templates protected)
await removeTemplate(db, template.id, callbacks);Variable Rendering
import { renderTemplate, extractPlaceholders, AVAILABLE_VARIABLES } from '@ordinatio/email';
// See all available variables
console.log(AVAILABLE_VARIABLES);
// [
// { key: 'clientName', label: 'Client Name', category: 'client' },
// { key: 'orderNumber', label: 'Order Number', category: 'order' },
// { key: 'clothierName', label: 'Clothier Name', category: 'clothier' },
// ...
// ]
// Extract placeholders from a template
const placeholders = extractPlaceholders('Hello {{clientName}}, your {{garmentType}} is ready.');
// → ['clientName', 'garmentType']
// Render with variables
const rendered = renderTemplate(
{ subject: 'Order {{orderNumber}}', bodyHtml: '<p>Hi {{clientName}}</p>' },
{ clientName: 'John Smith', orderNumber: 'ORD-001' }
);
// → { subject: 'Order ORD-001', bodyHtml: '<p>Hi John Smith</p>' }Default Templates
The engine ships with default templates that auto-seed on first access:
| Category | Templates |
|----------|-----------|
| fitting | Fitting appointment confirmation, fitting reminder |
| order | Order confirmation, order ready for pickup |
| fabric | Fabric arrived notification |
| welcome | New client welcome |
| followup | Post-delivery follow-up |
import { ensureDefaults, resetToDefaults } from '@ordinatio/email';
// Auto-seed defaults (idempotent)
await ensureDefaults(db);
// Reset all templates to factory defaults
await resetToDefaults(db, callbacks);Scheduled Emails
Queue emails for future delivery with retry logic.
import {
scheduleEmail,
cancelScheduledEmail,
getScheduledEmails,
getPendingToSend,
markAsProcessing,
markAsSent,
markAsFailed,
retryScheduledEmail,
} from '@ordinatio/email';
// Schedule for later
const scheduled = await scheduleEmail(db, {
toEmail: '[email protected]',
subject: 'Your fitting is tomorrow',
bodyHtml: '<p>Just a reminder...</p>',
scheduledFor: new Date('2026-03-10T09:00:00Z'),
}, callbacks);
// Cancel before sending
await cancelScheduledEmail(db, scheduled.id, callbacks);
// Worker polling: find emails due for delivery
const due = await getPendingToSend(db);
for (const email of due) {
await markAsProcessing(db, email.id);
try {
// ... send via provider ...
await markAsSent(db, email.id, callbacks);
} catch (err) {
await markAsFailed(db, email.id, err.message, callbacks);
}
}
// Retry a failed email
await retryScheduledEmail(db, failedEmailId);Status lifecycle: PENDING -> PROCESSING -> SENT | FAILED
OAEM Protocol Integration
The email engine integrates with the OAEM protocol layer (bundled at src/oaem/) via the OaemCallbacks interface. OAEM exports are available from the main @ordinatio/email entry point or via the @ordinatio/email/oaem subpath.
import type { OaemCallbacks } from '@ordinatio/email';
const oaemCallbacks: OaemCallbacks = {
// Outgoing: build and inject capsule before sending
buildCapsule: async (context) => {
const capsule = encodeCapsule({ intent: 'proposal_offer', ... });
const signed = await signCapsule(capsule, privateKey, options);
const augmented = embedCapsule(context.bodyHtml, capsule, { signature: signed });
return { bodyHtml: augmented, capsuleRaw: capsule };
},
// Incoming: parse and verify capsule from received email
parseCapsule: async (context) => {
const extracted = extractCapsule(context.bodyHtml);
if (!extracted?.found) return { found: false, trustTier: 0, verified: false };
const trust = await evaluateTrust(extracted.payload, extracted.signature, trustContext);
return { found: true, capsule: extracted.payload, trustTier: trust.tier, verified: trust.signatureValid };
},
// After verification: persist to thread ledger
onCapsuleVerified: async (result) => {
await db.oaemThreadLedger.upsert({ ... });
},
};Callback Injection Pattern
The engine has zero imports from any host application. All side effects are supplied via typed callback interfaces:
// Activity logging (e.g., to an activity feed)
interface ActivityLogger {
(action: EmailActivityAction, description: string, data?: EmailActivityData): Promise<void>;
}
// Event emission (e.g., to trigger automations)
interface EventEmitter {
(event: { type: string; data: Record<string, unknown> }): Promise<void>;
}
// Contact resolution (e.g., create CRM contacts from email senders)
// Used during sync to auto-link emails to contacts/clients
interface ContactResolver {
(db: PrismaClient, email: string, name?: string): Promise<{ id: string; clientId?: string } | null>;
}
// HTML sanitization (e.g., strip script tags from email body)
interface HtmlSanitizer {
(html: string): string;
}
// Context extraction (e.g., extract structured data for LLM context windows)
interface EmailContextExtractor {
(emailData: { subject: string; fromEmail: string; snippet: string; bodyHtml?: string }): Promise<void>;
}Auto-Contact from Email Senders
The resolveContact callback in EmailSyncCallbacks enables automatic contact creation from email senders. During sync, if an incoming email doesn't match any existing client, the engine calls resolveContact with the sender's email and name.
const syncCallbacks: EmailSyncCallbacks = {
resolveContact: async (db, fromEmail, fromName) => {
// Check if contact already exists
let contact = await db.contact.findFirst({ where: { email: fromEmail } });
if (!contact) {
// Auto-create contact from sender
contact = await db.contact.create({
data: {
email: fromEmail,
name: fromName || fromEmail.split('@')[0],
source: 'EMAIL_AUTO',
},
});
}
// If contact is linked to a client, return clientId too
return {
id: contact.id,
clientId: contact.clientId ?? undefined,
};
},
};The resolved contactId is stored on the email record, enabling:
- Sender name links to contact/client profile in inbox
- Client emails grouped under CRM records
- Contact-to-client conversion when upgrading a prospect
Recommended feature flag: AUTO_CONTACT_FROM_EMAIL — lets consumers toggle this behavior per-tenant.
Composing Callbacks
const callbacks: EmailMutationCallbacks = {
onActivity: async (action, description, data) => {
await createActivity(db, { action, description, metadata: data });
},
onEvent: async (event) => {
await emitTriggerEvent(event.type, event.data);
},
};
// Pass to any mutation
await connectAccount(db, 'gmail', code, email, callbacks);
await replyToEmail(db, input, callbacks);
await archiveEmail(db, emailId, userId, callbacks);Error System
179 error codes following the Rule 8 pattern. Every error includes a unique timestamped reference for debugging.
import { emailError, EMAIL_ENGINE_ERRORS } from '@ordinatio/email';
// Generate an error with ref
const { code, ref, timestamp, description, diagnosis } = emailError('EMAIL_101');
// → {
// code: 'EMAIL_101',
// ref: 'EMAIL_101-20260305T120000',
// description: 'OAuth token exchange failed',
// severity: 'error',
// recoverable: true,
// diagnosis: ['Check OAuth client credentials', 'Verify redirect URI matches', ...]
// }
// With runtime context
const error = emailError('EMAIL_601', { domain: 'acmecorp.com', source: 'mx_resolver' });Error Code Ranges
| Range | Module | Count |
|-------|--------|-------|
| EMAIL_100-104 | Account (OAuth, connect/disconnect) | 5 |
| EMAIL_200-206 | Messages (inbox, detail, reply, link) | 7 |
| EMAIL_300-310 | Attachments & transcription | 11 |
| EMAIL_400-421 | Categories & tasks (legacy) | 22 |
| EMAIL_430-502 | Scheduled, archive, drafts, templates, sync | 73 |
| EMAIL_600-650 | Discovery + multi-provider | 51 |
| TEMPLATE_100-109 | Email templates | 10 |
Each error entry includes:
{
file: string; // Source file
function: string; // Function name
httpStatus: number; // Suggested HTTP status code
severity: 'low' | 'medium' | 'error' | 'warn';
recoverable: boolean; // Can the user retry?
description: string; // Human-readable description
diagnosis: string[]; // Steps to investigate
}Validation Schemas
11 Zod schemas for runtime input validation:
import {
ConnectAccountSchema,
ScheduleEmailSchema,
CreateEmailTemplateSchema,
GetInboxMessagesQuerySchema,
// ...
} from '@ordinatio/email';
// Validate input
const result = ScheduleEmailSchema.safeParse(userInput);
if (!result.success) {
return { error: result.error.flatten().fieldErrors };
}| Schema | Used For |
|--------|----------|
| EmailProviderSchema | Provider type validation (gmail, outlook, imap) |
| ConnectAccountSchema | OAuth connection input |
| ScheduleEmailSchema | Schedule email input (to, subject, body, scheduledFor) |
| GetScheduledEmailsQuerySchema | Scheduled email list query params |
| GetInboxMessagesQuerySchema | Inbox query params (search, status, pagination) |
| CreateDraftSchema | Draft creation input |
| CreateEmailTemplateSchema | Template creation |
| UpdateEmailTemplateSchema | Template update |
| RenderEmailTemplateSchema | Template render (template + variables) |
| UseEmailTemplateSchema | Apply template to compose |
| ScheduledEmailStatusSchema | Status enum validation |
Testing
# Run all tests
pnpm --filter @ordinatio/email test:run
# Run with watch
pnpm --filter @ordinatio/email test
# TypeScript check
pnpm --filter @ordinatio/email exec tsc --noEmit529 tests across 19 test files (11 core + 8 OAEM):
| File | Tests | Coverage |
|------|-------|----------|
| account.test.ts | 11 | OAuth flow, token refresh, disconnection |
| templates.test.ts | 31 | CRUD, rendering, defaults, categories |
| template-renderer.test.ts | 18 | Variables, placeholders, edge cases |
| scheduled.test.ts | 24 | Scheduling, cancellation, retry, status |
| sync-service.test.ts | 39 | Multi-provider sync, failures, contacts |
| providers/gmail.test.ts | 42 | OAuth, messages, MIME, archive |
| providers/gmail-mime.test.ts | 29 | RFC 2822 MIME encoding, attachments |
| providers/outlook.test.ts | 31 | Graph API, OAuth, send, archive |
| providers/imap-smtp.test.ts | 26 | IMAP connect, SMTP, TLS, folders |
| discovery/discovery-service.test.ts | 25 | Multi-source pipeline, confidence |
| discovery/mx-resolver.test.ts | 16 | MX lookup, known provider matching |
| oaem/capsule/capsule.test.ts | — | CBOR encoding, dual-prefix embedding |
| oaem/signing/signing.test.ts | — | Ed25519 JWS, key rotation |
| oaem/trust/trust.test.ts | — | 3-tier trust evaluation |
| oaem/ledger/ledger.test.ts | — | Hash-chained thread state machine |
| oaem/oaem-security.test.ts | — | Replay protection, nonce tracking |
| oaem/oaem-invariants.test.ts | — | Protocol invariant checks |
| oaem/oaem-durability.test.ts | — | Durability under failure |
| oaem/oaem-extraction-torture.test.ts | — | Capsule extraction edge cases |
Plus 346 mob tests in the host application testing edge cases, fuzzing, concurrency, security, and state corruption scenarios.
API Reference
Providers
| Export | Type | Description |
|--------|------|-------------|
| getProvider(type) | (ProviderType) => EmailProvider | Factory — returns provider instance |
| isProviderSupported(type) | (string) => boolean | Type guard for valid providers |
| registerProvider(type, factory) | (string, () => EmailProvider) => void | Register a custom provider |
| GmailProvider | class | Gmail implementation (OAuth) |
| OutlookProvider | class | Outlook implementation (OAuth) |
| ImapSmtpProvider | class | Universal IMAP/SMTP implementation |
| buildMimeMessage(options) | (MimeMessageOptions) => string | Build raw MIME message |
Account
| Export | Type | Description |
|--------|------|-------------|
| getActiveAccount(db) | async | Get the currently active email account |
| getConnectUrl(provider) | (string) => string | Get OAuth authorization URL |
| getValidAccessToken(db, accountId) | async | Refresh token if expired, return valid token |
| updateSyncTimestamp(db, accountId) | async | Update lastSyncAt after sync |
| connectAccount(db, provider, code, email, callbacks?) | async | Exchange OAuth code, create account |
| disconnectAccount(db, accountId, callbacks?) | async | Delete account and associated data |
Messages
| Export | Type | Description |
|--------|------|-------------|
| getInboxEmails(db, options) | async | List inbox messages (paginated, searchable) |
| getInboxThreads(db, options) | async | List threaded conversations |
| getThreadMessages(db, threadId) | async | Get all messages in a thread (conversation view) |
| getEmail(db, emailId) | async | Get single email with full content |
| getClientEmails(db, clientId) | async | Get all emails linked to a client |
| replyToEmail(db, input, callbacks?) | async | Reply to an email |
| linkEmailToClient(db, emailId, clientId, userId, callbacks?) | async | Associate email with CRM client |
Sync & Archive
| Export | Type | Description |
|--------|------|-------------|
| syncEmails(db, callbacks?) | async | Sync emails from all active accounts |
| logSyncFailure(error, db?) | async | Log sync failure with error code |
| archiveEmail(db, emailId, userId, callbacks?) | async | Archive an email |
| fetchEmailContent(db, emailId, callbacks?) | async | Lazy-fetch full body + attachments |
Templates
| Export | Type | Description |
|--------|------|-------------|
| ensureDefaults(db) | async | Seed default templates (idempotent) |
| listTemplates(db, options?) | async | List templates by category |
| getTemplateById(db, id) | async | Get single template |
| getActiveByCategory(db, category) | async | Get active template for a category |
| createTemplate(db, input, callbacks?) | async | Create a new template |
| updateTemplate(db, id, input, callbacks?) | async | Update template |
| removeTemplate(db, id, callbacks?) | async | Delete template (default protected) |
| resetToDefaults(db, callbacks?) | async | Reset all templates to factory defaults |
| renderTemplate(template, variables) | sync | Render template with variables |
| extractPlaceholders(text) | sync | Extract {{variable}} placeholders |
| validateTemplate(template) | sync | Validate template structure |
| buildVariablesFromContext(client?, order?, clothier?) | sync | Build variables from context objects |
| AVAILABLE_VARIABLES | const | All available template variables |
| SAMPLE_VARIABLES | const | Sample data for template preview |
Scheduled
| Export | Type | Description |
|--------|------|-------------|
| getScheduledEmails(db, options?) | async | List scheduled emails |
| getScheduledEmail(db, id) | async | Get single scheduled email |
| getPendingToSend(db) | async | Get emails due for delivery |
| scheduleEmail(db, input, callbacks?) | async | Schedule email for future delivery |
| cancelScheduledEmail(db, id, callbacks?) | async | Cancel a pending email |
| markAsProcessing(db, id) | async | Mark as being sent |
| markAsSent(db, id, callbacks?) | async | Mark as successfully sent |
| markAsFailed(db, id, errorMessage, callbacks?) | async | Mark as failed with reason |
| retryScheduledEmail(db, id) | async | Reset failed email for retry |
Discovery
| Export | Type | Description |
|--------|------|-------------|
| discoverProvider(email, options?) | async | Auto-detect provider settings |
Error Registry
| Export | Type | Description |
|--------|------|-------------|
| emailError(code, context?) | sync | Generate error with ref |
| templateError(code, context?) | sync | Alias for template errors |
| EMAIL_ENGINE_ERRORS | Record | Full error registry |
| EMAIL_ERRORS | Record | Email-specific subset |
| TEMPLATE_ERRORS | Record | Template-specific subset |
Companion: @ordinatio/ui
The @ordinatio/ui package provides ready-made React components for building email UIs on top of this engine:
| Component | What It Does |
|-----------|-------------|
| <EmailBodyRenderer> | Iframe-isolated HTML rendering with <style> stripping, auto-resize |
| <EmailThreadView> | Gmail-style expand/collapse conversation from getThreadMessages() |
| useKeyboardShortcuts() | Declarative email triage shortcuts (R=reply, E=archive, etc.) |
| cachedFetch() | Stale-while-revalidate caching for inbox data |
| <ErrorBoundary> | Catch rendering crashes with copyable error ref codes |
Pugil Integration
This package includes a Pugil reporter that generates Council-consumable trial_report artifacts from test results.
# Normal test run (no Pugil overhead)
pnpm --filter @ordinatio/email test:run
# With Pugil trial report generation
PUGIL_ENABLED=true pnpm --filter @ordinatio/email test:run
# With Council cycle integration
PUGIL_ENABLED=true PUGIL_CYCLE_ID=cycle-email-v1 pnpm --filter @ordinatio/email test:run- Config:
src/pugil.config.ts— maps test files to categories (unit, integration, adversarial, chaos, concurrency) - Reporter:
src/pugil-reporter.ts— Vitest custom reporter, writes topugil-reports/ - Types:
PugilTestResult,PugilTestCategoryfrom@ordinatio/core
License
MIT — part of the Ordinatio platform.
