@bernierllc/nevarmail

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

Official client for the NevarMail v1 API. Typed, retries 429/5xx, throws NevarMailError on every non-2xx.

原始使用文档
文档目录14 节
  1. Installation
  2. Integration status
  3. Usage
  4. Tenancy — one key is one org
  5. Keys, surfaces, and modes
  6. Errors
  7. Retries
  8. Use cases and suppression scope
  9. Methods
  10. sendEmailToList returns a rollup, not a verdict
  11. Scheduling needs a real instant
  12. Agent inboxes — NevarMailInbox
  13. Options
  14. Use-case groups and inherited settings

@bernierllc/nevarmail

The official client for the NevarMail v1 API.

Where this lives. This package is published from bernierllc/tools (packages/core/nevarmail) and is the single source of truth for the client. The NevarMail API it wraps is built in bernierllc/email_demo. Any change to the NevarMail v1 API surface requires a matching change here, in the same unit of work — see that repo's CLAUDE.md → "NevarMail SDK (@bernierllc/nevarmail)".

One rule: every non-2xx throws. If a call returns, the API accepted it. There is no "success" object carrying a failure inside it, and no option to silence errors — the audit that produced this package found a production integration that swallowed every error, so an org may have been mailing into a 403 for months with nothing on either side noticing.

Installation

npm install @bernierllc/nevarmail

Server-side only; requires Node 20+ (uses global fetch and crypto.randomUUID). Never expose API credentials in browser code. Zero dependencies.

Integration status

IntegrationStatusWhy
Logger (@bernierllc/logger)not-applicableA zero-dependency API client stays zero-dependency. Every failure surfaces as a thrown NevarMailError carrying status, code, requestId and cause — log it in the consumer, with the consumer's logger.
NeverHub (@bernierllc/neverhub-adapter)not-applicableCore-tier package with no runtime lifecycle to register; NeverHub registration belongs to the service or suite that composes this client. Graceful degradation is therefore not required here.

Usage

import { NevarMail } from "@bernierllc/nevarmail";

const nevarmail = new NevarMail({ apiKey: process.env.NEVARMAIL_API_KEY! });

const result = await nevarmail.sendEmail({
  to: "[email protected]",
  subject: "Your quote is ready",
  html: "<p>Here it is.</p>",
  use_case_id: process.env.NEVARMAIL_USE_CASE_TRANSACTIONAL,
});

console.log(result.id, result.status);

Tenancy — one key is one org

A NevarMail API key is the organization. There is no org id in any request body; the server resolves it from the key and ignores anything you send. Two consequences:

  • One key per org. A key cannot be pointed at a second org, and a multi-tenant app that sends on behalf of several NevarMail orgs holds several keys.
  • Never accept an org identifier from an end user and pass it through. There is nowhere to put it, and the attempt is a sign the integration is modeling tenancy wrong.

Keys, surfaces, and modes

Keys look like nvm_api_live_…, nvm_api_test_…, nvm_mcp_live_…, nvm_mcp_test_….

  • Surface (api vs mcp) — this package speaks REST, so it needs an api key. Pass an mcp key and the constructor throws immediately rather than letting the server answer a 403 KEY_TYPE_MISMATCH at your first send, which reads like an auth bug.
  • Mode (live vs test) — a test key sends only to addresses on the org's test allow-list; anything else is a 422 TEST_MODE_RECIPIENT_NOT_ALLOWED. The mode is a property of the key, never a client option: client.mode reports what the key is.
const nevarmail = new NevarMail({ apiKey: key });
if (nevarmail.mode === "test") console.warn("test key — sends are allow-list only");

Errors

Every failure is a NevarMailError:

import { NevarMail, NevarMailError } from "@bernierllc/nevarmail";

try {
  await nevarmail.sendEmail({ to, subject, html });
} catch (err) {
  if (err instanceof NevarMailError && err.code === "RECIPIENT_SUPPRESSED") {
    // Expected: this person opted out. Not an outage.
    return;
  }
  throw err; // everything else is worth waking someone for
}
FieldMeaning
statusHTTP status; 0 when the request never reached the API
codeMachine-readable code — branch on this, never on message
requestIdQuote it in support; it matches the API's x-request-id
retryAfterSeconds, from Retry-After on a 429. Waits of 30s or less are retried for you; a longer one (a daily cap) throws at once
bodyThe parsed response, for codes newer than your installed version

