@bumail/queue

中文文档
0.14.2发布于 2026/11/05MITTypeScriptES Module零依赖近 30 天下载 3495

The outbound mail queue of a mail server: every recipient's state, retries with back-off, delivery status notifications (RFC 3464), leases for several workers, and a memory, a bun:sqlite, a PostgreSQL (Bun.sql) and a Redis (Bun.redis) store, delivering th

此软件包已被标记为弃用

moved to @softistx/bumail-queue (private, GitHub Packages)

原始使用文档
文档目录18 节
  1. Install
  2. Subpaths
  3. Usage
  4. Retries and bounces
  5. Routing
  6. MTA-STS
  7. Reusing connections
  8. Several workers
  9. PostgreSQL, for several machines
  10. Redis, for several machines
  11. Events and admin
  12. A store of your own
  13. Testing
  14. Limits
  15. Traps
  16. Upgrading
  17. API
  18. Documentation

@bumail/queue

The outbound queue of a mail server: it keeps every message your server sends to another one, delivers it through @bumail/smtp/client, retries what failed for now, and sends a delivery status notification (RFC 3464) back when it gives up. Each recipient has its own state, the queue survives a restart on bun:sqlite, PostgreSQL or Redis, and several workers — on one machine, or on many with PostgreSQL or Redis — can share one queue. No runtime dependency: only peers.

Install

bun add @bumail/queue @bumail/smtp @bumail/mime
bun add @bumail/mta-sts   # a required peer (its types are in the API), used when you set mtaSts
bun add @bumail/dns   # for direct delivery by MX: the resolver

@bumail/smtp (its client), @bumail/mime (for the DSNs) and @bumail/mta-sts (for mtaSts) are required peers. @bumail/dns is optional: any resolver of its shape will do, and a queue that only uses a smarthost needs none.

Subpaths

importwhat it gives
@bumail/queuecreateQueue, the QueueStore contract, QueueError and the types
@bumail/queue/memoryMemoryQueueStore: in memory, lost on a restart — for specs and trials
@bumail/queue/sqliteSqliteQueueStore: on disk with bun:sqlite, shared by the processes of one machine
@bumail/queue/postgresPostgresQueueStore: on PostgreSQL through Bun's own Bun.sql, shared by instances on several machines
@bumail/queue/redisRedisQueueStore: on Redis through Bun's own Bun.redis, shared by instances on several machines
@bumail/queue/conformancedescribeQueueStore: the QueueStore contract suite, run with bun test against a store written outside the package

Usage

import { nodeResolver } from '@bumail/dns';
import { createQueue } from '@bumail/queue';
import { SqliteQueueStore } from '@bumail/queue/sqlite';

const queue = createQueue({
    store: SqliteQueueStore.open({ directory: '/var/lib/bumail/queue' }),
    hostname: 'mail.example.net', // your public name: EHLO, and the DSN's Reporting-MTA
    resolver: nodeResolver(),
});
queue.start();

// The message as it leaves: whole, headers first, CRLF, already DKIM-signed
// (with @bumail/auth's signDkim, say).
const signedMessage = await Bun.file('outgoing.eml').bytes();
const item = await queue.enqueue(signedMessage, {
    from: '[email protected]',
    to: ['[email protected]', '[email protected]'],
});
item.recipients; // [{ address: '[email protected]', status: 'pending' }, …]

process.on('SIGTERM', () => queue.stop()); // lets deliveries under way end

