@remit/smtp-worker 0.0.30 → 0.0.32

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,717 @@
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";
22
18
  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({
19
+ BETTER_SQLITE3_LIMITS,
20
+ createSqliteRepositories,
21
+ } from "@remit/drizzle-service";
22
+ import { createShippedSqliteDb } from "@remit/drizzle-service/test-sqlite";
23
+ import type { MailFailureKind } from "@remit/logger";
24
+ import { RefreshTokenError } from "@remit/mail-oauth-service";
25
+ import { serializeEncryptedPayload } from "@remit/secrets-service";
26
+ import { SmtpConnectionError } from "@remit/smtp-service";
27
+ import {
28
+ type FakeSmtp,
29
+ type FakeSmtpOptions,
30
+ startFakeSmtp,
31
+ } from "@remit/smtp-service/test-fake-smtp";
32
+ import { createMockStorageService } from "@remit/storage-service";
33
+ import type { SendMessageEvent } from "../events.js";
34
+ import { handleSendMessage, type SendMessageContext } from "./send-message.js";
35
+
36
+ const MAX_ATTEMPTS = 3;
37
+
38
+ const storedSecret = (plaintext: string): string =>
39
+ JSON.stringify(
40
+ serializeEncryptedPayload({
41
+ encryptedDek: Buffer.from("dek"),
42
+ encryptedData: Buffer.from(plaintext),
43
+ iv: Buffer.from("iv"),
44
+ authTag: Buffer.from("tag"),
45
+ }),
46
+ );
47
+
48
+ interface SocketRequest {
49
+ host: string | undefined;
50
+ port: string | number | undefined;
51
+ }
52
+
53
+ interface World {
54
+ repos: ReturnType<typeof createSqliteRepositories>;
55
+ smtp: FakeSmtp;
56
+ queue: RecordingQueue;
57
+ storage: ReturnType<typeof createMockStorageService>;
58
+ requests: SocketRequest[];
59
+ failures: MailFailureKind[];
60
+ tokenRequests: string[];
61
+ ctx: SendMessageContext;
62
+ close: () => void;
63
+ }
64
+
65
+ const openWorld = async (smtpOptions: FakeSmtpOptions = {}): Promise<World> => {
66
+ const store = createShippedSqliteDb();
67
+ const repos = createSqliteRepositories(
68
+ {
69
+ app: store.db,
70
+ mail: store.db,
71
+ search: store.db,
72
+ },
73
+ BETTER_SQLITE3_LIMITS,
74
+ );
75
+ const smtp = await startFakeSmtp(smtpOptions);
76
+ const queue = recordingQueue();
77
+ const storage = createMockStorageService();
78
+ const requests: SocketRequest[] = [];
79
+ const failures: MailFailureKind[] = [];
80
+ const tokenRequests: string[] = [];
81
+ const ctx: SendMessageContext = {
82
+ ports: repos,
83
+ secrets: {
84
+ encrypt: async (plaintext) => ({
63
85
  encryptedDek: Buffer.from("dek"),
64
- encryptedData: Buffer.from("data"),
86
+ encryptedData: Buffer.from(plaintext),
65
87
  iv: Buffer.from("iv"),
66
88
  authTag: Buffer.from("tag"),
67
89
  }),
68
- ),
69
- imapHost: "imap.example.com",
90
+ decrypt: async (payload) => payload.encryptedData.toString(),
91
+ },
92
+ storage,
93
+ messageManagement: queue,
94
+ tokenService: {
95
+ getAccessToken: async (accountId) => {
96
+ tokenRequests.push(accountId);
97
+ return { accessToken: "access-token", expiresAt: 0, grantedScopes: [] };
98
+ },
99
+ },
100
+ getSocket: (request, callback) => {
101
+ requests.push({ host: request.host, port: request.port });
102
+ const connection = net.connect(smtp.port, "127.0.0.1", () =>
103
+ callback(null, { connection }),
104
+ );
105
+ },
106
+ maxAttempts: MAX_ATTEMPTS,
107
+ recordSmtpFailure: (kind) => failures.push(kind),
108
+ };
109
+ return {
110
+ repos,
111
+ smtp,
112
+ queue,
113
+ storage,
114
+ requests,
115
+ failures,
116
+ tokenRequests,
117
+ ctx,
118
+ close: () => {
119
+ smtp.close();
120
+ store.close();
121
+ },
122
+ };
123
+ };
124
+
125
+ interface Arranged {
126
+ accountId: string;
127
+ accountConfigId: string;
128
+ outboxMessageId: string;
129
+ event: SendMessageEvent;
130
+ }
131
+
132
+ const arrange = async (
133
+ w: World,
134
+ options: {
135
+ account?: Partial<CreateAccountInput>;
136
+ outbox?: Partial<CreateOutboxMessageInput>;
137
+ } = {},
138
+ ): Promise<Arranged> => {
139
+ const account = await w.repos.account.create({
140
+ accountConfigId: "cfg-1",
141
+ username: "alice@example.com",
142
+ email: "alice@example.com",
143
+ passwordHash: storedSecret("smtp-password"),
144
+ imapHost: "imap.example.invalid",
70
145
  imapPort: 993,
71
146
  imapTls: true,
72
147
  imapStartTls: false,
73
148
  smtpEnabled: true,
74
- smtpHost: "smtp.example.com",
149
+ smtpHost: "smtp.example.invalid",
75
150
  smtpPort: 587,
76
151
  smtpTls: false,
77
152
  smtpStartTls: true,
78
- smtpUsername: "",
79
153
  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 ?? [];
154
+ connectionState: ConnectionState.Authenticated,
155
+ ...options.account,
156
+ });
157
+ const outbox = await w.repos.outboxMessage.create({
158
+ accountId: account.accountId,
159
+ accountConfigId: account.accountConfigId,
160
+ fromAddress: "alice@example.com",
161
+ toAddresses: ["bob@example.com"],
162
+ subject: "Quarterly numbers",
163
+ textBody: "Hello Bob",
164
+ messageIdValue: "<send-1@example.com>",
165
+ status: OutboxMessageStatus.queued,
166
+ ...options.outbox,
167
+ });
168
+ return {
169
+ accountId: account.accountId,
170
+ accountConfigId: account.accountConfigId,
171
+ outboxMessageId: outbox.outboxMessageId,
172
+ event: {
173
+ type: "SEND_MESSAGE",
174
+ eventId: "evt-1",
175
+ timestamp: 0,
176
+ accountId: account.accountId,
177
+ outboxMessageId: outbox.outboxMessageId,
211
178
  },
212
- emitAppendSentMessage: async (accountId, outboxMessageId) => {
213
- recorded.appendCalls.push({ accountId, outboxMessageId });
214
- if (options.appendThrows) throw options.appendThrows;
179
+ };
180
+ };
181
+
182
+ const outboxRow = (w: World, a: Arranged) =>
183
+ w.repos.outboxMessage.get(a.accountConfigId, a.outboxMessageId);
184
+
185
+ const accountRow = (w: World, a: Arranged) => w.repos.account.get(a.accountId);
186
+
187
+ const attach = async (
188
+ w: World,
189
+ a: Arranged,
190
+ file: { filename: string; content: Buffer | null },
191
+ ): Promise<void> => {
192
+ const outboxAttachmentId = `att-${file.filename}`;
193
+ const reserved = await w.repos.outboxAttachment.reserve(
194
+ {
195
+ outboxAttachmentId,
196
+ outboxMessageId: a.outboxMessageId,
197
+ accountId: a.accountId,
198
+ accountConfigId: a.accountConfigId,
199
+ filename: file.filename,
200
+ contentType: "application/pdf",
201
+ sizeBytes: file.content?.length ?? 4,
202
+ storageKey: `key/${file.filename}`,
203
+ reservationExpiresAt: Math.floor(Date.now() / 1000) + 3600,
215
204
  },
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,
205
+ {
206
+ maxTotalBytes: 1_000_000,
207
+ maxCount: 10,
208
+ nowSeconds: Math.floor(Date.now() / 1000),
229
209
  },
210
+ );
211
+ assert.equal(reserved.outcome, "Reserved");
212
+ await w.repos.outboxAttachment.markStored(
213
+ a.accountConfigId,
214
+ outboxAttachmentId,
215
+ file.content?.length ?? 4,
216
+ );
217
+ if (file.content === null) return;
218
+ await w.storage.storeOutboxAttachment({
219
+ accountConfigId: a.accountConfigId,
220
+ accountId: a.accountId,
221
+ outboxMessageId: a.outboxMessageId,
222
+ outboxAttachmentId,
223
+ content: file.content,
224
+ });
225
+ };
226
+
227
+ const refusingSocket =
228
+ (code: string): SendMessageContext["getSocket"] =>
229
+ (_request, callback) => {
230
+ callback(Object.assign(new Error(`connect ${code}`), { code }), false);
230
231
  };
231
- return { deps, recorded };
232
+
233
+ const seedAddress = async (w: World, email: string): Promise<string> => {
234
+ const addressId = deriveAddressId("cfg-1", email);
235
+ const [localPart = "", domain = ""] = email.split("@");
236
+ await w.repos.address.upsertAddress({
237
+ addressId,
238
+ accountConfigId: "cfg-1",
239
+ localPart,
240
+ domain,
241
+ normalizedEmail: email,
242
+ normalizedCompound: email,
243
+ });
244
+ return addressId;
232
245
  };
233
246
 
234
- const event: SendMessageEvent = {
235
- type: "SEND_MESSAGE",
236
- eventId: "evt-1",
237
- timestamp: 0,
238
- accountId: "acc-1",
239
- outboxMessageId: "obx-1",
247
+ const assertBlockedOnReauth = async (w: World, a: Arranged): Promise<void> => {
248
+ const row = await outboxRow(w, a);
249
+ assert.equal(row.status, OutboxMessageStatus.blocked);
250
+ assert.match(String(row.lastError), /reconnected/i);
251
+ assert.match(String(row.lastError), /Reconnect/);
240
252
  };
241
253
 
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
- });
254
+ let w: World;
251
255
 