Codes worth handling explicitly:

CodeStatusWhat it means
RECIPIENT_SUPPRESSED422Bounced, complained, or unsubscribed. Do not retry.
COMPLIANCE_ADDRESS_REQUIRED422emailType: "marketing" (or a use case recipients can opt out of) from a project with no postal address. Set it with setProjectAddress.
TEST_MODE_RECIPIENT_NOT_ALLOWED422Test key, recipient not on the allow-list.
CC_BCC_NOT_SUPPORTED_FOR_TEMPLATE400Templated mail renders per recipient; send one request each.
VALIDATION_ERROR400The body is wrong. Retrying will not help.
SCOPE_DENIED403The key lacks the scope this route needs.
KEY_TYPE_MISMATCH403An MCP key on a REST route (this client refuses it at construction).
RATE_LIMITED429Retried automatically; surfaced if the budget runs out.
SCHEDULE_NOT_CANCELLABLE409The scheduled send already fired or was already cancelled.
NETWORK_ERROR0No response at all — DNS, TLS, or timeout.

Retries

Retried: 429 (honoring Retry-After), 5xx, and network failures — exponential backoff with jitter, two extra attempts by default (maxRetries).

Never retried: any other 4xx. It will fail the same way forever, so retrying turns one bad request into three. Sends are not idempotent; a retry after a 5xx may deliver twice.

Use cases and suppression scope

Suppressions are per project. checkSuppression(email, useCaseId?, projectId?) answers for one project: projectId, else the project of useCaseId, else the organization's only project. An organization with several projects gets 400 PROJECT_REQUIRED when neither is passed. A bounce or spam complaint on a provider account blocks every project; any other suppression blocks only its own.

use_case_id names the message category a send belongs to. It is the difference between "this person unsubscribed from your newsletter" and "this person can no longer be emailed":

  • Name it, and an opt-out from a different category's list does not block the send, and a category marked non-suppressable is exempt from opt-outs.
  • Omit it, and every active suppression of the project applies — the strict reading. Only a project-scoped key may omit it on sendEmail / scheduleEmail: the send runs as that project's default use case. An org-wide key that omits it gets 422 USE_CASE_REQUIRED; listUseCases() shows yours.

Bounces, spam complaints and manual suppressions block every category regardless. Only a use_case_id you passed yourself can narrow a block; a default the server resolves cannot.

sendEmail also takes the use case by slug — useCase: "receipts" — instead of its id (one of the two, not both). The slug must exist in exactly one project the key can open: unknown is a 404, the same slug in several projects is 400 USE_CASE_AMBIGUOUS (pass use_case_id then). listUseCases shows the slugs.

Methods

