@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.
- package/package.json +6 -2
- package/src/data-ports.ts +4 -20
- package/src/events.ts +5 -0
- package/src/failure-kind.ts +1 -1
- package/src/handlers/attachment-storage.test.ts +1 -58
- package/src/handlers/attachment-storage.ts +0 -23
- package/src/handlers/engagement-counters.test.ts +108 -0
- package/src/handlers/engagement-counters.ts +27 -3
- package/src/handlers/resolve-smtp-config.ts +2 -2
- package/src/handlers/send-message.test.ts +544 -864
- package/src/handlers/send-message.ts +510 -174
- package/src/index.test.ts +9 -4
- package/src/index.ts +10 -1
- package/src/lambda-context.test.ts +34 -0
- package/src/lambda-context.ts +41 -0
- package/src/ports.ts +21 -0
- package/src/process-bundle.test.ts +62 -0
- package/src/process.ts +11 -0
- package/src/processor.ts +7 -3
- package/src/handlers/send-message-core.ts +0 -514
|
@@ -1,1037 +1,717 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import
|
|
2
|
+
import net from "node:net";
|
|
3
|
+
import { afterEach, beforeEach, describe, it } from "node:test";
|
|
3
4
|
import type {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
UpdateOutboxMessageInput,
|
|
5
|
+
CreateAccountInput,
|
|
6
|
+
CreateOutboxMessageInput,
|
|
7
7
|
} from "@remit/data-ports";
|
|
8
|
-
import {
|
|
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
|
|
13
|
-
|
|
14
|
-
} from "@remit/
|
|
10
|
+
type RecordingQueue,
|
|
11
|
+
recordingQueue,
|
|
12
|
+
} from "@remit/data-ports/recording-queue";
|
|
15
13
|
import {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
} from "
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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(
|
|
86
|
+
encryptedData: Buffer.from(plaintext),
|
|
65
87
|
iv: Buffer.from("iv"),
|
|
66
88
|
authTag: Buffer.from("tag"),
|
|
67
89
|
}),
|
|
68
|
-
|
|
69
|
-
|
|
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.
|
|
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:
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
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
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
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
|
-
|
|
256
|
+
beforeEach(async () => {
|
|
257
|
+
w = await openWorld();
|
|
258
|
+
});
|
|
253
259
|
|
|
254
|
-
|
|
255
|
-
assert.deepEqual(recorded.updates, []);
|
|
256
|
-
assert.deepEqual(recorded.statuses, [], "never moved to sending");
|
|
257
|
-
});
|
|
260
|
+
afterEach(() => w.close());
|
|
258
261
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
|
268
|
+
await handleSendMessage(a.event, w.ctx, 1);
|
|
269
269
|
|
|
270
|
-
assert.
|
|
271
|
-
assert.
|
|
272
|
-
|
|
273
|
-
assert.equal(
|
|
274
|
-
|
|
275
|
-
|
|
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
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
|
292
|
+
await handleSendMessage(a.event, w.ctx, 1);
|
|
297
293
|
|
|
298
|
-
assert.
|
|
299
|
-
assert.
|
|
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("
|
|
303
|
-
const
|
|
304
|
-
|
|
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
|
|
302
|
+
await assert.rejects(() => handleSendMessage(a.event, w.ctx, 1));
|
|
308
303
|
|
|
309
|
-
assert.
|
|
310
|
-
assert.equal(
|
|
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("
|
|
330
|
-
const
|
|
331
|
-
|
|
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
|
|
312
|
+
await handleSendMessage(a.event, w.ctx, MAX_ATTEMPTS);
|
|
344
313
|
|
|
345
|
-
assert.
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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 `
|
|
361
|
-
const
|
|
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
|
|
326
|
+
await handleSendMessage(a.event, w.ctx, 1);
|
|
370
327
|
|
|
371
|
-
assert.
|
|
372
|
-
|
|
373
|
-
assert.equal(
|
|
374
|
-
assert.
|
|
375
|
-
assert.
|
|
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("
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
|
397
|
-
() => sendMessage(event, silentLogger, deps),
|
|
398
|
-
/SMTP transient error/,
|
|
399
|
-
);
|
|
341
|
+
await handleSendMessage(a.event, w.ctx, 1);
|
|
400
342
|
|
|
401
|
-
const
|
|
402
|
-
assert.
|
|
403
|
-
assert.equal(
|
|
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("
|
|
407
|
-
|
|
408
|
-
|
|
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
|
|
356
|
+
await assert.rejects(
|
|
357
|
+
() => handleSendMessage(a.event, w.ctx, 1),
|
|
358
|
+
/SMTP transient error/,
|
|
359
|
+
);
|
|
412
360
|
|
|
413
|
-
assert.equal(
|
|
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
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
-
|
|
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
|
-
|
|
435
|
-
|
|
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
|
|
454
|
-
|
|
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
|
|
383
|
+
await handleSendMessage(a.event, w.ctx, 1);
|
|
459
384
|
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
assert.
|
|
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
|
|
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
|
|
393
|
+
await handleSendMessage(a.event, w.ctx, 1);
|
|
478
394
|
|
|
479
|
-
assert.
|
|
480
|
-
assert.equal(
|
|
481
|
-
assert.
|
|
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
|
|
488
|
-
outbox:
|
|
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
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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
|
-
|
|
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
|
|
561
|
-
|
|
562
|
-
|
|
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("
|
|
588
|
-
const
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
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
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
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(
|
|
625
|
-
assert.equal(
|
|
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
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
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
|
|
644
|
-
account:
|
|
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
|
|
455
|
+
await handleSendMessage(a.event, w.ctx, 1);
|
|
652
456
|
|
|
653
|
-
assert.
|
|
654
|
-
assert.
|
|
655
|
-
assert.equal(
|
|
656
|
-
|
|
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("
|
|
665
|
-
const
|
|
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
|
|
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.
|
|
678
|
-
assert.equal(
|
|
679
|
-
|
|
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("
|
|
687
|
-
const
|
|
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
|
|
490
|
+
await handleSendMessage(a.event, w.ctx, 1);
|
|
697
491
|
|
|
698
|
-
assert.
|
|
699
|
-
assert.
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
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("
|
|
706
|
-
const
|
|
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
|
-
() =>
|
|
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
|
-
|
|
719
|
-
|
|
720
|
-
"must not flip connectionState",
|
|
520
|
+
(await accountRow(w, a)).connectionState,
|
|
521
|
+
ConnectionState.Authenticated,
|
|
721
522
|
);
|
|
722
523
|
});
|
|
723
524
|
|
|
724
|
-
it("
|
|
725
|
-
const
|
|
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
|
|
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.
|
|
739
|
-
assert.equal(
|
|
740
|
-
|
|
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("
|
|
748
|
-
const
|
|
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
|
-
() =>
|
|
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
|
-
|
|
765
|
-
|
|
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("
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
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
|
|
580
|
+
await handleSendMessage(a.event, w.ctx, 1);
|
|
783
581
|
|
|
784
|
-
|
|
785
|
-
assert.
|
|
786
|
-
|
|
787
|
-
|
|
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
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
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
|
|
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(
|
|
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
|
|
835
|
-
|
|
836
|
-
|
|
628
|
+
const a = await arrange(w, {
|
|
629
|
+
account: { connectionState: "reauth_required" },
|
|
630
|
+
outbox: { status: OutboxMessageStatus.draft },
|
|
837
631
|
});
|
|
838
632
|
|
|
839
|
-
await
|
|
633
|
+
await handleSendMessage(a.event, w.ctx, 1);
|
|
840
634
|
|
|
841
|
-
|
|
842
|
-
assert.equal(
|
|
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
|
-
|
|
865
|
-
|
|
866
|
-
|
|
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
|
-
|
|
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
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
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
|
|
659
|
+
await handleSendMessage(a.event, w.ctx, MAX_ATTEMPTS);
|
|
913
660
|
|
|
914
|
-
assert.equal(
|
|
915
|
-
|
|
916
|
-
|
|
661
|
+
assert.equal(
|
|
662
|
+
(await accountRow(w, a)).connectionState,
|
|
663
|
+
ConnectionState.Authenticated,
|
|
917
664
|
);
|
|
918
|
-
|
|
919
|
-
assert.
|
|
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
|
|
923
|
-
const
|
|
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
|
-
|
|
933
|
-
|
|
674
|
+
await handleSendMessage(
|
|
675
|
+
a.event,
|
|
676
|
+
{ ...w.ctx, getSocket: refusingSocket("ECONNREFUSED"), maxAttempts: 1 },
|
|
677
|
+
1,
|
|
934
678
|
);
|
|
935
|
-
|
|
936
|
-
|
|
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
|
|
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
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
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
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
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
|
-
|
|
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
|
-
|
|
980
|
-
});
|
|
710
|
+
await handleSendMessage(a.event, w.ctx, MAX_ATTEMPTS);
|
|
981
711
|
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
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
|
});
|