Ecosystem ↗

Email delivery, inboxes and campaigns

Separate transport, consent, tracking, scheduling and durable delivery state.

↗ View Markdown

Configure an SMTP manager#

ts
import { createEmailManager } from '@zeruxjs/email';
const mail = createEmailManager({ accounts: [{
  name: 'transactional',
  smtp: { host: 'smtp.example.com', port: 587, requireTLS: true,
    auth: { type: 'password', user: 'sender@example.com', pass: process.env.SMTP_PASSWORD! } },
  defaults: { from: { name: 'Sender', address: 'sender@example.com' } }
}] });
await mail.send({ account: 'transactional', to: 'person@example.net',
  subject: 'Receipt', text: 'Your receipt is ready.', tracking: { enabled: false } });
await mail.close();

Marketing sends require an affirmative current subscription from an injected preferences service. Transactional purpose is separate. Neither permission nor an unsubscribe silently changes message tracking. Instrumentation is per-send opt-in and explicit send options can disable it.

Templates and attachments#

Use escaped template variables and the email layout helpers for supported email-safe markup. Converting arbitrary browser HTML/CSS cannot make every mail client support it. Attachments can stream from the files manager with configured bounds; do not accept arbitrary local paths from clients.

Queues and delivery ambiguity#

The built-in transient queue and manager-lifetime idempotency are not durable exactly-once delivery. Use persisted message IDs with the BullMQ adapters and a shared CAS delivery record for restart-safe workflows. SMTP timeout after acceptance can be ambiguous; mark uncertain outcomes for operator reconciliation rather than blindly resending.

Inbound mail#

createEmailReceiver supports configured IMAP/POP3 polling, MIME parsing and attachment policies. Choose package-managed/mapped/custom inbox storage or JSON-only mode. Persist cursors per mailbox and verify access for UI APIs. POP3 cannot mutate IMAP-style flags; JSON-only receive mode does not deduplicate for you.

Campaigns and governance#

Campaign planning, outbox checkpoints, suppression, dashboards and exports are opt-in application integrations. Supply host authorization, consent checks, durable stores and signed provider webhook verification. Archive is not deletion; redaction has retention/legal-hold boundaries. The package README includes the detailed contracts for each subsystem.

Install and select the integration#

sh
npm install @zeruxjs/email@0.0.4

This package participates in the runtime's email integration. Start with email configuration, which documents zerux.config.js, its exact values and the matching TypeScript setup. Runtime setup and standalone factory input are different contracts; do not paste a factory argument under a config key without checking the adapter layer.

A practical integration workflow#

Use named SMTP accounts, provider-compatible TLS settings and a separate transactional/marketing policy. Runtime config supplies files, tracking, preferences and reputation adapters when configured; a standalone manager needs those real dependencies explicitly. Keep recipient rules and attachment limits close to the manager policy, and choose a default account before sending.

TypeScript: inputs, results and ownership#

ts
import { createEmailManager } from '@zeruxjs/email';

type Inputs = Parameters<typeof createEmailManager>;
type Result = Awaited<ReturnType<typeof createEmailManager>>;

Inputs is the function's complete argument tuple, including optional parameters. Result removes a Promise when the function is asynchronous. These aliases follow the installed declarations; no internal src/dist import is required. For a generic factory, keep the concrete call's inferred type when an application needs a narrower result than ReturnType preserves. Use satisfies or a public option type for configuration literals, and validate unknown request/file data separately.

In a JavaScript application the equivalent editor annotation is /** @type {Parameters<typeof createEmailManager>} */ on a matching argument tuple. This provides diagnostics only when JavaScript checking is enabled. It does not validate network data or create a resource automatically. See strict application setup for the complete NodeNext checker configuration.

Failure modes and resource lifetime#

Check accepted/rejected/unknown outcomes rather than treating a resolved SMTP call as final delivery. Keep consent/suppression/idempotency in durable shared stores for retryable marketing. Inbound persistence, feedback signatures and dashboard authorization are separate features. Close owned SMTP and inbound resources, and never place provider passwords, signing secrets or raw inbox payloads in client bundles.

Verify before deploying#

  • Exercise the example with the actual owning runtime/browser, not only an imported module.
  • Test one invalid input and the relevant unavailable/denied dependency path; do not turn those into a silent success.
  • Confirm teardown leaves no owned listeners, timers, connections or stale per-user state.
  • Check the production entry point and installed export map; a workspace checkout can contain features not yet published to npm.

Public option and data contracts#

These tables list fields declared directly on the named public contract. Referenced/inherited types retain their own declarations; optional does not imply a default value. Use the lifecycle and configuration chapters above for actual initialization behavior.

EmailManagerOptions#

