Email runtime and adapter contracts
The canonical contracts are in src/types.ts. Accounts are personal to an Elowen user.
Resolve interactive ownership with ctx.currentAccountUserId(), never a platform sender,
request body owner or administrator fallback. All store account lookups require both owner
and account ID. Background work enumerates explicitly opted-in accounts, not an ambient identity.
Registration and adapter lifecycle
src/index.ts creates one store, credential service, Google OAuth service, account service
and connection verifier. It registers the authenticated API, EmailSearch, EmailRead,
EmailCompose, EmailOrganize and the daemon-only two-minute alert interval. Its reads: ['stores'] capability
allows the alert job to check the native live usersRead.mayUsePlugin(owner, 'email') predicate
before opening credentials. Revoked owners are skipped, not treated as recurring job failures.
The API uses the alert lifecycle's clearAccount(owner, id, change) hook for disable/delete:
block new account polls, drain admitted work, apply the change and clear reconnect only on success.
The manifest declares every
registered tool and API path; undeclared contributions are rejected by the host.
EmailAccounts owns reconnect and removal, not backend acquisition. API operations, tools
and alert polling acquire backends directly through the factory wired in src/index.ts.
The single MailBackendFactory(account) rereads the owner-matched account and opens Graph
through the live Microsoft control, or IMAP through openImapBackend. Google first awaits
GoogleMailOAuth.accessToken, including locked refresh-token rotation, then passes the fresh
credential to IMAP. Password accounts read the same owner-scoped credential service.
IMAP receives the native public HTTP resolver for IP pinning; autoconfig receives the native
public HTTP request boundary. No context, token store or backend is substituted.
The connection verifier performs real IMAP login and SMTP verification for send-enabled
accounts, or a real Graph folder read, and always closes its backend before persistence.
Only GraphSignInRequiredError and MailAuthenticationError are reconnect conditions;
provider outages and arbitrary exception text do not become sign-in failures.
MailBackendFactory(account) opens one owner-checked account. Callers acquire it for an
operation and await close() in finally, on success and failure. There is no empty or
mock production backend: missing integration rejects loudly. Adapters implement folder/label
CRUD, paged live search, flags, labels, move/archive/trash/permanent deletion, threads,
message reads, attachment bytes, draft creation, sending and inbox polling.
No message bodies or attachments are persisted by the plugin.
The v2 adapter contract is a clean cut: list(query: MailQuery): Promise<MailPage>.
The query contains exactly one source, {folder} or {label}, plus page,pageSize
and optional text,from,unread,flagged,hasAttachment,since. A label source searches
selectable folders except Trash/Junk. Pages are zero-based; the offset is
page * pageSize. Responses contain ungrouped, newest-first messages and a
message total, not a card count. No list cursor or limit alias remains.
At a provider search cap, searchLimit states the accessible result ceiling.
For multiple sources, fetch the first (page + 1) * pageSize messages from each,
merge by date, slice once and sum totals; there is no local mail index.
Folders add parentId?, unread and total (the latter includes read mail).
createFolder(name,parentId?) and renameFolder(id,name) return the live
MailFolder; deleteFolder(id) returns void. Role folders cannot be renamed
or deleted. markFolderRead(id) marks a folder's unseen mail read; emptyFolder(id)
permanently removes only mail in a native Trash/Junk role, never child folders.
The human-only folder bulk route confirms emptying and rechecks the role; adapters
check it independently, with UIDPLUS required by IMAP. No agent tool action is added.
Labels are {id,name,color}: IDs are account-local identifiers,
not display text, and colours use the canonical MAIL_LABEL_COLORS tuple and derived
MailLabelColor type.
listLabels() returns {supported,folderBacked,labels}, distinguishing an empty label
collection from an IMAP server that cannot create persistent keywords. folderBacked is true
only for Gmail's native folder labels, detected through X-GM-EXT-1 even on an app-password
account stored as kind: 'imap'; keywords and Microsoft categories return false. The workspace
uses this capability, not the sign-in method, to bind label reads and mutations to folder IDs.
createLabel(name) and renameLabel(id,name) return a label;
recolorLabel(id,color) returns the updated Microsoft category; IMAP/Gmail reject
colour writes, while Microsoft rejects rename. deleteLabel(id) returns void.
HTTP label updates are a strict name-or-colour union (updateLabelSchema), not a patch
that can update both. Unsupported label writes reject.
Headers carry labels:string[], flagged, hasAttachments, threadKey
and threadCount. Group only equal keys within the same account and page.
threadCount counts those page members; standalone reads and polls use 1.
thread(id) returns {threadKey,messages:MailMessage[]} in chronological
order across the source folder and Sent. Both get and thread leave read
state unchanged. The browser explicitly calls setFlags when opening mail;
agent reads never do. Every header, including lists and polls, carries nullable
listUnsubscribe:{https:string|null,mailto:string|null}, with at least one
valid target when non-null. Never fetch these targets on the daemon.
Bulk signatures are setFlags(ids,{read?,flagged?}) (at least one flag),
setLabels(ids,add,remove), move(ids,folder), archive(ids),
trash(ids) and deletePermanently(ids), all returning Promise<void>.
They accept 1–100 same-account IDs. Permanent deletion is Trash-only;
IMAP without UIDPLUS must refuse it, never issue plain EXPUNGE.
A provider failure is not an atomic rollback: callers refresh live state
after writes and must not report the whole batch as successful after failure.
Moving IMAP mail can invalidate its old folder-bound IDs.
tests/fixtures/emailMailbox.ts supplies typed header/message, page/thread and
browser-compose examples for callers. The raw Graph fixture
also carries category/flag/thread/unsubscribe data and folder counts; the MIME
fixture supplies References and both List-Unsubscribe targets.
Message IDs carry the account ID and a typed provider reference. IMAP references contain
the folder, UIDVALIDITY as a decimal string and UID; adapters must verify UIDVALIDITY before
using a UID. Graph uses immutable message IDs, including the appropriate Graph Prefer header.
Use parseMessageId to locate the account, then authorize it through EmailStore.require
with the current owner. Adapters use decodeMessageId(id, expectedAccountId) to refuse a
foreign account before touching mail. Do not split IDs in callers. Offset paging is independent
of persisted alert cursors; inbox polling retains its existing provider cursor contract.
poll(null) establishes a silent baseline and returns no messages. Later polls return the
next cursor and newly arrived headers. IMAP cursors contain UIDVALIDITY and the last UID;
Graph cursors contain the scoped delta continuation. A mailbox reset establishes a new silent
baseline rather than reusing old UIDs. The alerts owner persists the cursor only after delivery.
Outgoing adapter attachments are bytes, never host paths. Agent tools read them through the host Project-files seam in the current workdir; the browser uploads bytes in its compose JSON and the API resolves them before calling the same adapter. Replies, reply-all and forwards name a source message; adapters derive protocol-specific threading from that source. Agent callers enforce read/draft/send access and obtain confirmation before sending. Draft-only agent access never invokes send. Human page actions are not limited by the account's agent access setting; owner authorization still applies to every operation.
Authenticated API declaration
src/apiContract.ts exports the pure apiRoutes object beside the compiled entry.
Its 29 descriptors own method, path, purpose, access, canonical body/query/params
schemas, existing credential policy and response kind. Runtime registration spreads
each descriptor into the existing owner/draining wrapper; no second endpoint list,
handler import or startup work belongs in the contract module.
The handler calls req.json(schema), req.parseQuery(schema) and
req.parseParams(schema) with those exact schema objects, at the existing parsing
positions. Account UUID and provider/message checks remain staged around owner
lookups; parsing the complete params object must not move a foreign-account lookup
behind another parameter's error. The same primitive UUID/message schemas are reused
for those resource checks and compose source references. Query defaults/transforms
are input semantics, not an output schema to publish as accepted request fields.
req.credentialsAllowed() runs at the original credential gate: missing account
still answers 401 account_required first, and every non-GET agent still receives
the original structured 403 human_required. Schema/JSON input failures retain
Email's 400 invalid_request envelope; dependency failures retain their existing
connection/OAuth/operation mappings. Ignored request bodies stay unparsed.
Attachment GET is binary; bodyless 204 results retain their original transport
behavior. This contract does not export tools, change agent mailbox access or
replace native send confirmation.
tests/pluginApiMailPurposes.test.ts checks all descriptors through the host's real
registration validator and exercises Email/Teams error envelopes through actual
authenticated HTTP dispatch. Direct Email fixtures bind the core's lazy request
parsers rather than ignoring schema arguments.
Microsoft control
Resolve the complete shared MicrosoftIdentityControl at call time with
microsoftIdentityMethods and the existing approved control:microsoftIdentity consent;
absence hides Microsoft sign-in. The host projects its no-argument mailAccounts() member
from Teams' JSON GET mail/accounts route. Email's Microsoft list, import and reconnect
paths all call this member inside authenticated HTTP dispatch. Teams derives the owner
only from req.auth, reads saved local account metadata and returns the array directly;
listing never retrieves tokens, refreshes credentials or contacts Graph. Email retains
its { accounts } response envelope. Without an active HTTP actor the projected member
rejects rather than guessing an owner; background mailbox work keeps native mailGraphFor.
mailGraphFor returns null only when sign-in is required; outages reject. Tokens stay in
Teams. MicrosoftMailGraph.request/json/binary mirror the delegated Graph signatures with
normalized mail-only paths. Removing a Microsoft account calls mailDisconnect before
removing its local record. Import and reconnect still verify the selected own reference
through the existing Graph backend before saving; agent writes remain refused.
Implementation ownership
- Foundation owns
types.ts,messageId.ts,store.ts,accounts.ts,secrets.ts,index.ts, manifest, translations and documentation. - IMAP work owns
imapBackend.ts,autoconfig.ts,connection.ts,googleOAuth.tsandapi.ts: account connection API and Google sign-in belong here, not in browser code. - Graph work owns
graphBackend.tsand its recorded fixtures. - Tools work owns
tools.tsand focused tool tests. - Alerts work owns
alerts.tsand polling/delivery tests. - Browser work owns
web-src/**, generatedweb/**and browser tests.
The foundation owner integrates registrations in index.ts; sibling implementations do not
edit that shared file or invent parallel account stores. The browser uses the account API,
never direct IMAP, SMTP, provider tokens or database access.
Integration prerequisites
The shared OAuth helper, noninteractive confirmation capability, Mail glyph and sandboxed HTML renderer require a coordinated core release. Do not advertise unreleased seams as present in an older released core. Final shared/UI API and core floors must come from the built, released candidate before publication.
The coordinated runtime dependencies are imapflow 2.5.9, nodemailer 10.1.7,
mailparser 3.10.0 and fast-xml-parser 5.11.1. Registry development additionally uses
smtp-server 3.20.2 for disposable SMTP fixtures. These releases include their own types;
obsolete @types packages must not be added. Runtime dependencies come from the host;
registry development declarations do not install or bundle a second runtime.
The coordinated core candidate exposes daemon-only ctx.userSecretsFor(userId). Email uses it
only for daemon background work with no current account. Interactive operations, including
runners, still use ambient ctx.userSecrets() with an exact owner match. No context identity is
invented, no bag is cached across owners and no instance vault or core SQL substitutes for personal
authorization. The host rechecks live grants and user existence on background bag operations,
including writes after lock waits or external refresh. The first released core floor remains
the release owner's decision; this candidate capability is not a claim about a released version.
Owner removal
The entry tracks each complete authenticated API and tool callback, not merely backend
opening. Work remains active through OAuth exchange, verification, refresh, persistence,
attachment transfer and each caller's awaited backend close(). Removing a user closes
admission for that owner, stops and drains their alert polls, awaits admitted callbacks,
discards pending Google sign-in and only then deletes plugin rows. New API requests return
503 and new tool calls reject while removal is in progress. The in-memory owner tombstone
remains for this registration lifetime; account IDs are not reused by core.
Callback failures still reach their original callers; draining with allSettled does not
turn their result into success. Core subsequently deletes all user vault entries in its
account-deletion transaction. Runners register no background timer and retain ambient ownership.
tests/emailRegistration.test.ts exercises the real entry against the candidate plugin
registry, host, account grants, encrypted vault and disposable PostgreSQL. It scripts only
the public Microsoft/provider network boundaries; no production backend is replaced.