@anarchitects/newsletter-nest
Newsletter backend package for epic #428, implementing ADR-0010.
Features
- Provider-neutral subscription requests with affirmative consent and exact policy-version validation.
- Append-only consent evidence committed before requesting provider-managed double opt-in.
- Withdrawal processing with atomic repository deduplication and safe partial-batch retries.
- Framework-independent application services, composition tokens and fake-port unit tests.
- PostgreSQL/TypeORM consent persistence with a migration and concurrent-delivery integration tests.
- Optional MailerLite subscriber and signed withdrawal-webhook adapters.
- Fastify HTTP controllers, explicit rate-limiter selection, and configurable Nest facade.
Native subscriber implementation
ADR-0010's native amendment
defines the first-party subscriber implementation. Select subscriber.mode: 'native'
to run double opt-in, confirmation and unsubscribe with PostgreSQL and the host's
Common Mailer transport. MailerLite is an independent alternative; native mode requires
no MailerLite account or credentials. Native mutable state stays separate from append-only
consent evidence. Campaign delivery remains outside #428.
Installation
Newsletter 0.0.1 was released on 2026-10-07 after #428. This README describes the unreleased coordinated 0.1 minor; the existing npm latest does not have this Nest 12 contract. Newsletter Nest, TS and Angular move to 0.1 together.
After publication, follow the Nest 11/12 install recipes and release plan. Runtime consumption needs no Nx or generator plugin. For native delivery, install Common Mailer 0.5 and its Mailer 3/Nodemailer 8 peers; custom/MailerLite modes can omit the optional adapters when they are not imported or configured.
Nest 11/12 compatibility (#467)
The upcoming Newsletter Nest 0.1 minor supports Nest common/core/platform-fastify ^11.1.6 || ^12.1.2, Config ^4.0.2 || ^12.0.1, and Fastify ^5.4.0. Keep all Nest core packages on the same major. The root workspace remains on Nest 11; the isolated packed hosts verify Nest 12 independently of Nx tooling.
| Host | Nest | Config | Fastify | Optional integrations |
|---|---|---|---|---|
nest11 |
11.1.6 | 4.0.2 | 5.4.0 | TypeORM + Common Mailer |
nest11-modern |
11.1.6 | 12.0.1 | 5.12.5 | TypeORM + Common Mailer |
nest12 |
12.1.2 | 12.0.1 | 5.12.5 | TypeORM + Common Mailer |
nest11-optional |
11.1.6 | 4.0.2 | 5.4.0 | Neither installed |
nest12-optional |
12.1.2 | 12.0.1 | 5.12.5 | Neither installed |
The full hosts use TypeORM 1.1.0, PostgreSQL 16, Common Mailer 0.5, Mailer 3.0.2 and Nodemailer 8.0.5. Newsletter owns its DataSource integration directly and does not require @nestjs/typeorm. Native Common-backed delivery requires installing the optional Common Mailer peer and its Mailer 3/Nodemailer 8 peers; configure the transport once at host root. Custom/noop/MailerLite provider hosts may omit Common Mailer, and custom persistence may omit TypeORM.
Breaking requirements: Newsletter Nest 0.1 requires Newsletter TS 0.1.0. Compared with the previous 0.0.1 manifest, the tested Nest/Config/Fastify floors are raised and the optional Common Mailer peer changes from ^0.4.0 to ^0.5.0. This is prepared as a new pre-1.0 minor, not a patch. Existing public entry points, provider seams, raw-body signature format and subscriber behavior are preserved. Newsletter TS already ships separate strict ESM/CJS declarations and needs no declaration rewrite for this change.
With Docker available, run:
yarn nx run newsletter-nest:test-nest-compatibility:nest11
yarn nx run newsletter-nest:test-nest-compatibility:nest11-modern
yarn nx run newsletter-nest:test-nest-compatibility:nest12
yarn nx run newsletter-nest:test-nest-compatibility:nest11-optional
yarn nx run newsletter-nest:test-nest-compatibility:nest12-optional
The full hosts compile strict NodeNext CJS/ESM consumers and the actual Nest example source against packed packages, without workspace aliases or skipLibCheck. They run exported migrations, native signup/mail/confirmation/unsubscribe/replay/restart, MailerLite provider failures, rate limiting, exact raw-body signature verification, rejected signatures/accounts/oversized payloads and durable concurrent webhook deduplication. Config-driven composition checks explicit overrides and disabled routes. Optional-peer hosts use separate clean npm installs and verify runtime CJS/ESM loading, custom/noop providers, advanced presentation composition, custom limiting and fail-closed behavior when raw-body capture is missing. Declaration checks for optional integration types run in the full hosts, where those types are installed.
tools/testing/newsletter-nest-hosts/candidates.json stages Newsletter Nest 0.1.0, Newsletter TS 0.1.0 (preserved DTO names, export-aware TypeScript resolution required), and Common Mailer 0.5.0. Only candidate versions change during staging; dependencies, peer ranges, optional-peer metadata and exports must match the source/build/packed contracts. Frozen registry locks retain integrity hashes and use strict peer/engine checks without install scripts. To refresh a lock intentionally after building, run node tools/testing/run-newsletter-nest-host.mjs <host> --refresh-lock, then validate the normal target. Update candidate versions and locks together when the coordinated release selects final versions.
Linux packed CI passed Nest 11.1.6 on Node 20.19.0, 22.13.0, 24.11.0 and 26.11.1, and Nest 12.1.2 on Node 22.13.0, 24.11.0 and 26.11.1, including optional-peer absence. TypeScript is 6.0.3. The locked Nest 12 graph excludes Node 20. That run used the earlier TS 0.0.1 staging version; #469 aligns staging with the coordinated TS 0.1.0 release, requiring fresh PR CI. Epic #428 is closed and its initial release shipped; the next minor retains coordinated release ownership. Source versions, tags and publication remain owned by the human-approved release workflow; Common Mailer 0.5 must be available before releasing this Common-backed contract.
Usage
Easy mode: native root facade
Install the optional peers typeorm ^1.1.0 and @anarchitects/common-nest-mailer ^0.5.0.
The host exports an initialized PostgreSQL DataSource from HostPersistenceModule
and configures Common Mailer once in HostMailModule, exporting CommonMailerModule.
Newsletter consumes its existing MailerPort; it does not create another transport.
Register all three Newsletter entities and both migrations listed under
Native operational core before serving traffic.
The facade does not initialize the DataSource, synchronize the schema or run migrations.
import { Module } from '@nestjs/common';
import { MailerPort } from '@anarchitects/common-nest-mailer';
import { NewsletterModule } from '@anarchitects/newsletter-nest';
import { HostPersistenceModule, HOST_DATA_SOURCE } from './host-persistence.module';
import { HostMailModule } from './host-mail.module';
@Module({
imports: [
NewsletterModule.forRoot({
imports: [HostPersistenceModule, HostMailModule],
consent: { version: 'host-policy/v1', text: 'Exact wording shown by your host.' },
persistence: { mode: 'typeorm', dataSourceToken: HOST_DATA_SOURCE },
subscriber: {
mode: 'native',
options: { scope: 'host-newsletter' },
mail: {
publicationName: 'Example news',
confirmationUrl: 'https://example.com/newsletter/confirm',
unsubscribeUrl: 'https://example.com/newsletter/unsubscribe',
message: { replyTo: 'help@example.com' },
},
},
mailer: { useExisting: MailerPort },
rateLimit: { mode: 'memory', limit: 5, windowMs: 60_000 },
presentation: { resolveClientKey: (request) => request.ip },
}),
],
})
export class HostNewsletterModule {}
The configured HTTPS URLs are host-owned landing pages, independent of the backend
route/global prefix. They should read the token, wait for an explicit user action, and POST
{ token } to the corresponding API endpoint. Merely opening a GET link never changes
membership, avoiding accidental confirmation/withdrawal by mail-link scanners. No
redirect URL is accepted from request data. Pages should use Referrer-Policy: no-referrer,
avoid third-party resources, and redact token query strings and request bodies from logs.
Native facade mode requires TypeORM persistence so withdrawal and its consent evidence
use one transaction on the same database. It requires an explicit mailer binding
(useExisting, useFactory/inject, or useValue) implementing the Common MailerPort.
For intentional no-delivery development, bind the existing Common noop provider explicitly.
Custom repository/token/mail composition remains available through the advanced entry points.
All #447 TTL, retry, renderer, sender/subject and aggregate monitoring options remain available
in subscriber.options and subscriber.mail; see Native mail flow.
Other root facade modes
Import NewsletterModule from the root entry point. This example assumes the host
exports a durable ConsentRepositoryPort binding from HostPersistenceModule:
import { Module } from '@nestjs/common';
import { NewsletterModule } from '@anarchitects/newsletter-nest';
import { HostPersistenceModule, HOST_CONSENT_REPOSITORY } from './host-persistence.module';
@Module({
imports: [
NewsletterModule.forRoot({
imports: [HostPersistenceModule],
consent: { version: 'host-policy/v1', text: 'Exact wording shown by your host.' },
persistence: { mode: 'custom', provider: { useExisting: HOST_CONSENT_REPOSITORY } },
subscriber: { mode: 'noop' },
rateLimit: { mode: 'memory', limit: 5, windowMs: 60_000 },
presentation: { resolveClientKey: (request) => request.ip },
}),
],
})
export class HostModule {}
The explicit noop subscriber records consent but does not send confirmation
emails or contact a provider. Select { mode: 'custom', provider: { useExisting: HOST_SUBSCRIBER } } for your own SubscriberPort, or select MailerLite explicitly:
subscriber: { mode: 'mailerlite', options: { apiKey, groupId } },
webhook: { webhookSecret, accountId },
The webhook is a separate opt-in and may also accompany a custom subscriber.
Absent/false webhook exposes no webhook route. Missing required credentials fail
startup. provider bindings support useValue, useExisting, or useFactory plus
inject. Export host tokens from modules listed in imports. Missing bindings or
missing required port methods fail startup; no fallback adapter is selected.
To use the bundled PostgreSQL adapter, select
persistence: { mode: 'typeorm', dataSourceToken: HOST_DATA_SOURCE } and import the
host module exporting that initialized DataSource. Register the entity/migration
as described below. The facade does not create connections or run migrations.
Custom persistence does not require the optional TypeORM peer.
Use Nest's Fastify adapter. Webhooks require raw-body capture at bootstrap:
import { BadRequestException } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
const adapter = new FastifyAdapter({
bodyLimit: 1_048_576,
schemaErrorFormatter: (errors, dataVar) => new BadRequestException(`${dataVar} ${errors.map((error) => error.message).join(', ')}`),
});
const app = await NestFactory.create<NestFastifyApplication>(HostModule, adapter, { rawBody: true });
await app.listen(3000);
The schema error formatter preserves HTTP 400 for host schema failures under Nest 12 and also works on Nest 11. Newsletter subscription/native routes retain their own strict validation. Do not replace captured bytes with reserialized JSON. Missing capture returns 503 without evidence writes. The host owns ingress body limits, trusted-proxy settings, connection lifecycle, and any global URL prefix. The presentation module requires Fastify and installs its route hook before Nest maps controllers.
HTTP behavior
Default routes are POST /newsletter/subscribe, native-only
POST /newsletter/confirm and POST /newsletter/unsubscribe, and, when independently enabled,
POST /newsletter/webhook. Set presentation.path to a relative capability prefix
(e.g. marketing/newsletter); host global prefixes still apply. There is no policy
read endpoint: the host owns publishing the same policy to its frontend.
Subscriptions use the shared TS route schema with strict validation, without
coercing values or dropping additional fields. A non-empty string website
honeypot in parsed JSON returns 202 { accepted: true } before schema validation,
limiter/context resolution, or writes. Malformed JSON and ingress size limits are
still enforced by Fastify. Genuine requests are validated, limited, and passed to
the application. Unknown/stale policy or invalid input returns generic 400;
limiter denial returns 429 with Retry-After in whole seconds; unavailable ports,
limiter, or key resolution return generic 503. Provider status is never returned.
Native actions accept a JSON object containing only a string token (maximum 128 characters).
They return 202 { accepted: true } for valid, malformed, unknown, wrong-purpose, expired,
superseded and replayed tokens, with Cache-Control: no-store and Referrer-Policy: no-referrer.
Invalid request structure returns generic 400. Valid token consumption and withdrawal
evidence are atomic; receipt failure never reverses withdrawal. Database failures return
generic 503 rather than a false success. Native actions share the configured host-key rate
budget with subscription requests (429/Retry-After on denial); they have no honeypot bypass.
No email, subscriber ID, status, token or token validity is returned. Responses are neutral
in content, not guaranteed constant-time. These endpoints are absent in other subscriber modes;
GET actions are not registered. The application boundary is NATIVE_NEWSLETTER_ACTIONS /
NewsletterNativeActionsPort from application with void confirm/unsubscribe methods.
Verified webhook deliveries return 200 { recorded, duplicates } only after
persistence completes. Invalid signatures return 401, invalid payloads 400,
adapter size violations 413, and operational/storage failures 503. Subscription
limits never consume webhook quota; hosts can apply independent ingress controls.
No IP evidence is recorded by default. Supply presentation.resolveContext to
return trusted { ipAddress } if required. resolveClientKey and resolveContext
receive the Fastify request; hosts own proxy trust and must not blindly copy
forwarded headers. Keys should identify the client, never subscriber existence.
Explicit rate limiting
rateLimit is required; choose one mode:
{ mode: 'disabled' }: explicit opt-out of library limiting, for example when a gateway enforces signup quotas.{ mode: 'memory', limit, windowMs, maxKeys? }: atomic fixed windows per key in this module instance. Counters reset on restart and are not shared across replicas. Default capacity is 10,000 keys; expired keys are reclaimed and full active capacity fails closed with 503.{ mode: 'custom', limit, windowMs, provider }: a host-ownedNewsletterRateLimiterPort, typically a shared/distributed implementation.
Enabled modes require presentation.resolveClientKey. Missing/blank keys, invalid
adapter results, or limiter failures return 503. The custom port implements
consume(key, policy) atomically, resolving { allowed: true } or
{ allowed: false, retryAfterMs }. Namespace keys when sharing a backend between
hosts. The memory implementation assumes one fixed policy per instance and keeps
no global singleton or background timer. Distributed deployments need shared or
gateway enforcement for a global quota.
Config-driven initialization
NewsletterModule.forRootFromConfig(overrides) reads the newsletterConfig
namespace from the config entry point. Explicit overrides take precedence over
config values, then defaults. Consent and presentation fields merge individually;
adapter and limiter selections are replaced as a whole. forRoot(options) never
reads the environment. Both paths require consent, persistence, subscriber and
rate-limiter choices. Persistence bindings and request resolvers remain host-supplied.
| Environment variable | Purpose |
|---|---|
NEWSLETTER_CONSENT_VERSION, NEWSLETTER_CONSENT_TEXT |
Exact host policy |
NEWSLETTER_SUBSCRIBER |
native, noop or mailerlite; custom providers use overrides |
NEWSLETTER_MAILERLITE_API_KEY, NEWSLETTER_MAILERLITE_GROUP_ID |
MailerLite subscription configuration |
NEWSLETTER_MAILERLITE_WEBHOOK_ENABLED |
Exact true/false; absent leaves route disabled |
NEWSLETTER_MAILERLITE_WEBHOOK_SECRET, NEWSLETTER_MAILERLITE_ACCOUNT_ID |
Explicit webhook configuration |
NEWSLETTER_RATE_LIMIT_MODE |
disabled or memory; custom limiters use overrides |
NEWSLETTER_RATE_LIMIT_MAX, NEWSLETTER_RATE_LIMIT_WINDOW_MS |
Positive integer memory quota/window |
NEWSLETTER_PATH |
Relative HTTP prefix; defaults to newsletter |
Native config additionally reads:
| Variable | Meaning |
|---|---|
NEWSLETTER_NATIVE_SCOPE |
Required host-owned scope |
NEWSLETTER_PUBLICATION_NAME |
Required display name |
NEWSLETTER_CONFIRMATION_URL, NEWSLETTER_UNSUBSCRIBE_URL |
Required trusted HTTPS landing pages |
NEWSLETTER_NATIVE_CONFIRMATION_TTL_MS |
Positive bounded duration; default 86400000 |
NEWSLETTER_NATIVE_UNSUBSCRIBE_TTL_MS |
Positive bounded duration; default 2592000000 |
NEWSLETTER_NATIVE_RESEND_COOLDOWN_MS |
Positive bounded duration, no greater than either TTL; default 60000 |
NEWSLETTER_CONFIRMATION_SUBJECT, NEWSLETTER_UNSUBSCRIBED_SUBJECT |
Optional subject overrides |
NEWSLETTER_MAIL_FROM, NEWSLETTER_MAIL_REPLY_TO |
Optional message metadata; absent uses Common transport defaults |
NEWSLETTER_NOTIFY_UNSUBSCRIBE |
Exact true/false; default true |
NEWSLETTER_MAIL_MAX_ATTEMPTS, NEWSLETTER_MAIL_RETRY_DELAY_MS |
Bounded attempts/delay; defaults 1 and 250 |
With NEWSLETTER_SUBSCRIBER=native, pass host bindings explicitly:
NewsletterModule.forRootFromConfig({
imports: [HostPersistenceModule, HostMailModule],
persistence: { mode: 'typeorm', dataSourceToken: HOST_DATA_SOURCE },
mailer: { useExisting: MailerPort },
presentation: { resolveClientKey: (request) => request.ip },
});
These native values are read only when native mode is selected through config. An explicit
subscriber override replaces the complete selection, including nested options/mail.
For custom renderers, headers or monitoring hooks, pass a complete native subscriber override
(or map config first and compose its result). Native configuration is validated at bootstrap.
Provider selection is never inferred from credentials. Webhook opt-in remains independent;
leave it absent/false for a native-only installation.
Environment variables must be loaded before calling forRootFromConfig. Hosts
using an already-resolved config object can call mapNewsletterConfigToOptions
then forRoot. Advanced MailerLite retry/body limits and memory capacity are
available through explicit options/overrides.
NewsletterModule.forRootFromConfig({
imports: [HostPersistenceModule],
persistence: { mode: 'custom', provider: { useExisting: HOST_CONSENT_REPOSITORY } },
presentation: { resolveClientKey: (request) => request.ip },
});
Advanced composition
The presentation entry point exports NewsletterPresentationModule.forRoot for
hosts that compose application services themselves. Supply imports exporting
NewsletterSubscriptionService and, if webhookEnabled: true, the
NEWSLETTER_WEBHOOK_HANDLER token. The handler receives raw bytes/signature and
must verify before processing; it may throw sanitized Nest HTTP exceptions.
For native HTTP actions, set nativeEnabled: true and import a provider for the
application token NATIVE_NEWSLETTER_ACTIONS implementing NewsletterNativeActionsPort.
Bind confirmation to the lifecycle service and unsubscribe to NativeUnsubscribeService
when mail receipts are desired; never return the private notification-preparation handoff.
The presentation options own the route prefix, request resolvers and required
limiter choice, independently of infrastructure.
The application entry point supports explicit composition. Supply adapters
implementing ConsentRepositoryPort and SubscriberPort; the services do not
register themselves with Nest or configure infrastructure.
import { NewsletterSubscriptionService, NewsletterWithdrawalService, type ConsentRepositoryPort, type SubscriberPort } from '@anarchitects/newsletter-nest/application';
function createNewsletter(consentRepository: ConsentRepositoryPort, subscriber: SubscriberPort) {
const subscriptions = new NewsletterSubscriptionService(consentRepository, subscriber, { version: 'host-policy/v1', text: 'Exact wording supplied by your host.' });
const withdrawals = new NewsletterWithdrawalService(consentRepository);
return { subscriptions, withdrawals };
}
Call subscriptions.subscribe with { email, consent: true, consentVersion }
and optional source. Successful requests return only { accepted: true }, which
reveals neither subscriber existence nor confirmation/delivery status. The client
version must match the configured version exactly; the service snapshots the
server's wording. Change the host-managed version whenever its wording changes.
Requests are validated with the shared TS schema without coercion. Email addresses
must satisfy that schema (including no surrounding whitespace) and are then
lowercased consistently for evidence and provider requests. Optional source is
attribution only. An optional second argument { ipAddress } must come from the
host's trusted context resolver, never directly from a browser or unchecked
forwarded header. Recording time comes from the server; an optional final
constructor argument NewsletterClock supports deterministic testing.
The service defensively acknowledges non-empty string honeypots without side effects, even if the rest of the request is invalid. Presentation must also handle honeypots before HTTP schema validation and apply the configured rate limiter before calling the application for genuine requests.
Subscription ordering and failure
- Validate the request, affirmative consent and exact configured policy version.
- Await
ConsentRepositoryPort.appendGrantcommitting the evidence. - Call
SubscriberPort.subscribewith email and optional source.
Persistence failure prevents the provider call. Provider failure preserves the grant and returns a generic retryable error. Repeating a subscription attempt may append another grant and call the provider again. There is no distributed transaction, provider rollback or exactly-once delivery promise.
The subscriber adapter must request double opt-in through the selected implementation, preserve existing opt-outs, and resolve identically for new/existing/opted-out addresses. It returns no account, subscriber ID or status. Adapters own timeouts, bounded retry/backoff and private operational diagnostics. Only operational failures may reject; subscriber-existence errors must not escape as an enumeration channel.
Withdrawal processing
Pass a batch of verified and normalized NewsletterWithdrawalEvent values to
withdrawals.process(events). Signature verification, raw bytes and provider
payload parsing belong to infrastructure. This service is not a webhook verifier.
Each event contains email, eventSource and dedupeKey. Identity strings are
opaque and preserved exactly. The normalizing adapter must namespace identities
by provider/account and event and reuse them on retries. Different withdrawals
for the same email remain separate events.
The application validates and snapshots the entire batch before writing, then
calls appendWithdrawalOnce sequentially. The repository must commit identity
and evidence atomically with durable uniqueness on (eventSource, dedupeKey).
Only that identity conflict returns duplicate; unrelated storage failures
reject. A read-before-write check or process-local set is not a production
implementation of this contract.
The result { recorded, duplicates } is an internal processing summary. Withdrawals
append even without a known grant and never invent consent wording/version or
modify earlier evidence. A failure stops the batch and rejects: ingress must not
acknowledge it as success. Retry the full batch with unchanged identities; committed
events become duplicates and the remaining events can be recorded. Concurrent
deliveries depend on the same repository guarantee, not service-instance state.
Errors
| Error | Meaning |
|---|---|
NewsletterValidationError |
Invalid request/context/withdrawal or policy mismatch; exposes a neutral code. |
NewsletterConfigurationError |
Missing/blank policy configuration or invalid server recording time. |
NewsletterUnavailableError |
Persistence/provider failure; retryable: true, with a neutral storage/subscriber code. |
Errors contain no submitted values, provider payloads, database details or adapter exception causes. Presentation owns HTTP mapping. Adapters must retain any needed private diagnostics themselves without exposing them through these errors.
PostgreSQL persistence
infrastructure-persistence exports NewsletterConsentEntity,
TypeOrmConsentRepository, CreateNewsletterConsentEvents1791244800000 and
NEWSLETTER_SCHEMA. TypeORM ^1.1.0 is an optional peer: install it and the pg
driver when selecting this adapter. Application-only consumers need neither.
Register the entity and migration in the host-owned PostgreSQL DataSource:
import { DataSource } from 'typeorm';
import { NewsletterConsentEntity, TypeOrmConsentRepository, CreateNewsletterConsentEvents1791244800000 } from '@anarchitects/newsletter-nest/infrastructure-persistence';
const dataSource = new DataSource({
type: 'postgres',
// Supply host-owned connection options here.
entities: [NewsletterConsentEntity],
migrations: [CreateNewsletterConsentEvents1791244800000],
synchronize: false,
});
await dataSource.initialize();
// Apply migrations through the host's deployment/migration process.
const consentRepository = new TypeOrmConsentRepository(dataSource);
Supply that repository to the application services or bind it to
CONSENT_REPOSITORY_PORT in host composition. It uses the DataSource's ordinary
connections so successful calls commit before returning; do not substitute a
transaction-bound manager or delay the commit until after a provider call.
PostgreSQL is the supported database for this adapter.
The migration creates newsletter.consent_events. Each row has a fresh UUID,
email, event kind and server recording timestamp. Grants store the exact policy
version/text and optional source/IP context; their provider identity columns are
NULL. Withdrawals store only their opaque event source/key and email/time, leaving
policy/source/IP fields NULL. Check constraints enforce those two shapes. An
email/timestamp index supports restricted audit queries through the host DataSource.
There are no cross-domain entity relations.
Uniqueness on (event_source, dedupe_key) makes concurrent withdrawal writes
atomic and durable. Only that named conflict is acknowledged as a duplicate;
other database failures propagate to the application error boundary. Normalization
belongs to the application/provider adapter; persistence preserves supplied
evidence and opaque identity strings exactly. Identity keys should be compact
enough for PostgreSQL's normal unique-index limits; oversized keys fail rather
than being truncated or silently treated as duplicates.
The repository exposes inserts only: it never updates, upserts or deletes evidence.
This is an application-level append-only contract, not a database trigger blocking
all host administration. Hosts own access, retention and authorized erasure; grant
runtime roles only the required SELECT/INSERT privileges where appropriate.
Rolling back this migration drops the consent table and its evidence, but leaves
the namespace and other host tables intact. Use forward migrations for deployed
evidence that must be retained; keep synchronize: false in production.
Native mail flow (#447)
NewsletterNativeMailService in infrastructure-native implements the Newsletter-owned
NewsletterNativeMailPort intent contract using the generic Common MailerPort.sendMessage.
It ships default HTML and plain-text confirmation/withdrawal content, requires no template
files, and never imports MailerService or configures a transport. Configure Common
once at the host root, then inject the exported MailerPort into the factory:
import { Module } from '@nestjs/common';
import { CommonMailerModule, MailerPort } from '@anarchitects/common-nest-mailer';
import { NewsletterNativeMailService } from '@anarchitects/newsletter-nest/infrastructure-native';
@Module({
imports: [
// Host transport configuration; no template directory is needed for rendered messages.
CommonMailerModule.forRootAsync({
useFactory: () => ({ transport: hostSmtpOptions, defaults: { from: 'news@example.com' } }),
}),
CommonMailerModule.forRoot({ provider: 'node' }),
],
providers: [
{
provide: NewsletterNativeMailService,
inject: [MailerPort],
useFactory: (mailer: MailerPort) =>
new NewsletterNativeMailService(mailer, {
publicationName: 'Example news',
confirmationUrl: 'https://example.com/newsletter/confirm',
unsubscribeUrl: 'https://example.com/newsletter/unsubscribe',
}),
},
],
exports: [NewsletterNativeMailService],
})
export class HostNewsletterMailModule {}
The existing CommonMailerModule.forRoot({ provider: 'noop' }) can replace both imports
for intentional no-delivery tests/development. Existing forRootFromConfig() plus
forProviderFromConfig() also works for hosts already using config-based Common
transport/template setup. Do not import a second transport just for Newsletter.
Noop success means the configured provider accepted the operation, not inbox delivery.
Options are exported as NewsletterNativeMailOptions from config:
- Required
publicationName,confirmationUrl, andunsubscribeUrl. Both endpoints must be absolute HTTPS URLs with no credentials, query, or fragment. Configure trusted public origins/routes at bootstrap, never derive them from request headers. Each link adds only atokenquery parameter containing the versioned, purpose-bound secret from #446. No email, subscriber ID, or consent evidence is placed in URLs. confirmationSubjectandunsubscribedSubjectoverride the default subjects.messageuses Common'sfrom,replyTo, and generic stringheadersvocabulary. Metadata is trusted host configuration; omit sender fields to use transport defaults.renderConfirmation(context)andrenderUnsubscribed(context)return bothhtmlandtext. Context contains the publication name and, for confirmation only, both sensitive URLs. Hosts own safe escaping and link inclusion in custom rendering. Defaults escape HTML and include confirmation and unsubscribe links in both alternatives.notifyUnsubscribe: falsedisables the receipt, without changing withdrawal behavior.maxAttemptsis 1..3 (default 1);retryDelayMsis 0..5000 (default 250). Attempts share one rendered message and token pair. Configure finite connection/socket timeouts in the Common transport; Newsletter does not race unresolved sends with a timeout.onDeliveryOutcomeis a synchronous aggregate monitoring hook with onlykind,outcome, andattempts. Rendering failure reports zero attempts. Provider errors, emails, URLs and tokens are never passed to the hook or logged by the library.
NativeUnsubscribeService.unsubscribe(secret) first commits withdrawal and consent
proof atomically, then attempts a receipt only for a newly effective token. Its response
is neutral for success, invalid/expired/superseded tokens and replays. The internal
unsubscribeAndPrepareNotification seam returns an address only after commit; never
expose that seam or its result as an HTTP response. The lower-level lifecycle
unsubscribe still performs withdrawal without sending a receipt.
Failure/retry contract: grant and pending state survive rendering or delivery failure.
The public subscription acknowledgement remains { accepted: true }, just as for an
already active address or a suppressed resend. No mail failure activates a subscriber,
rolls back evidence, or switches provider. Fresh affirmative subscription requests may
resend after the lifecycle cooldown, rotating both tokens; immediate retries send nothing.
Each accepted request still appends its own grant evidence. Hosts should monitor aggregate
failures and retain presentation/gateway abuse limits. Responses are neutral in content;
this synchronous flow does not promise constant-time responses.
Receipt failure never reverses withdrawal. Replaying a used token does not retry the receipt. Bounded in-call retries may deliver duplicates if transport acceptance was ambiguous. There is no durable outbox or exactly-once delivery: a crash after commit can lose a message, and delayed delivery after rotation may contain a superseded link. Raw tokens are held only in the transient mail handoff/rendered message and are not persisted for background retries. Host renderers/transports must not log them. Host HTTP pages should redact token query strings from access logs, avoid third-party resources, and apply a no-referrer policy; the facade's POST behavior is described above.
Compatibility: the Nest 11/12 line requires @anarchitects/common-nest-mailer ^0.5.0 (Mailer 3/Nodemailer 8). It retains the structured message contract delivered separately in
#454 and
released as 0.4.0.
Install that optional peer when using the native Common-backed mail service. Custom
Common mailer implementations must implement sendMessage in addition to send and
sendTemplate. Common 0.3.4 does not implement sendMessage; the constructor rejects
incompatible providers instead of silently dropping alternatives/metadata.
NativeSubscriberAdapter now requires a NewsletterNativeMailPort as its second
constructor argument (source change to the unreleased #446 advanced API). Custom and
MailerLite modes do not load Common Mailer at runtime. No new migration is required.
Native operational core (#446)
NewsletterNativeLifecycleService in application owns lifecycle decisions.
NativeSubscriberAdapter in infrastructure-native implements SubscriberPort
by invoking that service and the configured Newsletter mail port; CryptoNativeToken supplies cryptography. Persistence
is a separate TypeOrmNativeSubscriberRepository adapter. No MailerLite client,
credentials or Nest container is required for these classes.
Register all three entities and both migrations with the host PostgreSQL DataSource:
import { NewsletterConsentEntity, NewsletterNativeSubscriberEntity, NewsletterNativeTokenEntity, CreateNewsletterConsentEvents1791244800000, CreateNewsletterNativeSubscribers1791288000000, TypeOrmConsentRepository, TypeOrmNativeSubscriberRepository } from '@anarchitects/newsletter-nest/infrastructure-persistence';
import { NewsletterNativeLifecycleService, NewsletterSubscriptionService } from '@anarchitects/newsletter-nest/application';
import { CryptoNativeToken, NativeSubscriberAdapter, NewsletterNativeMailService, NativeUnsubscribeService } from '@anarchitects/newsletter-nest/infrastructure-native';
import type { MailerPort } from '@anarchitects/common-nest-mailer';
// In the host DataSource options; initialize and run migrations through host deployment tooling.
const entities = [NewsletterConsentEntity, NewsletterNativeSubscriberEntity, NewsletterNativeTokenEntity];
const migrations = [CreateNewsletterConsentEvents1791244800000, CreateNewsletterNativeSubscribers1791288000000];
// dataSource is the host's initialized DataSource with these entities/migrations.
const lifecycle = new NewsletterNativeLifecycleService(new TypeOrmNativeSubscriberRepository(dataSource), new CryptoNativeToken(), { scope: 'host-newsletter', confirmationTtlMs: 86_400_000, unsubscribeTtlMs: 2_592_000_000, resendCooldownMs: 60_000 });
// mailer is the MailerPort resolved from the host's existing Common Mailer provider.
declare const mailer: MailerPort;
const mail = new NewsletterNativeMailService(mailer, {
publicationName: 'Example news',
confirmationUrl: 'https://example.com/newsletter/confirm',
unsubscribeUrl: 'https://example.com/newsletter/unsubscribe',
message: { from: 'Example <news@example.com>', replyTo: 'help@example.com' },
});
const subscriber = new NativeSubscriberAdapter(lifecycle, mail);
const withdrawals = new NativeUnsubscribeService(lifecycle, mail);
const subscriptions = new NewsletterSubscriptionService(new TypeOrmConsentRepository(dataSource), subscriber, hostConsentPolicy);
This constructs the native core and mail flow for advanced composition. The root facade
composes these services and exposes the HTTP actions described above. Never
expose prepareSubscription as a public endpoint: it is an internal adapter seam
invoked after NewsletterSubscriptionService validates and commits affirmative
consent. It returns a transient NewsletterNativePreparation containing raw
confirmation/unsubscribe tokens for the trusted mail handoff, or undefined
for active subscribers and suppressed resends. NativeSubscriberAdapter hands
tokens to NewsletterNativeMailPort.sendConfirmation only after commit, then resolves
void, preserving the existing subscriber port.
Preparation does not grant consent or authorize itself; bypassing the subscription
use case would bypass consent validation. Do not log, persist or return handoff
tokens in subscription acknowledgements.
Options live in NewsletterNativeOptions in config. scope is required, nonblank
and at most 128 characters; keep it stable across deployments and use separate
scopes for independently managed lists. Email canonicalization matches the existing
subscription use case (lowercase, no implicit trim). Durations are positive integer
milliseconds, capped at one year. The resend cooldown must not exceed either token
TTL. Defaults are 24-hour confirmation, 30-day unsubscribe and a 60-second cooldown.
Host clocks must be synchronized; tests can inject the existing NewsletterClock.
The cooldown supplements, rather than replaces, presentation/gateway abuse controls.
| Operation | State and token behavior |
|---|---|
| First accepted subscription | Creates one pending_confirmation record and one token for each purpose |
| Repeat pending subscription | Suppressed during cooldown; afterwards rotates both tokens atomically, preserving the subscriber and generation |
| Confirmation | A valid unconsumed current-generation token changes pending to active and is consumed atomically |
| Repeat active subscription/confirmation | No new activation, token issuance or state change |
| Unsubscribe | Pending or active becomes unsubscribed; confirmation authority is removed and withdrawal evidence commits in the same transaction |
| Repeat unsubscribe | No state change or duplicate evidence |
| Fresh consent after unsubscribe | Reuses the subscriber identity with a new generation and requires fresh confirmation; old links are invalid |
confirm(secret) and unsubscribe(secret) return Promise<void> for both valid
and ineffective attempts. Malformed, wrong-purpose, unknown, expired, consumed,
superseded and wrong-scope tokens cause no side effects and disclose no membership
status. Operational failure throws only neutral NewsletterUnavailableError.
The facade maps these operations to the native POST routes with the validation and
abuse controls described above. An expired unsubscribe link does not change subscriber state; any future
renewal flow must establish authorization and must not silently resubscribe.
Bearer tokens contain 32 cryptographically random bytes (256 bits), a version and a purpose prefix. PostgreSQL stores only SHA-256 verifiers, purpose, subscriber ID, generation, expiry and consumption time. At most two token rows per subscriber are retained; resends replace obsolete rows. Raw secrets cannot be recovered from the repository. Once a preparation is lost, a later bounded resend must mint new tokens.
The migration creates newsletter.native_subscribers and newsletter.native_tokens;
consent remains in newsletter.consent_events. Operational rows may change, evidence
is insert-only. Native withdrawal identity is native:<scope> plus subscriber ID
and generation, so one lifecycle withdrawal is evidenced once. No cross-domain
entity relation is added; the token FK references a Newsletter subscriber only.
The repository uses PostgreSQL transaction-scoped advisory locks for absent/existing scoped identities and row locks for existing subscribers. It rereads token authority after locking. All mutations and native withdrawal evidence use the transaction's manager; failed evidence/token writes roll back state and token consumption. No mail or external network call belongs inside the transaction. These semantics follow PostgreSQL transaction-level locking and TypeORM transaction-manager ownership. A grant already committed by the subscription service remains intact if native persistence fails, preserving the existing evidence-before-subscriber contract.
Run the native migration after the consent migration with synchronize: false.
Its down migration drops only the two native tables, preserving consent and
host-owned objects; it deliberately destroys operational subscriber/token data.
Back up that state and plan rollback with the host before invoking a down migration.
The implementation adds no automatic migrations, mail transport or facade mode.
MailerLite adapters
The infrastructure-mailerlite entry point provides explicit composition without
an SDK dependency or environment access:
import { MailerLiteSubscriberAdapter, MailerLiteWebhookAdapter } from '@anarchitects/newsletter-nest/infrastructure-mailerlite';
import type { NewsletterWithdrawalService } from '@anarchitects/newsletter-nest/application';
function createMailerLite(host: { apiKey: string; groupId: string; accountId: string; webhookSecret: string }, withdrawals: NewsletterWithdrawalService) {
return {
subscriber: new MailerLiteSubscriberAdapter({ apiKey: host.apiKey, groupId: host.groupId }),
webhook: new MailerLiteWebhookAdapter({ webhookSecret: host.webhookSecret, accountId: host.accountId }, withdrawals),
};
}
Supply subscriber to NewsletterSubscriptionService through its SubscriberPort.
The adapter posts to the fixed MailerLite Connect endpoint with status: unconfirmed
and resubscribe: false; it never requests active status or forced resubscription.
Enable Double opt-in for API and integrations in the MailerLite account first.
This setting and confirmation-email delivery cannot be verified by local tests.
See MailerLite's double opt-in guidance
and subscriber API.
Set sourceField only when an existing MailerLite custom field should receive
the request's optional source. Otherwise no custom fields are sent. Configuration
is copied on construction; missing credentials/group IDs fail closed. There is no
automatic logging/no-op fallback or controller registration.
All 2xx and 422 responses resolve without provider data, preserving the reference
adapter's anti-enumeration policy. A 422 is a general provider validation response,
not proof of an existing subscriber; this policy can also mask invalid provider
field configuration. Other failures become generic NewsletterUnavailableError
values without provider bodies, credentials or submitted emails.
Network failures, timeouts, 408, 429 and 5xx receive bounded retries. Defaults are
two total attempts, 5000ms per attempt, 250ms exponential backoff and a 2000ms
maximum retry delay. Options cap attempts at three and timeout/delay at 30000ms.
Retry-After seconds or HTTP dates are respected; a delay beyond the configured
budget fails for a later caller retry instead of retrying too soon. Redirects
are rejected. An optional second constructor argument injects fetch and sleep
for tests; production uses native fetch. No response body is interpreted or exposed.
Signed webhook ingress
Pass the captured raw Buffer/Uint8Array and the single Signature header
value to webhook.receive(rawBody, signature). The adapter verifies a strict
64-character hex HMAC-SHA256 signature using the webhook secret, then parses those
same bytes. It does not accept parsed objects or reserialized JSON, and does not
implement the Classic API's signature scheme. Missing bytes, malformed signatures,
invalid UTF-8/JSON and invalid configuration fail closed. Ingress must also impose
its own body-size limit. See the MailerLite webhook contract.
Supported deliveries are single flat events, nested subscriber events and
{ events: [...] } batches. Unsubscribe/delete events become neutral withdrawals;
other named events are ignored. Every relevant event must have a valid email,
subscriber ID and occurrence timestamp. The entire batch is normalized before
dispatch, so a malformed later event cannot commit a valid prefix. Defaults limit
input to 1 MiB and 1000 events, configurable within fixed upper bounds.
The host's numeric-string accountId sets the stable mailerlite:<accountId>
namespace. Any supplied payload account ID must match. The v1: dedupe key hashes
a JSON tuple of event name, subscriber ID and timestamp. Unsubscriptions use
unsubscribed_at, deletions use deleted_at/forget_at, with updated_at as the
fallback. Timestamp text retains microsecond precision. Subscriber IDs alone do
not identify an occurrence; absent identity data is rejected rather than collapsed
into a permanent dedupe key. Stable provider occurrence fields are required across
retries; distinct events with the same type, subscriber and timestamp are not
distinguishable by this provider-derived identity. Keep account namespace/key rules
stable when redeploying or rotating webhook secrets.
Storage failures propagate for retry, while duplicates return the application's
processing summary. Presentation must acknowledge only after receive succeeds,
or after a separately designed durable handoff. Signature verification alone does
not prevent replay: durable repository deduplication provides that protection.
MailerLiteWebhookError.code distinguishes missing raw bytes, signature, payload
and size failures for HTTP mapping without leaking provider payloads.
Entry points
application exports NewsletterSubscriptionService, NewsletterWithdrawalService,
NewsletterNativeLifecycleService, their context/result/clock types, error classes, and the SubscriberPort and
ConsentRepositoryPort interfaces with SUBSCRIBER_PORT and
CONSENT_REPOSITORY_PORT symbols for host composition. Native repository and token
ports have separate NATIVE_SUBSCRIBER_REPOSITORY_PORT and NATIVE_TOKEN_PORT symbols.
NewsletterNativeActionsPort and NATIVE_NEWSLETTER_ACTIONS expose the neutral
confirmation/withdrawal boundary used by presentation.
infrastructure-native exports the native core adapter, cryptographic token
implementation, mail service and unsubscribe orchestrator; infrastructure-persistence
exports both consent and native storage.
The root exports NewsletterModule and its host-facing option types. config
exports the typed namespace, config mapper, validation and adapter option types.
presentation exports the controllers, composable module, webhook-handler seam,
and rate-limiter port/token/memory implementation.
Development notes
Run from the workspace root:
yarn nx run-many -p newsletter-nest -t lint test typecheck typecheck-tests build package-smoke
Application tests use fake subscriber and in-memory repository ports. Run the PostgreSQL persistence suite with Docker available:
yarn nx run newsletter-nest:test-persistence
It starts and removes an isolated postgres:16-alpine container, applies the real
migration with synchronization disabled, and tests exact evidence, concurrent and
reconnected duplicate delivery, unrelated constraint failures, partial-batch retry,
metadata/schema agreement and rollback. The target is uncached and runs for affected
projects in the GitHub-hosted integration step in both CI workflows.
Facade tests bootstrap Nest/Fastify and inject real HTTP requests to check strict validation, honeypots, all limiter modes, custom providers, route/context options, raw-body verification and operational failure responses. Configuration tests cover both initialization paths and explicit override precedence.
MailerLite tests use mocked fetch responses and locally signed fixtures, including application-service integration. No live MailerLite network calls are needed.
package-smoke checks built runtime exports and strict Node16 declaration
resolution from isolated CommonJS and ESM consumers without workspace aliases.
The root carries domain:newsletter, tech:nest and type:facade tags. Layers
inside this publishable project are enforced by path-aware ESLint rules. Newsletter
depends only on its own domain and compatible Common platform bricks; host
applications own composition with Blog and other business capabilities.
Integration examples and migration
See the Newsletter integration guide for native/MailerLite host setup, the Forms-backed Angular example, contracts, and the coordinated release gate. Use the app-local migration guide to plan adoption while retaining host policy and historical evidence.
Canonical contract adoption (#489)
Newsletter Contracts 0.1.0 owns the wire schemas and operation definitions.
The existing TypeBox DTO exports remain available through derived bindings;
presentation validates with the pinned canonical runtime parser, preserving
strict, non-coercing input and UTF-16 limits. Honeypot handling still runs first.
The MailerLite adapter checks the signature against the exact raw bytes before
decoding and canonical payload validation. Provider limits/account matching,
consent ordering, native double opt-in and durable withdrawal deduplication remain
implementation responsibilities. No provider or HTTP adapter is replaced.
TypeScript consumers must use NodeNext, supported Node16, or bundler resolution.
CommonJS runtime consumption remains supported. The adoption record
distinguishes package and fixture evidence from approved deployed-host conformance.
Canonical version and verification
This source line consumes @anarchitects/newsletter-contracts@0.1.0 through
the domain TS compatibility bindings. See the canonical conformance guide
for exact checks, configured-host obligations and remaining capability gates.
Generated implementation OpenAPI and passing fixtures do not approve a deployed
host's security profile. Existing runtime support and TypeBox exports remain.