Field Accepted type/value Optional
accounts EmailAccount[] No
defaultAccount string Yes
maxRecipients number Yes
suppressions string[] Yes
rules RecipientRule[] Yes
authorize (message:Readonly<EmailMessage>,account:string)=>boolean|Promise<boolean> Yes
files { stat(addr:{storage?:string;bucket:string;key:string}):Promise<{size:number;contentType:string}|null>;read(addr:{storage?:string;bucket:string;key:string}):Promise<AsyncIterable<Uint8Array>>} Yes
maxConcurrentSends integer 1–128, default 8 Yes
sendTimeoutMs integer 1–600000ms, default 30000 Yes
maxAttachmentStreams integer 1–40, default 4 Yes
maxAttachmentBytes number Yes
idempotencyTtlMs number Yes
maxIdempotencyKeys number Yes
tracking {instrumentHtml(html:string,context:{id:string;account:string;message:Readonly<EmailMessage>}):string|Promise<string>; onSent?(event:{id:string;account:string;message:Readonly<EmailMessage>;result:Readonly<EmailResult>}):void|Promise<void>} Yes
marketingSuppression {isSuppressed(address:EmailAddress):Promise<boolean>} Yes
preferences { isSubscribed(address:EmailAddress):Promise<boolean>; makeUnsubscribeUrl(address:EmailAddress):string; } Yes
reputation { checkBeforeSend:(account:string,purpose:'marketing'|'transactional')=>Promise<{allowed:boolean}>; recordAccepted:(account:string,email:string,messageId:string)=>Promise<boolean|unknown>; } Yes
onSmtpWarning (warning:{account:string;status:'accepted'|'partial'|'rejected'|'unknown';reason:string})=>void Yes
onReputationError (error:unknown)=>void Yes
onTrackingError (error:unknown)=>void Yes

EmailAccount#

Field Accepted type/value Optional
name string No
smtp SmtpSettings Yes
transport TransportLike Yes
ownTransport boolean, default true Yes
receive Omit<import('./inbound.js').IncomingAccount,'name'> Yes
defaults Partial<EmailMessage> Yes
maxRecipients number Yes
enabled boolean Yes

SmtpSettings#

Field Accepted type/value Optional
host string No
port number No
secure boolean Yes
requireTLS boolean Yes
ignoreTLS boolean Yes
auth SmtpAuth Yes
pool boolean Yes
maxConnections number Yes
maxMessages number Yes
connectionTimeout number Yes
greetingTimeout number Yes
socketTimeout number Yes
tls {servername?:string;minVersion?:'TLSv1.2'|'TLSv1.3';rejectUnauthorized?:boolean;ca?:string|string[]} Yes
dkim {domainName:string;keySelector:string;privateKey:string;cacheDir?:string} Yes

EmailSendOptions#

Field Accepted type/value Optional
signal AbortSignal Yes
timeoutMs integer 1–600000ms Yes
deadline bounded absolute epoch milliseconds Yes
htmlMode 'email'|'trusted' Yes
allowLocalAttachmentPaths boolean Yes
tracking boolean|NonNullable<EmailMessage['tracking']> Yes

EmailPreferencesOptions#

Field Accepted type/value Optional
baseUrl string No
secret string No
path string Yes
store ConsentStore Yes
maxAgeMs number Yes
postTimeoutMs number Yes

EmailReceiverOptions#

Field Accepted type/value Optional
accounts IncomingAccount[] No
store InboundStore Yes
mode 'persist'|'json' Yes
onMessage (message:InboundMessage)=>Promise<void>|void Yes
inspectRaw (context:{account:string;raw:Buffer})=>Promise<'clean'|'spam'|'phishing'|'malware'|'review'> Yes
inspectAttachment (context:{account:string;messageId:string;filename:string;content:Buffer})=>Promise<'clean'|'suspicious'|'blocked'> Yes
attachmentContent 'metadata'|'base64' Yes
maxInlineAttachmentBytes number Yes
connections IncomingConnections Yes
parse (raw:Buffer)=>Promise<ParsedIncomingMail> Yes
sanitizeHtml (html:string)=>string|Promise<string> Yes
saveAttachment (context:{account:string;messageId:string;attachment:InboundAttachment;content:Buffer})=>Promise<string> Yes
now ()=>number Yes

MarketingCampaignServiceOptions#

Field Accepted type/value Optional
db CampaignDatabaseConnection No
queue MarketingQueue No
delivery MarketingDeliveryStore No
renderer MarketingRenderer Yes
isSubscribed (email:string)=>Promise<boolean>|boolean Yes
isSuppressed (email:string)=>Promise<boolean>|boolean Yes
verifyMissingJob (messageId:string)=>Promise<boolean>|boolean Yes
approveRetention (campaign:Readonly<StoredMarketingCampaign>)=>Promise<boolean>|boolean Yes
authorize (action:'create'|'read'|'enqueue'|'cancel'|'render'|'append'|'pause'|'resume'|'redrive'|'schedule'|'archive'|'export'|'exportPII'|'retention'|'finalize',code?:string)=>Promise<boolean>|boolean Yes
onQueueError (code:string,error:unknown)=>void|Promise<void> Yes
verifyProvider (account:string)=>boolean|Promise<boolean> Yes
onAudit (entry:{code:string;action:'create'|'append'|'enqueue'|'pause'|'resume'|'cancel'|'redrive'|'schedule'|'archive'|'export'|'retention'|'finalize';at:number;count?:number})=>void|Promise<void> Yes
table string Yes
recipientTable string Yes
now ()=>number Yes

EmailLayoutOptions#

Field Accepted type/value Optional
maxWidth number Yes
background string Yes
direction 'ltr'|'rtl' Yes
language string Yes
preheader string Yes

HtmlConversionOptions#

Field Accepted type/value Optional
layout boolean Yes
maxInputBytes number Yes
allowImages boolean Yes
allowRemoteImages boolean Yes

EmailQueueOptions#

Field Accepted type/value Optional
concurrency number Yes
maxPending number Yes
retries number Yes
backoffMs number Yes
maxBackoffMs number Yes

CampaignPlannerOptions#

