@aglyn/shared-util-email

中文文档
1.18.0发布于 2026/11/07Apache-2.0TypeScriptES Module构建来源已验证近 30 天下载 1.7万

Published to npm from the Aglyn monorepo. Versions follow semantic versioning.

原始使用文档
文档目录10 节
  1. Install
  2. What is and is not in scope
  3. Configuration
  4. The mail provider
  5. Usage
  6. The transport boundary
  7. The List-Unsubscribe setting
  8. Checking configuration
  9. Running unit tests
  10. License

@aglyn/shared-util-email

Published to npm from the Aglyn monorepo. Versions follow semantic versioning.

Install

npm install @aglyn/shared-util-email

One place that sends outbound application email, through a mail provider the deployment chooses: Resend by default, or the operator's own sender behind a webhook.

Before this library the same ~30 lines of a vendor fetch were copy-pasted across 10 files, each reading the env vars itself and each handling failure a little differently. Consolidating them means the provider, the sender identity, and the failure semantics change in one place.

What is and is not in scope

MailSent byHere?
Invites, receipts, usage summaries, campaigns, staff alertsthe mail provider✅
Verification, password resetFirebase Auth❌
Inbound mail to @aglyn.com mailboxesGoogle Workspace❌

Configuration

The sender, and the settings of the provider that carries the mail, read at call time:

USAGE_EMAIL_FROM="Aglyn <[email protected]>"
RESEND_API_KEY=re_xxxxxxxx             # the default provider
# or, for your own sender:
AGLYN_MAIL_WEBHOOK_URL=https://relay.internal/send
AGLYN_MAIL_WEBHOOK_TOKEN=…

Both the sender and the provider's settings are required. With either missing, sendEmail() warns and returns { sent: false, reason: 'unconfigured' } — it does not throw, so local and preview environments run fine without a mail provider.

The mail provider

sendEmail() decides whether, as whom and to whom a message leaves — suppression, the send-rate governor, the phishing screen, the sending identity, the unsubscribe footer. A MailProvider (mail-provider.ts) only carries the finished message, and answers the platform's other questions about mail in the platform's own vocabulary: whether its credential works, what a delivery webhook said, what a received message contained, what the account already sent.

mail-providers.ts picks the deployment's provider:

  • AGLYN_MAIL_PROVIDER names one — resend, webhook, or an id a plugin or fork registered with registerMailProvider().
  • Unset, the first built-in with its settings wins: resend when RESEND_API_KEY is set, webhook when only AGLYN_MAIL_WEBHOOK_URL is, otherwise resend, unconfigured.
  • A name that is neither built in nor registered is refused: every send is skipped with an error naming it, and nothing falls back to another vendor.

The built-ins are compiled into this library rather than registered by a plugin because this is the transactional rail: it must send from every server process with or without a plugin loaded, and a registration that did not happen would look exactly like mail that is switched off.

The rest of the platform asks through normalizeDeliveryEvents(), readInboundMailEvent() and mailProviderReads(), never through a provider by name. mail-provider-resend.ts is the only module that knows Resend's wire format, and mail-provider-webhook.ts documents the webhook's.

Setup and DNS are documented in docs/EMAIL_SETUP.md.

Usage

import { sendEmail } from '@aglyn/shared-util-email'

const result = await sendEmail({
  to: '[email protected]',
  subject: 'You have been invited',
  text: 'Sign in to accept.',
  context: 'invite', // shows up in logs on failure
})

if (!result.sent) {
  // 'unconfigured' | 'no-recipient' | 'rejected' | 'network'
  console.warn('no mail went out:', result.reason)
}

sendEmail() never throws. Outbound mail is best-effort everywhere in this codebase — a checkout must not fail because a receipt bounced — so every outcome comes back as a result object. Do check sent: it is what lets the console tell a user honestly whether a message actually went out.

Optional fields: html, headers (e.g. List-Unsubscribe), tags (webhook attribution), replyTo, and from (overrides the configured sender — rarely correct, since the point of USAGE_EMAIL_FROM is one verified identity).

The transport boundary