MethodRouteScope
sendEmailPOST /api/v1/email/sendemail:send
sendEmailToListPOST /api/v1/email/send-to-listemail:send
scheduleEmailPOST /api/v1/email/scheduleemail:send
listScheduledEmailsGET /api/v1/email/scheduleemail:send
cancelScheduledEmailDELETE /api/v1/email/schedule/{id}email:send
checkSuppressionGET /api/v1/suppressions/checkorg:read
getPreferences(email, projectId?) — one address's lists in one project; projectId required when the org has more than oneGET /api/v1/preferencescontacts:read
setPreferences(email, { subscribedListIds, unsubscribeAll, projectId? }) — the complete set of lists to stay onPUT /api/v1/preferencescontacts:write
listTemplates / createTemplate / getTemplate / updateTemplate / deleteTemplate/api/v1/templatestemplates:read / templates:write
renderTemplatePOST /api/v1/templates/{id}/rendertemplates:read
listCampaigns({ projectId?, page?, perPage? }) / getCampaign(id, { projectId? }) — read-onlyGET /api/v1/campaigns, GET /api/v1/campaigns/{id}campaigns:read
getCampaignAnalytics(id, { projectId? }) — counts plus delivery/open/click/bounce/unsubscribe rates (0 to 1)GET /api/v1/campaigns/{id}/analyticsanalytics:read
getAnalyticsSummary({ period? } | { start, end }) — org-wide counts and rates for a trailing 7d/30d/90d (default 30d) or an inclusive UTC YYYY-MM-DD rangeGET /api/v1/analytics/summaryanalytics:read
getUsage({ start, end }) — metered outbound email counts (billable and test-mode apart) for an inclusive UTC YYYY-MM-DD range of at most 366 days, current hour included; counts only, no costGET /api/v1/usageusage:read
listLists / createList / getList/api/v1/listscontacts:read / contacts:write
addListContactsPOST /api/v1/lists/{id}/contactscontacts:write
removeListContactDELETE /api/v1/lists/{id}/contacts?email= (one list only; the contact is kept)contacts:write
listContacts / upsertContacts / updateContact / deleteContact/api/v1/contactscontacts:read / contacts:write
listBusinesses({ projectId?, page?, perPage? }) / getBusiness(id, { projectId? })GET /api/v1/businesses, GET /api/v1/businesses/{id}contacts:read
createBusiness(input) / updateBusiness(id, input) / deleteBusiness(id, { projectId? }) — update replaces domains, merges attributes; delete keeps the contactsPOST /api/v1/businesses, PATCH / DELETE /api/v1/businesses/{id}contacts:write
upsertBusinessByExternalRef(system, ref, input) — idempotent; returns { business, created }PUT /api/v1/businesses/by-ref/{system}/{ref}contacts:write
listBusinessMembers(id, { projectId?, page?, perPage? })GET /api/v1/businesses/{id}/memberscontacts:read
addBusinessMembers(id, members, { addedBy?, projectId? }) (max 100) / removeBusinessMembers(id, { contactIds?, emails?, projectId? }) — memberships onlyPOST / DELETE /api/v1/businesses/{id}/memberscontacts:write
addBusinessMembersByDomain(id, { projectId? }) — adds contacts at the business's domainsPOST /api/v1/businesses/{id}/members/by-domaincontacts:write
listSenders / createSender(input) — input.projectId? targets a project when the org has more than one/api/v1/senderssenders:read / senders:write
getSender / updateSender / deleteSender/api/v1/senders/{id}senders:read / senders:write
listUseCases({ projectId?, includeArchived? }) / createUseCase(input) — input.projectId? as for createSender/api/v1/use-casesuse-cases:read / use-cases:write
getUseCase / updateUseCase / archiveUseCase / restoreUseCase — delete archives; restore undoes it/api/v1/use-cases/{id}use-cases:read / use-cases:write
importSenders({ projectId? }) — adopts the project's provider-account identities; re-run to pick up provider verificationPOST /api/v1/senders/importsenders:write
listDomains / createDomain(domain, { projectId }) / getDomain / deleteDomain/api/v1/domainsdomains:read / domains:write
getDomainDnsRecords / verifyDomain/api/v1/domains/{id}/…domains:read / domains:write
getDomainBrandedTracking / enableDomainBrandedTrackingGET / POST /api/v1/domains/{id}/branded-trackingdomains:read / domains:write
listInboxes / getInbox / updateInbox(id, { displayName?, forwardTo?, storeMessages?, openTracking?, status? }) — openTracking: true needs an active HTTPS branded host/api/v1/inboxes, /api/v1/inboxes/{id}inboxes:read / inboxes:write
listSuppressions / addSuppressions/api/v1/suppressionsorg:read / org:write
getProjectAddress(projectId) / setProjectAddress(projectId, { physicalAddress, addressComponents? })GET / PUT /api/v1/projects/{id}/addressorg:read / org:write
getInboxDbStatusGET /api/v1/inbox-db/statusorg:read
connectInboxDb({ connectionString, provider? }) (202, starts a background connect) / testInboxDbConnectionPOST /api/v1/inbox-db/connection / …/connection/testorg:write
getInboxDbConnectAttempt (poll until status is not running)GET /api/v1/inbox-db/connectionorg:read

