@remit/smtp-worker 0.0.30 → 0.0.31

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.
@@ -1,1037 +1,711 @@
1
1
  import assert from "node:assert/strict";
2
- import { describe, it } from "node:test";
2
+ import net from "node:net";
3
+ import { afterEach, beforeEach, describe, it } from "node:test";
3
4
  import type {
4
- AccountItem,
5
- OutboxMessageItem,
6
- UpdateOutboxMessageInput,
5
+ CreateAccountInput,
6
+ CreateOutboxMessageInput,
7
7
  } from "@remit/data-ports";
8
- import { AccountAuthType } from "@remit/domain-enums";
9
- import { RefreshTokenError } from "@remit/mail-oauth-service";
10
- import { OutboxAttachmentUnavailableError } from "@remit/mailbox-service/outbox-attachment-content";
8
+ import { deriveAddressId } from "@remit/data-ports/id";
11
9
  import {
12
- type EncryptedPayload,
13
- serializeEncryptedPayload,
14
- } from "@remit/secrets-service";
10
+ type RecordingQueue,
11
+ recordingQueue,
12
+ } from "@remit/data-ports/recording-queue";
15
13
  import {
16
- type MailAttachment,
17
- type MailMessage,
18
- type SendResult,
19
- SmtpConnectionError,
20
- } from "@remit/smtp-service";
21
- import type { SendMessageEvent } from "../events.js";
14
+ AccountAuthType,
15
+ ConnectionState,
16
+ OutboxMessageStatus,
17
+ } from "@remit/domain-enums";
18
+ import { createSqliteRepositories } from "@remit/drizzle-service";
19
+ import { createShippedSqliteDb } from "@remit/drizzle-service/test-sqlite";
20
+ import type { MailFailureKind } from "@remit/logger";
21
+ import { RefreshTokenError } from "@remit/mail-oauth-service";
22
+ import { serializeEncryptedPayload } from "@remit/secrets-service";
23
+ import { SmtpConnectionError } from "@remit/smtp-service";
22
24
  import {
23
- getSendMessageMaxAttempts,
24
- SEND_MESSAGE_MAX_ATTEMPTS,
25
- type SendMessageDeps,
26
- sendMessage,
27
- } from "./send-message-core.js";
28
-
29
- const silentLogger = {
30
- info: () => {},
31
- warn: () => {},
32
- error: () => {},
33
- debug: () => {},
34
- trace: () => {},
35
- fatal: () => {},
36
- child: () => silentLogger,
37
- } as never;
38
-
39
- const buildOutbox = (
40
- overrides: Partial<OutboxMessageItem> = {},
41
- ): OutboxMessageItem =>
42
- ({
43
- outboxMessageId: "obx-1",
44
- accountId: "acc-1",
45
- accountConfigId: "cfg-1",
46
- fromAddress: "alice@example.com",
47
- toAddresses: ["bob@example.com"],
48
- messageIdValue: "msg-1@example.com",
49
- status: "queued",
50
- createdAt: 0,
51
- updatedAt: 0,
52
- ...overrides,
53
- }) as unknown as OutboxMessageItem;
54
-
55
- const buildAccount = (overrides: Partial<AccountItem> = {}): AccountItem =>
56
- ({
57
- accountId: "acc-1",
58
- accountConfigId: "cfg-1",
59
- username: "alice@example.com",
60
- email: "alice@example.com",
61
- passwordHash: JSON.stringify(
62
- serializeEncryptedPayload({
25
+ type FakeSmtp,
26
+ type FakeSmtpOptions,
27
+ startFakeSmtp,
28
+ } from "@remit/smtp-service/test-fake-smtp";
29
+ import { createMockStorageService } from "@remit/storage-service";
30
+ import type { SendMessageEvent } from "../events.js";
31
+ import { handleSendMessage, type SendMessageContext } from "./send-message.js";
32
+
33
+ const MAX_ATTEMPTS = 3;
34
+
35
+ const storedSecret = (plaintext: string): string =>
36
+ JSON.stringify(
37
+ serializeEncryptedPayload({
38
+ encryptedDek: Buffer.from("dek"),
39
+ encryptedData: Buffer.from(plaintext),
40
+ iv: Buffer.from("iv"),
41
+ authTag: Buffer.from("tag"),
42
+ }),
43
+ );
44
+
45
+ interface SocketRequest {
46
+ host: string | undefined;
47
+ port: string | number | undefined;
48
+ }
49
+
50
+ interface World {
51
+ repos: ReturnType<typeof createSqliteRepositories>;
52
+ smtp: FakeSmtp;
53
+ queue: RecordingQueue;
54
+ storage: ReturnType<typeof createMockStorageService>;
55
+ requests: SocketRequest[];
56
+ failures: MailFailureKind[];
57
+ tokenRequests: string[];
58
+ ctx: SendMessageContext;
59
+ close: () => void;
60
+ }
61
+
62
+ const openWorld = async (smtpOptions: FakeSmtpOptions = {}): Promise<World> => {
63
+ const store = createShippedSqliteDb();
64
+ const repos = createSqliteRepositories({
65
+ app: store.db,
66
+ mail: store.db,
67
+ search: store.db,
68
+ });
69
+ const smtp = await startFakeSmtp(smtpOptions);
70
+ const queue = recordingQueue();
71
+ const storage = createMockStorageService();
72
+ const requests: SocketRequest[] = [];
73
+ const failures: MailFailureKind[] = [];
74
+ const tokenRequests: string[] = [];
75
+ const ctx: SendMessageContext = {
76
+ ports: repos,
77
+ secrets: {
78
+ encrypt: async (plaintext) => ({
63
79
  encryptedDek: Buffer.from("dek"),
64
- encryptedData: Buffer.from("data"),
80
+ encryptedData: Buffer.from(plaintext),
65
81
  iv: Buffer.from("iv"),
66
82
  authTag: Buffer.from("tag"),
67
83
  }),
68
- ),
69
- imapHost: "imap.example.com",
84
+ decrypt: async (payload) => payload.encryptedData.toString(),
85
+ },
86
+ storage,
87
+ messageManagement: queue,
88
+ tokenService: {
89
+ getAccessToken: async (accountId) => {
90
+ tokenRequests.push(accountId);
91
+ return { accessToken: "access-token", expiresAt: 0, grantedScopes: [] };
92
+ },
93
+ },
94
+ getSocket: (request, callback) => {
95
+ requests.push({ host: request.host, port: request.port });
96
+ const connection = net.connect(smtp.port, "127.0.0.1", () =>
97
+ callback(null, { connection }),
98
+ );
99
+ },
100
+ maxAttempts: MAX_ATTEMPTS,
101
+ recordSmtpFailure: (kind) => failures.push(kind),
102
+ };
103
+ return {
104
+ repos,
105
+ smtp,
106
+ queue,
107
+ storage,
108
+ requests,
109
+ failures,
110
+ tokenRequests,
111
+ ctx,
112
+ close: () => {
113
+ smtp.close();
114
+ store.close();
115
+ },
116
+ };
117
+ };
118
+
119
+ interface Arranged {
120
+ accountId: string;
121
+ accountConfigId: string;
122
+ outboxMessageId: string;
123
+ event: SendMessageEvent;
124
+ }
125
+
126
+ const arrange = async (
127
+ w: World,
128
+ options: {
129
+ account?: Partial<CreateAccountInput>;
130
+ outbox?: Partial<CreateOutboxMessageInput>;
131
+ } = {},
132
+ ): Promise<Arranged> => {
133
+ const account = await w.repos.account.create({
134
+ accountConfigId: "cfg-1",
135
+ username: "alice@example.com",
136
+ email: "alice@example.com",
137
+ passwordHash: storedSecret("smtp-password"),
138
+ imapHost: "imap.example.invalid",
70
139
  imapPort: 993,
71
140
  imapTls: true,
72
141
  imapStartTls: false,
73
142
  smtpEnabled: true,
74
- smtpHost: "smtp.example.com",
143
+ smtpHost: "smtp.example.invalid",
75
144
  smtpPort: 587,
76
145
  smtpTls: false,
77
146
  smtpStartTls: true,
78
- smtpUsername: "",
79
147
  isActive: true,
80
- connectionState: "not_authenticated",
81
- createdAt: 0,
82
- updatedAt: 0,
83
- ...overrides,
84
- }) as unknown as AccountItem;
85
-
86
- interface Recorded {
87
- updates: Array<{ id: string; patch: UpdateOutboxMessageInput }>;
88
- statuses: Array<{ id: string; status: OutboxMessageItem["status"] }>;
89
- marked: Array<{ id: string; sentAt: number; smtpMessageId?: string }>;
90
- appendCalls: Array<{ accountId: string; outboxMessageId: string }>;
91
- sendCalls: number;
92
- resolveCalls: number;
93
- connectionStateUpdates: Array<{ accountId: string; state: string }>;
94
- conditionalUpdates: Array<{
95
- id: string;
96
- expected: OutboxMessageItem["status"];
97
- patch: UpdateOutboxMessageInput;
98
- }>;
99
- /** Ordered names of the writes the handler made, across every recorder. */
100
- writeOrder: string[];
101
- outboundIncrements: Array<{ addressId: string; now: number }>;
102
- replyIncrements: Array<{ addressId: string; now: number }>;
103
- }
104
-
105
- const buildDeps = (
106
- options: {
107
- outbox?: OutboxMessageItem;
108
- account?: AccountItem;
109
- sendResult?: SendResult;
110
- appendThrows?: Error;
111
- resolveCredentials?: SendMessageDeps["resolveCredentials"];
112
- send?: SendMessageDeps["send"];
113
- attachments?: MailAttachment[];
114
- attachmentsFail?: Error;
115
- } = {},
116
- ): { deps: SendMessageDeps; recorded: Recorded } => {
117
- const recorded: Recorded = {
118
- updates: [],
119
- statuses: [],
120
- marked: [],
121
- appendCalls: [],
122
- sendCalls: 0,
123
- resolveCalls: 0,
124
- connectionStateUpdates: [],
125
- conditionalUpdates: [],
126
- writeOrder: [],
127
- outboundIncrements: [],
128
- replyIncrements: [],
129
- };
130
- const outbox = options.outbox ?? buildOutbox();
131
- const account = options.account ?? buildAccount();
132
- let currentStatus: OutboxMessageItem["status"] = outbox.status;
133
- const deps: SendMessageDeps = {
134
- getOutbox: async (accountConfigId, id) => {
135
- assert.equal(accountConfigId, account.accountConfigId);
136
- assert.equal(id, outbox.outboxMessageId);
137
- return outbox;
138
- },
139
- getAccount: async (id) => {
140
- assert.equal(id, account.accountId);
141
- return account;
142
- },
143
- updateOutbox: async (accountConfigId, id, patch) => {
144
- assert.equal(accountConfigId, account.accountConfigId);
145
- if (patch.status) currentStatus = patch.status;
146
- recorded.writeOrder.push("outbox");
147
- recorded.updates.push({ id, patch });
148
- },
149
- updateOutboxStatus: async (accountConfigId, id, status) => {
150
- assert.equal(accountConfigId, account.accountConfigId);
151
- currentStatus = status;
152
- recorded.statuses.push({ id, status });
153
- },
154
- // Compare-and-set, as the port implements it: the write lands only while
155
- // the row still holds `expected`, and answers null when it does not.
156
- updateOutboxIfStatus: async (accountConfigId, id, expected, patch) => {
157
- assert.equal(accountConfigId, account.accountConfigId);
158
- recorded.conditionalUpdates.push({ id, expected, patch });
159
- if (expected !== currentStatus) return null;
160
- if (patch.status) currentStatus = patch.status;
161
- recorded.writeOrder.push("outbox");
162
- recorded.updates.push({ id, patch });
163
- return {};
164
- },
165
- markOutboxSent: async (accountConfigId, id, fields) => {
166
- assert.equal(accountConfigId, account.accountConfigId);
167
- recorded.marked.push({ id, ...fields });
168
- },
169
- secrets: {
170
- decrypt: async (payload: EncryptedPayload): Promise<string> =>
171
- payload.encryptedDek.toString(),
172
- },
173
- // Stub credential resolver: returns a password credential from the account's
174
- // encrypted passwordHash (mirroring what resolveConnectionCredentials does in prod).
175
- resolveCredentials:
176
- options.resolveCredentials ??
177
- (async (_account) => {
178
- recorded.resolveCalls += 1;
179
- return {
180
- status: "resolved" as const,
181
- credentials: {
182
- kind: "password" as const,
183
- password: _account.passwordHash
184
- ? "resolved-password"
185
- : "no-password-configured",
186
- },
187
- };
188
- }),
189
- updateConnectionState: async (accountId, state) => {
190
- recorded.writeOrder.push("connectionState");
191
- recorded.connectionStateUpdates.push({ accountId, state });
192
- },
193
- send:
194
- options.send ??
195
- (async () => {
196
- recorded.sendCalls += 1;
197
- return (
198
- options.sendResult ?? {
199
- success: true,
200
- messageId: "smtp-mid-1",
201
- isTransient: false,
202
- }
203
- );
204
- }),
205
- loadAttachments: async (tenant, outboxMessageId) => {
206
- assert.equal(tenant.accountConfigId, account.accountConfigId);
207
- assert.equal(tenant.accountId, account.accountId);
208
- assert.equal(outboxMessageId, outbox.outboxMessageId);
209
- if (options.attachmentsFail) throw options.attachmentsFail;
210
- return options.attachments ?? [];
148
+ connectionState: ConnectionState.Authenticated,
149
+ ...options.account,
150
+ });
151
+ const outbox = await w.repos.outboxMessage.create({
152
+ accountId: account.accountId,
153
+ accountConfigId: account.accountConfigId,
154
+ fromAddress: "alice@example.com",
155
+ toAddresses: ["bob@example.com"],
156
+ subject: "Quarterly numbers",
157
+ textBody: "Hello Bob",
158
+ messageIdValue: "<send-1@example.com>",
159
+ status: OutboxMessageStatus.queued,
160
+ ...options.outbox,
161
+ });
162
+ return {
163
+ accountId: account.accountId,
164
+ accountConfigId: account.accountConfigId,
165
+ outboxMessageId: outbox.outboxMessageId,
166
+ event: {
167
+ type: "SEND_MESSAGE",
168
+ eventId: "evt-1",
169
+ timestamp: 0,
170
+ accountId: account.accountId,
171
+ outboxMessageId: outbox.outboxMessageId,
211
172
  },
212
- emitAppendSentMessage: async (accountId, outboxMessageId) => {
213
- recorded.appendCalls.push({ accountId, outboxMessageId });
214
- if (options.appendThrows) throw options.appendThrows;
173
+ };
174
+ };
175
+
176
+ const outboxRow = (w: World, a: Arranged) =>
177
+ w.repos.outboxMessage.get(a.accountConfigId, a.outboxMessageId);
178
+
179
+ const accountRow = (w: World, a: Arranged) => w.repos.account.get(a.accountId);
180
+
181
+ const attach = async (
182
+ w: World,
183
+ a: Arranged,
184
+ file: { filename: string; content: Buffer | null },
185
+ ): Promise<void> => {
186
+ const outboxAttachmentId = `att-${file.filename}`;
187
+ const reserved = await w.repos.outboxAttachment.reserve(
188
+ {
189
+ outboxAttachmentId,
190
+ outboxMessageId: a.outboxMessageId,
191
+ accountId: a.accountId,
192
+ accountConfigId: a.accountConfigId,
193
+ filename: file.filename,
194
+ contentType: "application/pdf",
195
+ sizeBytes: file.content?.length ?? 4,
196
+ storageKey: `key/${file.filename}`,
197
+ reservationExpiresAt: Math.floor(Date.now() / 1000) + 3600,
215
198
  },
216
- engagement: {
217
- resolveAddressId: (accountConfigId, email) =>
218
- `addr-${accountConfigId}-${email}`,
219
- incrementOutboundCount: async (accountConfigId, addressId, now) => {
220
- assert.equal(accountConfigId, account.accountConfigId);
221
- recorded.outboundIncrements.push({ addressId, now });
222
- },
223
- incrementReplyCount: async (accountConfigId, addressId, now) => {
224
- assert.equal(accountConfigId, account.accountConfigId);
225
- recorded.replyIncrements.push({ addressId, now });
226
- },
227
- findMessageByHeader: async () => null,
228
- getEnvelopeFromEmail: async () => null,
199
+ {
200
+ maxTotalBytes: 1_000_000,
201
+ maxCount: 10,
202
+ nowSeconds: Math.floor(Date.now() / 1000),
229
203
  },
204
+ );
205
+ assert.equal(reserved.outcome, "Reserved");
206
+ await w.repos.outboxAttachment.markStored(
207
+ a.accountConfigId,
208
+ outboxAttachmentId,
209
+ file.content?.length ?? 4,
210
+ );
211
+ if (file.content === null) return;
212
+ await w.storage.storeOutboxAttachment({
213
+ accountConfigId: a.accountConfigId,
214
+ accountId: a.accountId,
215
+ outboxMessageId: a.outboxMessageId,
216
+ outboxAttachmentId,
217
+ content: file.content,
218
+ });
219
+ };
220
+
221
+ const refusingSocket =
222
+ (code: string): SendMessageContext["getSocket"] =>
223
+ (_request, callback) => {
224
+ callback(Object.assign(new Error(`connect ${code}`), { code }), false);
230
225
  };
231
- return { deps, recorded };
226
+
227
+ const seedAddress = async (w: World, email: string): Promise<string> => {
228
+ const addressId = deriveAddressId("cfg-1", email);
229
+ const [localPart = "", domain = ""] = email.split("@");
230
+ await w.repos.address.upsertAddress({
231
+ addressId,
232
+ accountConfigId: "cfg-1",
233
+ localPart,
234
+ domain,
235
+ normalizedEmail: email,
236
+ normalizedCompound: email,
237
+ });
238
+ return addressId;
232
239
  };
233
240
 
234
- const event: SendMessageEvent = {
235
- type: "SEND_MESSAGE",
236
- eventId: "evt-1",
237
- timestamp: 0,
238
- accountId: "acc-1",
239
- outboxMessageId: "obx-1",
241
+ const assertBlockedOnReauth = async (w: World, a: Arranged): Promise<void> => {
242
+ const row = await outboxRow(w, a);
243
+ assert.equal(row.status, OutboxMessageStatus.blocked);
244
+ assert.match(String(row.lastError), /reconnected/i);
245
+ assert.match(String(row.lastError), /Reconnect/);
240
246
  };
241
247
 
242
- describe("sendMessage handler", () => {
243
- it("retries a message whose files could not be read, below the budget", async () => {
244
- const { deps, recorded } = buildDeps({
245
- outbox: buildOutbox({ status: "queued" }),
246
- attachmentsFail: new OutboxAttachmentUnavailableError(
247
- "payroll.xlsx",
248
- "nothing is stored at key/payroll",
249
- ),
250
- });
248
+ let w: World;
251
249
 
252
- await assert.rejects(() => sendMessage(event, silentLogger, deps, 1));
250
+ beforeEach(async () => {
251
+ w = await openWorld();
252
+ });
253
253
 
254
- assert.equal(recorded.sendCalls, 0);
255
- assert.deepEqual(recorded.updates, []);
256
- assert.deepEqual(recorded.statuses, [], "never moved to sending");
257
- });
254
+ afterEach(() => w.close());
258
255
 
259
- it("settles a message whose files cannot be read as failed, naming the file, at the budget", async () => {
260
- const { deps, recorded } = buildDeps({
261
- outbox: buildOutbox({ status: "queued" }),
262
- attachmentsFail: new OutboxAttachmentUnavailableError(
263
- "payroll.xlsx",
264
- "nothing is stored at key/payroll",
265
- ),
256
+ describe("handleSendMessage", () => {
257
+ it("sends over the context's socket, marks the row sent and queues the Sent copy", async () => {
258
+ const a = await arrange(w, {
259
+ outbox: { lastError: "stale error from a prior attempt" },
266
260
  });
267
261
 
268
- await sendMessage(event, silentLogger, deps, SEND_MESSAGE_MAX_ATTEMPTS);
262
+ await handleSendMessage(a.event, w.ctx, 1);
269
263
 
270
- assert.equal(recorded.sendCalls, 0);
271
- assert.equal(recorded.updates.length, 1);
272
- assert.equal(recorded.updates[0].patch.status, "failed");
273
- assert.equal(
274
- recorded.updates[0].patch.lastError,
275
- '"payroll.xlsx" could not be read: nothing is stored at key/payroll, so this message was not sent. Remove "payroll.xlsx", attach it again, and send.',
264
+ assert.deepEqual(w.requests, [{ host: "smtp.example.invalid", port: 587 }]);
265
+ assert.match(w.smtp.received.join(""), /Subject: Quarterly numbers/);
266
+ const row = await outboxRow(w, a);
267
+ assert.equal(row.status, OutboxMessageStatus.sent);
268
+ assert.equal(row.lastError, undefined);
269
+ assert.deepEqual(
270
+ w.queue.sent.map(({ body }) => [
271
+ body.type,
272
+ body.accountId,
273
+ body.outboxMessageId,
274
+ ]),
275
+ [["APPEND_SENT_MESSAGE", a.accountId, a.outboxMessageId]],
276
276
  );
277
277
  });
278
278
 
279
279
  it("sends every file the draft carries as a part of the message", async () => {
280
- const attachments: MailAttachment[] = [
281
- {
282
- filename: "report.pdf",
283
- contentType: "application/pdf",
284
- content: Buffer.from("%PDF-1.4"),
285
- },
286
- ];
287
- const sent: MailMessage[] = [];
288
- const { deps } = buildDeps({
289
- attachments,
290
- send: async (_config, message) => {
291
- sent.push(message);
292
- return { success: true, messageId: "smtp-mid-1", isTransient: false };
293
- },
280
+ const a = await arrange(w);
281
+ await attach(w, a, {
282
+ filename: "report.pdf",
283
+ content: Buffer.from("%PDF-1.4"),
294
284
  });
295
285
 
296
- await sendMessage(event, silentLogger, deps);
286
+ await handleSendMessage(a.event, w.ctx, 1);
297
287
 
298
- assert.equal(sent.length, 1);
299
- assert.deepEqual(sent[0].attachments, attachments);
288
+ assert.match(w.smtp.received.join(""), /filename=report\.pdf/);
289
+ assert.equal((await outboxRow(w, a)).status, OutboxMessageStatus.sent);
300
290
  });
301
291
 
302
- it("marks status `blocked` when SMTP is disabled — never `sent`", async () => {
303
- const { deps, recorded } = buildDeps({
304
- account: buildAccount({ smtpEnabled: false }),
305
- });
292
+ it("retries a message whose files could not be read, below the budget", async () => {
293
+ const a = await arrange(w);
294
+ await attach(w, a, { filename: "payroll.xlsx", content: null });
306
295
 
307
- await sendMessage(event, silentLogger, deps);
296
+ await assert.rejects(() => handleSendMessage(a.event, w.ctx, 1));
308
297
 
309
- assert.equal(recorded.sendCalls, 0, "send must not be invoked");
310
- assert.equal(recorded.marked.length, 0, "must not mark as sent");
311
- assert.equal(recorded.updates.length, 1);
312
- assert.equal(recorded.updates[0].patch.status, "blocked");
313
- assert.match(
314
- String(recorded.updates[0].patch.lastError),
315
- /SMTP not configured/,
316
- );
317
- assert.equal(
318
- recorded.statuses.length,
319
- 0,
320
- "must not flicker through `sending`",
321
- );
322
- assert.equal(
323
- recorded.appendCalls.length,
324
- 0,
325
- "must not enqueue APPEND_SENT_MESSAGE",
326
- );
298
+ assert.deepEqual(w.requests, [], "never dialled the server");
299
+ assert.equal((await outboxRow(w, a)).status, OutboxMessageStatus.queued);
327
300
  });
328
301
 
329
- it("marks status `failed` (not `sent`) on permanent SMTP error", async () => {
330
- const { deps, recorded } = buildDeps({
331
- account: buildAccount({
332
- smtpHost: "smtp.example.com",
333
- smtpPort: 587,
334
- }),
335
- sendResult: {
336
- success: false,
337
- error: new Error("auth failed") as Error & { responseCode?: number },
338
- smtpCode: 535,
339
- isTransient: false,
340
- },
341
- });
302
+ it("settles a message whose files cannot be read as failed, naming the file, at the budget", async () => {
303
+ const a = await arrange(w);
304
+ await attach(w, a, { filename: "payroll.xlsx", content: null });
342
305
 
343
- await sendMessage(event, silentLogger, deps);
306
+ await handleSendMessage(a.event, w.ctx, MAX_ATTEMPTS);
344
307
 
345
- assert.equal(recorded.sendCalls, 1);
346
- assert.equal(recorded.marked.length, 0);
347
- const failedUpdate = recorded.updates.find(
348
- (u) => u.patch.status === "failed",
349
- );
350
- assert.ok(failedUpdate, "should write a failed update");
351
- assert.equal(failedUpdate.patch.lastSmtpCode, 535);
352
- assert.equal(failedUpdate.patch.lastError, "auth failed");
353
- assert.equal(
354
- recorded.appendCalls.length,
355
- 0,
356
- "must not enqueue APPEND_SENT_MESSAGE",
308
+ assert.deepEqual(w.requests, []);
309
+ const row = await outboxRow(w, a);
310
+ assert.equal(row.status, OutboxMessageStatus.failed);
311
+ assert.match(
312
+ String(row.lastError),
313
+ /^"payroll\.xlsx" could not be read: .*, so this message was not sent\. Remove "payroll\.xlsx", attach it again, and send\.$/,
357
314
  );
358
315
  });
359
316
 
360
- it("marks `sent` and clears prior error fields on success", async () => {
361
- const { deps, recorded } = buildDeps({
362
- outbox: buildOutbox({
363
- lastError: "stale error from prior attempt",
364
- lastSmtpCode: 421,
365
- }),
366
- account: buildAccount({ smtpHost: "smtp.example.com", smtpPort: 587 }),
367
- });
317
+ it("marks status `blocked` when SMTP is disabled — never `sent`", async () => {
318
+ const a = await arrange(w, { account: { smtpEnabled: false } });
368
319
 
369
- await sendMessage(event, silentLogger, deps);
320
+ await handleSendMessage(a.event, w.ctx, 1);
370
321
 
371
- assert.equal(recorded.sendCalls, 1);
372
- assert.equal(recorded.marked.length, 1);
373
- assert.equal(recorded.marked[0].id, "obx-1");
374
- assert.equal(recorded.marked[0].smtpMessageId, "smtp-mid-1");
375
- assert.equal(
376
- recorded.appendCalls.length,
377
- 1,
378
- "must enqueue APPEND_SENT_MESSAGE",
379
- );
380
- // `markOutboxSent` is the seam that clears lastError/lastSmtpCode.
381
- // We do not assert on `update` calls writing those fields — only that
382
- // `markOutboxSent` was used (not the legacy generic update).
322
+ assert.deepEqual(w.requests, [], "send must not be attempted");
323
+ const row = await outboxRow(w, a);
324
+ assert.equal(row.status, OutboxMessageStatus.blocked);
325
+ assert.match(String(row.lastError), /SMTP not configured/);
326
+ assert.deepEqual(w.queue.sent, [], "must not enqueue APPEND_SENT_MESSAGE");
383
327
  });
384
328
 
385
- it("reverts to `queued` and rethrows on transient SMTP error", async () => {
386
- const { deps, recorded } = buildDeps({
387
- account: buildAccount({ smtpHost: "smtp.example.com", smtpPort: 587 }),
388
- sendResult: {
389
- success: false,
390
- error: new Error("temporarily unavailable"),
391
- smtpCode: 421,
392
- isTransient: true,
393
- },
394
- });
329
+ it("marks status `failed` (not `sent`) on a permanent SMTP refusal, counting no engagement", async () => {
330
+ w.close();
331
+ w = await openWorld({ dataFinalResponse: "554 5.7.1 Rejected\r\n" });
332
+ const a = await arrange(w);
333
+ const bob = await seedAddress(w, "bob@example.com");
395
334
 
396
- await assert.rejects(
397
- () => sendMessage(event, silentLogger, deps),
398
- /SMTP transient error/,
399
- );
335
+ await handleSendMessage(a.event, w.ctx, 1);
400
336
 
401
- const requeued = recorded.statuses.find((s) => s.status === "queued");
402
- assert.ok(requeued, "should revert to queued for SQS retry");
403
- assert.equal(recorded.marked.length, 0, "must not mark as sent");
337
+ const row = await outboxRow(w, a);
338
+ assert.equal(row.status, OutboxMessageStatus.failed);
339
+ assert.equal(row.lastSmtpCode, 554);
340
+ assert.deepEqual(w.queue.sent, []);
341
+ const address = await w.repos.address.getAddress("cfg-1", bob);
342
+ assert.equal(address.outboundCount ?? 0, 0);
404
343
  });
405
344
 
406
- it("skips processing if message is already `sent` (idempotent)", async () => {
407
- const { deps, recorded } = buildDeps({
408
- outbox: buildOutbox({ status: "sent" }),
409
- });
345
+ it("reverts to `queued` and rethrows on a transient SMTP refusal", async () => {
346
+ w.close();
347
+ w = await openWorld({ dataFinalResponse: "451 4.3.0 Try later\r\n" });
348
+ const a = await arrange(w);
410
349
 
411
- await sendMessage(event, silentLogger, deps);
350
+ await assert.rejects(
351
+ () => handleSendMessage(a.event, w.ctx, 1),
352
+ /SMTP transient error/,
353
+ );
412
354
 
413
- assert.equal(recorded.sendCalls, 0);
414
- assert.equal(recorded.marked.length, 0);
415
- assert.equal(recorded.updates.length, 0);
416
- assert.equal(recorded.statuses.length, 0);
355
+ assert.equal((await outboxRow(w, a)).status, OutboxMessageStatus.queued);
417
356
  });
418
357
 
419
- it("never re-sends a message that was sent but could not be filed", async () => {
420
- // The redelivery fence is the only thing between SQS at-least-once and a
421
- // second copy on the wire. `unfiled` is past the send, so it belongs on
422
- // the same side of the fence as `sent`.
423
- const { deps, recorded } = buildDeps({
424
- outbox: buildOutbox({ status: "unfiled" }),
425
- });
426
-
427
- await sendMessage(event, silentLogger, deps);
358
+ for (const status of [
359
+ OutboxMessageStatus.sent,
360
+ OutboxMessageStatus.unfiled,
361
+ OutboxMessageStatus.failed,
362
+ ]) {
363
+ it(`never re-sends a row already at \`${status}\``, async () => {
364
+ const a = await arrange(w, { outbox: { status } });
428
365
 
429
- assert.equal(recorded.sendCalls, 0);
430
- assert.equal(recorded.marked.length, 0);
431
- assert.equal(recorded.updates.length, 0);
432
- });
366
+ await handleSendMessage(a.event, w.ctx, 1);
433
367
 
434
- it("drops the event for a row the enqueue settled at `failed`", async () => {
435
- // The other half of #936. An error from SQS `SendMessage` says the response
436
- // was lost, not that the broker refused the event, so the row is settled
437
- // outside this fence and the event that landed anyway dies here. Settling
438
- // it at `draft` instead would send it — and leave the user a row they can
439
- // send a second time.
440
- const { deps, recorded } = buildDeps({
441
- outbox: buildOutbox({ status: "failed" }),
368
+ assert.deepEqual(w.requests, []);
369
+ assert.equal((await outboxRow(w, a)).status, status);
442
370
  });
443
-
444
- await sendMessage(event, silentLogger, deps);
445
-
446
- assert.equal(recorded.sendCalls, 0, "must not send");
447
- assert.equal(recorded.marked.length, 0);
448
- assert.equal(recorded.updates.length, 0);
449
- assert.equal(recorded.statuses.length, 0);
450
- });
371
+ }
451
372
 
452
373
  it("settles the row as unfiled when the append event cannot be queued", async () => {
453
- const { deps, recorded } = buildDeps({
454
- account: buildAccount({ smtpHost: "smtp.example.com", smtpPort: 587 }),
455
- appendThrows: new Error("queue unreachable"),
456
- });
374
+ const a = await arrange(w);
375
+ w.queue.answer(() => Promise.reject(new Error("queue unreachable")));
457
376
 
458
- await sendMessage(event, silentLogger, deps);
377
+ await handleSendMessage(a.event, w.ctx, 1);
459
378
 
460
- // The send itself succeeded. Without a settle the row keeps the `sent`
461
- // status the Outbox list hides, and no APPEND is ever going to delete it.
462
- assert.equal(recorded.marked.length, 1);
463
- const patch = recorded.updates.at(-1)?.patch;
464
- assert.equal(patch?.status, "unfiled");
465
- assert.match(String(patch?.lastError), /could not be queued/);
379
+ const row = await outboxRow(w, a);
380
+ assert.equal(row.status, OutboxMessageStatus.unfiled);
381
+ assert.match(String(row.lastError), /could not be queued/);
466
382
  });
467
383
 
468
- it("drops event when account is deleted (tombstone fence)", async () => {
469
- const { deps, recorded } = buildDeps({
470
- account: buildAccount({
471
- smtpHost: "smtp.example.com",
472
- smtpPort: 587,
473
- deletedAt: Date.now(),
474
- }),
475
- });
384
+ it("drops the event when the account is deleted (tombstone fence)", async () => {
385
+ const a = await arrange(w, { account: { deletedAt: Date.now() } });
476
386
 
477
- await sendMessage(event, silentLogger, deps);
387
+ await handleSendMessage(a.event, w.ctx, 1);
478
388
 
479
- assert.equal(recorded.sendCalls, 0, "must not send");
480
- assert.equal(recorded.marked.length, 0, "must not mark as sent");
481
- assert.equal(recorded.updates.length, 0, "must not update outbox");
482
- assert.equal(recorded.statuses.length, 0, "must not change status");
483
- assert.equal(recorded.appendCalls.length, 0, "must not enqueue append");
389
+ assert.deepEqual(w.requests, []);
390
+ assert.equal((await outboxRow(w, a)).status, OutboxMessageStatus.queued);
391
+ assert.deepEqual(w.queue.sent, []);
484
392
  });
485
393
 
486
- it("increments outboundCount once per unique recipient on successful send", async () => {
487
- const { deps, recorded } = buildDeps({
488
- outbox: buildOutbox({
394
+ it("increments outboundCount once per unique recipient on a successful send", async () => {
395
+ const a = await arrange(w, {
396
+ outbox: {
489
397
  toAddresses: ["bob@example.com", "BOB@example.com"],
490
398
  ccAddresses: ["carol@example.com"],
491
399
  bccAddresses: ["dave@example.com"],
492
- }),
493
- account: buildAccount({ smtpHost: "smtp.example.com", smtpPort: 587 }),
494
- });
495
-
496
- await sendMessage(event, silentLogger, deps);
497
-
498
- assert.equal(recorded.marked.length, 1, "send must succeed");
499
- const incrementedAddressIds = recorded.outboundIncrements
500
- .map((i) => i.addressId)
501
- .sort();
502
- assert.deepEqual(incrementedAddressIds, [
503
- "addr-cfg-1-bob@example.com",
504
- "addr-cfg-1-carol@example.com",
505
- "addr-cfg-1-dave@example.com",
506
- ]);
507
- assert.equal(recorded.replyIncrements.length, 0);
508
- });
509
-
510
- it("does not increment counters when send fails", async () => {
511
- const { deps, recorded } = buildDeps({
512
- account: buildAccount({ smtpHost: "smtp.example.com", smtpPort: 587 }),
513
- sendResult: {
514
- success: false,
515
- error: new Error("auth failed") as Error & { responseCode?: number },
516
- smtpCode: 535,
517
- isTransient: false,
518
400
  },
519
401
  });
520
-
521
- await sendMessage(event, silentLogger, deps);
522
-
523
- assert.equal(recorded.outboundIncrements.length, 0);
524
- assert.equal(recorded.replyIncrements.length, 0);
525
- });
526
-
527
- it("increments replyCount when In-Reply-To resolves to a recipient", async () => {
528
- const { deps, recorded } = buildDeps({
529
- outbox: buildOutbox({
530
- toAddresses: ["bob@example.com"],
531
- inReplyTo: "parent-msg@example.com",
532
- }),
533
- account: buildAccount({ smtpHost: "smtp.example.com", smtpPort: 587 }),
534
- });
535
- deps.engagement.findMessageByHeader = async () =>
536
- ({ messageId: "msg-parent" }) as unknown as never;
537
- deps.engagement.getEnvelopeFromEmail = async () => "bob@example.com";
538
-
539
- await sendMessage(event, silentLogger, deps);
540
-
541
- assert.equal(recorded.replyIncrements.length, 1);
542
- assert.equal(
543
- recorded.replyIncrements[0].addressId,
544
- "addr-cfg-1-bob@example.com",
402
+ const ids = await Promise.all(
403
+ ["bob@example.com", "carol@example.com", "dave@example.com"].map(
404
+ (email) => seedAddress(w, email),
405
+ ),
545
406
  );
546
- });
547
-
548
- it("does NOT increment replyCount when resolved sender is not a recipient", async () => {
549
- const { deps, recorded } = buildDeps({
550
- outbox: buildOutbox({
551
- toAddresses: ["bob@example.com"],
552
- inReplyTo: "parent-msg@example.com",
553
- }),
554
- account: buildAccount({ smtpHost: "smtp.example.com", smtpPort: 587 }),
555
- });
556
- deps.engagement.findMessageByHeader = async () =>
557
- ({ messageId: "msg-parent" }) as unknown as never;
558
- deps.engagement.getEnvelopeFromEmail = async () => "stranger@example.com";
559
407
 
560
- await sendMessage(event, silentLogger, deps);
408
+ await handleSendMessage(a.event, w.ctx, 1);
561
409
 
562
- assert.equal(recorded.replyIncrements.length, 0);
563
- });
564
-
565
- it("collapses duplicate replyCount increments via the resolved Address", async () => {
566
- const { deps, recorded } = buildDeps({
567
- outbox: buildOutbox({
568
- toAddresses: ["bob@example.com"],
569
- inReplyTo: "parent-msg@example.com",
570
- references: ["root-msg@example.com", "parent-msg@example.com"],
571
- }),
572
- account: buildAccount({ smtpHost: "smtp.example.com", smtpPort: 587 }),
573
- });
574
- deps.engagement.findMessageByHeader = async () =>
575
- ({ messageId: "msg-parent" }) as unknown as never;
576
- deps.engagement.getEnvelopeFromEmail = async () => "bob@example.com";
577
-
578
- await sendMessage(event, silentLogger, deps);
579
-
580
- assert.equal(
581
- recorded.replyIncrements.length,
582
- 1,
583
- "replyCount must increment once per resolved Address",
410
+ const counts = await Promise.all(
411
+ ids.map(
412
+ async (id) =>
413
+ (await w.repos.address.getAddress("cfg-1", id)).outboundCount,
414
+ ),
584
415
  );
416
+ assert.deepEqual(counts, [1, 1, 1]);
585
417
  });
586
418
 
587
- it("resolves a reply target via accountId (account-scoped message identity)", async () => {
588
- const { deps, recorded } = buildDeps({
589
- outbox: buildOutbox({
590
- toAddresses: ["bob@example.com"],
591
- inReplyTo: "parent-msg@example.com",
592
- }),
593
- account: buildAccount({ smtpHost: "smtp.example.com", smtpPort: 587 }),
594
- });
595
-
596
- const headerLookups: string[] = [];
597
- deps.engagement.findMessageByHeader = async (accountId) => {
598
- headerLookups.push(accountId);
599
- return { messageId: "msg-parent" } as unknown as never;
600
- };
601
- deps.engagement.getEnvelopeFromEmail = async () => "bob@example.com";
602
-
603
- await sendMessage(event, silentLogger, deps);
604
-
605
- // Inbound messages are keyed by accountId now, so the reply lookup must
606
- // pass the outbox's accountId ("acc-1"), never its accountConfigId ("cfg-1").
607
- assert.ok(headerLookups.length > 0);
608
- for (const id of headerLookups) {
609
- assert.equal(id, "acc-1");
610
- }
611
- assert.equal(recorded.replyIncrements.length, 1);
612
- });
613
-
614
- it("does not fail the send when engagement counters throw", async () => {
615
- const { deps, recorded } = buildDeps({
616
- account: buildAccount({ smtpHost: "smtp.example.com", smtpPort: 587 }),
617
- });
618
- deps.engagement.incrementOutboundCount = async () => {
619
- throw new Error("simulated DDB outage");
419
+ it("does not fail the send when the engagement counters throw", async () => {
420
+ const a = await arrange(w);
421
+ const address = Object.create(w.repos.address);
422
+ address.incrementOutboundCount = async () => {
423
+ throw new Error("simulated outage");
620
424
  };
621
425
 
622
- await sendMessage(event, silentLogger, deps);
426
+ await handleSendMessage(
427
+ a.event,
428
+ { ...w.ctx, ports: { ...w.repos, address } },
429
+ 1,
430
+ );
623
431
 
624
- assert.equal(recorded.marked.length, 1, "send must still be marked sent");
625
- assert.equal(recorded.appendCalls.length, 1, "append must still emit");
432
+ assert.equal((await outboxRow(w, a)).status, OutboxMessageStatus.sent);
433
+ assert.equal(w.queue.sent.length, 1, "append must still emit");
626
434
  });
627
435
  });
628
436
 
629
- /**
630
- * The row is the only place the user learns a send stopped on re-auth, so every
631
- * branch that abandons the send has to leave a `blocked` row naming the remedy
632
- * (issue #1152).
633
- */
634
- const assertBlockedOnReauth = (recorded: Recorded): void => {
635
- const settled = recorded.updates.at(-1)?.patch;
636
- assert.equal(settled?.status, "blocked");
637
- assert.match(String(settled?.lastError), /reconnected/i);
638
- assert.match(String(settled?.lastError), /Reconnect/);
639
- };
437
+ describe("handleSendMessage OAuth reauth/ACK contract", () => {
438
+ const oauthAccount = {
439
+ authType: AccountAuthType.OauthMicrosoft,
440
+ passwordHash: undefined,
441
+ oauthRefreshTokenHash: storedSecret("refresh-token"),
442
+ };
640
443
 
641
- describe("sendMessage OAuth reauth/ACK contract", () => {
642
444
  it("blocks the message when the account already needs reauth (issue #1152)", async () => {
643
- const { deps, recorded } = buildDeps({
644
- account: buildAccount({
645
- smtpHost: "smtp.example.com",
646
- smtpPort: 587,
647
- connectionState: "reauth_required",
648
- }),
445
+ const a = await arrange(w, {
446
+ account: { ...oauthAccount, connectionState: "reauth_required" },
649
447
  });
650
448
 
651
- await sendMessage(event, silentLogger, deps);
449
+ await handleSendMessage(a.event, w.ctx, 1);
652
450
 
653
- assert.equal(recorded.resolveCalls, 0, "must not resolve credentials");
654
- assert.equal(recorded.sendCalls, 0, "must not send");
655
- assert.equal(
656
- recorded.connectionStateUpdates.length,
657
- 0,
658
- "must not flip connectionState",
659
- );
660
- assertBlockedOnReauth(recorded);
661
- assert.equal(recorded.statuses.length, 0, "must not change status");
451
+ assert.deepEqual(w.tokenRequests, [], "must not resolve credentials");
452
+ assert.deepEqual(w.requests, [], "must not send");
453
+ assert.equal((await accountRow(w, a)).connectionState, "reauth_required");
454
+ await assertBlockedOnReauth(w, a);
662
455
  });
663
456
 
664
- it("on RefreshTokenError reauth-required during credential resolution: flips to reauth_required and ACKs", async () => {
665
- const { deps, recorded } = buildDeps({
666
- account: buildAccount({ smtpHost: "smtp.example.com", smtpPort: 587 }),
667
- resolveCredentials: async () => {
668
- throw new RefreshTokenError({
669
- kind: "reauth-required",
670
- code: "invalid_grant",
671
- });
672
- },
673
- });
457
+ it("flips to reauth_required and ACKs when the refresh token is revoked", async () => {
458
+ const a = await arrange(w, { account: oauthAccount });
674
459
 
675
- await sendMessage(event, silentLogger, deps);
460
+ await handleSendMessage(
461
+ a.event,
462
+ {
463
+ ...w.ctx,
464
+ tokenService: {
465
+ getAccessToken: async () => {
466
+ throw new RefreshTokenError({
467
+ kind: "reauth-required",
468
+ code: "invalid_grant",
469
+ });
470
+ },
471
+ },
472
+ },
473
+ 1,
474
+ );
676
475
 
677
- assert.equal(recorded.sendCalls, 0, "must not send");
678
- assert.equal(recorded.connectionStateUpdates.length, 1);
679
- assert.deepEqual(recorded.connectionStateUpdates[0], {
680
- accountId: "acc-1",
681
- state: "reauth_required",
682
- });
683
- assertBlockedOnReauth(recorded);
476
+ assert.deepEqual(w.requests, [], "must not send");
477
+ assert.equal((await accountRow(w, a)).connectionState, "reauth_required");
478
+ await assertBlockedOnReauth(w, a);
684
479
  });
685
480
 
686
- it("account storing no credential: settles `blocked` and never retries (issue #1120)", async () => {
687
- const { deps, recorded } = buildDeps({
688
- account: buildAccount({ passwordHash: undefined }),
689
- resolveCredentials: async () => ({
690
- status: "missing" as const,
691
- terminalState: "credentials_missing" as const,
692
- reason: "authType=password but no passwordHash",
693
- }),
694
- });
481
+ it("settles `blocked` and never retries for an account storing no credential (issue #1120)", async () => {
482
+ const a = await arrange(w, { account: { passwordHash: undefined } });
695
483
 
696
- await sendMessage(event, silentLogger, deps);
484
+ await handleSendMessage(a.event, w.ctx, 1);
697
485
 
698
- assert.equal(recorded.sendCalls, 0, "must not send");
699
- assert.deepEqual(recorded.connectionStateUpdates, [
700
- { accountId: "acc-1", state: "credentials_missing" },
701
- ]);
702
- assert.equal(recorded.updates[0]?.patch.status, "blocked");
486
+ assert.deepEqual(w.requests, [], "must not send");
487
+ assert.equal(
488
+ (await accountRow(w, a)).connectionState,
489
+ "credentials_missing",
490
+ );
491
+ assert.equal((await outboxRow(w, a)).status, OutboxMessageStatus.blocked);
703
492
  });
704
493
 
705
- it("on transient credential error: rethrows", async () => {
706
- const { deps, recorded } = buildDeps({
707
- account: buildAccount({ smtpHost: "smtp.example.com", smtpPort: 587 }),
708
- resolveCredentials: async () => {
709
- throw new RefreshTokenError({ kind: "transient", code: "503" });
710
- },
711
- });
494
+ it("rethrows a transient refresh failure without flipping the account", async () => {
495
+ const a = await arrange(w, { account: oauthAccount });
712
496
 
713
497
  await assert.rejects(
714
- () => sendMessage(event, silentLogger, deps),
498
+ () =>
499
+ handleSendMessage(
500
+ a.event,
501
+ {
502
+ ...w.ctx,
503
+ tokenService: {
504
+ getAccessToken: async () => {
505
+ throw new RefreshTokenError({ kind: "transient", code: "503" });
506
+ },
507
+ },
508
+ },
509
+ 1,
510
+ ),
715
511
  /transient/,
716
512
  );
717
513
  assert.equal(
718
- recorded.connectionStateUpdates.length,
719
- 0,
720
- "must not flip connectionState",
514
+ (await accountRow(w, a)).connectionState,
515
+ ConnectionState.Authenticated,
721
516
  );
722
517
  });
723
518
 
724
- it("on SmtpConnectionError auth during credential resolution for OAuth account: flips to reauth_required and ACKs", async () => {
725
- const { deps, recorded } = buildDeps({
726
- account: buildAccount({
727
- smtpHost: "smtp.example.com",
728
- smtpPort: 587,
729
- authType: AccountAuthType.OauthMicrosoft,
730
- }),
731
- resolveCredentials: async () => {
732
- throw new SmtpConnectionError("auth", "535 authentication failed");
733
- },
734
- });
519
+ it("flips an OAuth account to reauth_required on an SMTP auth refusal while resolving", async () => {
520
+ const a = await arrange(w, { account: oauthAccount });
735
521
 
736
- await sendMessage(event, silentLogger, deps);
522
+ await handleSendMessage(
523
+ a.event,
524
+ {
525
+ ...w.ctx,
526
+ tokenService: {
527
+ getAccessToken: async () => {
528
+ throw new SmtpConnectionError("auth", "535 authentication failed");
529
+ },
530
+ },
531
+ },
532
+ 1,
533
+ );
737
534
 
738
- assert.equal(recorded.sendCalls, 0, "must not send");
739
- assert.equal(recorded.connectionStateUpdates.length, 1);
740
- assert.deepEqual(recorded.connectionStateUpdates[0], {
741
- accountId: "acc-1",
742
- state: "reauth_required",
743
- });
744
- assertBlockedOnReauth(recorded);
535
+ assert.deepEqual(w.requests, []);
536
+ assert.equal((await accountRow(w, a)).connectionState, "reauth_required");
537
+ await assertBlockedOnReauth(w, a);
745
538
  });
746
539
 
747
- it("on SmtpConnectionError auth during credential resolution for password account: rethrows (no state flip)", async () => {
748
- const { deps, recorded } = buildDeps({
749
- account: buildAccount({
750
- smtpHost: "smtp.example.com",
751
- smtpPort: 587,
752
- authType: AccountAuthType.Password,
753
- }),
754
- resolveCredentials: async () => {
755
- throw new SmtpConnectionError("auth", "535 authentication failed");
756
- },
757
- });
540
+ it("rethrows a password account's SMTP auth refusal while resolving, leaving the account alone", async () => {
541
+ const a = await arrange(w);
758
542
 
759
543
  await assert.rejects(
760
- () => sendMessage(event, silentLogger, deps),
544
+ () =>
545
+ handleSendMessage(
546
+ a.event,
547
+ {
548
+ ...w.ctx,
549
+ secrets: {
550
+ ...w.ctx.secrets,
551
+ decrypt: async () => {
552
+ throw new SmtpConnectionError(
553
+ "auth",
554
+ "535 authentication failed",
555
+ );
556
+ },
557
+ },
558
+ },
559
+ 1,
560
+ ),
761
561
  /535 authentication failed/,
762
562
  );
763
563
  assert.equal(
764
- recorded.connectionStateUpdates.length,
765
- 0,
766
- "must not flip connectionState for password account",
564
+ (await accountRow(w, a)).connectionState,
565
+ ConnectionState.Authenticated,
767
566
  );
768
567
  });
769
568
 
770
- it("on SmtpConnectionError auth during send for OAuth account: flips to reauth_required and ACKs", async () => {
771
- const { deps, recorded } = buildDeps({
772
- account: buildAccount({
773
- smtpHost: "smtp.example.com",
774
- smtpPort: 587,
775
- authType: AccountAuthType.OauthMicrosoft,
776
- }),
777
- send: async () => {
778
- throw new SmtpConnectionError("auth", "535 authentication failed");
779
- },
780
- });
569
+ it("flips an OAuth account to reauth_required when the server refuses its token, settling the row from `sending`", async () => {
570
+ w.close();
571
+ w = await openWorld({ authResponse: "535 5.7.8 Token rejected\r\n" });
572
+ const a = await arrange(w, { account: oauthAccount });
781
573
 
782
- await sendMessage(event, silentLogger, deps);
574
+ await handleSendMessage(a.event, w.ctx, 1);
783
575
 
784
- assert.equal(recorded.connectionStateUpdates.length, 1);
785
- assert.deepEqual(recorded.connectionStateUpdates[0], {
786
- accountId: "acc-1",
787
- state: "reauth_required",
788
- });
789
- assertBlockedOnReauth(recorded);
790
- });
791
-
792
- it("on SmtpConnectionError auth during send for password account: rethrows (no state flip)", async () => {
793
- const { deps, recorded } = buildDeps({
794
- account: buildAccount({
795
- smtpHost: "smtp.example.com",
796
- smtpPort: 587,
797
- authType: AccountAuthType.Password,
798
- }),
799
- send: async () => {
800
- throw new SmtpConnectionError("auth", "535 authentication failed");
801
- },
802
- });
803
-
804
- await assert.rejects(
805
- () => sendMessage(event, silentLogger, deps),
806
- /535 authentication failed/,
807
- );
808
- assert.equal(
809
- recorded.connectionStateUpdates.length,
810
- 0,
811
- "must not flip connectionState for password account",
812
- );
576
+ const account = await accountRow(w, a);
577
+ assert.equal(account.connectionState, "reauth_required");
578
+ assert.match(String(account.lastError), /Token rejected/);
579
+ await assertBlockedOnReauth(w, a);
813
580
  });
814
581
 
815
582
  it("flips the connection state before it settles the row", async () => {
816
- const { deps, recorded } = buildDeps({
817
- resolveCredentials: async () => {
818
- throw new RefreshTokenError({
819
- kind: "reauth-required",
820
- code: "invalid_grant",
821
- });
822
- },
823
- });
583
+ const a = await arrange(w, { account: oauthAccount });
584
+ const writes: string[] = [];
585
+ const account = Object.create(w.repos.account);
586
+ account.update = (...args: Parameters<typeof w.repos.account.update>) => {
587
+ writes.push("connectionState");
588
+ return w.repos.account.update(...args);
589
+ };
590
+ const outboxMessage = Object.create(w.repos.outboxMessage);
591
+ outboxMessage.updateIfStatus = (
592
+ ...args: Parameters<typeof w.repos.outboxMessage.updateIfStatus>
593
+ ) => {
594
+ writes.push("outbox");
595
+ return w.repos.outboxMessage.updateIfStatus(...args);
596
+ };
824
597
 
825
- await sendMessage(event, silentLogger, deps);
598
+ await handleSendMessage(
599
+ a.event,
600
+ {
601
+ ...w.ctx,
602
+ ports: { ...w.repos, account, outboxMessage },
603
+ tokenService: {
604
+ getAccessToken: async () => {
605
+ throw new RefreshTokenError({
606
+ kind: "reauth-required",
607
+ code: "invalid_grant",
608
+ });
609
+ },
610
+ },
611
+ },
612
+ 1,
613
+ );
826
614
 
827
615
  // A settle that throws after the flip is redelivered into the fence, which
828
616
  // settles the row. The other order leaves the account unfenced, and the
829
617
  // settled row is outside the send fence so no redelivery gets back here.
830
- assert.deepEqual(recorded.writeOrder, ["connectionState", "outbox"]);
618
+ assert.deepEqual(writes, ["connectionState", "outbox"]);
831
619
  });
832
620
 
833
621
  it("leaves a row the user pulled back into the composer alone", async () => {
834
- const { deps, recorded } = buildDeps({
835
- outbox: buildOutbox({ status: "draft" }),
836
- account: buildAccount({ connectionState: "reauth_required" }),
622
+ const a = await arrange(w, {
623
+ account: { connectionState: "reauth_required" },
624
+ outbox: { status: OutboxMessageStatus.draft },
837
625
  });
838
626
 
839
- await sendMessage(event, silentLogger, deps);
627
+ await handleSendMessage(a.event, w.ctx, 1);
840
628
 
841
- assert.equal(recorded.updates.length, 0, "must not overwrite the draft");
842
- assert.equal(recorded.conditionalUpdates.length, 0, "must not even try");
843
- });
844
-
845
- it("settles a send-time rejection against `sending`, the status the row reached", async () => {
846
- const { deps, recorded } = buildDeps({
847
- account: buildAccount({ authType: AccountAuthType.OauthMicrosoft }),
848
- send: async () => {
849
- throw new SmtpConnectionError("auth", "535 authentication failed");
850
- },
851
- });
852
-
853
- await sendMessage(event, silentLogger, deps);
854
-
855
- assert.deepEqual(
856
- recorded.conditionalUpdates.map((update) => update.expected),
857
- ["sending"],
858
- );
859
- assertBlockedOnReauth(recorded);
629
+ const row = await outboxRow(w, a);
630
+ assert.equal(row.status, OutboxMessageStatus.draft);
631
+ assert.equal(row.lastError, undefined);
860
632
  });
861
633
  });
862
634
 
863
- /**
864
- * A connection failure as `smtp-client.ts` raises one: the code nodemailer put
865
- * on the underlying error is what says how far the submission got, and it
866
- * survives only on the cause.
867
- */
868
- const connectionError = (code: string): SmtpConnectionError =>
869
- new SmtpConnectionError(
870
- "network",
871
- `SMTP connection failed: ${code}`,
872
- Object.assign(new Error(code), { code }),
873
- );
874
-
875
- describe("sendMessage retry-budget exhaustion (issue #951)", () => {
876
- it("rethrows a password account's auth failure below the retry budget, leaving the row `sending`", async () => {
877
- const { deps, recorded } = buildDeps({
878
- account: buildAccount({
879
- smtpHost: "smtp.example.com",
880
- smtpPort: 587,
881
- authType: AccountAuthType.Password,
882
- }),
883
- send: async () => {
884
- throw new SmtpConnectionError("auth", "535 authentication failed");
885
- },
886
- });
635
+ describe("handleSendMessage retry-budget exhaustion (issue #951)", () => {
636
+ it("rethrows a password account's auth refusal below the budget, leaving the row `sending`", async () => {
637
+ w.close();
638
+ w = await openWorld({ authResponse: "535 5.7.8 Bad credentials\r\n" });
639
+ const a = await arrange(w);
887
640
 
888
641
  await assert.rejects(
889
- () =>
890
- sendMessage(event, silentLogger, deps, SEND_MESSAGE_MAX_ATTEMPTS - 1),
891
- /535 authentication failed/,
892
- );
893
- assert.equal(
894
- recorded.updates.length,
895
- 0,
896
- "must not settle the row before the retry budget is spent",
642
+ () => handleSendMessage(a.event, w.ctx, MAX_ATTEMPTS - 1),
643
+ /authentication failed/,
897
644
  );
645
+ assert.equal((await outboxRow(w, a)).status, OutboxMessageStatus.sending);
898
646
  });
899
647
 
900
- it("settles a password account's exhausted auth failure at `failed`, not stranded at `sending`", async () => {
901
- const { deps, recorded } = buildDeps({
902
- account: buildAccount({
903
- smtpHost: "smtp.example.com",
904
- smtpPort: 587,
905
- authType: AccountAuthType.Password,
906
- }),
907
- send: async () => {
908
- throw new SmtpConnectionError("auth", "535 authentication failed");
909
- },
910
- });
648
+ it("settles a password account's exhausted auth refusal at `failed`", async () => {
649
+ w.close();
650
+ w = await openWorld({ authResponse: "535 5.7.8 Bad credentials\r\n" });
651
+ const a = await arrange(w);
911
652
 
912
- await sendMessage(event, silentLogger, deps, SEND_MESSAGE_MAX_ATTEMPTS);
653
+ await handleSendMessage(a.event, w.ctx, MAX_ATTEMPTS);
913
654
 
914
- assert.equal(recorded.connectionStateUpdates.length, 0);
915
- const failedUpdate = recorded.updates.find(
916
- (u) => u.patch.status === "failed",
655
+ assert.equal(
656
+ (await accountRow(w, a)).connectionState,
657
+ ConnectionState.Authenticated,
917
658
  );
918
- assert.ok(failedUpdate, "should settle the row as failed");
919
- assert.match(String(failedUpdate.patch.lastError), /authentication failed/);
659
+ const row = await outboxRow(w, a);
660
+ assert.equal(row.status, OutboxMessageStatus.failed);
661
+ assert.match(String(row.lastError), /authentication failed/);
662
+ assert.deepEqual(w.failures, ["auth"]);
920
663
  });
921
664
 
922
- it("settles an exhausted refused connection at `failed`, not stranded at `sending`", async () => {
923
- const { deps, recorded } = buildDeps({
924
- account: buildAccount({ smtpHost: "smtp.example.com", smtpPort: 587 }),
925
- send: async () => {
926
- throw connectionError("ECONNREFUSED");
927
- },
928
- });
665
+ it("settles a refused connection at `failed` on the context's budget", async () => {
666
+ const a = await arrange(w);
929
667
 
930
- await sendMessage(event, silentLogger, deps, SEND_MESSAGE_MAX_ATTEMPTS);
931
-
932
- const failedUpdate = recorded.updates.find(
933
- (u) => u.patch.status === "failed",
668
+ await handleSendMessage(
669
+ a.event,
670
+ { ...w.ctx, getSocket: refusingSocket("ECONNREFUSED"), maxAttempts: 1 },
671
+ 1,
934
672
  );
935
- assert.ok(failedUpdate, "should settle the row as failed");
936
- assert.match(String(failedUpdate.patch.lastError), /ECONNREFUSED/);
673
+
674
+ const row = await outboxRow(w, a);
675
+ assert.equal(row.status, OutboxMessageStatus.failed);
676
+ assert.match(String(row.lastError), /ECONNREFUSED/);
677
+ assert.deepEqual(w.failures, ["network"]);
937
678
  });
938
679
 
939
680
  for (const code of ["ECONNRESET", "ETIMEDOUT"]) {
940
681
  it(`settles an exhausted ${code} at \`unfiled\` — the server may hold the message`, async () => {
941
682
  // Both classify as `network` and both can land after DATA, so a
942
683
  // `failed` row here would offer a Retry that delivers a second copy.
943
- const { deps, recorded } = buildDeps({
944
- account: buildAccount({ smtpHost: "smtp.example.com", smtpPort: 587 }),
945
- send: async () => {
946
- throw connectionError(code);
947
- },
948
- });
949
-
950
- await sendMessage(event, silentLogger, deps, SEND_MESSAGE_MAX_ATTEMPTS);
684
+ const a = await arrange(w);
951
685
 
952
- assert.equal(
953
- recorded.updates.find((u) => u.patch.status === "failed"),
954
- undefined,
955
- "a row that may have been delivered must not be re-sendable",
686
+ await handleSendMessage(
687
+ a.event,
688
+ { ...w.ctx, getSocket: refusingSocket(code) },
689
+ MAX_ATTEMPTS,
956
690
  );
957
- const settled = recorded.updates.at(-1)?.patch;
958
- assert.equal(settled?.status, "unfiled");
959
- assert.match(
960
- String(settled?.lastError),
961
- /may already have been delivered/,
962
- );
963
- assert.match(String(settled?.lastError), new RegExp(code));
964
- });
965
- }
966
691
 
967
- it("settles an exhausted connection failure that names no code at `unfiled`", async () => {
968
- // Nothing says the message did not reach the server, and the answer that
969
- // cannot produce a second copy is the one to settle on.
970
- const { deps, recorded } = buildDeps({
971
- account: buildAccount({ smtpHost: "smtp.example.com", smtpPort: 587 }),
972
- send: async () => {
973
- throw new SmtpConnectionError("network", "SMTP connection failed");
974
- },
692
+ const row = await outboxRow(w, a);
693
+ assert.equal(row.status, OutboxMessageStatus.unfiled);
694
+ assert.match(String(row.lastError), /may already have been delivered/);
695
+ assert.match(String(row.lastError), new RegExp(code));
975
696
  });
697
+ }
976
698
 
977
- await sendMessage(event, silentLogger, deps, SEND_MESSAGE_MAX_ATTEMPTS);
978
-
979
- assert.equal(recorded.updates.at(-1)?.patch.status, "unfiled");
980
- });
981
-
982
- it("settles an exhausted transient SMTP failure at `failed` instead of leaving it `queued` forever", async () => {
983
- const { deps, recorded } = buildDeps({
984
- account: buildAccount({ smtpHost: "smtp.example.com", smtpPort: 587 }),
985
- sendResult: {
986
- success: false,
987
- error: new Error("temporarily unavailable"),
988
- smtpCode: 421,
989
- isTransient: true,
990
- },
991
- });
992
-
993
- await sendMessage(event, silentLogger, deps, SEND_MESSAGE_MAX_ATTEMPTS);
994
-
995
- const failedUpdate = recorded.updates.find(
996
- (u) => u.patch.status === "failed",
997
- );
998
- assert.ok(failedUpdate, "should settle the row as failed");
999
- assert.equal(failedUpdate.patch.lastSmtpCode, 421);
1000
- assert.equal(
1001
- recorded.statuses.find((s) => s.status === "queued"),
1002
- undefined,
1003
- "must not leave the row requeued once the budget is spent",
1004
- );
1005
- });
1006
- });
699
+ it("settles an exhausted transient SMTP refusal at `failed` instead of leaving it `queued` forever", async () => {
700
+ w.close();
701
+ w = await openWorld({ dataFinalResponse: "451 4.3.0 Try later\r\n" });
702
+ const a = await arrange(w);
1007
703
 
1008
- describe("the retry budget is the queue's, read from the environment", () => {
1009
- it("takes the deployment's own maxReceiveCount when it is set", () => {
1010
- // The e2e stack sets 1 (`deploy/vps/e2e.env`): its pollers hold a record
1011
- // for 300s, so a spec cannot wait out three deliveries.
1012
- assert.equal(
1013
- getSendMessageMaxAttempts({ SEND_MESSAGE_MAX_ATTEMPTS: "1" }),
1014
- 1,
1015
- );
1016
- assert.equal(
1017
- getSendMessageMaxAttempts({ SEND_MESSAGE_MAX_ATTEMPTS: "5" }),
1018
- 5,
1019
- );
1020
- });
704
+ await handleSendMessage(a.event, w.ctx, MAX_ATTEMPTS);
1021
705
 
1022
- it("falls back to remit-smtp's maxReceiveCount when it is unset or unusable", () => {
1023
- assert.equal(getSendMessageMaxAttempts({}), 3);
1024
- assert.equal(
1025
- getSendMessageMaxAttempts({ SEND_MESSAGE_MAX_ATTEMPTS: "" }),
1026
- 3,
1027
- );
1028
- assert.equal(
1029
- getSendMessageMaxAttempts({ SEND_MESSAGE_MAX_ATTEMPTS: "not-a-number" }),
1030
- 3,
1031
- );
1032
- assert.equal(
1033
- getSendMessageMaxAttempts({ SEND_MESSAGE_MAX_ATTEMPTS: "0" }),
1034
- 3,
1035
- );
706
+ const row = await outboxRow(w, a);
707
+ assert.equal(row.status, OutboxMessageStatus.failed);
708
+ assert.equal(row.lastSmtpCode, 451);
709
+ assert.deepEqual(w.failures, ["other"]);
1036
710
  });
1037
711
  });