Navigace
ELOWEN · Dokumentace

Poslední aktualizace: 27. října 2026

Procházet dokumentaci · Email connections and HTTP API

Tento článek zatím není přeložen. Je dostupný v angličtině.

Email connections and HTTP API

Canonical browser contract

src/apiContract.ts owns HTTP request validation and response envelopes, not a second account or message model. Domain types stay in src/types.ts. Browser code imports wire types with import type; it must not import server implementation modules. All paths below are relative to /plugins/email/api/ (browser runtime api('email', path, options)). Routes require the Email grant and a non-null authenticated req.auth.userId. Neither admin status nor request data can select another owner. API route registration is integrated by the foundation owner.

Method Path Input Successful JSON
GET providers none {google:{available:boolean},microsoft:{available:boolean}}
GET accounts none {accounts:PublicMailAccount[]}
POST autoconfig {address:string} AutoconfigResult
POST accounts/connect ConnectAccountInput 201 {account:PublicMailAccount}
POST accounts/:accountId/reconnect ReconnectAccountInput (IMAP or Microsoft) 200 {account:PublicMailAccount}
PATCH accounts/:accountId `{access:'read' 'draft'
DELETE accounts/:accountId none 204, no body
POST microsoft/start {} {url:string}
GET microsoft/accounts none {accounts:{ref,address,displayName}[]}
POST microsoft/import MicrosoftImportInput 201 {account:PublicMailAccount}
POST google/start GoogleStartInput: MailSettings OR {accountId} {url:string}
POST google/callback {state,code} OR {state,error} 201 new / 200 reconnect {account:PublicMailAccount,returnTo:'/p/email'}
GET accounts/:accountId/folders none 200 EmailFoldersResponse
POST accounts/:accountId/folders createFolderSchema 201 EmailFolderResponse
PATCH accounts/:accountId/folders/:folderId renameFolderSchema 200 EmailFolderResponse
DELETE accounts/:accountId/folders/:folderId confirmMailActionSchema 204, no body
POST accounts/:accountId/folders/:folderId/bulk BulkFolderInput 204, no body
GET accounts/:accountId/labels none 200 EmailLabelsResponse ({supported,folderBacked,labels})
POST accounts/:accountId/labels createLabelSchema 201 EmailLabelResponse
PATCH accounts/:accountId/labels/:labelId updateLabelSchema 200 EmailLabelResponse
DELETE accounts/:accountId/labels/:labelId none 204, no body
GET accounts/:accountId/messages query below 200 EmailMessagesResponse ({messages,total,searchLimit?})
POST accounts/:accountId/messages/bulk BulkMessagesInput 204, no body
GET accounts/:accountId/messages/:messageId none 200 EmailMessageResponse
GET accounts/:accountId/messages/:messageId/thread none 200 EmailThreadResponse ({threadKey,messages})
GET accounts/:accountId/messages/:messageId/attachments/:part none raw bytes, attachment disposition
POST accounts/:accountId/messages/:messageId/unsubscribe confirmMailActionSchema 200 EmailSentResponse ({sent:true})
POST accounts/:accountId/compose/draft ComposeMessageInput 201 EmailDraftResponse ({id})
POST accounts/:accountId/compose/send composeSendSchema 200 EmailSentResponse ({sent:true})

The API registers these v2 handlers against the canonical adapter contract. Adapters and browser consumers must migrate together before release; no cursor compatibility route, draft-ID send shortcut or no-op backend is provided.

URL-encode each path parameter exactly once, including the entire opaque message ID and attachment part. Do not split message IDs or decode them in the browser. The host decodes parameters once before dispatch. Never append extra path segments.

PublicMailAccount contains only id,kind,address,displayName,access,alerts,createdAt. It contains no owner ID, server login or credential. Folder and message fields are exactly the canonical adapter types. Message HTML is untrusted and belongs only in the host's sandboxed frame. Attachments use application/octet-stream, X-Content-Type-Options: nosniff, Cache-Control: private, no-store and an attachment disposition with a percent-encoded UTF-8 filename; never inline HTML.

MailSettings requires displayName (may be empty), access and alerts. ConnectAccountInput adds address,password and optional servers. servers is {imap:{host,port,tls},smtp:{host,port,tls},username}; tls:true means implicit TLS and false means mandatory STARTTLS, never plaintext. Omitting servers requests discovery. Never send kind or userId. MicrosoftImportInput adds the selected Microsoft ref to MailSettings.

ReconnectAccountInput is strictly {password,servers?} for an existing IMAP account or {ref} for Microsoft. IMAP omission of servers reuses the stored settings, not discovery. No reconnect body can replace owner, provider, mailbox address, display name, access or alert preferences. The target is an existing owner-authorized account ID. Failed verification preserves the existing connection. Google reconnect uses only google/start with {accountId}, not this route.

Message-list query chooses folder or label, never both; neither means folder=INBOX. Optional filters are text,from,unread,flagged,hasAttachment,since. Boolean query values are the literal true or false; since is ISO 8601 with an offset. page defaults to 0 and pageSize to 50 (HTTP range 1–100; the page offers 25/50/100). The slice starts at page * pageSize, and total counts messages before same-page thread grouping. cursor, limit and unknown query fields are rejected. Changing account, source or filters resets the browser page to 0. Label views span selectable folders except Trash/Junk. At Graph's search cap, searchLimit:1000 accompanies the accessible-result total.

Folder creation takes {name,parentId?}; rename takes {name}. Folder deletion takes {confirmed:true}, after a dialog naming the current message count. Role-folder rename/deletion is forbidden. Folder bulk takes exactly {action:'markRead'} or {action:'empty',confirmed:true}. Empty requires an existing native trash or junk role (404 if absent, 409 for another role); a display name such as “Trash” is not authority. IMAP emptying also requires UIDPLUS. Label creation takes {name}. Label PATCH takes exactly {name} or {color}, never both: rename is available over IMAP/Gmail, recolour only for Microsoft. MAIL_LABEL_COLORS in types.ts owns the closed nine-colour palette; arbitrary CSS colours are rejected. Label deletion has no body. supported:true means native labels can be assigned; an empty list is not an unsupported server. Gmail labels and Outlook categories can be created independently. Ordinary IMAP keywords exist on messages, so an empty keyword cannot be created: createLabel returns unsupported_operation; setLabels may assign a new keyword. supported:false means the server cannot persist arbitrary keywords. Outlook category names are immutable, so rename also returns unsupported_operation without changing the category or its assignments.

Bulk requests are discriminated by action, with 1–100 unique ids:

  • setFlags: flags:{read?,flagged?}, at least one value.
  • setLabels: add:string[],remove:string[], nonempty combined and disjoint.
  • move: folder:string.
  • archive or trash: no extra fields.
  • deletePermanently: confirmed:true; every message must already be in Trash.

All message IDs and compose source IDs are checked against the owned account and provider before any remote write. Writes close the backend in finally. All non-GET HTTP routes are human-only: verified req.auth.agent callers are rejected with 403 human_required, including account preferences and connection flows, not merely a caller-supplied confirmation field. The account's access setting limits agent tools, not the owner's browser actions. Agent sorting goes through EmailOrganize, never permanent/folder deletion. Opening a browser message explicitly marks it read through setFlags; GET message/thread requests and agent reads have no read-state side effect.

Compose draft bodies are {to,cc?,bcc?,subject,text,html,source?,attachments?}. Recipients are typed {address,name?}; address is z.email() bound explicitly to COMPOSE_ADDRESS_PATTERN, which the browser composer tests every typed address with. A draft may have no recipient, but send requires at least one To/Cc/Bcc recipient. source is {action:'reply'|'replyAll'|'forward',id}; omission means new mail. The browser supplies the complete plain text and formatted HTML, including quotes. The API applies the host's sanitizeDocumentHtml security boundary and then src/composeHtml.ts projects to p,br,strong,em,a,ul,ol,li. All attributes are removed except absolute HTTP/HTTPS/mailto link destinations and ol start in the host serializer's canonical String(integer) form (Number.isInteger, including zero and negative integers). Blank, fractional and noncanonical numeric values are removed; no other list attributes are accepted. This preserves the editor's numbering in both HTML and plain text. Unsupported wrappers are unwrapped. No style, remote image, script or arbitrary editor extension reaches the outgoing HTML. A send body is {message:ComposeMessageInput,confirmed:true}, after a dialog naming the sole recipient or counting all To/Cc/Bcc recipients of that message. There is no HTTP draft-ID send shortcut.

Composer attachments are either:

  • {kind:'upload',filename,contentType,contentBase64}: standard base64 File bytes, not a data URL, filesystem path or server upload handle;
  • {kind:'source',part}: a part of the validated forward source only. The API reads the source metadata/bytes from the same account and retains only requested parts.

The API parses MIME types and resolves both forms to MailOutgoingAttachment bytes. Uploads are request-scoped and never persisted; failed requests leave the browser draft intact, and dropping the composer needs no server cleanup. Combined uploaded bytes are capped at 5 MiB (MAX_UPLOADED_ATTACHMENT_BYTES); the host independently caps the entire JSON request at 8 MiB, including JSON escaping and attachment metadata. Provider limits still apply, including Graph's under-3,000,000-byte per-attachment limit (MAX_MICROSOFT_ATTACHMENT_BYTES). The pattern and both limits live in src/composeRules.ts, which builds no schema, so the routes, the Graph adapter and the browser bundle import the same values without bundling zod. Do not silently drop attachments or send without them on failure.

MailHeader.listUnsubscribe is null or {https,mailto} with at least one valid target. Lists and polls expose it too; MailMessage inherits it rather than redeclaring it. Prefer HTTPS, opening it in the browser; the daemon never fetches it. Only the confirmed mailto fallback calls the unsubscribe POST route, whose destination comes from the freshly read message, never a request URL/address. Both adapters use src/listUnsubscribe.ts: parseListUnsubscribe(value: string | undefined): MailListUnsubscribe | null. Pass raw field values without the header name; join repeated fields with a comma. The pure RFC 2369 parser handles bracketed URLs, folded whitespace and RFC 5322 comments. It returns the first credential-free HTTPS and first valid RFC 6068 mailto target, or null. Unsupported URL schemes are ignored; a malformed list item stops parsing the remaining list. Mailto recipients use the shared typed email validator; control characters in header query parameters are refused. The parser does not resolve DNS, fetch a URL or send mail. IMAP supplies native mailparser header lines and Graph supplies internetMessageHeaders values. tests/emailListUnsubscribe.test.ts covers the shared parsing boundary. The route only projects validated mailto recipients, subject and body. Mailto field names are case-insensitive and literal plus signs remain plus signs, not form-encoded spaces. Arbitrary headers (including Cc/Bcc) are ignored. A message with an HTTPS target refuses the POST fallback, even when it also has mailto.

Attachment preview uses the existing download route above, fetched as bytes. The browser builds/revokes blob URLs with an allow-listed type: PDF, PNG, JPEG, GIF or WebP. SVG/HTML and all other types download only; there is no inline route.

AutoconfigResult is {servers:MailServers|null,source:'domain'|'srv'|'ispdb'|null, suggestion:'google'|'microsoft'|null,failures:{source:'domain'|'srv'|'ispdb'|'mx', code:'unavailable'|'invalid'}[]}. No discovered settings means show manual settings; an MX suggestion is not a successful login or an automatic provider switch.

Failures use {error:{code,message}}: 400 invalid_request, 401 account_required, 404 account_not_found, 503 provider_unavailable, 422 certificate_rejected, authentication_refused, server_unreachable or connection_failed, 400 oauth_failed, 409 reconnect_required or 502 operation_failed. V2 operations additionally use 403 human_required, 409 unsupported_operation, 404 message_not_found/folder_not_found/label_not_found, 413 attachment_too_large and 502 delivery_incomplete. Connection-test errors can also carry details:{protocol:'IMAP'|'SMTP',host,certificateNames?}. The UI translates stable codes with those allowlisted details, never provider prose.

Adapters import MailUnsupportedOperationError from src/operationError.ts and throw it only for their own expected capability/policy refusals: Outlook category rename, IMAP permanent deletion without UIDPLUS and unsupported persistent labels. The class extends Error and exposes the fixed code: 'unsupported_operation'; its constructor message is diagnostic, never HTTP response copy. API preflight policy checks use the same class. The API recognizes its instance and returns HTTP 409 with {error:{code:'unsupported_operation',message:'This mailbox does not support the requested operation.'}}. A raw provider error with the same code is not this class: it remains a logged, sanitized 502 operation_failed. Do not wrap arbitrary provider responses in this class or add speculative error codes. Tools propagate the typed refusal normally.

MailDeliveryIncompleteError (same module, code: 'delivery_incomplete') means the provider accepted a message it did not complete (see "IMAP operations and delivery outcomes"). Its constructor message is diagnostic too. The API logs it like an unexpected failure (the cause stays local) and answers HTTP 502 {error:{code:'delivery_incomplete',message:'The message was sent, but not completely. Do not send it again; check its delivery and the Sent and Drafts folders.'}} for every send route (compose and unsubscribe), so no client mistakes it for the generic operation_failed that invites a retry. Tools propagate it normally.

registerEmailApi requires the plugin logger's error method. Unexpected failures are logged once with the method and route template, error types, typed codes, stack frames and recursive cause/aggregate traces. Provider messages, responses and commands are not serialized because they can contain credentials or mail. The original error stays in local-only failure metadata for host classification. Request parsing alone maps validation/syntax failures to 400; an internal parser failure is logged, not blamed on the user's input.

Sign-in browser lifecycle

Google appears only when both instance credentials are configured. GoogleMailOAuth.available() returns false for an absent or incomplete pair, including the intermediate state between separately autosaved settings; provider discovery remains usable for IMAP and Microsoft. client(), begin() and complete() still reject an incomplete pair before token requests. POST google/start, navigate to its URL, then render the browser page /p/email/oauth/callback. That page POSTs only state plus code or denial error to google/callback, clears callback parameters from browser history and replaces location with the returned fixed /p/email destination after success. Do not send error_description. Denials still consume their pending state. Retrying an expired, restarted or consumed flow requires a new start.

To reconnect Google, POST exactly {accountId} to google/start. The API requires an owned Google account and binds its ID to server-side pending state. The callback cannot select or replace that ID. The Gmail profile must match the existing address; success returns the original ID and does not create another row.

Microsoft availability follows the live optional microsoftIdentity control. POST microsoft/start; that control returns to /p/email after its own /p/msteams/mail/callback flow. Then GET microsoft/accounts and POST microsoft/import with the selected ref and account settings. The browser never sees Microsoft or Google tokens. For Microsoft reconnect, retain the target account ID as browser navigation intent, then POST the selected ref to accounts/:accountId/reconnect instead of importing another account. The provider-returned address must exactly match the original mailbox. Browser intent is not authority: the API and foundation recheck the authenticated owner.

Server ownership and persistence

registerEmailApi(ctx, dependencies) owns the routes in the contract table. The foundation supplies its existing store/accounts service, the one shared MailBackendFactory, EmailConnections, GoogleMailOAuth, discovery and a live Microsoft identity resolver. All backend reads close in finally. Owner-scoped listing distinguishes not-found from store failures without parsing the store's untyped exception prose. Responses are no-store, and only explicit public account metadata is projected; credentials never cross HTTP.

EmailConnections(store, secrets, verify, discover, identity, accounts) creates UUIDs on the server. Its required VerifyMailConnection(account, secret) must perform real login and close resources, not substitute a no-op. It accepts the unpersisted owner-bound account and credential; Microsoft verification uses the same Graph implementation through its identity control. Password and Google secrets are stored only after verification. Metadata follows. If the existing store's insert succeeds but its post-insert read fails, the credential is kept with that committed row and the failure is propagated; when no row committed, the newly stored secret is removed. Failure to establish cleanup is an aggregate error, never success. Microsoft imports select only a ref returned by mailAccounts() under the authenticated HTTP actor; it accepts no caller-selected owner.

The required sixth constructor argument is the existing foundation EmailAccounts service (only reconnect is needed); the store also supplies require. EmailConnections.reconnect(owner,id,input) selects the existing provider and passes the real verifier to EmailAccounts.reconnect. State-bound Google completion uses the same foundation operation. That operation validates the exact provider/address, verifies before writing, serializes credential changes under the account vault lock and restores the previous credential if metadata replacement fails. Identity, cursor and user preferences remain unchanged. Neither API nor connection service implements another credential transaction or reports a fabricated persisted connection status.

Typed reconnect classification

The foundation supplies one isReconnectRequired(error) function to both registerEmailApi and registerEmailAlerts; neither route nor interval parses error prose. The IMAP/Google workstream exports MailAuthenticationError from src/connection.ts, with typed reason values credentials_missing, credentials_rejected and mail_scope_missing. Google refresh converts only the shared OAuth helper's invalid_grant to rejected credentials; transport, HTTP outages and malformed token responses retain their non-authentication errors. Initial sign-in's code/state failures are not stored-account auth failures.

The Graph workstream separately owns GraphSignInRequiredError. The foundation combines those concrete error classes, not arbitrary HTTP status codes. A classified error from opening or reading an existing mailbox returns HTTP 409 with error.code = 'reconnect_required'; a network failure returns 502 operation_failed. Connection tests return the typed 422 codes above; only unclassified failures keep connection_failed. Microsoft verification reuses the same Graph sign-in classification for authentication_refused, without inventing an IMAP/SMTP protocol. The account list does not probe or fabricate connection status.

Google tokens and account lifecycle

GoogleMailOAuth(config, publicWebUrl, secrets) uses the shared oauthCodeFlow helpers, one pending PKCE/state record per owner and a ten-minute expiration. Starting again supersedes the owner's earlier flow; expired records are removed on start. A matching callback consumes state synchronously before any token request, including a denial. Another owner's callback cannot consume it. An optional reconnect account ID is held only in that owner-bound pending record and returned by complete; new-account flows have no target.

Google token and profile endpoints are fixed HTTPS URLs, never callback inputs. The profile comes from users/me/profile; its validated emailAddress binds the IMAP login to the authorized mailbox rather than an editable browser field. This requires Gmail API enabled in the same Google Cloud project. Google's profile reference documents that the full mail scope authorizes this request.

Refresh runs entirely inside EmailSecrets.withLock(owner, id, callback), rereads the current credential and awaits rotation persistence before returning. Omitted refresh tokens preserve the old token; missing offline access, missing expiry, a wrong token type or denied mail scope reject. Error responses and transport causes never expose tokens, codes or provider response prose.

Foundation lifecycle integration must stop accepting operations for a removed owner and await every in-flight connection, callback, poll and token refresh before deleting rows/secrets. Then call google.forget(owner) to clear pending state. This module does not retain a vault bag or invent a removed-user registry. The owner-authorized background vault seam and its write revocation semantics remain foundation-owned; ambient credentials are not a background fallback.

Discovery transport and parsing

discoverMailServers(address, ctx.host.publicHttp()) tries, in order, the HTTPS domain configuration, the domain's RFC 6186 SRV records and then Thunderbird ISPDB; the first two are published by the domain owner, so the third-party database is asked only when neither answers. HTTP goes through the host's validated, DNS-pinned transport. It sends no password, token or full email address, follows no redirect, limits each response to 256 KiB and each request to ten seconds, and always cancels the response stream.

SRV discovery uses node:dns resolveSrv with the module's bounded resolver (2.5 s, one try; tests pass a Pick<Resolver,'resolveMx'|'resolveSrv'> double as the third argument). Following RFC 8314 it asks _imaps._tcp before _imap._tcp and _submissions._tcp before _submission._tcp; implicit TLS maps to tls:true, STARTTLS to tls:false. Within one service the lowest priority and then the highest weight wins; a . target means the service is absent. Both an IMAP and a submission record are required and the username is the full address (RFC 6186 section 4); host names come only from the records, never from guesses. A missing service is the srv/unavailable failure, an invalid target srv/invalid.

DNS MX lookup only suggests a provider. For both DNS sources, an ENOTFOUND (NXDOMAIN) or ENODATA answer is a normal absence (mx/unavailable for MX); any other DNS error rejects discovery with the original error as cause, so the route answers operation_failed instead of reporting a misconfiguration as "not found".

The pinned fast-xml-parser parses and validates XML with entity expansion disabled. Typed schemas select IMAP/SMTP, password authentication and SSL or STARTTLS. Only the documented email/domain/local-part placeholders are expanded; different IMAP and SMTP usernames cannot be represented by the current account model and therefore require manual settings. Plaintext configuration is refused.

Verification

Focused suites: tests/emailApiContract.test.ts, tests/emailApi.test.ts, tests/emailConnection.test.ts, tests/emailAutoconfig.test.ts and tests/emailGoogleOAuth.test.ts. tests/emailReconnect.test.ts traverses the real API, connection service, foundation accounts/secrets and disposable PostgreSQL store for stable IDs/preferences/cursors, failed-login retention and Google/Microsoft mailbox-match rejection; only provider transport, the vault bag and socket verifier are test doubles. The suites also use typed transport/store doubles, a disposable real account store for API owner-isolation checks, the real XML parser and real shared PKCE/token helper. They do not prove real IMAP/SMTP behavior or replace disposable-server integration tests. Google/Microsoft browser callbacks are verified by the browser workstream against the canonical routes.

IMAP open and verification contracts

src/imapBackend.ts exports:

  • openImapBackend(account, secret, network): Promise<ImapBackend>: account is an IMAP/Google NewMailAccount (a persisted MailAccount is also accepted); secret is the canonical MailAccountSecret; network is Pick<PluginPublicHttp, 'resolve'>. It performs a real IMAP connect/login before returning through the internal ImapBackend.connect() admission seam. A failed connect force-closes the native client; focused tests exercise this actual catch with the native SDK error shape without opening sockets.
  • verifyImapConnection(account, secret, network): Promise<void>: opens the same backend, verifies SMTP only for access:'send', and awaits close in finally. Use this in the foundation's VerifyMailConnection branch for non-Microsoft accounts. Credentials are supplied before account persistence; this function never chooses an owner, reads a vault or writes metadata.
  • ImapBackend.verify(): SMTP admission check for an already opened backend. close() logs out and force-closes IMAP even when logout fails. Each SMTP verification/send owns and closes a fresh native transporter.

The foundation's one MailBackendFactory(account) supplies credentials through EmailSecrets, refreshes Google via GoogleMailOAuth.accessToken(owner,id) when required, and calls openImapBackend(account, secret, ctx.host.publicHttp()). All API, tools and alerts use that factory and close in finally; the adapter does not call either ambient or daemon-only vault accessors. Use a real Graph verification path for Microsoft, not a no-op shared verifier.

Before each new IMAP or SMTP connection, publicHttp.resolve() validates an HTTPS URL containing only the mail authority and port; it sends no HTTP request. The SDK receives the returned IP literal as host and the normalized URL's original hostname as TLS servername. Certificate validation stays enabled; non-implicit connections require STARTTLS (doSTARTTLS:true / requireTLS:true). There is no SDK service/proxy override, second DNS resolver, private-address exception or plugin-owned IP policy. SMTP transporters are not reused across operations, so a later connection resolves afresh.

The exact published ImapFlow 2.5.9 declarations and source accept an IP host and independent servername for both implicit TLS and STARTTLS. Nodemailer 10.1.7's hostname resolver returns IP literals without querying DNS. Dependency source inspection is evidence of the integration contract, not a live socket test.

IMAP organization, labels and threads

ImapBackend implements the canonical v2 MailBackend; no cursor list overload remains. Folder lists request live unread/total counts and expose native parents. Folder create/rename uses a single leaf name under the selected parent and the server's namespace delimiter. Role folders, including ancestors containing a role folder, cannot be renamed or deleted.

Gmail is detected by the server's X-GM-EXT-1 capability, including app-password accounts. User mailboxes outside [Gmail]/ and [Google Mail]/, excluding INBOX and special-use folders, are labels. Their complete native paths support nested labels. CREATE/RENAME/DELETE changes those mailboxes; assignment uses messageFlagsAdd/Remove(...,{uid:true,useLabels:true}) (X-GM-LABELS), not MOVE. The palette is a deterministic hash of the name. A Gmail label view reads its one native mailbox, avoiding duplicate copies in Inbox and other label folders. MailLabelList.folderBacked exposes this native-folder identity to the workspace independently of password versus OAuth sign-in. Other IMAP keyword lists return false.

Other servers expose keywords only where SELECT explicitly includes PERMANENTFLAGS (\\*). Discovery uses selectable mailboxes' FLAGS and PERMANENTFLAGS as candidates, excluding system flags, then searches each candidate for a live assignment. Servers retain unused keyword vocabulary after a delete or rename, so showing that vocabulary alone would resurrect removed labels. The cost is one UID SEARCH per candidate per supported mailbox. Thunderbird $label1..5 map to Important, Work, Personal, To Do and Later with their conventional palette colours. Keywords are ASCII IMAP atoms, never flags or arbitrary display names containing spaces. Renaming/removing searches each selectable mailbox for that keyword and updates only matching UIDs; renaming to an existing different keyword is refused.

Native keyword limit: IMAP defines no command to create an empty keyword. createLabel therefore explicitly refuses empty keyword creation with unsupported_operation; it never returns a successful but non-persisted label. setLabels may assign a new valid keyword directly, making it discoverable. There is no persistent local catalog or fabricated STORE affecting unrelated messages. Empty keyword creation is a documented provider limitation, not a retryable failure; Gmail label creation is fully native and persistent.

A normal folder page uses SEARCH UIDs sorted descending, then the exact page * pageSize slice; total counts messages. Attachment filters inspect complete BODYSTRUCTURE for all search candidates before slicing/counting, without reading their attachment bytes. Ordinary keyword views search all selectable folders except Trash/Junk, merge by the actual message date and slice the combined result. Their metadata scan can be proportional to all matching mail; only the selected page's bounded MIME prefixes are fetched for snippets. Every page remains ungrouped, with same-page thread counts. No mailbox snapshot or mail cache exists.

Reads request PEEK, flags, native labels, BODYSTRUCTURE and provider thread IDs; they never mark Seen. Conversation keys prefer X-GM-THRID or OBJECTID THREADID, then the References/In-Reply-To root and Message-ID. thread(id) scans only the source folder and Sent, returns complete messages oldest first, and deduplicates native EMAILID/X-GM-MSGID copies. On a server without native thread IDs the scan reads complete headers; it does not approximate threads by subject.

Every bulk write accepts 1–100 unique same-account IMAP references, validates the whole batch's folder/UIDVALIDITY/existence before its first write, and rechecks each locked folder immediately before mutation. IMAP has no cross-folder transaction: a subsequent remote failure can leave earlier groups changed and is propagated, never reported as success. Seen and Flagged updates preserve all other flags; keyword additions require that selected mailbox's wildcard support.

Move uses native MOVE or the SDK's COPY + UID EXPUNGE emulation only with UIDPLUS. Without both capabilities it refuses before copying or marking Deleted. Archive uses the Archive role or creates Archive lazily after reference preflight. ImapFlow 2.5.9 exposes only mailboxCreate(path), not CREATE-SPECIAL-USE options, so this creation uses ordinary CREATE; the SDK recognizes the Archive name on later folder lists. Advertising CREATE-SPECIAL-USE alone does not change this SDK limitation. Trash moves to the Trash role and is a no-op for mail already there; it never implicitly deletes permanently. markFolderRead(id) locks the mailbox, searches unseen UIDs and applies messageFlagsAdd(uids, ['\\Seen'], {uid:true}) only when that search is nonempty. ImapFlow returns false for empty search-object ranges as well as a refused STORE; explicit UID search distinguishes an already-read mailbox from a real write refusal. emptyFolder(id) verifies the native Trash/Junk role and UIDPLUS before locking and calling messageDelete({all:true}, {uid:true}); it does not delete the folder or its descendants. A false SDK result is a refusal, never success. The native ImapFlow methods accept search-object ranges and boolean results (checked 2026-10-22). recolorLabel refuses for IMAP keywords and Gmail labels because IMAP has no writable colour operation.

Permanent deletion requires the existing Trash role and UIDPLUS, checked before any destructive command; only UID EXPUNGE is allowed. Stored-draft send also requires UIDPLUS before SMTP because its post-send cleanup deletes one UID. The canonical MailUnsupportedOperationError from src/operationError.ts carries code:'unsupported_operation' for the API's 409 response; it is not a reconnect or credential error.

IMAP operations and delivery outcomes

Folder roles come from special-use metadata. Drafts require a Drafts role and Sent storage requires a Sent role; missing folders reject before SMTP. Message references bind account, mailbox, UIDVALIDITY and UID. Every read selects and locks its mailbox and checks UIDVALIDITY before fetching by UID. List pages search matching UIDs, sort descending and slice at the zero-based offset; live arrivals can shift later pages because no local snapshot is retained. Mail is read live: list pages fetch complete headers and a bounded 32 KiB MIME prefix for plain-text snippets, rather than downloading every attachment on the page. A body beyond that prefix can have an empty preview; opening the message reads its full source. No header/body/attachment cache is retained.

The native mailparser supplies HTML-to-text conversion and CID data URIs. Mailparser 3.10.0 omits text for HTML-only multipart bodies, including multipart/related with inline images. When HTML exists but text is absent, the adapter passes that decoded HTML through the same parser as a root UTF-8 text/html part. This bounded workaround adds one HTML-only parse, retains the native conversion limits and leaves the original headers, HTML and attachments intact. Supplied plain text, including an explicit empty part, is preserved. Conversion errors from either parse propagate instead of returning an empty successful read. Attachment parts use native MIME part IDs (root attachment 1); missing filenames receive a neutral part-based display name. Parser order is not an authorization token. Outgoing attachment data is already-owned bytes, never a host path or a URL.

Draft APPEND accepts an empty To/Cc/Bcc list and uses \\Draft and \\Seen, retaining Bcc. The shared MIME composer requires a sender but permits an empty recipient envelope; ImapBackend.send() rejects that envelope before creating SMTP, for both new messages and saved drafts. tests/emailImapCompose.test.ts exercises this production composer for IMAP and Google drafts. APPENDUID supplies the stable ID; without UIDPLUS the adapter identifies its generated Message-ID inside the locked Drafts folder. A missing or ambiguous result rejects instead of inventing an ID. Replies derive References/In-Reply-To from the same-account source; tools supply the confirmed recipients, text and explicit attachments.

A single native MIME tree builds the private stored copy with Bcc and the SMTP wire copy without Bcc; the envelope still includes blind recipients. Sending an existing draft parses its stored MIME, verifies the Drafts folder and Draft flag, sends, stores Sent, then removes only that draft. Gmail (Google accounts or the server's X-GM-EXT-1 capability) owns its Sent copy and receives no duplicate APPEND. Partial recipient rejection or a Sent/draft-cleanup failure after SMTP acceptance raises MailDeliveryIncompleteError (src/operationError.ts): the message was sent, and must not be resent automatically. There is no blind retry. HTTP reports it as 502 delivery_incomplete; the composer closes as sent and warns.

ImapFlow 2.5.9's LOGIN and OAuth authentication mark the original command error with authenticationFailed:true on a server refusal; they do not necessarily throw an AuthenticationFailure instance. The native admission catch validates that exact boolean flag with a typed schema, force-closes the client and raises MailAuthenticationError('credentials_rejected'). Unflagged admission errors retain their original identity; provider prose is not authority. SMTP EAUTH with 534/535 becomes rejected credentials; temporary 454 replies, socket errors and TLS failures do not become reconnect errors. None of these classifiers reads provider prose or logs protocol traffic.

Only connection testing wraps those native errors as MailConnectionError in verifyImapConnection, before persistence in add-account and reconnect (including Google). Live reads retain their existing reconnect semantics. IMAP open includes the host resolver; SMTP verification includes transport creation. Each wrapper supplies the configured hostname and protocol, not the pinned IP. ImapFlow 2.5.9 preserves Node's code; Nodemailer 10.1.7's _formatError preserves it as originalCode before assigning ESOCKET/ETLS. Certificate rejection codes include ERR_TLS_CERT_ALTNAME_INVALID, CERT_HAS_EXPIRED, DEPTH_ZERO_SELF_SIGNED_CERT, SELF_SIGNED_CERT_IN_CHAIN and UNABLE_TO_VERIFY_LEAF_SIGNATURE; network codes include ENOTFOUND, ECONNREFUSED, ETIMEDOUT, EHOSTUNREACH, and ImapFlow's ETIMEOUT/CONNECT_TIMEOUT. A generic tlsFailed:true is not proof of a certificate rejection. Native authentication causes are retained under MailAuthenticationError.cause.

When Node exposes error.cert, cert.subjectaltname takes precedence over cert.subject.CN. SAN text is preserved verbatim, including Node's quoted-entry representation; it is not parsed by splitting commas or inferred from messages. Libraries do not supply a peer certificate on every TLS error, especially chain/expiry errors. Missing names remain absent on the wire and get explicit localized copy. No additional socket probe or insecure TLS option is introduced. tests/emailConnectionFailures.test.ts drives the real verifier with native transport doubles for both protocols, all three classes, SAN/CN/unavailable names and resolver errors.

Polling uses STATUS INBOX (UIDNEXT UIDVALIDITY) while holding the mailbox lock. Null cursors and validity resets establish a silent baseline; otherwise only headers/flags/internal dates from the finite new UID range are fetched; polling does not read message bodies or attachments. A backwards UIDNEXT without a validity reset is an invariant failure. The alerts owner persists only after delivery and drains work before account/user cleanup.

Remaining integration verification

tests/emailImapBackend.test.ts and tests/emailImapV2.test.ts cover adapter organization and delivery logic with explicit SDK doubles; the latter uses real mailparser MIME parsing. tests/emailImapMime.test.ts covers HTML-only/CID MIME. tests/emailImapWire.test.ts runs real ImapFlow against a disposable loopback protocol peer: UID STORE + UID EXPUNGE, refusal before STORE without UIDPLUS, Gmail X-GM-LABELS/X-GM-THRID, OBJECTID THREADID and PEEK reads. This is an emulated peer, not Dovecot interoperability coverage. tests/emailImapSmtp.test.ts uses real smtp-server, Nodemailer and MailComposer for To/Cc/Bcc envelope delivery, no public Bcc header, the private Sent copy and explicit attachments; its IMAP append is a double. All peers close in finally. tests/emailImapDovecot.test.ts uses standalone Dovecot IMAP processes with a disposable Maildir, exposed only through a loopback fixture listener. Set ELOWEN_DOVECOT_ROOT to an inspected package root containing usr/bin/doveconf and usr/lib/dovecot/imap; otherwise the suite visibly skips. Run it in the normal isolation boundary, binding that root read-only after the private tmpfs. No system service is installed, started or reconfigured. The suite covers native folder counts/CRUD, flags, move/archive/trash, targeted UID EXPUNGE while unrelated Deleted mail survives, keyword rename/delete/discovery, References threads, server-advertised UIDPLUS disabled and read-only EXAMINE keyword refusal. It uses PREAUTH standalone processes, not password login or provider TLS. Real-provider TLS/authentication and the coordinated foundation lifecycle and core/plugin compatibility gates remain separate integration requirements.