@remit/mailbox-service 0.0.111 → 0.0.112
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 +21 -6
- package/src/body-part-mapper.ts +51 -1
- package/src/body-sync-queue.test.ts +39 -99
- package/src/body-sync-terminal.test.ts +115 -277
- package/src/body-sync-terminal.ts +10 -12
- package/src/body-sync.test.ts +925 -0
- package/src/body-sync.ts +156 -235
- package/src/classification-backfill.test.ts +197 -527
- package/src/classify-message.test.ts +11 -74
- package/src/external-move.test.ts +102 -209
- package/src/external-move.ts +13 -0
- package/src/filter-operations.test.ts +319 -0
- package/src/filter-operations.ts +377 -0
- package/src/filter-update.test.ts +313 -0
- package/src/filters/filter-config.test.ts +55 -197
- package/src/filters/filter-config.ts +4 -39
- package/src/filters/match.test.ts +1 -38
- package/src/filters/match.ts +3 -52
- package/src/filters/pipeline.test.ts +158 -289
- package/src/filters/pipeline.ts +56 -170
- package/src/flag-push-terminal.ts +9 -11
- package/src/flag-push.test.ts +76 -372
- package/src/flag-queue.test.ts +197 -0
- package/src/folder-operations.test.ts +273 -0
- package/src/folder-operations.ts +217 -0
- package/src/forwarded-message.test.ts +122 -0
- package/src/imapflow-connect-retry.test.ts +76 -113
- package/src/imapflow-connection.test.ts +34 -0
- package/src/imapflow-connection.ts +5 -4
- package/src/index.ts +59 -12
- package/src/list-id-backfill.test.ts +133 -499
- package/src/mailbox-cursor.test.ts +81 -189
- package/src/mailbox-delete-intent.test.ts +78 -205
- package/src/mailbox-intent.ts +5 -3
- package/src/mailbox-management.test.ts +107 -220
- package/src/mailbox-management.ts +1 -2
- package/src/mailbox-presence.test.ts +36 -34
- package/src/mailbox-queue.ts +2 -20
- package/src/mailbox-rename-intent.test.ts +156 -325
- package/src/mailbox-rename-subtree.test.ts +205 -582
- package/src/mailbox-sync.test.ts +182 -744
- package/src/message-move.test.ts +726 -0
- package/src/message-move.ts +227 -57
- package/src/message-read.ts +87 -0
- package/src/message-save-retry.test.ts +90 -0
- package/src/message-sync-flags.test.ts +10 -1
- package/src/message-sync.test.ts +762 -0
- package/src/message-sync.ts +409 -239
- package/src/mime-walker.ts +16 -15
- package/src/organize-job.ts +93 -0
- package/src/outbox-attachment.test.ts +97 -209
- package/src/outbox-failed-recovery.test.ts +198 -269
- package/src/outbox-queue.test.ts +24 -0
- package/src/outbox-queue.ts +25 -8
- package/src/ownership.ts +140 -0
- package/src/placement-move-terminal.ts +9 -12
- package/src/placement-move.ts +33 -6
- package/src/placement-settled.ts +28 -4
- package/src/placement.test.ts +404 -0
- package/src/quarantine.test.ts +90 -148
- package/src/quarantine.ts +3 -2
- package/src/refusal.ts +29 -0
- package/src/restore-source-placement.ts +0 -4
- package/src/spam-report.test.ts +238 -710
- package/src/sync-warning.test.ts +213 -0
- package/src/sync-warning.ts +56 -0
- package/src/test-helpers/fake-imap.ts +690 -0
- package/src/test-helpers/test-queue.ts +56 -0
- package/src/test-helpers/test-store.ts +299 -0
- package/src/thread-root.test.ts +29 -0
- package/src/thread-root.ts +51 -0
- package/src/types/mailparser-augment.d.ts +8 -0
- package/src/adversarial-mime.e2e.test.ts +0 -307
- package/src/body-sync-auto-read-writeonce.test.ts +0 -219
- package/src/body-sync-calendar-suggestion.test.ts +0 -433
- package/src/body-sync-category-denormalize.test.ts +0 -352
- package/src/body-sync-category-override.test.ts +0 -501
- package/src/body-sync-category-writeonce.test.ts +0 -234
- package/src/body-sync-filter.test.ts +0 -237
- package/src/body-sync-message-gone.test.ts +0 -139
- package/src/body-sync-placement-flags.test.ts +0 -347
- package/src/body-sync-placement-writeonce.test.ts +0 -337
- package/src/body-sync-quarantine.test.ts +0 -276
- package/src/body-sync-resync.test.ts +0 -605
- package/src/body-sync-unclassifiable-once.test.ts +0 -260
- package/src/body-sync-unsubscribed-auto-read.test.ts +0 -176
- package/src/filters/anchor-drift.ts +0 -55
- package/src/filters/message-embedder.test.ts +0 -39
- package/src/filters/message-embedder.ts +0 -33
- package/src/flag-push-terminal.test.ts +0 -209
- package/src/flag-queue-stars.test.ts +0 -171
- package/src/imapflow-connection.e2e.test.ts +0 -335
- package/src/imapflow-connection.integ.test.ts +0 -316
- package/src/message-delete-junk-reconcile.test.ts +0 -117
- package/src/message-delete-no-trash.test.ts +0 -245
- package/src/message-delete-unsettled-placement.test.ts +0 -396
- package/src/message-move-copy.test.ts +0 -562
- package/src/message-move-empty-trash.test.ts +0 -326
- package/src/message-move-same-mailbox.test.ts +0 -139
- package/src/message-move-unsettled-placement.test.ts +0 -378
- package/src/message-sync-address-spoof.test.ts +0 -293
- package/src/message-sync-category.test.ts +0 -142
- package/src/message-sync-changedsince.test.ts +0 -840
- package/src/message-sync-junk-harvest.test.ts +0 -244
- package/src/message-sync-quarantine.test.ts +0 -416
- package/src/message-sync-repoint.test.ts +0 -419
- package/src/outbox-queue-settled-row.test.ts +0 -140
- package/src/pass-through-unit-of-work.ts +0 -17
- package/src/placement-move-terminal.test.ts +0 -300
- package/src/placement-move-unsettled.test.ts +0 -275
- package/src/placement-transition-race.test.ts +0 -482
- package/src/stale-message-reconcile.test.ts +0 -82
- package/src/stale-message-reconcile.ts +0 -55
- package/src/test-helpers/isolated-mailbox.ts +0 -44
- package/src/test-helpers/mailfuzz-connection.ts +0 -27
- package/test/fixtures/mime-adversarial-e2e/01-octet-stream-pdf.eml +0 -21
- package/test/fixtures/mime-adversarial-e2e/02-nested-related-cid-image.eml +0 -31
- package/test/fixtures/mime-adversarial-e2e/03-calendar-invite.eml +0 -33
- package/test/fixtures/mime-adversarial-e2e/04-forwarded-rfc822-with-attachments.eml +0 -42
- package/test/fixtures/mime-adversarial-e2e/05-tnef-winmail.eml +0 -25
- package/test/fixtures/mime-adversarial-e2e/06-8bit-non-ascii.eml +0 -15
- package/test/fixtures/mime-adversarial-e2e/07-quoted-printable-soft-breaks.eml +0 -17
- package/test/fixtures/mime-adversarial-e2e/08-alternative-empty-text.eml +0 -18
|
@@ -1,344 +1,276 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The two ways an outbox row used to become unusable without ever leaving.
|
|
3
|
-
*
|
|
4
|
-
* `createAndSend` wrote the row at `queued` and then enqueued. A throw from the
|
|
5
|
-
* enqueue left it there, and `queued` is accepted by neither `send` nor
|
|
6
|
-
* `deleteDraft` — the message could be neither sent nor discarded (#936).
|
|
7
|
-
*
|
|
8
|
-
* A send that failed settled at `failed`, and `updateDraft` refused anything
|
|
9
|
-
* but `draft`. Retry re-queued the same envelope and Edit took a 409 on the
|
|
10
|
-
* flush that precedes the send, so a message refused for a bad address had no
|
|
11
|
-
* way back to sent short of retyping it (#933).
|
|
12
|
-
*
|
|
13
|
-
* Where a stranded row settles is the load-bearing part. Not `draft`: a throw
|
|
14
|
-
* from `SendMessage` says the response was lost, not that the broker refused
|
|
15
|
-
* the event, and `draft` is inside the SMTP worker's send fence — a row put
|
|
16
|
-
* there is sendable both by the event that landed anyway and by the user
|
|
17
|
-
* pressing Send. `failed` is outside that fence and, since #933, is a row the
|
|
18
|
-
* user can still edit and send.
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
1
|
import assert from "node:assert/strict";
|
|
22
|
-
import { describe, it } from "node:test";
|
|
23
|
-
import type {
|
|
24
|
-
IAccountRepository,
|
|
25
|
-
IOutboxMessageRepository,
|
|
26
|
-
OutboxMessageItem,
|
|
27
|
-
UpdateOutboxMessageInput,
|
|
28
|
-
} from "@remit/data-ports";
|
|
2
|
+
import { afterEach, beforeEach, describe, it } from "node:test";
|
|
3
|
+
import type { AccountItem, OutboxMessageItem } from "@remit/data-ports";
|
|
29
4
|
import { ConflictError } from "@remit/data-ports/errors";
|
|
30
5
|
import { OutboxMessageStatus } from "@remit/domain-enums";
|
|
31
|
-
import
|
|
6
|
+
import { OutboxAttachmentService } from "./outbox-attachment.js";
|
|
32
7
|
import { OutboxQueueService } from "./outbox-queue.js";
|
|
8
|
+
import {
|
|
9
|
+
beforeNext,
|
|
10
|
+
captureQueue,
|
|
11
|
+
openStore,
|
|
12
|
+
type QueueCapture,
|
|
13
|
+
type QueuedEvent,
|
|
14
|
+
type Store,
|
|
15
|
+
seedAccount,
|
|
16
|
+
} from "./test-helpers/test-store.js";
|
|
17
|
+
|
|
18
|
+
let store: Store;
|
|
19
|
+
let account: AccountItem;
|
|
20
|
+
let capture: QueueCapture;
|
|
21
|
+
let enqueued: QueuedEvent[];
|
|
22
|
+
let enqueueFails: Error | undefined;
|
|
23
|
+
let whileEnqueueing: () => Promise<unknown>;
|
|
24
|
+
|
|
25
|
+
beforeEach(async () => {
|
|
26
|
+
store = await openStore();
|
|
27
|
+
account = await seedAccount(store);
|
|
28
|
+
enqueueFails = undefined;
|
|
29
|
+
whileEnqueueing = async () => {};
|
|
30
|
+
capture = captureQueue();
|
|
31
|
+
capture.answer(async () => {
|
|
32
|
+
await whileEnqueueing();
|
|
33
|
+
if (enqueueFails) throw enqueueFails;
|
|
34
|
+
return {};
|
|
35
|
+
});
|
|
36
|
+
enqueued = capture.sent;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterEach(async () => {
|
|
40
|
+
capture.restore();
|
|
41
|
+
await store.close();
|
|
42
|
+
});
|
|
33
43
|
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
44
|
+
const queue = () =>
|
|
45
|
+
new OutboxQueueService({
|
|
46
|
+
outboxMessageService: store.outboxMessage,
|
|
47
|
+
outboxAttachmentService: new OutboxAttachmentService({
|
|
48
|
+
outboxMessageService: store.outboxMessage,
|
|
49
|
+
outboxAttachmentService: store.outboxAttachment,
|
|
50
|
+
storage: store.storage(),
|
|
51
|
+
}),
|
|
52
|
+
accountService: store.account,
|
|
53
|
+
sqsSmtpQueueUrl: "http://localhost/queue",
|
|
54
|
+
});
|
|
37
55
|
|
|
38
|
-
const
|
|
39
|
-
({
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
56
|
+
const message = (status: OutboxMessageItem["status"]) =>
|
|
57
|
+
store.outboxMessage.create({
|
|
58
|
+
accountId: account.accountId,
|
|
59
|
+
accountConfigId: account.accountConfigId,
|
|
43
60
|
fromAddress: "me@example.com",
|
|
44
61
|
toAddresses: ["typo@exmaple.com"],
|
|
45
|
-
ccAddresses: [],
|
|
46
|
-
bccAddresses: [],
|
|
47
|
-
references: [],
|
|
48
62
|
messageIdValue: "<m1@example.com>",
|
|
49
63
|
subject: "Invoice",
|
|
50
64
|
textBody: "Attached.",
|
|
51
|
-
status
|
|
52
|
-
createdAt: 0,
|
|
53
|
-
updatedAt: 0,
|
|
54
|
-
...overrides,
|
|
55
|
-
}) as OutboxMessageItem;
|
|
56
|
-
|
|
57
|
-
interface ConditionalWrite {
|
|
58
|
-
expected: OutboxMessageItem["status"];
|
|
59
|
-
input: UpdateOutboxMessageInput;
|
|
60
|
-
applied: boolean;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
interface Harness {
|
|
64
|
-
service: OutboxQueueService;
|
|
65
|
-
enqueued: string[];
|
|
66
|
-
createdStatuses: string[];
|
|
67
|
-
writes: ConditionalWrite[];
|
|
68
|
-
/** What the row holds now, after every write this harness applied. */
|
|
69
|
-
status: () => OutboxMessageItem["status"];
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
interface HarnessOptions {
|
|
73
|
-
enqueueFails?: Error;
|
|
74
|
-
settleFails?: Error;
|
|
75
|
-
/** Moves the row the instant the service reads it, as a racing writer would. */
|
|
76
|
-
movesOnRead?: OutboxMessageItem["status"];
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
const createHarness = (
|
|
80
|
-
stored: OutboxMessageItem,
|
|
81
|
-
options: HarnessOptions = {},
|
|
82
|
-
): Harness => {
|
|
83
|
-
let current = stored;
|
|
84
|
-
|
|
85
|
-
const harness: Harness = {
|
|
86
|
-
service: undefined as unknown as OutboxQueueService,
|
|
87
|
-
enqueued: [],
|
|
88
|
-
createdStatuses: [],
|
|
89
|
-
writes: [],
|
|
90
|
-
status: () => current.status,
|
|
91
|
-
};
|
|
92
|
-
|
|
93
|
-
const outboxMessageService = {
|
|
94
|
-
get: async () => {
|
|
95
|
-
const seen = current;
|
|
96
|
-
if (options.movesOnRead) {
|
|
97
|
-
current = row({ ...current, status: options.movesOnRead });
|
|
98
|
-
}
|
|
99
|
-
return seen;
|
|
100
|
-
},
|
|
101
|
-
create: async (input: Record<string, unknown>) => {
|
|
102
|
-
current = row(input as Partial<OutboxMessageItem>);
|
|
103
|
-
harness.createdStatuses.push(current.status);
|
|
104
|
-
return current;
|
|
105
|
-
},
|
|
106
|
-
updateIfStatus: async (
|
|
107
|
-
_configId: string,
|
|
108
|
-
_id: string,
|
|
109
|
-
expected: OutboxMessageItem["status"],
|
|
110
|
-
input: UpdateOutboxMessageInput,
|
|
111
|
-
) => {
|
|
112
|
-
if (options.settleFails && input.status === OutboxMessageStatus.failed) {
|
|
113
|
-
throw options.settleFails;
|
|
114
|
-
}
|
|
115
|
-
const applied = current.status === expected;
|
|
116
|
-
harness.writes.push({ expected, input, applied });
|
|
117
|
-
if (!applied) return null;
|
|
118
|
-
current = row({ ...current, ...input });
|
|
119
|
-
return current;
|
|
120
|
-
},
|
|
121
|
-
} as unknown as IOutboxMessageRepository;
|
|
122
|
-
|
|
123
|
-
harness.service = new OutboxQueueService({
|
|
124
|
-
outboxMessageService,
|
|
125
|
-
outboxAttachmentService: {
|
|
126
|
-
unfinishedUpload: async () => undefined,
|
|
127
|
-
} as unknown as OutboxAttachmentService,
|
|
128
|
-
accountService: {
|
|
129
|
-
get: async () => ({ syncedServices: ["Mail"] }),
|
|
130
|
-
} as unknown as IAccountRepository,
|
|
131
|
-
sqsSmtpQueueUrl: "http://localhost/queue",
|
|
132
|
-
sqsClient: {
|
|
133
|
-
send: async (command: { input: { MessageBody: string } }) => {
|
|
134
|
-
if (options.enqueueFails) throw options.enqueueFails;
|
|
135
|
-
harness.enqueued.push(command.input.MessageBody);
|
|
136
|
-
return {};
|
|
137
|
-
},
|
|
138
|
-
} as never,
|
|
65
|
+
status,
|
|
139
66
|
});
|
|
140
67
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
const createInput = {
|
|
145
|
-
accountId: ACCOUNT_ID,
|
|
146
|
-
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
68
|
+
const createInput = () => ({
|
|
69
|
+
accountId: account.accountId,
|
|
70
|
+
accountConfigId: account.accountConfigId,
|
|
147
71
|
fromAddress: "me@example.com",
|
|
148
72
|
toAddresses: ["them@example.com"],
|
|
149
73
|
subject: "Invoice",
|
|
150
74
|
textBody: "Attached.",
|
|
151
|
-
};
|
|
75
|
+
});
|
|
152
76
|
|
|
153
|
-
const
|
|
154
|
-
|
|
77
|
+
const current = (outboxMessageId: string) =>
|
|
78
|
+
store.outboxMessage.get(account.accountConfigId, outboxMessageId);
|
|
155
79
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
80
|
+
const all = async () =>
|
|
81
|
+
(await store.outboxMessage.listByAccount(account.accountId)).items;
|
|
82
|
+
|
|
83
|
+
const isConflict = (error: unknown) => {
|
|
84
|
+
assert.ok(error instanceof ConflictError);
|
|
85
|
+
assert.equal(error.statusCode, 409);
|
|
86
|
+
return true;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
describe("an enqueue that fails", () => {
|
|
90
|
+
it("settles the new row of createAndSend at failed, saying why, and still reports the failure", async () => {
|
|
91
|
+
enqueueFails = new Error("SQS unavailable");
|
|
161
92
|
|
|
162
93
|
await assert.rejects(
|
|
163
|
-
()
|
|
94
|
+
queue().createAndSend(createInput()),
|
|
164
95
|
/SQS unavailable/,
|
|
165
|
-
"the caller still hears the failure",
|
|
166
96
|
);
|
|
167
97
|
|
|
168
|
-
|
|
169
|
-
assert.equal(
|
|
98
|
+
const [row] = await all();
|
|
99
|
+
assert.equal(row.status, OutboxMessageStatus.failed);
|
|
100
|
+
assert.match(
|
|
101
|
+
String(row.lastError),
|
|
102
|
+
/could not be handed to the outgoing queue/,
|
|
103
|
+
);
|
|
170
104
|
});
|
|
171
105
|
|
|
172
|
-
it("
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
// message. This is the assertion that keeps it out.
|
|
176
|
-
const harness = createHarness(row({}), {
|
|
177
|
-
enqueueFails: new Error("SQS unavailable"),
|
|
178
|
-
});
|
|
106
|
+
it("takes a sent draft to failed, never back to draft", async () => {
|
|
107
|
+
const draft = await message(OutboxMessageStatus.draft);
|
|
108
|
+
enqueueFails = new Error("SQS unavailable");
|
|
179
109
|
|
|
180
|
-
await assert.rejects(
|
|
110
|
+
await assert.rejects(
|
|
111
|
+
queue().send(account.accountConfigId, draft.outboxMessageId),
|
|
112
|
+
);
|
|
181
113
|
|
|
182
|
-
assert.
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
/could not be handed to the outgoing queue/,
|
|
186
|
-
"and it says why, where the Outbox shows it",
|
|
114
|
+
assert.equal(
|
|
115
|
+
(await current(draft.outboxMessageId)).status,
|
|
116
|
+
OutboxMessageStatus.failed,
|
|
187
117
|
);
|
|
188
118
|
});
|
|
189
119
|
|
|
190
|
-
it("
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
});
|
|
120
|
+
it("returns a blocked row to blocked", async () => {
|
|
121
|
+
const blocked = await message(OutboxMessageStatus.blocked);
|
|
122
|
+
enqueueFails = new Error("SQS unavailable");
|
|
194
123
|
|
|
195
|
-
await assert.rejects(
|
|
196
|
-
|
|
124
|
+
await assert.rejects(
|
|
125
|
+
queue().send(account.accountConfigId, blocked.outboxMessageId),
|
|
197
126
|
);
|
|
198
127
|
|
|
199
|
-
assert.equal(
|
|
128
|
+
assert.equal(
|
|
129
|
+
(await current(blocked.outboxMessageId)).status,
|
|
130
|
+
OutboxMessageStatus.blocked,
|
|
131
|
+
);
|
|
200
132
|
});
|
|
201
133
|
|
|
202
|
-
it("
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
134
|
+
it("leaves a row the worker already took alone", async () => {
|
|
135
|
+
const draft = await message(OutboxMessageStatus.draft);
|
|
136
|
+
enqueueFails = new Error("SQS timed out after the event landed");
|
|
137
|
+
whileEnqueueing = () =>
|
|
138
|
+
store.outboxMessage.updateStatus(
|
|
139
|
+
account.accountConfigId,
|
|
140
|
+
draft.outboxMessageId,
|
|
141
|
+
OutboxMessageStatus.sending,
|
|
142
|
+
);
|
|
209
143
|
|
|
210
|
-
await assert.rejects(
|
|
211
|
-
|
|
144
|
+
await assert.rejects(
|
|
145
|
+
queue().send(account.accountConfigId, draft.outboxMessageId),
|
|
212
146
|
);
|
|
213
147
|
|
|
214
|
-
assert.equal(
|
|
148
|
+
assert.equal(
|
|
149
|
+
(await current(draft.outboxMessageId)).status,
|
|
150
|
+
OutboxMessageStatus.sending,
|
|
151
|
+
);
|
|
215
152
|
});
|
|
216
153
|
|
|
217
|
-
it("
|
|
218
|
-
|
|
219
|
-
enqueueFails: new Error("SQS unavailable"),
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
await assert.rejects(() => harness.service.createAndSend(createInput));
|
|
154
|
+
it("keeps the row queued when the enqueue succeeds", async () => {
|
|
155
|
+
await queue().createAndSend(createInput());
|
|
223
156
|
|
|
224
|
-
const
|
|
225
|
-
assert.equal(
|
|
157
|
+
const [row] = await all();
|
|
158
|
+
assert.equal(row.status, OutboxMessageStatus.queued);
|
|
159
|
+
assert.equal(enqueued.length, 1);
|
|
226
160
|
});
|
|
161
|
+
});
|
|
227
162
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
const harness = createHarness(row({}), {
|
|
232
|
-
enqueueFails: new Error("SQS unavailable"),
|
|
233
|
-
settleFails: new Error("database unreachable"),
|
|
234
|
-
});
|
|
163
|
+
describe("a row the worker settled", () => {
|
|
164
|
+
it("is sendable again from failed, and discardable", async () => {
|
|
165
|
+
const failed = await message(OutboxMessageStatus.failed);
|
|
235
166
|
|
|
236
|
-
await
|
|
237
|
-
|
|
238
|
-
|
|
167
|
+
await queue().send(account.accountConfigId, failed.outboxMessageId);
|
|
168
|
+
assert.equal(
|
|
169
|
+
(await current(failed.outboxMessageId)).status,
|
|
170
|
+
OutboxMessageStatus.queued,
|
|
239
171
|
);
|
|
172
|
+
|
|
173
|
+
const other = await message(OutboxMessageStatus.failed);
|
|
174
|
+
await queue().deleteDraft(account.accountConfigId, other.outboxMessageId);
|
|
175
|
+
await assert.rejects(current(other.outboxMessageId));
|
|
240
176
|
});
|
|
241
177
|
|
|
242
|
-
it("
|
|
243
|
-
const
|
|
178
|
+
it("is discardable but never sendable from unfiled", async () => {
|
|
179
|
+
const unfiled = await message(OutboxMessageStatus.unfiled);
|
|
244
180
|
|
|
245
|
-
await
|
|
181
|
+
await assert.rejects(
|
|
182
|
+
queue().send(account.accountConfigId, unfiled.outboxMessageId),
|
|
183
|
+
/cannot be sent again/,
|
|
184
|
+
);
|
|
185
|
+
assert.deepEqual(enqueued, []);
|
|
246
186
|
|
|
247
|
-
|
|
248
|
-
assert.
|
|
187
|
+
await queue().deleteDraft(account.accountConfigId, unfiled.outboxMessageId);
|
|
188
|
+
await assert.rejects(current(unfiled.outboxMessageId));
|
|
249
189
|
});
|
|
250
190
|
});
|
|
251
191
|
|
|
252
192
|
describe("correcting a message that failed to send", () => {
|
|
253
|
-
it("accepts the edit and returns the row to
|
|
254
|
-
const
|
|
193
|
+
it("accepts the edit, keeps the content and returns the row to draft", async () => {
|
|
194
|
+
const failed = await message(OutboxMessageStatus.failed);
|
|
255
195
|
|
|
256
|
-
const updated = await
|
|
257
|
-
|
|
258
|
-
|
|
196
|
+
const updated = await queue().updateDraft(
|
|
197
|
+
account.accountConfigId,
|
|
198
|
+
failed.outboxMessageId,
|
|
259
199
|
{ toAddresses: ["them@example.com"] },
|
|
260
200
|
);
|
|
261
201
|
|
|
262
|
-
assert.deepEqual(applied(harness), [
|
|
263
|
-
{
|
|
264
|
-
status: OutboxMessageStatus.draft,
|
|
265
|
-
toAddresses: ["them@example.com"],
|
|
266
|
-
},
|
|
267
|
-
]);
|
|
268
202
|
assert.equal(updated.status, OutboxMessageStatus.draft);
|
|
269
|
-
assert.
|
|
203
|
+
assert.deepEqual(updated.toAddresses, ["them@example.com"]);
|
|
204
|
+
assert.equal(updated.subject, "Invoice");
|
|
270
205
|
});
|
|
271
206
|
|
|
272
207
|
it("sends the corrected message", async () => {
|
|
273
|
-
const
|
|
208
|
+
const failed = await message(OutboxMessageStatus.failed);
|
|
274
209
|
|
|
275
|
-
await
|
|
210
|
+
await queue().updateDraft(account.accountConfigId, failed.outboxMessageId, {
|
|
276
211
|
toAddresses: ["them@example.com"],
|
|
277
212
|
});
|
|
278
|
-
await
|
|
279
|
-
|
|
280
|
-
assert.equal(harness.status(), OutboxMessageStatus.queued);
|
|
281
|
-
assert.equal(harness.enqueued.length, 1);
|
|
282
|
-
});
|
|
283
|
-
|
|
284
|
-
it("accepts the edit on a `blocked` row too — Send already does", async () => {
|
|
285
|
-
const harness = createHarness(row({ status: OutboxMessageStatus.blocked }));
|
|
286
|
-
|
|
287
|
-
await harness.service.updateDraft(ACCOUNT_CONFIG_ID, OUTBOX_MESSAGE_ID, {
|
|
288
|
-
subject: "Invoice, corrected",
|
|
289
|
-
});
|
|
213
|
+
await queue().send(account.accountConfigId, failed.outboxMessageId);
|
|
290
214
|
|
|
291
|
-
assert.equal(
|
|
215
|
+
assert.equal(
|
|
216
|
+
(await current(failed.outboxMessageId)).status,
|
|
217
|
+
OutboxMessageStatus.queued,
|
|
218
|
+
);
|
|
219
|
+
assert.equal(enqueued.length, 1);
|
|
292
220
|
});
|
|
293
221
|
|
|
294
|
-
it("
|
|
295
|
-
const
|
|
222
|
+
it("accepts the edit on a blocked row too", async () => {
|
|
223
|
+
const blocked = await message(OutboxMessageStatus.blocked);
|
|
296
224
|
|
|
297
|
-
await
|
|
298
|
-
|
|
299
|
-
|
|
225
|
+
const updated = await queue().updateDraft(
|
|
226
|
+
account.accountConfigId,
|
|
227
|
+
blocked.outboxMessageId,
|
|
228
|
+
{ subject: "Invoice, corrected" },
|
|
229
|
+
);
|
|
300
230
|
|
|
301
|
-
assert.
|
|
231
|
+
assert.equal(updated.status, OutboxMessageStatus.draft);
|
|
302
232
|
});
|
|
303
233
|
});
|
|
304
234
|
|
|
305
235
|
describe("a message that moves while the request is deciding", () => {
|
|
306
|
-
it("refuses the edit rather than pulling a queued row back to
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
236
|
+
it("refuses the edit rather than pulling a queued row back to draft", async () => {
|
|
237
|
+
const failed = await message(OutboxMessageStatus.failed);
|
|
238
|
+
beforeNext(store.outboxMessage, "updateIfStatus", () =>
|
|
239
|
+
store.outboxMessage.updateStatus(
|
|
240
|
+
account.accountConfigId,
|
|
241
|
+
failed.outboxMessageId,
|
|
242
|
+
OutboxMessageStatus.queued,
|
|
243
|
+
),
|
|
244
|
+
);
|
|
312
245
|
|
|
313
246
|
await assert.rejects(
|
|
314
|
-
()
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
(error: unknown) => {
|
|
319
|
-
assert.ok(error instanceof ConflictError);
|
|
320
|
-
assert.equal(error.statusCode, 409);
|
|
321
|
-
return true;
|
|
322
|
-
},
|
|
247
|
+
queue().updateDraft(account.accountConfigId, failed.outboxMessageId, {
|
|
248
|
+
toAddresses: ["them@example.com"],
|
|
249
|
+
}),
|
|
250
|
+
isConflict,
|
|
323
251
|
);
|
|
324
252
|
|
|
325
|
-
assert.equal(
|
|
253
|
+
assert.equal(
|
|
254
|
+
(await current(failed.outboxMessageId)).status,
|
|
255
|
+
OutboxMessageStatus.queued,
|
|
256
|
+
);
|
|
326
257
|
});
|
|
327
258
|
|
|
328
259
|
it("refuses the second of two sends racing the same row", async () => {
|
|
329
|
-
const
|
|
330
|
-
|
|
331
|
-
|
|
260
|
+
const draft = await message(OutboxMessageStatus.draft);
|
|
261
|
+
beforeNext(store.outboxMessage, "updateIfStatus", () =>
|
|
262
|
+
store.outboxMessage.updateStatus(
|
|
263
|
+
account.accountConfigId,
|
|
264
|
+
draft.outboxMessageId,
|
|
265
|
+
OutboxMessageStatus.sending,
|
|
266
|
+
),
|
|
267
|
+
);
|
|
332
268
|
|
|
333
269
|
await assert.rejects(
|
|
334
|
-
()
|
|
335
|
-
|
|
336
|
-
assert.ok(error instanceof ConflictError);
|
|
337
|
-
return true;
|
|
338
|
-
},
|
|
270
|
+
queue().send(account.accountConfigId, draft.outboxMessageId),
|
|
271
|
+
isConflict,
|
|
339
272
|
);
|
|
340
|
-
|
|
341
|
-
assert.deepEqual(harness.enqueued, [], "nothing reached the SMTP queue");
|
|
273
|
+
assert.deepEqual(enqueued, []);
|
|
342
274
|
});
|
|
343
275
|
});
|
|
344
276
|
|
|
@@ -349,22 +281,19 @@ describe("a message that is not the user's to edit", () => {
|
|
|
349
281
|
OutboxMessageStatus.sent,
|
|
350
282
|
OutboxMessageStatus.unfiled,
|
|
351
283
|
]) {
|
|
352
|
-
it(`refuses the edit of a
|
|
353
|
-
const
|
|
284
|
+
it(`refuses the edit of a ${status} row`, async () => {
|
|
285
|
+
const row = await message(status);
|
|
354
286
|
|
|
355
287
|
await assert.rejects(
|
|
356
|
-
()
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
(error: unknown) => {
|
|
361
|
-
assert.ok(error instanceof ConflictError);
|
|
362
|
-
assert.equal(error.statusCode, 409);
|
|
363
|
-
return true;
|
|
364
|
-
},
|
|
288
|
+
queue().updateDraft(account.accountConfigId, row.outboxMessageId, {
|
|
289
|
+
toAddresses: ["them@example.com"],
|
|
290
|
+
}),
|
|
291
|
+
isConflict,
|
|
365
292
|
);
|
|
366
293
|
|
|
367
|
-
assert.deepEqual(
|
|
294
|
+
assert.deepEqual((await current(row.outboxMessageId)).toAddresses, [
|
|
295
|
+
"typo@exmaple.com",
|
|
296
|
+
]);
|
|
368
297
|
});
|
|
369
298
|
}
|
|
370
299
|
});
|
package/src/outbox-queue.test.ts
CHANGED
|
@@ -289,3 +289,27 @@ describe("OutboxQueueService and an account with mail off", () => {
|
|
|
289
289
|
assert.equal(harness.enqueued.length, 1);
|
|
290
290
|
});
|
|
291
291
|
});
|
|
292
|
+
|
|
293
|
+
describe("OutboxQueueService and a reply's threading headers", () => {
|
|
294
|
+
it("stores the parent's ids bare however they were bracketed", async () => {
|
|
295
|
+
const { service } = createHarness(draft({}));
|
|
296
|
+
|
|
297
|
+
const outbox = await service.createDraft(
|
|
298
|
+
sendInput({
|
|
299
|
+
toAddresses: ["them@example.com"],
|
|
300
|
+
inReplyTo: "<parent@example.com>",
|
|
301
|
+
references: [
|
|
302
|
+
"<<root@example.com>>",
|
|
303
|
+
"parent@example.com",
|
|
304
|
+
"<parent@example.com>",
|
|
305
|
+
],
|
|
306
|
+
}),
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
assert.equal(outbox.inReplyTo, "parent@example.com");
|
|
310
|
+
assert.deepEqual(outbox.references, [
|
|
311
|
+
"root@example.com",
|
|
312
|
+
"parent@example.com",
|
|
313
|
+
]);
|
|
314
|
+
});
|
|
315
|
+
});
|
package/src/outbox-queue.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
ConflictError,
|
|
11
11
|
MailSyncOffError,
|
|
12
12
|
} from "@remit/data-ports/errors";
|
|
13
|
+
import { bareMessageId } from "@remit/data-ports/id";
|
|
13
14
|
import { AccountService, OutboxMessageStatus } from "@remit/domain-enums";
|
|
14
15
|
import { createQueueProducer } from "@remit/sqs-client/producer";
|
|
15
16
|
import type { OutboxAttachmentService } from "./outbox-attachment.js";
|
|
@@ -69,6 +70,27 @@ export interface UpdateDraftInput {
|
|
|
69
70
|
references?: string[];
|
|
70
71
|
}
|
|
71
72
|
|
|
73
|
+
const MAX_REFERENCES = 21;
|
|
74
|
+
|
|
75
|
+
const capReferences = (ids: string[]): string[] =>
|
|
76
|
+
ids.length <= MAX_REFERENCES
|
|
77
|
+
? ids
|
|
78
|
+
: [ids[0], ...ids.slice(ids.length - (MAX_REFERENCES - 1))];
|
|
79
|
+
|
|
80
|
+
const bareThreadingHeaders = (input: {
|
|
81
|
+
inReplyTo?: string;
|
|
82
|
+
references?: string[];
|
|
83
|
+
}): { inReplyTo?: string; references?: string[] } => ({
|
|
84
|
+
...(input.inReplyTo !== undefined && {
|
|
85
|
+
inReplyTo: bareMessageId(input.inReplyTo),
|
|
86
|
+
}),
|
|
87
|
+
...(input.references !== undefined && {
|
|
88
|
+
references: capReferences([
|
|
89
|
+
...new Set(input.references.map(bareMessageId).filter((id) => id !== "")),
|
|
90
|
+
]),
|
|
91
|
+
}),
|
|
92
|
+
});
|
|
93
|
+
|
|
72
94
|
/**
|
|
73
95
|
* Nobody to send to. `@minItems(1)` on the create input is the only thing that
|
|
74
96
|
* has ever stood between a zero-recipient message and nodemailer, which refuses
|
|
@@ -184,8 +206,7 @@ export class OutboxQueueService {
|
|
|
184
206
|
subject: input.subject,
|
|
185
207
|
textBody: input.textBody,
|
|
186
208
|
htmlBody: input.htmlBody,
|
|
187
|
-
|
|
188
|
-
references: input.references,
|
|
209
|
+
...bareThreadingHeaders(input),
|
|
189
210
|
messageIdValue,
|
|
190
211
|
status: OutboxMessageStatus.draft,
|
|
191
212
|
});
|
|
@@ -244,10 +265,7 @@ export class OutboxQueueService {
|
|
|
244
265
|
...(input.subject !== undefined && { subject: input.subject }),
|
|
245
266
|
...(input.textBody !== undefined && { textBody: input.textBody }),
|
|
246
267
|
...(input.htmlBody !== undefined && { htmlBody: input.htmlBody }),
|
|
247
|
-
...(input
|
|
248
|
-
...(input.references !== undefined && {
|
|
249
|
-
references: input.references,
|
|
250
|
-
}),
|
|
268
|
+
...bareThreadingHeaders(input),
|
|
251
269
|
},
|
|
252
270
|
);
|
|
253
271
|
if (!updated) throw new ConflictError(MOVED_WHILE_EDITING_MESSAGE);
|
|
@@ -369,8 +387,7 @@ export class OutboxQueueService {
|
|
|
369
387
|
subject: input.subject,
|
|
370
388
|
textBody: input.textBody,
|
|
371
389
|
htmlBody: input.htmlBody,
|
|
372
|
-
|
|
373
|
-
references: input.references,
|
|
390
|
+
...bareThreadingHeaders(input),
|
|
374
391
|
messageIdValue,
|
|
375
392
|
status: OutboxMessageStatus.queued,
|
|
376
393
|
});
|