The message is a Uint8Array, a string or a ReadableStream<Uint8Array>, with CRLF line ends: a bare CR or LF is refused, as sendMail would refuse it. Every address is checked as one sendMail takes (@bumail/smtp/client's isMailbox: an RFC 5321 Mailbox with no source route, no control character (C0, DEL or C1), no >, no U+2028 or U+2029, no Unicode format character (\p{Cf}), no lone surrogate and no IPv4 literal octet above 255), so a bad one is refused at enqueue, never at delivery. Delivery groups the recipients by domain: one session per domain per attempt, with STARTTLS when offered.

Retries and bounces

A 4xx, a connection error or a timeout leaves the recipient deferred; the queue tries it again after 30 minutes, then 1 hour, 2, 4, and every 4 hours, each plus up to 10% of jitter, and gives up after 5 days (RFC 5321 §4.5.4.1). A 5xx fails the recipient at once.

import { nodeResolver } from '@bumail/dns';
import { createQueue } from '@bumail/queue';
import { MemoryQueueStore } from '@bumail/queue/memory';

const queue = createQueue({
    store: new MemoryQueueStore(),
    hostname: 'mail.example.net',
    resolver: nodeResolver(),
    retry: { first: 15 * 60_000, giveUpAfter: 3 * 24 * 3_600_000 },
    dsn: { delayAfter: 4 * 3_600_000, returnContent: 'headers' },
});

A failed recipient gets a DSN back to the sender, a multipart/report with the original's header fields; a recipient still deferred after delayAfter gets one "delayed" warning. A DSN is enqueued from the null sender <>, and a message from <> never causes one.

The envelope can carry the sender's DSN parameters (RFC 3461), which the SMTP server hands over as they are and the queue passes on to the next hop:

await queue.enqueue(message, {
    from: '[email protected]',
    to: ['[email protected]'],
    ret: 'HDRS', // what a DSN returns: the headers only (FULL: the whole message)
    envid: 'QQ314159', // echoed as Original-Envelope-Id
    recipients: [
        { address: '[email protected]', notify: ['SUCCESS', 'FAILURE'], orcpt: { type: 'rfc822', address: '[email protected]' } },
    ],
});

notify: 'NEVER' sends no DSN; absent, failure and delay as always; a list, only those events. Upgrading a shared store: stop every old worker before starting a new one (an old worker that keeps running ignores the DSN parameters and drops them from the items it completes). SUCCESS with a next hop that does not offer DSN sends a relayed DSN. For a message the application delivered itself, deliveredDsn() writes the delivered one, and failedDsn() the failed one for a recipient it could not deliver.

Routing

import { createQueue } from '@bumail/queue';
import { MemoryQueueStore } from '@bumail/queue/memory';

const smtpPassword = (await Bun.file('/run/secrets/smtp').text()).trim(); // from your secrets
createQueue({
    store: new MemoryQueueStore(),
    hostname: 'mail.example.net',
    // Port 25 blocked? Everything through a provider's submission port:
    route: {
        host: 'smtp.provider.example',
        port: 587,
        auth: { username: '[email protected]', password: smtpPassword },
    },
    // …or only some domains, the rest by MX (which needs the resolver):
    routes: { 'partner.example': { host: 'relay.partner.example' } },
    // …or by MX, with a smarthost only as the way out when MX fails:
    // fallback: { host: 'smtp.provider.example', port: 587, auth: { username: '[email protected]', password: smtpPassword } },
});

route defaults to 'mx': each recipient domain's own mail hosts. Credentials go only over TLS whose certificate checked out: createQueue refuses auth with a tls other than 'required', and checks the ports, the TLS modes and the timeouts as sendMail would. A route sendMail still refuses (INVALID_OPTION) defers its recipients as 4.3.5 and says so on the error event; it does not bounce them until retry.giveUpAfter, when they fail as 4.4.7 with a DSN like any deferred recipient.

With fallback (a smarthost), a recipient whose session by MX failed by a connection failure, a timeout or a reputation refusal goes through that smarthost at once (not after the final dot of DATA, where the MX may have taken the message: every recipient the MX did not refuse at RCPT is marked mayHaveReachedMx and never falls back on its own; a refused one may). If the smarthost's own session ends after the final dot, the recipient is marked mayHaveReachedFallback and later attempts go to the smarthost again, not to the MX; see Falling back to a smarthost.

MTA-STS

Opt in with mtaSts (off unless given; the smarthost routes and the fallback never use it). For each recipient domain on the MX route the queue reads _mta-sts.<domain> and fetches the policy (RFC 8461) through @bumail/mta-sts, keeping it in a PolicyCache until its max_age ends. With mode enforce it sends with tls: 'required' to the MX hosts the policy names only; with testing it sends as usual and only reports; a failure under enforce is deferred as 4.7.5 and bounced as 5.7.5 only at retry.giveUpAfter, after the policy is fetched once more. Under enforce nothing goes to the fallback: a connection failure defers. mtaSts.skip(item, domain) exempts a session (a mailed TLS-RPT report, a domain delivered in-process). Every try is told on the tls event, for RFC 8460 reports.

import { nodeResolver } from '@bumail/dns';
import { createMemoryPolicyCache, createQueue } from '@bumail/queue';
import { MemoryQueueStore } from '@bumail/queue/memory';

const resolver = nodeResolver(); // has txt
const queue = createQueue({
    store: new MemoryQueueStore(),
    hostname: 'mail.example.net',
    resolver,
    mtaSts: { cache: createMemoryPolicyCache() }, // fetch: globalThis.fetch; resolver: the queue's
});
queue.on('tls', ({ domain, policy, mxHost, ok, tlsResult }) => {});

The tls event also carries kind ('try' or 'policy'), at (the queue's clock, ms), sendingAddress and reused. A send of your own receives mxNames, tls, onAttempt and onSkip in its options and has to pass them to sendMail. See MTA-STS.

Reusing connections

Without a send of your own, the queue sends through a pool of SMTP connections it owns: several messages to one server share a connection (100 at most, idle 5 seconds), and stop() closes the pool. reuse tunes it, reuse: false opens one connection per session, and a send you give is never wrapped. Events carry reused when the sender says so. The bumail serve queue of @bumail/server sends through the same pool, with no setting. See createSmtpPool in the @bumail/smtp README.

createQueue({
    store: new MemoryQueueStore(),
    hostname: 'mail.example.net',
    resolver: nodeResolver(),
    reuse: { maxMessages: 50, idleSeconds: 10 }, // default { 100, 5 }; or false
});

See Reusing connections.

Several workers

import { nodeResolver } from '@bumail/dns';
import { createQueue } from '@bumail/queue';
import { SqliteQueueStore } from '@bumail/queue/sqlite';

// In each process, on the same directory: a claim leases an item to one worker.
const queue = createQueue({
    store: SqliteQueueStore.open({ directory: '/var/lib/bumail/queue' }),
    hostname: 'mail.example.net',
    resolver: nodeResolver(),
});
queue.start();

A worker claims a due item with a lease, renews it while it delivers, and lets go of it with the outcome; a renewal that fails is told on error and tried again. If it crashes, another worker claims the item once the lease expires (leaseMs, 10 minutes by default). A directory the store makes is 0700; one that exists keeps its mode. concurrency (20) bounds the items one worker delivers at once — each item opens one session per recipient domain — and perDomain (2) its sessions to one recipient domain.

PostgreSQL, for several machines

import { nodeResolver } from '@bumail/dns';
import { createQueue } from '@bumail/queue';
import { PostgresQueueStore } from '@bumail/queue/postgres';

// In each instance, on the same database. A Bun.SQL client of your own works too:
// PostgresQueueStore.open({ sql: new Bun.SQL({ url, max: 10 }) }).
const store = PostgresQueueStore.open({
    sql: Bun.env['DATABASE_URL'] ?? 'postgres://bumail@localhost:5432/mail',
    tablePrefix: 'bumail_queue_', // the default: bumail_queue_items, …_messages, …_schema
});
await store.migrate(); // makes the tables; the first call would anyway
const queue = createQueue({ store, hostname: 'mail.example.net', resolver: nodeResolver() });
queue.start();

process.on('SIGTERM', async () => {
    await queue.stop();
    await store.close(); // closes the client it opened for the URL, never yours
});

A claim is one UPDATE … RETURNING whose item a SELECT … FOR UPDATE SKIP LOCKED picks: two instances never take the same item, and none waits on one another is taking. An instance that crashes loses its items when their leases expire, as with bun:sqlite. Every write runs at READ COMMITTED, so a client whose sessions default to repeatable read or serializable serves the queue too. No driver to install: Bun.sql is Bun's.

Redis, for several machines

import { nodeResolver } from '@bumail/dns';
import { createQueue } from '@bumail/queue';
import { RedisQueueStore } from '@bumail/queue/redis';

// In each instance, on the same Redis. A Bun.RedisClient of your own works too:
// RedisQueueStore.open({ client: new Bun.RedisClient(url) }).
const store = RedisQueueStore.open({
    url: Bun.env['REDIS_URL'] ?? 'redis://localhost:6379',
    keyPrefix: 'bumail:queue:', // the default: bumail:queue:items, …:item:<id>, …
});
const queue = createQueue({ store, hostname: 'mail.example.net', resolver: nodeResolver() });
queue.start();

process.on('SIGTERM', async () => {
    await queue.stop();
    await store.close(); // closes the client it opened for the URL, never yours
});

Every operation that writes is one Lua script, which Redis runs whole: two instances never take the same item, and a crashed instance's items are claimed again once their leases expire. The message is kept byte for byte. One Redis, or a primary with replicas — not Redis Cluster. Redis acknowledges a write once it is in memory: with appendfsync everysec a crash loses up to a second of writes, and a failover to a replica can lose acknowledged ones, so keep appendonly yes, appendfsync always and maxmemory-policy noeviction where a lost message matters (the guide's Redis section). No driver to install: Bun.redis is Bun's.

Events and admin

// queue: the one created under Usage.
queue.on('delivered', ({ id, recipient, reply }) => console.info({ id, recipient, reply }));
queue.on('deferred', ({ recipient, reply, nextAttemptAt }) => {});
queue.on('failed', ({ recipient, reply }) => {}); // reply: { code?, status?, text, host? }
queue.on('dsn', ({ kind, of, to }) => {});
queue.on('tls', ({ domain, policy, mxHost, address, ok, tlsResult }) => {}); // with mtaSts
queue.on('error', ({ error, id }) => console.error(id, error)); // the store, a lost lease, a bad route

const [next] = await queue.list({ limit: 50 }); // the next due first
if (next) await queue.retryNow(next.id); // false while a worker delivers it
if (next) await queue.cancel(next.id); // no DSN

A store of your own

QueueStore may grow in a 0.x minor version (methods, fields); a store of your own follows it.

Hold a QueueStore you wrote to the same tests the stores here pass. The spec runs with bun test (the suite imports bun:test):

// my-store.spec.ts
import { describeQueueStore } from '@bumail/queue/conformance';
import { MyQueueStore } from './my-store';

describeQueueStore('MyQueueStore', {
    create: () => MyQueueStore.open({ url: process.env.MY_STORE_URL }),
    share: (store) => (store as MyQueueStore).connectAgain(), // a second handle on the same queue
    dispose: (store) => (store as MyQueueStore).close(),
});

Without share the two several-workers tests are skipped, and a store shared over a network should pass a real second connection. See Holding your own store to the contract.

Testing

import { createQueue, type Sender } from '@bumail/queue';
import { MemoryQueueStore } from '@bumail/queue/memory';

let now = Date.UTC(2026, 0, 1);
const send: Sender = async (_message, options) => ({
    accepted: [options.to].flat().map((recipient) => ({ recipient, reply: { code: 250, text: 'OK' } })),
    rejected: [],
    reply: { code: 250, text: 'Queued' },
    host: 'mx.test',
    port: 25,
    tls: false,
    authenticated: false,
});
const queue = createQueue({
    store: new MemoryQueueStore(),
    hostname: 'mail.test',
    route: { host: 'mx.test' },
    clock: { now: () => now },
    send,
});
await queue.enqueue('Subject: hi\r\n\r\nhello\r\n', { from: 'a@test', to: 'b@test' });
await queue.deliverDue(); // one pass, by hand: no timer, no network
now += 30 * 60_000;

Limits

import { nodeResolver } from '@bumail/dns';
import { createQueue } from '@bumail/queue';
import { MemoryQueueStore } from '@bumail/queue/memory';

createQueue({
    store: new MemoryQueueStore(),
    hostname: 'mail.example.net',
    resolver: nodeResolver(),
    limits: { maxMessageSize: 10 * 1024 * 1024, maxRecipients: 50, maxItems: 100_000 },
});

Everything is bounded, through limits: the message (25 MiB), the recipients per message (100), the items in the store (none by default), the reply text kept per recipient (512 characters, control characters replaced by spaces), and the original a DSN returns (64 KiB, each line cut at 998 bytes). Nothing a remote server says reaches a DSN's header fields with a CR, an LF or a control character in it.

Traps

  • Bun only. The stores use bun:sqlite, Bun.sql and Bun.redis, so the package runs on Bun 1.4.2 or later, not on Node.

  • It sends what you enqueue, to anyone. The queue is not a relay policy: enqueue only what an authenticated user submitted, or what your own server writes, never what an unauthenticated client handed you.

  • A custom QueueStore must port complete (0.11, breaking). complete(id, owner, result, dsns) takes the DSN items the outcome calls for and returns { item, dsns }, or undefined for a lost lease. Store the outcome and the DSNs in one step, so a crash cannot leave one without the other; a store that returns the bare item still works, but the queue reports Queue store outdated on the error event and adds each DSN with add, outside that step. The stores of this package are atomic. DSNs ignore limits.maxItems: a full queue still bounces, and the count may exceed the limit by the DSNs it holds. They are delivered at least once; see Writing a store. The this.* calls below are your store's own helpers, a sketch.

    async complete(id, owner, result, dsns) {
      // refuse a bad DSN (INVALID) before opening a connection or a transaction
      for (const dsn of dsns) this.check(dsn);
      return this.transaction(async () => {
          const item = await this.apply(id, owner, result); // undefined on a lost lease
          if (!item) return undefined; // writes no DSN
          const stored = [];
          for (const dsn of dsns) stored.push(await this.insert(dsn)); // in order
          return { item, dsns: stored };
      });
    }
    

Upgrading

With MTA-STS (the release that adds mtaSts):

  • @bumail/mta-sts is a required peer. Install it even when mtaSts is unused: bun add @bumail/mta-sts.
  • QueueEvents has a new tls event kind, for the TLS-RPT counts. A listener that switches on the event kinds should handle it or ignore it.
  • SmtpErrorCode has MX_NOT_ALLOWED (X.7.5, from @bumail/smtp), for an MX host an enforce policy does not allow. A switch over the codes needs the case.
  • A custom send must pass mxNames, tls and onAttempt to sendMail, or an enforce policy is not applied and the tls events stay silent.
  • QueueStore is unchanged.

API

export
createQueue(options), Queuethe queue: enqueue, deliverDue, start, stop, on, list, get, retryNow, cancel, owner
QueueOptions (route, routes, fallback, reuse, mtaSts), Route, Smarthost, RetrySchedule, DsnOptions, ReuseOptions, QueueLimits, Clock, Senderwhat createQueue takes
MessageSource, QueueEnvelope (ret, envid, recipients), RecipientDsn, RecipientNotify, NotifyEvent, OriginalRecipient, RetModewhat enqueue takes, with the DSN parameters
describeQueueStore, QueueStoreHarness, CreateQueueStore, ShareStorefrom @bumail/queue/conformance: describeQueueStore(name, { create, share?, dispose? }), run under bun test; without share the two several-workers tests are skipped
deliveredDsn(input), DeliveredDsnInput, DeliveredRecipientthe delivered DSN for a local delivery: the bytes to enqueue, or undefined when no recipient asked for SUCCESS
failedDsn(input), FailedDsnInput, FailedRecipient, DEFAULT_MAX_RETURNthe failed DSN for a recipient the application could not deliver locally: the bytes to enqueue, or undefined for a null reverse-path or when no recipient asked for failures; DEFAULT_MAX_RETURN is the 64 KiB a DSN returns of the original by default
QueueEvents, QueueListener, RecipientEvent (route, fallback, mxReply, mayHaveReachedMx, mayHaveReachedFallback), DeferredEvent, DsnEvent, TlsEvent, TlsPolicyReport, QueueErrorEventthe events
MtaStsOptions, StsResolver, PolicyCache, PolicyCacheEntry, createMemoryPolicyCache()MTA-STS: the mtaSts option, its policy cache (get, set, delete?) and a cache in memory
QueueStorethe contract a store answers: add, get, list, count, readMessage, claim, renew, complete(id, owner, result, dsns), reschedule, cancel
Completed (item, dsns)what complete returns: the item as it now stands, and the DSN items it stored in the same step, in the order given; undefined when the lease was lost
QueueItem, RecipientState, RecipientStatus, FinalStatus, Diagnostic, Leasean item, and where each recipient stands
NewQueueItem, AddOptions, ClaimRequest, AttemptResult, RecipientUpdate, QueueListOptionswhat a store takes
QueueError, QueueErrorCodeINVALID, MESSAGE_TOO_BIG, TOO_MANY_RECIPIENTS, QUEUE_FULL, CLOSED, LEASE_LOST, MESSAGE_UNREADABLE
MemoryQueueStorefrom @bumail/queue/memory
SqliteQueueStore, SqliteQueueStoreOptionsfrom @bumail/queue/sqlite: SqliteQueueStore.open({ directory, busyTimeout? }), close()
PostgresQueueStore, PostgresQueueStoreOptions, PostgresClient, PostgresQueryablefrom @bumail/queue/postgres: PostgresQueueStore.open({ sql, tablePrefix? }), migrate(), close(); sql a Bun.SQL client or a postgres:// URL
RedisQueueStore, RedisQueueStoreOptions, RedisQueueClientOptions, RedisQueueUrlOptions, RedisQueueClientfrom @bumail/queue/redis: RedisQueueStore.open({ client | url, keyPrefix? }), close(); client a Bun.RedisClient, url a redis:// URL

Documentation

These pages ship in the package, under docs/.

  • Index: the pages, and when to read each.
  • Guide: enqueuing, delivery and routing, the retry schedule, DSNs, several workers and leases, events and admin, the stores (PostgreSQL and Redis included), testing, and writing a store of your own.
  • Troubleshooting: every QueueError, and what to do about it.
  • Roadmap: what is coming, and what is not planned.

版本与发布记录

@bumail/queue 共发布 22 个版本,首次发布于 2026/10/04;过去 12 个月发布了 22 个版本。

版本发布日期距今
0.14.2latest2026/11/053 天前
0.14.12026/11/044 天前
0.14.02026/11/044 天前
0.13.02026/11/044 天前
0.12.02026/11/045 天前
0.11.02026/11/045 天前

关于 @bumail/queue 的常见问题

@bumail/queue 是什么?

The outbound mail queue of a mail server: every recipient's state, retries with back-off, delivery status notifications (RFC 3464), leases for several workers, and a memory, a bun:sqlite, a PostgreSQL (Bun.sql) and a Redis (Bun.redis) store, delivering th注意:该包已被作者标记为弃用——moved to @softistx/bumail-queue (private, GitHub Packages)

如何安装 @bumail/queue?

在项目目录运行 npm install @bumail/queue,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:@bumail/mime(^0.4.0)、@bumail/smtp(^0.13.0)、@bumail/mta-sts(^0.1.0)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@bumail/queue 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@bumail/queue 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@bumail/queue 可以商用吗?

@bumail/queue 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@bumail/queue 的下载量有多少?

根据 npm 官方下载统计,@bumail/queue 在 2026/10/09 – 2026/11/07 这 30 天内被下载 3,495 次,日均约 117 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@bumail/queue 还在维护吗?

最新版本 0.14.2 发布于 2026/11/05(3 天前);至今共发布 22 个版本,其中过去 12 个月发布了 22 个;首次发布于 2026/10/04。作者已将其标记为弃用,新项目建议寻找替代方案。