252
- await assert.rejects(() => sendMessage(event, silentLogger, deps, 1));
256
+ beforeEach(async () => {
257
+ w = await openWorld();
258
+ });
253
259
 
254
- assert.equal(recorded.sendCalls, 0);
255
- assert.deepEqual(recorded.updates, []);
256
- assert.deepEqual(recorded.statuses, [], "never moved to sending");
257
- });
260
+ afterEach(() => w.close());
258
261
 
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
- ),
262
+ describe("handleSendMessage", () => {
263
+ it("sends over the context's socket, marks the row sent and queues the Sent copy", async () => {
264
+ const a = await arrange(w, {
265
+ outbox: { lastError: "stale error from a prior attempt" },
266
266
  });
267
267
 
268
- await sendMessage(event, silentLogger, deps, SEND_MESSAGE_MAX_ATTEMPTS);
268
+ await handleSendMessage(a.event, w.ctx, 1);
269
269
 
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.',
270
+ assert.deepEqual(w.requests, [{ host: "smtp.example.invalid", port: 587 }]);
271
+ assert.match(w.smtp.received.join(""), /Subject: Quarterly numbers/);
272
+ const row = await outboxRow(w, a);
273
+ assert.equal(row.status, OutboxMessageStatus.sent);
274
+ assert.equal(row.lastError, undefined);
275
+ assert.deepEqual(
276
+ w.queue.sent.map(({ body }) => [
277
+ body.type,
278
+ body.accountId,
279
+ body.outboxMessageId,
280
+ ]),
281
+ [["APPEND_SENT_MESSAGE", a.accountId, a.outboxMessageId]],
276
282
  );
277
283
  });
278
284
 
279
285
  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
- },
286
+ const a = await arrange(w);
287
+ await attach(w, a, {
288
+ filename: "report.pdf",
289
+ content: Buffer.from("%PDF-1.4"),
294
290
  });
295
291
 
296
- await sendMessage(event, silentLogger, deps);
292
+ await handleSendMessage(a.event, w.ctx, 1);
297
293
 
298
- assert.equal(sent.length, 1);
299
- assert.deepEqual(sent[0].attachments, attachments);
294
+ assert.match(w.smtp.received.join(""), /filename=report\.pdf/);
295
+ assert.equal((await outboxRow(w, a)).status, OutboxMessageStatus.sent);
300
296
  });
