@voltro/plugin-mail 0.81.0 → 0.83.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  import { Context } from 'effect';
2
+ import { DataStore } from '@voltro/database';
2
3
  import { Effect } from 'effect';
3
4
  import { Layer } from 'effect';
5
+ import { PluginEgressDeclaration } from '@voltro/protocol';
6
+ import { PluginHttpRouteRequest } from '@voltro/protocol';
4
7
  import { Schema } from 'effect';
5
8
  import { VoltroPlugin } from '@voltro/protocol';
6
9
 
@@ -115,12 +118,30 @@ export declare interface IdempotencyStore {
115
118
  readonly record: (tenantId: string | null, key: string, result: SendResult) => Effect.Effect<void>;
116
119
  readonly activate?: (env: NodeJS.ProcessEnv) => Promise<void>;
117
120
  readonly deactivate?: () => Promise<void>;
121
+ /** Receives the app's `DataStore` at `bindDataStore`, for a store kept in the
122
+ * app's own database. */
123
+ readonly bind?: (store: DataStore) => Promise<void>;
118
124
  }
119
125
 
120
126
  /** Structural guard: recognise a `defineEmail(...)` result among a module's
121
127
  * exports (name + subject fn + render fn + a props schema). */
122
128
  export declare const isEmailTemplate: (v: unknown) => v is AnyEmailTemplate;
123
129
 
130
+ export declare interface LettermintOptions {
131
+ /** Project API token (`lm_…`), sent as `x-lettermint-token`. */
132
+ readonly projectToken: string;
133
+ /** Default `https://api.lettermint.co/v1`. */
134
+ readonly baseUrl?: string;
135
+ /** Route slug per stream. Default: the project's default route (its
136
+ * transactional one) for `transactional`, `broadcast` for `broadcast`. */
137
+ readonly routes?: {
138
+ readonly transactional?: string;
139
+ readonly broadcast?: string;
140
+ };
141
+ }
142
+
143
+ export declare const lettermintProvider: (options: LettermintOptions) => MailProvider;
144
+
124
145
  /** Names (+ locale variants) of every globally-registered template. */
125
146
  export declare const listGlobalTemplates: () => ReadonlyArray<{
126
147
  readonly name: string;
@@ -141,6 +162,12 @@ export declare interface MailAttachment {
141
162
  readonly contentType?: string;
142
163
  }
143
164
 
165
+ /** What mail egress a provider choice declares. */
166
+ export declare const mailEgressOf: (options: {
167
+ readonly provider?: MailProviderName | MailProvider;
168
+ readonly env?: Readonly<Record<string, string | undefined>>;
169
+ }) => ReadonlyArray<PluginEgressDeclaration>;
170
+
144
171
  /**
145
172
  * Mail failure. `transient` drives retry — HTTP 408 / 429 / 5xx and network
146
173
  * failures are retryable; other HTTP 4xx are not. SMTP instead retries 4xx
@@ -164,10 +191,21 @@ export declare interface MailEvent {
164
191
  readonly email: string;
165
192
  readonly provider: string;
166
193
  readonly messageId?: string;
194
+ /** The provider's id for this event — the key to act on a redelivered
195
+ * webhook only once. */
196
+ readonly eventId?: string;
197
+ /** What the message was sent with as `metadata`, returned by the provider. */
198
+ readonly metadata?: Readonly<Record<string, string>>;
167
199
  readonly raw: unknown;
168
200
  }
169
201
 
170
- export declare type MailEventType = 'delivered' | 'bounced' | 'complained' | 'opened' | 'clicked' | 'unknown';
202
+ /** `unsubscribed`: the recipient left a broadcast through the provider's own
203
+ * unsubscribe (a provider that `managesUnsubscribe`). It is NOT suppressed
204
+ * app-wide — it ended one list, not the person's transactional mail.
205
+ * `unsuppressed`: the provider lifted a suppression (someone removed it in
206
+ * its dashboard) — lifted here too, or the app would keep dropping mail the
207
+ * provider would now deliver. */
208
+ export declare type MailEventType = 'delivered' | 'bounced' | 'complained' | 'unsubscribed' | 'unsuppressed' | 'opened' | 'clicked' | 'unknown';
171
209
 
