@aria-framework/email
v0.11.0
Published
Aria App Framework — outbound email module. Outbound provider dispatcher (SMTP via nodemailer, Microsoft 365 via Graph /me/sendMail with msal-node) behind one send interface, plus inbound intake (IMAP) delivering a normalised message to an app handler; ap
Readme
@aria-framework/email
Aria App Framework — outbound email module. One send interface over two providers, selected at runtime from the app's credential store:
- smtp — nodemailer, STARTTLS or implicit TLS,
verify()-backed test flow with plain-language error hints for admins. - o365 — Microsoft Graph
/me/sendMailwith a delegated token from msal-node (confidential client forwork, public client forpersonal— see Account types below; persisted token cache; silent refresh).
The package holds no app state: the app injects its credential store and logger once at boot. Envelope building (templates, tickets, queues) stays in the app — this package only picks a provider and sends.
Install & wire up
npm install @aria-framework/emailconst email = require('@aria-framework/email');
email.configure({
getStore: () => encryption.getSecureStore(), // your store accessor
logger, // .info/.warn/.error (default console)
appName: 'MyApp' // default from-name + test-mail copy
});
email.init(); // select the active provider
// later, fire-and-forget:
email.sendMail({ to, subject, html, label: 'welcome' }).catch(() => {});getStore() must return an object with exists(type), load(type),
save(type, row). @aria-framework/secure-keystore's SecureStore satisfies
this, but the contract is structural — any conforming store works; this package
does not depend on the keystore package.
Store row contracts
| type | fields read | notes |
|---|---|---|
| email_provider | provider | 'smtp' or 'o365'; missing row = smtp |
| smtp | host, port, username, password, from_address, from_name?, secure? | secure === 'true' = implicit TLS (465), else STARTTLS (587) |
| entra | client_id, tenant_id | App Registration identity for MSAL |
| entra_email | mode, client_id, tenant_id, client_secret | optional (0.5.0) in the sense that empty/absent DATA is fine — overrides entra for email only when mode === 'separate' and all fields are present, otherwise the entra row is used. But the consumer MUST declare this row in its OWN store schema (or rely on the 0.5.1 guard below) — exists('entra_email') throws for an undeclared type, and that throw is not "no override configured" |
| o365_oauth | account_home_id, account_username, token_cache, account_type?, from_address | token_cache is the serialized MSAL cache; the package writes it back after silent refresh. account_type (0.6.0) is 'work' or 'personal'; absent means inferred (legacy installs pre-date this field) — since 0.6.2 the consumers tenant (alias or GUID) infers personal and outranks secret-presence, then a client secret infers work, then common infers personal, else work. Declare it neither encrypted (not a secret) nor required (the encryptor rejects '', so a required optional field cannot be cleared) — or spread O365_STORE_SCHEMA and get it right for free. Set it explicitly if your mailbox is personal and shares the sign-in app; inference is a migration path, not a substitute. The O365 sender is always the connected mailbox — there is no from_name? here as there is for smtp; Graph never receives a from |
The o365_oauth connect flow (authorization-code consent that first populates
account_home_id + token_cache) lives in the consuming app's admin UI;
SCOPES and msalClient are exported for it.
API
configure({ getStore, logger?, appName? })— REQUIRED once, before anything elseinit()→ boolean — init both providers, select active fromemail_providersendMail({ to, subject, html, attachments?, label? })→{success, accepted, rejected, response, messageId}or{success:false, error}; lazy re-init if unconfigured at bootrefresh()— re-read config after the admin changes it (resets MSAL client)testSmtp(to?)→{ ok, steps[], error?, code?, hint? }— real connect/auth (+ optional test send)describe()/getActiveKind()— status for admin pagesbuildTransport(smtpRow)— raw nodemailer transport (used by IMAP-adjacent app code)SCOPES,msalClient,providers.smtp,providers.o365validateAppRegistration({ accountType, tenantId, clientSecret })→{ ok: true }or{ ok: false, code, message }— validate a proposed App Registration configuration before saving it (pure, no store/MSAL); see Account types belowACCOUNT_TYPES→['work', 'personal']isConsumersTenant(tenant)→boolean(0.6.2) — is this Microsoft's consumers tenant, by theconsumersalias or its well-known GUID? Case- and whitespace-insensitive.falseforcommon, which also admits organisational users. Exported so an admin form can ask the same question the inference asks, instead of hard-coding the GUIDCONSUMERS_TENANT_GUID(0.6.2) →'9188040d-6c67-4c5b-b112-36a304b66dad'saveAppRegistration({ mode, accountType, clientId, tenantId, redirectUri, submittedSecret })(0.8.0) →{ ok: true, mode, accountType, changed, hadConnection, disconnected, resolved, fromAddress }or{ ok: false, code, message }— apply an admin "Save" of the O365 App Registration. Writesentra_email+o365_oauth, detects whether the effective app changed, and clears a dead mailbox connection when it did. Values in, values out: never touchesreq/res, so your app keeps its permission gate, flash, audit, logging and redirect. Nothing is written if it returnsok: false. Five rules it owns, each of which cost a real debugging session before it existed: blank-secret-means-unchanged (forseparate+workonly;sharedandpersonaldeliberately clear it);accountTyperesolved before the id gate; validation against the effective credentials (theentrarow insharedmode, not the submitted fields); change detected on the resolved identity, so amodetoggle between two entries naming the same app — and a secret-only rotation — do not disconnect; and disconnect only onchanged && hadConnection. Extra error code beyondvalidateAppRegistration's:O365_SEPARATE_IDS_REQUIREDappRegistrationIdentityKey(describeAppRegistrationResult)→string | null(0.6.2) — comparable identity of the App Registration email sends through. Capture before and after a config write; a changed key means the mailbox connection must be dropped, because a refresh token issued by the old app can never work again. Twonulls compare equal (nothing was in use either side, so nothing became invalid); mode, source and the secret are deliberately not part of it, so rotating a secret does not drop a working mailbox. PureEMAIL_STORE_SCHEMA(0.7.0) — all four SecureStore rows this package reads and owns (smtp,email_provider,entra_email,o365_oauth), frozen, for apps to spread into their own schema. Spread it FIRST so the app's own row declarations win any future name collision:{ ...EMAIL_STORE_SCHEMA, ...myRows }. Every flag in it fails silently when hand-copied wrong, andsmtp.password: { encrypted: true }is the worst of them — omitting it is completely symptomless: mail still sends, nothing logs, and a live SMTP credential sits in the database in plaintext. Does not includeentra, which belongs to staff sign-in (consumers own fields on it this package knows nothing about, and it is destined for@aria-framework/auth)O365_STORE_SCHEMA(0.6.2) — the narrower subset (entra_email,o365_oauth), kept for consumers already spreading it. Derived fromEMAIL_STORE_SCHEMA, not a second literal, so the two can never drift
Error codes
O365_RECONSENT_REQUIRED (token cache dead — surface a Reconnect button),
O365_ENTRA_NOT_CONFIGURED, O365_ATTACHMENT_TOO_LARGE (>3 MB Graph inline
limit).
Admin-flow logic — oauth (since 0.3.0)
The intricate, security-sensitive half of the "connect a Microsoft 365 mailbox" admin flow, shared so it can't drift between apps. The package owns the OAuth mechanics; each app keeps its own settings route/view/menu, permission gate, audit, flash, and session — it just calls these instead of hand-rolling MSAL.
// connect (GET): build the consent URL
const { url, state, verifier } = await email.oauth.buildConnectUrl({
redirectUri: `${base}/admin/email/o365/callback`
// pkce is deprecated — PKCE is now AUTOMATIC and MANDATORY whenever the
// resolved account type is 'personal' (see Account types below); no
// consumer needs to pass it any more.
});
req.session.o365State = state; // the app owns where the nonce lives
req.session.o365Verifier = verifier; // present whenever PKCE was used
// callback (GET): verify state + exchange code
const { accountUsername, accountHomeId } = await email.oauth.completeConnect({
code: req.query.code,
redirectUri: `${base}/admin/email/o365/callback`,
expectedState: req.session.o365State, // state compare lives IN the package
actualState: req.query.state, // so an app can't forget it
verifier: req.session.o365Verifier // REQUIRED for a personal account
});
// completeConnect() already persisted account_username/account_home_id to the
// o365_oauth row itself (see 0.4.0 below) — do NOT also save them here. Doing
// so from a pre-exchange snapshot wipes the token_cache the cache plugin just
// wrote during the exchange (the bug the 0.4.0 fix exists to prevent).
email.refresh();
// disconnect: clears token_cache + account identity + resets the client
email.oauth.disconnect();buildConnectUrl/completeConnect never touch req/res/session — plain values
in, plain values out. Token-cache persistence stays automatic (msalClient's
cache plugin writes the o365_oauth row). Error codes: O365_STATE_MISMATCH,
O365_ENTRA_NOT_CONFIGURED, O365_PKCE_REQUIRED (0.6.0 — a personal
account's completeConnect was called with no verifier; no exchange is
attempted).
The mandatory-PKCE guarantee covers only consumers using these two
helpers. A consumer with its own connect flow that calls msalClient
directly instead of buildConnectUrl/completeConnect — Acc101 does exactly
this, it does not call buildConnectUrl at all — must implement PKCE itself;
this package cannot enforce a rule inside code it never runs.
Changelog
0.8.0 — the App Registration save policy moves into the package. It was ~190 lines duplicated between Support101 (
routes/admin.js) and Acc101 (routes/settings.js) — the second ported from the first, carrying across in its comments the scar of a bug the first had already fixed. Support101 needed four review rounds and one self-inflicted contradiction to land these rules; a third hand-written copy would get one wrong. NewsaveAppRegistration(see above). The ORDER of its steps is the policy, not an implementation detail:accountTypeis resolved before the id gate because resolving it after made the gate demand a secret for anyseparatesave — rejecting a validseparate+personalsave, which has no secret by definition, beforeaccountTypewas even known. The module says so where a future reader would be tempted to tidy it, and the test suite reproduces that exact failure when the ordering is reverted.refreshis injected byindex.jsrather than imported insidelib/: that file cannot require the index without a cycle, and callingo365Provider.refresh()alone would skip the active-provider re-selectioninit()does. Deliberately NOT promoted: whetherredirect_urimay be blank. Acc101 rejects an empty one and Support101 does not; both are defensible, the schema permits'', and promoting either would silently change the other's behaviour. MINOR, same reasoning as 0.7.0 — new API surface, nothing urgent, and a consumer must change code to benefit.0.7.0 — finish the store-schema fragment. 0.6.2 exported the two O365 rows and left
smtpandemail_providerhand-transcribed in every consumer — fixing part of a class while reading as though the class were closed. Both were byte-identical in the two known consumers. NewEMAIL_STORE_SCHEMAcovers all four rows this package reads;O365_STORE_SCHEMAremains as the derived subset.smtpis the row that most needed this:providers/smtp.jsreads it directly and depends on the field names, onpasswordarriving decrypted, and onsecurebeing the string'true'/'false'(SecureStore fields are text-only). Omittingencrypted: trueonpasswordproduces no symptom at any layer — mail still sends while the credential sits in plaintext. Every other flag in this class eventually misbehaves visibly; that one never does.entrastays excluded: it is sign-in's row, consumers declare fields on it this package does not know about, and it belongs to a future@aria-framework/auth. MINOR, not a patch — deliberately, and the contrast with 0.6.2 is the point. 0.6.2 carried a silent-misclassification fix that had to reach the next consumer without anyone editing a manifest, which is what justified additive exports riding a patch. Nothing here is urgent, and a consumer must change code to benefit anyway, so requiring a^0.7.0bump costs nothing it was not already paying. The patch channel stays reserved for changes that must arrive unasked.0.6.2 — fix: a personal mailbox sharing a sign-in App Registration was inferred as
work, building a confidential client that logs a healthy "provider initialized" and fails only on send. Found by a second consumer (Acc101) on 0.6.1:describeAppRegistration()returned{source:'shared', tenantId:'9188040d-…', accountType:'work'}for a personal mailbox. Two independent causes, both inresolveAccountType's legacy inference:hasSecretoutranked the tenant. Insharedmode the secret comes from theentrarow — the sign-in app's secret — and any app doing the server-side OIDC code flow necessarily has one. It says nothing about the mailbox. The consumers tenant serves personal Microsoft accounts and nothing else, so it is the stronger signal and now wins.- The consumers GUID was not recognised. The check tested only the literal
'consumers'/'common', so a row holding9188040d-6c67-4c5b-b112-36a304b66dad— the same tenant — inferredworkeven with no secret.msalClientnow also warns when it sees the contradictory combination (consumers tenant + a secret + no explicitaccount_type), so this is diagnosable at boot rather than at send. The warning is suppressed onceaccount_typeis set explicitly. Behaviour change, shipped as a patch on purpose.^0.6.1accepts a patch but not0.7.0, and this fix needs to reach the next consumer without a manifest edit in every app. Nothing observable to an existing caller changed: no signatures, no removals. The only altered result is for a configuration that cannot currently send at all (a confidential client againstconsumers), so no install can be relying on it. This is not the 0.3.2 mistake — that was a breaking change in a patch. Also corrected: this module's own docblock claimed thecommoninference branch existed to protect Acc101's live install. That was wrong on two counts — their tenant is the GUID, and their row carries a secret — so the branch could never have fired for them. The branch is kept on its own merits (a genuinely secret-less legacy row) and the false rationale is gone. New exports:isConsumersTenant,CONSUMERS_TENANT_GUID,appRegistrationIdentityKey(promoted on the two-consumer rule — both apps held byte-identical copies),O365_STORE_SCHEMA. All additive.
0.6.1 — fix: the legacy account-type inference could misclassify a half-configured
workinstall aspersonal, building a public client against an organisational tenant. A consuming app can legitimately save an App Registration's Client ID and Tenant ID before pasting the secret. In 0.6.0,resolveAccountType()inferredpersonalfor ANY secret-less row, regardless of tenant — so that in-progressworkstate got aPublicClientApplicationbuilt against an org tenant instead of failing closed withO365_ENTRA_NOT_CONFIGURED(the pre-0.6.0 behaviour). This is exactly the downgrademsalClient.js's own header docblock warns against: "Never build a confidential client forpersonal, or a public one forworkwithout also threading a PKCE verifier ... AND re-registering the redirect URI under a public-client platform." The inference now also considers the tenant: with no secret,consumers/commonstill inferpersonal(protecting Acc101's live personal-account install, stored with tenantcommon— the reason this inference exists at all), but any other tenant — an org GUID, a verified domain, or none at all — now inferswork, sogetClient()'s existingwork && !clientSecret -> nullrule fails it closed as before. This is a deliberate asymmetry: inference stays permissive (acceptscommon) for legacy rows;validateAppRegistrationstays strict (consumersonly) for new configs — not relaxed to match.resolveAccountType()'s signature gained an optionaltenantargument;ACCOUNT_TYPESandvalidateAppRegistrationare unchanged.- fix:
describeIds()didn't actually apply the fail-closed rule its own docblock claimed.getClient()returnsnullfor aworkconfig missing its secret;describeIds()only checked!ids, so the same unusable config looked "configured" to admin status text and to the change-detection that compares it — reporting healthy when a send would fail. Both functions now go through one shared_isUsable()helper so they can't drift apart again (a previous review of this file flagged exactly this duplication risk). - docs: removed a stale code line from the Admin-flow sample that
duplicated
store.save('o365_oauth', {...account_username...})aftercompleteConnect()—completeConnect()has persisted that identity itself since 0.4.0, and the sample's own changelog entry a few lines above says to delete exactly that call; corrected the package description's claim that O365 always uses a public client (conditional since 0.6.0); and removedfrom_name?from theo365_oauthstore-contract row (nothing on the O365 path reads it —graphSendMail.jsnever sends afromat all; the O365 sender is always the connected mailbox).
- fix:
0.6.0 — account types:
workvspersonal, and mandatory automatic PKCE forpersonal. O365 email now recognises two kinds of Microsoft account, each a whole coherent bundle rather than three independently-set knobs:work— an organisational tenant.ConfidentialClientApplicationwith a requiredclient_secret;tenant_ida GUID or verified domain. This is 0.3.2's always-confidential rule, now named and made explicit rather than the package's only supported shape.personal— a personal Microsoft account.PublicClientApplicationwith no secret;tenant_idmust be'consumers'. 0.3.2's always-confidential rule broke this case outright: a personal account has no client secret to give, so every connect attempt failed.msalClientnow builds the right MSAL class for the resolved type (lib/accountType.jsholds the pure rules;describeIds()/getClient()apply them).
Legacy inference, so no install breaks on upgrade: an
o365_oauthrow with noaccount_type(every install configured before this version) is inferred from whether a secret is currently stored — secret present →work, absent →personal. That is observably correct for every install running today; the first explicit save ofaccount_typereplaces the inference for good. An unrecognised stored value degrades to the same inference rather than being rejected, so a malformed row can't stop email outright.PKCE is now automatic and MANDATORY for
personal, not opt-in. A public client's only proof of identity is PKCE — a public client with neither a secret nor PKCE is exactly theAADSTS7000218failure that started this whole line of work (see 0.3.2 below).buildConnectUrlnow reads the account type viamsalClient.describeIds()and adds an S256 challenge whenever it resolves topersonal, with no caller action required; thepkceparameter still works (forces a challenge forworktoo) but is deprecated — no consumer passes it.completeConnectfails closed: apersonalaccount with noverifierthrowsO365_PKCE_REQUIREDand performs no token exchange at all, rather than ever reaching Azure without either proof. This guarantee covers only consumers usingbuildConnectUrl/completeConnect— a consumer with its own connect flow that talks tomsalClientdirectly (Acc101 does exactly this) is outside it and must implement PKCE itself.New validation export:
validateAppRegistration({ accountType, tenantId, clientSecret })→{ ok: true }or{ ok: false, code, message }, for an app to validate an admin's proposed configuration before saving it. Error codes:O365_ACCOUNT_TYPE_INVALID,O365_WORK_SECRET_REQUIRED,O365_WORK_TENANT_INVALID,O365_PERSONAL_TENANT_INVALID,O365_PERSONAL_SECRET_UNEXPECTED(a personal account's secret is rejected, not silently ignored — a live credential at rest for nothing, and an admin who'd wrongly believe it matters). Also exported:ACCOUNT_TYPES→['work', 'personal'](frozen — a consumer mutating it can't corrupt the shared module singleton for other callers in the process).0.5.1 — fix: an undeclared
entra_emailschema no longer throws out of every send/connect.getClient()/describeIds()calledstore.exists('entra_email')unconditionally;@aria-framework/secure-keystore'sexists()throwsUnknown credential type: entra_emailfor a type the consumer never declared in its own schema. A consumer that upgrades to 0.5.0 without adding the row (e.g. a second consumer still tracking^0.3.x, or anyone who just bumps the manifest without reading the migration notes) got that raw error out of every O365 send and connect instead of the documentedO365_ENTRA_NOT_CONFIGURED._readEntraIds()now degrades to the sharedentrarow whenentra_emailisn't a declared type — exactly the existing "any mode other thanseparate" rule, just triggered by a missing schema entry instead of a missing/wrongmodevalue. A genuinely incompleteseparateconfig still fails closed tonull; only "the store doesn't know this type at all" degrades. Also:_readEntraIds()now returns the resolvedsource('shared'/'separate') alongside the ids sodescribeIds()no longer re-derives "is separate active" on its own (one place decides); fixedindex.jsrequiring./lib/msalClienttwice (top-level const + inline inmodule.exports); corrected the header docblock, which still described ids as coming only fromentrawith no mention ofentra_emailor fail-closed.0.5.0 — optional separate App Registration for email. New store row
entra_email: { mode: 'shared'|'separate', client_id, tenant_id, client_secret }. With no row, or anymodeother than the exact string'separate', behaviour is unchanged (theentrarow is used), so this is backward compatible. Withmode: 'separate', email uses those credentials instead — lettingMail.Sendbe held by a different Azure app than staff sign-in, and letting an operator grant email admins full control of email setup without the sign-in App Registration. An incompleteseparateconfig fails closed (getClient()→null→O365_ENTRA_NOT_CONFIGURED); it deliberately does NOT fall back to the sign-in app, because that would use credentials the admin explicitly opted out of. NewdescribeAppRegistration()returns{ source: 'shared'|'separate', clientId, tenantId }(never the secret) for admin UIs and for detecting that the effective app changed. Note the memoisation key already covers client/tenant/secret, so switching apps rebuilds the client automatically.0.4.0 —
oauth.completeConnect()now persists the account identity itself. BREAKING-ish contract change: callers must stop writingaccount_username/account_home_idto theo365_oauthrow. Why: that row has two writers — msalClient's cache plugin (token_cache) and whoever saves the account identity. An app naturally loads the row before the exchange (it needsredirect_urifrom it), then merges and saves after — which silently wipes thetoken_cachethe plugin wrote during the exchange. The row is left withaccount_home_idbut notoken_cache, soinit()reportsready: falsewhile the connect logs success. That split failure is deeply confusing in the logs ("Microsoft 365 connected as …" immediately followed by "provider 'o365' not ready") and it cost a real debug session. The package now owns the whole row: one writer, one merge, loaded fresh after MSAL has awaited its cache plugin.oauth.smoke.jsgained a regression guard whose fakeacquireTokenByCodewritestoken_cachemid-exchange, then asserts both it and the account identity survive. Migration: delete yourstore.save('o365_oauth', {…account_username…})call from the OAuth callback. Keeping it is only safe if it re-loads the row aftercompleteConnect; leaving a pre-exchange snapshot in place reintroduces the bug.0.3.2 — O365 now uses a
ConfidentialClientApplication. It was aPublicClientApplicationthat never readclient_secret, while consumers calloauth.buildConnectUrl()withoutpkce— so the authorization-code exchange reached Azure with neither a secret nor a PKCE proof and was rejected withAADSTS7000218("must contain client_assertion or client_secret"). O365 outbound could therefore never have worked against a real tenant. It went unnoticed because the App Registration sits under the Web platform (so staff sign-in, which builds its own confidential client, worked fine) and becauseoauth.smoke.jsstubsmsalClient.getClientwholesale — nothing exercised client construction.client_secretis now required: a missing one returnsnull, surfacing the caller'sO365_ENTRA_NOT_CONFIGUREDinstead of an opaque Azure error one hop later. It is also part of the memoisation key, so a rotated secret rebuilds the client. Newtest/msalClient.smoke.jsstubs@azure/msal-nodethrough the require cache and asserts both the constructor used and theauthconfig passed. No consumer code changes needed:acquireTokenByCodeandacquireTokenSilentbehave identically on a confidential client, and no Azure change is needed beyond registering the O365 callback redirect URI.0.3.1 —
sendMail's failure result now carries the provider errorcode({success:false, error, code}) so apps can map operator guidance onto their OWN navigation instead of this package's app-agnostic text; theO365_ENTRA_NOT_CONFIGUREDmessage itself de-app-ified ("Set it up in the Entra ID settings first" — the old "under Login & SSO" was Support101's nav and misdirected other consumers).0.3.0 — added
oauthadmin-flow helpers (buildConnectUrl/completeConnect/disconnect): the shared, security-sensitive O365 mailbox-connect dance, extracted so it stops being duplicated per app. Views, menus, permission names, audit, and the session state store stay app-side (the two apps' settings UIs diverge). PKCE is opt-in. No API removed.0.2.1 — msalClient: token-cache persistence is now serialised on a write lock (ported from Acc101). The persist is a read-modify-write of the
o365_oauthrow, so two concurrent refreshes (scheduler send + interactive send) could interleave and clobber a freshly rotated refresh token, forcing a reconsent. Failed persists log and keep the lock chain alive.0.2.0 — log tag is
envelope.labelonly (thetemplateKeyfallback was unreachable and baked one app's vocabulary into shared code — apps map their own field ontolabel); removed the unusedisConfiguredexport (configure()-before-use is enforced bycfg()throwing).0.1.0 — first release. Extracted from Support101/Acc101 (
lib/email-sender.jsdispatcher core +lib/email-providers/+lib/o365/); hardwired app requires replaced byconfigure()injection; app-specific strings ("Support101") replaced byappName; ticket/DB envelope building left in the app.
Inbound (0.9.0)
Intake, as a mirror of outbound: outbound is providers.smtp | providers.o365,
inbound is imap | o365.
const email = require('@aria-framework/email');
email.inbound.start({
onMessage: async (msg) => {
// msg: { id, messageId, rawDate, from, to, cc, subject, text, html,
// attachments[], headers, inReplyTo, references[], flags }
if (msg.flags.isLoop) return { retry: false, reason: 'auto-reply' };
if (!msg.attachments.length) return { retry: false, reason: 'no document' };
await storeInvoice(msg); // throw, or return { retry: true }, to try again
}
});The package does not know what a message means. It delivers a normalised message and acts on the verdict:
| return | effect |
|---|---|
| undefined (or anything else) | handled — mark read |
| { retry: false, reason } | rejected deliberately — mark read, log the reason |
| { retry: true, reason } | leave unread, retry next poll |
| handler throws | same as { retry: true } |
Marking read happens only AFTER the handler accepts. A database blip or a restart mid-batch costs a retry, never a message.
The message shape
text/html and attachments are peers — one consumer's payload is the body,
another's is the attachment. Bodies arrive raw: truncation and HTML-stripping are
app policy. Threading headers are exposed but never acted on. Loop/auto-reply
classification is reported in flags, not enforced, so a consumer can record why
it dropped something.
rawDate is the Date header, not parsed.date — mailparser substitutes the parse
clock for a missing header, which would change a Message-ID-less fingerprint on every
poll and silently defeat a consumer's dedupe.
Two providers: imap and o365
Selected by the inbound row (provider, folder, enabled). With no inbound
row the dispatcher falls back to "imap if the imap row is enabled" — the pre-0.9.0
behaviour — so an existing consumer upgrades without touching configuration.
IMAP is for generic mailboxes, not Microsoft 365. O365 uses Graph, which reuses the
same MSAL client, token cache and connected account as outbound, provides a real folder
tree via /me/mailFolders, and adds no dependency. IMAP-with-XOAUTH2 would need a
different Azure resource, a second consent, and — for app-only — an Exchange service
principal per mailbox.
imapflow and mailparser are optional peers, required lazily. A Graph-only
consumer installs neither.
Microsoft 365 inbound consent is separate and opt-in
INBOUND_SCOPES (Mail.ReadWrite) is not folded into SCOPES. Widening the
outbound set would fail acquireTokenSilent for every already-connected mailbox, so
every consumer would stop sending on upgrade — for a feature they may never enable.
if (await email.inbound.needsInboundConsent()) {
const { url, state } = await email.oauth.buildConnectUrl({
redirectUri, scopes: email.INBOUND_SCOPES // same account, extra grant
});
// ...callback: email.oauth.completeConnect({ ..., scopes: email.INBOUND_SCOPES })
}Until that grant exists, inbound reports O365_INBOUND_CONSENT_REQUIRED and sending
keeps working throughout — the message says so, because an admin told to "reconnect"
would reasonably fear breaking the half that works.
Mail.ReadWrite rather than Mail.Read: setting isRead needs write, and marking read
only after the handler accepts is what makes intake lossless.
Only fileAttachment becomes an attachment. itemAttachment (an embedded message) and
referenceAttachment (a OneDrive link) carry no bytes — treating them as files would
hand a consumer empty buffers that look like real documents.
Intake pauses on failures that retrying cannot fix
A wrong password on a 30-second timer is ~120 failed logins an hour. Mail hosts commonly run fail2ban or CSF and ban the source IP after a handful — and that ban presents as every mail port silently timing out while HTTPS stays up, i.e. exactly like a network fault. The app breaks itself, shows the wrong symptom, and then appears to recover when the ban expires.
So a rejected credential (IMAP) or a missing consent / 401 / 403 (Graph) stops the
loop and is reported through status():
const s = email.inbound.status();
// { kind, running, paused, pausedReason, pausedCode, hint }A refused connection, timeout, DNS failure, 429 or 5xx does not pause — those are genuinely transient and retrying is the correct response. Pausing on them would turn a blip into an outage needing manual intervention.
Resume with refresh() (what an app calls when an admin saves inbound settings) or an
explicit start(). Pausing is deliberately loud and inspectable, because the failure
mode of pausing wrongly is "intake stopped and nobody noticed".
Other calls
inbound.listFolders()→[{ path, name, delimiter, depth, specialUse }]for a pickerinbound.testConnection()→{ ok, steps[], error?, hint? }inbound.status()→{ kind, running, paused, pausedReason, hint }inbound.needsInboundConsent()→boolean(o365)inbound.stop()/inbound.refresh()/inbound.poll()/inbound.isEnabled()