301
297
 
302
- it("marks status `blocked` when SMTP is disabled — never `sent`", async () => {
303
- const { deps, recorded } = buildDeps({
304
- account: buildAccount({ smtpEnabled: false }),
305
- });
298
+ it("retries a message whose files could not be read, below the budget", async () => {
299
+ const a = await arrange(w);
300
+ await attach(w, a, { filename: "payroll.xlsx", content: null });
306
301
 
307
- await sendMessage(event, silentLogger, deps);
302
+ await assert.rejects(() => handleSendMessage(a.event, w.ctx, 1));
308
303
 
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
- );
304
+ assert.deepEqual(w.requests, [], "never dialled the server");
305
+ assert.equal((await outboxRow(w, a)).status, OutboxMessageStatus.queued);
327
306
  });
328
307
 
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
- });
308
+ it("settles a message whose files cannot be read as failed, naming the file, at the budget", async () => {
309
+ const a = await arrange(w);
310
+ await attach(w, a, { filename: "payroll.xlsx", content: null });
342
311
 
343
- await sendMessage(event, silentLogger, deps);
312
+ await handleSendMessage(a.event, w.ctx, MAX_ATTEMPTS);
344
313
 
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",
314
+ assert.deepEqual(w.requests, []);
315
+ const row = await outboxRow(w, a);
316
+ assert.equal(row.status, OutboxMessageStatus.failed);
317
+ assert.match(
318
+ String(row.lastError),
319
+ /^"payroll\.xlsx" could not be read: .*, so this message was not sent\. Remove "payroll\.xlsx", attach it again, and send\.$/,
357
320
  );
358
321
  });