Application-level details worth knowing before you hit them:

  • List methods keep pagination. listTemplates and friends return { data, pagination: { page, perPage, total } } — the envelope's pagination is the point of calling a list route, so it is not unwrapped away. Single-resource methods return the payload directly, same as the send methods.
  • Businesses. client.businesses groups the ten business methods: list, get, create, update, delete, upsertByExternalRef, addByDomain and members.{list, add, remove}, each delegating to the flat method above. Tenancy: projectId goes in the query for get, delete and addByDomain, and in the body for update, createBusiness and the member writes. Omit it only when the org has one project.
  • Updates are PATCH, and partial. Send only the fields you are changing.
  • Campaign reads take an optional projectId. Omit it only when the org has one project; otherwise the server answers 400 PROJECT_REQUIRED. A campaign in another project is a 404 NOT_FOUND, same as a missing one. An org with no project gets 422 NO_PROJECT; one with no marketing provider gets 404 NO_MARKETING_PROVIDER. listCampaigns filters after the provider's page, so a page can hold fewer than perPage rows. The analytics route needs analytics:read, not campaigns:read.
  • renderTemplate(id, variables) sends the variables map as the whole body — not wrapped in a variables key.
  • deleteTemplate never 404s: an unknown id resolves { deleted: false } on a 200. Every other delete throws on a missing resource.
  • Lists have no update or delete — the API exposes none; the SDK does not invent them.
  • Suppression scope is cross-field: scope: "use_case" requires use_case_id, and use_case_id with scope: "account" is a 400. projectId names the project the rows land in; omitted, they land in the use case's project, else in every project the key's owner can open.
  • Managed accounts cannot delete domains — deleteDomain throws 403 MANAGED_ACCOUNT.
  • The inbox-db connection string is sent once and never echoed. connectInboxDb stores it encrypted; InboxDbStatus omits it. schemaName is the Postgres schema NevarMail owns in your database.

Anything still not covered — webhook endpoints, for one — goes through request() (or requestPaginated() for list routes), which applies the same auth, envelope unwrapping, throwing, and retries:

const endpoints = await nevarmail.request<unknown>("GET", "/api/v1/webhook-endpoints");

Reach for request() rather than writing a second HTTP client beside this one. That is how the swallowed-error problem happened the first time.

sendEmailToList returns a rollup, not a verdict

A list send that reaches some recipients is a 200 with status: "partial" and a non-zero failedCount. Check the fields; a 2xx does not mean everyone received it.

const run = await nevarmail.sendEmailToList({ listId, subject, htmlContent });
if (run.status !== "sent") console.error(run.failedCount, run.failures);

Scheduling needs a real instant

scheduled_for must carry an explicit offset — 2026-09-01T14:00:00Z or 2026-09-01T10:00:00-04:00. A bare local time is four different moments depending on who typed it, so the API rejects it rather than guessing. Convert on your side, where the zone is actually known. Templated scheduled sends are refused: the processor does not render templates, so accepting one would queue an empty body.

Agent inboxes — NevarMailInbox

