@ultimat3/mail 22.2.2 → 22.3.1

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/README.md CHANGED
@@ -34,6 +34,7 @@ delivers inline only when `{ sync: true }` is passed or no job driver is configu
34
34
  | Every colour is a token | `MAIL_TOKENS` in `layout.ts` holds light + dark hexes; templates never see a hex |
35
35
  | Every date takes an IANA zone | `options.tz`, else `ctx.tz`, else `UTC` |
36
36
  | No CR/LF in a header-bound field | checked in `renderMessage` and again in `sendMailJob`, so every driver refuses the same message (`X_MAIL_HEADER_INVALID`). `mime.ts` keeps its own gate for the headers the SMTP transport mints itself |
37
+ | `unsubscribeUrl` is one-click unless you say otherwise | it emits `List-Unsubscribe: <url>` plus `List-Unsubscribe-Post: List-Unsubscribe=One-Click` (RFC 8058), a promise that a POST to that URL unsubscribes. When the URL is a confirm page — GET shows a button and must never unsubscribe, because scanners prefetch — pass `unsubscribeOneClick: false`: the `-Post` line goes, `List-Unsubscribe` and the footer link stay |
37
38
  | Sending is a job | `retry: { attempts: 5, backoff: 'exponential' }`, idempotency key `mail:<mailId>:<hash(recipients + rendered)>` — 128 bits, ASCII, under Resend's 256-character limit at any recipient count — or `(mailId, your key)` when you pass one (digested if it is not a short ASCII token) — a caller's key is scoped to its mail so two templates cannot dedupe each other away |
38
39
 
39
40
  ## Drivers
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/mail",
3
- "version": "22.2.2",
3
+ "version": "22.3.1",
4
4
  "description": "Transactional email as data: one template renders HTML and text, sent through a job.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,10 +31,10 @@
31
31
  "test": "bun test"
32
32
  },
33
33
  "dependencies": {
34
- "@ultimat3/core": "22.2.2",
35
- "@ultimat3/i18n": "22.2.2",
36
- "@ultimat3/jobs": "22.2.2",
37
- "@ultimat3/schema": "22.2.2",
38
- "@ultimat3/time": "22.2.2"
34
+ "@ultimat3/core": "22.3.1",
35
+ "@ultimat3/i18n": "22.3.1",
36
+ "@ultimat3/jobs": "22.3.1",
37
+ "@ultimat3/schema": "22.3.1",
38
+ "@ultimat3/time": "22.3.1"
39
39
  }
40
40
  }
package/src/driver.ts CHANGED
@@ -27,6 +27,12 @@ export interface MailMessage {
27
27
  readonly cc?: readonly string[] | undefined;
28
28
  readonly bcc?: readonly string[] | undefined;
29
29
  readonly unsubscribeUrl?: string | undefined;
30
+ /**
31
+ * `false` drops `List-Unsubscribe-Post` and keeps the GET-only `List-Unsubscribe`: for an
32
+ * `unsubscribeUrl` that is a confirm page, which cannot honour RFC 8058's promise that a POST to
33
+ * it unsubscribes. Absent means `true` — one-click, what Gmail and Yahoo require of bulk senders.
34
+ */
35
+ readonly unsubscribeOneClick?: boolean | undefined;
30
36
  readonly idempotencyKey?: string | undefined;
31
37
  }
32
38
 
@@ -47,14 +53,17 @@ export interface MailDriver {
47
53
 
48
54
  /**
49
55
  * RFC 8058 one-click unsubscribe. Gmail and Yahoo require it for bulk senders and
50
- * reward it for transactional ones, so it is computed here rather than per driver.
56
+ * reward it for transactional ones, so it is computed here rather than per driver. The `-Post`
57
+ * line is a promise that a POST to the URL unsubscribes, so `unsubscribeOneClick: false` omits it.
51
58
  */
52
59
  export function messageHeaders(message: MailMessage): Readonly<Record<string, string>> {
53
60
  const headers: Record<string, string> = { 'Auto-Submitted': 'auto-generated' };
54
61
  if (message.replyTo !== undefined) headers['Reply-To'] = message.replyTo;
55
62
  if (message.unsubscribeUrl !== undefined) {
56
63
  headers['List-Unsubscribe'] = `<${message.unsubscribeUrl}>`;
57
- headers['List-Unsubscribe-Post'] = 'List-Unsubscribe=One-Click';
64
+ if (message.unsubscribeOneClick !== false) {
65
+ headers['List-Unsubscribe-Post'] = 'List-Unsubscribe=One-Click';
66
+ }
58
67
  }
59
68
  return headers;
60
69
  }
@@ -42,6 +42,8 @@ export function mailIdempotencyKey(message: MailMessage): string {
42
42
  tz: message.tz,
43
43
  replyTo: message.replyTo ?? '',
44
44
  unsubscribeUrl: message.unsubscribeUrl ?? '',
45
+ // Only when it changes the wire: every key minted before the option existed stays the same.
46
+ ...(message.unsubscribeOneClick === false ? { unsubscribeOneClick: false } : {}),
45
47
  }),
46
48
  );
47
49
  return `mail:${message.mailId}:${digest}`;
package/src/job.ts CHANGED
@@ -21,6 +21,7 @@ export const mailMessageSchema: StandardSchemaV1<unknown, MailMessage> = t.objec
21
21
  cc: t.array(t.email).optional(),
22
22
  bcc: t.array(t.email).optional(),
23
23
  unsubscribeUrl: t.url.optional(),
24
+ unsubscribeOneClick: t.boolean.optional(),
24
25
  idempotencyKey: t.string.optional(),
25
26
  });
26
27
 
package/src/mail.ts CHANGED
@@ -43,6 +43,12 @@ export interface SendOptions {
43
43
  readonly cc?: readonly string[] | undefined;
44
44
  readonly bcc?: readonly string[] | undefined;
45
45
  readonly unsubscribeUrl?: string | undefined;
46
+ /**
47
+ * Default `true`: `List-Unsubscribe-Post: List-Unsubscribe=One-Click` (RFC 8058) rides with
48
+ * `List-Unsubscribe`. `false` when `unsubscribeUrl` is a GET confirm page that cannot take the
49
+ * one-click POST — the header and the footer link stay, the POST promise goes.
50
+ */
51
+ readonly unsubscribeOneClick?: boolean | undefined;
46
52
  readonly idempotencyKey?: string | undefined;
47
53
  /** Deliver inline instead of through the queue. Tests and CLI one-shots only. */
48
54
  readonly sync?: boolean | undefined;
@@ -133,6 +139,7 @@ export function renderMessage<I>(
133
139
  cc: options.cc,
134
140
  bcc: options.bcc,
135
141
  unsubscribeUrl: options.unsubscribeUrl,
142
+ unsubscribeOneClick: options.unsubscribeOneClick,
136
143
  idempotencyKey: options.idempotencyKey,
137
144
  };
138
145
  // Here, not in a driver: interpolated data reaches `Subject`, and whether a break in it injects