172
210
  export declare interface MailgunOptions {
173
211
  readonly apiKey: string;
@@ -208,12 +246,30 @@ export declare interface MailMessage {
208
246
  * step. The first send with a given key delivers; a replay with the same
209
247
  * key is a no-op returning the recorded result. Scoped per tenant. */
210
248
  readonly idempotencyKey?: string;
249
+ /** `transactional` (default) or `broadcast`. See `MailStream`. */
250
+ readonly stream?: MailStream;
251
+ /** How a recipient leaves a broadcast. See `MailUnsubscribe`. */
252
+ readonly unsubscribe?: MailUnsubscribe;
253
+ /** Key/value pairs the provider stores with the message and returns on its
254
+ * delivery events (`MailEvent.metadata`) — how a bounce finds its way back
255
+ * to the row that caused it. Never added to the mail itself. Lettermint and
256
+ * Postmark carry it; other providers ignore it. */
257
+ readonly metadata?: Readonly<Record<string, string>>;
211
258
  }
212
259
 
213
- export declare const mailPlugin: (options?: MailPluginOptions) => VoltroPlugin;
260
+ /**
261
+ * The mail plugin, with the service it built — the same one handlers yield as
262
+ * `MailService`. For wiring that happens in `app.config.ts`, before any
263
+ * handler runs: `authRoutesPlugin({ sendEmail: mailSender(mail.service) })`.
264
+ */
265
+ export declare type MailPlugin = VoltroPlugin & {
266
+ readonly service: MailServiceShape;
267
+ };
268
+
269
+ export declare const mailPlugin: (options?: MailPluginOptions) => MailPlugin;
214
270
 
215
271
  export declare interface MailPluginOptions {
216
- /** `'resend' | 'postmark' | 'sendgrid' | 'smtp' | 'console' | 'memory'`
272
+ /** `'lettermint' | 'resend' | 'postmark' | 'sendgrid' | 'smtp' | 'console' | 'memory'`
217
273
  * (built from env) or a `MailProvider`. Default: `MAIL_PROVIDER` env, else
218
274
  * `'console'`. */
219
275
  readonly provider?: MailProviderName | MailProvider;
@@ -232,12 +288,12 @@ export declare interface MailPluginOptions {
232
288
  * `mail.send({ template, props })`. */
233
289
  readonly templates?: ReadonlyArray<AnyEmailTemplate>;
234
290
  /** Suppression backend for bounced/complained addresses: `'memory'`
235
- * (default, single-node), `'postgres'` (own pool from `DB_*`/`PG_*` env),
291
+ * (default, single-node), `'postgres'` (the app's own postgres connection),
236
292
  * or a custom `SuppressionStore`. */
237
293
  readonly suppression?: 'memory' | 'postgres' | SuppressionStore;
238
294
  /** Per-send idempotency backend (webhook-retry double-send guard):
239
- * `'memory'` (default, single-node), `'postgres'` (own pool from
240
- * `DB_*`/`PG_*` env), or a custom `IdempotencyStore`. Engaged only for a
295
+ * `'memory'` (default, single-node), `'postgres'` (the app's own
296
+ * postgres connection), or a custom `IdempotencyStore`. Engaged only for a
241
297
  * send carrying `idempotencyKey`. */
242
298
  readonly idempotency?: 'memory' | 'postgres' | IdempotencyStore;
243
299
  /** Backing store for HELD scheduled messages (providers without native
@@ -247,7 +303,9 @@ export declare interface MailPluginOptions {
247
303
  /** How often (ms) the scheduled-send flush loop wakes to deliver due held
248
304
  * messages. Default 1000. Ignored when the provider schedules natively. */
249
305
  readonly scheduleTickMs?: number;
250
- /** Disambiguates multiple instances of this plugin in one app. */
306
+ /** Mount the provider's delivery-event webhook. Needs a provider with a
307
+ * `webhook` verifier (Lettermint). See `MailWebhookOptions`. */
308
+ readonly webhook?: MailWebhookOptions;
251
309
  /**
252
310
  * Namespace for this plugin's inspect endpoints. Default `mail`.
253
311
  *
@@ -290,9 +348,24 @@ export declare interface MailProvider {
290
348
  /** One request for N personalized messages. Each keeps its own subject /
291
349
  * body / template vars. Returns one `SendResult` per input, in order. */
292
350
  readonly sendBatch?: (messages: ReadonlyArray<ResolvedMessage>) => Effect.Effect<ReadonlyArray<SendResult>, MailError>;
351
+ /** True if the provider adds its own unsubscribe link and one-click headers
352
+ * to broadcast mail and suppresses whoever uses them (Lettermint's
353
+ * broadcast route). The service then sends no `List-Unsubscribe` of its own
354
+ * — two competing unsubscribe mechanisms would each only half work. */
355
+ readonly managesUnsubscribe?: boolean;
356
+ /** Verifies and parses the provider's delivery-event webhook. Present →
357
+ * `mailPlugin({ webhook })` can mount the endpoint. */
358
+ readonly webhook?: MailProviderWebhook;
293
359
  }
294
360
 
295
- export declare type MailProviderName = 'resend' | 'postmark' | 'sendgrid' | 'ses' | 'mailgun' | 'smtp' | 'console' | 'memory';
361
+ export declare type MailProviderName = 'lettermint' | 'resend' | 'postmark' | 'sendgrid' | 'ses' | 'mailgun' | 'smtp' | 'console' | 'memory';
362
+
363
+ /** A provider's delivery-event webhook: signature check, then normalisation. */
364
+ export declare interface MailProviderWebhook {
365
+ /** Verify the signature over the RAW body and decode it. Fails with
366
+ * `MailWebhookError` for a missing, stale or forged signature. */
367
+ readonly verify: (headers: Readonly<Record<string, string | undefined>>, rawBody: string, secret: string, nowSeconds?: number) => Effect.Effect<unknown, MailWebhookError>;
368
+ }
296
369
 
297
370
  export declare interface MailSendRecord {
298
371
  readonly id: string;
@@ -356,6 +429,69 @@ export declare interface MailServiceShape {
356
429
  readonly unsuppress: (tenantId: string | null, email: string) => Effect.Effect<void>;
357
430
  /** Is this address currently suppressed for the tenant? */
358
431
  readonly isSuppressed: (tenantId: string | null, email: string) => Effect.Effect<boolean>;
432
+ /** The provider behind the service. `managesUnsubscribe` tells a broadcast
433
+ * sender whether the provider already puts an unsubscribe link in the mail
434
+ * (then the body needs none of its own). Absent on a hand-built service. */
435
+ readonly provider?: {
436
+ readonly name: string;
437
+ readonly managesUnsubscribe: boolean;
438
+ };
439
+ }
440
+
441
+ /**
442
+ * Which kind of mail this is. `transactional` (the default) is one-to-one mail
443
+ * a person's own action triggered — an invitation, a receipt, a password
444
+ * reset. `broadcast` is one-to-many mail — a newsletter, a campaign. Providers
445
+ * that separate the two (Lettermint routes, Postmark message streams) send
446
+ * them from different infrastructure, so a newsletter's complaints never cost
447
+ * a password reset its inbox placement.
448
+ */
449
+ export declare type MailStream = 'transactional' | 'broadcast';
450
+
451
+ /**
452
+ * How a recipient leaves a broadcast. `oneClickUrl` is the HTTPS endpoint a
453
+ * mail client POSTs `List-Unsubscribe=One-Click` to (RFC 8058); the service
454
+ * turns it into the `List-Unsubscribe` + `List-Unsubscribe-Post` headers.
455
+ * Required on a broadcast unless the provider unsubscribes recipients itself
456
+ * (`MailProvider.managesUnsubscribe`).
457
+ */
458
+ export declare interface MailUnsubscribe {
459
+ readonly oneClickUrl: string;
460
+ /** Optional `mailto:` alternative listed beside the URL. */
461
+ readonly mailto?: string;
462
+ }
463
+
464
+ /** A webhook that failed verification — answer 401, never process it. */
465
+ export declare class MailWebhookError extends MailWebhookError_base {
466
+ }
467
+
468
+ declare const MailWebhookError_base: Schema.TaggedErrorClass<MailWebhookError, "MailWebhookError", {
469
+ readonly _tag: Schema.tag<"MailWebhookError">;
470
+ } & {
471
+ provider: typeof Schema.String;
472
+ message: typeof Schema.String;
473
+ }>;
474
+
475
+ /**
476
+ * The provider's delivery-event webhook, mounted by the plugin. Bounces and
477
+ * spam complaints suppress the address in the plugin's OWN suppression store
478
+ * — the one every send checks — then `onEvent` sees every event.
479
+ */
480
+ export declare interface MailWebhookOptions {
481
+ /** Signing secret. Default: `MAIL_WEBHOOK_SECRET`. Without one the endpoint
482
+ * answers 503, and a production boot refuses. */
483
+ readonly secret?: string;
484
+ /** Default `/webhooks/mail`. */
485
+ readonly path?: string;
486
+ /**
487
+ * Every verified event, after suppression — an unsubscribe to record, a
488
+ * bounce to mark on the row the message's `metadata` names. A failure
489
+ * answers 500 and the provider redelivers, so act idempotently
490
+ * (`event.eventId`). `store` is the app's boot store, as on any plugin route.
491
+ */
492
+ readonly onEvent?: (event: MailEvent, ctx: {
493
+ readonly store: PluginHttpRouteRequest['store'];
494
+ }) => Effect.Effect<void, unknown> | Promise<void> | void;
359
495
  }
360
496
 
361
497
  export declare const makeScheduler: (options: SchedulerOptions) => Scheduler;
@@ -378,30 +514,33 @@ export declare interface Outbox {
378
514
  /** Normalise a provider webhook payload to MailEvents. Unknown shapes → []. */
379
515
  export declare const parseMailEvent: (provider: string, payload: unknown) => ReadonlyArray<MailEvent>;
380
516
 
381
- export declare interface PostgresIdempotencyOptions {
382
- readonly table?: string;
383
- readonly maxConnections?: number;
384
- }
385
-
386
517
  /**
387
- * Postgres dedup store. `reserve` is an atomic
388
- * `INSERT … ON CONFLICT (tenant, key) DO NOTHING RETURNING` — exactly one
389
- * replica sees the insert, so exactly one send fires fleet-wide. Unlike the
390
- * suppression store this does NOT fail open: if the dedup DB is unreachable a
391
- * reserve reports `won: false` (skip) so a blip can never turn into a
392
- * double-send — the safer failure for an at-least-once webhook.
518
+ * Dedup in the app's own database: `_voltro_mail_idempotency`, declared through
519
+ * the plugin's `extendSchema` (`storeTables.ts`) and written through the app's
520
+ * `DataStore`, bound at `bindDataStore`. `reserve` is an `insertIgnore` on the
521
+ * (tenant, key) primary key, and its outcome comes from the store's own insert
522
+ * decision — exactly one replica sees `inserted`, so exactly one send fires
523
+ * fleet-wide. Unlike the suppression store this does NOT fail open: if the
524
+ * database is unreachable a reserve reports `won: false` (skip) so a blip can
525
+ * never turn into a double-send — the safer failure for an at-least-once
526
+ * webhook.
393
527
  */
394
- export declare const postgresIdempotencyStore: (options?: PostgresIdempotencyOptions) => IdempotencyStore;
395
-
396
- export declare interface PostgresSuppressionOptions {
397
- readonly table?: string;
398
- readonly maxConnections?: number;
399
- }
528
+ export declare const postgresIdempotencyStore: () => IdempotencyStore;
400
529
 
401
- export declare const postgresSuppressionStore: (options?: PostgresSuppressionOptions) => SuppressionStore;
530
+ /**
531
+ * The suppression list in the app's own database: `_voltro_mail_suppression`,
532
+ * declared through the plugin's `extendSchema` (`storeTables.ts`) and written
533
+ * through the app's `DataStore`, bound at `bindDataStore`. Multi-node correct on
534
+ * every dialect the app runs on; nothing opens a connection of its own.
535
+ */
536
+ export declare const postgresSuppressionStore: () => SuppressionStore;
402
537
 
403
538
  export declare const postmarkProvider: (options: {
404
539
  readonly serverToken: string;
540
+ readonly streams?: {
541
+ readonly transactional?: string;
542
+ readonly broadcast?: string;
543
+ };
405
544
  }) => MailProvider;
406
545
 
407
546
  /** Stable snapshot of everything the memory provider has "sent". */
@@ -537,6 +676,9 @@ export declare interface SuppressionStore {
537
676
  readonly unsuppress: (tenantId: string | null, email: string) => Effect.Effect<void>;
538
677
  readonly activate?: (env: NodeJS.ProcessEnv) => Promise<void>;
539
678
  readonly deactivate?: () => Promise<void>;
679
+ /** Receives the app's `DataStore` at `bindDataStore`, for a store kept in the
680
+ * app's own database. */
681
+ readonly bind?: (store: DataStore) => Promise<void>;
540
682
  }
541
683
 
542
684
  /** Send a registered template instead of raw html/text. */
@@ -558,8 +700,19 @@ export declare interface TemplateMessage {
558
700
  readonly scheduledAt?: Date;
559
701
  /** See `MailMessage.idempotencyKey`. */
560
702
  readonly idempotencyKey?: string;
703
+ /** See `MailMessage.stream`. */
704
+ readonly stream?: MailStream;
705
+ /** See `MailMessage.unsubscribe`. */
706
+ readonly unsubscribe?: MailUnsubscribe;
707
+ /** See `MailMessage.metadata`. */
708
+ readonly metadata?: Readonly<Record<string, string>>;
561
709
  }
562
710
 
711
+ /** Lettermint signs `${t}.${rawBody}` with HMAC-SHA256 under the webhook
712
+ * secret: `x-lettermint-signature: t=<unix>,v1=<hex>` (several `v1` during a
713
+ * secret rotation), `x-lettermint-delivery: <unix>` must equal `t`. */
714
+ export declare const verifyLettermintSignature: (headers: Readonly<Record<string, string | undefined>>, rawBody: string, secret: string, nowSeconds?: number, toleranceSeconds?: number) => Effect.Effect<unknown, MailWebhookError>;
715
+
563
716
  export { VoltroPlugin }
564
717
 
565
718
  export { }