@remit/smtp-worker 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,624 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import type {
4
+ AccountItem,
5
+ OutboxMessageItem,
6
+ UpdateOutboxMessageInput,
7
+ } from "@remit/data-ports";
8
+ import { AccountAuthType } from "@remit/domain-enums";
9
+ import { RefreshTokenError } from "@remit/mail-oauth-service";
10
+ import {
11
+ type EncryptedPayload,
12
+ serializeEncryptedPayload,
13
+ } from "@remit/secrets-service";
14
+ import { type SendResult, SmtpConnectionError } from "@remit/smtp-service";
15
+ import type { SendMessageEvent } from "../events.js";
16
+ import { type SendMessageDeps, sendMessage } from "./send-message-core.js";
17
+
18
+ const silentLogger = {
19
+ info: () => {},
20
+ warn: () => {},
21
+ error: () => {},
22
+ debug: () => {},
23
+ trace: () => {},
24
+ fatal: () => {},
25
+ child: () => silentLogger,
26
+ } as never;
27
+
28
+ const buildOutbox = (
29
+ overrides: Partial<OutboxMessageItem> = {},
30
+ ): OutboxMessageItem =>
31
+ ({
32
+ outboxMessageId: "obx-1",
33
+ accountId: "acc-1",
34
+ accountConfigId: "cfg-1",
35
+ fromAddress: "alice@example.com",
36
+ toAddresses: ["bob@example.com"],
37
+ messageIdValue: "msg-1@example.com",
38
+ status: "queued",
39
+ createdAt: 0,
40
+ updatedAt: 0,
41
+ ...overrides,
42
+ }) as unknown as OutboxMessageItem;
43
+
44
+ const buildAccount = (overrides: Partial<AccountItem> = {}): AccountItem =>
45
+ ({
46
+ accountId: "acc-1",
47
+ accountConfigId: "cfg-1",
48
+ username: "alice@example.com",
49
+ email: "alice@example.com",
50
+ passwordHash: JSON.stringify(
51
+ serializeEncryptedPayload({
52
+ encryptedDek: Buffer.from("dek"),
53
+ encryptedData: Buffer.from("data"),
54
+ iv: Buffer.from("iv"),
55
+ authTag: Buffer.from("tag"),
56
+ }),
57
+ ),
58
+ imapHost: "imap.example.com",
59
+ imapPort: 993,
60
+ imapTls: true,
61
+ imapStartTls: false,
62
+ smtpEnabled: true,
63
+ smtpHost: "smtp.example.com",
64
+ smtpPort: 587,
65
+ smtpTls: false,
66
+ smtpStartTls: true,
67
+ smtpUsername: "",
68
+ isActive: true,
69
+ connectionState: "not_authenticated",
70
+ createdAt: 0,
71
+ updatedAt: 0,
72
+ ...overrides,
73
+ }) as unknown as AccountItem;
74
+
75
+ interface Recorded {
76
+ updates: Array<{ id: string; patch: UpdateOutboxMessageInput }>;
77
+ statuses: Array<{ id: string; status: OutboxMessageItem["status"] }>;
78
+ marked: Array<{ id: string; sentAt: number; smtpMessageId?: string }>;
79
+ appendCalls: Array<{ accountId: string; outboxMessageId: string }>;
80
+ sendCalls: number;
81
+ resolveCalls: number;
82
+ connectionStateUpdates: Array<{ accountId: string; state: string }>;
83
+ outboundIncrements: Array<{ addressId: string; now: number }>;
84
+ replyIncrements: Array<{ addressId: string; now: number }>;
85
+ }
86
+
87
+ const buildDeps = (
88
+ options: {
89
+ outbox?: OutboxMessageItem;
90
+ account?: AccountItem;
91
+ sendResult?: SendResult;
92
+ appendThrows?: Error;
93
+ resolveCredentials?: SendMessageDeps["resolveCredentials"];
94
+ send?: SendMessageDeps["send"];
95
+ } = {},
96
+ ): { deps: SendMessageDeps; recorded: Recorded } => {
97
+ const recorded: Recorded = {
98
+ updates: [],
99
+ statuses: [],
100
+ marked: [],
101
+ appendCalls: [],
102
+ sendCalls: 0,
103
+ resolveCalls: 0,
104
+ connectionStateUpdates: [],
105
+ outboundIncrements: [],
106
+ replyIncrements: [],
107
+ };
108
+ const outbox = options.outbox ?? buildOutbox();
109
+ const account = options.account ?? buildAccount();
110
+ const deps: SendMessageDeps = {
111
+ getOutbox: async (accountConfigId, id) => {
112
+ assert.equal(accountConfigId, account.accountConfigId);
113
+ assert.equal(id, outbox.outboxMessageId);
114
+ return outbox;
115
+ },
116
+ getAccount: async (id) => {
117
+ assert.equal(id, account.accountId);
118
+ return account;
119
+ },
120
+ updateOutbox: async (accountConfigId, id, patch) => {
121
+ assert.equal(accountConfigId, account.accountConfigId);
122
+ recorded.updates.push({ id, patch });
123
+ },
124
+ updateOutboxStatus: async (accountConfigId, id, status) => {
125
+ assert.equal(accountConfigId, account.accountConfigId);
126
+ recorded.statuses.push({ id, status });
127
+ },
128
+ markOutboxSent: async (accountConfigId, id, fields) => {
129
+ assert.equal(accountConfigId, account.accountConfigId);
130
+ recorded.marked.push({ id, ...fields });
131
+ },
132
+ secrets: {
133
+ decrypt: async (payload: EncryptedPayload): Promise<string> =>
134
+ payload.encryptedDek.toString(),
135
+ },
136
+ // Stub credential resolver: returns a password credential from the account's
137
+ // encrypted passwordHash (mirroring what resolveConnectionCredentials does in prod).
138
+ resolveCredentials:
139
+ options.resolveCredentials ??
140
+ (async (_account) => {
141
+ recorded.resolveCalls += 1;
142
+ return {
143
+ kind: "password" as const,
144
+ password: _account.passwordHash
145
+ ? "resolved-password"
146
+ : "no-password-configured",
147
+ };
148
+ }),
149
+ updateConnectionState: async (accountId, state) => {
150
+ recorded.connectionStateUpdates.push({ accountId, state });
151
+ },
152
+ send:
153
+ options.send ??
154
+ (async () => {
155
+ recorded.sendCalls += 1;
156
+ return (
157
+ options.sendResult ?? {
158
+ success: true,
159
+ messageId: "smtp-mid-1",
160
+ isTransient: false,
161
+ }
162
+ );
163
+ }),
164
+ emitAppendSentMessage: async (accountId, outboxMessageId) => {
165
+ recorded.appendCalls.push({ accountId, outboxMessageId });
166
+ if (options.appendThrows) throw options.appendThrows;
167
+ },
168
+ engagement: {
169
+ resolveAddressId: (accountConfigId, email) =>
170
+ `addr-${accountConfigId}-${email}`,
171
+ incrementOutboundCount: async (accountConfigId, addressId, now) => {
172
+ assert.equal(accountConfigId, account.accountConfigId);
173
+ recorded.outboundIncrements.push({ addressId, now });
174
+ },
175
+ incrementReplyCount: async (accountConfigId, addressId, now) => {
176
+ assert.equal(accountConfigId, account.accountConfigId);
177
+ recorded.replyIncrements.push({ addressId, now });
178
+ },
179
+ findMessageByHeader: async () => null,
180
+ getEnvelopeFromEmail: async () => null,
181
+ },
182
+ };
183
+ return { deps, recorded };
184
+ };
185
+
186
+ const event: SendMessageEvent = {
187
+ type: "SEND_MESSAGE",
188
+ eventId: "evt-1",
189
+ timestamp: 0,
190
+ accountId: "acc-1",
191
+ outboxMessageId: "obx-1",
192
+ };
193
+
194
+ describe("sendMessage handler", () => {
195
+ it("marks status `blocked` when SMTP is disabled — never `sent`", async () => {
196
+ const { deps, recorded } = buildDeps({
197
+ account: buildAccount({ smtpEnabled: false }),
198
+ });
199
+
200
+ await sendMessage(event, silentLogger, deps);
201
+
202
+ assert.equal(recorded.sendCalls, 0, "send must not be invoked");
203
+ assert.equal(recorded.marked.length, 0, "must not mark as sent");
204
+ assert.equal(recorded.updates.length, 1);
205
+ assert.equal(recorded.updates[0].patch.status, "blocked");
206
+ assert.match(
207
+ String(recorded.updates[0].patch.lastError),
208
+ /SMTP not configured/,
209
+ );
210
+ assert.equal(
211
+ recorded.statuses.length,
212
+ 0,
213
+ "must not flicker through `sending`",
214
+ );
215
+ assert.equal(
216
+ recorded.appendCalls.length,
217
+ 0,
218
+ "must not enqueue APPEND_SENT_MESSAGE",
219
+ );
220
+ });
221
+
222
+ it("marks status `failed` (not `sent`) on permanent SMTP error", async () => {
223
+ const { deps, recorded } = buildDeps({
224
+ account: buildAccount({
225
+ smtpHost: "smtp.example.com",
226
+ smtpPort: 587,
227
+ }),
228
+ sendResult: {
229
+ success: false,
230
+ error: new Error("auth failed") as Error & { responseCode?: number },
231
+ smtpCode: 535,
232
+ isTransient: false,
233
+ },
234
+ });
235
+
236
+ await sendMessage(event, silentLogger, deps);
237
+
238
+ assert.equal(recorded.sendCalls, 1);
239
+ assert.equal(recorded.marked.length, 0);
240
+ const failedUpdate = recorded.updates.find(
241
+ (u) => u.patch.status === "failed",
242
+ );
243
+ assert.ok(failedUpdate, "should write a failed update");
244
+ assert.equal(failedUpdate.patch.lastSmtpCode, 535);
245
+ assert.equal(failedUpdate.patch.lastError, "auth failed");
246
+ assert.equal(
247
+ recorded.appendCalls.length,
248
+ 0,
249
+ "must not enqueue APPEND_SENT_MESSAGE",
250
+ );
251
+ });
252
+
253
+ it("marks `sent` and clears prior error fields on success", async () => {
254
+ const { deps, recorded } = buildDeps({
255
+ outbox: buildOutbox({
256
+ lastError: "stale error from prior attempt",
257
+ lastSmtpCode: 421,
258
+ }),
259
+ account: buildAccount({ smtpHost: "smtp.example.com", smtpPort: 587 }),
260
+ });
261
+
262
+ await sendMessage(event, silentLogger, deps);
263
+
264
+ assert.equal(recorded.sendCalls, 1);
265
+ assert.equal(recorded.marked.length, 1);
266
+ assert.equal(recorded.marked[0].id, "obx-1");
267
+ assert.equal(recorded.marked[0].smtpMessageId, "smtp-mid-1");
268
+ assert.equal(
269
+ recorded.appendCalls.length,
270
+ 1,
271
+ "must enqueue APPEND_SENT_MESSAGE",
272
+ );
273
+ // `markOutboxSent` is the seam that clears lastError/lastSmtpCode.
274
+ // We do not assert on `update` calls writing those fields — only that
275
+ // `markOutboxSent` was used (not the legacy generic update).
276
+ });
277
+
278
+ it("reverts to `queued` and rethrows on transient SMTP error", async () => {
279
+ const { deps, recorded } = buildDeps({
280
+ account: buildAccount({ smtpHost: "smtp.example.com", smtpPort: 587 }),
281
+ sendResult: {
282
+ success: false,
283
+ error: new Error("temporarily unavailable"),
284
+ smtpCode: 421,
285
+ isTransient: true,
286
+ },
287
+ });
288
+
289
+ await assert.rejects(
290
+ () => sendMessage(event, silentLogger, deps),
291
+ /SMTP transient error/,
292
+ );
293
+
294
+ const requeued = recorded.statuses.find((s) => s.status === "queued");
295
+ assert.ok(requeued, "should revert to queued for SQS retry");
296
+ assert.equal(recorded.marked.length, 0, "must not mark as sent");
297
+ });
298
+
299
+ it("skips processing if message is already `sent` (idempotent)", async () => {
300
+ const { deps, recorded } = buildDeps({
301
+ outbox: buildOutbox({ status: "sent" }),
302
+ });
303
+
304
+ await sendMessage(event, silentLogger, deps);
305
+
306
+ assert.equal(recorded.sendCalls, 0);
307
+ assert.equal(recorded.marked.length, 0);
308
+ assert.equal(recorded.updates.length, 0);
309
+ assert.equal(recorded.statuses.length, 0);
310
+ });
311
+
312
+ it("drops event when account is deleted (tombstone fence)", async () => {
313
+ const { deps, recorded } = buildDeps({
314
+ account: buildAccount({
315
+ smtpHost: "smtp.example.com",
316
+ smtpPort: 587,
317
+ deletedAt: Date.now(),
318
+ }),
319
+ });
320
+
321
+ await sendMessage(event, silentLogger, deps);
322
+
323
+ assert.equal(recorded.sendCalls, 0, "must not send");
324
+ assert.equal(recorded.marked.length, 0, "must not mark as sent");
325
+ assert.equal(recorded.updates.length, 0, "must not update outbox");
326
+ assert.equal(recorded.statuses.length, 0, "must not change status");
327
+ assert.equal(recorded.appendCalls.length, 0, "must not enqueue append");
328
+ });
329
+
330
+ it("increments outboundCount once per unique recipient on successful send", async () => {
331
+ const { deps, recorded } = buildDeps({
332
+ outbox: buildOutbox({
333
+ toAddresses: ["bob@example.com", "BOB@example.com"],
334
+ ccAddresses: ["carol@example.com"],
335
+ bccAddresses: ["dave@example.com"],
336
+ }),
337
+ account: buildAccount({ smtpHost: "smtp.example.com", smtpPort: 587 }),
338
+ });
339
+
340
+ await sendMessage(event, silentLogger, deps);
341
+
342
+ assert.equal(recorded.marked.length, 1, "send must succeed");
343
+ const incrementedAddressIds = recorded.outboundIncrements
344
+ .map((i) => i.addressId)
345
+ .sort();
346
+ assert.deepEqual(incrementedAddressIds, [
347
+ "addr-cfg-1-bob@example.com",
348
+ "addr-cfg-1-carol@example.com",
349
+ "addr-cfg-1-dave@example.com",
350
+ ]);
351
+ assert.equal(recorded.replyIncrements.length, 0);
352
+ });
353
+
354
+ it("does not increment counters when send fails", async () => {
355
+ const { deps, recorded } = buildDeps({
356
+ account: buildAccount({ smtpHost: "smtp.example.com", smtpPort: 587 }),
357
+ sendResult: {
358
+ success: false,
359
+ error: new Error("auth failed") as Error & { responseCode?: number },
360
+ smtpCode: 535,
361
+ isTransient: false,
362
+ },
363
+ });
364
+
365
+ await sendMessage(event, silentLogger, deps);
366
+
367
+ assert.equal(recorded.outboundIncrements.length, 0);
368
+ assert.equal(recorded.replyIncrements.length, 0);
369
+ });
370
+
371
+ it("increments replyCount when In-Reply-To resolves to a recipient", async () => {
372
+ const { deps, recorded } = buildDeps({
373
+ outbox: buildOutbox({
374
+ toAddresses: ["bob@example.com"],
375
+ inReplyTo: "parent-msg@example.com",
376
+ }),
377
+ account: buildAccount({ smtpHost: "smtp.example.com", smtpPort: 587 }),
378
+ });
379
+ deps.engagement.findMessageByHeader = async () =>
380
+ ({ messageId: "msg-parent" }) as unknown as never;
381
+ deps.engagement.getEnvelopeFromEmail = async () => "bob@example.com";
382
+
383
+ await sendMessage(event, silentLogger, deps);
384
+
385
+ assert.equal(recorded.replyIncrements.length, 1);
386
+ assert.equal(
387
+ recorded.replyIncrements[0].addressId,
388
+ "addr-cfg-1-bob@example.com",
389
+ );
390
+ });
391
+
392
+ it("does NOT increment replyCount when resolved sender is not a recipient", async () => {
393
+ const { deps, recorded } = buildDeps({
394
+ outbox: buildOutbox({
395
+ toAddresses: ["bob@example.com"],
396
+ inReplyTo: "parent-msg@example.com",
397
+ }),
398
+ account: buildAccount({ smtpHost: "smtp.example.com", smtpPort: 587 }),
399
+ });
400
+ deps.engagement.findMessageByHeader = async () =>
401
+ ({ messageId: "msg-parent" }) as unknown as never;
402
+ deps.engagement.getEnvelopeFromEmail = async () => "stranger@example.com";
403
+
404
+ await sendMessage(event, silentLogger, deps);
405
+
406
+ assert.equal(recorded.replyIncrements.length, 0);
407
+ });
408
+
409
+ it("collapses duplicate replyCount increments via the resolved Address", async () => {
410
+ const { deps, recorded } = buildDeps({
411
+ outbox: buildOutbox({
412
+ toAddresses: ["bob@example.com"],
413
+ inReplyTo: "parent-msg@example.com",
414
+ references: ["root-msg@example.com", "parent-msg@example.com"],
415
+ }),
416
+ account: buildAccount({ smtpHost: "smtp.example.com", smtpPort: 587 }),
417
+ });
418
+ deps.engagement.findMessageByHeader = async () =>
419
+ ({ messageId: "msg-parent" }) as unknown as never;
420
+ deps.engagement.getEnvelopeFromEmail = async () => "bob@example.com";
421
+
422
+ await sendMessage(event, silentLogger, deps);
423
+
424
+ assert.equal(
425
+ recorded.replyIncrements.length,
426
+ 1,
427
+ "replyCount must increment once per resolved Address",
428
+ );
429
+ });
430
+
431
+ it("resolves a reply target via accountId (account-scoped message identity)", async () => {
432
+ const { deps, recorded } = buildDeps({
433
+ outbox: buildOutbox({
434
+ toAddresses: ["bob@example.com"],
435
+ inReplyTo: "parent-msg@example.com",
436
+ }),
437
+ account: buildAccount({ smtpHost: "smtp.example.com", smtpPort: 587 }),
438
+ });
439
+
440
+ const headerLookups: string[] = [];
441
+ deps.engagement.findMessageByHeader = async (accountId) => {
442
+ headerLookups.push(accountId);
443
+ return { messageId: "msg-parent" } as unknown as never;
444
+ };
445
+ deps.engagement.getEnvelopeFromEmail = async () => "bob@example.com";
446
+
447
+ await sendMessage(event, silentLogger, deps);
448
+
449
+ // Inbound messages are keyed by accountId now, so the reply lookup must
450
+ // pass the outbox's accountId ("acc-1"), never its accountConfigId ("cfg-1").
451
+ assert.ok(headerLookups.length > 0);
452
+ for (const id of headerLookups) {
453
+ assert.equal(id, "acc-1");
454
+ }
455
+ assert.equal(recorded.replyIncrements.length, 1);
456
+ });
457
+
458
+ it("does not fail the send when engagement counters throw", async () => {
459
+ const { deps, recorded } = buildDeps({
460
+ account: buildAccount({ smtpHost: "smtp.example.com", smtpPort: 587 }),
461
+ });
462
+ deps.engagement.incrementOutboundCount = async () => {
463
+ throw new Error("simulated DDB outage");
464
+ };
465
+
466
+ await sendMessage(event, silentLogger, deps);
467
+
468
+ assert.equal(recorded.marked.length, 1, "send must still be marked sent");
469
+ assert.equal(recorded.appendCalls.length, 1, "append must still emit");
470
+ });
471
+ });
472
+
473
+ describe("sendMessage OAuth reauth/ACK contract", () => {
474
+ it("skips send when account is reauth_required", async () => {
475
+ const { deps, recorded } = buildDeps({
476
+ account: buildAccount({
477
+ smtpHost: "smtp.example.com",
478
+ smtpPort: 587,
479
+ connectionState: "reauth_required",
480
+ }),
481
+ });
482
+
483
+ await sendMessage(event, silentLogger, deps);
484
+
485
+ assert.equal(recorded.resolveCalls, 0, "must not resolve credentials");
486
+ assert.equal(recorded.sendCalls, 0, "must not send");
487
+ assert.equal(
488
+ recorded.connectionStateUpdates.length,
489
+ 0,
490
+ "must not flip connectionState",
491
+ );
492
+ assert.equal(recorded.updates.length, 0, "must not update outbox");
493
+ assert.equal(recorded.statuses.length, 0, "must not change status");
494
+ });
495
+
496
+ it("on RefreshTokenError reauth-required during credential resolution: flips to reauth_required and ACKs", async () => {
497
+ const { deps, recorded } = buildDeps({
498
+ account: buildAccount({ smtpHost: "smtp.example.com", smtpPort: 587 }),
499
+ resolveCredentials: async () => {
500
+ throw new RefreshTokenError({
501
+ kind: "reauth-required",
502
+ code: "invalid_grant",
503
+ });
504
+ },
505
+ });
506
+
507
+ await sendMessage(event, silentLogger, deps);
508
+
509
+ assert.equal(recorded.sendCalls, 0, "must not send");
510
+ assert.equal(recorded.connectionStateUpdates.length, 1);
511
+ assert.deepEqual(recorded.connectionStateUpdates[0], {
512
+ accountId: "acc-1",
513
+ state: "reauth_required",
514
+ });
515
+ });
516
+
517
+ it("on transient credential error: rethrows", async () => {
518
+ const { deps, recorded } = buildDeps({
519
+ account: buildAccount({ smtpHost: "smtp.example.com", smtpPort: 587 }),
520
+ resolveCredentials: async () => {
521
+ throw new RefreshTokenError({ kind: "transient", code: "503" });
522
+ },
523
+ });
524
+
525
+ await assert.rejects(
526
+ () => sendMessage(event, silentLogger, deps),
527
+ /transient/,
528
+ );
529
+ assert.equal(
530
+ recorded.connectionStateUpdates.length,
531
+ 0,
532
+ "must not flip connectionState",
533
+ );
534
+ });
535
+
536
+ it("on SmtpConnectionError auth during credential resolution for OAuth account: flips to reauth_required and ACKs", async () => {
537
+ const { deps, recorded } = buildDeps({
538
+ account: buildAccount({
539
+ smtpHost: "smtp.example.com",
540
+ smtpPort: 587,
541
+ authType: AccountAuthType.OauthMicrosoft,
542
+ }),
543
+ resolveCredentials: async () => {
544
+ throw new SmtpConnectionError("auth", "535 authentication failed");
545
+ },
546
+ });
547
+
548
+ await sendMessage(event, silentLogger, deps);
549
+
550
+ assert.equal(recorded.sendCalls, 0, "must not send");
551
+ assert.equal(recorded.connectionStateUpdates.length, 1);
552
+ assert.deepEqual(recorded.connectionStateUpdates[0], {
553
+ accountId: "acc-1",
554
+ state: "reauth_required",
555
+ });
556
+ });
557
+
558
+ it("on SmtpConnectionError auth during credential resolution for password account: rethrows (no state flip)", async () => {
559
+ const { deps, recorded } = buildDeps({
560
+ account: buildAccount({
561
+ smtpHost: "smtp.example.com",
562
+ smtpPort: 587,
563
+ authType: AccountAuthType.Password,
564
+ }),
565
+ resolveCredentials: async () => {
566
+ throw new SmtpConnectionError("auth", "535 authentication failed");
567
+ },
568
+ });
569
+
570
+ await assert.rejects(
571
+ () => sendMessage(event, silentLogger, deps),
572
+ /535 authentication failed/,
573
+ );
574
+ assert.equal(
575
+ recorded.connectionStateUpdates.length,
576
+ 0,
577
+ "must not flip connectionState for password account",
578
+ );
579
+ });
580
+
581
+ it("on SmtpConnectionError auth during send for OAuth account: flips to reauth_required and ACKs", async () => {
582
+ const { deps, recorded } = buildDeps({
583
+ account: buildAccount({
584
+ smtpHost: "smtp.example.com",
585
+ smtpPort: 587,
586
+ authType: AccountAuthType.OauthMicrosoft,
587
+ }),
588
+ send: async () => {
589
+ throw new SmtpConnectionError("auth", "535 authentication failed");
590
+ },
591
+ });
592
+
593
+ await sendMessage(event, silentLogger, deps);
594
+
595
+ assert.equal(recorded.connectionStateUpdates.length, 1);
596
+ assert.deepEqual(recorded.connectionStateUpdates[0], {
597
+ accountId: "acc-1",
598
+ state: "reauth_required",
599
+ });
600
+ });
601
+
602
+ it("on SmtpConnectionError auth during send for password account: rethrows (no state flip)", async () => {
603
+ const { deps, recorded } = buildDeps({
604
+ account: buildAccount({
605
+ smtpHost: "smtp.example.com",
606
+ smtpPort: 587,
607
+ authType: AccountAuthType.Password,
608
+ }),
609
+ send: async () => {
610
+ throw new SmtpConnectionError("auth", "535 authentication failed");
611
+ },
612
+ });
613
+
614
+ await assert.rejects(
615
+ () => sendMessage(event, silentLogger, deps),
616
+ /535 authentication failed/,
617
+ );
618
+ assert.equal(
619
+ recorded.connectionStateUpdates.length,
620
+ 0,
621
+ "must not flip connectionState for password account",
622
+ );
623
+ });
624
+ });