Field Accepted type/value Optional
store CampaignStore No
source CampaignSource No
isSubscribed (email:string)=>Promise<boolean> No
isSuppressed (email:string)=>Promise<boolean> No
ensureMessage (target:CampaignTarget,context:{campaignId:string;tracking:EmailMessage['tracking']})=>Promise<void> No
enqueue (messageId:string,sendAt:number)=>Promise<void> No
now ()=>number Yes
batchSize number Yes
leaseMs number Yes
onAudit (entry:{campaignId:string;action:string;at:string})=>void|Promise<void> Yes
onMetric (metric:{campaignId:string;kind:'queued'|'scheduled'|'completed'|'cancelled';at:number;count:number})=>Promise<void>|void Yes

CampaignDashboardOptions#

Field Accepted type/value Optional
planner CampaignPlanner No
authorize (request:Request)=>unknown|Promise<unknown> No
verifyCsrf (request:Request,principal:unknown)=>boolean|Promise<boolean> No
csrfToken (request:Request,principal:unknown)=>string|Promise<string> No
authorizeCampaign (principal:unknown,id:string,action:string)=>boolean|Promise<boolean> No
deliveryReport (campaignId:string)=>Promise<unknown> Yes
performanceReport (campaignId:string)=>Promise<unknown> Yes
trendsReport (campaignId:string,from:string,to:string)=>Promise<unknown> Yes
prefix string Yes
publicOrigin string Yes

MarketingFeedbackHttpOptions#

Field Accepted type/value Optional
verifySignature (body:Uint8Array,headers:Headers)=>Promise<boolean>|boolean No
parse (body:Uint8Array,headers:Headers)=>Promise<VerifiedEmailNotification[]>|VerifiedEmailNotification[] No
suppression ReturnType<typeof createMarketingSuppressionService> No
reputation {recordFeedback(input:{account:string;email:string;eventId:string;kind:'hard-bounce'|'soft-bounce'|'complaint';at?:number}):Promise<unknown>} Yes
feedbackAccount (event:MarketingFeedback)=>string Yes
providerOutcomes {record(event:{eventId:string;provider:string;campaignId:string;messageId:string;kind:'hard-bounce'|'soft-bounce'|'complaint'|'delivered'|'deferred';occurredAt:number}):Promise<boolean>} Yes
feedbackDelivery (event:VerifiedEmailNotification)=>{provider:string;campaignId:string;messageId:string;occurredAt:number} Yes
path string Yes
maxBodyBytes number Yes
timeoutMs number Yes
maxEvents number Yes

ReputationPolicy#

Field Accepted type/value Optional
windowMs number Yes
minimumAccepted number Yes
warnBouncePercent number Yes
pauseBouncePercent number Yes
warnComplaintPercent number Yes
pauseComplaintPercent number Yes
warnSoftBouncePercent number Yes
pauseSoftBouncePercent number Yes
protectTransactional boolean Yes
scanLimit number Yes

CampaignImportOptions#

Field Accepted type/value Optional
batchSize number Yes
maxRecipients number Yes
enqueueAfter boolean Yes
signal AbortSignal Yes
onProgress (result:{processed:number;added:number;existing:number})=>void|Promise<void> Yes

MarketingCampaignDashboardOptions#

Field Accepted type/value Optional
service MarketingCampaignService No
authorize (request:Request)=>unknown|Promise<unknown> No
authorizeCampaign (principal:unknown,code:string,action:'read'|'recipients'|'preview'|'append'|'enqueue'|'pause'|'resume'|'cancel'|'redrive'|'schedule'|'archive'|'finalize'|'export')=>boolean|Promise<boolean> No
authorizeCreate (principal:unknown)=>boolean|Promise<boolean> No
authorizeList (principal:unknown)=>boolean|Promise<boolean> Yes
verifyCsrf (request:Request,principal:unknown)=>boolean|Promise<boolean> No
csrfToken (request:Request,principal:unknown)=>string|Promise<string> No
prefix string Yes
publicOrigin string Yes

MarketingImportJobOptions#

Field Accepted type/value Optional
db CampaignDatabaseConnection No
id string No
campaignCode string No
sourceRevision string No
source MarketingImportSource No
append (campaignCode:string,entries:readonly (string|MarketingRecipient)[])=>Promise<{added:number;existing:number;recipientCount:number}> No
batchSize number Yes
leaseMs number Yes
now ()=>number Yes
table string Yes

ContactRetentionOptions#

Field Accepted type/value Optional
store MailingContactStore No
olderThan number No
canErase (contact:Readonly<MailingContact>)=>Promise<boolean>|boolean No
dryRun boolean Yes
cursor string|null Yes
pageSize number Yes
maxScanned number Yes
signal AbortSignal Yes
onErased (metadata:{contactId:string;at:number})=>void|Promise<void> Yes
now ()=>number Yes

IncomingPollOptions#

Field Accepted type/value Optional
cursor IncomingPollCursor Yes
mailbox string Yes

MappedDatabaseInboxOptions#

Field Accepted type/value Optional
table string No
createTable boolean Yes
columns Partial<MappedInboxColumns> Yes

MailboxSyncOptions#

Field Accepted type/value Optional
db CampaignDatabaseConnection No
id string No
account string No
mailbox string Yes
receiver {poll:(account:string,options:{cursor:IncomingPollCursor;mailbox?:string})=>Promise<{stored:number;nextCursor?:IncomingPollCursor}>} No
leaseMs number Yes
table string Yes
now ()=>number Yes

InboundRetentionOptions#