An agent inbox has its own API (/api/v1/inbox/*) and its own credential: a client id (nvm_inbox_…) and secret from the inbox's Agents tab. API keys are refused there, and an inbox credential reaches nothing outside its own inbox, so it is a separate client:

import { NevarMailInbox } from "@bernierllc/nevarmail";

const inbox = new NevarMailInbox({
  clientId: process.env.NEVARMAIL_INBOX_CLIENT_ID!,
  clientSecret: process.env.NEVARMAIL_INBOX_CLIENT_SECRET!,
  // scopes: ["inbox:read"], // narrow the token; defaults to inbox:read + inbox:send
});

const { data } = await inbox.listEmails({ perPage: 10 });
for (const summary of data.filter((e) => !e.isRead)) {
  const email = await inbox.getEmail(summary.id);
  await inbox.send({
    to: email.from,
    subject: `Re: ${email.subject ?? ""}`,
    text: "Got it.",
    reply_to_email_id: email.id,
  });
  await inbox.markEmailRead(email.id);
}
MethodRouteScope
listEmails({ page, perPage, threadId })GET /api/v1/inbox/emailsinbox:read
getEmail(id)GET /api/v1/inbox/emails/{id}inbox:read
markEmailRead(id)POST /api/v1/inbox/emails/{id}/readinbox:read
deleteEmail(id)DELETE /api/v1/inbox/emails/{id}inbox:read
send(input)POST /api/v1/inbox/sendinbox:send
rotateCredentials()POST /api/v1/inbox/credentials/rotateinbox:read
  • Tokens are handled for you. The client exchanges the credential for a short-lived token (OAuth client_credentials at /api/oauth/token) on first use, renews it a minute before expiry, and on a 401 gets one fresh token and retries once. A rejected credential throws code: "INVALID_CLIENT".
  • send is deliberately narrow. From is always the inbox's address; at most 5 recipients across to and cc; no templates or lists. Hitting the daily limit is a 429 inbox_daily_limit_reached that throws at once, with retryAfter set to the seconds until UTC midnight.
  • rotateCredentials() returns the new secret once and switches the client to it. Store it. The old secret stops working.

Same options (baseUrl, timeoutMs, maxRetries, fetch) and the same error contract as NevarMail.

Options

new NevarMail({
  apiKey: process.env.NEVARMAIL_API_KEY!,
  baseUrl: "https://app.nevarmail.com", // default
  timeoutMs: 30_000,                    // default
  maxRetries: 2,                        // default
  fetch: undefined,                     // inject one for tests or a custom runtime
});

Use-case groups and inherited settings

Create reusable project defaults with createUseCaseGroup, then create a use case with only its name and group ID. Groups allow suppressable, transactional, or mixed use cases; mixed groups have a default kind and separate tracking defaults per kind.

const group = await mail.createUseCaseGroup({
  name: "Account emails", projectId,
  allowedKind: "transactional", defaultSuppressable: false,
  defaults: {
    shared: { fromName: { value: "Accounts" } },
    suppressable: {}, transactional: { clickTracking: { value: false } },
  },
});
const login = await mail.createUseCase({ name: "Login", groupId: group.id });
await mail.updateUseCase(login.id, {
  expectedRevision: login.settingsRevision,
  settings: { set: { replyToEmail: null }, inherit: ["fromName"] },
});

An omitted override inherits; explicit false disables; explicit null clears a nullable field. settings on responses includes effective/configured values, source and override status. An override equal to the group value remains an override. Group defaults stay live for inheritors.

Use listUseCaseGroups, getUseCaseGroup, and deleteUseCaseGroup to manage groups. Delete returns {id, deleted: true} and refuses a group containing any members, including archived ones. To edit defaults, call previewUseCaseGroupUpdate(id, {changes}), review affected members, then updateUseCaseGroup with those changes, the preview's revision and token.

const target = { groupId: destinationId }; // null means Ungrouped
const preview = await mail.previewUseCaseGroupMove(login.id, target);
// Show preview.rows and both outcomes to the user before applying.
const idempotencyKey = globalThis.crypto.randomUUID();
const moved = await mail.moveUseCaseToGroup(login.id, {
  ...target, previewToken: preview.previewToken,
  policy: "inherit_destination", // or "preserve_effective"
  idempotencyKey,
});

Moves retain existing overrides. preserve_effective additionally snapshots inherited values as overrides. A kind change supplies suppressable through the same preview/apply flow, not an ordinary metadata PATCH. Tokens expire after 15 minutes and are bound to the actor/scope; use the same credential for preview and apply. A stale preview returns 409 and is not retried: fetch another comparison and obtain confirmation again.

The move key is a UUID in the request body. Omission generates one once per SDK call, preserved across automatic retries. Retain and reuse an explicit key when retrying a whole call after an ambiguous network result. A committed retry returns its original result even after token expiry; reusing the key for a different move is a conflict.

Production transitions require confirmation: {productionSending: true} and server permissions/readiness. Enabling tracking for transactional mail requires transactionalTracking: "transactional-tracking-v1" after the user sees the disclosure. Never auto-confirm these choices. Tracking is resolved server-side; SendEmailInput has no tracking overrides. Keep API credentials server-side: app browser components use session routes.

版本与发布记录

@bernierllc/nevarmail 共发布 31 个版本,首次发布于 2026/09/11;过去 12 个月发布了 31 个版本。

版本发布日期距今
0.30.0latest2026/11/053 天前
0.29.02026/10/2316 天前
0.28.02026/10/2118 天前
0.27.02026/10/2019 天前
0.26.02026/10/2019 天前
0.25.02026/10/2019 天前

关于 @bernierllc/nevarmail 的常见问题

@bernierllc/nevarmail 是什么?

Official client for the NevarMail v1 API. Typed, retries 429/5xx, throws NevarMailError on every non-2xx.

如何安装 @bernierllc/nevarmail?

在项目目录运行 npm install @bernierllc/nevarmail,或使用 pnpm add / yarn add / bun add。

@bernierllc/nevarmail 支持 TypeScript 吗?

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

@bernierllc/nevarmail 支持 ESM 还是 CommonJS?

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

@bernierllc/nevarmail 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=20.0.0。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@bernierllc/nevarmail 可以商用吗?

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

@bernierllc/nevarmail 的下载量有多少?

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

@bernierllc/nevarmail 还在维护吗?

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