359
322
 
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
- });
323
+ it("marks status `blocked` when SMTP is disabled — never `sent`", async () => {
324
+ const a = await arrange(w, { account: { smtpEnabled: false } });
368
325
 
369
- await sendMessage(event, silentLogger, deps);
326
+ await handleSendMessage(a.event, w.ctx, 1);
370
327
 
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).
328
+ assert.deepEqual(w.requests, [], "send must not be attempted");
329
+ const row = await outboxRow(w, a);
330
+ assert.equal(row.status, OutboxMessageStatus.blocked);
331
+ assert.match(String(row.lastError), /SMTP not configured/);
332
+ assert.deepEqual(w.queue.sent, [], "must not enqueue APPEND_SENT_MESSAGE");
383
333
  });
384
334
 
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
- });
335
+ it("marks status `failed` (not `sent`) on a permanent SMTP refusal, counting no engagement", async () => {
336
+ w.close();
337
+ w = await openWorld({ dataFinalResponse: "554 5.7.1 Rejected\r\n" });
338
+ const a = await arrange(w);
339
+ const bob = await seedAddress(w, "bob@example.com");
395
340
 
396
- await assert.rejects(
397
- () => sendMessage(event, silentLogger, deps),
398
- /SMTP transient error/,
399
- );
341
+ await handleSendMessage(a.event, w.ctx, 1);
400
342
 
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");
343
+ const row = await outboxRow(w, a);
344
+ assert.equal(row.status, OutboxMessageStatus.failed);
345
+ assert.equal(row.lastSmtpCode, 554);
346
+ assert.deepEqual(w.queue.sent, []);
347
+ const address = await w.repos.address.getAddress("cfg-1", bob);
348
+ assert.equal(address.outboundCount ?? 0, 0);
404
349
  });
405
350
 
406
- it("skips processing if message is already `sent` (idempotent)", async () => {
407
- const { deps, recorded } = buildDeps({
408
- outbox: buildOutbox({ status: "sent" }),
409
- });
351
+ it("reverts to `queued` and rethrows on a transient SMTP refusal", async () => {
352
+ w.close();
353
+ w = await openWorld({ dataFinalResponse: "451 4.3.0 Try later\r\n" });
354
+ const a = await arrange(w);
410
355
 
411
- await sendMessage(event, silentLogger, deps);
356
+ await assert.rejects(
357
+ () => handleSendMessage(a.event, w.ctx, 1),
358
+ /SMTP transient error/,
359
+ );
412
360
 
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);
361
+ assert.equal((await outboxRow(w, a)).status, OutboxMessageStatus.queued);
417
362
  });
418
363
 
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);
364
+ for (const status of [
365
+ OutboxMessageStatus.sent,
366
+ OutboxMessageStatus.unfiled,
367
+ OutboxMessageStatus.failed,
368
+ ]) {
369
+ it(`never re-sends a row already at \`${status}\``, async () => {
370
+ const a = await arrange(w, { outbox: { status } });
428
371
 
429
- assert.equal(recorded.sendCalls, 0);
430
- assert.equal(recorded.marked.length, 0);
431
- assert.equal(recorded.updates.length, 0);
432
- });
372
+ await handleSendMessage(a.event, w.ctx, 1);
433
373
 
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" }),
374
+ assert.deepEqual(w.requests, []);
375
+ assert.equal((await outboxRow(w, a)).status, status);
442
376
  });
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
- });
377
+ }
451
378
 
