toiljs 0.0.43 → 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.
- package/CHANGELOG.md +9 -0
- package/RSG.md +334 -0
- package/build/cli/.tsbuildinfo +1 -1
- package/build/cli/index.js +2 -1
- package/build/client/.tsbuildinfo +1 -1
- package/build/client/auth.js +1 -1
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/config.d.ts +4 -0
- package/build/compiler/config.js +1 -0
- package/build/compiler/generate.js +1 -0
- package/build/compiler/index.js +1 -0
- package/build/devserver/.tsbuildinfo +1 -1
- package/build/devserver/crypto.js +1 -1
- package/build/devserver/dotenv.d.ts +8 -0
- package/build/devserver/dotenv.js +59 -0
- package/build/devserver/email/caps.d.ts +9 -0
- package/build/devserver/email/caps.js +0 -0
- package/build/devserver/email/config.d.ts +21 -0
- package/build/devserver/email/config.js +72 -0
- package/build/devserver/email/index.d.ts +25 -0
- package/build/devserver/email/index.js +57 -0
- package/build/devserver/email/providers.d.ts +12 -0
- package/build/devserver/email/providers.js +96 -0
- package/build/devserver/email/status.d.ts +10 -0
- package/build/devserver/email/status.js +11 -0
- package/build/devserver/email/validate.d.ts +2 -0
- package/build/devserver/email/validate.js +24 -0
- package/build/devserver/email/wire.d.ts +8 -0
- package/build/devserver/email/wire.js +32 -0
- package/build/devserver/env.d.ts +2 -0
- package/build/devserver/env.js +9 -0
- package/build/devserver/host.js +39 -7
- package/build/devserver/index.d.ts +2 -0
- package/build/devserver/index.js +8 -0
- package/build/devserver/module.js +1 -1
- package/build/shared/.tsbuildinfo +1 -1
- package/build/shared/index.d.ts +13 -0
- package/docs/README.md +4 -1
- package/docs/email.md +90 -53
- package/docs/environment.md +97 -0
- package/examples/basic/server/main.ts +1 -0
- package/examples/basic/server/routes/EnvDemo.ts +42 -0
- package/package.json +4 -2
- package/server/globals/environment.ts +82 -0
- package/src/cli/create.ts +2 -1
- package/src/client/auth.ts +1 -1
- package/src/compiler/config.ts +14 -0
- package/src/compiler/generate.ts +1 -0
- package/src/compiler/index.ts +1 -0
- package/src/devserver/crypto.ts +1 -1
- package/src/devserver/dotenv.ts +94 -0
- package/src/devserver/email/caps.ts +0 -0
- package/src/devserver/email/config.ts +123 -0
- package/src/devserver/email/index.ts +111 -0
- package/src/devserver/email/providers.ts +130 -0
- package/src/devserver/email/status.ts +23 -0
- package/src/devserver/email/validate.ts +40 -0
- package/src/devserver/email/wire.ts +55 -0
- package/src/devserver/env.ts +30 -0
- package/src/devserver/host.ts +71 -12
- package/src/devserver/index.ts +20 -0
- package/src/devserver/module.ts +1 -1
- package/src/shared/index.ts +36 -0
- package/test/devserver-email.test.ts +241 -0
package/src/devserver/module.ts
CHANGED
|
@@ -53,7 +53,7 @@ interface HandleExports {
|
|
|
53
53
|
/** Host functions the dev server provides under `env` (see `host.ts`). */
|
|
54
54
|
const PROVIDED_IMPORTS = new Set([
|
|
55
55
|
'abort', 'set_status', 'set_header', 'respond_file', 'thread_spawn', 'Date.now',
|
|
56
|
-
'client_ip', 'ratelimit_check', 'email_send',
|
|
56
|
+
'client_ip', 'ratelimit_check', 'email_send', 'env_get', 'env_get_secure',
|
|
57
57
|
// Web Crypto host functions (see ./crypto.ts).
|
|
58
58
|
'crypto.fill_random', 'crypto.random_uuid', 'crypto.take_result', 'crypto.digest',
|
|
59
59
|
'crypto.import_key', 'crypto.export_key', 'crypto.encrypt', 'crypto.decrypt',
|
package/src/shared/index.ts
CHANGED
|
@@ -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
|
+
});
|