@framers/agentos-ext-channel-email 0.4.1 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -79,10 +79,20 @@ await email.sendEmail({
79
79
  });
80
80
  ```
81
81
 
82
- Through Resend the fields go in the request's `headers` ([custom headers](https://resend.com/docs/dashboard/emails/custom-headers)); through SMTP nodemailer writes each into the message. `headers` is a plain object of names and values. A name is printable US-ASCII with no space or colon, the field name of RFC 5322, and a value is a string with no line break or other control character than the tab, so no field can end and start another. Each value is read once, and the fields are sent as read.
82
+ Through Resend the fields go in the request's `headers` ([custom headers](https://resend.com/docs/dashboard/emails/custom-headers)); through SMTP nodemailer writes each into the message as given, folded at its white space. Both transports get the same text, so `headers` takes only what both carry alike:
83
+
84
+ - `headers` is a plain object of names and values, made in this realm or in another, such as a `vm` context. A list, a `Map` or text is refused.
85
+ - A name is printable US-ASCII with no space or colon, the field name of RFC 5322, given once in any case.
86
+ - A value is a string of printable US-ASCII words, with one space or tab between each two and none at either end. An empty or blank value is refused: nodemailer would leave the field out, and Resend would send it empty. So is a character outside US-ASCII, which nodemailer would write as an RFC 2047 encoded word that a structured field such as `List-Unsubscribe` cannot hold; percent-encode a link instead. A line break or any other control character is refused, so no field can end and start another, and so is white space at either end or two white-space characters in a row, which nodemailer can fold into a line of white space alone: obsolete syntax ([RFC 5322 section 4.2](https://www.rfc-editor.org/rfc/rfc5322#section-4.2)) that a message must not be written with.
87
+ - No word may be so long that the name, a colon, a space and the word pass 998 characters, the longest line [RFC 5322 section 2.1.1](https://www.rfc-editor.org/rfc/rfc5322#section-2.1.1) allows: a field folds only at its white space, so a word stays on one line. A `List-Unsubscribe` link holds at most 980 characters.
88
+ - `In-Reply-To` and `References` list message ids, each in angle brackets, such as `<id@example.com>`.
89
+
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.
83
91
 
84
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.
85
93
 
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
+
86
96
  ## Suppressions and unsubscribe tokens
87
97
 
88
98
  Two stores a sender reads, each on the host's SQL database: `SqlSuppressionStore` keeps the addresses not to mail, each named by its SHA-256, and `SqlRecipientCapability` keeps opaque tokens, such as an unsubscribe link's, each naming a recipient and a scope. Both take [`@framers/sql-storage-adapter`](https://www.npmjs.com/package/@framers/sql-storage-adapter)'s adapter on SQLite or PostgreSQL, or any handle with its `run` and `get` (`?` placeholders, positional parameters), so the pack gains no dependency.
@@ -97,7 +107,8 @@ import {
97
107
  emailPreferencesDdl,
98
108
  } from '@framers/agentos-ext-channel-email';
99
109
 
100
- const db = await createDatabase({ url: process.env.DATABASE_URL });
110
+ // strict: with DATABASE_URL unset, throw rather than open a local database in its place
111
+ const db = await createDatabase({ url: process.env.DATABASE_URL, strict: true });
101
112
  await db.exec(emailPreferencesDdl('mail')); // mail_suppressions and mail_capabilities
102
113
 
103
114
  // A bounce reported by the provider: the store gets the address's hash, never the address.
@@ -114,17 +125,17 @@ const link = `https://example.com/unsubscribe?t=${token}`;
114
125
  const grant = await tokens.find(token); // { recipient: 'account-1', scope: 'weekly-summary' }, or null