452
379
  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
- });
380
+ const a = await arrange(w);
381
+ w.queue.answer(() => Promise.reject(new Error("queue unreachable")));
457
382
 
458
- await sendMessage(event, silentLogger, deps);
383
+ await handleSendMessage(a.event, w.ctx, 1);
459
384
 
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/);
385
+ const row = await outboxRow(w, a);
386
+ assert.equal(row.status, OutboxMessageStatus.unfiled);
387
+ assert.match(String(row.lastError), /could not be queued/);
466
388
  });
467
389
 
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
- });
390
+ it("drops the event when the account is deleted (tombstone fence)", async () => {
391
+ const a = await arrange(w, { account: { deletedAt: Date.now() } });
476
392
 
477
- await sendMessage(event, silentLogger, deps);
393
+ await handleSendMessage(a.event, w.ctx, 1);
478
394
 
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");
395
+ assert.deepEqual(w.requests, []);
396
+ assert.equal((await outboxRow(w, a)).status, OutboxMessageStatus.queued);
397
+ assert.deepEqual(w.queue.sent, []);
484
398
  });
485
399
 
486
- it("increments outboundCount once per unique recipient on successful send", async () => {
487
- const { deps, recorded } = buildDeps({
488
- outbox: buildOutbox({
400
+ it("increments outboundCount once per unique recipient on a successful send", async () => {
401
+ const a = await arrange(w, {
402
+ outbox: {
489
403
  toAddresses: ["bob@example.com", "BOB@example.com"],
490
404
  ccAddresses: ["carol@example.com"],
491
405
  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
406
  },
519
407
  });
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",
408
+ const ids = await Promise.all(
409
+ ["bob@example.com", "carol@example.com", "dave@example.com"].map(
410
+ (email) => seedAddress(w, email),
411
+ ),
545
412
  );
546
- });
547
413
 
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";
414
+ await handleSendMessage(a.event, w.ctx, 1);
559
415
 
560
- await sendMessage(event, silentLogger, deps);
561
-
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",
416
+ const counts = await Promise.all(
417
+ ids.map(
418
+ async (id) =>
419
+ (await w.repos.address.getAddress("cfg-1", id)).outboundCount,
420
+ ),
584
421
  );
422
+ assert.deepEqual(counts, [1, 1, 1]);
585
423
  });
586
424
 
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;
425
+ it("does not fail the send when the engagement counters throw", async () => {
426
+ const a = await arrange(w);
427
+ const address = Object.create(w.repos.address);
428
+ address.incrementOutboundCount = async () => {
429
+ throw new Error("simulated outage");
600
430
  };
601
- deps.engagement.getEnvelopeFromEmail = async () => "bob@example.com";
602
-
603
- await sendMessage(event, silentLogger, deps);
604
431
 
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");
620
- };
621
-
622
- await sendMessage(event, silentLogger, deps);
432
+ await handleSendMessage(
433
+ a.event,
434
+ { ...w.ctx, ports: { ...w.repos, address } },
435
+ 1,
436
+ );
623
437
 
624
- assert.equal(recorded.marked.length, 1, "send must still be marked sent");
625
- assert.equal(recorded.appendCalls.length, 1, "append must still emit");
438
+ assert.equal((await outboxRow(w, a)).status, OutboxMessageStatus.sent);
439
+ assert.equal(w.queue.sent.length, 1, "append must still emit");
626
440
  });
627
441
  });
628
442
 
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
- };
443
+ describe("handleSendMessage OAuth reauth/ACK contract", () => {
444
+ const oauthAccount = {
445
+ authType: AccountAuthType.OauthMicrosoft,
446
+ passwordHash: undefined,
447
+ oauthRefreshTokenHash: storedSecret("refresh-token"),
448
+ };
640
449
 
641
- describe("sendMessage OAuth reauth/ACK contract", () => {
642
450
  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
- }),
451
+ const a = await arrange(w, {
452
+ account: { ...oauthAccount, connectionState: "reauth_required" },
649
453
  });
650
454
 
651
- await sendMessage(event, silentLogger, deps);
455
+ await handleSendMessage(a.event, w.ctx, 1);
652
456
 
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");
457
+ assert.deepEqual(w.tokenRequests, [], "must not resolve credentials");
458
+ assert.deepEqual(w.requests, [], "must not send");
459
+ assert.equal((await accountRow(w, a)).connectionState, "reauth_required");
460
+ await assertBlockedOnReauth(w, a);
662
461
  });