Field Accepted type/value Optional
store InboundStore No
account string No
olderThan number No
canErase (message:Readonly<InboundMessage>)=>boolean|Promise<boolean> No
dryRun boolean Yes
cursor string|null Yes
pageSize number Yes
maxScanned number Yes
signal AbortSignal Yes
afterErase (entry:{id:string;account:string;references:string[]})=>Promise<void>|void Yes

InboxApiOptions#

Field Accepted type/value Optional
store InboundStore No
authorize (access:InboxAccess)=>Promise<boolean>|boolean No
receiver Pick<EmailReceiver,'poll'|'markSeen'|'markUnread'|'setFlagged'|'folders'|'accounts'> Yes
canErase (message:Readonly<InboundMessage>)=>Promise<boolean>|boolean Yes

Sending source and cleanup map#

The package README maps lazy SMTP ownership to manager.ts, recipient dispositions to smtp-outcomes.ts, byte-reader disposal to attachments.ts, local retries to queue.ts and committed one-click changes to preferences.ts. The public examples/bounded-send.ts adapter returns status/counters and borrows its native transport explicitly. Its close drains the manager without closing the host transport.

Keep the local queue's close policy separate: default close rejects queued work without awaiting active sends; explicit drain observes completion and requires the host to stop producers first. V1 template/policy loading validates staged data, merges templates/suppressions and replaces rules. These registries and trusted placeholder/fingerprint objects are not code sandboxes or distributed transactions. Review raw native errors, SMTP responses, mailbox values and unsubscribe URLs as private application data.

The paged planner's completed state means planning has finished, so queued delivery remains allowed. The separate function-first marketing service finalizes registered terminal deliveries and then blocks further sends. Campaign metadata, recipient inserts, delivery records and queue publication are separate commits. Explicit pending redrive checks exact durable identity, fresh policy and native queue absence; sending/uncertain outcomes are never replayed automatically.

Import jobs bind aggregate checkpoints to a host-asserted immutable source revision. Changed source data needs a new job identity/revision. An append may commit before its checkpoint, so replay must use identical recipient data and durable idempotence. Lease cancellation prevents later checkpoint commits; it cannot forcibly cancel native persistence already in progress.

Inbound receiver close stops new polls without cancelling or draining an active poll. Native IMAP operations release their locks and connections; injected drivers and stores retain host ownership. Attachment persistence can precede message metadata, making orphan repair a host responsibility. Inbox summaries remain private untrusted text even after authorization.

Both optional dashboards require an opaque verified principal on pages, assets and API calls. Writes additionally require exact origin, host CSRF and action-scoped permission. Their five-second body-reader deadlines do not bound arbitrary host callbacks. Browser results render through text-only DOM operations; email HTML is displayed as data rather than injected into the console. Package parser/HTTP fixtures do not certify a live browser, provider or assistive technology.

Public entry points

Manifest reference for @zeruxjs/email@0.0.4. Each target describes a published entry, not automatic framework registration.

ImportTarget
@zeruxjs/email
{
  "types": "./dist/index.d.ts",
  "default": "./dist/index.js"
}
@zeruxjs/email/html
{
  "types": "./dist/html.d.ts",
  "default": "./dist/html.js"
}
Runtime requirements and dependencies
{
  "engines": {},
  "dependencies": {
    "html-to-text": "^10.0.1",
    "imapflow": "^1.0.0",
    "juice": "^11.0.0",
    "mailparser": "^3.14.3",
    "node-pop3": "^0.15.1",
    "nodemailer": "^10.5.0",
    "sanitize-html": "^2.13.0"
  },
  "peerDependencies": {
    "@zeruxjs/files": "0.0.4"
  },
  "bin": {}
}
Full package README

@zeruxjs/email#

Multiple independent SMTP connections, Nodemailer transport reuse, password or OAuth2 authentication, optional pooling, DKIM, typed templates, email-safe HTML and tables, recipient suppression, batching, transient retry queues, attachment streams from @zeruxjs/files, and opt-in email analytics hooks.

ts
import {createEmailManager,createEmailLayout,emailRow} from '@zeruxjs/email';
import {createEmailTrackingService} from '@zeruxjs/analytics/email';

const tracking=createEmailTrackingService({
  analytics,baseUrl:'https://events.example.com',secret:process.env.EMAIL_TRACKING_SECRET!
});
const mail=createEmailManager({
  accounts:[
    {name:'transactional',smtp:{host:'smtp.example.com',port:587,requireTLS:true,
      auth:{type:'password',user:'sender@example.com',pass:process.env.SMTP_PASSWORD!}},
      defaults:{from:{ name: 'Sender', address: 'sender@example.com' }}},
    {name:'notifications',smtp:{host:'smtp.example.com',port:465,secure:true,
      auth:{type:'oauth2',user:'alerts@example.com',clientId:process.env.OAUTH_CLIENT_ID!,
        clientSecret:process.env.OAUTH_CLIENT_SECRET!,refreshToken:process.env.OAUTH_REFRESH_TOKEN!}},
      defaults:{from:{ name: 'Alerts', address: 'alerts@example.com' }}}
  ],
  tracking,files // optional named Files manager for attachment streaming
});

await mail.send({account:'transactional',to:'person@example.net',subject:'Order receipt',
  html:createEmailLayout(emailRow(['Item','Price'])),text:'Order receipt',
  tracking:{enabled:false}});

// Marketing/notification tracking is a PER-SEND explicit choice.
await mail.send({account:'notifications',to:'person@example.net',subject:'News',
  html:'<p>View our news</p>',text:'View our news'},
  {tracking:{enabled:true,opens:true,clicks:true,campaign:'october'}});

