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