@zeyolabs/kit 0.0.0-stage → 2026.11.6

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.
Files changed (95) hide show
  1. package/README.md +102 -2
  2. package/auth/accounts/accounts.remote.ts +116 -0
  3. package/auth/accounts/accounts.ts +332 -0
  4. package/auth/accounts/handle.ts +102 -0
  5. package/auth/accounts/invites.ts +149 -0
  6. package/auth/accounts/permissions.ts +90 -0
  7. package/auth/accounts/schemas.ts +22 -0
  8. package/auth/config.ts +77 -0
  9. package/auth/emails.ts +57 -0
  10. package/auth/schema.ts +152 -0
  11. package/auth/sessions/handle.ts +124 -0
  12. package/auth/sessions/magic-link/magic-link.ts +71 -0
  13. package/auth/sessions/passkeys/client.ts +115 -0
  14. package/auth/sessions/passkeys/passkeys.ts +295 -0
  15. package/auth/sessions/sessions.ts +101 -0
  16. package/auth/sveltekit/loads.ts +59 -0
  17. package/auth/sveltekit/login.ts +28 -0
  18. package/auth/sveltekit/routes.ts +264 -0
  19. package/auth/users.ts +49 -0
  20. package/cloudflare/access.ts +134 -0
  21. package/cloudflare/amqp/client.ts +276 -0
  22. package/cloudflare/amqp/testing.ts +9 -0
  23. package/cloudflare/context.ts +66 -0
  24. package/cloudflare/dispatch/client.ts +30 -0
  25. package/cloudflare/email/base.ts +59 -0
  26. package/cloudflare/email/client.ts +51 -0
  27. package/cloudflare/email/dev.ts +14 -0
  28. package/cloudflare/email/smtp.ts +65 -0
  29. package/cloudflare/email/testing.ts +18 -0
  30. package/cloudflare/r2/client.ts +154 -0
  31. package/cloudflare/r2/testing.ts +43 -0
  32. package/cloudflare/rate-limiter.ts +65 -0
  33. package/cloudflare/wait-until.ts +12 -0
  34. package/cloudflare/workers/index.ts +311 -0
  35. package/cloudflare/workers/testing.ts +94 -0
  36. package/cloudflare/workers/ticking-do.ts +104 -0
  37. package/cloudflare/workers-ai/client.ts +30 -0
  38. package/cloudflare/workers-ai/testing.ts +17 -0
  39. package/cloudflare/workers-env.ts +12 -0
  40. package/eslint-config.js +115 -0
  41. package/monitoring/alerts/client.ts +48 -0
  42. package/monitoring/alerts/event-config.ts +33 -0
  43. package/monitoring/alerts/policy.ts +39 -0
  44. package/monitoring/alerts/registry.ts +44 -0
  45. package/monitoring/gates/client.ts +90 -0
  46. package/monitoring/gates/dev.ts +29 -0
  47. package/monitoring/gates/testing.ts +30 -0
  48. package/monitoring/logger/client.ts +66 -0
  49. package/monitoring/prometheus/client.ts +147 -0
  50. package/monitoring/prometheus/dev.ts +23 -0
  51. package/monitoring/prometheus/gen/remote_write_pb.ts +105 -0
  52. package/monitoring/prometheus/metrics.ts +180 -0
  53. package/monitoring/prometheus/testing.ts +19 -0
  54. package/monitoring/slack/api.ts +68 -0
  55. package/monitoring/slack/client.ts +33 -0
  56. package/monitoring/slack/events.ts +15 -0
  57. package/package.json +156 -5
  58. package/server/logger.ts +63 -0
  59. package/server/rate-limit-client.ts +88 -0
  60. package/server/rate-limit.ts +24 -0
  61. package/server/remote-functions.ts +139 -0
  62. package/server/result.ts +18 -0
  63. package/server/turnstile.ts +19 -0
  64. package/storage/clickhouse/client.ts +441 -0
  65. package/storage/clickhouse/compiler.ts +218 -0
  66. package/storage/clickhouse/config.ts +13 -0
  67. package/storage/clickhouse/dev.ts +18 -0
  68. package/storage/clickhouse/error.ts +61 -0
  69. package/storage/clickhouse/index.ts +4 -0
  70. package/storage/clickhouse/native-decoder.ts +192 -0
  71. package/storage/clickhouse/readers.ts +167 -0
  72. package/storage/clickhouse/retry.ts +30 -0
  73. package/storage/clickhouse/schema.ts +163 -0
  74. package/storage/clickhouse/testing.ts +81 -0
  75. package/storage/manticore/client.ts +165 -0
  76. package/storage/manticore/schema.ts +23 -0
  77. package/storage/postgres/client.ts +44 -0
  78. package/storage/postgres/testing.ts +275 -0
  79. package/storage/redis/client.ts +219 -0
  80. package/storage/redis/protocol.ts +119 -0
  81. package/storage/redis/schema.ts +17 -0
  82. package/storage/redis/testing.ts +122 -0
  83. package/utils/crypto.ts +36 -0
  84. package/utils/datetime.ts +338 -0
  85. package/utils/email.ts +173 -0
  86. package/utils/env.ts +171 -0
  87. package/utils/errors.ts +67 -0
  88. package/utils/form.ts +135 -0
  89. package/utils/http-status.ts +42 -0
  90. package/utils/money.ts +10 -0
  91. package/utils/public-host.ts +66 -0
  92. package/utils/schema.ts +176 -0
  93. package/utils/strings/format.ts +51 -0
  94. package/utils/strings/normalize.ts +129 -0
  95. package/utils/transport.ts +25 -0
