toiljs 0.0.44 → 0.0.45

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 (46) hide show
  1. package/RSG.md +105 -27
  2. package/build/cli/.tsbuildinfo +1 -1
  3. package/build/compiler/.tsbuildinfo +1 -1
  4. package/build/compiler/config.d.ts +4 -0
  5. package/build/compiler/config.js +1 -0
  6. package/build/compiler/index.js +1 -0
  7. package/build/devserver/.tsbuildinfo +1 -1
  8. package/build/devserver/dotenv.d.ts +8 -0
  9. package/build/devserver/dotenv.js +59 -0
  10. package/build/devserver/email/caps.d.ts +9 -0
  11. package/build/devserver/email/caps.js +0 -0
  12. package/build/devserver/email/config.d.ts +21 -0
  13. package/build/devserver/email/config.js +72 -0
  14. package/build/devserver/email/index.d.ts +25 -0
  15. package/build/devserver/email/index.js +57 -0
  16. package/build/devserver/email/providers.d.ts +12 -0
  17. package/build/devserver/email/providers.js +96 -0
  18. package/build/devserver/email/status.d.ts +10 -0
  19. package/build/devserver/email/status.js +11 -0
  20. package/build/devserver/email/validate.d.ts +2 -0
  21. package/build/devserver/email/validate.js +24 -0
  22. package/build/devserver/email/wire.d.ts +8 -0
  23. package/build/devserver/email/wire.js +32 -0
  24. package/build/devserver/env.js +5 -54
  25. package/build/devserver/host.js +22 -7
  26. package/build/devserver/index.d.ts +2 -0
  27. package/build/devserver/index.js +8 -0
  28. package/build/shared/.tsbuildinfo +1 -1
  29. package/build/shared/index.d.ts +13 -0
  30. package/docs/email.md +29 -3
  31. package/package.json +3 -1
  32. package/src/compiler/config.ts +14 -0
  33. package/src/compiler/index.ts +1 -0
  34. package/src/devserver/dotenv.ts +94 -0
  35. package/src/devserver/email/caps.ts +0 -0
  36. package/src/devserver/email/config.ts +123 -0
  37. package/src/devserver/email/index.ts +111 -0
  38. package/src/devserver/email/providers.ts +130 -0
  39. package/src/devserver/email/status.ts +23 -0
  40. package/src/devserver/email/validate.ts +40 -0
  41. package/src/devserver/email/wire.ts +55 -0
  42. package/src/devserver/env.ts +8 -65
  43. package/src/devserver/host.ts +29 -12
  44. package/src/devserver/index.ts +20 -0
  45. package/src/shared/index.ts +36 -0
  46. package/test/devserver-email.test.ts +241 -0
@@ -18,6 +18,8 @@
18
18
  */
19
19
 
20
20
  import { buildCryptoImports, freshCryptoState, type CryptoState } from './crypto.js';
21
+ import { EmailStatus, getEmailService } from './email/index.js';
22
+ import { parseEmailBlob } from './email/wire.js';
21
23
  import { devEnvGet, devEnvGetSecure } from './env.js';
22
24
  import { ratelimitCheck } from './ratelimit.js';
23
25
 
@@ -210,21 +212,36 @@ export function buildHostImports(ref: MemoryRef, state: DispatchState): WebAssem
210
212
  return d.allowed ? 1 : -Math.max(1, d.retryAfterSecs);
211
213
  },
212
214
 
