@framers/agentos-ext-channel-email 0.6.2 → 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 +99 -4
- package/dist/EmailChannelAdapter.d.ts +4 -1
- package/dist/EmailChannelAdapter.d.ts.map +1 -1
- package/dist/EmailChannelAdapter.js +6 -1
- package/dist/EmailChannelAdapter.js.map +1 -1
- package/dist/EmailService.d.ts +47 -1
- package/dist/EmailService.d.ts.map +1 -1
- package/dist/EmailService.js +88 -16
- package/dist/EmailService.js.map +1 -1
- package/dist/index.d.ts +19 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +18 -9
- package/dist/index.js.map +1 -1
- package/dist/packageVersion.d.ts +20 -0
- package/dist/packageVersion.d.ts.map +1 -0
- package/dist/packageVersion.js +35 -0
- package/dist/packageVersion.js.map +1 -0
- package/dist/tools/send.d.ts +3 -1
- package/dist/tools/send.d.ts.map +1 -1
- package/dist/tools/send.js +5 -1
- package/dist/tools/send.js.map +1 -1
- package/dist/unsubscribe.d.ts +208 -0
- package/dist/unsubscribe.d.ts.map +1 -0
- package/dist/unsubscribe.js +584 -0
- package/dist/unsubscribe.js.map +1 -0
- package/dist/unsubscribeHandler.d.ts +65 -0
- package/dist/unsubscribeHandler.d.ts.map +1 -0
- package/dist/unsubscribeHandler.js +218 -0
- package/dist/unsubscribeHandler.js.map +1 -0
- package/manifest.json +3 -2
- package/package.json +5 -4
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
|
-
| `
|
|
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
|
|
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":";AAAA,cAAc;AACd;;GAEG;;;AAGH,+DAAgE;AAiChE,MAAa,mBAAmB;IAY9B,YAAY,OAAqB;QAXxB,aAAQ,GAAoB,OAAO,CAAC;QACpC,gBAAW,GAAG,OAAO,CAAC;QACtB,iBAAY,GAAiC;YACpD,MAAM,EAAE,WAAW,EAAE,WAAW,EAAE,QAAQ,EAAE,SAAS;SACtD,CAAC;QAGM,gBAAW,GAAkB,IAAI,CAAC;QAClC,aAAQ,GAA6B,IAAI,GAAG,EAAE,CAAC;QAC/C,iBAAY,GAAkB,IAAI,CAAC;QAGzC,IAAI,CAAC,OAAO,GAAG,OAAO,CAAC;IACzB,CAAC;IAED,KAAK,CAAC,UAAU,CAAC,KAAwB;QACvC,IAAI,CAAC;YACH,MAAM,IAAI,CAAC,OAAO,CAAC,UAAU,EAAE,CAAC;YAChC,IAAI,CAAC,WAAW,GAAG,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC;YAC5C,IAAI,CAAC,YAAY,GAAG,IAAI,CAAC;QAC3B,CAAC;QAAC,OAAO,GAAQ,EAAE,CAAC;YAClB,IAAI,CAAC,YAAY,GAAG,GAAG,CAAC,OAAO,CAAC;YAChC,MAAM,GAAG,CAAC;QACZ,CAAC;IACH,CAAC;IAED,KAAK,CAAC,QAAQ;QACZ,MAAM,IAAI,CAAC,OAAO,CAAC,QAAQ,EAAE,CAAC;QAC9B,IAAI,CAAC,WAAW,GAAG,IAAI,CAAC;IAC1B,CAAC;IAED,iBAAiB;QACf,IAAI,IAAI,CAAC,YAAY,EAAE,CAAC;YACtB,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,YAAY,EAAE,IAAI,CAAC,YAAY,EAAE,CAAC;QAC9D,CAAC;QACD,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,SAAS,EAAE,CAAC;YAC5B,OAAO,EAAE,MAAM,EAAE,cAAc,EAAE,CAAC;QACpC,CAAC;QACD,OAAO;YACL,MAAM,EAAE,WAAW;YACnB,cAAc,EAAE,IAAI,CAAC,WAAW,IAAI,SAAS;YAC7C,YAAY,EAAE,EAAE,QAAQ,EAAE,OAAO,EAAE;SACpC,CAAC;IACJ,CAAC;IAED
|
|
1
|
+
{"version":3,"file":"EmailChannelAdapter.js","sourceRoot":"","sources":["../src/EmailChannelAdapter.ts"],"names":[],"mappings":"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"}
|
package/dist/EmailService.d.ts
CHANGED
|
@@ -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":"AACA;;;;;;;;;GASG;AAKH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AAK1D,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"EmailService.d.ts","sourceRoot":"","sources":["../src/EmailService.ts"],"names":[],"mappings":"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"}
|
package/dist/EmailService.js
CHANGED
|
@@ -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
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
|
477
|
+
to,
|
|
427
478
|
subject,
|
|
428
479
|
text: body,
|
|
429
480
|
html,
|
|
430
|
-
replyTo
|
|
431
|
-
cc
|
|
432
|
-
bcc
|
|
481
|
+
replyTo,
|
|
482
|
+
cc,
|
|
483
|
+
bcc,
|
|
433
484
|
headers,
|
|
434
|
-
idempotencyKey
|
|
435
|
-
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
|
|
493
|
+
to,
|
|
443
494
|
subject,
|
|
444
495
|
text: body,
|
|
445
496
|
html,
|
|
446
|
-
replyTo
|
|
447
|
-
cc
|
|
448
|
-
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:
|
|
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
|
}
|