@@ -0,0 +1,276 @@
1
+ const PROTOCOL_HEADER = new Uint8Array([0x41, 0x4d, 0x51, 0x50, 0, 0, 9, 1]);
2
+ const FRAME_END = 0xce;
3
+ const FRAME_TYPE_METHOD = 1;
4
+ const FRAME_TYPE_HEADER = 2;
5
+ const FRAME_TYPE_BODY = 3;
6
+
7
+ const CLASS_CONNECTION = 10;
8
+ const CLASS_CHANNEL = 20;
9
+ const CLASS_BASIC = 60;
10
+
11
+ type MethodFrame = { args: Uint8Array; channelId: number; classId: number; methodId: number };
12
+
13
+ class ByteWriter {
14
+ private chunks: number[] = [];
15
+
16
+ build(): Uint8Array {
17
+ return new Uint8Array(this.chunks);
18
+ }
19
+
20
+ bytes(b: Uint8Array): this {
21
+ for (const byte of b) this.chunks.push(byte);
22
+ return this;
23
+ }
24
+
25
+ emptyTable(): this {
26
+ this.long(0);
27
+ return this;
28
+ }
29
+
30
+ long(v: number): this {
31
+ this.chunks.push((v >>> 24) & 0xff, (v >>> 16) & 0xff, (v >>> 8) & 0xff, v & 0xff);
32
+ return this;
33
+ }
34
+
35
+ longlong(v: number): this {
36
+ this.long(0);
37
+ this.long(v);
38
+ return this;
39
+ }
40
+
41
+ longstr(bytes: Uint8Array): this {
42
+ this.long(bytes.length);
43
+ this.bytes(bytes);
44
+ return this;
45
+ }
46
+
47
+ octet(v: number): this {
48
+ this.chunks.push(v & 0xff);
49
+ return this;
50
+ }
51
+
52
+ short(v: number): this {
53
+ this.chunks.push((v >>> 8) & 0xff, v & 0xff);
54
+ return this;
55
+ }
56
+
57
+ shortstr(s: string): this {
58
+ const bytes = new TextEncoder().encode(s);
59
+ this.octet(bytes.length);
60
+ this.bytes(bytes);
61
+ return this;
62
+ }
63
+ }
64
+
65
+ class ByteReader {
66
+ private buf = new Uint8Array(0);
67
+
68
+ constructor(private reader: ReadableStreamDefaultReader<Uint8Array>) {}
69
+
70
+ async readExact(n: number): Promise<Uint8Array> {
71
+ while (this.buf.length < n) {
72
+ const { done, value } = await this.reader.read();
73
+ if (done) throw new Error(`AMQP: connection closed while expecting ${n} bytes`);
74
+ const merged = new Uint8Array(this.buf.length + value.length);
75
+ merged.set(this.buf, 0);
76
+ merged.set(value, this.buf.length);
77
+ this.buf = merged;
78
+ }
79
+ const out = this.buf.slice(0, n);
80
+ this.buf = this.buf.slice(n);
81
+ return out;
82
+ }
83
+ }
84
+
85
+ function u16(b: Uint8Array, offset: number): number {
86
+ return (b[offset] << 8) | b[offset + 1];
87
+ }
88
+
89
+ function u32(b: Uint8Array, offset: number): number {
90
+ return ((b[offset] << 24) | (b[offset + 1] << 16) | (b[offset + 2] << 8) | b[offset + 3]) >>> 0;
91
+ }
92
+
93
+ async function writeFrame(
94
+ writer: WritableStreamDefaultWriter<Uint8Array>,
95
+ type: number,
96
+ channelId: number,
97
+ payload: Uint8Array
98
+ ): Promise<void> {
99
+ const header = new ByteWriter().octet(type).short(channelId).long(payload.length).build();
100
+ await writer.write(header);
101
+ await writer.write(payload);
102
+ await writer.write(new Uint8Array([FRAME_END]));
103
+ }
104
+
105
+ async function readFrame(
106
+ reader: ByteReader
107
+ ): Promise<{ channelId: number; payload: Uint8Array; type: number }> {
108
+ const header = await reader.readExact(7);
109
+ const type = header[0];
110
+ const channelId = u16(header, 1);
111
+ const size = u32(header, 3);
112
+ const payload = await reader.readExact(size);
113
+ const trailer = await reader.readExact(1);
114
+ if (trailer[0] !== FRAME_END) {
115
+ throw new Error(
116
+ `AMQP: malformed frame (expected 0xCE trailer, got 0x${trailer[0].toString(16)})`
117
+ );
118
+ }
119
+ return { channelId, payload, type };
120
+ }
121
+
122
+ async function readMethodFrame(reader: ByteReader): Promise<MethodFrame> {
123
+ const frame = await readFrame(reader);
124
+ if (frame.type !== FRAME_TYPE_METHOD) {
125
+ throw new Error(`AMQP: expected a method frame, got frame type ${frame.type}`);
126
+ }
127
+ return {
128
+ args: frame.payload.slice(4),
129
+ channelId: frame.channelId,
130
+ classId: u16(frame.payload, 0),
131
+ methodId: u16(frame.payload, 2),
132
+ };
133
+ }
134
+
135
+ function parseConnectionClose(args: Uint8Array): { replyCode: number; replyText: string } {
136
+ const replyCode = u16(args, 0);
137
+ const textLen = args[2];
138
+ const replyText = new TextDecoder().decode(args.slice(3, 3 + textLen));
139
+ return { replyCode, replyText };
140
+ }
141
+
142
+ export type AmqpSocket = {
143
+ readable: ReadableStream<Uint8Array>;
144
+ writable: WritableStream<Uint8Array>;
145
+ };
146
+
147
+ export type AmqpConfig = {
148
+ password: string;
149
+ username: string;
150
+ vpc: { connect(address: string): AmqpSocket };
151
+ };
152
+
153
+ export interface IAmqpClient {
154
+ publishJson(queue: string, body: unknown): Promise<void>;
155
+ }
156
+
157
+ export class AmqpClient implements IAmqpClient {
158
+ constructor(private readonly config: AmqpConfig) {}
159
+
160
+ async publishJson(queue: string, body: unknown): Promise<void> {
161
+ const { password, username, vpc } = this.config;
162
+ const socket = vpc.connect('127.0.0.1:5672');
163
+ const writer = socket.writable.getWriter();
164
+ const reader = new ByteReader(socket.readable.getReader());
165
+
166
+ try {
167
+ await writer.write(PROTOCOL_HEADER);
168
+
169
+ const start = await readMethodFrame(reader);
170
+ if (start.classId !== CLASS_CONNECTION || start.methodId !== 10) {
171
+ throw new Error(
172
+ `AMQP: expected Connection.Start, got class ${start.classId} method ${start.methodId}`
173
+ );
174
+ }
175
+
176
+ const saslResponse = new TextEncoder().encode(`\0${username}\0${password}`);
177
+ const startOk = new ByteWriter()
178
+ .short(CLASS_CONNECTION)
179
+ .short(11)
180
+ .emptyTable()
181
+ .shortstr('PLAIN')
182
+ .longstr(saslResponse)
183
+ .shortstr('en_US')
184
+ .build();
185
+ await writeFrame(writer, FRAME_TYPE_METHOD, 0, startOk);
186
+
187
+ const tune = await readMethodFrame(reader);
188
+ if (tune.classId !== CLASS_CONNECTION || tune.methodId !== 30) {
189
+ throw new Error(
190
+ `AMQP: expected Connection.Tune, got class ${tune.classId} method ${tune.methodId}`
191
+ );
192
+ }
193
+ const channelMax = u16(tune.args, 0);
194
+ const frameMax = u32(tune.args, 2);
195
+
196
+ const tuneOk = new ByteWriter()
197
+ .short(CLASS_CONNECTION)
198
+ .short(31)
199
+ .short(channelMax)
200
+ .long(frameMax)
201
+ .short(0)
202
+ .build();
203
+ await writeFrame(writer, FRAME_TYPE_METHOD, 0, tuneOk);
204
+
205
+ const open = new ByteWriter()
206
+ .short(CLASS_CONNECTION)
207
+ .short(40)
208
+ .shortstr('/')
209
+ .shortstr('')
210
+ .octet(0)
211
+ .build();
212
+ await writeFrame(writer, FRAME_TYPE_METHOD, 0, open);
213
+
214
+ const openOk = await readMethodFrame(reader);
215
+ if (openOk.classId !== CLASS_CONNECTION || openOk.methodId !== 41) {
216
+ if (openOk.classId === CLASS_CONNECTION && openOk.methodId === 50) {
217
+ const { replyCode, replyText } = parseConnectionClose(openOk.args);
218
+ throw new Error(`AMQP: connection rejected (${replyCode}): ${replyText}`);
219
+ }
220
+ throw new Error(
221
+ `AMQP: expected Connection.OpenOk, got class ${openOk.classId} method ${openOk.methodId}`
222
+ );
223
+ }
224
+
225
+ const channelOpen = new ByteWriter().short(CLASS_CHANNEL).short(10).shortstr('').build();
226
+ await writeFrame(writer, FRAME_TYPE_METHOD, 1, channelOpen);
227
+ const channelOpenOk = await readMethodFrame(reader);
228
+ if (channelOpenOk.classId !== CLASS_CHANNEL || channelOpenOk.methodId !== 11) {
229
+ throw new Error(
230
+ `AMQP: expected Channel.OpenOk, got class ${channelOpenOk.classId} method ${channelOpenOk.methodId}`
231
+ );
232
+ }
233
+
234
+ const publish = new ByteWriter()
235
+ .short(CLASS_BASIC)
236
+ .short(40)
237
+ .short(0)
238
+ .shortstr('')
239
+ .shortstr(queue)
240
+ .octet(0)
241
+ .build();
242
+ await writeFrame(writer, FRAME_TYPE_METHOD, 1, publish);
243
+
244
+ const bodyBytes = new TextEncoder().encode(JSON.stringify(body));
245
+ const header = new ByteWriter()
246
+ .short(CLASS_BASIC)
247
+ .short(0)
248
+ .longlong(bodyBytes.length)
249
+ .short(0x9000)
250
+ .shortstr('application/json')
251
+ .octet(2)
252
+ .build();
253
+ await writeFrame(writer, FRAME_TYPE_HEADER, 1, header);
254
+
255
+ await writeFrame(writer, FRAME_TYPE_BODY, 1, bodyBytes);
256
+
257
+ const close = new ByteWriter()
258
+ .short(CLASS_CONNECTION)
259
+ .short(50)
260
+ .short(200)
261
+ .shortstr('')
262
+ .short(0)
263
+ .short(0)
264
+ .build();
265
+ await writeFrame(writer, FRAME_TYPE_METHOD, 0, close);
266
+ const closeOk = await readMethodFrame(reader);
267
+ if (closeOk.classId !== CLASS_CONNECTION || closeOk.methodId !== 51) {
268
+ throw new Error(
269
+ `AMQP: expected Connection.CloseOk, got class ${closeOk.classId} method ${closeOk.methodId}`
270
+ );
271
+ }
272
+ } finally {
273
+ await writer.close().catch(() => {});
274
+ }
275
+ }
276
+ }
@@ -0,0 +1,9 @@
1
+ import type { IAmqpClient } from './client';
2
+
3
+ export class FakeAmqpClient implements IAmqpClient {
4
+ readonly published: { body: unknown; queue: string }[] = [];
5
+
6
+ async publishJson(queue: string, body: unknown): Promise<void> {
7
+ this.published.push({ body, queue });
8
+ }
9
+ }
@@ -0,0 +1,66 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+
3
+ import { EnvStore } from '../utils/env';
4
+
5
+ /** Per-request/invocation cache with teardown tracking, holding the raw runtime `env`. */
6
+ export class ContextStore {
7
+ private cache = new Map<string, unknown>();
8
+ private closers: Array<() => Promise<void>> = [];
9
+
10
+ constructor(readonly env: object) {}
11
+
12
+ async closeAll(): Promise<void> {
13
+ await Promise.allSettled(this.closers.map((c) => c()));
14
+ }
15
+
16
+ once<T>(key: string, build: () => T, close?: (v: T) => Promise<void>): T {
17
+ if (!this.cache.has(key)) {
18
+ const v = build();
19
+ this.cache.set(key, v);
20
+ if (close) this.closers.push(() => close(v));
21
+ }
22
+ return this.cache.get(key) as T;
23
+ }
24
+
25
+ /** For a binding whose construction is app-specific and can't be built generically here. */
26
+ provide<T>(key: string, value: T): void {
27
+ this.cache.set(key, value);
28
+ }
29
+
30
+ require<T>(key: string): T {
31
+ if (!this.cache.has(key)) {
32
+ throw new Error(
33
+ `Context key "${key}" was never provided — call store.provide("${key}", …) first`
34
+ );
35
+ }
36
+ return this.cache.get(key) as T;
37
+ }
38
+ }
39
+
40
+ const als = new AsyncLocalStorage<ContextStore>();
41
+
42
+ /** Wrap one request/invocation in this — whatever gets built during the call is closed
43
+ * automatically when it returns. `secretNames`: the app's list of Secrets Store binding names. */
44
+ export async function runWithContext<T>(
45
+ env: object,
46
+ secretNames: ReadonlySet<string>,
47
+ fn: () => Promise<T>
48
+ ): Promise<T> {
49
+ await EnvStore.resolve(env, secretNames);
50
+ const store = new ContextStore(env);
51
+ try {
52
+ return await als.run(store, fn);
53
+ } finally {
54
+ await store.closeAll();
55
+ }
56
+ }
57
+
58
+ export function currentContext(): ContextStore {
59
+ const store = als.getStore();
60
+ if (!store) {
61
+ throw new Error(
62
+ 'No context — call runWithContext(env, fn) at the top of the request/invocation'
63
+ );
64
+ }
65
+ return store;
66
+ }
@@ -0,0 +1,30 @@
1
+ /** Structural shape of the dispatch queue binding — kept minimal so kit doesn't pull in a
2
+ * Cloudflare Workers type it doesn't already depend on. */
3
+ export type DispatchQueueBinding = { send(body: unknown): Promise<void> };
4
+
5
+ export interface IDispatchClient<M> {
6
+ send(message: M): Promise<void>;
7
+ }
8
+
9
+ /**
10
+ * Hands a message to the `dispatch` queue, generic over the caller's own message union `M` — kit
11
+ * has no notion of what a dispatch type is called. Stamps `enqueuedAt` (unix seconds) on the way
12
+ * out, which is the one envelope field every consumer relies on and no producer should have to
13
+ * remember.
14
+ */
15
+ export class DispatchClient<M extends { type: string }> implements IDispatchClient<M> {
16
+ constructor(private readonly queue: DispatchQueueBinding) {}
17
+
18
+ async send(message: M): Promise<void> {
19
+ await this.queue.send({ ...message, enqueuedAt: Math.floor(Date.now() / 1000) });
20
+ }
21
+ }
22
+
23
+ /** Used when no `DISPATCH_QUEUE` binding exists (local dev) — logs instead of delivering, so a
24
+ * missing binding degrades the same way `DevPrometheusClient` does rather than throwing. */
25
+ export class DevDispatchClient<M extends { type: string }> implements IDispatchClient<M> {
26
+ async send(message: M): Promise<void> {
27
+ console.info('[dispatch] no DISPATCH_QUEUE binding — dropping message', { type: message.type });
28
+ return Promise.resolve();
29
+ }
30
+ }
@@ -0,0 +1,59 @@
1
+ import { logger as defaultLogger, type LoggerClient } from '../../monitoring/logger/client';
2
+
3
+ // What every email client shares: the message shape, the `IEmailClient` contract, and
4
+ // `BaseEmailClient`, which owns the sender and the log-and-return-false `send`. A transport
5
+ // (Workers `send_email` binding, SMTP, ...) only implements `deliver`.
6
+
7
+ export type EmailAttachment = { base64Data: string; contentType: string; filename: string };
8
+
9
+ export type EmailInput = {
10
+ attachments?: EmailAttachment[];
11
+ /** Display name for this message, overriding the client's `fromName`. */
12
+ fromName?: string;
13
+ html?: string;
14
+ replyTo?: { email: string; name?: string };
15
+ subject: string;
16
+ /** Plain-text body (or alternative to `html`). Give at least one of the two. */
17
+ text?: string;
18
+ to: string;
19
+ };
20
+
21
+ export interface IEmailClient {
22
+ /** Sends one message; throws on failure, for callers that retry (queue consumers). */
23
+ deliver(input: EmailInput): Promise<void>;
24
+ /** Sends one message. Logs and returns `false` on failure instead of throwing. */
25
+ send(input: EmailInput): Promise<boolean>;
26
+ }
27
+
28
+ export type EmailSender = {
29
+ /** Envelope and header sender. */
30
+ from: string;
31
+ /** Display name next to `from`. */
32
+ fromName?: string;
33
+ logger?: LoggerClient;
34
+ };
35
+
36
+ export abstract class BaseEmailClient implements IEmailClient {
37
+ constructor(protected readonly sender: EmailSender) {}
38
+
39
+ abstract deliver(input: EmailInput): Promise<void>;
40
+
41
+ async send(input: EmailInput): Promise<boolean> {
42
+ try {
43
+ await this.deliver(input);
44
+ return true;
45
+ } catch (err) {
46
+ await (this.sender.logger ?? defaultLogger).error(err, {
47
+ from: this.sender.from,
48
+ subject: input.subject,
49
+ to: input.to,
50
+ });
51
+ return false;
52
+ }
53
+ }
54
+
55
+ /** The display name for one message: its own, else the client's. */
56
+ protected nameFor(input: EmailInput): string | undefined {
57
+ return input.fromName ?? this.sender.fromName;
58
+ }
59
+ }
@@ -0,0 +1,51 @@
1
+ import { createMimeMessage } from 'mimetext/browser';
2
+
3
+ import { BaseEmailClient, type EmailInput, type EmailSender } from './base';
4
+
5
+ export {
6
+ BaseEmailClient,
7
+ type EmailAttachment,
8
+ type EmailInput,
9
+ type EmailSender,
10
+ type IEmailClient,
11
+ } from './base';
12
+
13
+ /** The shape of a Workers `send_email` binding. */
14
+ export type EmailBinding = { send(message: unknown): Promise<unknown> };
15
+
16
+ function buildRawEmail(from: string, fromName: string | undefined, input: EmailInput): string {
17
+ const msg = createMimeMessage();
18
+ msg.setSender(fromName ? { addr: from, name: fromName } : { addr: from });
19
+ msg.setRecipient(input.to);
20
+ msg.setSubject(input.subject);
21
+ if (input.replyTo) msg.setHeader('Reply-To', input.replyTo.email);
22
+ if (input.text) msg.addMessage({ contentType: 'text/plain', data: input.text });
23
+ if (input.html) msg.addMessage({ contentType: 'text/html', data: input.html });
24
+ for (const attachment of input.attachments ?? []) {
25
+ msg.addAttachment({
26
+ contentType: attachment.contentType,
27
+ data: attachment.base64Data,
28
+ filename: attachment.filename,
29
+ });
30
+ }
31
+ return msg.asRaw();
32
+ }
33
+
34
+ /** Sends transactional email over a Workers `send_email` binding (Cloudflare Email Routing).
35
+ * Use `DevEmailClient` where there's no binding. */
36
+ export class EmailClient extends BaseEmailClient {
37
+ private readonly binding: EmailBinding;
38
+
39
+ constructor(config: EmailSender & { binding: EmailBinding }) {
40
+ super(config);
41
+ this.binding = config.binding;
42
+ }
43
+
44
+ async deliver(input: EmailInput): Promise<void> {
45
+ const { from } = this.sender;
46
+ // Dynamic: `cloudflare:email` only resolves inside the Workers runtime.
47
+ const { EmailMessage } = await import('cloudflare:email');
48
+ const raw = buildRawEmail(from, this.nameFor(input), input);
49
+ await this.binding.send(new EmailMessage(from, input.to, raw));
50
+ }
51
+ }
@@ -0,0 +1,14 @@
1
+ import type { EmailInput, IEmailClient } from './base';
2
+
3
+ /** Stands in for a real client where there's no transport (local dev): logs instead of sending,
4
+ * so the message is still visible. */
5
+ export class DevEmailClient implements IEmailClient {
6
+ async deliver({ subject, text, to }: EmailInput): Promise<void> {
7
+ console.log(`[email:dev] to=${to} subject=${subject}${text ? `\n${text}` : ''}`);
8
+ }
9
+
10
+ async send(input: EmailInput): Promise<boolean> {
11
+ await this.deliver(input);
12
+ return true;
13
+ }
14
+ }
@@ -0,0 +1,65 @@
1
+ import { envStore } from '../../utils/env';
2
+ import { BaseEmailClient, type EmailInput, type EmailSender } from './base';
3
+
4
+ // Email over SMTP (e.g. Amazon SES) with `worker-mailer`, which speaks SMTP over
5
+ // `cloudflare:sockets`: for sending to any recipient, where a `send_email` binding
6
+ // (`EmailClient`) can't. Same `IEmailClient` contract, so `DevEmailClient` and `FakeEmailClient`
7
+ // stand in for it too.
8
+
9
+ export type SmtpSettings = { host: string; password: string; port: number; username: string };
10
+
11
+ /** The wrangler names of the SMTP settings: three vars and one Secrets Store secret. */
12
+ export type SmtpKeys = { host: string; password: string; port: string; username: string };
13
+
14
+ /** Connection options for worker-mailer: port 465 is implicit TLS, anything else STARTTLS. */
15
+ export function smtpConnection({ host, password, port, username }: SmtpSettings) {
16
+ const implicitTls = port === 465;
17
+ return {
18
+ authType: 'plain' as const,
19
+ credentials: { password, username },
20
+ host,
21
+ port,
22
+ secure: implicitTls,
23
+ startTls: !implicitTls,
24
+ };
25
+ }
26
+
27
+ export class SmtpClient extends BaseEmailClient {
28
+ private readonly settings: SmtpSettings;
29
+
30
+ constructor(config: EmailSender & SmtpSettings) {
31
+ super(config);
32
+ this.settings = config;
33
+ }
34
+
35
+ /** A client whose settings come from `envStore()` under the names in `keys` (so the password's
36
+ * name must be among the request context's `secretNames`). */
37
+ static fromEnv(keys: SmtpKeys, sender: EmailSender): SmtpClient {
38
+ const store = envStore();
39
+ return new SmtpClient({
40
+ ...sender,
41
+ host: store.string(keys.host),
42
+ password: store.secret(keys.password),
43
+ port: store.int(keys.port),
44
+ username: store.string(keys.username),
45
+ });
46
+ }
47
+
48
+ async deliver(input: EmailInput): Promise<void> {
49
+ // Loaded lazily: it imports cloudflare:sockets, which only exists on Workers.
50
+ const { WorkerMailer } = await import('worker-mailer');
51
+ await WorkerMailer.send(smtpConnection(this.settings), {
52
+ attachments: input.attachments?.map((a) => ({
53
+ content: a.base64Data,
54
+ filename: a.filename,
55
+ mimeType: a.contentType,
56
+ })),
57
+ from: { email: this.sender.from, name: this.nameFor(input) },
58
+ html: input.html,
59
+ reply: input.replyTo,
60
+ subject: input.subject,
61
+ text: input.text,
62
+ to: input.to,
63
+ });
64
+ }
65
+ }
@@ -0,0 +1,18 @@
1
+ import type { EmailInput, IEmailClient } from './base';
2
+
3
+ /** Records every message instead of sending anything — for asserting what a service emailed,
4
+ * without a mock transport. Set `succeeds = false` to simulate a delivery failure. */
5
+ export class FakeEmailClient implements IEmailClient {
6
+ readonly sent: EmailInput[] = [];
7
+ succeeds = true;
8
+
9
+ async deliver(input: EmailInput): Promise<void> {
10
+ this.sent.push(input);
11
+ if (!this.succeeds) throw new Error('delivery failed');
12
+ }
13
+
14
+ async send(input: EmailInput): Promise<boolean> {
15
+ this.sent.push(input);
16
+ return this.succeeds;
17
+ }
18
+ }