663
462
 
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
- });
463
+ it("flips to reauth_required and ACKs when the refresh token is revoked", async () => {
464
+ const a = await arrange(w, { account: oauthAccount });
674
465
 
675
- await sendMessage(event, silentLogger, deps);
466
+ await handleSendMessage(
467
+ a.event,
468
+ {
469
+ ...w.ctx,
470
+ tokenService: {
471
+ getAccessToken: async () => {
472
+ throw new RefreshTokenError({
473
+ kind: "reauth-required",
474
+ code: "invalid_grant",
475
+ });
476
+ },
477
+ },
478
+ },
479
+ 1,
480
+ );
676
481
 
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);
482
+ assert.deepEqual(w.requests, [], "must not send");
483
+ assert.equal((await accountRow(w, a)).connectionState, "reauth_required");
484
+ await assertBlockedOnReauth(w, a);
684
485
  });
685
486
 
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
- });
487
+ it("settles `blocked` and never retries for an account storing no credential (issue #1120)", async () => {
488
+ const a = await arrange(w, { account: { passwordHash: undefined } });
695
489
 
696
- await sendMessage(event, silentLogger, deps);
490
+ await handleSendMessage(a.event, w.ctx, 1);
697
491
 
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");
492
+ assert.deepEqual(w.requests, [], "must not send");
493
+ assert.equal(
494
+ (await accountRow(w, a)).connectionState,
495
+ "credentials_missing",
496
+ );
497
+ assert.equal((await outboxRow(w, a)).status, OutboxMessageStatus.blocked);
703
498
  });
704
499
 
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
- });
500
+ it("rethrows a transient refresh failure without flipping the account", async () => {
501
+ const a = await arrange(w, { account: oauthAccount });
712
502
 
713
503
  await assert.rejects(
714
- () => sendMessage(event, silentLogger, deps),
504
+ () =>
505
+ handleSendMessage(
506
+ a.event,
507
+ {
508
+ ...w.ctx,
509
+ tokenService: {
510
+ getAccessToken: async () => {
511
+ throw new RefreshTokenError({ kind: "transient", code: "503" });
512
+ },
513
+ },
514
+ },
515
+ 1,
516
+ ),
715
517
  /transient/,
716
518
  );
717
519
  assert.equal(
718
- recorded.connectionStateUpdates.length,
719
- 0,
720
- "must not flip connectionState",
520
+ (await accountRow(w, a)).connectionState,
521
+ ConnectionState.Authenticated,
721
522
  );
722
523
  });
723
524
 
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
- });
525
+ it("flips an OAuth account to reauth_required on an SMTP auth refusal while resolving", async () => {
526
+ const a = await arrange(w, { account: oauthAccount });
735
527
 
736
- await sendMessage(event, silentLogger, deps);
528
+ await handleSendMessage(
529
+ a.event,
530
+ {
531
+ ...w.ctx,
532
+ tokenService: {
533
+ getAccessToken: async () => {
534
+ throw new SmtpConnectionError("auth", "535 authentication failed");
535
+ },
536
+ },
537
+ },
538
+ 1,
539
+ );
737
540
 
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);
541
+ assert.deepEqual(w.requests, []);
542
+ assert.equal((await accountRow(w, a)).connectionState, "reauth_required");
543
+ await assertBlockedOnReauth(w, a);
745
544
  });