await mail.close();

Tracking does not run automatically, even if it appears in an account default. {tracking:false} in send options overrides message tracking. Successful delivery is never retried because of tracking instrumentation, database writes, or warning callbacks. The open pixel has approximate accuracy (images can be blocked, cached or prefetched); clicks and opens do not imply explicit human engagement. Tracking links exclude unsubscribe URLs and should be enabled only when lawful and disclosed to recipients.

convertHtmlToEmail sanitizes and inlines a supported subset of HTML/CSS, createEmailLayout, emailRow, emailButton produce email-client-friendly table markup, and createTemplateRegistry manages reusable templates with escaped variables. Arbitrary CSS Grid/Flex and complex interactivity cannot be made universally compatible across mail clients.

Delivery safeguards include createRecipientFilter for allow/block/suppression lists, encrypted-transport policy options, optional authorize hook for @zeruxjs/auth, bounded batches, idempotency keys scoped to a manager lifetime, and a bounded in-memory transient-error queue. The included createEmailQueue is not durable; use the existing durable queue/outbox integration to persist scheduling, suppression, campaign state and send IDs before delivery. Never use open/click telemetry as a substitute for unsubscribe/consent handling.

For file attachments supply files when creating the manager and set attachments:[{filename:'manual.pdf',file:{storage:'object',bucket:'uploads',key:'manual.pdf'}}]. Streams are bounded by the configured attachment limits. Avoid local path attachments unless explicitly allowed for trusted server code.

Marketing permission is separate from activity tracking#

purpose:'marketing' requires one recipient and an affirmative subscribed record in an injected preferences service. Marketing unsubscribe endpoints are hosted by ZeruxJS and require a POST to modify preference state. purpose:'transactional' remains independent of the marketing permission store. Neither an opt-in subscription nor an unsubscribe silently changes tracking for any message. Tracking is still off unless explicitly requested on the send, and can be overridden by {tracking:false}.

For tracked deliveries, EmailResult.trackingId identifies activity reports (an opaque UUID), but is only supplied when tracking is requested and a tracking service is injected. message.tracking accepts opens, clicks, downloads, and a list of exact HTTPS downloadLinks. For per-email first/last timestamps and counts call tracking.getActivity(result.trackingId) in @zeruxjs/analytics. A link click is not a proven completed download. Attachments are not trackable after delivery.

Marketing campaigns#

Use createCampaignPlanner for persistent, segmented recipient paging and stage-before-enqueue checkpoints, createDatabaseCampaignStore for atomic multi-node document CAS, createMarketingSuppressionService with a shared store for hard/soft bounce and complaint handling, and createCampaignDashboard/createMarketingFeedbackEndpoint for opt-in authenticated routes. createCampaignAnalyticsReporter can emit best-effort operational events for internal Reports. Never assume memory stores provide restart/multi-server safety; provider webhooks must verify raw-body signatures.

See the durable email guide for queue integration and delivery controls.

Receiving email (IMAP/POP3)#

createEmailReceiver({accounts,store}) supports IMAP/POP3 TLS inbox polling, deduplicated message storage, MIME parsing, optional attachment storage and explicit IMAP read-flag updates. Configure combined SMTP/incoming accounts through ZeruxJS email.accounts[].receive plus email.receive.database, or use email.receive.accounts for receive-only applications. Incoming HTML is sanitized and no public inbox endpoints are enabled.

Inbox storage choice and UI APIs#

The package-managed inbox table remains available: createDatabaseInboxStore(db, table) creates the table lazily, deduplicates by remote message identity and supports paginated get/page reads. Use createMappedDatabaseInboxStore(db,{table,columns,createTable:false}) to write to an existing application-owned table with mapped identity, payload and optional metadata columns without creating schema. For completely custom schemas, inject your own InboundStore.

Alternatively createEmailReceiver({accounts,mode:'json',onMessage}) returns the parsed message payloads directly and does not require an inbox database. This mode does not deduplicate for you.

Build private inbox UIs using createInboxApi({store,receiver,authorize,canErase}): list, search, get, folders, markSeen, markUnread, setFlagged, poll and eraseLocal. Authentication/authorization is mandatory for API creation, list/search pages are summary-only, search scans at most a configured number of rows, and deletion needs explicit retention approval. IMAP mutations check UIDVALIDITY; POP3 cannot set remote read/flag state.

Multiple IMAP mailboxes and stronger inbound validation#

Use IncomingAccount.mailboxes as an allowlist for additional IMAP folders and receiver.poll(name,{mailbox,cursor}) for folder-scoped receiving. Returned nextCursor.mailbox must be persisted and reused only for the same mailbox. receiver.pollConfiguredMailboxes() is a convenience without durable cursors; for multi-server jobs, create one createDatabaseMailboxSync({...,mailbox}) coordinator per configured folder. The old poll() default-folder API remains.

POP3 UIDL entries are validated for uniqueness; invalid MIME attachment metadata is rejected, and oversized attachment binary is not exposed or stored. The Inbox API poll(account,mailbox?) passes the selected mailbox to authorize before network access. The package-managed inbox table, host-owned mapped tables and JSON-only mode remain supported. See the email guide for configuration and storage boundaries.

Function-based campaign completion#

