@framers/agentos-ext-channel-email 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -2
- package/dist/index.js.map +1 -1
- package/dist/preferences/RecipientCapability.d.ts +100 -0
- package/dist/preferences/RecipientCapability.d.ts.map +1 -0
- package/dist/preferences/RecipientCapability.js +168 -0
- package/dist/preferences/RecipientCapability.js.map +1 -0
- package/dist/preferences/SuppressionStore.d.ts +72 -0
- package/dist/preferences/SuppressionStore.d.ts.map +1 -0
- package/dist/preferences/SuppressionStore.js +91 -0
- package/dist/preferences/SuppressionStore.js.map +1 -0
- package/dist/preferences/database.d.ts +34 -0
- package/dist/preferences/database.d.ts.map +1 -0
- package/dist/preferences/database.js +34 -0
- package/dist/preferences/database.js.map +1 -0
- package/dist/preferences/index.d.ts +15 -0
- package/dist/preferences/index.d.ts.map +1 -0
- package/dist/preferences/index.js +30 -0
- package/dist/preferences/index.js.map +1 -0
- package/manifest.json +3 -2
- package/package.json +3 -2
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 and replies to mail through IMAP. The pack's factory wraps the service in a messaging-channel adapter and five tools for an agent.
|
|
3
|
+
Email for AgentOS. `EmailService` sends mail through SMTP or Resend's HTTPS API, and reads, searches, extracts codes from and replies to 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.
|
|
4
4
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
@@ -83,6 +83,43 @@ Through Resend the fields go in the request's `headers` ([custom headers](https:
|
|
|
83
83
|
|
|
84
84
|
`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
85
|
|
|
86
|
+
## Suppressions and unsubscribe tokens
|
|
87
|
+
|
|
88
|
+
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.
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
import { createHash } from 'node:crypto';
|
|
92
|
+
import { createDatabase } from '@framers/sql-storage-adapter';
|
|
93
|
+
import {
|
|
94
|
+
DEFAULT_SUPPRESSION_SCOPE,
|
|
95
|
+
SqlRecipientCapability,
|
|
96
|
+
SqlSuppressionStore,
|
|
97
|
+
emailPreferencesDdl,
|
|
98
|
+
} from '@framers/agentos-ext-channel-email';
|
|
99
|
+
|
|
100
|
+
const db = await createDatabase({ url: process.env.DATABASE_URL });
|
|
101
|
+
await db.exec(emailPreferencesDdl('mail')); // mail_suppressions and mail_capabilities
|
|
102
|
+
|
|
103
|
+
// A bounce reported by the provider: the store gets the address's hash, never the address.
|
|
104
|
+
const suppressions = new SqlSuppressionStore(db, 'mail');
|
|
105
|
+
const addressHash = createHash('sha256').update('reader@example.com'.toLowerCase()).digest('hex');
|
|
106
|
+
await suppressions.suppress(DEFAULT_SUPPRESSION_SCOPE, addressHash, 'bounce', '2026-10-15');
|
|
107
|
+
const skip = await suppressions.isSuppressed(DEFAULT_SUPPRESSION_SCOPE, addressHash); // true: send nothing
|
|
108
|
+
|
|
109
|
+
// An unsubscribe link: the message's id is the nonce, so a retried message carries the same token.
|
|
110
|
+
const tokens = new SqlRecipientCapability(db, 'mail');
|
|
111
|
+
const key = Buffer.from(process.env.UNSUBSCRIBE_KEY ?? '', 'hex'); // 32 bytes or more, kept secret
|
|
112
|
+
const token = await tokens.issue('account-1', 'weekly-summary', 'message-42', key);
|
|
113
|
+
const link = `https://example.com/unsubscribe?t=${token}`;
|
|
114
|
+
const grant = await tokens.find(token); // { recipient: 'account-1', scope: 'weekly-summary' }, or null
|
|
115
|
+
```
|
|
116
|
+
|
|
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.
|
|
118
|
+
|
|
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.
|
|
120
|
+
|
|
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.
|
|
122
|
+
|
|
86
123
|
## Errors and retries
|
|
87
124
|
|
|
88
125
|
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.
|
|
@@ -121,7 +158,12 @@ The subject, the bodies and the message id a reply names must be text. Through S
|
|
|
121
158
|
| `EmailApiError`, `retryWaitSeconds` | A refusal from Resend, and the wait before its retry (`null` for none) |
|
|
122
159
|
| `EmailChannelAdapter` | The messaging-channel adapter |
|
|
123
160
|
| `EmailSendTool`, `EmailReadTool`, `EmailSearchTool`, `EmailExtractCodesTool`, `EmailReplyTool` | The five tools |
|
|
161
|
+
| `SqlSuppressionStore`, `suppressionStoreDdl(prefix)` | The suppression record on a SQL database, and its table's DDL |
|
|
162
|
+
| `SqlRecipientCapability`, `recipientCapabilityDdl(prefix)` | Recipient capabilities on a SQL database, and their table's DDL |
|
|
163
|
+
| `emailPreferencesDdl(prefix)` | Both tables' DDL |
|
|
164
|
+
| `DEFAULT_SUPPRESSION_SCOPE`, `SUPPRESSION_REASONS` | The scope of a host that keeps one list (`mail`), and the reasons an address is suppressed |
|
|
124
165
|
| `EmailConfig`, `SendEmailOptions`, `EmailMessage`, `SearchEmailOptions`, `ResendOptions`, `OutgoingEmail`, `OutgoingAttachment`, `EmailChannelOptions`, `ExtensionContext`, `ExtensionPack` | The types of the configuration, the messages, the options and the pack |
|
|
166
|
+
| `SuppressionStore`, `SuppressionReason`, `RecipientCapability`, `RecipientGrant`, `RecipientCapabilityIssueOptions`, `SqlRecipientCapabilityOptions`, `EmailPreferencesDb` | The stores' interfaces, their answers and options, and what they need of a database |
|
|
125
167
|
|
|
126
168
|
## License
|
|
127
169
|
|
package/dist/index.d.ts
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Provides an IChannelAdapter + 5 tools for sending, reading, searching,
|
|
5
5
|
* extracting codes from, and replying to emails: it sends through SMTP or
|
|
6
|
-
* Resend's HTTPS API and reads through IMAP.
|
|
6
|
+
* Resend's HTTPS API and reads through IMAP. It also provides the stores a
|
|
7
|
+
* sender reads on a host's SQL database: the suppression record and
|
|
8
|
+
* recipient capabilities (`./preferences`).
|
|
7
9
|
*
|
|
8
10
|
* @module @framers/agentos-ext-channel-email
|
|
9
11
|
*/
|
|
@@ -59,4 +61,6 @@ export { EmailReadTool } from './tools/read.js';
|
|
|
59
61
|
export { EmailSearchTool } from './tools/search.js';
|
|
60
62
|
export { EmailExtractCodesTool } from './tools/extractCodes.js';
|
|
61
63
|
export { EmailReplyTool } from './tools/reply.js';
|
|
64
|
+
export { DEFAULT_SUPPRESSION_SCOPE, SUPPRESSION_REASONS, SqlRecipientCapability, SqlSuppressionStore, emailPreferencesDdl, recipientCapabilityDdl, suppressionStoreDdl, } from './preferences/index.js';
|
|
65
|
+
export type { EmailPreferencesDb, RecipientCapability, RecipientCapabilityIssueOptions, RecipientGrant, SqlRecipientCapabilityOptions, SuppressionReason, SuppressionStore, } from './preferences/index.js';
|
|
62
66
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA;;;;;;;;;;GAUG;AAeH,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB;;;OAGG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,uEAAuE;IACvE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;OAGG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAClC;AAkFD,MAAM,WAAW,gBAAgB;IAC/B,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAClC,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAClC;AAED,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,KAAK,CAAC;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,OAAO,CAAA;KAAE,CAAC,CAAC;IACtF,UAAU,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IACjC,YAAY,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CACpC;AAMD,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,gBAAgB,GAAG,aAAa,CAgC5E;AAMD,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AACjD,YAAY,EAAE,WAAW,EAAE,gBAAgB,EAAE,YAAY,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AACzG,OAAO,EAAE,eAAe,EAAE,aAAa,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AACxF,YAAY,EAAE,aAAa,EAAE,aAAa,EAAE,kBAAkB,EAAE,MAAM,sBAAsB,CAAC;AAC7F,OAAO,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAC;AAC/D,OAAO,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAChD,OAAO,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAChD,OAAO,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AACpD,OAAO,EAAE,qBAAqB,EAAE,MAAM,yBAAyB,CAAC;AAChE,OAAO,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAClD,OAAO,EACL,yBAAyB,EACzB,mBAAmB,EACnB,sBAAsB,EACtB,mBAAmB,EACnB,mBAAmB,EACnB,sBAAsB,EACtB,mBAAmB,GACpB,MAAM,wBAAwB,CAAC;AAChC,YAAY,EACV,kBAAkB,EAClB,mBAAmB,EACnB,+BAA+B,EAC/B,cAAc,EACd,6BAA6B,EAC7B,iBAAiB,EACjB,gBAAgB,GACjB,MAAM,wBAAwB,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -5,12 +5,14 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Provides an IChannelAdapter + 5 tools for sending, reading, searching,
|
|
7
7
|
* extracting codes from, and replying to emails: it sends through SMTP or
|
|
8
|
-
* Resend's HTTPS API and reads through IMAP.
|
|
8
|
+
* Resend's HTTPS API and reads through IMAP. It also provides the stores a
|
|
9
|
+
* sender reads on a host's SQL database: the suppression record and
|
|
10
|
+
* recipient capabilities (`./preferences`).
|
|
9
11
|
*
|
|
10
12
|
* @module @framers/agentos-ext-channel-email
|
|
11
13
|
*/
|
|
12
14
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
-
exports.EmailReplyTool = exports.EmailExtractCodesTool = exports.EmailSearchTool = exports.EmailReadTool = exports.EmailSendTool = exports.EmailChannelAdapter = exports.retryWaitSeconds = exports.EmailApiError = exports.ResendTransport = exports.EmailService = void 0;
|
|
15
|
+
exports.suppressionStoreDdl = exports.recipientCapabilityDdl = exports.emailPreferencesDdl = exports.SqlSuppressionStore = exports.SqlRecipientCapability = exports.SUPPRESSION_REASONS = exports.DEFAULT_SUPPRESSION_SCOPE = exports.EmailReplyTool = exports.EmailExtractCodesTool = exports.EmailSearchTool = exports.EmailReadTool = exports.EmailSendTool = exports.EmailChannelAdapter = exports.retryWaitSeconds = exports.EmailApiError = exports.ResendTransport = exports.EmailService = void 0;
|
|
14
16
|
exports.createExtensionPack = createExtensionPack;
|
|
15
17
|
const EmailService_js_1 = require("./EmailService.js");
|
|
16
18
|
const EmailChannelAdapter_js_1 = require("./EmailChannelAdapter.js");
|
|
@@ -136,4 +138,12 @@ var extractCodes_js_2 = require("./tools/extractCodes.js");
|
|
|
136
138
|
Object.defineProperty(exports, "EmailExtractCodesTool", { enumerable: true, get: function () { return extractCodes_js_2.EmailExtractCodesTool; } });
|
|
137
139
|
var reply_js_2 = require("./tools/reply.js");
|
|
138
140
|
Object.defineProperty(exports, "EmailReplyTool", { enumerable: true, get: function () { return reply_js_2.EmailReplyTool; } });
|
|
141
|
+
var index_js_1 = require("./preferences/index.js");
|
|
142
|
+
Object.defineProperty(exports, "DEFAULT_SUPPRESSION_SCOPE", { enumerable: true, get: function () { return index_js_1.DEFAULT_SUPPRESSION_SCOPE; } });
|
|
143
|
+
Object.defineProperty(exports, "SUPPRESSION_REASONS", { enumerable: true, get: function () { return index_js_1.SUPPRESSION_REASONS; } });
|
|
144
|
+
Object.defineProperty(exports, "SqlRecipientCapability", { enumerable: true, get: function () { return index_js_1.SqlRecipientCapability; } });
|
|
145
|
+
Object.defineProperty(exports, "SqlSuppressionStore", { enumerable: true, get: function () { return index_js_1.SqlSuppressionStore; } });
|
|
146
|
+
Object.defineProperty(exports, "emailPreferencesDdl", { enumerable: true, get: function () { return index_js_1.emailPreferencesDdl; } });
|
|
147
|
+
Object.defineProperty(exports, "recipientCapabilityDdl", { enumerable: true, get: function () { return index_js_1.recipientCapabilityDdl; } });
|
|
148
|
+
Object.defineProperty(exports, "suppressionStoreDdl", { enumerable: true, get: function () { return index_js_1.suppressionStoreDdl; } });
|
|
139
149
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AAAA,cAAc;AACd
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { EmailPreferencesDb } from './database.js';
|
|
2
|
+
/** What a token names: the recipient it was issued for and the scope it acts on. */
|
|
3
|
+
export interface RecipientGrant {
|
|
4
|
+
/** The recipient, such as an account id. */
|
|
5
|
+
readonly recipient: string;
|
|
6
|
+
/** The scope, such as one kind of mail. */
|
|
7
|
+
readonly scope: string;
|
|
8
|
+
}
|
|
9
|
+
/** The options of one `issue`. */
|
|
10
|
+
export interface RecipientCapabilityIssueOptions {
|
|
11
|
+
/** When the token stops working, in epoch milliseconds. A token issued without it works until it is revoked. */
|
|
12
|
+
readonly expiresAt?: number;
|
|
13
|
+
}
|
|
14
|
+
/** The options of a `SqlRecipientCapability`. */
|
|
15
|
+
export interface SqlRecipientCapabilityOptions {
|
|
16
|
+
/** The clock `find` reads an expiry against and `revoke` records, in epoch milliseconds: `Date.now` unless given. */
|
|
17
|
+
readonly now?: () => number;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Opaque tokens that each name a recipient and a scope, kept as their SHA-256 alone. A token carries neither: only
|
|
21
|
+
* `find` maps it to them, and the store holds nothing a token can be recovered from.
|
|
22
|
+
*/
|
|
23
|
+
export interface RecipientCapability {
|
|
24
|
+
/**
|
|
25
|
+
* Issues the token for a nonce, such as a message's id: base64url of HMAC-SHA256 over the nonce under the key, 43
|
|
26
|
+
* characters. Issuing again with the same nonce and key, for the same recipient, scope and expiry, answers the same
|
|
27
|
+
* token and writes nothing; for another recipient, scope or expiry it throws, and the first token keeps its recipient
|
|
28
|
+
* and scope. A nonce whose token was revoked throws for every recipient, scope and expiry, so a message that needs a
|
|
29
|
+
* working token after `revoke` takes a new nonce.
|
|
30
|
+
*
|
|
31
|
+
* @param recipient The recipient, 1 to 512 characters.
|
|
32
|
+
* @param scope The scope, 1 to 512 characters.
|
|
33
|
+
* @param nonce The nonce, 1 to 512 characters: one message, one nonce, never another message's.
|
|
34
|
+
* @param key The key, 32 bytes or more, kept secret by the caller.
|
|
35
|
+
* @param options The token's expiry, if it has one.
|
|
36
|
+
* @returns The token.
|
|
37
|
+
*/
|
|
38
|
+
issue(recipient: string, scope: string, nonce: string, key: Uint8Array, options?: RecipientCapabilityIssueOptions): Promise<string>;
|
|
39
|
+
/**
|
|
40
|
+
* The recipient and the scope the token was issued for, or `null` for a token that is malformed, unknown, revoked or
|
|
41
|
+
* past its expiry. The token is found by its SHA-256 and compared in constant time; a malformed token reaches no
|
|
42
|
+
* database.
|
|
43
|
+
*
|
|
44
|
+
* @param token The token as the link carried it.
|
|
45
|
+
*/
|
|
46
|
+
find(token: string): Promise<RecipientGrant | null>;
|
|
47
|
+
/**
|
|
48
|
+
* Ends every token issued for the recipient, in every scope. Each token's row stays, marked revoked, so its nonce is
|
|
49
|
+
* never issued again; a token issued afterwards from a new nonce works.
|
|
50
|
+
*
|
|
51
|
+
* @param recipient The recipient, 1 to 512 characters.
|
|
52
|
+
* @returns How many tokens it ended: a token ended by an earlier revoke is not counted again.
|
|
53
|
+
*/
|
|
54
|
+
revoke(recipient: string): Promise<number>;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The token for a nonce under a key: base64url of HMAC-SHA256, 43 characters.
|
|
58
|
+
*
|
|
59
|
+
* @param nonce The nonce, as UTF-8.
|
|
60
|
+
* @param key The key's bytes.
|
|
61
|
+
*/
|
|
62
|
+
export declare function capabilityToken(nonce: string, key: Uint8Array): string;
|
|
63
|
+
/**
|
|
64
|
+
* What the store keeps of a token: its SHA-256 in lower-case hexadecimal.
|
|
65
|
+
*
|
|
66
|
+
* @param token The token, as UTF-8.
|
|
67
|
+
*/
|
|
68
|
+
export declare function capabilityTokenHash(token: string): string;
|
|
69
|
+
/**
|
|
70
|
+
* The recipient capabilities' table, `<prefix>_capabilities`, and the index `revoke` reads, for a host's migration: one
|
|
71
|
+
* row a token, its SHA-256 the key, with the recipient, the scope, an optional expiry and the time the token was
|
|
72
|
+
* revoked, if it was. Both statements are `IF NOT EXISTS`, so they can run at each start, and they run on SQLite and on
|
|
73
|
+
* PostgreSQL. A row refuses its nonce for as long as it stays: a host that deletes rows, by its own foreign key with
|
|
74
|
+
* `ON DELETE CASCADE` or by hand, never issues their nonces again, and a host that cannot promise that calls `revoke`
|
|
75
|
+
* with each deletion and keeps the rows.
|
|
76
|
+
*
|
|
77
|
+
* @param prefix A lower-case letter followed by at most 39 lower-case letters, digits or underscores.
|
|
78
|
+
* @throws RangeError when the prefix does not fit.
|
|
79
|
+
*/
|
|
80
|
+
export declare function recipientCapabilityDdl(prefix: string): string;
|
|
81
|
+
/**
|
|
82
|
+
* Recipient capabilities on a host's SQL database, in the table `recipientCapabilityDdl` makes under the same prefix.
|
|
83
|
+
* Each argument is checked before any statement, and an error from the database reaches the caller as it was thrown.
|
|
84
|
+
*/
|
|
85
|
+
export declare class SqlRecipientCapability implements RecipientCapability {
|
|
86
|
+
private readonly db;
|
|
87
|
+
private readonly table;
|
|
88
|
+
private readonly now;
|
|
89
|
+
/**
|
|
90
|
+
* @param db The database: `@framers/sql-storage-adapter`'s adapter, or any handle of `EmailPreferencesDb`'s shape.
|
|
91
|
+
* @param prefix The prefix the table was made under.
|
|
92
|
+
* @param options The clock that expiries are read against and revokes record.
|
|
93
|
+
* @throws RangeError when the prefix does not fit.
|
|
94
|
+
*/
|
|
95
|
+
constructor(db: EmailPreferencesDb, prefix: string, options?: SqlRecipientCapabilityOptions);
|
|
96
|
+
issue(recipient: string, scope: string, nonce: string, key: Uint8Array, options?: RecipientCapabilityIssueOptions): Promise<string>;
|
|
97
|
+
find(token: string): Promise<RecipientGrant | null>;
|
|
98
|
+
revoke(recipient: string): Promise<number>;
|
|
99
|
+
}
|
|
100
|
+
//# sourceMappingURL=RecipientCapability.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"RecipientCapability.d.ts","sourceRoot":"","sources":["../../src/preferences/RecipientCapability.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAC;AAGxD,oFAAoF;AACpF,MAAM,WAAW,cAAc;IAC7B,4CAA4C;IAC5C,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,2CAA2C;IAC3C,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;CACxB;AAED,kCAAkC;AAClC,MAAM,WAAW,+BAA+B;IAC9C,gHAAgH;IAChH,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;CAC7B;AAED,iDAAiD;AACjD,MAAM,WAAW,6BAA6B;IAC5C,qHAAqH;IACrH,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;CAC7B;AAED;;;GAGG;AACH,MAAM,WAAW,mBAAmB;IAClC;;;;;;;;;;;;;OAaG;IACH,KAAK,CACH,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,MAAM,EACb,GAAG,EAAE,UAAU,EACf,OAAO,CAAC,EAAE,+BAA+B,GACxC,OAAO,CAAC,MAAM,CAAC,CAAC;IAEnB;;;;;;OAMG;IACH,IAAI,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,GAAG,IAAI,CAAC,CAAC;IAEpD;;;;;;OAMG;IACH,MAAM,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;CAC5C;AAQD;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,UAAU,GAAG,MAAM,CAEtE;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAEzD;AAuDD;;;;;;;;;;GAUG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAY7D;AAED;;;GAGG;AACH,qBAAa,sBAAuB,YAAW,mBAAmB;IAW9D,OAAO,CAAC,QAAQ,CAAC,EAAE;IAVrB,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAS;IAC/B,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAe;IAEnC;;;;;OAKG;gBAEgB,EAAE,EAAE,kBAAkB,EACvC,MAAM,EAAE,MAAM,EACd,OAAO,GAAE,6BAAkC;IAMvC,KAAK,CACT,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,MAAM,EACb,GAAG,EAAE,UAAU,EACf,OAAO,CAAC,EAAE,+BAA+B,GACxC,OAAO,CAAC,MAAM,CAAC;IA4BZ,IAAI,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,GAAG,IAAI,CAAC;IAgBnD,MAAM,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;CASjD"}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SqlRecipientCapability = void 0;
|
|
4
|
+
exports.capabilityToken = capabilityToken;
|
|
5
|
+
exports.capabilityTokenHash = capabilityTokenHash;
|
|
6
|
+
exports.recipientCapabilityDdl = recipientCapabilityDdl;
|
|
7
|
+
/**
|
|
8
|
+
* @fileoverview Recipient capabilities: opaque tokens that each name one recipient and one scope, such as the link in a
|
|
9
|
+
* message that lets its reader stop one kind of mail without signing in. A token is base64url of HMAC-SHA256 over the
|
|
10
|
+
* caller's nonce under the caller's key, so a message retried with the same nonce carries the same token, and only the
|
|
11
|
+
* token's SHA-256 is stored, beside the recipient and the scope. A token works until its recipient's tokens are
|
|
12
|
+
* revoked, or until the expiry its issuer set. A revoked token's row stays, marked revoked, so its nonce is never issued
|
|
13
|
+
* again: a revoked link never works again, and never comes to name another recipient or scope.
|
|
14
|
+
*/
|
|
15
|
+
const node_crypto_1 = require("node:crypto");
|
|
16
|
+
const database_js_1 = require("./database.js");
|
|
17
|
+
/** A token: 43 base64url characters, 32 bytes without padding. */
|
|
18
|
+
const TOKEN = /^[A-Za-z0-9_-]{43}$/;
|
|
19
|
+
/** The shortest key `issue` takes, HMAC-SHA256's output length; RFC 2104 section 3 discourages shorter keys. */
|
|
20
|
+
const KEY_BYTES = 32;
|
|
21
|
+
/**
|
|
22
|
+
* The token for a nonce under a key: base64url of HMAC-SHA256, 43 characters.
|
|
23
|
+
*
|
|
24
|
+
* @param nonce The nonce, as UTF-8.
|
|
25
|
+
* @param key The key's bytes.
|
|
26
|
+
*/
|
|
27
|
+
function capabilityToken(nonce, key) {
|
|
28
|
+
return (0, node_crypto_1.createHmac)('sha256', key).update(nonce, 'utf8').digest('base64url');
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* What the store keeps of a token: its SHA-256 in lower-case hexadecimal.
|
|
32
|
+
*
|
|
33
|
+
* @param token The token, as UTF-8.
|
|
34
|
+
*/
|
|
35
|
+
function capabilityTokenHash(token) {
|
|
36
|
+
return (0, node_crypto_1.createHash)('sha256').update(token, 'utf8').digest('hex');
|
|
37
|
+
}
|
|
38
|
+
/** Returns the key when it holds 32 bytes or more, and otherwise throws a `TypeError` that never holds the key. */
|
|
39
|
+
function requireKey(key) {
|
|
40
|
+
if (key instanceof Uint8Array && key.byteLength >= KEY_BYTES)
|
|
41
|
+
return key;
|
|
42
|
+
throw new TypeError(`The key was refused: give it as ${KEY_BYTES} bytes or more, such as a Buffer`);
|
|
43
|
+
}
|
|
44
|
+
/** The expiry `issue`'s options set, or `null` for none; anything but a whole number of milliseconds is refused. */
|
|
45
|
+
function requireExpiry(options) {
|
|
46
|
+
if (options === undefined)
|
|
47
|
+
return null;
|
|
48
|
+
if (typeof options !== 'object' || options === null) {
|
|
49
|
+
throw new TypeError('The options were refused: give them as an object or leave them out');
|
|
50
|
+
}
|
|
51
|
+
const expiresAt = options.expiresAt;
|
|
52
|
+
if (expiresAt === undefined)
|
|
53
|
+
return null;
|
|
54
|
+
if (typeof expiresAt === 'number' && Number.isSafeInteger(expiresAt))
|
|
55
|
+
return expiresAt;
|
|
56
|
+
throw new TypeError('The expiry was refused: give it as a whole number of epoch milliseconds or leave it out');
|
|
57
|
+
}
|
|
58
|
+
/** A stored expiry as a number, or `null` for none: PostgreSQL answers a BIGINT as text. */
|
|
59
|
+
function expiryOf(value) {
|
|
60
|
+
return value === null || value === undefined ? null : Number(value);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The time a revoke records: the clock's reading in whole epoch milliseconds, or 0 when it answers no such number. A
|
|
64
|
+
* revoke must mark its rows whatever the clock says: SQLite stores a NaN as NULL, which would leave the tokens working.
|
|
65
|
+
*/
|
|
66
|
+
function revokedAtOf(reading) {
|
|
67
|
+
const at = typeof reading === 'number' ? Math.trunc(reading) : Number.NaN;
|
|
68
|
+
return Number.isSafeInteger(at) ? at : 0;
|
|
69
|
+
}
|
|
70
|
+
/** Whether two strings are equal, compared in time that does not depend on where they differ. */
|
|
71
|
+
function equalInConstantTime(left, right) {
|
|
72
|
+
const a = Buffer.from(left, 'utf8');
|
|
73
|
+
const b = Buffer.from(right, 'utf8');
|
|
74
|
+
return a.length === b.length && (0, node_crypto_1.timingSafeEqual)(a, b);
|
|
75
|
+
}
|
|
76
|
+
/** Whether a stored row was revoked: any value in `revoked_at`, which PostgreSQL answers as text. */
|
|
77
|
+
function isRevoked(row) {
|
|
78
|
+
return row.revoked_at !== null && row.revoked_at !== undefined;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The recipient capabilities' table, `<prefix>_capabilities`, and the index `revoke` reads, for a host's migration: one
|
|
82
|
+
* row a token, its SHA-256 the key, with the recipient, the scope, an optional expiry and the time the token was
|
|
83
|
+
* revoked, if it was. Both statements are `IF NOT EXISTS`, so they can run at each start, and they run on SQLite and on
|
|
84
|
+
* PostgreSQL. A row refuses its nonce for as long as it stays: a host that deletes rows, by its own foreign key with
|
|
85
|
+
* `ON DELETE CASCADE` or by hand, never issues their nonces again, and a host that cannot promise that calls `revoke`
|
|
86
|
+
* with each deletion and keeps the rows.
|
|
87
|
+
*
|
|
88
|
+
* @param prefix A lower-case letter followed by at most 39 lower-case letters, digits or underscores.
|
|
89
|
+
* @throws RangeError when the prefix does not fit.
|
|
90
|
+
*/
|
|
91
|
+
function recipientCapabilityDdl(prefix) {
|
|
92
|
+
const table = `${(0, database_js_1.requirePrefix)(prefix)}_capabilities`;
|
|
93
|
+
return [
|
|
94
|
+
`CREATE TABLE IF NOT EXISTS ${table} (`,
|
|
95
|
+
' token_hash TEXT PRIMARY KEY,',
|
|
96
|
+
' recipient TEXT NOT NULL,',
|
|
97
|
+
' scope TEXT NOT NULL,',
|
|
98
|
+
' expires_at BIGINT,',
|
|
99
|
+
' revoked_at BIGINT',
|
|
100
|
+
');',
|
|
101
|
+
`CREATE INDEX IF NOT EXISTS ${table}_recipient ON ${table} (recipient);`,
|
|
102
|
+
].join('\n');
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Recipient capabilities on a host's SQL database, in the table `recipientCapabilityDdl` makes under the same prefix.
|
|
106
|
+
* Each argument is checked before any statement, and an error from the database reaches the caller as it was thrown.
|
|
107
|
+
*/
|
|
108
|
+
class SqlRecipientCapability {
|
|
109
|
+
/**
|
|
110
|
+
* @param db The database: `@framers/sql-storage-adapter`'s adapter, or any handle of `EmailPreferencesDb`'s shape.
|
|
111
|
+
* @param prefix The prefix the table was made under.
|
|
112
|
+
* @param options The clock that expiries are read against and revokes record.
|
|
113
|
+
* @throws RangeError when the prefix does not fit.
|
|
114
|
+
*/
|
|
115
|
+
constructor(db, prefix, options = {}) {
|
|
116
|
+
this.db = db;
|
|
117
|
+
this.table = `${(0, database_js_1.requirePrefix)(prefix)}_capabilities`;
|
|
118
|
+
this.now = options.now ?? Date.now;
|
|
119
|
+
}
|
|
120
|
+
async issue(recipient, scope, nonce, key, options) {
|
|
121
|
+
(0, database_js_1.requireName)('recipient', recipient);
|
|
122
|
+
(0, database_js_1.requireName)('scope', scope);
|
|
123
|
+
(0, database_js_1.requireName)('nonce', nonce);
|
|
124
|
+
const expiresAt = requireExpiry(options);
|
|
125
|
+
const token = capabilityToken(nonce, requireKey(key));
|
|
126
|
+
const tokenHash = capabilityTokenHash(token);
|
|
127
|
+
const inserted = await this.db.run(`INSERT INTO ${this.table} (token_hash, recipient, scope, expires_at) VALUES (?, ?, ?, ?) ` +
|
|
128
|
+
'ON CONFLICT (token_hash) DO NOTHING', [tokenHash, recipient, scope, expiresAt]);
|
|
129
|
+
if (inserted.changes === 1)
|
|
130
|
+
return token;
|
|
131
|
+
// The nonce was issued before, or a handle that reports no `changes` wrote it: the stored row decides.
|
|
132
|
+
const row = await this.db.get(`SELECT recipient, scope, expires_at, revoked_at FROM ${this.table} WHERE token_hash = ?`, [tokenHash]);
|
|
133
|
+
if (row === null || row === undefined) {
|
|
134
|
+
throw new Error('The token was not stored: its row was deleted while it was issued');
|
|
135
|
+
}
|
|
136
|
+
if (isRevoked(row)) {
|
|
137
|
+
throw new Error('The token was refused: its nonce was revoked under this key; give the message a new nonce');
|
|
138
|
+
}
|
|
139
|
+
if (row.recipient === recipient && row.scope === scope && expiryOf(row.expires_at) === expiresAt)
|
|
140
|
+
return token;
|
|
141
|
+
throw new Error('The token was refused: its nonce was issued under this key for another recipient, scope or expiry');
|
|
142
|
+
}
|
|
143
|
+
async find(token) {
|
|
144
|
+
if (typeof token !== 'string' || !TOKEN.test(token))
|
|
145
|
+
return null;
|
|
146
|
+
const tokenHash = capabilityTokenHash(token);
|
|
147
|
+
const row = await this.db.get(`SELECT token_hash, recipient, scope, expires_at, revoked_at FROM ${this.table} WHERE token_hash = ?`, [tokenHash]);
|
|
148
|
+
if (row === null || row === undefined || typeof row.token_hash !== 'string')
|
|
149
|
+
return null;
|
|
150
|
+
if (!equalInConstantTime(row.token_hash, tokenHash))
|
|
151
|
+
return null;
|
|
152
|
+
if (isRevoked(row))
|
|
153
|
+
return null;
|
|
154
|
+
const expiresAt = expiryOf(row.expires_at);
|
|
155
|
+
// Written so that a clock that answers NaN ends the token rather than keeping it.
|
|
156
|
+
if (expiresAt !== null && !(this.now() < expiresAt))
|
|
157
|
+
return null;
|
|
158
|
+
return { recipient: row.recipient, scope: row.scope };
|
|
159
|
+
}
|
|
160
|
+
async revoke(recipient) {
|
|
161
|
+
const name = (0, database_js_1.requireName)('recipient', recipient);
|
|
162
|
+
// The rows stay, so `issue` can refuse their nonces; only the live ones are marked and counted.
|
|
163
|
+
const result = await this.db.run(`UPDATE ${this.table} SET revoked_at = ? WHERE recipient = ? AND revoked_at IS NULL`, [revokedAtOf(this.now()), name]);
|
|
164
|
+
return result.changes ?? 0;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
exports.SqlRecipientCapability = SqlRecipientCapability;
|
|
168
|
+
//# sourceMappingURL=RecipientCapability.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"RecipientCapability.js","sourceRoot":"","sources":["../../src/preferences/RecipientCapability.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview The suppression record: the addresses a host must not mail, by scope, each named by the SHA-256 of the
|
|
3
|
+
* address that the host computes, so the record never holds an address. A host's senders read it before a send, and
|
|
4
|
+
* the handler of its provider's events writes it when a message bounces, draws a complaint or meets the provider's own
|
|
5
|
+
* suppression list. A second record of an address writes nothing, so a provider's repeated event is harmless.
|
|
6
|
+
*/
|
|
7
|
+
import type { EmailPreferencesDb } from './database.js';
|
|
8
|
+
/** The reasons an address is suppressed, in the order the README lists them. */
|
|
9
|
+
export declare const SUPPRESSION_REASONS: readonly ["bounce", "complaint", "suppressed", "manual"];
|
|
10
|
+
/**
|
|
11
|
+
* Why an address is suppressed: `bounce` (a message to it bounced), `complaint` (its reader reported a message as
|
|
12
|
+
* spam), `suppressed` (the provider refused a message because the address was already on its own list) or `manual`
|
|
13
|
+
* (an operator, or the person, asked).
|
|
14
|
+
*/
|
|
15
|
+
export type SuppressionReason = (typeof SUPPRESSION_REASONS)[number];
|
|
16
|
+
/** The scope of a host that keeps one list: every reader and writer of the record passes it. */
|
|
17
|
+
export declare const DEFAULT_SUPPRESSION_SCOPE = "mail";
|
|
18
|
+
/**
|
|
19
|
+
* The addresses a host must not mail, by scope. A scope is one list, such as all of a host's mail or one sending
|
|
20
|
+
* domain's, and most hosts keep one, `DEFAULT_SUPPRESSION_SCOPE`. An address is named by its hash, the SHA-256 of the
|
|
21
|
+
* address as 64 lower-case hexadecimal characters, which the host computes the same way wherever it reads or writes the
|
|
22
|
+
* record, such as over the address lower-cased. Anything else is refused with a `TypeError`, so an address passed by
|
|
23
|
+
* mistake is never stored and never answered as not suppressed.
|
|
24
|
+
*/
|
|
25
|
+
export interface SuppressionStore {
|
|
26
|
+
/**
|
|
27
|
+
* Whether the address is suppressed in the scope.
|
|
28
|
+
*
|
|
29
|
+
* @param scope The list, 1 to 512 characters.
|
|
30
|
+
* @param addressHash The SHA-256 of the address as 64 lower-case hexadecimal characters.
|
|
31
|
+
*/
|
|
32
|
+
isSuppressed(scope: string, addressHash: string): Promise<boolean>;
|
|
33
|
+
/**
|
|
34
|
+
* Records the address as suppressed in the scope, with the reason and the day, and no end. A second record of the
|
|
35
|
+
* address in the scope writes nothing and leaves the first as it was, whatever its reason or day.
|
|
36
|
+
*
|
|
37
|
+
* @param scope The list, 1 to 512 characters.
|
|
38
|
+
* @param addressHash The SHA-256 of the address as 64 lower-case hexadecimal characters.
|
|
39
|
+
* @param reason Why the address is suppressed.
|
|
40
|
+
* @param day The calendar day of the event, `YYYY-MM-DD`.
|
|
41
|
+
* @returns `true` when this call recorded the address, `false` when the scope held it already.
|
|
42
|
+
*/
|
|
43
|
+
suppress(scope: string, addressHash: string, reason: SuppressionReason, day: string): Promise<boolean>;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The suppression record's table, `<prefix>_suppressions`, for a host's migration: one row an address and scope, with
|
|
47
|
+
* the reason and the day, `CREATE TABLE IF NOT EXISTS` so it can run at each start. It runs on SQLite and on
|
|
48
|
+
* PostgreSQL.
|
|
49
|
+
*
|
|
50
|
+
* @param prefix A lower-case letter followed by at most 39 lower-case letters, digits or underscores.
|
|
51
|
+
* @throws RangeError when the prefix does not fit.
|
|
52
|
+
*/
|
|
53
|
+
export declare function suppressionStoreDdl(prefix: string): string;
|
|
54
|
+
/**
|
|
55
|
+
* The suppression record on a host's SQL database, in the table `suppressionStoreDdl` makes under the same prefix. A
|
|
56
|
+
* record is one `INSERT ... ON CONFLICT DO NOTHING`, so two hosts recording one address at once leave one row. Each
|
|
57
|
+
* argument is checked before any statement, and an error from the database reaches the caller as it was thrown: no
|
|
58
|
+
* failure is answered as `false`.
|
|
59
|
+
*/
|
|
60
|
+
export declare class SqlSuppressionStore implements SuppressionStore {
|
|
61
|
+
private readonly db;
|
|
62
|
+
private readonly table;
|
|
63
|
+
/**
|
|
64
|
+
* @param db The database: `@framers/sql-storage-adapter`'s adapter, or any handle of `EmailPreferencesDb`'s shape.
|
|
65
|
+
* @param prefix The prefix the table was made under.
|
|
66
|
+
* @throws RangeError when the prefix does not fit.
|
|
67
|
+
*/
|
|
68
|
+
constructor(db: EmailPreferencesDb, prefix: string);
|
|
69
|
+
isSuppressed(scope: string, addressHash: string): Promise<boolean>;
|
|
70
|
+
suppress(scope: string, addressHash: string, reason: SuppressionReason, day: string): Promise<boolean>;
|
|
71
|
+
}
|
|
72
|
+
//# sourceMappingURL=SuppressionStore.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"SuppressionStore.d.ts","sourceRoot":"","sources":["../../src/preferences/SuppressionStore.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAC;AAGxD,gFAAgF;AAChF,eAAO,MAAM,mBAAmB,0DAA2D,CAAC;AAE5F;;;;GAIG;AACH,MAAM,MAAM,iBAAiB,GAAG,CAAC,OAAO,mBAAmB,CAAC,CAAC,MAAM,CAAC,CAAC;AAErE,gGAAgG;AAChG,eAAO,MAAM,yBAAyB,SAAS,CAAC;AAEhD;;;;;;GAMG;AACH,MAAM,WAAW,gBAAgB;IAC/B;;;;;OAKG;IACH,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAEnE;;;;;;;;;OASG;IACH,QAAQ,CAAC,KAAK,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,MAAM,EAAE,iBAAiB,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CACxG;AAwCD;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAW1D;AAED;;;;;GAKG;AACH,qBAAa,mBAAoB,YAAW,gBAAgB;IASxD,OAAO,CAAC,QAAQ,CAAC,EAAE;IARrB,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAS;IAE/B;;;;OAIG;gBAEgB,EAAE,EAAE,kBAAkB,EACvC,MAAM,EAAE,MAAM;IAKV,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IASlE,QAAQ,CAAC,KAAK,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,MAAM,EAAE,iBAAiB,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;CAS7G"}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SqlSuppressionStore = exports.DEFAULT_SUPPRESSION_SCOPE = exports.SUPPRESSION_REASONS = void 0;
|
|
4
|
+
exports.suppressionStoreDdl = suppressionStoreDdl;
|
|
5
|
+
const database_js_1 = require("./database.js");
|
|
6
|
+
/** The reasons an address is suppressed, in the order the README lists them. */
|
|
7
|
+
exports.SUPPRESSION_REASONS = ['bounce', 'complaint', 'suppressed', 'manual'];
|
|
8
|
+
/** The scope of a host that keeps one list: every reader and writer of the record passes it. */
|
|
9
|
+
exports.DEFAULT_SUPPRESSION_SCOPE = 'mail';
|
|
10
|
+
/** The SHA-256 of an address in lower-case hexadecimal. */
|
|
11
|
+
const ADDRESS_HASH = /^[0-9a-f]{64}$/;
|
|
12
|
+
/** A calendar day, `YYYY-MM-DD`. */
|
|
13
|
+
const DAY = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
14
|
+
/**
|
|
15
|
+
* Returns the hash when it is 64 lower-case hexadecimal characters, and otherwise throws a `TypeError` that never holds
|
|
16
|
+
* the value.
|
|
17
|
+
*/
|
|
18
|
+
function requireAddressHash(value) {
|
|
19
|
+
if (typeof value === 'string' && ADDRESS_HASH.test(value))
|
|
20
|
+
return value;
|
|
21
|
+
throw new TypeError('The address hash was refused: give the SHA-256 of the address as 64 lower-case hexadecimal characters');
|
|
22
|
+
}
|
|
23
|
+
/** Returns the reason when it is one of `SUPPRESSION_REASONS`, and otherwise throws a `TypeError`. */
|
|
24
|
+
function requireReason(value) {
|
|
25
|
+
if (typeof value === 'string' && exports.SUPPRESSION_REASONS.includes(value)) {
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
throw new TypeError('The reason was refused: give bounce, complaint, suppressed or manual');
|
|
29
|
+
}
|
|
30
|
+
/** Returns the day when it is a calendar date written `YYYY-MM-DD`, and otherwise throws a `TypeError`. */
|
|
31
|
+
function requireDay(value) {
|
|
32
|
+
if (typeof value === 'string') {
|
|
33
|
+
const parts = DAY.exec(value);
|
|
34
|
+
// A date that does not exist, such as the 30th of February, comes back from Date.UTC as another day.
|
|
35
|
+
if (parts !== null &&
|
|
36
|
+
new Date(Date.UTC(Number(parts[1]), Number(parts[2]) - 1, Number(parts[3]))).toISOString().slice(0, 10) === value) {
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
throw new TypeError('The day was refused: give a calendar date as YYYY-MM-DD');
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The suppression record's table, `<prefix>_suppressions`, for a host's migration: one row an address and scope, with
|
|
44
|
+
* the reason and the day, `CREATE TABLE IF NOT EXISTS` so it can run at each start. It runs on SQLite and on
|
|
45
|
+
* PostgreSQL.
|
|
46
|
+
*
|
|
47
|
+
* @param prefix A lower-case letter followed by at most 39 lower-case letters, digits or underscores.
|
|
48
|
+
* @throws RangeError when the prefix does not fit.
|
|
49
|
+
*/
|
|
50
|
+
function suppressionStoreDdl(prefix) {
|
|
51
|
+
const table = `${(0, database_js_1.requirePrefix)(prefix)}_suppressions`;
|
|
52
|
+
return [
|
|
53
|
+
`CREATE TABLE IF NOT EXISTS ${table} (`,
|
|
54
|
+
' scope TEXT NOT NULL,',
|
|
55
|
+
' address_hash TEXT NOT NULL,',
|
|
56
|
+
' reason TEXT NOT NULL,',
|
|
57
|
+
' suppressed_on TEXT NOT NULL,',
|
|
58
|
+
' PRIMARY KEY (scope, address_hash)',
|
|
59
|
+
');',
|
|
60
|
+
].join('\n');
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The suppression record on a host's SQL database, in the table `suppressionStoreDdl` makes under the same prefix. A
|
|
64
|
+
* record is one `INSERT ... ON CONFLICT DO NOTHING`, so two hosts recording one address at once leave one row. Each
|
|
65
|
+
* argument is checked before any statement, and an error from the database reaches the caller as it was thrown: no
|
|
66
|
+
* failure is answered as `false`.
|
|
67
|
+
*/
|
|
68
|
+
class SqlSuppressionStore {
|
|
69
|
+
/**
|
|
70
|
+
* @param db The database: `@framers/sql-storage-adapter`'s adapter, or any handle of `EmailPreferencesDb`'s shape.
|
|
71
|
+
* @param prefix The prefix the table was made under.
|
|
72
|
+
* @throws RangeError when the prefix does not fit.
|
|
73
|
+
*/
|
|
74
|
+
constructor(db, prefix) {
|
|
75
|
+
this.db = db;
|
|
76
|
+
this.table = `${(0, database_js_1.requirePrefix)(prefix)}_suppressions`;
|
|
77
|
+
}
|
|
78
|
+
async isSuppressed(scope, addressHash) {
|
|
79
|
+
const params = [(0, database_js_1.requireName)('scope', scope), requireAddressHash(addressHash)];
|
|
80
|
+
const row = await this.db.get(`SELECT 1 AS hit FROM ${this.table} WHERE scope = ? AND address_hash = ?`, params);
|
|
81
|
+
return row !== null && row !== undefined;
|
|
82
|
+
}
|
|
83
|
+
async suppress(scope, addressHash, reason, day) {
|
|
84
|
+
const params = [(0, database_js_1.requireName)('scope', scope), requireAddressHash(addressHash), requireReason(reason), requireDay(day)];
|
|
85
|
+
const result = await this.db.run(`INSERT INTO ${this.table} (scope, address_hash, reason, suppressed_on) VALUES (?, ?, ?, ?) ` +
|
|
86
|
+
'ON CONFLICT (scope, address_hash) DO NOTHING', params);
|
|
87
|
+
return result.changes === 1;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
exports.SqlSuppressionStore = SqlSuppressionStore;
|
|
91
|
+
//# sourceMappingURL=SuppressionStore.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"SuppressionStore.js","sourceRoot":"","sources":["../../src/preferences/SuppressionStore.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview What the preference stores need of a database, and the checks of the table prefix and the names they
|
|
3
|
+
* take. Nothing here imports a database driver: the host passes its own handle.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* What `SqlSuppressionStore` and `SqlRecipientCapability` need of a database: a statement that answers the number of
|
|
7
|
+
* rows it changed, and a query that answers its first row, both with `?` placeholders and positional parameters.
|
|
8
|
+
* `@framers/sql-storage-adapter`'s `StorageAdapter` has this shape on SQLite and on PostgreSQL, and so does the handle
|
|
9
|
+
* its `transaction` gives a callback, so a store made on that handle writes inside the transaction.
|
|
10
|
+
*/
|
|
11
|
+
export interface EmailPreferencesDb {
|
|
12
|
+
/** Runs one statement; `changes` is the number of rows it inserted, updated or deleted. */
|
|
13
|
+
run(sql: string, params?: unknown[]): Promise<{
|
|
14
|
+
changes?: number;
|
|
15
|
+
}>;
|
|
16
|
+
/** Runs one query and answers its first row, or `null` (or `undefined`) when it has none. */
|
|
17
|
+
get<T>(sql: string, params?: unknown[]): Promise<T | null | undefined>;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Returns the prefix when it is a lower-case letter followed by at most 39 lower-case letters, digits or underscores,
|
|
21
|
+
* and otherwise throws a `RangeError`. The prefix is written into the SQL as it is, so nothing else passes.
|
|
22
|
+
*
|
|
23
|
+
* @param prefix The table prefix the caller gave.
|
|
24
|
+
*/
|
|
25
|
+
export declare function requirePrefix(prefix: unknown): string;
|
|
26
|
+
/**
|
|
27
|
+
* Returns `value` when it is a string of 1 to 512 characters, and otherwise throws a `TypeError` that names `field` and
|
|
28
|
+
* never the value.
|
|
29
|
+
*
|
|
30
|
+
* @param field The argument's name, such as `scope`.
|
|
31
|
+
* @param value The value the caller gave.
|
|
32
|
+
*/
|
|
33
|
+
export declare function requireName(field: string, value: unknown): string;
|
|
34
|
+
//# sourceMappingURL=database.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"database.d.ts","sourceRoot":"","sources":["../../src/preferences/database.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH;;;;;GAKG;AACH,MAAM,WAAW,kBAAkB;IACjC,2FAA2F;IAC3F,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,EAAE,GAAG,OAAO,CAAC;QAAE,OAAO,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACpE,6FAA6F;IAC7F,GAAG,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,EAAE,GAAG,OAAO,CAAC,CAAC,GAAG,IAAI,GAAG,SAAS,CAAC,CAAC;CACxE;AAKD;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,OAAO,GAAG,MAAM,CAKrD;AAED;;;;;;GAMG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,MAAM,CAGjE"}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @fileoverview What the preference stores need of a database, and the checks of the table prefix and the names they
|
|
4
|
+
* take. Nothing here imports a database driver: the host passes its own handle.
|
|
5
|
+
*/
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
exports.requirePrefix = requirePrefix;
|
|
8
|
+
exports.requireName = requireName;
|
|
9
|
+
/** A table prefix: a lower-case letter, then at most 39 lower-case letters, digits or underscores. */
|
|
10
|
+
const PREFIX = /^[a-z][a-z0-9_]{0,39}$/;
|
|
11
|
+
/**
|
|
12
|
+
* Returns the prefix when it is a lower-case letter followed by at most 39 lower-case letters, digits or underscores,
|
|
13
|
+
* and otherwise throws a `RangeError`. The prefix is written into the SQL as it is, so nothing else passes.
|
|
14
|
+
*
|
|
15
|
+
* @param prefix The table prefix the caller gave.
|
|
16
|
+
*/
|
|
17
|
+
function requirePrefix(prefix) {
|
|
18
|
+
if (typeof prefix === 'string' && PREFIX.test(prefix))
|
|
19
|
+
return prefix;
|
|
20
|
+
throw new RangeError('The table prefix was refused: give a lower-case letter followed by at most 39 lower-case letters, digits or underscores');
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Returns `value` when it is a string of 1 to 512 characters, and otherwise throws a `TypeError` that names `field` and
|
|
24
|
+
* never the value.
|
|
25
|
+
*
|
|
26
|
+
* @param field The argument's name, such as `scope`.
|
|
27
|
+
* @param value The value the caller gave.
|
|
28
|
+
*/
|
|
29
|
+
function requireName(field, value) {
|
|
30
|
+
if (typeof value === 'string' && value.length > 0 && value.length <= 512)
|
|
31
|
+
return value;
|
|
32
|
+
throw new TypeError(`The ${field} was refused: give it as a string of 1 to 512 characters`);
|
|
33
|
+
}
|
|
34
|
+
//# sourceMappingURL=database.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"database.js","sourceRoot":"","sources":["../../src/preferences/database.ts"],"names":[],"mappings":";AAAA;;;GAGG;;AAwBH,sCAKC;AASD,kCAGC;AA1BD,sGAAsG;AACtG,MAAM,MAAM,GAAG,wBAAwB,CAAC;AAExC;;;;;GAKG;AACH,SAAgB,aAAa,CAAC,MAAe;IAC3C,IAAI,OAAO,MAAM,KAAK,QAAQ,IAAI,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC;QAAE,OAAO,MAAM,CAAC;IACrE,MAAM,IAAI,UAAU,CAClB,yHAAyH,CAC1H,CAAC;AACJ,CAAC;AAED;;;;;;GAMG;AACH,SAAgB,WAAW,CAAC,KAAa,EAAE,KAAc;IACvD,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,CAAC,MAAM,GAAG,CAAC,IAAI,KAAK,CAAC,MAAM,IAAI,GAAG;QAAE,OAAO,KAAK,CAAC;IACvF,MAAM,IAAI,SAAS,CAAC,OAAO,KAAK,0DAA0D,CAAC,CAAC;AAC9F,CAAC"}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type { EmailPreferencesDb } from './database.js';
|
|
2
|
+
export { DEFAULT_SUPPRESSION_SCOPE, SUPPRESSION_REASONS, SqlSuppressionStore, suppressionStoreDdl } from './SuppressionStore.js';
|
|
3
|
+
export type { SuppressionReason, SuppressionStore } from './SuppressionStore.js';
|
|
4
|
+
export { SqlRecipientCapability, recipientCapabilityDdl } from './RecipientCapability.js';
|
|
5
|
+
export type { RecipientCapability, RecipientCapabilityIssueOptions, RecipientGrant, SqlRecipientCapabilityOptions, } from './RecipientCapability.js';
|
|
6
|
+
/**
|
|
7
|
+
* Both tables' DDL under one prefix, the suppression record's then the recipient capabilities', for a host whose one
|
|
8
|
+
* migration makes both. A host whose tables come from different migrations runs `suppressionStoreDdl` and
|
|
9
|
+
* `recipientCapabilityDdl` where each belongs.
|
|
10
|
+
*
|
|
11
|
+
* @param prefix A lower-case letter followed by at most 39 lower-case letters, digits or underscores.
|
|
12
|
+
* @throws RangeError when the prefix does not fit.
|
|
13
|
+
*/
|
|
14
|
+
export declare function emailPreferencesDdl(prefix: string): string;
|
|
15
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/preferences/index.ts"],"names":[],"mappings":"AAOA,YAAY,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAC;AACxD,OAAO,EAAE,yBAAyB,EAAE,mBAAmB,EAAE,mBAAmB,EAAE,mBAAmB,EAAE,MAAM,uBAAuB,CAAC;AACjI,YAAY,EAAE,iBAAiB,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AACjF,OAAO,EAAE,sBAAsB,EAAE,sBAAsB,EAAE,MAAM,0BAA0B,CAAC;AAC1F,YAAY,EACV,mBAAmB,EACnB,+BAA+B,EAC/B,cAAc,EACd,6BAA6B,GAC9B,MAAM,0BAA0B,CAAC;AAElC;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAE1D"}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.recipientCapabilityDdl = exports.SqlRecipientCapability = exports.suppressionStoreDdl = exports.SqlSuppressionStore = exports.SUPPRESSION_REASONS = exports.DEFAULT_SUPPRESSION_SCOPE = void 0;
|
|
4
|
+
exports.emailPreferencesDdl = emailPreferencesDdl;
|
|
5
|
+
/**
|
|
6
|
+
* @fileoverview The email pack's preference stores: the suppression record and recipient capabilities, each on a
|
|
7
|
+
* host's SQL database under a table prefix.
|
|
8
|
+
*/
|
|
9
|
+
const RecipientCapability_js_1 = require("./RecipientCapability.js");
|
|
10
|
+
const SuppressionStore_js_1 = require("./SuppressionStore.js");
|
|
11
|
+
var SuppressionStore_js_2 = require("./SuppressionStore.js");
|
|
12
|
+
Object.defineProperty(exports, "DEFAULT_SUPPRESSION_SCOPE", { enumerable: true, get: function () { return SuppressionStore_js_2.DEFAULT_SUPPRESSION_SCOPE; } });
|
|
13
|
+
Object.defineProperty(exports, "SUPPRESSION_REASONS", { enumerable: true, get: function () { return SuppressionStore_js_2.SUPPRESSION_REASONS; } });
|
|
14
|
+
Object.defineProperty(exports, "SqlSuppressionStore", { enumerable: true, get: function () { return SuppressionStore_js_2.SqlSuppressionStore; } });
|
|
15
|
+
Object.defineProperty(exports, "suppressionStoreDdl", { enumerable: true, get: function () { return SuppressionStore_js_2.suppressionStoreDdl; } });
|
|
16
|
+
var RecipientCapability_js_2 = require("./RecipientCapability.js");
|
|
17
|
+
Object.defineProperty(exports, "SqlRecipientCapability", { enumerable: true, get: function () { return RecipientCapability_js_2.SqlRecipientCapability; } });
|
|
18
|
+
Object.defineProperty(exports, "recipientCapabilityDdl", { enumerable: true, get: function () { return RecipientCapability_js_2.recipientCapabilityDdl; } });
|
|
19
|
+
/**
|
|
20
|
+
* Both tables' DDL under one prefix, the suppression record's then the recipient capabilities', for a host whose one
|
|
21
|
+
* migration makes both. A host whose tables come from different migrations runs `suppressionStoreDdl` and
|
|
22
|
+
* `recipientCapabilityDdl` where each belongs.
|
|
23
|
+
*
|
|
24
|
+
* @param prefix A lower-case letter followed by at most 39 lower-case letters, digits or underscores.
|
|
25
|
+
* @throws RangeError when the prefix does not fit.
|
|
26
|
+
*/
|
|
27
|
+
function emailPreferencesDdl(prefix) {
|
|
28
|
+
return `${(0, SuppressionStore_js_1.suppressionStoreDdl)(prefix)}\n${(0, RecipientCapability_js_1.recipientCapabilityDdl)(prefix)}`;
|
|
29
|
+
}
|
|
30
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/preferences/index.ts"],"names":[],"mappings":";;;AA0BA,kDAEC;AA5BD;;;GAGG;AACH,qEAAkE;AAClE,+DAA4D;AAG5D,6DAAiI;AAAxH,gIAAA,yBAAyB,OAAA;AAAE,0HAAA,mBAAmB,OAAA;AAAE,0HAAA,mBAAmB,OAAA;AAAE,0HAAA,mBAAmB,OAAA;AAEjG,mEAA0F;AAAjF,gIAAA,sBAAsB,OAAA;AAAE,gIAAA,sBAAsB,OAAA;AAQvD;;;;;;;GAOG;AACH,SAAgB,mBAAmB,CAAC,MAAc;IAChD,OAAO,GAAG,IAAA,yCAAmB,EAAC,MAAM,CAAC,KAAK,IAAA,+CAAsB,EAAC,MAAM,CAAC,EAAE,CAAC;AAC7E,CAAC"}
|
package/manifest.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://agentos.sh/schemas/extension-manifest-v1.json",
|
|
3
3
|
"id": "com.framers.channels.email",
|
|
4
4
|
"name": "Email Channel Extension",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.4.1",
|
|
6
6
|
"description": "Bidirectional email integration: send through SMTP or Resend's HTTPS API, and read, search, extract verification codes and reply through IMAP.",
|
|
7
7
|
"categories": ["channel", "communication"],
|
|
8
8
|
"platforms": ["email"],
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"Read inbox and other IMAP folders",
|
|
23
23
|
"Search emails by query, folder, and date range",
|
|
24
24
|
"Extract verification codes and OTPs from email bodies",
|
|
25
|
-
"Reply to email threads maintaining conversation context"
|
|
25
|
+
"Reply to email threads maintaining conversation context",
|
|
26
|
+
"Keep a suppression record and opaque unsubscribe tokens, stored as hashes, on a SQL database"
|
|
26
27
|
]
|
|
27
28
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@framers/agentos-ext-channel-email",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Email channel adapter for AgentOS: send through SMTP or Resend's HTTPS API
|
|
3
|
+
"version": "0.4.1",
|
|
4
|
+
"description": "Email channel adapter for AgentOS: send through SMTP or Resend's HTTPS API; read, search, extract codes and reply through IMAP; keep a suppression record and unsubscribe tokens on a SQL database.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
7
7
|
"license": "Apache-2.0",
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"nodemailer": "^10.0.16"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|
|
25
|
+
"@framers/sql-storage-adapter": "^0.6.14",
|
|
25
26
|
"@types/node": "^20.0.0",
|
|
26
27
|
"typescript": "^5.0.0",
|
|
27
28
|
"vitest": "^3.2.7",
|