746
545
 
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
- });
546
+ it("rethrows a password account's SMTP auth refusal while resolving, leaving the account alone", async () => {
547
+ const a = await arrange(w);
758
548
 
759
549
  await assert.rejects(
760
- () => sendMessage(event, silentLogger, deps),
550
+ () =>
551
+ handleSendMessage(
552
+ a.event,
553
+ {
554
+ ...w.ctx,
555
+ secrets: {
556
+ ...w.ctx.secrets,
557
+ decrypt: async () => {
558
+ throw new SmtpConnectionError(
559
+ "auth",
560
+ "535 authentication failed",
561
+ );
562
+ },
563
+ },
564
+ },
565
+ 1,
566
+ ),
761
567
  /535 authentication failed/,
762
568
  );
763
569
  assert.equal(
764
- recorded.connectionStateUpdates.length,
765
- 0,
766
- "must not flip connectionState for password account",
570
+ (await accountRow(w, a)).connectionState,
571
+ ConnectionState.Authenticated,
767
572
  );
768
573
  });
769
574
 
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
- });
575
+ it("flips an OAuth account to reauth_required when the server refuses its token, settling the row from `sending`", async () => {
576
+ w.close();
577
+ w = await openWorld({ authResponse: "535 5.7.8 Token rejected\r\n" });
578
+ const a = await arrange(w, { account: oauthAccount });
781
579
 
782
- await sendMessage(event, silentLogger, deps);
580
+ await handleSendMessage(a.event, w.ctx, 1);
783
581
 
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
- );
582
+ const account = await accountRow(w, a);
583
+ assert.equal(account.connectionState, "reauth_required");
584
+ assert.match(String(account.lastError), /Token rejected/);
585
+ await assertBlockedOnReauth(w, a);
813
586
  });
814
587
 
815
588
  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
- });
589
+ const a = await arrange(w, { account: oauthAccount });
590
+ const writes: string[] = [];
591
+ const account = Object.create(w.repos.account);
592
+ account.update = (...args: Parameters<typeof w.repos.account.update>) => {
593
+ writes.push("connectionState");
594
+ return w.repos.account.update(...args);
595
+ };
596
+ const outboxMessage = Object.create(w.repos.outboxMessage);
597
+ outboxMessage.updateIfStatus = (
598
+ ...args: Parameters<typeof w.repos.outboxMessage.updateIfStatus>
599
+ ) => {
600
+ writes.push("outbox");
601
+ return w.repos.outboxMessage.updateIfStatus(...args);
602
+ };
824
603
 
825
- await sendMessage(event, silentLogger, deps);
604
+ await handleSendMessage(
605
+ a.event,
606
+ {
607
+ ...w.ctx,
608
+ ports: { ...w.repos, account, outboxMessage },
609
+ tokenService: {
610
+ getAccessToken: async () => {
611
+ throw new RefreshTokenError({
612
+ kind: "reauth-required",
613
+ code: "invalid_grant",
614
+ });
615
+ },
616
+ },
617
+ },
618
+ 1,
619
+ );
826
620
 
827
621
  // A settle that throws after the flip is redelivered into the fence, which
828
622
  // settles the row. The other order leaves the account unfenced, and the
829
623
  // settled row is outside the send fence so no redelivery gets back here.
830
- assert.deepEqual(recorded.writeOrder, ["connectionState", "outbox"]);
624
+ assert.deepEqual(writes, ["connectionState", "outbox"]);
831
625
  });
832
626
 
833
627
  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" }),
628
+ const a = await arrange(w, {
629
+ account: { connectionState: "reauth_required" },
630
+ outbox: { status: OutboxMessageStatus.draft },
837
631
  });
838
632
 
839
- await sendMessage(event, silentLogger, deps);
633
+ await handleSendMessage(a.event, w.ctx, 1);
840
634
 
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);
635
+ const row = await outboxRow(w, a);
636
+ assert.equal(row.status, OutboxMessageStatus.draft);
637
+ assert.equal(row.lastError, undefined);
860
638
  });
861
639
  });
