@framers/agentos-ext-channel-email 0.6.3 → 0.6.4

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
@@ -1,6 +1,6 @@
1
1
  # @framers/agentos-ext-channel-email
2
2
 
3
- Email for AgentOS. `EmailService` sends mail through SMTP or Resend's HTTPS API, and reads, searches, extracts codes from, replies to, moves, labels, flags and deletes mail through IMAP. The pack's factory wraps the service in a messaging-channel adapter and five tools for an agent. Two stores on a host's SQL database keep what a sender reads before a send: a suppression record and the tokens of unsubscribe links.
3
+ Email for AgentOS. `EmailService` sends mail through SMTP or Resend's HTTPS API, and reads, searches, extracts codes from, replies to, moves, labels, flags and deletes mail through IMAP. The pack's factory wraps the service in a messaging-channel adapter and five tools for an agent. Two stores on a host's SQL database keep what a sender reads before a send: a suppression record and the tokens of unsubscribe links. With its `unsubscribe` option, every mail the service sends carries an unsubscribe link and RFC 8058's one-click fields and reads the suppression record first, and `createUnsubscribeHandler` answers the link ([unsubscribe on every mail](#unsubscribe-on-every-mail)).
4
4
 
5
5
  ## Installation
6
6
 
@@ -89,7 +89,7 @@ Through Resend the fields go in the request's `headers` ([custom headers](https:
89
89
 
90
90
  Each value is read once, and the fields are sent as read. A value with a double quote, such as a `List-Id` of `"Weekly summary" <weekly.example.com>`, arrives as written through both transports. Two differences stay, and neither changes what a field means: nodemailer writes a name in its own case, such as `List-ID` for `List-Id`, and RFC 5322 makes field names case-insensitive; and it writes `Sender` as the address it parses from the value, the same mailbox. Resend's [custom headers](https://resend.com/docs/dashboard/emails/custom-headers) page and its [send call](https://resend.com/docs/api-reference/emails/send-email) do not say how Resend writes a long field into the message.
91
91
 
92
- `headers` adds fields and never replaces the service's own: `From`, `To`, `Cc`, `Bcc`, `Reply-To`, `Subject`, `Date`, `Message-ID`, `MIME-Version` and every `Content-` field are refused, in any case. The recipients, the reply-to and the subject have their own options, and the From comes from the configuration's `from`. A name given twice in different cases is refused too. A refusal throws an `Error` before either transport is used, naming the field, or its place in the object when its name is at fault, and none of its value. The `emailSend` tool and the channel adapter add no header fields.
92
+ `headers` adds fields and never replaces the service's own: `From`, `To`, `Cc`, `Bcc`, `Reply-To`, `Subject`, `Date`, `Message-ID`, `MIME-Version` and every `Content-` field are refused, in any case. The recipients, the reply-to and the subject have their own options, and the From comes from the configuration's `from`. A name given twice in different cases is refused too. A refusal throws an `Error` before either transport is used, naming the field, or its place in the object when its name is at fault, and none of its value. The `emailSend` tool and the channel adapter add no header fields of their own. With `unsubscribe` configured the service writes `List-Unsubscribe` and `List-Unsubscribe-Post` itself, on every send and reply, and a send that gives either is refused ([unsubscribe on every mail](#unsubscribe-on-every-mail)).
93
93
 
94
94
  The subject goes out on one line through both transports: each line break in it, and each other control character but the tab, is sent as a space, as nodemailer writes a line break in a subject.
95
95
 
@@ -131,6 +131,97 @@ const grant = await tokens.find(token); // { recipient: 'account-1', scope: 'wee
131
131
 
132
132
  `suppressionStoreDdl(prefix)` makes the table `<prefix>_suppressions` (`scope`, `address_hash`, `reason`, `suppressed_on`), `recipientCapabilityDdl(prefix)` makes `<prefix>_capabilities` (`token_hash`, `recipient`, `scope`, `expires_at`, `revoked_at`) with its index on the recipient, and `emailPreferencesDdl(prefix)` makes both. The prefix is a lower-case letter and at most 39 lower-case letters, digits or underscores; any other value throws a `RangeError` from the DDL functions and both constructors. The prefix keeps two hosts' tables apart on one database. On SQLite, take a prefix other than `sqlite` and those beginning `sqlite_`: their tables would begin `sqlite_`, names SQLite keeps for its own tables, so its `CREATE TABLE` refuses them ([CREATE TABLE](https://www.sqlite.org/lang_createtable.html)) and every call of a store on such a prefix then fails on the missing table. PostgreSQL makes them. The statements are `CREATE ... IF NOT EXISTS`, so a migration can run them again, and they run on SQLite and PostgreSQL, from one process at a time: PostgreSQL does not promise that two `CREATE TABLE IF NOT EXISTS` of one new table at once both succeed, and one of them can fail on a duplicate key in `pg_type` (pgsql-hackers, 9 October 2018: [the report](https://www.postgresql.org/message-id/93336f06-86c2-71b6-abe3-0d42cc614b2b%40postgrespro.ru) and [the reply](https://www.postgresql.org/message-id/22700.1539093909%40sss.pgh.pa.us)). A host whose processes start together runs them in a migration, such as the adapter's `runSchemaMigrations` (0.7.2 or later), which takes an advisory lock on PostgreSQL. A token's row refuses its nonce for as long as it stays, revoked or not. A host that deletes a recipient's rows, by its own foreign key with `ON DELETE CASCADE` or by hand, never issues their nonces again: each message's nonce is one no other message ever had, such as a random id or a row id that is never reused. A host that cannot promise that calls `revoke` with each deletion and keeps the rows. Each store checks every argument before its first statement. A scope, a recipient and a nonce are strings of 1 to 512 UTF-16 code units (JavaScript's `length`, in which a character past U+FFFF counts two) with no U+0000, which PostgreSQL's text refuses, and no lone surrogate, which UTF-8 cannot encode, so a driver would store something else in its place: any other value throws a `TypeError` that holds none of it, and a name is stored as given on every backend. A database error passes through as it was thrown. A store made on the handle that the adapter's `transaction` gives its callback writes inside that transaction. On a server, open the database as the example does, with the adapter's `strict` option (0.6.15 or later): without it, a `url` that is unset opens a local SQLite database in its place, and the record a sender reads before each send would live there.
133
133
 
134
+ ## Unsubscribe on every mail
135
+
136
+ ```ts
137
+ import { createDatabase } from '@framers/sql-storage-adapter';
138
+ import {
139
+ EmailService,
140
+ SqlRecipientCapability,
141
+ SqlSuppressionStore,
142
+ createUnsubscribeHandler,
143
+ emailPreferencesDdl,
144
+ } from '@framers/agentos-ext-channel-email';
145
+
146
+ const db = await createDatabase({ url: process.env.DATABASE_URL, strict: true });
147
+ await db.exec(emailPreferencesDdl('mail'));
148
+ const tokens = new SqlRecipientCapability(db, 'mail');
149
+ const suppressions = new SqlSuppressionStore(db, 'mail');
150
+
151
+ const email = new EmailService({
152
+ resend: { apiKey: process.env.RESEND_API_KEY ?? '' },
153
+ from: 'Example <hello@example.com>',
154
+ unsubscribe: {
155
+ pageUrl: 'https://example.com/email/unsubscribe',
156
+ mailto: 'unsubscribe@example.com',
157
+ tokens,
158
+ suppressions,
159
+ key: Buffer.from(process.env.UNSUBSCRIBE_KEY ?? '', 'hex'), // 32 random bytes or more, kept secret
160
+ postalAddress: 'Example LLC, 1 Example Street, Example City, EX 00000',
161
+ },
162
+ });
163
+ await email.initialize();
164
+
165
+ // Optional mail names its category: its link stops that category.
166
+ await email.sendEmail({ to: 'reader@example.com', subject: 'Your week', body: 'Plain words.', category: 'weekly-summary' });
167
+ // Mail a person cannot turn off is required: its link turns off every optional mail.
168
+ await email.sendEmail({ to: 'reader@example.com', subject: 'Your receipt', body: 'Paid.', required: true });
169
+
170
+ // The route at the page's path, such as a Next.js route handler in app/email/unsubscribe/route.ts.
171
+ const unsubscribe = createUnsubscribeHandler({ tokens, suppressions, productName: 'Example', deleteAccountUrl: '/settings/account' });
172
+ export const GET = unsubscribe;
173
+ export const POST = unsubscribe;
174
+ ```
175
+
176
+ With `unsubscribe` configured, every mail the service sends carries a link of its own, through `sendEmail`, `replyToEmail`, the `emailSend` and `emailReply` tools and the channel adapter alike:
177
+
178
+ - `List-Unsubscribe` names the link, then `mailto:` with the subject `unsubscribe` when `mailto` is given, and `List-Unsubscribe-Post` is `List-Unsubscribe=One-Click`, the two fields of a one-click unsubscribe ([RFC 8058](https://www.rfc-editor.org/rfc/rfc8058)). Through Resend they go in the request's `headers` ([custom headers](https://resend.com/docs/dashboard/emails/custom-headers)); through SMTP nodemailer writes them as it writes other added fields. A receiver acts on the pair only when the message's DKIM signature covers both ([RFC 8058 section 4](https://www.rfc-editor.org/rfc/rfc8058#section-4)). Resend's pages do not say which fields its signature lists, so read the `h=` tag of the `DKIM-Signature` of a mail received from the sending domain before relying on one-click there.
179
+ - The text part ends with a blank line, `Unsubscribe: ` and the link, and the postal address on the next line when one is configured. The HTML part gets a paragraph in 13-pixel text, the smallest a reader is asked to read at a phone's width, before its last closing body tag, or at its end when it has none: the link, underlined, and the postal address under it. The part is read as the HTML standard's tokenizer reads it, and a test checks the placement against parse5: a `</body>` inside a comment, an attribute's value, a doctype, a template or an element whose content is text (a script, a style, a title or a textarea) is no tag, and a template or anything of these the part leaves open at its end gets the footer before it. Three limits stand: an element the part leaves open around that place, such as a hidden `div` or a `select` with no end tag, holds the footer too; inside an `svg` or a `math` a style's or a title's content is taken as text and a CDATA section as a declaration, so an inline `svg` that leaves a comment open in its style can hide the footer; and a frameset page has no body to hold it, which no mail client renders. A part that holds `{{unsubscribe_url}}` (`UNSUBSCRIBE_URL_MARKER`) in its text or a tag's attribute, such as a link's `href`, gets the link in place of each and no footer, so a branded template places the link itself and keeps it legible. A marker only in a comment, a doctype, a script, a style, a title, a textarea, a template or a tag left open is written as the link too, and the part still gets the footer, since a reader sees none of those; commercial mail whose part does not hold the postal address then gets it after the part's text.
180
+ - The link is `pageUrl` with `?t=` and a token of 43 characters that `tokens.issue` draws for the recipient's address hash and the list the link writes. It names nothing else: no address, account, category or time can be read from it, and `tokens` keeps only its SHA-256. The send's `idempotencyKey`, when it has one, is its nonce (as `send:` and the key), so a retry carries the same link; otherwise each send draws a new one.
181
+
182
+ A send says what kind of mail it is, and each kind reads its own lists before anything is drawn or sent:
183
+
184
+ | The send | The lists it reads, in order | Its link writes |
185
+ |---|---|---|
186
+ | `required: true`, and every reply | `do-not-contact` (`DO_NOT_CONTACT_SCOPE`) | `optional` (`OPTIONAL_MAIL_SCOPE`): every optional mail |
187
+ | `category: 'weekly-summary'` | `do-not-contact`, `mail` (`DEFAULT_SUPPRESSION_SCOPE`, the provider's bounces and complaints), `optional`, `optional:weekly-summary` | `optional:weekly-summary` (`categoryScope('weekly-summary')`) |
188
+
189
+ An address on a list its send reads rejects the send with `EmailSuppressedError`, whose `code` is `suppressed` and whose `scope` names the list, before a token is drawn or anything is sent; its message names the list and never the address. The `emailSend` tool answers it as `{ success: false, error }`, and the channel adapter throws it. The record names an address by `addressHash` of the address trimmed and lower-cased: its SHA-256 in lower-case hexadecimal unless the configuration gives a function of its own, which every other writer of the record then uses too. That function answers 64 lower-case hexadecimal characters, the name the route reads a link's recipient by; a send whose address it names any other way throws a `TypeError` that holds neither, before any list is read, so no mail goes out with a link that stops nothing. The do-not-contact list is the operator's: `await suppressions.suppress(DO_NOT_CONTACT_SCOPE, addressHash, 'manual', '2026-11-04')` stops every mail to the address, required mail and replies included, and a host writes its standing do-not-contact addresses there before its first send.
190
+
191
+ - `category` is a lower-case letter, then at most 63 lower-case letters, digits, hyphens or underscores, and optional mail must name one. `required: true` marks mail a person cannot turn off: sign-in codes, receipts, security and legal notices, and mail to the team's own address, such as an operations alert. `commercial: true` marks advertising, which is never required and is refused unless the configuration gives `postalAddress`, since CAN-SPAM asks commercial mail for the sender's valid physical postal address ([15 U.S.C. 7704(a)(5)](https://www.law.cornell.edu/uscode/text/15/7704)).
192
+ - A send goes to one address, `reader@example.com` or `Reader <reader@example.com>`, with no `cc` or `bcc`, since a link names one recipient, and gives no `List-Unsubscribe` or `List-Unsubscribe-Post` of its own. Anything else throws before anything is read, drawn or sent. The recipients, the reply-to, the idempotency key and the attachments are read once, before anything is awaited, so the address whose lists were read and whose link was drawn is the one the message goes to, even when the caller changes the options object while the send waits. Without `unsubscribe`, a `category`, a `required: true` or a `commercial: true` throws the same way, so no send is taken for one that carries a link.
193
+ - The `emailSend` tool and the channel adapter send under `agentCategory`; `emailReply` and `replyToEmail` send as required mail does, so a person who turned off every optional mail still gets the answer to a message they wrote, and an address on the do-not-contact list gets none. A reply reads the do-not-contact list for the address it goes to and for every address the original names in its From, Sender and Reply-To, so a person on the list who writes with another Reply-To gets no reply either.
194
+
195
+ | `unsubscribe` option | Default | What it sets |
196
+ |---|---|---|
197
+ | `pageUrl` | required | The page each link opens and the address of the one-click POST: https, no query, fragment or credentials, 900 characters at most |
198
+ | `mailto` | none | An address at the sender that takes unsubscribe requests by mail, listed after the link; leave it out when nothing reads that mailbox |
199
+ | `tokens` | required | Where the tokens are drawn, such as `SqlRecipientCapability`; the route finds them there |
200
+ | `suppressions` | required | The suppression record each send reads, such as `SqlSuppressionStore`; the route writes it |
201
+ | `key` | required | The key the tokens are drawn under, 32 random bytes or more, or a function that answers it at each send so a rotated key applies at once |
202
+ | `retiredKeys` | none | The keys tokens were drawn under before `key`, or a function that answers them at each send. List a rotated key while a send under an idempotency key may still be retried, 24 hours through Resend: the retry finds the token its first attempt drew and carries the same link, since Resend answers a changed payload under the same key with 409 `invalid_idempotent_request` ([errors](https://resend.com/docs/api-reference/errors)) |
203
+ | `addressHash` | SHA-256 in hex | How the record names an address, given the address trimmed and lower-cased |
204
+ | `postalAddress` | none | The sender's postal address on one line, 300 characters at most, at the foot of every mail; commercial mail is refused without it |
205
+ | `agentCategory` | `conversation` | The category of the mail the `emailSend` tool and the channel adapter send |
206
+ | `linkText` | `Unsubscribe` | The words of the footer's link |
207
+
208
+ Each option is checked when the service is made, and one that does not fit throws a `TypeError` that names it and holds none of its value. The pack's factory takes the same object as its option `unsubscribe`.
209
+
210
+ `createUnsubscribeHandler(options)` is the route the link opens: a function from a web `Request` to a `Response`, mounted at `pageUrl`'s path on any server on Node.js that speaks them, such as a Next.js route handler on the Node.js runtime. The route reads the request with web APIs alone; the package it ships in loads Node's modules. It holds no transport, so an unsubscribe is never answered by a mail.
211
+
212
+ - A GET or a HEAD shows a page that asks and changes nothing, since link scanners follow links. A category's link offers a button that stops the category and one that turns off every optional mail; a required mail's link offers one button that turns off every optional mail, says what still comes and why (sign-in codes, receipts, replies to mail the reader writes, and security and legal notices), and links to `deleteAccountUrl` when it is given. The page loads nothing, runs no script, reads at 16 pixels with 44-pixel buttons, and follows the reader's light or dark setting. A token that finds nothing answers 404 with a page that says so.
213
+ - A POST suppresses at once, with the reason `manual` on the day `now` reads, and depends on nothing but the token in its address. A mailbox provider's one-click POST, or any POST that names no `choice`, writes the link's own list and answers a blank 200, never a redirect ([RFC 8058 section 3.2](https://www.rfc-editor.org/rfc/rfc8058#section-3.2)). The page's buttons post `choice=this` or `choice=all` as a form and get the page that says what was done; `all`, and every required mail's link, writes `optional`. A form longer than 4,096 bytes counts as a one-click POST. A token that finds nothing, or one drawn for a list that is no unsubscribe list, writes nothing and gets the same blank 200, or the 404 page from a button.
214
+ - Any other method answers 405 with `Allow: GET, HEAD, POST`. Every answer carries `Cache-Control: no-store`, `Referrer-Policy: no-referrer` and `X-Robots-Tag: noindex`, as the address carries the token, and a page a `Content-Security-Policy` that loads nothing and posts only to its own origin. An error of a store passes through as it was thrown, so the server answers 500 and a mailbox provider tries again.
215
+
216
+ | Route option | Default | What it sets |
217
+ |---|---|---|
218
+ | `tokens`, `suppressions` | required | The stores the service draws in and reads |
219
+ | `productName` | none | The sender's name, above the page's heading |
220
+ | `categoryNames` | each category with its hyphens and underscores as spaces | The words a page names each category by, such as `{ 'weekly-summary': 'weekly summary' }`; a category it leaves out, `constructor` included, gets the default |
221
+ | `deleteAccountUrl` | none | An https address or a path, linked on a required mail's page |
222
+ | `now` | `Date.now` | The clock each suppression's day is read from |
223
+ | `render` | the route's own page | Writes the page from the view: `{ state: 'ask', category, token }`, `{ state: 'done', category, all }` or `{ state: 'invalid' }`, `category` `null` for a required mail's link |
224
+
134
225
  ## Errors and retries
135
226
 
136
227
  A refusal from Resend throws `EmailApiError`, with `status` (the HTTP status), `type` (Resend's error name, such as `validation_error`, or `unknown` when the answer's body is not JSON, names no error, or names one that is not 1 to 64 letters, digits, `_`, `.`, `:` or `-`) and `retryAfterSeconds` (the answer's `retry-after`, or `null`). Its message names the status and the type and never carries Resend's message, which can quote an address.
@@ -234,7 +325,7 @@ A `uid` that is not a whole number from 1 to 4,294,967,295, a folder, destinatio
234
325
 
235
326
  ## In an agent
236
327
 
237
- `createExtensionPack(context)` returns five tools, `emailSend`, `emailRead`, `emailSearch`, `emailExtractCodes` and `emailReply`, and the messaging channel `emailChannel`. Its options (`smtpHost`, `smtpUser`, `smtpPassword`, `smtpPort`, `smtpSecure`, `imapHost`, `imapUser`, `imapPassword`, `imapPort`, `imapSecure`, `authServId`, `resendApiKey`, `resendBaseUrl`, `from`) take precedence over the secrets and the environment variables named above; `authServId` falls back to the secret `email.authServId`, then to `EMAIL_AUTH_SERV_ID`, and an empty value at any of them counts as none given, so the next one counts. `manifest.json` lists the SMTP secrets as required, and the IMAP secrets, `email.authServId` and `email.resendApiKey` as optional.
328
+ `createExtensionPack(context)` returns five tools, `emailSend`, `emailRead`, `emailSearch`, `emailExtractCodes` and `emailReply`, and the messaging channel `emailChannel`. Its options (`smtpHost`, `smtpUser`, `smtpPassword`, `smtpPort`, `smtpSecure`, `imapHost`, `imapUser`, `imapPassword`, `imapPort`, `imapSecure`, `authServId`, `resendApiKey`, `resendBaseUrl`, `from`, `unsubscribe`) take precedence over the secrets and the environment variables named above; `authServId` falls back to the secret `email.authServId`, then to `EMAIL_AUTH_SERV_ID`, and an empty value at any of them counts as none given, so the next one counts. `manifest.json` lists the SMTP secrets as required, and the IMAP secrets, `email.authServId` and `email.resendApiKey` as optional.
238
329
 
239
330
  A model names the tools' arguments and the channel's blocks, so `emailSend` and `emailChannel` pass on an attachment only as `content`, a `data:` address or, through Resend, an `http` or `https` address. They refuse any other before anything is sent, reading no file and fetching no address: the tool answers `{ success: false, error }`, the adapter throws an `Error`, and the message names the attachment's filename, never its path.
240
331
 
@@ -260,7 +351,11 @@ The subject, the bodies and the message id a reply names must be text. Through S
260
351
  | `SqlRecipientCapability`, `recipientCapabilityDdl(prefix)` | Recipient capabilities on a SQL database, and their table's DDL |
261
352
  | `emailPreferencesDdl(prefix)` | Both tables' DDL |
262
353
  | `DEFAULT_SUPPRESSION_SCOPE`, `SUPPRESSION_REASONS` | The scope of a host that keeps one list (`mail`), and the reasons an address is suppressed, frozen |
263
- | `EmailConfig`, `SendEmailOptions`, `EmailMessage`, `EmailAttachment`, `SearchEmailOptions`, `ReadOptions`, `ReadInboxOptions`, `MailboxOptions`, `EmailMailboxErrorCode`, `EmailAuthentication`, `HeaderFieldOptions`, `HeaderFields`, `ResendOptions`, `OutgoingEmail`, `OutgoingAttachment`, `EmailChannelOptions`, `ExtensionContext`, `ExtensionPack` | The types of the configuration, the messages, the options and the pack |
354
+ | `createUnsubscribeHandler(options)` | The route an unsubscribe link opens: a GET asks, a POST suppresses |
355
+ | `EmailSuppressedError` | A send the suppression record stopped, with `code` `suppressed` and the list in `scope` |
356
+ | `DO_NOT_CONTACT_SCOPE`, `OPTIONAL_MAIL_SCOPE`, `categoryScope(category)` | The do-not-contact list, the list of all optional mail, and a category's list |
357
+ | `UNSUBSCRIBE_URL_MARKER` | `{{unsubscribe_url}}`, where a part places its link itself |
358
+ | `EmailConfig`, `SendEmailOptions`, `EmailMessage`, `EmailAttachment`, `SearchEmailOptions`, `ReadOptions`, `ReadInboxOptions`, `MailboxOptions`, `EmailMailboxErrorCode`, `EmailAuthentication`, `HeaderFieldOptions`, `HeaderFields`, `ResendOptions`, `OutgoingEmail`, `OutgoingAttachment`, `EmailChannelOptions`, `ExtensionContext`, `ExtensionPack`, `UnsubscribeOptions`, `UnsubscribeHandlerOptions`, `UnsubscribeView` | The types of the configuration, the messages, the options and the pack |
264
359
  | `SuppressionStore`, `SuppressionReason`, `RecipientCapability`, `RecipientGrant`, `RecipientCapabilityIssueOptions`, `SqlRecipientCapabilityOptions`, `EmailPreferencesDb` | The stores' interfaces, their answers and options, and what they need of a database |
265
360
 
266
361
  ## Releases
@@ -51,7 +51,10 @@ export declare class EmailChannelAdapter {
51
51
  * its `text`), the `subject` in `platformOptions` and `replyToMessageId` must be strings when given: otherwise the
52
52
  * service throws a `TypeError` naming the field before anything is sent. A reply goes to the original's Reply-To, or
53
53
  * its From, as `EmailService.replyToEmail` sends it, and a `replyToMessageId` that is not one message id, or that no
54
- * message in the inbox has, throws an `Error` before anything is sent.
54
+ * message in the inbox has, throws an `Error` before anything is sent. While the service's `unsubscribe` is configured,
55
+ * a message goes out under its `agentCategory` and a reply as `EmailService.replyToEmail` sends one, each with its
56
+ * link and the two fields, and a recipient on the suppression record rejects with `EmailSuppressedError` before
57
+ * anything is sent.
55
58
  */
56
59
  sendMessage(conversationId: string, content: MessageContent): Promise<ChannelSendResult>;
57
60
  sendTypingIndicator(_conversationId: string, _isTyping: boolean): Promise<void>;
@@ -1 +1 @@
1
- {"version":3,"file":"EmailChannelAdapter.d.ts","sourceRoot":"","sources":["../src/EmailChannelAdapter.ts"],"names":[],"mappings":"AACA;;GAEG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAGtD,MAAM,MAAM,eAAe,GAAG,MAAM,CAAC;AACrC,MAAM,MAAM,iBAAiB,GAAG,MAAM,CAAC;AACvC,MAAM,MAAM,uBAAuB,GAAG,cAAc,GAAG,YAAY,GAAG,WAAW,GAAG,cAAc,GAAG,OAAO,CAAC;AAC7G,MAAM,MAAM,gBAAgB,GAAG,MAAM,CAAC;AAEtC,MAAM,WAAW,iBAAiB;IAChC,QAAQ,EAAE,eAAe,CAAC;IAC1B,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACjC;AAED,MAAM,WAAW,qBAAqB;IACpC,MAAM,EAAE,uBAAuB,CAAC;IAChC,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACxC;AAED,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAA;KAAE,CAAC,CAAC;IACpD,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,eAAe,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC3C;AAED,MAAM,WAAW,iBAAiB;IAChC,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,MAAM,mBAAmB,GAAG,CAAC,KAAK,EAAE,GAAG,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;AAEvE,qBAAa,mBAAmB;IAC9B,QAAQ,CAAC,QAAQ,EAAE,eAAe,CAAW;IAC7C,QAAQ,CAAC,WAAW,WAAW;IAC/B,QAAQ,CAAC,YAAY,EAAE,SAAS,iBAAiB,EAAE,CAEjD;IAEF,OAAO,CAAC,OAAO,CAAe;IAC9B,OAAO,CAAC,WAAW,CAAuB;IAC1C,OAAO,CAAC,QAAQ,CAAuC;IACvD,OAAO,CAAC,YAAY,CAAuB;gBAE/B,OAAO,EAAE,YAAY;IAI3B,UAAU,CAAC,KAAK,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC;IAWnD,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC;IAK/B,iBAAiB,IAAI,qBAAqB;IAc1C;;;;;;;;;;OAUG;IACG,WAAW,CAAC,cAAc,EAAE,MAAM,EAAE,OAAO,EAAE,cAAc,GAAG,OAAO,CAAC,iBAAiB,CAAC;IAyCxF,mBAAmB,CAAC,eAAe,EAAE,MAAM,EAAE,SAAS,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC;IAIrF,EAAE,CAAC,OAAO,EAAE,mBAAmB,EAAE,WAAW,CAAC,EAAE,gBAAgB,EAAE,GAAG,MAAM,IAAI;CAI/E"}
1
+ {"version":3,"file":"EmailChannelAdapter.d.ts","sourceRoot":"","sources":["../src/EmailChannelAdapter.ts"],"names":[],"mappings":"AACA;;GAEG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAGtD,MAAM,MAAM,eAAe,GAAG,MAAM,CAAC;AACrC,MAAM,MAAM,iBAAiB,GAAG,MAAM,CAAC;AACvC,MAAM,MAAM,uBAAuB,GAAG,cAAc,GAAG,YAAY,GAAG,WAAW,GAAG,cAAc,GAAG,OAAO,CAAC;AAC7G,MAAM,MAAM,gBAAgB,GAAG,MAAM,CAAC;AAEtC,MAAM,WAAW,iBAAiB;IAChC,QAAQ,EAAE,eAAe,CAAC;IAC1B,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACjC;AAED,MAAM,WAAW,qBAAqB;IACpC,MAAM,EAAE,uBAAuB,CAAC;IAChC,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACxC;AAED,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAA;KAAE,CAAC,CAAC;IACpD,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,eAAe,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC3C;AAED,MAAM,WAAW,iBAAiB;IAChC,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,MAAM,mBAAmB,GAAG,CAAC,KAAK,EAAE,GAAG,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;AAEvE,qBAAa,mBAAmB;IAC9B,QAAQ,CAAC,QAAQ,EAAE,eAAe,CAAW;IAC7C,QAAQ,CAAC,WAAW,WAAW;IAC/B,QAAQ,CAAC,YAAY,EAAE,SAAS,iBAAiB,EAAE,CAEjD;IAEF,OAAO,CAAC,OAAO,CAAe;IAC9B,OAAO,CAAC,WAAW,CAAuB;IAC1C,OAAO,CAAC,QAAQ,CAAuC;IACvD,OAAO,CAAC,YAAY,CAAuB;gBAE/B,OAAO,EAAE,YAAY;IAI3B,UAAU,CAAC,KAAK,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC;IAWnD,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC;IAK/B,iBAAiB,IAAI,qBAAqB;IAc1C;;;;;;;;;;;;;OAaG;IACG,WAAW,CAAC,cAAc,EAAE,MAAM,EAAE,OAAO,EAAE,cAAc,GAAG,OAAO,CAAC,iBAAiB,CAAC;IA2CxF,mBAAmB,CAAC,eAAe,EAAE,MAAM,EAAE,SAAS,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC;IAIrF,EAAE,CAAC,OAAO,EAAE,mBAAmB,EAAE,WAAW,CAAC,EAAE,gBAAgB,EAAE,GAAG,MAAM,IAAI;CAI/E"}
@@ -55,7 +55,10 @@ class EmailChannelAdapter {
55
55
  * its `text`), the `subject` in `platformOptions` and `replyToMessageId` must be strings when given: otherwise the
56
56
  * service throws a `TypeError` naming the field before anything is sent. A reply goes to the original's Reply-To, or
57
57
  * its From, as `EmailService.replyToEmail` sends it, and a `replyToMessageId` that is not one message id, or that no
58
- * message in the inbox has, throws an `Error` before anything is sent.
58
+ * message in the inbox has, throws an `Error` before anything is sent. While the service's `unsubscribe` is configured,
59
+ * a message goes out under its `agentCategory` and a reply as `EmailService.replyToEmail` sends one, each with its
60
+ * link and the two fields, and a recipient on the suppression record rejects with `EmailSuppressedError` before
61
+ * anything is sent.
59
62
  */
60
63
  async sendMessage(conversationId, content) {
61
64
  const textBlock = content.blocks.find((b) => b.type === 'text');
@@ -82,12 +85,14 @@ class EmailChannelAdapter {
82
85
  const result = await this.service.replyToEmail(content.replyToMessageId, text, html);
83
86
  return { messageId: result.messageId, timestamp: new Date().toISOString() };
84
87
  }
88
+ const category = this.service.agentCategory;
85
89
  const result = await this.service.sendEmail({
86
90
  to: conversationId,
87
91
  subject,
88
92
  body: text,
89
93
  html,
90
94
  attachments: checked !== undefined && checked.length > 0 ? checked : undefined,
95
+ ...(category === undefined ? {} : { category }),
91
96
  });
92
97
  return { messageId: result.messageId, timestamp: new Date().toISOString() };
93
98
  }
@@ -1 +1 @@
1
- {"version":3,"file":"EmailChannelAdapter.js","sourceRoot":"","sources":["../src/EmailChannelAdapter.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"EmailChannelAdapter.js","sourceRoot":"","sources":["../src/EmailChannelAdapter.ts"],"names":[],"mappings":"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"}
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import type { ResendOptions } from './ResendTransport.js';
12
12
  import type { EmailAuthentication } from './headers.js';
13
+ import type { UnsubscribeOptions } from './unsubscribe.js';
13
14
  /** The accounts the service uses: an SMTP account or Resend's HTTPS API to send, and IMAP to read. */
14
15
  export interface EmailConfig {
15
16
  /** The SMTP account that sends, and reads when `imap` is not given. Required unless `resend` is given. */
@@ -40,6 +41,14 @@ export interface EmailConfig {
40
41
  * throws an `Error` when the service is made; `null` and the empty string count as not given.
41
42
  */
42
43
  authServId?: string;
44
+ /**
45
+ * An unsubscribe link on every mail the service sends, replies included: the suppression record read before each
46
+ * send, an opaque token drawn for the recipient and the kind of mail, RFC 8058's `List-Unsubscribe` and
47
+ * `List-Unsubscribe-Post` fields, and a visible link at the foot of each part with the sender's postal address. With
48
+ * it, a send names its `category`, or is marked `required`, and goes to one recipient with no cc or bcc. Each option
49
+ * is checked when the service is made, and a `TypeError` names the first one at fault.
50
+ */
51
+ unsubscribe?: UnsubscribeOptions;
43
52
  }
44
53
  export interface SendEmailOptions {
45
54
  to: string;
@@ -67,6 +76,22 @@ export interface SendEmailOptions {
67
76
  * `Subject`, `Date`, `Message-ID`, `MIME-Version` and any `Content-` field.
68
77
  */
69
78
  headers?: Record<string, string>;
79
+ /**
80
+ * With `unsubscribe` configured, the category of optional mail, such as `weekly-summary`: a lower-case letter, then at
81
+ * most 63 lower-case letters, digits, hyphens or underscores. Its link stops the category, and the send reads the
82
+ * category's list first. Required for mail that is not `required`; refused without `unsubscribe`.
83
+ */
84
+ category?: string;
85
+ /**
86
+ * With `unsubscribe` configured, mail a person cannot turn off: sign-in codes, receipts, security and legal notices.
87
+ * Only the do-not-contact list stops it, and its link turns off every optional mail. Refused without `unsubscribe`.
88
+ */
89
+ required?: boolean;
90
+ /**
91
+ * With `unsubscribe` configured, advertising mail: optional, never `required`, and refused unless the configuration
92
+ * gives a postal address, which its parts then carry. Refused without `unsubscribe`.
93
+ */
94
+ commercial?: boolean;
70
95
  }
71
96
  /** An attachment of a message a read gave: what its body structure says of it, and a read of its bytes. */
72
97
  export interface EmailAttachment {
@@ -258,6 +283,7 @@ export declare class EmailService {
258
283
  private transporter;
259
284
  private resend;
260
285
  private running;
286
+ private readonly unsubscribe;
261
287
  /**
262
288
  * The authserv-id `EmailConfig.authServId` names, in lowercase, or `undefined` when it names none: the `authServId`
263
289
  * of a read that names none, and the one the `emailRead` and `emailSearch` tools read under; without it they leave
@@ -266,9 +292,14 @@ export declare class EmailService {
266
292
  readonly authServId: string | undefined;
267
293
  /**
268
294
  * Keeps `config`. An `authServId` that is not text, holds a control character or is white space alone throws an
269
- * `Error` that holds none of it.
295
+ * `Error` that holds none of it, and an `unsubscribe` option that does not fit throws a `TypeError` that names it.
270
296
  */
271
297
  constructor(config: EmailConfig);
298
+ /**
299
+ * The category the `emailSend` tool and the channel adapter send under while `unsubscribe` is configured (its
300
+ * `agentCategory`, `conversation` when it names none), and `undefined` without it.
301
+ */
302
+ get agentCategory(): string | undefined;
272
303
  /**
273
304
  * Opens the transport that sends: Resend's API when `resend` is given (`from` is then required), else the SMTP
274
305
  * account. Throws an `Error` before opening anything when the configuration gives neither `resend` nor `smtp`.
@@ -305,6 +336,16 @@ export declare class EmailService {
305
336
  * field, or its place when its name is at fault, and none of its value, before either transport is used. Each value
306
337
  * is read once, and both transports send the fields as read: nodemailer writes each value as given, folded at its
307
338
  * white space, except `Sender`, which it writes as the address it parses from the value.
339
+ *
340
+ * With `unsubscribe` configured, the send names its `category` or is marked `required` (and `commercial` for
341
+ * advertising), goes to one recipient with no cc or bcc, and adds no `List-Unsubscribe` or `List-Unsubscribe-Post` of
342
+ * its own: anything else throws before anything is read or sent. The suppression record is then read for the
343
+ * recipient (the do-not-contact list for every send; for optional mail also the provider's record, the list of all
344
+ * optional mail and the category's), and an address on one of them rejects with `EmailSuppressedError` before a token
345
+ * is drawn or anything is sent. Otherwise the token is drawn, under the send's `idempotencyKey` when it has one so a
346
+ * retry carries the same link, the two fields are added, and each part gets its link: where it holds
347
+ * `UNSUBSCRIBE_URL_MARKER`, in place of each, and otherwise in a footer with the postal address. Without
348
+ * `unsubscribe`, a `category`, a `required: true` or a `commercial: true` throws.
308
349
  */
309
350
  sendEmail(options: SendEmailOptions): Promise<{
310
351
  messageId: string;
@@ -441,6 +482,11 @@ export declare class EmailService {
441
482
  * is too long for one line of `In-Reply-To`, throws an `Error` before the original is read; no message with that id,
442
483
  * or an original that names no address to reply to, throws an `Error` before anything is sent. None of these errors
443
484
  * holds the id.
485
+ *
486
+ * With `unsubscribe` configured, the reply goes to one address and reads the do-not-contact list first, for that
487
+ * address and for every address the original names in its From, Sender and Reply-To: any of them on it rejects with
488
+ * `EmailSuppressedError` before a token is drawn or anything is sent. Otherwise the reply carries the two fields and a link
489
+ * at the foot of each part that turns off every optional mail.
444
490
  */
445
491
  replyToEmail(messageId: string, body: string, html?: string): Promise<{
446
492
  messageId: string;
@@ -1 +1 @@
1
- {"version":3,"file":"EmailService.d.ts","sourceRoot":"","sources":["../src/EmailService.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"EmailService.d.ts","sourceRoot":"","sources":["../src/EmailService.ts"],"names":[],"mappings":"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"}
@@ -53,6 +53,7 @@ const messageText_js_1 = require("./messageText.js");
53
53
  const reads_js_1 = require("./reads.js");
54
54
  const headers_js_1 = require("./headers.js");
55
55
  const attachments_js_1 = require("./attachments.js");
56
+ const unsubscribe_js_1 = require("./unsubscribe.js");
56
57
  /**
57
58
  * A change to one message, or a read of an attachment, that failed: `gone` when the folder holds no message with the UID
58
59
  * named, which a caller that wants the message gone counts as done; `refused` when the server refused the command or
@@ -87,6 +88,12 @@ function smtpField(key, value) {
87
88
  function firstAddress(list) {
88
89
  return list?.find((entry) => typeof entry?.address === 'string' && entry.address !== '')?.address;
89
90
  }
91
+ /** Every address an envelope's list names, such as its From, its Sender or its Reply-To. */
92
+ function addressesOf(list) {
93
+ if (!Array.isArray(list))
94
+ return [];
95
+ return list.flatMap((entry) => (typeof entry?.address === 'string' && entry.address !== '' ? [entry.address] : []));
96
+ }
90
97
  /** The most bytes of a message's text part, and of its HTML part, a read gives when it names no bound: 256 KiB. */
91
98
  const DEFAULT_PART_BYTES = 262144;
92
99
  /** The most bytes of an attachment `read()` gives when it names no bound: 25 MiB. */
@@ -325,7 +332,7 @@ function emailMessageOf(planned, bodies, context) {
325
332
  class EmailService {
326
333
  /**
327
334
  * Keeps `config`. An `authServId` that is not text, holds a control character or is white space alone throws an
328
- * `Error` that holds none of it.
335
+ * `Error` that holds none of it, and an `unsubscribe` option that does not fit throws a `TypeError` that names it.
329
336
  */
330
337
  constructor(config) {
331
338
  this.transporter = null;
@@ -333,6 +340,14 @@ class EmailService {
333
340
  this.running = false;
334
341
  this.config = config;
335
342
  this.authServId = (0, headers_js_1.requireAuthServId)(config.authServId);
343
+ this.unsubscribe = config.unsubscribe === undefined || config.unsubscribe === null ? null : new unsubscribe_js_1.Unsubscribe(config.unsubscribe);
344
+ }
345
+ /**
346
+ * The category the `emailSend` tool and the channel adapter send under while `unsubscribe` is configured (its
347
+ * `agentCategory`, `conversation` when it names none), and `undefined` without it.
348
+ */
349
+ get agentCategory() {
350
+ return this.unsubscribe?.agentCategory;
336
351
  }
337
352
  /**
338
353
  * Opens the transport that sends: Resend's API when `resend` is given (`from` is then required), else the SMTP
@@ -412,44 +427,80 @@ class EmailService {
412
427
  * field, or its place when its name is at fault, and none of its value, before either transport is used. Each value
413
428
  * is read once, and both transports send the fields as read: nodemailer writes each value as given, folded at its
414
429
  * white space, except `Sender`, which it writes as the address it parses from the value.
430
+ *
431
+ * With `unsubscribe` configured, the send names its `category` or is marked `required` (and `commercial` for
432
+ * advertising), goes to one recipient with no cc or bcc, and adds no `List-Unsubscribe` or `List-Unsubscribe-Post` of
433
+ * its own: anything else throws before anything is read or sent. The suppression record is then read for the
434
+ * recipient (the do-not-contact list for every send; for optional mail also the provider's record, the list of all
435
+ * optional mail and the category's), and an address on one of them rejects with `EmailSuppressedError` before a token
436
+ * is drawn or anything is sent. Otherwise the token is drawn, under the send's `idempotencyKey` when it has one so a
437
+ * retry carries the same link, the two fields are added, and each part gets its link: where it holds
438
+ * `UNSUBSCRIBE_URL_MARKER`, in place of each, and otherwise in a footer with the postal address. Without
439
+ * `unsubscribe`, a `category`, a `required: true` or a `commercial: true` throws.
415
440
  */
416
441
  async sendEmail(options) {
417
442
  this.requireRunning();
418
443
  // Each is read once, so the value checked is the value sent.
419
444
  const subject = (0, messageText_js_1.oneLine)((0, messageText_js_1.requireText)('subject', options.subject));
420
- const body = (0, messageText_js_1.requireText)('body', options.body);
421
- const html = (0, messageText_js_1.optionalText)('html', options.html);
422
- const headers = (0, messageHeaders_js_1.requireAddedHeaders)(options.headers);
445
+ let body = (0, messageText_js_1.requireText)('body', options.body);
446
+ let html = (0, messageText_js_1.optionalText)('html', options.html);
447
+ let headers = (0, messageHeaders_js_1.requireAddedHeaders)(options.headers);
448
+ // Read before anything is awaited, so the recipients whose record was read and whose link was drawn are the ones
449
+ // the message goes to, even when the caller changes the options object while the send waits.
450
+ const { to, replyTo, idempotencyKey, attachments } = options;
451
+ let { cc, bcc } = options;
452
+ const kind = { category: options.category, required: options.required, commercial: options.commercial };
453
+ if (this.unsubscribe) {
454
+ (0, unsubscribe_js_1.refuseCopies)(cc, bcc);
455
+ // An empty list passes the check above but is still the caller's array, which it could fill while the send
456
+ // waits; with links on, the message goes to the one recipient the link names and to no copy.
457
+ cc = undefined;
458
+ bcc = undefined;
459
+ const plan = this.unsubscribe.plan(kind);
460
+ const address = (0, unsubscribe_js_1.oneRecipient)(to);
461
+ (0, unsubscribe_js_1.refuseOwnListFields)(headers);
462
+ const nonce = typeof idempotencyKey === 'string' && idempotencyKey !== '' ? `send:${idempotencyKey}` : undefined;
463
+ const link = await this.unsubscribe.prepare(address, plan, nonce);
464
+ body = this.unsubscribe.text(body, link.url, plan.commercial);
465
+ if (html !== undefined)
466
+ html = this.unsubscribe.html(html, link.url, plan.commercial);
467
+ headers = (0, messageHeaders_js_1.requireAddedHeaders)({ ...(headers ?? {}), ...link.headers });
468
+ }
469
+ else if ((kind.category !== undefined && kind.category !== null) ||
470
+ kind.required === true ||
471
+ kind.commercial === true) {
472
+ throw new Error('The send was refused: category, required and commercial need the unsubscribe configuration');
473
+ }
423
474
  if (this.resend) {
424
475
  return this.resend.send({
425
476
  from: this.config.from,
426
- to: options.to,
477
+ to,
427
478
  subject,
428
479
  text: body,
429
480
  html,
430
- replyTo: options.replyTo,
431
- cc: options.cc,
432
- bcc: options.bcc,
481
+ replyTo,
482
+ cc,
483
+ bcc,
433
484
  headers,
434
- idempotencyKey: options.idempotencyKey,
435
- attachments: options.attachments,
485
+ idempotencyKey,
486
+ attachments,
436
487
  });
437
488
  }
438
489
  if (!this.transporter)
439
490
  throw new Error('SMTP transporter not initialized');
440
491
  const info = await this.transporter.sendMail({
441
492
  from: this.config.from ?? this.config.smtp.user,
442
- to: options.to,
493
+ to,
443
494
  subject,
444
495
  text: body,
445
496
  html,
446
- replyTo: options.replyTo,
447
- cc: options.cc,
448
- bcc: options.bcc,
497
+ replyTo,
498
+ cc,
499
+ bcc,
449
500
  // A list, so nodemailer adds each field under its own name: an object with `key` and `value` properties would be
450
501
  // read as one field named by its `key`. Each value is prepared, so nodemailer writes it as Resend is given it.
451
502
  headers: headers === undefined ? undefined : Object.entries(headers).map(([key, value]) => smtpField(key, value)),
452
- attachments: options.attachments?.map((a) => ({
503
+ attachments: attachments?.map((a) => ({
453
504
  filename: a.filename,
454
505
  content: a.content,
455
506
  path: a.path,
@@ -812,6 +863,11 @@ class EmailService {
812
863
  * is too long for one line of `In-Reply-To`, throws an `Error` before the original is read; no message with that id,
813
864
  * or an original that names no address to reply to, throws an `Error` before anything is sent. None of these errors
814
865
  * holds the id.
866
+ *
867
+ * With `unsubscribe` configured, the reply goes to one address and reads the do-not-contact list first, for that
868
+ * address and for every address the original names in its From, Sender and Reply-To: any of them on it rejects with
869
+ * `EmailSuppressedError` before a token is drawn or anything is sent. Otherwise the reply carries the two fields and a link
870
+ * at the foot of each part that turns off every optional mail.
815
871
  */
816
872
  async replyToEmail(messageId, body, html) {
817
873
  this.requireRunning();
@@ -837,6 +893,7 @@ class EmailService {
837
893
  original = {
838
894
  // A server gives the From as the Reply-To of a message that names none (RFC 3501 section 7.4.2).
839
895
  to: firstAddress(msg.envelope?.replyTo) ?? firstAddress(msg.envelope?.from) ?? '',
896
+ senders: [msg.envelope?.from, msg.envelope?.sender, msg.envelope?.replyTo].flatMap(addressesOf),
840
897
  subject: (0, messageText_js_1.oneLine)(msg.envelope?.subject ?? ''),
841
898
  thread: (0, messageHeaders_js_1.threadOf)(msg.headers?.toString() ?? ''),
842
899
  };
@@ -856,8 +913,21 @@ class EmailService {
856
913
  throw new Error('The reply was refused: the message names no address to reply to');
857
914
  const replySubject = /^re:/i.test(original.subject) ? original.subject : `Re: ${original.subject}`;
858
915
  const references = [...original.thread, parent].join(' ');
916
+ // With unsubscribe links on, a reply reads the do-not-contact list, so an address on it gets no reply, and carries
917
+ // a link that turns off every optional mail.
918
+ let listFields = {};
919
+ if (this.unsubscribe) {
920
+ // Every address the original names as its sender is read, so a person on the list who wrote with another
921
+ // Reply-To, or through another Sender, gets no reply either.
922
+ await this.unsubscribe.refuseDoNotContact(original.senders);
923
+ const link = await this.unsubscribe.prepare((0, unsubscribe_js_1.oneRecipient)(original.to), this.unsubscribe.replyPlan(), undefined);
924
+ body = this.unsubscribe.text(body, link.url, false);
925
+ if (html !== undefined)
926
+ html = this.unsubscribe.html(html, link.url, false);
927
+ listFields = link.headers;
928
+ }
859
929
  // Checked by the rules every added field meets, so both transports are given the same fields.
860
- const thread = (0, messageHeaders_js_1.requireHeaders)({ 'In-Reply-To': parent, References: references });
930
+ const thread = (0, messageHeaders_js_1.requireHeaders)({ 'In-Reply-To': parent, References: references, ...listFields });
861
931
  if (this.resend) {
862
932
  return this.resend.send({
863
933
  from: this.config.from,
@@ -868,6 +938,7 @@ class EmailService {
868
938
  headers: thread,
869
939
  });
870
940
  }
941
+ const added = Object.entries(listFields);
871
942
  const info = await this.transporter.sendMail({
872
943
  from: this.config.from ?? this.config.smtp.user,
873
944
  to: original.to,
@@ -876,6 +947,7 @@ class EmailService {
876
947
  html,
877
948
  inReplyTo: parent,
878
949
  references,
950
+ headers: added.length === 0 ? undefined : added.map(([key, value]) => smtpField(key, value)),
879
951
  });
880
952
  return { messageId: info.messageId };
881
953
  }