createMarketingCampaignService includes schedule, report, finalize, exportPage, archive and opt-in archival redactArchived in addition to existing create/preview/import/queue/paused operations. Configure renderer:createVariableRegistry() for globally registered subject/HTML/text variables; segment supports the supported all/any primitive conditions. Require durable onAudit:createDatabaseMarketingAuditLog(db).record for PII-free audit trails, real verifyMissingJob/queue.getJob before redrive, and consent/suppression policies both at queue and SMTP boundaries. Use the durable outbox for Redis outage recovery. The dashboard presents paginated, privacy-limited reports/exports with per-action host authorization and CSRF.

Important: archive is not a deletion operation: it accepts only cancelled or safely finalized campaigns with no nonterminal/uncertain delivery. redactArchived defaults to dry-run, requires a legal-hold approval hook and a DB delete capability, and erases only campaign-specific template/recipient data, not durable mail activity or consent/suppression stores. See the email guide for related APIs.

Bounded submission and explicit outcomes#

The public-import example creates a generation-owned manager around a host-owned transport. No transport starts on import. ownTransport:false borrows an injected transport; omission retains the legacy manager-owned default. Call close() during generation teardown. Failed idle verification closes an owned transport once; a released injected handle must be replaced.

maxConcurrentSends defaults to 8 (maximum 128), sendTimeoutMs to 30000 (maximum 600000), and accounts are limited to 64. send accepts signal, timeoutMs and an absolute epoch-millisecond deadline; sendBatch shares a total deadline. Overload rejects with EMAIL_SEND_OVERLOAD. Coalesced idempotent callers have independently bounded observation, without cancelling the first caller's owned operation. Execution controls do not change the message fingerprint.

A deadline bounds caller observation. Actual native work retains its capacity until settlement and attachment cleanup; close() aborts cooperative work and waits for actual settlement before closing owned pools. Injected transports receive an optional second {signal} context. Native Nodemailer has no assumed per-message cancellation contract here: configure SMTP connection/greeting/socket timeouts too. Noncooperative transports or host callbacks can delay shutdown; arbitrary JavaScript is not force-cancelled.

recipientOutcomes distinguishes accepted, rejected, transient, permanent and uncertain; missing or conflicting recipient reports remain uncertain. A fulfilled partial submission never resends accepted recipients. SMTP acceptance is not inbox delivery. See Nodemailer's SMTP result contract. EmailSubmissionError exposes a fixed code/message; its non-enumerable cause and recipient outcomes are trusted internal detail. Use describeSmtpRecipients for address-free counters. Existing EmailResult.response, accepted/rejected lists and message IDs remain private application data, not a sanitized public response.

If optional post-send telemetry stalls after known acceptance, the observed result preserves acceptance with telemetryIncomplete:true. Do not resend. Manager idempotency caches successful and uncertain outcomes for the configured TTL (default five minutes), within a bounded process-local cache; it is not durable or cross-instance deduplication. Ambiguous outcomes need host reconciliation. The in-memory queue retries explicit transient SMTP replies or qualified pre-DATA connection failures only. Bare ECONNRESET/ETIMEDOUT and DATA uncertainty no longer imply safe retry. Failover stops before submission; it never retries a failed send on another account.

Attachment preparation validates up to 40 sources and an aggregate raw-byte budget (default 25 MiB, maximum 1 GiB). File reads open lazily, enforce recorded length including underflow, and limit concurrent underlying streams (maxAttachmentStreams, default 4, maximum 40). resolveAttachmentScope owns wrappers and has awaitable dispose; direct compatibility resolveAttachments callers must destroy unconsumed streams. Files managers remain borrowed. Read/iterator cancellation is cooperative and cleanup waits for actual settlement. String/byte attachments remain supported; arbitrary raw/href options are rejected. Explicit local-path mode accepts absolute regular files only, rejecting URL paths and final symlinks; filesystem ancestry and replacement races remain the trusted host's responsibility. Prefer the authorized files adapter. See Nodemailer's attachment inputs.

Marketing checks fresh boolean subscription/suppression state again at the SMTP boundary, and authorization requires literal true. Tracking remains a separate per-send opt-in; analytics failure does not undo one-click unsubscribe. Queue delivery, SMTP handles and inbox ingestion retain separate ownership. Existing inbox APIs authorize before reads/polls, and inbound MIME/attachment budgets and sanitization remain enforced in inbox-api.ts, inbound.ts and inbound-safety.ts.

Qualification uses injected transports, streams and local HTTP preference fixtures. It does not certify live SMTP/TLS delivery, IMAP/POP3 servers, broker crash recovery or production durable stores.

Authenticated feedback and local sender visibility#

createSenderFeedbackService is an opt-in correlated ledger alongside the unchanged createEmailReputationService policy API. The public-import example binds one tenant/site/account/provider and wires authenticated feedback, suppression and a report endpoint. No collectors, provider polling, remote requests or analytics start on import.

The host must supply a 32-byte private lookupKey, stable across workers/restarts, and a sender-feedback-v1 store. The database adapter createDatabaseSenderFeedbackStore uses immutable primary-key inserts with conflict verification, keyset reads and scope/recipient indexes when the connector exposes createIndex. It borrows the DB connection; the host closes it. Production requires real durable unique inserts and committed reads, not a synthetic adapter. createMemorySenderFeedbackStore is a bounded reference fixture (default 10,000 records), explicitly process-only; using it requires requireDurable:false. No automatic fallback exists.

