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.archiveortrash: 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/GoogleNewMailAccount(a persistedMailAccountis also accepted); secret is the canonicalMailAccountSecret; network isPick<PluginPublicHttp, 'resolve'>. It performs a real IMAP connect/login before returning through the internalImapBackend.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 foraccess:'send', and awaits close infinally. Use this in the foundation'sVerifyMailConnectionbranch 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.