@ultimat3/mail 22.3.0 → 22.3.2
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 +1 -0
- package/package.json +6 -6
- package/src/driver.ts +11 -2
- package/src/idempotency.ts +2 -0
- package/src/job.ts +1 -0
- package/src/mail.ts +7 -0
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.3.
|
|
3
|
+
"version": "22.3.2",
|
|
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.3.
|
|
35
|
-
"@ultimat3/i18n": "22.3.
|
|
36
|
-
"@ultimat3/jobs": "22.3.
|
|
37
|
-
"@ultimat3/schema": "22.3.
|
|
38
|
-
"@ultimat3/time": "22.3.
|
|
34
|
+
"@ultimat3/core": "22.3.2",
|
|
35
|
+
"@ultimat3/i18n": "22.3.2",
|
|
36
|
+
"@ultimat3/jobs": "22.3.2",
|
|
37
|
+
"@ultimat3/schema": "22.3.2",
|
|
38
|
+
"@ultimat3/time": "22.3.2"
|
|
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
|
-
|
|
64
|
+
if (message.unsubscribeOneClick !== false) {
|
|
65
|
+
headers['List-Unsubscribe-Post'] = 'List-Unsubscribe=One-Click';
|
|
66
|
+
}
|
|
58
67
|
}
|
|
59
68
|
return headers;
|
|
60
69
|
}
|
package/src/idempotency.ts
CHANGED
|
@@ -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
|