Records bind tenant/site/account/provider plus provider event, message and canonical recipient identity. Recipients and message references are HMACed before storage; provider event references become opaque IDs. Repeated identical identities return duplicate, while conflicting message/kind/time data rejects. SMTP acceptance has its own identity namespace and keeps the first accepted timestamp on replay. Multiple provider event IDs about the same message/recipient/kind remain ledger evidence but do not inflate a cohort count. Hashes are pseudonymous linkage, not proof of authorship, consent or authorization. Keep ledger/key access private; key rotation requires an explicit data migration or retained lookup strategy, not simply swapping keys and losing suppression history.

Authenticate, persist, then admit more marketing#

createMarketingFeedbackEndpoint retains its mandatory raw-body verifySignature and provider-specific parser. Optional correlatedFeedback:{service,normalize} requires signatureTime(headers); it checks a signed timestamp against a bounded replay window (default five minutes, maximum ten minutes, at most one minute future skew). The verifier must authenticate that timestamp with the actual provider signature, along with the correct raw body, source account/topic and signing-key policy. A separate unsigned timestamp header is not replay protection. Providers with timestamps in their signed envelope can verify the envelope and return that verified value through an appropriately scoped adapter; this package does not ship a universal vendor verifier. For example, SES/SNS has its own signature verification requirements; no certificate-URL fetch or SES subscription auto-confirmation is implemented here. Legacy endpoint configurations without signatureTime keep their existing host-owned replay checks.

Normalize only authenticated provider metadata into {eventId,messageId,email,kind,at}. Resolve message IDs to the exact IDs recorded at SMTP acceptance; do not fuzzy-match email addresses or trust an unsigned tenant/account field. Delivered/deferred notifications also need a verified recipient correlation. All normalized batch records validate before any writes. The correlated ledger is persisted before legacy suppression/reputation/outcome callbacks; failures return 503 and retries remain independently idempotent. These separate stores are not a distributed transaction, and a partially failed batch may already have durable effects.

Wire the new service's isSuppressed into both campaign planning and EmailManager.marketingSuppression. Its durable event ledger is the source of truth: any recorded hard bounce/complaint blocks, and soft bounces block after the configured number of distinct messages (default three). Duplicate notifications do not raise that count. A clock-skewed but accepted future-dated complaint still suppresses immediately. Keep fresh subscription consent checks too. The legacy suppression store remains supported, including its bounded recent-event list; it alone is not the new indefinite deduplication ledger. In-flight sends already admitted cannot be recalled atomically by a later feedback write.

The new service does not invent an account health grade or pause transactional mail. Wire recordAccepted to the manager's existing reputation hook alongside your chosen checkBeforeSend policy. Manager hooks remain best-effort operational telemetry: failed acceptance recording means the cohort ledger may lack that denominator. There is no atomic SMTP/database commit. Do not label such coverage as complete delivery history.

Windows, denominators and late reports#

report(from,until,signal?) uses UTC epoch milliseconds with an inclusive start and exclusive end, a maximum 90-day completed window, and a separately returned asOf. rolling() defaults to seven days. Accepted message/recipient pairs in that window form the denominator. Reported delivery, hard bounce, soft bounce and complaint numerators each count distinct correlated pairs, not raw webhook repetitions. Categories may overlap and must not be summed as mutually exclusive delivery states. Rates are percentages and null when the denominator is zero.

Feedback received later can update a past cohort's numerators. UTC trend days follow the acceptance date, not webhook arrival date. lateFeedback counts unique correlated outcomes first received after the cohort window; unmatchedFeedback counts unique scanned feedback without an acceptance in that cohort (including other-window or missing acceptance records). Missing identity/feedback is not assumed successful delivery. Deferred events are retained but are not a delivered/bounce/complaint numerator. The report exposes retained-ledger/recorded-through coverage and snapshotConsistent:false: paged reads are not an immutable database snapshot, and concurrent inserts may change a subsequent result.

Scans use 500-record keyset pages with a configured retained-history cap (default 10,000, maximum 100,000). Overflow or storage failure rejects coverage instead of returning sampled rates or an unsuppressed result. Reads cooperatively check cancellation between pages; DB execution deadlines remain the host/database policy's responsibility. Large histories require a deliberately maintained aggregate/retention strategy before increasing limits. There is no automatic pruning: deleting old feedback can remove deduplication and suppression evidence. Retain durable suppression separately before any approved retention migration.

Authorized Reports charts and interpretation#

senderReputationCharts(report) produces bounded, PII-free Reports-compatible UTC trend charts with an explicit accepted-cohort denominator series. createSenderReputationReportEndpoint({authorize,resolveService}) exposes {report,charts} on an explicit GET route; authorization must enforce the bound tenant/site before service resolution. Only bounded from/until parameters are accepted, responses are private, no-store, and client errors contain no raw DB/provider cause. Mount charts through optional @zeruxjs/reports/view to retain its captions, table alternatives, local sorting/pagination, keyboard controls and disposable ownership. No Reports runtime dependency, default public dashboard, or global cross-tenant report source is added. Analytics/audit integrations should consume aggregate counts/coverage only, never recipient/message hashes, addresses, raw responses or signed URLs; this adapter emits no automatic analytics events.

SMTP acceptance measures acceptance by the configured outbound transport, not delivery or inbox placement. Provider delivery feedback reports the provider's observation; an asynchronous bounce can follow earlier acceptance. Opens/clicks remain separately consented, approximate tracking observations, not unique readers or proof of delivery. These local rates are explicitly a local heuristic with authenticated-reported-only coverage, not mailbox/provider/DNS reputation. Provider dashboards use their own populations and denominators; see SES's reputation metrics and asynchronous-delivery behavior. No SPF, DKIM, DMARC, blocklist or DNS status is asserted without a separate current evidence source.

