Anarchitecture Bricks Docs

Repository documentation hub for packages, guides, and generated references.

@anarchitects/newsletter-nest

Newsletter backend package for epic #428, implementing ADR-0010.

Features

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:

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

  1. Validate the request, affirmative consent and exact configured policy version.
  2. Await ConsentRepositoryPort.appendGrant committing the evidence.
  3. Call SubscriberPort.subscribe with 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:

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.

Source Links