862
640
 
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
- });
641
+ describe("handleSendMessage retry-budget exhaustion (issue #951)", () => {
642
+ it("rethrows a password account's auth refusal below the budget, leaving the row `sending`", async () => {
643
+ w.close();
644
+ w = await openWorld({ authResponse: "535 5.7.8 Bad credentials\r\n" });
645
+ const a = await arrange(w);
887
646
 
888
647
  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",
648
+ () => handleSendMessage(a.event, w.ctx, MAX_ATTEMPTS - 1),
649
+ /authentication failed/,
897
650
  );
651
+ assert.equal((await outboxRow(w, a)).status, OutboxMessageStatus.sending);
898
652
  });
899
653
 
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
- });
654
+ it("settles a password account's exhausted auth refusal at `failed`", async () => {
655
+ w.close();
656
+ w = await openWorld({ authResponse: "535 5.7.8 Bad credentials\r\n" });
657
+ const a = await arrange(w);
911
658
 
912
- await sendMessage(event, silentLogger, deps, SEND_MESSAGE_MAX_ATTEMPTS);
659
+ await handleSendMessage(a.event, w.ctx, MAX_ATTEMPTS);
913
660
 
914
- assert.equal(recorded.connectionStateUpdates.length, 0);
915
- const failedUpdate = recorded.updates.find(
916
- (u) => u.patch.status === "failed",
661
+ assert.equal(
662
+ (await accountRow(w, a)).connectionState,
663
+ ConnectionState.Authenticated,
917
664
  );
918
- assert.ok(failedUpdate, "should settle the row as failed");
919
- assert.match(String(failedUpdate.patch.lastError), /authentication failed/);
665
+ const row = await outboxRow(w, a);
666
+ assert.equal(row.status, OutboxMessageStatus.failed);
667
+ assert.match(String(row.lastError), /authentication failed/);
668
+ assert.deepEqual(w.failures, ["auth"]);
920
669
  });
921
670
 
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
- });
929
-
930
- await sendMessage(event, silentLogger, deps, SEND_MESSAGE_MAX_ATTEMPTS);
671
+ it("settles a refused connection at `failed` on the context's budget", async () => {
672
+ const a = await arrange(w);
931
673
 
932
- const failedUpdate = recorded.updates.find(
933
- (u) => u.patch.status === "failed",
674
+ await handleSendMessage(
675
+ a.event,
676
+ { ...w.ctx, getSocket: refusingSocket("ECONNREFUSED"), maxAttempts: 1 },
677
+ 1,
934
678
  );
935
- assert.ok(failedUpdate, "should settle the row as failed");
936
- assert.match(String(failedUpdate.patch.lastError), /ECONNREFUSED/);
679
+
680
+ const row = await outboxRow(w, a);
681
+ assert.equal(row.status, OutboxMessageStatus.failed);
682
+ assert.match(String(row.lastError), /ECONNREFUSED/);
683
+ assert.deepEqual(w.failures, ["network"]);
937
684
  });
938
685
 
939
686
  for (const code of ["ECONNRESET", "ETIMEDOUT"]) {
940
687
  it(`settles an exhausted ${code} at \`unfiled\` — the server may hold the message`, async () => {
941
688
  // Both classify as `network` and both can land after DATA, so a
942
689
  // `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
- });
690
+ const a = await arrange(w);
949
691
 
950
- await sendMessage(event, silentLogger, deps, SEND_MESSAGE_MAX_ATTEMPTS);
951
-
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",
956
- );
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/,
692
+ await handleSendMessage(
693
+ a.event,
694
+ { ...w.ctx, getSocket: refusingSocket(code) },
695
+ MAX_ATTEMPTS,
962
696
  );
963
- assert.match(String(settled?.lastError), new RegExp(code));
964
- });
965
- }
966
697
 
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
- },
698
+ const row = await outboxRow(w, a);
699
+ assert.equal(row.status, OutboxMessageStatus.unfiled);
700
+ assert.match(String(row.lastError), /may already have been delivered/);
701
+ assert.match(String(row.lastError), new RegExp(code));
975
702
  });
703
+ }
976
704
 
977
- await sendMessage(event, silentLogger, deps, SEND_MESSAGE_MAX_ATTEMPTS);
705
+ it("settles an exhausted transient SMTP refusal at `failed` instead of leaving it `queued` forever", async () => {
706
+ w.close();
707
+ w = await openWorld({ dataFinalResponse: "451 4.3.0 Try later\r\n" });
708
+ const a = await arrange(w);
978
709
 
979
- assert.equal(recorded.updates.at(-1)?.patch.status, "unfiled");
980
- });
710
+ await handleSendMessage(a.event, w.ctx, MAX_ATTEMPTS);
981
711
 
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
- });
1007
-
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
- });
1021
-
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
- );
712
+ const row = await outboxRow(w, a);
713
+ assert.equal(row.status, OutboxMessageStatus.failed);
714
+ assert.equal(row.lastSmtpCode, 451);
715
+ assert.deepEqual(w.failures, ["other"]);
1036
716
  });
1037
717
  });