npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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/sendMail with a delegated token from msal-node (confidential client for work, public client for personal — 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/email
const 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 else
  • init() → boolean — init both providers, select active from email_provider
  • sendMail({ to, subject, html, attachments?, label? }){success, accepted, rejected, response, messageId} or {success:false, error}; lazy re-init if unconfigured at boot
  • refresh() — 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 pages
  • buildTransport(smtpRow) — raw nodemailer transport (used by IMAP-adjacent app code)
  • SCOPES, msalClient, providers.smtp, providers.o365
  • validateAppRegistration({ 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 below
  • ACCOUNT_TYPES['work', 'personal']
  • isConsumersTenant(tenant)boolean (0.6.2) — is this Microsoft's consumers tenant, by the consumers alias or its well-known GUID? Case- and whitespace-insensitive. false for common, which also admits organisational users. Exported so an admin form can ask the same question the inference asks, instead of hard-coding the GUID
  • CONSUMERS_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. Writes entra_email + o365_oauth, detects whether the effective app changed, and clears a dead mailbox connection when it did. Values in, values out: never touches req/res, so your app keeps its permission gate, flash, audit, logging and redirect. Nothing is written if it returns ok: false. Five rules it owns, each of which cost a real debugging session before it existed: blank-secret-means-unchanged (for separate+work only; shared and personal deliberately clear it); accountType resolved before the id gate; validation against the effective credentials (the entra row in shared mode, not the submitted fields); change detected on the resolved identity, so a mode toggle between two entries naming the same app — and a secret-only rotation — do not disconnect; and disconnect only on changed && hadConnection. Extra error code beyond validateAppRegistration's: O365_SEPARATE_IDS_REQUIRED
  • appRegistrationIdentityKey(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. Two nulls 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. Pure
  • EMAIL_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, and smtp.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 include entra, 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 from EMAIL_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.0the 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. New saveAppRegistration (see above). The ORDER of its steps is the policy, not an implementation detail: accountType is resolved before the id gate because resolving it after made the gate demand a secret for any separate save — rejecting a valid separate+personal save, which has no secret by definition, before accountType was 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. refresh is injected by index.js rather than imported inside lib/: that file cannot require the index without a cycle, and calling o365Provider.refresh() alone would skip the active-provider re-selection init() does. Deliberately NOT promoted: whether redirect_uri may 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.0finish the store-schema fragment. 0.6.2 exported the two O365 rows and left smtp and email_provider hand-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. New EMAIL_STORE_SCHEMA covers all four rows this package reads; O365_STORE_SCHEMA remains as the derived subset. smtp is the row that most needed this: providers/smtp.js reads it directly and depends on the field names, on password arriving decrypted, and on secure being the string 'true'/'false' (SecureStore fields are text-only). Omitting encrypted: true on password produces 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. entra stays 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.0 bump costs nothing it was not already paying. The patch channel stays reserved for changes that must arrive unasked.

  • 0.6.2fix: 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 in resolveAccountType's legacy inference:

    1. hasSecret outranked the tenant. In shared mode the secret comes from the entra row — 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.
    2. The consumers GUID was not recognised. The check tested only the literal 'consumers'/'common', so a row holding 9188040d-6c67-4c5b-b112-36a304b66dad — the same tenant — inferred work even with no secret. msalClient now also warns when it sees the contradictory combination (consumers tenant + a secret + no explicit account_type), so this is diagnosable at boot rather than at send. The warning is suppressed once account_type is set explicitly. Behaviour change, shipped as a patch on purpose. ^0.6.1 accepts a patch but not 0.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 against consumers), 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 the common inference 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.1fix: the legacy account-type inference could misclassify a half-configured work install as personal, 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() inferred personal for ANY secret-less row, regardless of tenant — so that in-progress work state got a PublicClientApplication built against an org tenant instead of failing closed with O365_ENTRA_NOT_CONFIGURED (the pre-0.6.0 behaviour). This is exactly the downgrade msalClient.js's own header docblock warns against: "Never build a confidential client for personal, or a public one for work without 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/common still infer personal (protecting Acc101's live personal-account install, stored with tenant common — the reason this inference exists at all), but any other tenant — an org GUID, a verified domain, or none at all — now infers work, so getClient()'s existing work && !clientSecret -> null rule fails it closed as before. This is a deliberate asymmetry: inference stays permissive (accepts common) for legacy rows; validateAppRegistration stays strict (consumers only) for new configs — not relaxed to match. resolveAccountType()'s signature gained an optional tenant argument; ACCOUNT_TYPES and validateAppRegistration are unchanged.

    • fix: describeIds() didn't actually apply the fail-closed rule its own docblock claimed. getClient() returns null for a work config 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...}) after completeConnect()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 removed from_name? from the o365_oauth store-contract row (nothing on the O365 path reads it — graphSendMail.js never sends a from at all; the O365 sender is always the connected mailbox).
  • 0.6.0account types: work vs personal, and mandatory automatic PKCE for personal. O365 email now recognises two kinds of Microsoft account, each a whole coherent bundle rather than three independently-set knobs:

    • work — an organisational tenant. ConfidentialClientApplication with a required client_secret; tenant_id a 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. PublicClientApplication with no secret; tenant_id must 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. msalClient now builds the right MSAL class for the resolved type (lib/accountType.js holds the pure rules; describeIds()/getClient() apply them).

    Legacy inference, so no install breaks on upgrade: an o365_oauth row with no account_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 of account_type replaces 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 the AADSTS7000218 failure that started this whole line of work (see 0.3.2 below). buildConnectUrl now reads the account type via msalClient.describeIds() and adds an S256 challenge whenever it resolves to personal, with no caller action required; the pkce parameter still works (forces a challenge for work too) but is deprecated — no consumer passes it. completeConnect fails closed: a personal account with no verifier throws O365_PKCE_REQUIRED and performs no token exchange at all, rather than ever reaching Azure without either proof. This guarantee covers only consumers using buildConnectUrl/ completeConnect — a consumer with its own connect flow that talks to msalClient directly (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.1fix: an undeclared entra_email schema no longer throws out of every send/connect. getClient()/describeIds() called store.exists('entra_email') unconditionally; @aria-framework/secure-keystore's exists() throws Unknown credential type: entra_email for 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 documented O365_ENTRA_NOT_CONFIGURED. _readEntraIds() now degrades to the shared entra row when entra_email isn't a declared type — exactly the existing "any mode other than separate" rule, just triggered by a missing schema entry instead of a missing/wrong mode value. A genuinely incomplete separate config still fails closed to null; only "the store doesn't know this type at all" degrades. Also: _readEntraIds() now returns the resolved source ('shared'/'separate') alongside the ids so describeIds() no longer re-derives "is separate active" on its own (one place decides); fixed index.js requiring ./lib/msalClient twice (top-level const + inline in module.exports); corrected the header docblock, which still described ids as coming only from entra with no mention of entra_email or fail-closed.

  • 0.5.0optional separate App Registration for email. New store row entra_email: { mode: 'shared'|'separate', client_id, tenant_id, client_secret }. With no row, or any mode other than the exact string 'separate', behaviour is unchanged (the entra row is used), so this is backward compatible. With mode: 'separate', email uses those credentials instead — letting Mail.Send be 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 incomplete separate config fails closed (getClient()nullO365_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. New describeAppRegistration() 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.0oauth.completeConnect() now persists the account identity itself. BREAKING-ish contract change: callers must stop writing account_username / account_home_id to the o365_oauth row. 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 needs redirect_uri from it), then merges and saves after — which silently wipes the token_cache the plugin wrote during the exchange. The row is left with account_home_id but no token_cache, so init() reports ready: false while 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.js gained a regression guard whose fake acquireTokenByCode writes token_cache mid-exchange, then asserts both it and the account identity survive. Migration: delete your store.save('o365_oauth', {…account_username…}) call from the OAuth callback. Keeping it is only safe if it re-loads the row after completeConnect; leaving a pre-exchange snapshot in place reintroduces the bug.

  • 0.3.2O365 now uses a ConfidentialClientApplication. It was a PublicClientApplication that never read client_secret, while consumers call oauth.buildConnectUrl() without pkce — so the authorization-code exchange reached Azure with neither a secret nor a PKCE proof and was rejected with AADSTS7000218 ("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 because oauth.smoke.js stubs msalClient.getClient wholesale — nothing exercised client construction. client_secret is now required: a missing one returns null, surfacing the caller's O365_ENTRA_NOT_CONFIGURED instead of an opaque Azure error one hop later. It is also part of the memoisation key, so a rotated secret rebuilds the client. New test/msalClient.smoke.js stubs @azure/msal-node through the require cache and asserts both the constructor used and the auth config passed. No consumer code changes needed: acquireTokenByCode and acquireTokenSilent behave identically on a confidential client, and no Azure change is needed beyond registering the O365 callback redirect URI.

  • 0.3.1sendMail's failure result now carries the provider error code ({success:false, error, code}) so apps can map operator guidance onto their OWN navigation instead of this package's app-agnostic text; the O365_ENTRA_NOT_CONFIGURED message 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 oauth admin-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_oauth row, 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.label only (the templateKey fallback was unreachable and baked one app's vocabulary into shared code — apps map their own field onto label); removed the unused isConfigured export (configure()-before-use is enforced by cfg() throwing).

  • 0.1.0 — first release. Extracted from Support101/Acc101 (lib/email-sender.js dispatcher core + lib/email-providers/ + lib/o365/); hardwired app requires replaced by configure() injection; app-specific strings ("Support101") replaced by appName; 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 picker
  • inbound.testConnection(){ ok, steps[], error?, hint? }
  • inbound.status(){ kind, running, paused, pausedReason, hint }
  • inbound.needsInboundConsent()boolean (o365)
  • inbound.stop() / inbound.refresh() / inbound.poll() / inbound.isEnabled()