Navigácia
ELOWEN · Dokumentácia

Posledná aktualizácia: 27. októbra 2026

Prechádzať dokumentáciu · Email runtime and adapter contracts

Tento článok zatiaľ nie je preložený. Je dostupný v angličtine.

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.ts and api.ts: account connection API and Google sign-in belong here, not in browser code.
  • Graph work owns graphBackend.ts and its recorded fixtures.
  • Tools work owns tools.ts and focused tool tests.
  • Alerts work owns alerts.ts and polling/delivery tests.
  • Browser work owns web-src/**, generated web/** 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.