@vllnt/convex-email 0.1.0-canary.63aca6b
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/LICENSE +21 -0
- package/README.md +171 -0
- package/dist/client/index.d.ts +174 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +151 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/types.d.ts +84 -0
- package/dist/client/types.d.ts.map +1 -0
- package/dist/client/types.js +3 -0
- package/dist/client/types.js.map +1 -0
- package/dist/component/_generated/api.d.ts +40 -0
- package/dist/component/_generated/api.d.ts.map +1 -0
- package/dist/component/_generated/api.js +31 -0
- package/dist/component/_generated/api.js.map +1 -0
- package/dist/component/_generated/component.d.ts +110 -0
- package/dist/component/_generated/component.d.ts.map +1 -0
- package/dist/component/_generated/component.js +11 -0
- package/dist/component/_generated/component.js.map +1 -0
- package/dist/component/_generated/dataModel.d.ts +46 -0
- package/dist/component/_generated/dataModel.d.ts.map +1 -0
- package/dist/component/_generated/dataModel.js +11 -0
- package/dist/component/_generated/dataModel.js.map +1 -0
- package/dist/component/_generated/server.d.ts +121 -0
- package/dist/component/_generated/server.d.ts.map +1 -0
- package/dist/component/_generated/server.js +78 -0
- package/dist/component/_generated/server.js.map +1 -0
- package/dist/component/convex.config.d.ts +3 -0
- package/dist/component/convex.config.d.ts.map +1 -0
- package/dist/component/convex.config.js +7 -0
- package/dist/component/convex.config.js.map +1 -0
- package/dist/component/crons.d.ts +16 -0
- package/dist/component/crons.d.ts.map +1 -0
- package/dist/component/crons.js +19 -0
- package/dist/component/crons.js.map +1 -0
- package/dist/component/mutations.d.ts +95 -0
- package/dist/component/mutations.d.ts.map +1 -0
- package/dist/component/mutations.js +243 -0
- package/dist/component/mutations.js.map +1 -0
- package/dist/component/queries.d.ts +59 -0
- package/dist/component/queries.d.ts.map +1 -0
- package/dist/component/queries.js +61 -0
- package/dist/component/queries.js.map +1 -0
- package/dist/component/schema.d.ts +54 -0
- package/dist/component/schema.d.ts.map +1 -0
- package/dist/component/schema.js +40 -0
- package/dist/component/schema.js.map +1 -0
- package/dist/component/validators.d.ts +54 -0
- package/dist/component/validators.d.ts.map +1 -0
- package/dist/component/validators.js +40 -0
- package/dist/component/validators.js.map +1 -0
- package/dist/jmap/index.d.ts +22 -0
- package/dist/jmap/index.d.ts.map +1 -0
- package/dist/jmap/index.js +21 -0
- package/dist/jmap/index.js.map +1 -0
- package/dist/jmap/send.d.ts +125 -0
- package/dist/jmap/send.d.ts.map +1 -0
- package/dist/jmap/send.js +418 -0
- package/dist/jmap/send.js.map +1 -0
- package/dist/jmap/types.d.ts +107 -0
- package/dist/jmap/types.d.ts.map +1 -0
- package/dist/jmap/types.js +16 -0
- package/dist/jmap/types.js.map +1 -0
- package/dist/shared.d.ts +32 -0
- package/dist/shared.d.ts.map +1 -0
- package/dist/shared.js +33 -0
- package/dist/shared.js.map +1 -0
- package/dist/smtp/index.d.ts +22 -0
- package/dist/smtp/index.d.ts.map +1 -0
- package/dist/smtp/index.js +21 -0
- package/dist/smtp/index.js.map +1 -0
- package/dist/smtp/send.d.ts +51 -0
- package/dist/smtp/send.d.ts.map +1 -0
- package/dist/smtp/send.js +124 -0
- package/dist/smtp/send.js.map +1 -0
- package/dist/smtp/transport.d.ts +43 -0
- package/dist/smtp/transport.d.ts.map +1 -0
- package/dist/smtp/transport.js +55 -0
- package/dist/smtp/transport.js.map +1 -0
- package/dist/smtp/types.d.ts +122 -0
- package/dist/smtp/types.d.ts.map +1 -0
- package/dist/smtp/types.js +9 -0
- package/dist/smtp/types.js.map +1 -0
- package/package.json +118 -0
- package/src/client/index.ts +312 -0
- package/src/client/types.ts +90 -0
- package/src/component/_generated/api.ts +56 -0
- package/src/component/_generated/component.ts +134 -0
- package/src/component/_generated/dataModel.ts +60 -0
- package/src/component/_generated/server.ts +156 -0
- package/src/component/convex.config.ts +9 -0
- package/src/component/crons.ts +23 -0
- package/src/component/mutations.ts +262 -0
- package/src/component/queries.ts +70 -0
- package/src/component/schema.ts +40 -0
- package/src/component/validators.ts +47 -0
- package/src/jmap/index.ts +39 -0
- package/src/jmap/send.test.ts +565 -0
- package/src/jmap/send.ts +502 -0
- package/src/jmap/types.ts +117 -0
- package/src/shared.ts +41 -0
- package/src/smtp/index.ts +30 -0
- package/src/smtp/send.test.ts +240 -0
- package/src/smtp/send.ts +154 -0
- package/src/smtp/transport.ts +58 -0
- package/src/smtp/types.ts +124 -0
- package/src/test.ts +12 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public TypeScript surface for the optional generic-SMTP transport adapter.
|
|
3
|
+
*
|
|
4
|
+
* Import this from your own `"use node"` action to send queued messages over any
|
|
5
|
+
* SMTP server. The component itself never sends — it records the message and its
|
|
6
|
+
* status; the SMTP server is host config.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Generic SMTP connection config — works with Stalwart, Postfix, or any SMTP
|
|
10
|
+
* server. Host-supplied; mirrors the subset of nodemailer's transport options
|
|
11
|
+
* this adapter drives.
|
|
12
|
+
*/
|
|
13
|
+
export interface SmtpConfig {
|
|
14
|
+
/** SMTP server hostname (e.g. `"smtp.example.com"`, a Stalwart host, a Postfix relay). */
|
|
15
|
+
host: string;
|
|
16
|
+
/** SMTP server port (commonly 465 for implicit TLS, 587 for STARTTLS, 25 for relay). */
|
|
17
|
+
port: number;
|
|
18
|
+
/**
|
|
19
|
+
* Use implicit TLS on connect (`true` ⇒ typically port 465). When `false`,
|
|
20
|
+
* nodemailer upgrades via STARTTLS if the server offers it. Defaults to `true`
|
|
21
|
+
* when the port is 465, else `false` — set it explicitly to be unambiguous.
|
|
22
|
+
*/
|
|
23
|
+
secure?: boolean;
|
|
24
|
+
/** SMTP AUTH credentials. Omit for an unauthenticated relay (e.g. a localhost MTA). */
|
|
25
|
+
auth?: {
|
|
26
|
+
/** SMTP AUTH username. */
|
|
27
|
+
user: string;
|
|
28
|
+
/** SMTP AUTH password (a secret — keep it server-side; never ships to a client). */
|
|
29
|
+
pass: string;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Default `From` address used when a {@link SmtpMessage} omits `from`. The host
|
|
33
|
+
* supplies the opaque address; the adapter never invents one.
|
|
34
|
+
*/
|
|
35
|
+
from?: string;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* A single outbound message handed to the SMTP transport. Mirrors the queue's
|
|
39
|
+
* stored fields (`to`/`from` plus the rendered body) without coupling to the
|
|
40
|
+
* component's storage shape — the host maps a {@link MessageView} payload onto it.
|
|
41
|
+
*/
|
|
42
|
+
export interface SmtpMessage {
|
|
43
|
+
/** The recipient address (one address or a comma-separated list). */
|
|
44
|
+
to: string;
|
|
45
|
+
/** The sender address; falls back to {@link SmtpConfig.from} when omitted. */
|
|
46
|
+
from?: string;
|
|
47
|
+
/** The message subject line. */
|
|
48
|
+
subject?: string;
|
|
49
|
+
/** The plain-text body. At least one of `text`/`html` should be set. */
|
|
50
|
+
text?: string;
|
|
51
|
+
/** The HTML body. At least one of `text`/`html` should be set. */
|
|
52
|
+
html?: string;
|
|
53
|
+
/** Optional `Reply-To` address. */
|
|
54
|
+
replyTo?: string;
|
|
55
|
+
/** Optional extra SMTP headers (host-supplied, opaque to the adapter). */
|
|
56
|
+
headers?: Record<string, string>;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The injected transport seam — the minimal surface {@link sendViaSmtp} drives.
|
|
60
|
+
* The real implementation is a `nodemailer` transporter; a fake one satisfies the
|
|
61
|
+
* same shape in tests, so the pure send logic is 100%-coverable with no network.
|
|
62
|
+
*/
|
|
63
|
+
export interface SmtpTransport {
|
|
64
|
+
/**
|
|
65
|
+
* Dispatch one message. Returns the transport's own send result; `sendViaSmtp`
|
|
66
|
+
* normalizes it to a {@link SmtpSendResult}. Throws on a send failure (the host
|
|
67
|
+
* catches it and calls `markFailed`).
|
|
68
|
+
*/
|
|
69
|
+
sendMail(options: SmtpMailOptions): Promise<SmtpSendInfo>;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The mail options passed to {@link SmtpTransport.sendMail} — the nodemailer
|
|
73
|
+
* `sendMail` argument shape, narrowed to the fields this adapter sets. Declared
|
|
74
|
+
* locally so the public surface stays dependency-free (no `@types/nodemailer` in
|
|
75
|
+
* the published types).
|
|
76
|
+
*/
|
|
77
|
+
export interface SmtpMailOptions {
|
|
78
|
+
to: string;
|
|
79
|
+
from: string;
|
|
80
|
+
subject?: string;
|
|
81
|
+
text?: string;
|
|
82
|
+
html?: string;
|
|
83
|
+
replyTo?: string;
|
|
84
|
+
headers?: Record<string, string>;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The raw result a transport's `sendMail` resolves with — the nodemailer
|
|
88
|
+
* `SentMessageInfo` subset this adapter reads. `accepted`/`rejected` are address
|
|
89
|
+
* lists; `messageId` is the SMTP message handle recorded as the queue's `providerId`.
|
|
90
|
+
*/
|
|
91
|
+
export interface SmtpSendInfo {
|
|
92
|
+
/** The SMTP message id assigned by the server (recorded as `providerId`). */
|
|
93
|
+
messageId?: string;
|
|
94
|
+
/** Addresses the server accepted. */
|
|
95
|
+
accepted?: ReadonlyArray<string | {
|
|
96
|
+
address: string;
|
|
97
|
+
}>;
|
|
98
|
+
/** Addresses the server rejected. */
|
|
99
|
+
rejected?: ReadonlyArray<string | {
|
|
100
|
+
address: string;
|
|
101
|
+
}>;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* The normalized result of {@link sendViaSmtp}. `messageId` is the transport's
|
|
105
|
+
* own handle (store it as the queue's `providerId` on `markSent`); `accepted` /
|
|
106
|
+
* `rejected` are flattened address lists.
|
|
107
|
+
*/
|
|
108
|
+
export interface SmtpSendResult {
|
|
109
|
+
/** The SMTP message handle, or `""` when the transport returned none. */
|
|
110
|
+
messageId: string;
|
|
111
|
+
/** Addresses the server accepted. */
|
|
112
|
+
accepted: string[];
|
|
113
|
+
/** Addresses the server rejected (non-empty even on a resolved send is a partial failure). */
|
|
114
|
+
rejected: string[];
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* A bound sender: a function that sends one {@link SmtpMessage} through a
|
|
118
|
+
* preconfigured transport. {@link createSmtpSender} returns one over a real
|
|
119
|
+
* `nodemailer` transport; the host calls it inside its own `"use node"` action.
|
|
120
|
+
*/
|
|
121
|
+
export type SmtpSender = (message: SmtpMessage) => Promise<SmtpSendResult>;
|
|
122
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/smtp/types.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH;;;;GAIG;AACH,MAAM,WAAW,UAAU;IACzB,0FAA0F;IAC1F,IAAI,EAAE,MAAM,CAAC;IACb,wFAAwF;IACxF,IAAI,EAAE,MAAM,CAAC;IACb;;;;OAIG;IACH,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,uFAAuF;IACvF,IAAI,CAAC,EAAE;QACL,0BAA0B;QAC1B,IAAI,EAAE,MAAM,CAAC;QACb,oFAAoF;QACpF,IAAI,EAAE,MAAM,CAAC;KACd,CAAC;IACF;;;OAGG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED;;;;GAIG;AACH,MAAM,WAAW,WAAW;IAC1B,qEAAqE;IACrE,EAAE,EAAE,MAAM,CAAC;IACX,8EAA8E;IAC9E,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,gCAAgC;IAChC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,wEAAwE;IACxE,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,kEAAkE;IAClE,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,mCAAmC;IACnC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,0EAA0E;IAC1E,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAClC;AAED;;;;GAIG;AACH,MAAM,WAAW,aAAa;IAC5B;;;;OAIG;IACH,QAAQ,CAAC,OAAO,EAAE,eAAe,GAAG,OAAO,CAAC,YAAY,CAAC,CAAC;CAC3D;AAED;;;;;GAKG;AACH,MAAM,WAAW,eAAe;IAC9B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAClC;AAED;;;;GAIG;AACH,MAAM,WAAW,YAAY;IAC3B,6EAA6E;IAC7E,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,qCAAqC;IACrC,QAAQ,CAAC,EAAE,aAAa,CAAC,MAAM,GAAG;QAAE,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACvD,qCAAqC;IACrC,QAAQ,CAAC,EAAE,aAAa,CAAC,MAAM,GAAG;QAAE,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CACxD;AAED;;;;GAIG;AACH,MAAM,WAAW,cAAc;IAC7B,yEAAyE;IACzE,SAAS,EAAE,MAAM,CAAC;IAClB,qCAAqC;IACrC,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,8FAA8F;IAC9F,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB;AAED;;;;GAIG;AACH,MAAM,MAAM,UAAU,GAAG,CAAC,OAAO,EAAE,WAAW,KAAK,OAAO,CAAC,cAAc,CAAC,CAAC"}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public TypeScript surface for the optional generic-SMTP transport adapter.
|
|
3
|
+
*
|
|
4
|
+
* Import this from your own `"use node"` action to send queued messages over any
|
|
5
|
+
* SMTP server. The component itself never sends — it records the message and its
|
|
6
|
+
* status; the SMTP server is host config.
|
|
7
|
+
*/
|
|
8
|
+
export {};
|
|
9
|
+
//# sourceMappingURL=types.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/smtp/types.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG"}
|
package/package.json
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@vllnt/convex-email",
|
|
3
|
+
"version": "0.1.0-canary.63aca6b",
|
|
4
|
+
"description": "Durable, transport-agnostic outbound transactional email queue — enqueue, retry, idempotent send, and per-message delivery status as a Convex component",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"packageManager": "pnpm@9.15.4",
|
|
8
|
+
"files": [
|
|
9
|
+
"dist",
|
|
10
|
+
"src"
|
|
11
|
+
],
|
|
12
|
+
"exports": {
|
|
13
|
+
"./package.json": "./package.json",
|
|
14
|
+
".": {
|
|
15
|
+
"types": "./dist/client/index.d.ts",
|
|
16
|
+
"default": "./dist/client/index.js"
|
|
17
|
+
},
|
|
18
|
+
"./test": "./src/test.ts",
|
|
19
|
+
"./smtp": {
|
|
20
|
+
"types": "./dist/smtp/index.d.ts",
|
|
21
|
+
"default": "./dist/smtp/index.js"
|
|
22
|
+
},
|
|
23
|
+
"./jmap": {
|
|
24
|
+
"types": "./dist/jmap/index.d.ts",
|
|
25
|
+
"default": "./dist/jmap/index.js"
|
|
26
|
+
},
|
|
27
|
+
"./_generated/component.js": {
|
|
28
|
+
"types": "./dist/component/_generated/component.d.ts"
|
|
29
|
+
},
|
|
30
|
+
"./_generated/component": {
|
|
31
|
+
"types": "./dist/component/_generated/component.d.ts"
|
|
32
|
+
},
|
|
33
|
+
"./convex.config": {
|
|
34
|
+
"types": "./dist/component/convex.config.d.ts",
|
|
35
|
+
"default": "./dist/component/convex.config.js"
|
|
36
|
+
},
|
|
37
|
+
"./convex.config.js": {
|
|
38
|
+
"types": "./dist/component/convex.config.d.ts",
|
|
39
|
+
"default": "./dist/component/convex.config.js"
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
"types": "./dist/client/index.d.ts",
|
|
43
|
+
"module": "./dist/client/index.js",
|
|
44
|
+
"scripts": {
|
|
45
|
+
"build": "tsc --project ./tsconfig.build.json",
|
|
46
|
+
"build:codegen": "pnpm convex codegen --component-dir ./src/component && pnpm build",
|
|
47
|
+
"build:clean": "rm -rf dist *.tsbuildinfo && pnpm build:codegen",
|
|
48
|
+
"typecheck": "tsc --noEmit",
|
|
49
|
+
"typecheck:ci": "tsc --noEmit --project tsconfig.ci.json",
|
|
50
|
+
"lint": "eslint .",
|
|
51
|
+
"test": "vitest run --passWithNoTests",
|
|
52
|
+
"test:watch": "vitest --typecheck --clearScreen false",
|
|
53
|
+
"test:coverage": "vitest run --coverage --coverage.reporter=text",
|
|
54
|
+
"generate:llms": "node scripts/generate-llms.mjs",
|
|
55
|
+
"preversion": "pnpm install --frozen-lockfile && pnpm build && pnpm test:coverage && pnpm typecheck && pnpm generate:llms",
|
|
56
|
+
"prepublishOnly": "npm whoami || npm login",
|
|
57
|
+
"alpha": "npm version prerelease --preid alpha && npm publish --tag alpha && git push --follow-tags",
|
|
58
|
+
"release": "npm version patch && npm publish && git push --follow-tags"
|
|
59
|
+
},
|
|
60
|
+
"dependencies": {},
|
|
61
|
+
"peerDependencies": {
|
|
62
|
+
"convex": "^1.41.0",
|
|
63
|
+
"nodemailer": "^8.0.4"
|
|
64
|
+
},
|
|
65
|
+
"peerDependenciesMeta": {
|
|
66
|
+
"nodemailer": {
|
|
67
|
+
"optional": true
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
"devDependencies": {
|
|
71
|
+
"@edge-runtime/vm": "^5.0.0",
|
|
72
|
+
"@types/nodemailer": "^8.0.1",
|
|
73
|
+
"@vitest/coverage-v8": "^4.1.8",
|
|
74
|
+
"@vllnt/eslint-config": "^1.0.0",
|
|
75
|
+
"@vllnt/typescript": "^1.0.0",
|
|
76
|
+
"convex": "^1.41.0",
|
|
77
|
+
"convex-test": "^0.0.53",
|
|
78
|
+
"eslint": "^9.0.0",
|
|
79
|
+
"nodemailer": "^8.0.11",
|
|
80
|
+
"prettier": "^3.4.0",
|
|
81
|
+
"typescript": "^5.7.0",
|
|
82
|
+
"vitest": "^4.1.8"
|
|
83
|
+
},
|
|
84
|
+
"homepage": "https://github.com/vllnt/convex-email#readme",
|
|
85
|
+
"bugs": {
|
|
86
|
+
"url": "https://github.com/vllnt/convex-email/issues"
|
|
87
|
+
},
|
|
88
|
+
"author": {
|
|
89
|
+
"name": "bntvllnt",
|
|
90
|
+
"url": "https://bntvllnt.com"
|
|
91
|
+
},
|
|
92
|
+
"funding": {
|
|
93
|
+
"type": "github",
|
|
94
|
+
"url": "https://github.com/sponsors/bntvllnt"
|
|
95
|
+
},
|
|
96
|
+
"repository": {
|
|
97
|
+
"type": "git",
|
|
98
|
+
"url": "https://github.com/vllnt/convex-email"
|
|
99
|
+
},
|
|
100
|
+
"engines": {
|
|
101
|
+
"node": ">=18"
|
|
102
|
+
},
|
|
103
|
+
"publishConfig": {
|
|
104
|
+
"access": "public"
|
|
105
|
+
},
|
|
106
|
+
"sideEffects": false,
|
|
107
|
+
"keywords": [
|
|
108
|
+
"convex",
|
|
109
|
+
"convex-component",
|
|
110
|
+
"email",
|
|
111
|
+
"transactional-email",
|
|
112
|
+
"email-queue",
|
|
113
|
+
"transport-agnostic",
|
|
114
|
+
"smtp",
|
|
115
|
+
"jmap",
|
|
116
|
+
"nodemailer"
|
|
117
|
+
]
|
|
118
|
+
}
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
FunctionArgs,
|
|
3
|
+
FunctionReference,
|
|
4
|
+
FunctionReturnType,
|
|
5
|
+
PaginationOptions,
|
|
6
|
+
PaginationResult,
|
|
7
|
+
} from "convex/server";
|
|
8
|
+
import type {
|
|
9
|
+
EmailOptions,
|
|
10
|
+
EnqueueOptions,
|
|
11
|
+
EnqueueResult,
|
|
12
|
+
MarkFailedResult,
|
|
13
|
+
MessageStatus,
|
|
14
|
+
MessageView,
|
|
15
|
+
Parser,
|
|
16
|
+
} from "./types.js";
|
|
17
|
+
import { DEFAULT_MAX_ATTEMPTS, DEFAULT_PRUNE_BATCH } from "../shared.js";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The component's raw message view, before the client narrows the opaque host
|
|
21
|
+
* payload. `payload` is `unknown` here; the {@link Email} client runs the host
|
|
22
|
+
* validator over it at its typed boundary.
|
|
23
|
+
*/
|
|
24
|
+
type RawView = {
|
|
25
|
+
messageId: string;
|
|
26
|
+
to: string;
|
|
27
|
+
from: string;
|
|
28
|
+
transport: string;
|
|
29
|
+
status: MessageStatus;
|
|
30
|
+
payload?: unknown;
|
|
31
|
+
subjectRef?: string;
|
|
32
|
+
idempotencyKey?: string;
|
|
33
|
+
providerId?: string;
|
|
34
|
+
attempts: number;
|
|
35
|
+
maxAttempts: number;
|
|
36
|
+
error?: string;
|
|
37
|
+
createdAt: number;
|
|
38
|
+
updatedAt: number;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The email component's function references, as exposed on the host via
|
|
43
|
+
* `components.email`. The host's stored `payload` is opaque here (`unknown`); the
|
|
44
|
+
* {@link Email} client narrows it at its own typed boundary.
|
|
45
|
+
*/
|
|
46
|
+
export interface EmailComponent {
|
|
47
|
+
mutations: {
|
|
48
|
+
enqueue: FunctionReference<
|
|
49
|
+
"mutation",
|
|
50
|
+
"internal",
|
|
51
|
+
{
|
|
52
|
+
messageId: string;
|
|
53
|
+
to: string;
|
|
54
|
+
from: string;
|
|
55
|
+
transport: string;
|
|
56
|
+
payload?: unknown;
|
|
57
|
+
subjectRef?: string;
|
|
58
|
+
idempotencyKey?: string;
|
|
59
|
+
maxAttempts: number;
|
|
60
|
+
},
|
|
61
|
+
{ messageId: string; deduplicated: boolean }
|
|
62
|
+
>;
|
|
63
|
+
markSending: FunctionReference<
|
|
64
|
+
"mutation",
|
|
65
|
+
"internal",
|
|
66
|
+
{ messageId: string },
|
|
67
|
+
{ attempts: number }
|
|
68
|
+
>;
|
|
69
|
+
markSent: FunctionReference<
|
|
70
|
+
"mutation",
|
|
71
|
+
"internal",
|
|
72
|
+
{ messageId: string; providerId?: string },
|
|
73
|
+
null
|
|
74
|
+
>;
|
|
75
|
+
markFailed: FunctionReference<
|
|
76
|
+
"mutation",
|
|
77
|
+
"internal",
|
|
78
|
+
{ messageId: string; error?: string },
|
|
79
|
+
{ status: "queued" | "failed"; retried: boolean }
|
|
80
|
+
>;
|
|
81
|
+
prune: FunctionReference<
|
|
82
|
+
"mutation",
|
|
83
|
+
"internal",
|
|
84
|
+
{ before?: number; batch: number },
|
|
85
|
+
number
|
|
86
|
+
>;
|
|
87
|
+
};
|
|
88
|
+
queries: {
|
|
89
|
+
get: FunctionReference<
|
|
90
|
+
"query",
|
|
91
|
+
"internal",
|
|
92
|
+
{ messageId: string },
|
|
93
|
+
RawView | null
|
|
94
|
+
>;
|
|
95
|
+
listByStatus: FunctionReference<
|
|
96
|
+
"query",
|
|
97
|
+
"internal",
|
|
98
|
+
{ status: MessageStatus; paginationOpts: PaginationOptions },
|
|
99
|
+
PaginationResult<RawView>
|
|
100
|
+
>;
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
interface RunQueryCtx {
|
|
105
|
+
runQuery<Q extends FunctionReference<"query", "internal">>(
|
|
106
|
+
reference: Q,
|
|
107
|
+
args: FunctionArgs<Q>,
|
|
108
|
+
): Promise<FunctionReturnType<Q>>;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
interface RunMutationCtx {
|
|
112
|
+
runMutation<M extends FunctionReference<"mutation", "internal">>(
|
|
113
|
+
reference: M,
|
|
114
|
+
args: FunctionArgs<M>,
|
|
115
|
+
): Promise<FunctionReturnType<M>>;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Consumer-facing client for the durable, transport-agnostic outbound email
|
|
120
|
+
* queue. A host mutation enqueues a message with `enqueue` and gets its id back;
|
|
121
|
+
* the host's own transport sender (JMAP, an HTTP API, an SMTP-relay shim — never
|
|
122
|
+
* a vendor baked into this component) claims it (`markSending`), dispatches it,
|
|
123
|
+
* and reports the outcome (`markSent` / `markFailed`); `markFailed` retries until
|
|
124
|
+
* the attempt budget is spent. The component records intent and status — it never
|
|
125
|
+
* calls a provider. The host owns meaning and auth: it passes opaque `to`/`from`
|
|
126
|
+
* addresses, a `transport` adapter name, and an opaque `payload` the component
|
|
127
|
+
* stores without inspecting. Pass `payloadValidator` to narrow that opaque data
|
|
128
|
+
* to `TPayload` at the boundary — there is no unchecked cast.
|
|
129
|
+
*
|
|
130
|
+
* @typeParam TPayload - The host's rendered message payload type (defaults to `unknown`).
|
|
131
|
+
*
|
|
132
|
+
* @example
|
|
133
|
+
* ```ts
|
|
134
|
+
* const email = new Email(components.email, {
|
|
135
|
+
* payloadValidator: v.object({ subject: v.string(), html: v.string() }).parse,
|
|
136
|
+
* });
|
|
137
|
+
* const { messageId } = await email.enqueue(ctx, id, "user@x.com", "no-reply@app.com", "jmap", {
|
|
138
|
+
* payload: { subject: "Hi", html: "<p>Welcome</p>" },
|
|
139
|
+
* idempotencyKey: `welcome:${userId}`,
|
|
140
|
+
* });
|
|
141
|
+
* // ... the host's transport sender:
|
|
142
|
+
* await email.markSending(ctx, messageId);
|
|
143
|
+
* await email.markSent(ctx, messageId, { providerId: "jmap-123" });
|
|
144
|
+
* // ... clients poll:
|
|
145
|
+
* const msg = await email.get(ctx, messageId); // typed payload
|
|
146
|
+
* ```
|
|
147
|
+
*/
|
|
148
|
+
export class Email<TPayload = unknown> {
|
|
149
|
+
private readonly payloadValidator: Parser<TPayload> | undefined;
|
|
150
|
+
private readonly maxAttempts: number;
|
|
151
|
+
|
|
152
|
+
constructor(
|
|
153
|
+
private readonly component: EmailComponent,
|
|
154
|
+
options: EmailOptions<TPayload> = {},
|
|
155
|
+
) {
|
|
156
|
+
this.payloadValidator = options.payloadValidator;
|
|
157
|
+
this.maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Narrow an opaque value through the host parser; pass `undefined` and an unset parser through. */
|
|
161
|
+
private parse(value: unknown): TPayload | undefined {
|
|
162
|
+
if (value === undefined) {
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
165
|
+
if (this.payloadValidator === undefined) {
|
|
166
|
+
return value as TPayload;
|
|
167
|
+
}
|
|
168
|
+
return this.payloadValidator(value);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Project a raw component view into the typed, validated client view. */
|
|
172
|
+
private view(raw: RawView): MessageView<TPayload> {
|
|
173
|
+
return {
|
|
174
|
+
messageId: raw.messageId,
|
|
175
|
+
to: raw.to,
|
|
176
|
+
from: raw.from,
|
|
177
|
+
transport: raw.transport,
|
|
178
|
+
status: raw.status,
|
|
179
|
+
payload: this.parse(raw.payload),
|
|
180
|
+
subjectRef: raw.subjectRef,
|
|
181
|
+
idempotencyKey: raw.idempotencyKey,
|
|
182
|
+
providerId: raw.providerId,
|
|
183
|
+
attempts: raw.attempts,
|
|
184
|
+
maxAttempts: raw.maxAttempts,
|
|
185
|
+
error: raw.error,
|
|
186
|
+
createdAt: raw.createdAt,
|
|
187
|
+
updatedAt: raw.updatedAt,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Enqueue an outbound message and return its id immediately. `messageId` is
|
|
193
|
+
* host-supplied and must be unique; `to`/`from` are opaque addresses;
|
|
194
|
+
* `transport` names the host-configured adapter. `opts.payload` is opaque host
|
|
195
|
+
* data validated against `payloadValidator` before storage. When
|
|
196
|
+
* `opts.idempotencyKey` matches an existing message the existing id is returned
|
|
197
|
+
* (`deduplicated: true`) and no new row is inserted. The message starts
|
|
198
|
+
* `queued`.
|
|
199
|
+
*/
|
|
200
|
+
enqueue(
|
|
201
|
+
ctx: RunMutationCtx,
|
|
202
|
+
messageId: string,
|
|
203
|
+
to: string,
|
|
204
|
+
from: string,
|
|
205
|
+
transport: string,
|
|
206
|
+
opts: EnqueueOptions<TPayload> = {},
|
|
207
|
+
): Promise<EnqueueResult> {
|
|
208
|
+
return ctx.runMutation(this.component.mutations.enqueue, {
|
|
209
|
+
messageId,
|
|
210
|
+
to,
|
|
211
|
+
from,
|
|
212
|
+
transport,
|
|
213
|
+
payload: opts.payload === undefined ? undefined : this.parse(opts.payload),
|
|
214
|
+
subjectRef: opts.subjectRef,
|
|
215
|
+
idempotencyKey: opts.idempotencyKey,
|
|
216
|
+
maxAttempts: opts.maxAttempts ?? this.maxAttempts,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Claim a `queued` message for a send attempt (move it to `sending`, increment
|
|
222
|
+
* `attempts`). Returns the new attempt count. Rejects a missing id, a terminal
|
|
223
|
+
* message, and an already-`sending` message (claimed by another sender).
|
|
224
|
+
*/
|
|
225
|
+
markSending(ctx: RunMutationCtx, messageId: string): Promise<{ attempts: number }> {
|
|
226
|
+
return ctx.runMutation(this.component.mutations.markSending, { messageId });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Record a successful send — the message moves to terminal `sent`, recording
|
|
231
|
+
* the transport's `opts.providerId`. Idempotent against a replayed callback.
|
|
232
|
+
* Rejects a missing id and an already-`failed` message.
|
|
233
|
+
*/
|
|
234
|
+
markSent(
|
|
235
|
+
ctx: RunMutationCtx,
|
|
236
|
+
messageId: string,
|
|
237
|
+
opts: { providerId?: string } = {},
|
|
238
|
+
): Promise<null> {
|
|
239
|
+
return ctx.runMutation(this.component.mutations.markSent, {
|
|
240
|
+
messageId,
|
|
241
|
+
providerId: opts.providerId,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Record a failed send attempt, recording `opts.error`. The message returns to
|
|
247
|
+
* `queued` for another attempt while attempts remain, or lands in terminal
|
|
248
|
+
* `failed` once exhausted (see the returned `retried` flag). Rejects a missing
|
|
249
|
+
* id and an already-terminal message.
|
|
250
|
+
*/
|
|
251
|
+
markFailed(
|
|
252
|
+
ctx: RunMutationCtx,
|
|
253
|
+
messageId: string,
|
|
254
|
+
opts: { error?: string } = {},
|
|
255
|
+
): Promise<MarkFailedResult> {
|
|
256
|
+
return ctx.runMutation(this.component.mutations.markFailed, {
|
|
257
|
+
messageId,
|
|
258
|
+
error: opts.error,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** The current envelope for `messageId`, or `null` if no such message is held. */
|
|
263
|
+
async get(
|
|
264
|
+
ctx: RunQueryCtx,
|
|
265
|
+
messageId: string,
|
|
266
|
+
): Promise<MessageView<TPayload> | null> {
|
|
267
|
+
const raw = await ctx.runQuery(this.component.queries.get, { messageId });
|
|
268
|
+
return raw === null ? null : this.view(raw);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Page messages in one `status`, oldest first. Returns the standard Convex
|
|
273
|
+
* pagination envelope with each row narrowed to the typed view.
|
|
274
|
+
*/
|
|
275
|
+
async listByStatus(
|
|
276
|
+
ctx: RunQueryCtx,
|
|
277
|
+
status: MessageStatus,
|
|
278
|
+
paginationOpts: PaginationOptions,
|
|
279
|
+
): Promise<PaginationResult<MessageView<TPayload>>> {
|
|
280
|
+
const result = await ctx.runQuery(this.component.queries.listByStatus, {
|
|
281
|
+
status,
|
|
282
|
+
paginationOpts,
|
|
283
|
+
});
|
|
284
|
+
return { ...result, page: result.page.map((raw) => this.view(raw)) };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Delete terminal messages whose `updatedAt < before` in bounded batches,
|
|
289
|
+
* oldest first. `before` defaults to the server clock; `batch` caps each pass
|
|
290
|
+
* and the sweep self-reschedules until the tail is clean. Returns the count
|
|
291
|
+
* removed in the first pass. The built-in daily cron drives this automatically.
|
|
292
|
+
*/
|
|
293
|
+
prune(
|
|
294
|
+
ctx: RunMutationCtx,
|
|
295
|
+
opts: { before?: number; batch?: number } = {},
|
|
296
|
+
): Promise<number> {
|
|
297
|
+
return ctx.runMutation(this.component.mutations.prune, {
|
|
298
|
+
before: opts.before,
|
|
299
|
+
batch: opts.batch ?? DEFAULT_PRUNE_BATCH,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export type {
|
|
305
|
+
EmailOptions,
|
|
306
|
+
EnqueueOptions,
|
|
307
|
+
EnqueueResult,
|
|
308
|
+
MarkFailedResult,
|
|
309
|
+
MessageStatus,
|
|
310
|
+
MessageView,
|
|
311
|
+
Parser,
|
|
312
|
+
};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/** Public TypeScript surface for the email client. */
|
|
2
|
+
|
|
3
|
+
/** The four lifecycle states a message moves through. */
|
|
4
|
+
export type MessageStatus = "queued" | "sending" | "sent" | "failed";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Validates and narrows the opaque stored `payload` to a host type `T` at the
|
|
8
|
+
* client boundary. Receives the raw value the component returned (`unknown`) and
|
|
9
|
+
* MUST return a typed `T` or throw. A `convex/values` validator's `.parse` (or a
|
|
10
|
+
* Zod `.parse`) fits directly; omit it to keep the value unvalidated.
|
|
11
|
+
*
|
|
12
|
+
* @typeParam T - The host's stored message `payload` type.
|
|
13
|
+
*/
|
|
14
|
+
export type Parser<T> = (value: unknown) => T;
|
|
15
|
+
|
|
16
|
+
/** The public envelope returned by {@link Email.get}. */
|
|
17
|
+
export interface MessageView<TPayload = unknown> {
|
|
18
|
+
/** The host-supplied id naming this message. */
|
|
19
|
+
messageId: string;
|
|
20
|
+
/** The opaque destination address. */
|
|
21
|
+
to: string;
|
|
22
|
+
/** The opaque sender address. */
|
|
23
|
+
from: string;
|
|
24
|
+
/** The host-configured transport adapter this message is routed through. */
|
|
25
|
+
transport: string;
|
|
26
|
+
/** The current lifecycle status. */
|
|
27
|
+
status: MessageStatus;
|
|
28
|
+
/** The opaque rendered message payload (narrowed if a `payloadValidator` is set). */
|
|
29
|
+
payload?: TPayload;
|
|
30
|
+
/** An opaque host ref for subject-centric listing (e.g. the addressee subject). */
|
|
31
|
+
subjectRef?: string;
|
|
32
|
+
/** The dedup key, if the message was enqueued with one. */
|
|
33
|
+
idempotencyKey?: string;
|
|
34
|
+
/** The transport's own message handle, recorded once `sent`. */
|
|
35
|
+
providerId?: string;
|
|
36
|
+
/** The number of send attempts made so far. */
|
|
37
|
+
attempts: number;
|
|
38
|
+
/** The maximum send attempts before a failure is terminal. */
|
|
39
|
+
maxAttempts: number;
|
|
40
|
+
/** The host-supplied failure reason from the last failed attempt. */
|
|
41
|
+
error?: string;
|
|
42
|
+
/** Absolute ms timestamp the message was enqueued. */
|
|
43
|
+
createdAt: number;
|
|
44
|
+
/** Absolute ms timestamp of the last transition. */
|
|
45
|
+
updatedAt: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The result of {@link Email.enqueue}. */
|
|
49
|
+
export interface EnqueueResult {
|
|
50
|
+
/** The id of the queued message — the existing one when `deduplicated`. */
|
|
51
|
+
messageId: string;
|
|
52
|
+
/** True when an existing `idempotencyKey` matched and no new row was inserted. */
|
|
53
|
+
deduplicated: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Per-call options for {@link Email.enqueue}. */
|
|
57
|
+
export interface EnqueueOptions<TPayload> {
|
|
58
|
+
/** The opaque rendered message payload (validated against `payloadValidator` before storage). */
|
|
59
|
+
payload?: TPayload;
|
|
60
|
+
/** An opaque host ref recorded for subject-centric listing. */
|
|
61
|
+
subjectRef?: string;
|
|
62
|
+
/** A dedup key — a second enqueue carrying the same key returns the existing message. */
|
|
63
|
+
idempotencyKey?: string;
|
|
64
|
+
/** Override the client-level `maxAttempts` for this message. */
|
|
65
|
+
maxAttempts?: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The result of {@link Email.markFailed}. */
|
|
69
|
+
export interface MarkFailedResult {
|
|
70
|
+
/** `queued` when the message was re-queued for retry, `failed` when terminal. */
|
|
71
|
+
status: "queued" | "failed";
|
|
72
|
+
/** True when the message was re-queued (attempts remained), false when terminal. */
|
|
73
|
+
retried: boolean;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Construction options for the {@link Email} client. */
|
|
77
|
+
export interface EmailOptions<TPayload> {
|
|
78
|
+
/**
|
|
79
|
+
* Validates/narrows a stored `payload` to `TPayload` at the boundary — applied
|
|
80
|
+
* to the `payload` passed into `enqueue` (before storage) and the `payload`
|
|
81
|
+
* returned by `get` / `listByStatus`. Throws on a mismatch. Omit to leave the
|
|
82
|
+
* payload unvalidated.
|
|
83
|
+
*/
|
|
84
|
+
payloadValidator?: Parser<TPayload>;
|
|
85
|
+
/**
|
|
86
|
+
* The default maximum send attempts before a `markFailed` is terminal. Per-call
|
|
87
|
+
* `enqueue` `maxAttempts` overrides it. Defaults to `DEFAULT_MAX_ATTEMPTS` (5).
|
|
88
|
+
*/
|
|
89
|
+
maxAttempts?: number;
|
|
90
|
+
}
|