115
126
  ```
116
127
 
117
- `suppress(scope, addressHash, reason, day)` records an address in a scope with its reason and its day (`YYYY-MM-DD`), and no end. The reason is `bounce`, `complaint`, `suppressed` (the provider refused a message because its own list held the address) or `manual`. A second record of the address in the scope writes nothing and keeps the first, so a provider's repeated event is harmless; `suppress` answers `true` only for the call that recorded it, and two hosts recording one address at once leave one row. `isSuppressed(scope, addressHash)` answers for one scope. A scope is one list, and a host that keeps one passes `DEFAULT_SUPPRESSION_SCOPE` (`mail`) everywhere. `addressHash` is the SHA-256 of the address as 64 lower-case hexadecimal characters, which the host computes the same way wherever it reads or writes the record. Any other value, an address among them, throws a `TypeError` that holds none of it, so a mistake stores nothing and is never answered as not suppressed.
128
+ `suppress(scope, addressHash, reason, day)` records an address in a scope with its reason and its day (`YYYY-MM-DD`), and no end: the store has no call that lifts an address, so an operator lifts one by deleting its row (`DELETE FROM <prefix>_suppressions WHERE scope = ? AND address_hash = ?`). The reason is `bounce`, `complaint`, `suppressed` (the provider refused a message because its own list held the address) or `manual`. A second record of the address in the scope writes nothing and keeps the first, so a provider's repeated event is harmless; `suppress` answers `true` only for the call that recorded it, and two hosts recording one address at once leave one row. `isSuppressed(scope, addressHash)` answers for one scope. A scope is one list, and a host that keeps one passes `DEFAULT_SUPPRESSION_SCOPE` (`mail`) everywhere. `addressHash` is the SHA-256 of the address as 64 lower-case hexadecimal characters, which the host computes the same way wherever it reads or writes the record. Any other value, an address among them, throws a `TypeError` that holds none of it, so a mistake stores nothing and is never answered as not suppressed.
118
129
 
119
- `issue(recipient, scope, nonce, key)` answers base64url of HMAC-SHA256 over the nonce under the key, 43 characters, and stores the token's SHA-256 beside the recipient and the scope: the token carries neither, and the table holds nothing it can be recovered from. The same nonce under the same key gives the same token, so each message takes a nonce of its own, such as its id. Issuing it again for the same recipient and scope writes nothing; for another recipient or scope it throws, and the first token keeps its recipient and scope. The key is 32 bytes or more, given as bytes; a different key gives different tokens for the same nonces. `find(token)` answers the recipient and the scope, or `null` for a token that is malformed, unknown, revoked or past its expiry: it looks the token up by its SHA-256 and compares in constant time. `revoke(recipient)` ends every token of the recipient, in every scope, and answers how many it ended. Each revoked row stays, marked with the time of the revoke, so `issue` refuses a revoked token's nonce for every recipient, scope and expiry: a revoked link never works again and never comes to name someone else. A message that needs a working token after a revoke takes a new nonce, and the token it gets works. A token has no expiry unless `issue`'s options set `expiresAt`, in epoch milliseconds.
130
+ `issue(recipient, scope, nonce, key)` answers base64url of HMAC-SHA256 over the nonce under the key, 43 characters, and stores the token's SHA-256 beside the recipient and the scope: the token carries neither, and the table holds nothing it can be recovered from. The same nonce under the same key gives the same token, so each message takes a nonce of its own, such as its id. Issuing it again for the same recipient, scope and expiry writes nothing; for another recipient, scope or expiry it throws, and the first token keeps all three. The key is 32 bytes or more, given as bytes; a different key gives different tokens for the same nonces. `find(token)` answers the recipient and the scope, or `null` for a token that is malformed, unknown, revoked or past its expiry: it looks the token up by its SHA-256 and compares in constant time. `revoke(recipient)` ends every token of the recipient, in every scope, and answers how many it ended. Each revoked row stays, marked with the time of the revoke, so `issue` refuses a revoked token's nonce for every recipient, scope and expiry: a revoked link never works again and never comes to name someone else. A message that needs a working token after a revoke takes a new nonce, and the token it gets works. A token has no expiry unless `issue`'s options set `expiresAt`, in epoch milliseconds.
120
131
 
121
- `suppressionStoreDdl(prefix)` makes the table `<prefix>_suppressions`, `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, and it keeps two hosts on one database apart. The statements are `CREATE ... IF NOT EXISTS` and run on SQLite and 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 and lets a database error through as it was thrown. A store made on the handle that the adapter's `transaction` gives its callback writes inside that transaction.
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, and it keeps two hosts on one database apart. The statements are `CREATE ... IF NOT EXISTS` and run on SQLite and 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 characters 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.
122
133
 
123
134
  ## Errors and retries
124
135
 
125
136
  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.
126
137
 
127
- `fetch`'s own errors pass through as it throws them: a `TypeError` when the connection fails, and a `TimeoutError` when `timeoutMs` passes before an answer comes. A deadline that passes while an answer's body is being read throws no `TimeoutError`: a refusal then throws `EmailApiError` with `type` `unknown`, and an accepted send resolves with an empty `messageId`. The key is trimmed at each send. A key, an `idempotencyKey` or a `userAgent` that holds a character an HTTP header cannot carry (a line break or another control character other than a tab, or a character above U+00FF) throws an `Error` before any request, and its message names which one and none of its value. `ResendTransport.send` checks a message's `headers` by the rules for names and values in [header fields](#header-fields), though not against the fields the service writes, and throws the same way: a reply through Resend whose message id holds a line break is refused rather than sent in `In-Reply-To` and `References`.
138
+ `fetch`'s own errors pass through as it throws them: a `TypeError` when the connection fails, and a `TimeoutError` when `timeoutMs` passes before an answer comes. A deadline that passes while an answer's body is being read throws no `TimeoutError`: a refusal then throws `EmailApiError` with `type` `unknown`, and an accepted send resolves with an empty `messageId`. The key is trimmed at each send. A key, an `idempotencyKey` or a `userAgent` that holds a character an HTTP header cannot carry (a line break or another control character other than a tab, or a character above U+00FF) throws an `Error` before any request, and its message names which one and none of its value. `ResendTransport.send` checks a message's `headers` by the rules for names and values in [header fields](#header-fields), though not against the fields the service writes, and throws the same way.
128
139
 
129
140
  A send is tried again only on the terms of Resend's [errors](https://resend.com/docs/api-reference/errors) and [rate limit](https://resend.com/docs/api-reference/rate-limit) pages, as `retryWaitSeconds` decides:
130
141
 
@@ -136,7 +147,19 @@ The wait is the answer's `retry-after` when it has one, else as many seconds as
136
147
 
137
148
  ## Reading mail
138
149
 
