Mail IMAP Server
IMAP4rev2 mailbox-access listener (RFC 9051; obsoletes RFC 3501). Modern MUAs (Thunderbird, Apple Mail, mutt, K-9, FairEmail, etc.) connect here to read + manage messages without operators running dovecot / cyrus alongside. Composes the framework's existing substrates:
- b.guardImapCommand for wire-protocol shape + smuggling defense (literal-injection, bare-CR/LF refusal, per-verb shape, RFC 9051 §2.2.2 literal framing) - b.mail.server.rateLimit for per-IP DoS defense (concurrent + rate + AUTH-failure budget + slow-loris) - b.mailStore (operator-supplied backend) for the actual mail storage + UIDVALIDITY + modseq tracking - operator-supplied authenticator for SASL credential verify - b.mail.server.tls recommended for cert + key loading + rotation
## State machine (RFC 9051 §3)
NOT-AUTHENTICATED → [STARTTLS → NOT-AUTH-TLS] → AUTH/LOGIN →
AUTHENTICATED ↔ SELECTED → LOGOUT
↑ EXAMINE ↓ CLOSE / UNSELECT
Commands gated by state:
- NOT-AUTHENTICATED: STARTTLS / AUTHENTICATE / LOGIN / NOOP / CAPABILITY / LOGOUT / ID - AUTHENTICATED: SELECT / EXAMINE / CREATE / DELETE / RENAME / SUBSCRIBE / UNSUBSCRIBE / LIST / STATUS / APPEND / NAMESPACE / IDLE / ENABLE / NOOP / CAPABILITY / LOGOUT / ID - SELECTED: CHECK / CLOSE / UNSELECT / EXPUNGE / SEARCH / FETCH / STORE / COPY / MOVE / UID … / IDLE / NOOP / CAPABILITY / LOGOUT + every AUTHENTICATED command
Tagged response model: every client command carries a tag (A001 LOGIN …); server replies with one or more untagged responses (* …) then A001 OK … / A001 NO … / A001 BAD ….
## Wire-protocol defenses
- **STARTTLS stripping (CVE-2021-33515 Dovecot class)** — STARTTLS upgrade clears pre-handshake receive buffer; any pipelined command queued before TLS is refused with BAD Pipelined post-STARTTLS not permitted.
- **Literal-injection / command-continuation smuggling** — {n} literal continuation MUST come on a line of its own (per b.guardImapCommand.detectLiteralSmuggling); oversize literals refused (default 64 MiB); LITERAL+ (RFC 7888) non- synchronizing literals only honored post-AUTH.
- **Mailbox-name traversal** — mailbox path components validated through _validateMailboxName: refuses .., NUL, control chars, oversize. UTF-8 mailbox names (RFC 9051 §5.1) accepted; modified-UTF7 (RFC 3501 §5.1.3 legacy) refused unless allowLegacyMUtf7: true.
- **APPEND-flood** — per-tenant byte/sec cap surfaces via the b.mail.server.rateLimit's minBytesPerSecond floor on the APPEND-literal-body phase (same shape the MX listener uses for DATA-body).
- **Resource exhaustion** — per-line cap (default 8 KiB sans literal payload), per-literal cap (64 MiB), per-connection idle cap (default 30 min when not in IDLE; IDLE itself capped at 29 min per RFC 2177 §3 to force re-issue).
- **Connection-rate + AUTH-failure budget** — composes b.mail.server.rateLimit. Each AUTH failure increments the budget; trip the cap and new AUTH attempts get * BAD Too many AUTH failures + connection close.
## Audit lifecycle
mail.server.imap.connect— IP, TLS statemail.server.imap.auth_attempt— mechanism, actor-hashmail.server.imap.auth_success— mechanism, tenantId, scopesmail.server.imap.auth_failed— mechanism, reasonmail.server.imap.select— mailbox, modseq, exists countmail.server.imap.append— mailbox, size, flagsmail.server.imap.fetch_bulk— sequence-set size, BODY partsmail.server.imap.expunge— count, modseqmail.server.imap.literal_overflow_refused— attempt size, capmail.server.imap.rate_limit_refused— IP, reasonmail.server.imap.smtp_smuggling_detected— literal-injection
## What v1 does NOT ship
- **SEARCH / COPY / MOVE** — operator-domain logic against the mailStore index, supplied through opts.overrides; until then the listener answers NO . A handler supplied once serves BOTH the plain and the UID form: the UID verb dispatches back through the same registry entry and sets parsed.useUid, which the handler reads to answer in unique identifiers per RFC 9051 §6.4.9. parsed.limits carries the profile's caps, and a handler that copies, moves or otherwise writes holds the set it resolves to maxSequenceSetItems: a set written against * names whatever the mailbox holds when the handler reads it, which is a number only the handler has. parsed.answer(text) writes the command's tagged response, as in parsed.answer("OK SEARCH completed"), and returns whether it wrote one. A handler may write to the socket instead, but only an answer written through parsed.answer is one the listener knows about, and the listener writes a tagged refusal of its own if the handler's promise rejects. A handler that has answered and then fails should resolve and report the failure by its own route; RFC 9051 §2.2.2 gives the command one tagged response. parsed.answer belongs to the command it was passed with: text that is not a non-empty string, and a call made once that command is over, are refused with false and an mail.server.imap.answer_refused audit event rather than an exception, since a handler may call it from a timer or another detached callback where nothing would catch one. - **UID EXPUNGE (RFC 4315)** — refused unless the operator supplies an EXPUNGE handler that reads the uid-set. The shipped EXPUNGE takes no set and expunges every message flagged \Deleted, so serving the UID form from it would delete messages the client did not name. CLOSE does not call this handler: RFC 9051 §6.4.2 gives CLOSE one tagged answer and no untagged EXPUNGE responses, which a handler written to serve the EXPUNGE command holds the connection to send. CLOSE removes the \Deleted set through mailStore.expungeFolder, and says in its own answer when there is none, so a deployment whose removal lives in an override supplies expungeFolder as well. - **NOTIFY (RFC 5465)**, **METADATA (RFC 5464)**, **CATENATE (RFC 4469)**, **URLAUTH (RFC 4467)**, **IMAPSIEVE (RFC 6785)**, **COMPRESS=DEFLATE (RFC 4978)** — opt-in / refused. - **CONDSTORE / QRESYNC (RFC 7162)** — modseq is exposed via STATUS but per-FETCH CHANGEDSINCE delta is operator-side follow-up.
b.mail.server.imap.create(opts) #
{
tlsContext: SecureContext, // required (no plaintext mode)
implicitTls: boolean, // RFC 8314 §3 — TLS from the SYN on 993 (the default port becomes 993). STARTTLS is neither advertised nor accepted on it. Default false
greeting: string, // default "blamejs IMAP4rev2"
maxLineBytes: number, // default 8192
maxLiteralBytes: number, // default 64 MiB
idleTimeoutMs: number, // default 30 min
maxConnections: number, // default 1024 — listener-wide ceiling
profile: "strict" | "balanced" | "permissive",
auth: {
mechanisms: ["PLAIN", "LOGIN", "SCRAM-SHA-256", "EXTERNAL", "XOAUTH2"],
verify: async function (mechanism, credentials) → { ok, actor },
},
mailStore: b.mailStore handle, // required
mailboxAdmin: object, // optional. Enables CREATE / DELETE / RENAME / SUBSCRIBE / UNSUBSCRIBE, each called as method(actor, ...mailboxNames). Without it those verbs answer NO: they mutate a namespace a client names by string, so the account that may do it is the operator's question, not this listener's
accountKey: function (actor) → string | number, // optional. Names the account an actor belongs to, used to decide which other sessions a DELETE or RENAME affects. Without it two actors are the same account when every identity field present on both is equal and at least one is present — the fields b.requestHelpers.ACTOR_IDENTITY_FIELDS lists — and any `tenantId` matches too; `tenantId` alone scopes an account rather than naming one
rateLimit: b.mail.server.rateLimit handle | opts | false,
capabilities: function (caps, state) → string[], // optional. The advertised capability list, applied where it is COMPUTED — so the greeting, the CAPABILITY answer and the code completing AUTHENTICATE / LOGIN all carry the same thing. IMAP4rev2 is re-added if dropped (RFC 9051 §6.1.1)
audit: b.audit // optional
overrides: { [verb]: { fn, maxHandlerBytes, maxHandlerMs } }, // optional. Replaces a
verb's handler through b.mail.serverRegistry, which keeps wire-protocol
state, the audit lifecycle and the guard substrate. Both budgets are
required per entry: one omitted raises from the registry rather than
defaulting, so an override cannot quietly drop a resource ceiling
tenantScope: object, // optional. A b.agent.tenant.create() instance the registry
checks the authenticated actor against. One without a `.check` function
raises mail-server-registry/bad-tenant-scope, and supplying it without
agentTenantId raises mail-server-registry/no-agent-tenant-id
agentTenantId: string, // optional. The tenant this listener belongs to, which
tenantScope compares the actor's against
allowLegacyMUtf7: boolean, // optional, default false, read strictly so only `true`
turns it on. With it unset a mailbox name
containing a modified-UTF-7 escape (`&...-`, the RFC 3501 encoding) is
refused as a name, so only the RFC 9051 UTF-8 form is accepted. Set it
for a store whose existing mailbox names carry those escapes
`fetchRange` may return each row's `payload` as a Buffer, and should whenever
the row carries message content. RFC 9051 §4.3 makes a literal a counted
sequence of octets, and a string cannot hold one: a message octet that is not
valid UTF-8 does not survive being encoded on the way to the socket, and the
count the response announced stops matching the number of octets it wrote —
which is what tells a client where the response ends. A Buffer payload is
framed and written as the octets it holds. A string payload is unchanged and
remains right for the rows that carry only attributes, such as `FLAGS (\Seen)`.
`storeFlags` is called with `maxMessages`, the profile's
`maxSequenceSetItems`, and refuses a set that resolves to more than that
many messages. Which messages a set names is the store's answer: a UID
range's width is not a message count, and `1:*` is whatever the mailbox
holds when the store reads it, so the listener forwards the set as the
client wrote it and carries the cap down to the party that resolves it.
`b.guardImapCommand` refuses beforehand what can be counted from the line
alone, an enumerated set and a message-number range.
}
Build an IMAP4rev2 listener (RFC 9051). The handle exposes listen({ port, address }) → ephemeral-bind promise resolving to { port, address }, plus close() for graceful shutdown.
var imap = b.mail.server.imap.create({
tlsContext: b.mail.server.tls.context({ certFile, keyFile }).secureContext,
auth: {
mechanisms: ["PLAIN", "SCRAM-SHA-256"],
verify: async function (mech, creds) {
return { ok: true, actor: { tenantId: "t1", username: creds.authzid } };
},
},
mailStore: b.mailStore.create({ backend: b.db }),
});
await imap.listen({ port: 143 });
b.mail.server.imap.legacyMUtf7Allowed(profile, posture?) #
Report whether a profile accepts the legacy modified UTF-7 mailbox-name encoding of RFC 3501 §5.1.3, which RFC 9051 replaced with UTF-8. The answer is the allowLegacyMUtf7 value in b.guardImapCommand's profile table, so the listener and the guard give one answer: the listener used to derive its own from profile === "permissive", and under the balanced profile the table said the encoding was accepted while the listener refused it.
b.mail.server.imap.legacyMUtf7Allowed("permissive"); // → true
b.mail.server.imap.legacyMUtf7Allowed("balanced"); // → false
b.mail.server.imap.legacyMUtf7Allowed("strict"); // → false