213
- // `env::email_send`: the dev server has no email provider, so it
214
- // parses the recipient for a log line and reports Sent (0), the same
215
- // i32 contract the edge returns. The suspension is a host-side concern
216
- // on the edge (call_async); the wasm just sees an i32 either way.
215
+ // `env::email_send`: the FULL email pipeline in dev (./email): parse +
216
+ // recipient validation + dedup + per-min/day budget + per-recipient cap
217
+ // run SYNCHRONOUSLY (exact status BadRecipient/Deduped/Budget/
218
+ // RecipientCapped), then the real provider send is FIRE-AND-FORGET (a
219
+ // sync wasm import can't await it), so the guest gets Sent optimistically
220
+ // and the true outcome is logged. Unconfigured email stays a log-only
221
+ // mock returning Sent. Mirrors the edge's `email_send_import.rs`.
217
222
  email_send: (reqPtr: number, reqLen: number): number => {
218
- // Header: u16 to_len | u16 subj_len | u16 purpose_len | u32 body_len
219
- // | u32 html_len (14 bytes), then payloads; `to` is first.
220
223
  const raw = readBytes(ref, reqPtr, reqLen);
221
- let to = '<unparsed>';
222
- if (raw.length >= 14) {
223
- const toLen = raw.readUInt16LE(0);
224
- if (14 + toLen <= raw.length) to = raw.toString('utf8', 14, 14 + toLen);
224
+ const svc = getEmailService();
225
+ if (svc === null) {
226
+ const to = parseEmailBlob(raw)?.to ?? '<unparsed>';
227
+ process.stdout.write(` ✉ dev email_send -> ${to} (no email config; not sent)\n`);
228
+ return EmailStatus.Sent;
225
229
  }
226
- process.stdout.write(` ✉ dev email_send -> ${to} (not actually sent)\n`);
227
- return 0; // EmailStatus.Sent
230
+ const { status, parsed } = svc.prepare(raw);
231
+ if (parsed === null) {
232
+ process.stdout.write(` ✉ dev email_send -> ${EmailStatus[status]}\n`);
233
+ return status;
234
+ }
235
+ void svc
236
+ .deliver(parsed)
237
+ .then((s) => {
238
+ const label = s === EmailStatus.Sent ? 'sent' : EmailStatus[s];
239
+ process.stdout.write(` ✉ dev email_send -> ${parsed.to} (${label})\n`);
240
+ })
241
+ .catch((e: unknown) => {
242
+ process.stdout.write(` ✉ dev email_send -> ${parsed.to} (error: ${String(e)})\n`);
243
+ });
244
+ return EmailStatus.Sent; // optimistic; sync wasm can't await the send
228
245
  },
229
246
 
230
247
  // `Environment.get` / `getSecure`: copy one tenant env value into the
@@ -25,7 +25,10 @@ import path from 'node:path';
25
25
  import { Server, type Request, type Response } from '@dacely/hyper-express';
26
26
  import pc from 'picocolors';
27
27
 
28
+ import type { EmailBackendConfig } from 'toiljs/shared';
29
+
28
30
  import { applyCacheRule, lookupCache } from './cache.js';
31
+ import { initEmailService } from './email/index.js';
29
32
  import { METHOD_CODES, type EnvelopeRequest } from './envelope.js';
30
33
  import { WasmServerModule } from './module.js';
31
34
  import { proxyToVite, wireWebsocketProxy, type ViteTarget } from './proxy.js';
@@ -81,6 +84,12 @@ export interface DevServerOptions {
81
84
  readonly vite: ViteTarget;
82
85
  /** Max request body bytes. Default 8 MB. */
83
86
  readonly maxBodyLength?: number;
87
+ /**
88
+ * The `toil.config.ts` `server.email` section (non-secret). When set (and the
89
+ * API key is in `.env.secrets`), `EmailService.send` really sends in dev;
90
+ * otherwise it stays a log-only mock. See `./email`.
91
+ */
92
+ readonly email?: EmailBackendConfig;
84
93
  }
85
94
 
86
95
  /** A running dev server. */
@@ -170,6 +179,17 @@ function sendWasmResponse(
170
179
  export async function startDevServer(options: DevServerOptions): Promise<RunningDevServer> {
171
180
  const host = options.host ?? '127.0.0.1';
172
181
  const root = path.resolve(options.root);
182
+
183
+ // Wire the email service from toil.config `server.email` + `.env.secrets`
184
+ // (TOIL_EMAIL_*). Configured -> real sends; otherwise the import stays a
185
+ // log-only mock. A partial-but-invalid config logs why it stayed off.
186
+ const emailInit = initEmailService(root, options.email);
187
+ if (emailInit.service !== null) {
188
+ process.stdout.write(pc.dim(` ✉ email enabled: ${emailInit.note}`) + '\n');
189
+ } else if (emailInit.note !== null) {
190
+ process.stdout.write(pc.yellow(' ! ') + pc.dim(`email off: ${emailInit.note}`) + '\n');
191
+ }
192
+
173
193
  const module = new WasmServerModule(options.wasmFile);
174
194
 
175
195
  let warnedMissing = false;
@@ -8,3 +8,39 @@ export const FRAMEWORK_NAME = 'toiljs';
8
8
  export interface ToilTarget {
9
9
  readonly name: 'client' | 'compiler' | 'cli' | 'server';
10
10
  }
11
+
12
+ /** SMTP connection config (non-secret) for the `smtp` / `gmail` providers. */
13
+ export interface SmtpBackendConfig {
14
+ /** SMTP host. Empty + provider `gmail` defaults to `smtp.gmail.com`. */
15
+ readonly host?: string;
16
+ /** Submission port. `0`/unset defaults to 587 (STARTTLS); 465 = implicit TLS. */
17
+ readonly port?: number;
18
+ /** SMTP username. Defaults to `from`. */
19
+ readonly user?: string;
20
+ }
21
+
22
+ /**
23
+ * The **non-secret** email backend config — the typed `email` section of
24
+ * `toil.config.ts` (`server.email`), consumed by the dev server and the future
25
+ * Node self-host. The provider API key / SMTP password is a SECRET and is NEVER
26
+ * here: it comes from `.env.secrets` (`TOIL_EMAIL_API_KEY`). Any `TOIL_EMAIL_*`
27
+ * env var overrides the matching field here.
28
+ *
29
+ * Mirrors the edge's `TOIL_EMAIL_*` keys (toil-backend `host/email.rs`
30
+ * `EmailSection`). Lives in `toiljs/shared` so both the compiler (config schema)
31
+ * and the dev server (resolver) can reference it regardless of build order.
32
+ */
33
+ export interface EmailBackendConfig {
34
+ /** `"resend"` (JSON API) | `"gmail"` | `"smtp"` (SMTP). Default `"resend"`. */
35
+ readonly provider?: 'resend' | 'gmail' | 'smtp';
36
+ /** The "from" address. Validated (single address, no CRLF). */
37
+ readonly from?: string;
38
+ /** Per-process send ceiling, sends/minute (rolling). `0` = unlimited. Default 60. */
39
+ readonly maxPerMin?: number;
40
+ /** Per-process send ceiling, sends/day (rolling). `0` = unlimited. Default 0. */
41
+ readonly maxPerDay?: number;
42
+ /** Per-recipient hourly cap (anti-abuse). Default 5. */
43
+ readonly maxPerRecipientPerHour?: number;
44
+ /** SMTP connection details (the `gmail` / `smtp` providers). */
45
+ readonly smtp?: SmtpBackendConfig;
46
+ }
@@ -0,0 +1,241 @@
1
+ /**
2
+ * The dev / self-host email pipeline (src/devserver/email): wire parse,
3
+ * recipient validation, in-process caps (dedup + per-recipient + per-min/day
4
+ * budget), config resolution (toil.config + TOIL_EMAIL_* env), the Resend
5
+ * transport, and the NodeEmailService prepare/deliver split. Mirrors the edge
6
+ * (toil-backend host/email.rs, mailer.rs, email_budget.rs).
7
+ */
8
+ import { afterEach, describe, expect, it, vi } from 'vitest';
9
+
10
+ import { EmailCaps } from '../src/devserver/email/caps.js';
11
+ import { resolveEmailConfig, type ResolvedEmailConfig } from '../src/devserver/email/config.js';
12
+ import { NodeEmailService } from '../src/devserver/email/index.js';
13
+ import { sendResend } from '../src/devserver/email/providers.js';
14
+ import { EmailStatus } from '../src/devserver/email/status.js';
15
+ import { validFrom, validRecipient } from '../src/devserver/email/validate.js';
16
+ import { parseEmailBlob } from '../src/devserver/email/wire.js';
17
+
18
+ /** Encode an `email_send` blob exactly like the guest (`server/globals/email.ts`). */
19
+ function encodeBlob(to: string, subject: string, purpose: string, body: string, html: string): Buffer {
20
+ const e = (s: string): Buffer => Buffer.from(s, 'utf8');
21
+ const [t, s, p, b, h] = [e(to), e(subject), e(purpose), e(body), e(html)];
22
+ const head = Buffer.alloc(14);
23
+ head.writeUInt16LE(t.length, 0);
24
+ head.writeUInt16LE(s.length, 2);
25
+ head.writeUInt16LE(p.length, 4);
26
+ head.writeUInt32LE(b.length, 6);
27
+ head.writeUInt32LE(h.length, 10);
28
+ return Buffer.concat([head, t, s, p, b, h]);
29
+ }
30
+
31
+ const reserved = (o: Record<string, string>): Map<string, string> => new Map(Object.entries(o));
32
+
33
+ describe('validate', () => {
34
+ it('accepts plain addresses, rejects injection / multiple / malformed', () => {
35
+ expect(validRecipient('user@example.com')).toBe(true);
36
+ expect(validRecipient('a.b+tag@sub.example.co')).toBe(true);
37
+ for (const bad of [
38
+ 'a@b.com\r\nBcc: evil@x.com',
39
+ 'a@b.com\nDATA',
40
+ 'a@b.com\0',
41
+ 'a@b.com,c@d.com',
42
+ 'a@b.com;c@d.com',
43
+ 'a@b.com c@d.com',
44
+ '<a@b.com>',
45
+ '"a"@b.com',
46
+ '',
47
+ 'nobody',
48
+ 'a@b@c.com',
49
+ '@b.com',
50
+ 'a@bcom',
51
+ 'a@.com',
52
+ 'a@b.',
53
+ ]) {
54
+ expect(validRecipient(bad), bad).toBe(false);
55
+ }
56
+ });
57
+
58
+ it('validFrom is lenient but rejects header injection', () => {
59
+ expect(validFrom('me@example.com')).toBe(true);
60
+ expect(validFrom('a@b.com\r\nBcc: x')).toBe(false);
61
+ expect(validFrom('no-at-sign')).toBe(false);
62
+ });
63
+ });
64
+
65
+ describe('wire parse', () => {
66
+ it('round-trips a well-formed blob', () => {
67
+ const raw = encodeBlob('a@b.com', 'Hi', 'verify', '123456', '<b>x</b>');
68
+ const p = parseEmailBlob(raw);
69
+ expect(p).toEqual({ to: 'a@b.com', subject: 'Hi', purpose: 'verify', body: '123456', html: '<b>x</b>' });
70
+ });
71
+
72
+ it('rejects truncated / trailing-garbage / non-UTF8', () => {
73
+ expect(parseEmailBlob(Buffer.alloc(4))).toBeNull(); // shorter than header
74
+ const raw = encodeBlob('a@b.com', 's', 'p', 'body', '');
75
+ expect(parseEmailBlob(raw.subarray(0, raw.length - 1))).toBeNull(); // truncated payload
76
+ expect(parseEmailBlob(Buffer.concat([raw, Buffer.from([0xff])]))).toBeNull(); // trailing garbage
77
+ const bad = Buffer.from(raw);
78
+ bad[bad.length - 1] = 0xff; // corrupt last body byte -> invalid UTF-8
79
+ expect(parseEmailBlob(bad)).toBeNull();
80
+ });
81
+ });
82
+
83
+ describe('caps', () => {
84
+ it('dedup collapses an identical (recipient, purpose) within the window', () => {
85
+ const c = new EmailCaps();
86
+ expect(c.isDuplicate('a@b.com', 'verify', 0)).toBe(false); // first
87
+ expect(c.isDuplicate('a@b.com', 'verify', 1_000)).toBe(true); // repeat in window
88
+ expect(c.isDuplicate('a@b.com', 'reset', 1_000)).toBe(false); // different purpose
89
+ expect(c.isDuplicate('a@b.com', 'verify', 31_000)).toBe(false); // after the 30s window
90
+ });
91
+
92
+ it('per-recipient hourly cap holds then resets', () => {
93
+ const c = new EmailCaps();
94
+ expect(c.recipientOk('x@y.com', 2, 0)).toBe(true);
95
+ expect(c.recipientOk('x@y.com', 2, 10)).toBe(true);
96
+ expect(c.recipientOk('x@y.com', 2, 20)).toBe(false); // third over the cap
97
+ expect(c.recipientOk('z@y.com', 2, 20)).toBe(true); // a different recipient is independent
98
+ expect(c.recipientOk('x@y.com', 2, 3_600_001)).toBe(true); // next hour resets
99
+ });
100
+
101
+ it('per-minute and per-day budgets both gate, 0 = unlimited', () => {
102
+ const c = new EmailCaps();
103
+ expect(c.budgetOk(2, 0, 0)).toBe(true);
104
+ expect(c.budgetOk(2, 0, 0)).toBe(true);
105
+ expect(c.budgetOk(2, 0, 0)).toBe(false); // minute cap (2) exhausted at the same instant
106
+ // A separate budget instance with only a day cap.
107
+ const d = new EmailCaps();
108
+ expect(d.budgetOk(0, 1, 0)).toBe(true);
109
+ expect(d.budgetOk(0, 1, 0)).toBe(false); // day cap (1) exhausted
110
+ // Unlimited (0/0) never blocks.
111
+ const u = new EmailCaps();
112
+ for (let i = 0; i < 100; i++) expect(u.budgetOk(0, 0, i)).toBe(true);
113
+ });
114
+ });
115
+
116
+ describe('config resolution', () => {
117
+ it('is unconfigured (silent) with no config + no env', () => {
118
+ expect(resolveEmailConfig(null, reserved({}))).toEqual({ config: null, warning: null });
119
+ });
120
+
121
+ it('merges toil.config + env, api key from env, env wins', () => {
122
+ const { config } = resolveEmailConfig(
123
+ { provider: 'resend', from: 'cfg@x.com', maxPerMin: 10 },
124
+ reserved({ TOIL_EMAIL_API_KEY: 're_k', TOIL_EMAIL_FROM: 'env@x.com' }),
125
+ );
126
+ expect(config).toMatchObject({ provider: 'resend', from: 'env@x.com', apiKey: 're_k', maxPerMin: 10 });
127
+ });
128
+
129
+ it('gmail resolves to smtp with defaults', () => {
130
+ const { config } = resolveEmailConfig(
131
+ { provider: 'gmail', from: 'me@gmail.com' },
132
+ reserved({ TOIL_EMAIL_API_KEY: 'app-pw' }),
133
+ );
134
+ expect(config).toMatchObject({ provider: 'smtp', smtp: { host: 'smtp.gmail.com', port: 587, user: 'me@gmail.com' } });
135
+ });
136
+
137
+ it('warns (off) on partial/invalid config', () => {
138
+ // from but no api key
139
+ expect(resolveEmailConfig({ from: 'a@b.com' }, reserved({})).warning).toMatch(/API_KEY/);
140
+ // bad from
141
+ expect(resolveEmailConfig({ from: 'no-at' }, reserved({ TOIL_EMAIL_API_KEY: 'k' })).warning).toMatch(/from/);
142
+ // smtp without host
143
+ expect(
144
+ resolveEmailConfig({ provider: 'smtp', from: 'a@b.com' }, reserved({ TOIL_EMAIL_API_KEY: 'k' })).warning,
145
+ ).toMatch(/SMTP_HOST/);
146
+ // unknown provider
147
+ expect(
148
+ resolveEmailConfig({ from: 'a@b.com' }, reserved({ TOIL_EMAIL_API_KEY: 'k', TOIL_EMAIL_PROVIDER: 'mailgun' }))
149
+ .warning,
150
+ ).toMatch(/unknown/);
151
+ });
152
+
153
+ it('TOIL_EMAIL_ENABLED=false disables silently', () => {
154
+ expect(
155
+ resolveEmailConfig({ provider: 'resend', from: 'a@b.com' }, reserved({ TOIL_EMAIL_API_KEY: 'k', TOIL_EMAIL_ENABLED: 'false' })),
156
+ ).toEqual({ config: null, warning: null });
157
+ });
158
+ });
159
+
160
+ describe('Resend transport', () => {
161
+ afterEach(() => vi.unstubAllGlobals());
162
+
163
+ const cfg: ResolvedEmailConfig = {
164
+ provider: 'resend',
165
+ from: 'noreply@x.com',
166
+ apiKey: 're_secret',
167
+ maxPerMin: 0,
168
+ maxPerDay: 0,
169
+ maxPerRecipientPerHour: 0,
170
+ };
171
+
172
+ it('2xx -> Sent, with the right payload + Bearer auth', async () => {
173
+ const fetchMock = vi.fn(async () => new Response(null, { status: 200 }));
174
+ vi.stubGlobal('fetch', fetchMock);
175
+ const status = await sendResend(cfg, { from: cfg.from, to: 'a@b.com', subject: 'Hi', body: 'text', html: '<b>h</b>' });
176
+ expect(status).toBe(EmailStatus.Sent);
177
+ expect(fetchMock).toHaveBeenCalledTimes(1);
178
+ const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
179
+ expect((init.headers as Record<string, string>).authorization).toBe('Bearer re_secret');
180
+ expect(JSON.parse(init.body as string)).toEqual({ from: 'noreply@x.com', to: ['a@b.com'], subject: 'Hi', text: 'text', html: '<b>h</b>' });
181
+ });
182
+
183
+ it('4xx -> ProviderError, no retry', async () => {
184
+ const fetchMock = vi.fn(async () => new Response(null, { status: 422 }));
185
+ vi.stubGlobal('fetch', fetchMock);
186
+ const status = await sendResend(cfg, { from: cfg.from, to: 'a@b.com', subject: 's', body: 'b', html: '' });
187
+ expect(status).toBe(EmailStatus.ProviderError);
188
+ expect(fetchMock).toHaveBeenCalledTimes(1);
189
+ });
190
+ });
191
+
192
+ describe('NodeEmailService pipeline', () => {
193
+ afterEach(() => vi.unstubAllGlobals());
194
+
195
+ const base: ResolvedEmailConfig = {
196
+ provider: 'resend',
197
+ from: 'noreply@x.com',
198
+ apiKey: 're_secret',
199
+ maxPerMin: 0,
200
+ maxPerDay: 0,
201
+ maxPerRecipientPerHour: 0,
202
+ };
203
+
204
+ it('prepare returns terminal statuses and a parsed message to deliver', () => {
205
+ const svc = new NodeEmailService(base);
206
+ // Bad recipient.
207
+ expect(svc.prepare(encodeBlob('not-an-email', 's', 'p', 'b', ''), 0).status).toBe(EmailStatus.BadRecipient);
208
+ // Valid -> Sent + parsed.
209
+ const ok = svc.prepare(encodeBlob('a@b.com', 's', 'verify', 'b', ''), 0);
210
+ expect(ok.status).toBe(EmailStatus.Sent);
211
+ expect(ok.parsed?.to).toBe('a@b.com');
212
+ // Identical (to, purpose) again -> Deduped.
213
+ expect(svc.prepare(encodeBlob('a@b.com', 's', 'verify', 'b', ''), 1_000).status).toBe(EmailStatus.Deduped);
214
+ });
215
+
216
+ it('enforces the per-minute budget', () => {
217
+ const svc = new NodeEmailService({ ...base, maxPerMin: 2 });
218
+ // Distinct recipients so dedup/recipient caps don't fire first.
219
+ expect(svc.prepare(encodeBlob('a@x.com', 's', 'p', 'b', ''), 0).status).toBe(EmailStatus.Sent);
220
+ expect(svc.prepare(encodeBlob('b@x.com', 's', 'p', 'b', ''), 0).status).toBe(EmailStatus.Sent);
221
+ expect(svc.prepare(encodeBlob('c@x.com', 's', 'p', 'b', ''), 0).status).toBe(EmailStatus.Budget);
222
+ });
223
+
224
+ it('enforces the per-recipient hourly cap', () => {
225
+ const svc = new NodeEmailService({ ...base, maxPerRecipientPerHour: 2 });
226
+ // Same recipient, distinct purposes so dedup doesn't fire.
227
+ expect(svc.prepare(encodeBlob('a@x.com', 's', 'p1', 'b', ''), 0).status).toBe(EmailStatus.Sent);
228
+ expect(svc.prepare(encodeBlob('a@x.com', 's', 'p2', 'b', ''), 0).status).toBe(EmailStatus.Sent);
229
+ expect(svc.prepare(encodeBlob('a@x.com', 's', 'p3', 'b', ''), 0).status).toBe(EmailStatus.RecipientCapped);
230
+ });
231
+
232
+ it('deliver actually sends via the provider', async () => {
233
+ const fetchMock = vi.fn(async () => new Response(null, { status: 200 }));
234
+ vi.stubGlobal('fetch', fetchMock);
235
+ const svc = new NodeEmailService(base);
236
+ const { parsed } = svc.prepare(encodeBlob('a@b.com', 's', 'p', 'b', ''), 0);
237
+ expect(parsed).not.toBeNull();
238
+ expect(await svc.deliver(parsed!)).toBe(EmailStatus.Sent);
239
+ expect(fetchMock).toHaveBeenCalledTimes(1);
240
+ });
241
+ });