139
- `readInbox`, `searchEmails`, `extractCodes` and `replyToEmail` read through IMAP: the `imap` account (`host`, `user` and `password`, with `port` and `secure`, by default TLS on port 993), or the SMTP account when `imap` is not given. With Resend, reading needs `imap`. A reply goes out through the transport that sends, threaded with `In-Reply-To` and `References`. The pack's factory reads the account from the secrets `email.imap.host`, `email.imap.user` and `email.imap.password`, or from `IMAP_HOST`, `IMAP_USER` and `IMAP_PASSWORD`.
150
+ `readInbox`, `searchEmails`, `extractCodes` and `replyToEmail` read through IMAP: the `imap` account (`host`, `user` and `password`, with `port` and `secure`, by default TLS on port 993), or the SMTP account when `imap` is not given. With Resend, reading needs `imap`. The pack's factory reads the account from the secrets `email.imap.host`, `email.imap.user` and `email.imap.password`, or from `IMAP_HOST`, `IMAP_USER` and `IMAP_PASSWORD`.
151
+
152
+ `searchEmails` finds the messages whose subject, body or From holds `query`, ignoring case, and gives the last `limit` it finds (20 by default), newest first. Each search answers UIDs, and the messages are fetched by those UIDs, which need not follow the messages' sequence numbers once a message has been removed ([RFC 3501 section 2.3.1.1](https://www.rfc-editor.org/rfc/rfc3501#section-2.3.1.1)).
153
+
154
+ `extractCodes(messageId, pattern?)` and `replyToEmail(messageId, body, html?)` name an INBOX message by its Message-ID, with or without its angle brackets: `<id@example.com>` and `id@example.com` name the same message. An IMAP search matches every Message-ID that holds the text ([RFC 3501 section 6.4.4](https://www.rfc-editor.org/rfc/rfc3501#section-6.4.4)), so each searches for the id in angle brackets and takes only the message whose own id is that one. `extractCodes` answers no codes for an id that is not one message id or that no message has.
155
+
156
+ A reply goes out through the transport that sends:
157
+
158
+ - to the original's Reply-To, or its From when it names none ([RFC 5322 section 3.6.2](https://www.rfc-editor.org/rfc/rfc5322#section-3.6.2));
159
+ - under the original's subject on one line, with `Re: ` before it unless it starts with `Re:` in any case;
160
+ - threaded as [RFC 5322 section 3.6.4](https://www.rfc-editor.org/rfc/rfc5322#section-3.6.4) has it: `In-Reply-To` names the original, and `References` lists the ids of the original's own References, or the one id of its In-Reply-To when its References lists none, then the original's id. Only fields of those very names are read, each whole however it is folded. An id in them that holds a character outside printable US-ASCII, or that is too long for one line, is left out, so whatever the original holds, both transports send the same reply.
161
+
162
+ `replyToEmail` throws an `Error` that holds none of the id when the id is not one message id of printable US-ASCII with no space, or is too long for one line of `In-Reply-To`, before the original is read; and when no message in INBOX has the id, or the original names no address to reply to, before anything is sent.
140
163
 
141
164
  ## In an agent
142
165
 
@@ -146,7 +169,7 @@ A model names the tools' arguments and the channel's blocks, so `emailSend` and
146
169
 
147
170
  They send each attachment as they checked it: a copy of its own `filename`, `content`, `path` and `contentType`, each read once, with a Buffer's bytes copied. They refuse the same way an attachment that is not an object, one that gives any of those four through a getter or a setter, and one whose `filename` or `contentType` is not a string. Any other property of an attachment is left out.
148
171
 
149
- The subject, the bodies and the message id a reply names must be text. Through SMTP, nodemailer reads the file or fetches the address an object body names into the message, and writes an object subject or message id marked `prepared` into the headers as it is. So `sendEmail` and `replyToEmail`, whoever calls them, throw a `TypeError` that names the field, never its value, before anything is sent on either transport: `subject`, `body` and the reply's `messageId` must be strings, and `html` a string when given (`null` leaves the HTML part out). `emailSend` and `emailReply` answer it as `{ success: false, error }`, and the adapter throws it.
172
+ The subject, the bodies and the message id a reply names must be text. Through SMTP, nodemailer reads the file or fetches the address an object body names into the message, and writes an object subject or message id marked `prepared` into the headers as it is. So `sendEmail` and `replyToEmail`, whoever calls them, throw a `TypeError` that names the field, never its value, before anything is sent on either transport: `subject`, `body` and the reply's `messageId` must be strings, and `html` a string when given (`null` leaves the HTML part out). `emailSend` and `emailReply` answer it as `{ success: false, error }`, and the adapter throws it. A reply whose message id is not one message id, or names no message in INBOX, is answered and thrown the same way, with the `Error` [reading mail](#reading-mail) describes.
150
173
 
151
174
  ## API
152
175
 
@@ -49,7 +49,9 @@ export declare class EmailChannelAdapter {
49
49
  * filename before anything is sent, a reply included; what is sent is the copy `requireAgentAttachments` made of
50
50
  * each attachment, so a Buffer is sent as the bytes it held then. A text block's `text`, an HTML block's `html` (or
51
51
  * its `text`), the `subject` in `platformOptions` and `replyToMessageId` must be strings when given: otherwise the
52
- * service throws a `TypeError` naming the field before anything is sent.
52
+ * service throws a `TypeError` naming the field before anything is sent. A reply goes to the original's Reply-To, or
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.
53
55
  */
54
56
  sendMessage(conversationId: string, content: MessageContent): Promise<ChannelSendResult>;
55
57
  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;;;;;;;;OAQG;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;;;;;;;;;;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"}
@@ -53,7 +53,9 @@ class EmailChannelAdapter {
53
53
  * filename before anything is sent, a reply included; what is sent is the copy `requireAgentAttachments` made of
54
54
  * each attachment, so a Buffer is sent as the bytes it held then. A text block's `text`, an HTML block's `html` (or
55
55
  * its `text`), the `subject` in `platformOptions` and `replyToMessageId` must be strings when given: otherwise the
56
- * service throws a `TypeError` naming the field before anything is sent.
56
+ * service throws a `TypeError` naming the field before anything is sent. A reply goes to the original's Reply-To, or
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.
57
59
  */
58
60
  async sendMessage(conversationId, content) {
59
61
  const textBlock = content.blocks.find((b) => b.type === 'text');
@@ -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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"}
@@ -45,10 +45,12 @@ export interface SendEmailOptions {
45
45
  idempotencyKey?: string;
46
46
  /**
47
47
  * Header fields added to the message, by name, such as `List-Unsubscribe` and `List-Unsubscribe-Post`: through Resend
48
- * the request's `headers`, through SMTP fields nodemailer writes into the message. A name is printable US-ASCII with no
49
- * space or colon, given once in any case, and a value is a string with no line break or other control character than
50
- * the tab. A field the service writes itself is refused, in any case: `From`, `To`, `Cc`, `Bcc`, `Reply-To`, `Subject`,
51
- * `Date`, `Message-ID`, `MIME-Version` and any `Content-` field.
48
+ * the request's `headers`, through SMTP fields nodemailer writes into the message as given. A name is printable
49
+ * US-ASCII with no space or colon, given once in any case. A value is a string of printable US-ASCII words, one space
50
+ * or tab between each two and none at either end, with no word so long that the name, a colon, a space and the word
51
+ * pass 998 characters, RFC 5322's longest line; `In-Reply-To` and `References` list message ids, each in angle
52
+ * brackets. A field the service writes itself is refused, in any case: `From`, `To`, `Cc`, `Bcc`, `Reply-To`,
53
+ * `Subject`, `Date`, `Message-ID`, `MIME-Version` and any `Content-` field.
52
54
  */
53
55
  headers?: Record<string, string>;
54
56
  }
@@ -98,31 +100,61 @@ export declare class EmailService {
98
100
  * `subject` and `body` must be strings, and `html` a string when it is given (`null` sends no HTML part, as
99
101
  * `undefined` does): any other value throws a `TypeError` naming the field, never its value, before either transport
100
102
  * is used. An object body would have nodemailer read the file its `path` names or fetch the address its `href` names
101
- * into the message, and an object subject marked `prepared` would be written into the headers as it is.
103
+ * into the message, and an object subject marked `prepared` would be written into the headers as it is. The subject
104
+ * goes out on one line through either transport: each line break in it, and each other control character but the
105
+ * tab, is sent as a space, as nodemailer writes a line break in a subject.
102
106
  *
103
107
  * `headers` adds fields to the message and never replaces one the service writes. It must be a plain object of names
104
- * and values: a name printable US-ASCII with no space or colon, given once in any case, and none of `From`, `To`,
105
- * `Cc`, `Bcc`, `Reply-To`, `Subject`, `Date`, `Message-ID`, `MIME-Version` or a `Content-` field, in any case; a value
106
- * a string with no line break or other control character than the tab. Anything else throws an `Error` that names the
107
- * field, or its place when its name is at fault, and none of its value, before either transport is used. Each value is
108
- * read once, and the fields are sent as read.
108
+ * and values, made in this realm or another: a name printable US-ASCII with no space or colon, given once in any case,
109
+ * and none of `From`, `To`, `Cc`, `Bcc`, `Reply-To`, `Subject`, `Date`, `Message-ID`, `MIME-Version` or a `Content-`
110
+ * field, in any case; a value a string of printable US-ASCII words, one space or tab between each two and none at
111
+ * either end, with no word so long that the name, a colon, a space and the word pass 998 characters, and for
112
+ * `In-Reply-To` and `References` message ids, each in angle brackets. Anything else throws an `Error` that names the
113
+ * field, or its place when its name is at fault, and none of its value, before either transport is used. Each value
114
+ * is read once, and both transports send the fields as read: nodemailer writes each value as given, folded at its
115
+ * white space, except `Sender`, which it writes as the address it parses from the value.
109
116
  */
110
117
  sendEmail(options: SendEmailOptions): Promise<{
111
118
  messageId: string;
112
119
  }>;
113
120
  readInbox(folder?: string, limit?: number): Promise<EmailMessage[]>;
121
+ /**
122
+ * The messages of `folder` (INBOX unless given) whose subject, body or From holds `query`, ignoring case, received on
123
+ * or after `since` when it is given: the last `limit` (20 unless given) the search finds, newest first. The search
124
+ * answers UIDs, and the messages are fetched by those UIDs, which need not follow the messages' sequence numbers.
125
+ */
114
126
  searchEmails(options: SearchEmailOptions): Promise<EmailMessage[]>;
127
+ /**
128
+ * The codes in the INBOX message whose Message-ID is `messageId`, given with or without its angle brackets: the
129
+ * matches of `pattern` when it is given, else runs of four to eight digits, then the codes that phrases such as "code
130
+ * is", "OTP:", "is your" or "enter ... to" point to, each once. The message is searched for by the id in angle brackets
131
+ * and fetched by the UID the search answers; a search matches every Message-ID that holds the text, so only the
132
+ * message whose own id is the one given counts. An id that is not one message id, or that no message has, gives no
133
+ * codes.
134
+ */
115
135
  extractCodes(messageId: string, pattern?: string): Promise<{
116
136
  codes: string[];
117
137
  messageId: string;
118
138
  }>;
119
139
  /**
120
- * Replies to the message whose Message-ID is `messageId`, reading its sender, subject and References through IMAP,
121
- * and sends `body` with `html`, when given, through the transport that sends, threaded with `In-Reply-To` and
122
- * `References`. `messageId` and `body` must be strings, and `html` a string when it is given (`null` sends no HTML
123
- * part): any other value throws a `TypeError` naming the field, never its value, before the original is read or
124
- * anything is sent. Besides the bodies' files and addresses (see `sendEmail`), an object message id marked `prepared`
125
- * would be written into the `In-Reply-To` header as it is.
140
+ * Replies to the INBOX message whose Message-ID is `messageId`, given with or without its angle brackets, and sends
141
+ * `body` with `html`, when given, through the transport that sends. The original is searched for by the id in angle
142
+ * brackets and fetched by the UID the search answers; a search matches every Message-ID that holds the text, so the
143
+ * original is the message whose own id is the one given. The reply goes to the original's Reply-To, or its From when
144
+ * it names none (RFC 5322 section 3.6.2), under its subject with one `Re: ` before it unless it starts with one in any
145
+ * case, on one line. It is threaded as RFC 5322 section 3.6.4 has it: `In-Reply-To` names the original, and
146
+ * `References` lists the ids of the original's own References, or the one id of its In-Reply-To when its References
147
+ * lists none, then the original's id. Only fields of those very names are read, each whole however it is folded, and
148
+ * an id in them that holds a character outside printable US-ASCII, or that is too long for one line, is left out, so
149
+ * both transports send the same fields whatever the original holds.
150
+ *
151
+ * `messageId` and `body` must be strings, and `html` a string when it is given (`null` sends no HTML part): any other
152
+ * value throws a `TypeError` naming the field, never its value, before the original is read or anything is sent.
153
+ * Besides the bodies' files and addresses (see `sendEmail`), an object message id marked `prepared` would be written
154
+ * into the `In-Reply-To` header as it is. A message id that is not one id of printable US-ASCII with no space, or that
155
+ * is too long for one line of `In-Reply-To`, throws an `Error` before the original is read; no message with that id,
156
+ * or an original that names no address to reply to, throws an `Error` before anything is sent. None of these errors
157
+ * holds the id.
126
158
  */
127
159
  replyToEmail(messageId: string, body: string, html?: string): Promise<{
128
160
  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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"}
@@ -47,6 +47,24 @@ const ResendTransport_js_1 = require("./ResendTransport.js");
47
47
  const messageHeaders_js_1 = require("./messageHeaders.js");
48
48
  const messageText_js_1 = require("./messageText.js");
49
49
  // ---------------------------------------------------------------------------
50
+ // Helpers
51
+ // ---------------------------------------------------------------------------
52
+ /**
53
+ * The fields nodemailer writes in a form of its own whatever value it is given (its `FORMATTED_HEADERS`) and that
54
+ * `sendEmail` lets a caller add, in lowercase: `Sender`, an address nodemailer parses and writes again, and
55
+ * `References`, whose message ids nodemailer writes as they are once the check has passed them. Every other added
56
+ * field is given to nodemailer as a prepared value, which it writes as given, folded at its white space.
57
+ */
58
+ const FORMATTED_BY_NODEMAILER = new Set(['sender', 'references']);
59
+ /** A checked field as nodemailer's `headers` list takes it, so nodemailer writes the value Resend would be given. */
60
+ function smtpField(key, value) {
61
+ return { key, value: FORMATTED_BY_NODEMAILER.has(key.toLowerCase()) ? value : { prepared: true, foldLines: true, value } };
62
+ }
63
+ /** The first address an envelope's list names, such as its Reply-To or its From, or `undefined`. */
64
+ function firstAddress(list) {
65
+ return list?.find((entry) => typeof entry?.address === 'string' && entry.address !== '')?.address;
66
+ }
67
+ // ---------------------------------------------------------------------------
50
68
  // Service
51
69
  // ---------------------------------------------------------------------------
52
70
  class EmailService {
@@ -121,19 +139,24 @@ class EmailService {
121
139
  * `subject` and `body` must be strings, and `html` a string when it is given (`null` sends no HTML part, as
122
140
  * `undefined` does): any other value throws a `TypeError` naming the field, never its value, before either transport
123
141
  * is used. An object body would have nodemailer read the file its `path` names or fetch the address its `href` names
124
- * into the message, and an object subject marked `prepared` would be written into the headers as it is.
142
+ * into the message, and an object subject marked `prepared` would be written into the headers as it is. The subject
143
+ * goes out on one line through either transport: each line break in it, and each other control character but the
144
+ * tab, is sent as a space, as nodemailer writes a line break in a subject.
125
145
  *
126
146
  * `headers` adds fields to the message and never replaces one the service writes. It must be a plain object of names
127
- * and values: a name printable US-ASCII with no space or colon, given once in any case, and none of `From`, `To`,
128
- * `Cc`, `Bcc`, `Reply-To`, `Subject`, `Date`, `Message-ID`, `MIME-Version` or a `Content-` field, in any case; a value
129
- * a string with no line break or other control character than the tab. Anything else throws an `Error` that names the
130
- * field, or its place when its name is at fault, and none of its value, before either transport is used. Each value is
131
- * read once, and the fields are sent as read.
147
+ * and values, made in this realm or another: a name printable US-ASCII with no space or colon, given once in any case,
148
+ * and none of `From`, `To`, `Cc`, `Bcc`, `Reply-To`, `Subject`, `Date`, `Message-ID`, `MIME-Version` or a `Content-`
149
+ * field, in any case; a value a string of printable US-ASCII words, one space or tab between each two and none at
150
+ * either end, with no word so long that the name, a colon, a space and the word pass 998 characters, and for
151
+ * `In-Reply-To` and `References` message ids, each in angle brackets. Anything else throws an `Error` that names the
152
+ * field, or its place when its name is at fault, and none of its value, before either transport is used. Each value
153
+ * is read once, and both transports send the fields as read: nodemailer writes each value as given, folded at its
154
+ * white space, except `Sender`, which it writes as the address it parses from the value.
132
155
  */
133
156
  async sendEmail(options) {
134
157
  this.requireRunning();
135
158
  // Each is read once, so the value checked is the value sent.
136
- const subject = (0, messageText_js_1.requireText)('subject', options.subject);
159
+ const subject = (0, messageText_js_1.oneLine)((0, messageText_js_1.requireText)('subject', options.subject));
137
160
  const body = (0, messageText_js_1.requireText)('body', options.body);
138
161
  const html = (0, messageText_js_1.optionalText)('html', options.html);
139
162
  const headers = (0, messageHeaders_js_1.requireAddedHeaders)(options.headers);
@@ -164,8 +187,8 @@ class EmailService {
164
187
  cc: options.cc,
165
188
  bcc: options.bcc,
166
189
  // A list, so nodemailer adds each field under its own name: an object with `key` and `value` properties would be
167
- // read as one field named by its `key`.
168
- headers: headers === undefined ? undefined : Object.entries(headers).map(([key, value]) => ({ key, value })),
190
+ // read as one field named by its `key`. Each value is prepared, so nodemailer writes it as Resend is given it.
191
+ headers: headers === undefined ? undefined : Object.entries(headers).map(([key, value]) => smtpField(key, value)),
169
192
  attachments: options.attachments?.map((a) => ({
170
193
  filename: a.filename,
171
194
  content: a.content,
@@ -219,6 +242,11 @@ class EmailService {
219
242
  }
220
243
  }
221
244
  // ── Search Emails ──
245
+ /**
246
+ * The messages of `folder` (INBOX unless given) whose subject, body or From holds `query`, ignoring case, received on
247
+ * or after `since` when it is given: the last `limit` (20 unless given) the search finds, newest first. The search
248
+ * answers UIDs, and the messages are fetched by those UIDs, which need not follow the messages' sequence numbers.
249
+ */
222
250
  async searchEmails(options) {
223
251
  this.requireRunning();
224
252
  const client = await this.getImapClient();
@@ -243,12 +271,13 @@ class EmailService {
243
271
  const uidList = Array.isArray(uids) ? uids : [];
244
272
  const limitedUids = uidList.slice(-(options.limit ?? 20));
245
273
  if (limitedUids.length > 0) {
274
+ // The search answered UIDs, so the fetch reads its range as UIDs rather than sequence numbers.
246
275
  for await (const msg of client.fetch(limitedUids, {
247
276
  envelope: true,
248
277
  source: true,
249
278
  flags: true,
250
279
  uid: true,
251
- })) {
280
+ }, { uid: true })) {
252
281
  const envelope = msg.envelope;
253
282
  const source = msg.source?.toString() ?? '';
254
283
  const bodyText = this.extractPlainText(source);
@@ -275,53 +304,66 @@ class EmailService {
275
304
  }
276
305
  }
277
306
  // ── Extract Verification Codes ──
307
+ /**
308
+ * The codes in the INBOX message whose Message-ID is `messageId`, given with or without its angle brackets: the
309
+ * matches of `pattern` when it is given, else runs of four to eight digits, then the codes that phrases such as "code
310
+ * is", "OTP:", "is your" or "enter ... to" point to, each once. The message is searched for by the id in angle brackets
311
+ * and fetched by the UID the search answers; a search matches every Message-ID that holds the text, so only the
312
+ * message whose own id is the one given counts. An id that is not one message id, or that no message has, gives no
313
+ * codes.
314
+ */
278
315
  async extractCodes(messageId, pattern) {
279
316
  this.requireRunning();
317
+ const wanted = (0, messageHeaders_js_1.oneMessageId)(messageId);
318
+ if (wanted === undefined)
319
+ return { codes: [], messageId };
280
320
  const client = await this.getImapClient();
281
321
  try {
282
322
  const lock = await client.getMailboxLock('INBOX');
323
+ let source;
283
324
  try {
284
- // Search for the specific message
285
- const uids = await client.search({ header: { 'message-id': messageId } }, { uid: true });
325
+ const uids = await client.search({ header: { 'message-id': wanted } }, { uid: true });
286
326
  const uidList = Array.isArray(uids) ? uids : [];
287
- if (uidList.length === 0) {
288
- return { codes: [], messageId };
289
- }
290
- for await (const msg of client.fetch(uidList, { source: true })) {
291
- const source = msg.source?.toString() ?? '';
292
- const bodyText = this.extractPlainText(source);
293
- const htmlBody = this.extractHtmlBody(source);
294
- const fullText = bodyText + ' ' + htmlBody;
295
- // Apply custom or default code extraction patterns
296
- const regex = pattern
297
- ? new RegExp(pattern, 'g')
298
- : /\b(\d{4,8})\b/g;
299
- const codes = [];
300
- let match;
301
- while ((match = regex.exec(fullText)) !== null) {
302
- codes.push(match[1] ?? match[0]);
303
- }
304
- // Also look for common verification code patterns
305
- const verifyPatterns = [
306
- /(?:code|verification|OTP|pin)\s*(?:is|:)\s*(\w{4,8})/gi,
307
- /(\d{6})\s*(?:is your|to verify)/gi,
308
- /(?:enter|use)\s+(\w{4,8})\s+(?:to|as|for)/gi,
309
- ];
310
- for (const vp of verifyPatterns) {
311
- while ((match = vp.exec(fullText)) !== null) {
312
- const code = match[1];
313
- if (code && !codes.includes(code)) {
314
- codes.push(code);
315
- }
316
- }
327
+ if (uidList.length > 0) {
328
+ // The loop reads every message fetched and keeps the first whose own id is the one given.
329
+ for await (const msg of client.fetch(uidList, { envelope: true, source: true }, { uid: true })) {
330
+ if (source === undefined && (0, messageHeaders_js_1.messageIdIn)(msg.envelope?.messageId) === wanted)
331
+ source = msg.source?.toString() ?? '';
317
332
  }
318
- return { codes: [...new Set(codes)], messageId };
319
333
  }
320
334
  }
321
335
  finally {
322
336
  lock.release();
323
337
  }
324
- return { codes: [], messageId };
338
+ if (source === undefined)
339
+ return { codes: [], messageId };
340
+ const bodyText = this.extractPlainText(source);
341
+ const htmlBody = this.extractHtmlBody(source);
342
+ const fullText = bodyText + ' ' + htmlBody;
343
+ // Apply custom or default code extraction patterns
344
+ const regex = pattern
345
+ ? new RegExp(pattern, 'g')
346
+ : /\b(\d{4,8})\b/g;
347
+ const codes = [];
348
+ let match;
349
+ while ((match = regex.exec(fullText)) !== null) {
350
+ codes.push(match[1] ?? match[0]);
351
+ }
352
+ // Also look for common verification code patterns
353
+ const verifyPatterns = [
354
+ /(?:code|verification|OTP|pin)\s*(?:is|:)\s*(\w{4,8})/gi,
355
+ /(\d{6})\s*(?:is your|to verify)/gi,
356
+ /(?:enter|use)\s+(\w{4,8})\s+(?:to|as|for)/gi,
357
+ ];
358
+ for (const vp of verifyPatterns) {
359
+ while ((match = vp.exec(fullText)) !== null) {
360
+ const code = match[1];
361
+ if (code && !codes.includes(code)) {
362
+ codes.push(code);
363
+ }
364
+ }
365
+ }
366
+ return { codes: [...new Set(codes)], messageId };
325
367
  }
326
368
  finally {
327
369
  await client.logout();
@@ -329,12 +371,24 @@ class EmailService {
329
371
  }
330
372
  // ── Reply to Email ──
331
373
  /**
332
- * Replies to the message whose Message-ID is `messageId`, reading its sender, subject and References through IMAP,
333
- * and sends `body` with `html`, when given, through the transport that sends, threaded with `In-Reply-To` and
334
- * `References`. `messageId` and `body` must be strings, and `html` a string when it is given (`null` sends no HTML
335
- * part): any other value throws a `TypeError` naming the field, never its value, before the original is read or
336
- * anything is sent. Besides the bodies' files and addresses (see `sendEmail`), an object message id marked `prepared`
337
- * would be written into the `In-Reply-To` header as it is.
374
+ * Replies to the INBOX message whose Message-ID is `messageId`, given with or without its angle brackets, and sends
375
+ * `body` with `html`, when given, through the transport that sends. The original is searched for by the id in angle
376
+ * brackets and fetched by the UID the search answers; a search matches every Message-ID that holds the text, so the
377
+ * original is the message whose own id is the one given. The reply goes to the original's Reply-To, or its From when
378
+ * it names none (RFC 5322 section 3.6.2), under its subject with one `Re: ` before it unless it starts with one in any
379
+ * case, on one line. It is threaded as RFC 5322 section 3.6.4 has it: `In-Reply-To` names the original, and
380
+ * `References` lists the ids of the original's own References, or the one id of its In-Reply-To when its References
381
+ * lists none, then the original's id. Only fields of those very names are read, each whole however it is folded, and
382
+ * an id in them that holds a character outside printable US-ASCII, or that is too long for one line, is left out, so
383
+ * both transports send the same fields whatever the original holds.
384
+ *
385
+ * `messageId` and `body` must be strings, and `html` a string when it is given (`null` sends no HTML part): any other
386
+ * value throws a `TypeError` naming the field, never its value, before the original is read or anything is sent.
387
+ * Besides the bodies' files and addresses (see `sendEmail`), an object message id marked `prepared` would be written
388
+ * into the `In-Reply-To` header as it is. A message id that is not one id of printable US-ASCII with no space, or that
389
+ * is too long for one line of `In-Reply-To`, throws an `Error` before the original is read; no message with that id,
390
+ * or an original that names no address to reply to, throws an `Error` before anything is sent. None of these errors
391
+ * holds the id.
338
392
  */
339
393
  async replyToEmail(messageId, body, html) {
340
394
  this.requireRunning();
@@ -343,23 +397,26 @@ class EmailService {
343
397
  (0, messageText_js_1.requireText)('messageId', messageId);
344
398
  (0, messageText_js_1.requireText)('body', body);
345
399
  html = (0, messageText_js_1.optionalText)('html', html);
346
- // Fetch the original message to get reply headers
400
+ // The original's id as In-Reply-To and the end of References carry it, through either transport.
401
+ const parent = (0, messageHeaders_js_1.requireMessageId)(messageId);
347
402
  const client = await this.getImapClient();
348
- let originalFrom = '';
349
- let originalSubject = '';
350
- let previous = '';
403
+ let original;
351
404
  try {
352
405
  const lock = await client.getMailboxLock('INBOX');
353
406
  try {
354
- const uids = await client.search({ header: { 'message-id': messageId } }, { uid: true });
407
+ const uids = await client.search({ header: { 'message-id': parent } }, { uid: true });
355
408
  const uidList = Array.isArray(uids) ? uids : [];
356
409
  if (uidList.length > 0) {
357
- for await (const msg of client.fetch(uidList, { envelope: true, headers: true })) {
358
- originalFrom = msg.envelope?.from?.[0]?.address ?? '';
359
- originalSubject = msg.envelope?.subject ?? '';
360
- const headersStr = msg.headers?.toString() ?? '';
361
- const refsMatch = headersStr.match(/References:\s*(.+)/i);
362
- previous = refsMatch ? refsMatch[1].trim() : '';
410
+ // The loop reads every message fetched and keeps the first whose own id is the one given.
411
+ for await (const msg of client.fetch(uidList, { envelope: true, headers: true }, { uid: true })) {
412
+ if (original !== undefined || (0, messageHeaders_js_1.messageIdIn)(msg.envelope?.messageId) !== parent)
413
+ continue;
414
+ original = {
415
+ // A server gives the From as the Reply-To of a message that names none (RFC 3501 section 7.4.2).
416
+ to: firstAddress(msg.envelope?.replyTo) ?? firstAddress(msg.envelope?.from) ?? '',
417
+ subject: (0, messageText_js_1.oneLine)(msg.envelope?.subject ?? ''),
418
+ thread: (0, messageHeaders_js_1.threadOf)(msg.headers?.toString() ?? ''),
419
+ };
363
420
  }
364
421
  }
365
422
  }
@@ -370,25 +427,31 @@ class EmailService {
370
427
  finally {
371
428
  await client.logout();
372
429
  }
373
- const replySubject = originalSubject.startsWith('Re:') ? originalSubject : `Re: ${originalSubject}`;
374
- const references = previous ? `${previous} ${messageId}` : messageId;
430
+ if (original === undefined)
431
+ throw new Error('The reply was refused: no message in the inbox has that message id');
432
+ if (original.to === '')
433
+ throw new Error('The reply was refused: the message names no address to reply to');
434
+ const replySubject = /^re:/i.test(original.subject) ? original.subject : `Re: ${original.subject}`;
435
+ const references = [...original.thread, parent].join(' ');
436
+ // Checked by the rules every added field meets, so both transports are given the same fields.
437
+ const thread = (0, messageHeaders_js_1.requireHeaders)({ 'In-Reply-To': parent, References: references });
375
438
  if (this.resend) {
376
439
  return this.resend.send({
377
440
  from: this.config.from,
378
- to: originalFrom,
441
+ to: original.to,
379
442
  subject: replySubject,
380
443
  text: body,
381
444
  html,
382
- headers: { 'In-Reply-To': messageId, References: references },
445
+ headers: thread,
383
446
  });
384
447
  }
385
448
  const info = await this.transporter.sendMail({
386
449
  from: this.config.from ?? this.config.smtp.user,
387
- to: originalFrom,
450
+ to: original.to,
388
451
  subject: replySubject,
389
452
  text: body,
390
453
  html,
391
- inReplyTo: messageId,
454
+ inReplyTo: parent,
392
455
  references,
393
456
  });
394
457
  return { messageId: info.messageId };