postResendEmail() (in mail-provider-resend.ts) is the only function that POSTs to Resend's send endpoint, and it throws on a payload with no to rather than putting it on the wire. Such a payload cannot become a message; Resend answers 422 missing_required_field, which costs an API call and then sits in the vendor dashboard looking exactly like mail that failed to deliver, carrying nothing that names the code responsible. sendEmail() filters recipients long before this point, so ordinary senders never meet the guard — it is there because RESEND_SEND_ENDPOINT is exported and a module that fetches it directly bypasses every check sendEmail() owns.

The List-Unsubscribe setting

list-unsubscribe.ts is the one place the List-Unsubscribe / List-Unsubscribe-Post (RFC 8058) pair is modeled, for every sender that makes it a setting — an outreach sequence (default off) and a marketing campaign (default on):

import {
  decideListUnsubscribe,
  listUnsubscribeHeaders,
  readListUnsubscribeSetting,
} from '@aglyn/shared-util-email/list-unsubscribe'

const requested = readListUnsubscribeSetting(stored.listUnsubscribe, true)
const decision = decideListUnsubscribe({
  requested,
  recentVolume, // the organization's campaign messages in the last day
  sendVolume, // everyone this email will address
})
const headers = decision.on ? listUnsubscribeHeaders({ url: oneClickUrl }) : {}

decideListUnsubscribe is the bulk-sender guard: with the setting off, a send that takes the organization to EMAIL_LIST_UNSUBSCRIBE_BULK_THRESHOLD messages (default 5,000, Gmail's and Yahoo's line) carries the pair anyway, and the decision says it was forced and why. The headers are plain headers, so any provider behind sendEmail delivers them.

Checking configuration

import { isEmailConfigured, describeEmailConfig, checkEmailCredentials }
  from '@aglyn/shared-util-email'
  • isEmailConfigured() — the provider has its settings and a sender is set. Use it to answer 501 from a route instead of pretending to have sent.
  • describeEmailConfig() — which provider, which of its settings are missing, plus the sender and its domain, with no credential ever included.
  • checkEmailCredentials() — asks the provider whether its credential is accepted without sending anything. A provider with no probe answers unknown. Resend's probe GETs the domains collection and reading the error NAME rather than the status. A 2xx, or a restricted_api_key/invalid_permission rejection (what a sending-scoped key gets, and it can only be reached once Resend has authenticated the key), means the credential works. missing_api_key, validation_error and suspended_api_key mean it was refused. Anything else is unknown, never invalid-key. It never touches the send endpoint: a probe aimed there is logged by Resend as a 422 on POST /emails and reads, in the dashboard, as failed mail. It cannot confirm domain verification — only a real send does that.

These back the staff-only /api/admin/email-health route in the console.

Running unit tests

Run nx test shared-util-email to execute the unit tests via Jest.

Note: prefer bare jest over nx test when a test depends on env vars — nx injects the root .env, which can turn a genuinely failing test green.

License

Apache-2.0. Source: https://github.com/aglyn/aglyn/tree/main/libs/shared/util/email

版本与发布记录

@aglyn/shared-util-email 共发布 159 个版本,首次发布于 2026/09/21;过去 12 个月发布了 159 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
1.18.0latest2026/11/07今天
1.17.02026/11/07今天
1.16.02026/11/07今天
1.15.22026/11/061 天前
1.15.12026/11/061 天前
1.15.02026/11/061 天前

其他发布通道:beta → 1.0.0-beta.296。

关于 @aglyn/shared-util-email 的常见问题

@aglyn/shared-util-email 是什么?

Published to npm from the Aglyn monorepo. Versions follow semantic versioning.

如何安装 @aglyn/shared-util-email?

在项目目录运行 npm install @aglyn/shared-util-email,或使用 pnpm add / yarn add / bun add。

@aglyn/shared-util-email 支持 TypeScript 吗?

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

@aglyn/shared-util-email 支持 ESM 还是 CommonJS?

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

@aglyn/shared-util-email 可以商用吗?

@aglyn/shared-util-email 使用 Apache-2.0 许可。宽松许可:允许商业使用、修改和再分发,并附带明确的专利授权;分发时需保留许可与 NOTICE 文件,修改过的文件要注明改动。以上为概括,不构成法律意见,请以许可原文为准。

@aglyn/shared-util-email 的下载量有多少?

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

@aglyn/shared-util-email 还在维护吗?

最新版本 1.18.0 发布于 2026/11/07(今天);至今共发布 159 个版本,其中过去 12 个月发布了 159 个;首次发布于 2026/09/21。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。