Qualification includes signed HMAC fixture tamper/stale-window checks (not a vendor protocol claim), conflicting/duplicate feedback, late/zero-denominator cohorts, authorized report access, source failure, Reports DOM-emulated tables, and a disposable SQLite two-connection/reopen fixture with cleanup. Live SMTP/provider webhook, production DB engines, DNS checks, browsers and assistive technologies remain separate prerequisites; no live-provider acceptance is claimed.

Inbound markup heuristics scan at most 1 MiB of UTF-16 text, bound individual tag analysis to 8 KiB, and report body_truncated when those limits are reached. They compare apparent link text with literal destinations without fetching URLs or executing markup. These lexical warnings are advisory, not an HTML sanitizer, complete browser parser, authentication result or malware certification.

The dependency floor now selects Nodemailer 10.4+ and Mailparser 3.14.2+ to avoid the earlier address-parser advisories. Transport acceptance, ambiguous outcomes and MIME limits retain the contracts above; injected transport and MIME fixtures are not live SMTP qualification.

src/manager.ts owns lazy native transports, actual unsettled send/verification slots, caller observation, explicit tracking policy and process-local idempotency fences. src/smtp-outcomes.ts classifies intended envelope recipients and projects address-free counters. Neither a fulfilled send nor a tracking observation proves inbox delivery. The historical internal redactResult helper normalizes private native fields; it is not a public response sanitizer.

src/attachments.ts owns lazy Node wrappers, exact recorded lengths, concurrent reader slots and iterator/close settlement. Files managers remain borrowed. src/queue.ts owns a separate process-local FIFO/retry loop: close() without drain:true rejects queued jobs without cancelling or awaiting active borrowed sends. flush() observes actual local drain. A draining close waits before stopping admission, so hosts should stop producers first. This is distinct from manager close and durable outbox delivery.

src/validation.ts, src/filters.ts, src/templates.ts and src/html.ts separate envelope policy, local recipient rules, inert placeholder substitution and maintained sanitization/inlining. Authored table/cell HTML remains trusted input to layout helpers. Templates and fingerprinting do not sandbox getters, toJSON or scalar conversion on trusted application objects. Filter setRules retains its sequential replacement behavior; it is not a staged transaction.

src/persistence.ts stage-validates a bounded V1 snapshot before applying it. Loading merges templates and suppressions while replacing rules; an absent imported template/suppression does not delete an existing one. Trusted directory ancestry and concurrent filesystem operators remain host responsibilities. Snapshot contents contain private recipient values, even though SMTP credentials are excluded.

src/preferences.ts keeps committed subscription state separate from tracking. GET/HEAD confirmation cannot unsubscribe; the encrypted capability plus exact bounded POST body commits the change. The default memory consent store is development-only, its inspection snapshots copy the outer map rather than deep-freezing records, and production requires a shared committed store. Host code must authorize management/status access and independently verify opt-in evidence.

src/campaigns.ts is the paged planner: its completed state means planning finished and still permits delivery of queued messages. src/marketing.ts is the separate function-first service: finalization checks registered terminal delivery records and its completed state blocks further sends. Campaign, recipient, durable delivery and queue writes are separate commits. Stable job IDs do not provide exactly-once SMTP delivery; pending redrive requires explicit queue-absence evidence and never replays sending/uncertain outcomes. Its five-minute CAS operation lease is private management state, not a browser capability.

src/marketing-import.ts parses bounded inert CSV without evaluating source. src/marketing-import-jobs.ts checkpoints aggregate progress against an immutable host-asserted source revision. Changed input needs a new job identity/revision; append must reconcile identical replays because a crash can occur after append and before checkpoint. Lease loss/cancellation fences later checkpoints but cannot recall an already started native append.

src/inbound.ts owns native per-operation IMAP locks/logout and POP3 completion, while injected drivers and stores remain borrowed. Receiver close() only stops admission: it does not cancel or drain active polls. Attachment persistence can precede message metadata, so hosts own idempotence and orphan repair. src/inbound-sync.ts fences durable folder cursors with lease epochs; src/inbox-api.ts requires literal per-call authorization and exposes private untrusted summaries. Remote unread/flag changes are distinct from local erasure. Retention previews do not delete; actual deletion needs explicit host policy, and related stores are not erased atomically.

src/campaign-dashboard.ts and src/marketing-dashboard.ts return explicit host-registerable routes, never a listener. Every page, asset and API request requires a verified principal; writes also require exact origin, host CSRF and scoped action approval. Body readers own five-second read deadlines (12 KiB planner, 128 KiB marketing), not arbitrary callback deadlines. The browser assets use text-only result rendering; rendered email HTML remains untrusted data. The sender visibility example shows public imports with durable stores and host signature/report authorization callbacks. HTTP/parser fixtures are not live browser or provider qualification.

Implementation sources

Implementation files for the APIs described on this page.

ZeruxJS · Open source · MIT OR Apache-2.0

Make yourself at home.

Every shortcut has a visible control too. Reading and navigation work without JavaScript.

Ctrl / ⌘ K
Search all documentation
/
Search, outside editable fields
?
This help, outside editable fields
↑ / ↓
Move through search results
Esc
Close a dialog or version menu
Progress, privacy, offline reading and controls →

Reset this learning record?

This removes course answers, exercise confirmations, bookmarks and recent pages for this docs version in this browser. Export a copy first if you want to keep it.

Search documentation

Type at least two characters.