@remit/mailbox-service 0.0.112 → 0.0.114
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/dist/account-credentials.d.ts +83 -0
- package/dist/account-credentials.js +104 -0
- package/dist/attribute-mapper.d.ts +36 -0
- package/dist/attribute-mapper.js +119 -0
- package/dist/body-parse.d.ts +28 -0
- package/{src/body-parse.ts → dist/body-parse.js} +18 -26
- package/dist/body-part-mapper.d.ts +92 -0
- package/dist/body-part-mapper.js +301 -0
- package/dist/body-sync-queue.d.ts +59 -0
- package/dist/body-sync-queue.js +56 -0
- package/dist/body-sync-terminal.d.ts +58 -0
- package/dist/body-sync-terminal.js +95 -0
- package/dist/body-sync.d.ts +351 -0
- package/dist/body-sync.js +1204 -0
- package/dist/calendar-parts.d.ts +23 -0
- package/dist/calendar-parts.js +18 -0
- package/dist/classification-backfill.d.ts +91 -0
- package/dist/classification-backfill.js +180 -0
- package/dist/classify-message.d.ts +31 -0
- package/dist/classify-message.js +89 -0
- package/dist/connection-factory.d.ts +54 -0
- package/dist/connection-factory.js +54 -0
- package/dist/connection-test.d.ts +41 -0
- package/dist/connection-test.js +152 -0
- package/dist/external-move.d.ts +94 -0
- package/dist/external-move.js +173 -0
- package/dist/filter-operations.d.ts +42 -0
- package/dist/filter-operations.js +211 -0
- package/dist/filters/filter-config.d.ts +14 -0
- package/dist/filters/filter-config.js +10 -0
- package/{src/filters/list-id.ts → dist/filters/list-id.d.ts} +2 -16
- package/dist/filters/list-id.js +26 -0
- package/dist/filters/match.d.ts +69 -0
- package/dist/filters/match.js +165 -0
- package/dist/filters/pipeline.d.ts +64 -0
- package/dist/filters/pipeline.js +100 -0
- package/{src/flag-push-terminal.ts → dist/flag-push-terminal.d.ts} +16 -72
- package/dist/flag-push-terminal.js +63 -0
- package/dist/flag-push.d.ts +69 -0
- package/dist/flag-push.js +111 -0
- package/dist/flag-queue.d.ts +175 -0
- package/dist/flag-queue.js +291 -0
- package/dist/folder-operations.d.ts +39 -0
- package/dist/folder-operations.js +112 -0
- package/dist/folder-placement.d.ts +13 -0
- package/dist/folder-placement.js +23 -0
- package/dist/heuristics/authenticationResults.d.ts +10 -0
- package/dist/heuristics/authenticationResults.js +187 -0
- package/dist/heuristics/classifyByHeaders.d.ts +82 -0
- package/dist/heuristics/classifyByHeaders.js +365 -0
- package/dist/heuristics/classifyPlacement.d.ts +67 -0
- package/dist/heuristics/classifyPlacement.js +153 -0
- package/dist/heuristics/machineSenders.d.ts +21 -0
- package/{src/heuristics/machineSenders.ts → dist/heuristics/machineSenders.js} +34 -40
- package/dist/heuristics/senderMismatch.d.ts +81 -0
- package/dist/heuristics/senderMismatch.js +192 -0
- package/dist/heuristics/socialDomains.d.ts +14 -0
- package/{src/heuristics/socialDomains.ts → dist/heuristics/socialDomains.js} +9 -9
- package/dist/heuristics/transactionalDomains.d.ts +18 -0
- package/{src/heuristics/transactionalDomains.ts → dist/heuristics/transactionalDomains.js} +8 -8
- package/dist/imapflow-connection.d.ts +377 -0
- package/dist/imapflow-connection.js +1147 -0
- package/dist/index.d.ts +63 -0
- package/dist/index.js +65 -0
- package/dist/list-id-backfill.d.ts +83 -0
- package/dist/list-id-backfill.js +132 -0
- package/dist/mailbox-cursor-rebuild.d.ts +74 -0
- package/dist/mailbox-cursor-rebuild.js +71 -0
- package/{src/mailbox-cursor.ts → dist/mailbox-cursor.d.ts} +20 -98
- package/dist/mailbox-cursor.js +102 -0
- package/dist/mailbox-intent.d.ts +41 -0
- package/{src/mailbox-intent.ts → dist/mailbox-intent.js} +38 -64
- package/dist/mailbox-management.d.ts +240 -0
- package/dist/mailbox-management.js +576 -0
- package/dist/mailbox-presence.d.ts +52 -0
- package/{src/mailbox-presence.ts → dist/mailbox-presence.js} +19 -30
- package/dist/mailbox-queue.d.ts +129 -0
- package/dist/mailbox-queue.js +216 -0
- package/dist/mailbox-sync.d.ts +93 -0
- package/dist/mailbox-sync.js +490 -0
- package/dist/mailbox-upstream.d.ts +38 -0
- package/{src/mailbox-upstream.ts → dist/mailbox-upstream.js} +37 -47
- package/dist/message-category.d.ts +51 -0
- package/{src/message-category.ts → dist/message-category.js} +28 -71
- package/dist/message-move.d.ts +285 -0
- package/dist/message-move.js +858 -0
- package/dist/message-parser.d.ts +14 -0
- package/dist/message-parser.js +17 -0
- package/{src/message-presence.ts → dist/message-presence.d.ts} +1 -13
- package/dist/message-presence.js +29 -0
- package/dist/message-read.d.ts +51 -0
- package/dist/message-read.js +31 -0
- package/dist/message-sync.d.ts +345 -0
- package/dist/message-sync.js +1358 -0
- package/dist/mime-walker.d.ts +102 -0
- package/dist/mime-walker.js +198 -0
- package/dist/organize-job.d.ts +20 -0
- package/dist/organize-job.js +37 -0
- package/dist/outbox-attachment-content.d.ts +37 -0
- package/dist/outbox-attachment-content.js +50 -0
- package/dist/outbox-attachment-filename.d.ts +19 -0
- package/dist/outbox-attachment-filename.js +64 -0
- package/dist/outbox-attachment.d.ts +148 -0
- package/dist/outbox-attachment.js +273 -0
- package/dist/outbox-queue.d.ts +73 -0
- package/dist/outbox-queue.js +280 -0
- package/{src/outbox-status.ts → dist/outbox-status.d.ts} +1 -6
- package/dist/outbox-status.js +18 -0
- package/dist/ownership.d.ts +24 -0
- package/dist/ownership.js +83 -0
- package/dist/placement-move-terminal.d.ts +67 -0
- package/dist/placement-move-terminal.js +88 -0
- package/dist/placement-move.d.ts +83 -0
- package/dist/placement-move.js +216 -0
- package/{src/placement-settled.ts → dist/placement-settled.d.ts} +13 -68
- package/dist/placement-settled.js +75 -0
- package/dist/quarantine.d.ts +95 -0
- package/dist/quarantine.js +131 -0
- package/dist/refusal.d.ts +31 -0
- package/dist/refusal.js +3 -0
- package/{src/restore-source-placement.ts → dist/restore-source-placement.d.ts} +29 -97
- package/dist/restore-source-placement.js +79 -0
- package/dist/search-text-backfill.d.ts +15 -0
- package/dist/search-text-backfill.js +33 -0
- package/dist/snippet.d.ts +61 -0
- package/dist/snippet.js +129 -0
- package/dist/spam-report.d.ts +86 -0
- package/dist/spam-report.js +241 -0
- package/dist/sync-warning.d.ts +21 -0
- package/dist/sync-warning.js +26 -0
- package/dist/sync-watermarks.d.ts +138 -0
- package/dist/sync-watermarks.js +205 -0
- package/dist/test-helpers/fake-imap.d.ts +151 -0
- package/dist/test-helpers/fake-imap.js +508 -0
- package/dist/test-helpers/folder-roles.d.ts +34 -0
- package/dist/test-helpers/folder-roles.js +47 -0
- package/dist/test-helpers/mime-tree-generator.d.ts +82 -0
- package/dist/test-helpers/mime-tree-generator.js +329 -0
- package/dist/test-helpers/test-store.d.ts +69 -0
- package/dist/test-helpers/test-store.js +193 -0
- package/dist/text/normalizer.d.ts +20 -0
- package/dist/text/normalizer.js +102 -0
- package/dist/thread-message-rows.d.ts +69 -0
- package/dist/thread-message-rows.js +40 -0
- package/dist/thread-root.d.ts +7 -0
- package/dist/thread-root.js +25 -0
- package/dist/types.d.ts +343 -0
- package/dist/types.js +19 -0
- package/package.json +86 -88
- package/src/account-credentials.ts +0 -210
- package/src/attribute-mapper.test.ts +0 -104
- package/src/attribute-mapper.ts +0 -148
- package/src/body-parse.test.ts +0 -55
- package/src/body-part-mapper.property.test.ts +0 -354
- package/src/body-part-mapper.ts +0 -410
- package/src/body-sync-queue.test.ts +0 -64
- package/src/body-sync-queue.ts +0 -131
- package/src/body-sync-terminal.test.ts +0 -148
- package/src/body-sync-terminal.ts +0 -174
- package/src/body-sync.test.ts +0 -925
- package/src/body-sync.ts +0 -1891
- package/src/calendar-parts.ts +0 -41
- package/src/classification-backfill.test.ts +0 -285
- package/src/classification-backfill.ts +0 -363
- package/src/classify-message.test.ts +0 -142
- package/src/classify-message.ts +0 -116
- package/src/connection-factory.ts +0 -102
- package/src/connection-test.ts +0 -208
- package/src/credentials.test.ts +0 -181
- package/src/external-move.test.ts +0 -262
- package/src/external-move.ts +0 -285
- package/src/filter-operations.test.ts +0 -319
- package/src/filter-operations.ts +0 -377
- package/src/filter-update.test.ts +0 -313
- package/src/filters/filter-config.test.ts +0 -87
- package/src/filters/filter-config.ts +0 -34
- package/src/filters/list-id.test.ts +0 -50
- package/src/filters/match.test.ts +0 -392
- package/src/filters/match.ts +0 -225
- package/src/filters/pipeline.test.ts +0 -199
- package/src/filters/pipeline.ts +0 -170
- package/src/flag-push.test.ts +0 -112
- package/src/flag-push.ts +0 -198
- package/src/flag-queue.test.ts +0 -197
- package/src/flag-queue.ts +0 -509
- package/src/folder-operations.test.ts +0 -273
- package/src/folder-operations.ts +0 -217
- package/src/folder-placement.ts +0 -56
- package/src/forwarded-message.test.ts +0 -122
- package/src/heuristics/authenticationResults.test.ts +0 -189
- package/src/heuristics/authenticationResults.ts +0 -212
- package/src/heuristics/classifyByHeaders.test.ts +0 -889
- package/src/heuristics/classifyByHeaders.ts +0 -467
- package/src/heuristics/classifyPlacement.test.ts +0 -366
- package/src/heuristics/classifyPlacement.ts +0 -211
- package/src/heuristics/classifyRealMail.test.ts +0 -242
- package/src/heuristics/machineSenders.test.ts +0 -101
- package/src/heuristics/senderMismatch.test.ts +0 -346
- package/src/heuristics/senderMismatch.ts +0 -259
- package/src/imapflow-connect-retry.test.ts +0 -97
- package/src/imapflow-connection.test.ts +0 -503
- package/src/imapflow-connection.ts +0 -1476
- package/src/index.ts +0 -397
- package/src/list-id-backfill.test.ts +0 -207
- package/src/list-id-backfill.ts +0 -274
- package/src/mailbox-cursor-rebuild.test.ts +0 -171
- package/src/mailbox-cursor-rebuild.ts +0 -146
- package/src/mailbox-cursor.test.ts +0 -141
- package/src/mailbox-delete-intent.test.ts +0 -119
- package/src/mailbox-management.test.ts +0 -243
- package/src/mailbox-management.ts +0 -838
- package/src/mailbox-presence.test.ts +0 -74
- package/src/mailbox-queue.ts +0 -397
- package/src/mailbox-rename-intent.test.ts +0 -220
- package/src/mailbox-rename-subtree.test.ts +0 -287
- package/src/mailbox-sync.test.ts +0 -286
- package/src/mailbox-sync.ts +0 -724
- package/src/message-move.test.ts +0 -726
- package/src/message-move.ts +0 -1518
- package/src/message-parser.ts +0 -26
- package/src/message-presence.test.ts +0 -50
- package/src/message-read.ts +0 -87
- package/src/message-save-retry.test.ts +0 -90
- package/src/message-sync-flags.test.ts +0 -167
- package/src/message-sync.test.ts +0 -762
- package/src/message-sync.ts +0 -2133
- package/src/mime-walker.test.ts +0 -311
- package/src/mime-walker.ts +0 -306
- package/src/organize-job.ts +0 -93
- package/src/outbox-attachment-content.test.ts +0 -153
- package/src/outbox-attachment-content.ts +0 -104
- package/src/outbox-attachment-filename.test.ts +0 -132
- package/src/outbox-attachment-filename.ts +0 -76
- package/src/outbox-attachment.test.ts +0 -626
- package/src/outbox-attachment.ts +0 -536
- package/src/outbox-failed-recovery.test.ts +0 -299
- package/src/outbox-queue.test.ts +0 -315
- package/src/outbox-queue.ts +0 -525
- package/src/ownership.ts +0 -140
- package/src/placement-move-terminal.ts +0 -145
- package/src/placement-move.ts +0 -363
- package/src/placement-settled.test.ts +0 -147
- package/src/placement.test.ts +0 -404
- package/src/quarantine.test.ts +0 -133
- package/src/quarantine.ts +0 -229
- package/src/refusal.ts +0 -29
- package/src/snippet.test.ts +0 -213
- package/src/snippet.ts +0 -152
- package/src/spam-report.test.ts +0 -329
- package/src/spam-report.ts +0 -357
- package/src/sync-warning.test.ts +0 -213
- package/src/sync-warning.ts +0 -56
- package/src/sync-watermarks.test.ts +0 -409
- package/src/sync-watermarks.ts +0 -319
- package/src/system-flag-wire-format.test.ts +0 -77
- package/src/test-helpers/fake-imap.ts +0 -690
- package/src/test-helpers/folder-roles.ts +0 -66
- package/src/test-helpers/mime-tree-generator.ts +0 -461
- package/src/test-helpers/test-queue.ts +0 -56
- package/src/test-helpers/test-store.ts +0 -299
- package/src/text/normalizer.test.ts +0 -223
- package/src/text/normalizer.ts +0 -140
- package/src/thread-message-rows.ts +0 -77
- package/src/thread-root.test.ts +0 -29
- package/src/thread-root.ts +0 -51
- package/src/types/mailparser-augment.d.ts +0 -23
- package/src/types/natural-porter-stemmer.d.ts +0 -50
- package/src/types/stopword.d.ts +0 -69
- package/src/types.ts +0 -371
- package/tsconfig.json +0 -8
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { holdsRoom } from "@remit/data-ports";
|
|
2
|
+
import { ConflictError } from "@remit/data-ports/errors";
|
|
3
|
+
import { base36uuid } from "@remit/data-ports/id";
|
|
4
|
+
import { OutboxAttachmentRejectionReason } from "@remit/domain-enums";
|
|
5
|
+
import { buildOutboxAttachmentKey, UPLOAD_URL_TTL_SECONDS, } from "@remit/storage-service";
|
|
6
|
+
import { loadOutboxAttachmentContents, outboxAttachmentContentId, } from "./outbox-attachment-content.js";
|
|
7
|
+
import { normalizeAttachmentContentType, sanitizeAttachmentFilename, } from "./outbox-attachment-filename.js";
|
|
8
|
+
import { isOpenForWork } from "./outbox-status.js";
|
|
9
|
+
/**
|
|
10
|
+
* 25 MB is what most receiving servers accept, and base64 inflates it to roughly
|
|
11
|
+
* 34 MB on the wire. The same number caps one file and the sum of a draft's
|
|
12
|
+
* files, so a single file can fill a message.
|
|
13
|
+
*/
|
|
14
|
+
export const OUTBOX_ATTACHMENT_MAX_TOTAL_BYTES = 25 * 1024 * 1024;
|
|
15
|
+
/**
|
|
16
|
+
* A ceiling on how many files one draft can accumulate. The byte cap does not
|
|
17
|
+
* bound the count — a thousand one-byte files stay far under it.
|
|
18
|
+
*/
|
|
19
|
+
export const OUTBOX_ATTACHMENT_MAX_COUNT = 25;
|
|
20
|
+
const formatBytes = (bytes) => `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
21
|
+
export class OutboxAttachmentService {
|
|
22
|
+
outboxMessageService;
|
|
23
|
+
attachments;
|
|
24
|
+
storage;
|
|
25
|
+
now;
|
|
26
|
+
constructor(config) {
|
|
27
|
+
this.outboxMessageService = config.outboxMessageService;
|
|
28
|
+
this.attachments = config.outboxAttachmentService;
|
|
29
|
+
this.storage = config.storage;
|
|
30
|
+
this.now = config.now ?? (() => Math.floor(Date.now() / 1000));
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Resolve a draft the caller is entitled to act on.
|
|
34
|
+
*
|
|
35
|
+
* Mode "act": the caller has named the draft, so a foreign one is denied with
|
|
36
|
+
* 403 rather than feigned as a 404. An entry the user can no longer work on
|
|
37
|
+
* is a conflict. Both abort the request — only the file itself comes back as
|
|
38
|
+
* a result the composer can render next to the row it refused.
|
|
39
|
+
*
|
|
40
|
+
* Same predicate the composer's other two writes use. A `failed` message is
|
|
41
|
+
* editable and sendable again (#933), and an attachment is part of what the
|
|
42
|
+
* correction may be — a message refused for the wrong file would otherwise
|
|
43
|
+
* still be stuck.
|
|
44
|
+
*/
|
|
45
|
+
getWritableDraft = async (accountConfigId, outboxMessageId) => {
|
|
46
|
+
const outbox = await this.outboxMessageService.get(accountConfigId, outboxMessageId, "act");
|
|
47
|
+
if (!isOpenForWork(outbox.status)) {
|
|
48
|
+
throw new ConflictError(`This message is already ${outbox.status} and can no longer take an attachment. Start a new message to change it.`);
|
|
49
|
+
}
|
|
50
|
+
return outbox;
|
|
51
|
+
};
|
|
52
|
+
usedBytesOn = async (accountConfigId, outboxMessageId) => {
|
|
53
|
+
const held = await this.attachments.listByOutboxMessage(accountConfigId, outboxMessageId);
|
|
54
|
+
const nowSeconds = this.now();
|
|
55
|
+
return held
|
|
56
|
+
.filter((item) => holdsRoom(item, nowSeconds))
|
|
57
|
+
.reduce((total, item) => total + item.sizeBytes, 0);
|
|
58
|
+
};
|
|
59
|
+
reject = (reason, message, usedBytes) => ({
|
|
60
|
+
outcome: "Rejected",
|
|
61
|
+
rejection: {
|
|
62
|
+
reason,
|
|
63
|
+
message,
|
|
64
|
+
limitBytes: OUTBOX_ATTACHMENT_MAX_TOTAL_BYTES,
|
|
65
|
+
usedBytes,
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
/**
|
|
69
|
+
* Room on a draft for one file, and somewhere to put it.
|
|
70
|
+
*
|
|
71
|
+
* The row is written before the URL is handed out, and writing it is what
|
|
72
|
+
* claims the room — the count and the insert are one database transaction, so
|
|
73
|
+
* two requests arriving together cannot both be told there was space. That is
|
|
74
|
+
* the whole of the cap; nothing above this layer needs to serialize anything.
|
|
75
|
+
*/
|
|
76
|
+
mint = async (input) => {
|
|
77
|
+
const outbox = await this.getWritableDraft(input.accountConfigId, input.outboxMessageId);
|
|
78
|
+
const filename = sanitizeAttachmentFilename(input.filename);
|
|
79
|
+
if (filename === null) {
|
|
80
|
+
return this.reject(OutboxAttachmentRejectionReason.UnusableFilename, "That file's name is empty once path separators and hidden characters are removed. Rename it and try again.", await this.usedBytesOn(input.accountConfigId, input.outboxMessageId));
|
|
81
|
+
}
|
|
82
|
+
if (input.sizeBytes <= 0) {
|
|
83
|
+
return this.reject(OutboxAttachmentRejectionReason.EmptyFile, `"${filename}" is empty, so there is nothing to attach.`, await this.usedBytesOn(input.accountConfigId, input.outboxMessageId));
|
|
84
|
+
}
|
|
85
|
+
if (input.sizeBytes > OUTBOX_ATTACHMENT_MAX_TOTAL_BYTES) {
|
|
86
|
+
return this.reject(OutboxAttachmentRejectionReason.FileTooLarge, `"${filename}" is ${formatBytes(input.sizeBytes)}, over the ${formatBytes(OUTBOX_ATTACHMENT_MAX_TOTAL_BYTES)} a message can carry.`, await this.usedBytesOn(input.accountConfigId, input.outboxMessageId));
|
|
87
|
+
}
|
|
88
|
+
const nowSeconds = this.now();
|
|
89
|
+
const reservationExpiresAt = nowSeconds + UPLOAD_URL_TTL_SECONDS;
|
|
90
|
+
// The id is the row's own, and the key is built from it after the fact, so
|
|
91
|
+
// there is exactly one place an attachment's identity comes from.
|
|
92
|
+
const outboxAttachmentId = base36uuid();
|
|
93
|
+
const storageKey = buildOutboxAttachmentKey(input.accountConfigId, outbox.accountId, input.outboxMessageId, outboxAttachmentId);
|
|
94
|
+
const reserved = await this.attachments.reserve({
|
|
95
|
+
outboxAttachmentId,
|
|
96
|
+
outboxMessageId: input.outboxMessageId,
|
|
97
|
+
accountId: outbox.accountId,
|
|
98
|
+
accountConfigId: input.accountConfigId,
|
|
99
|
+
filename,
|
|
100
|
+
contentType: normalizeAttachmentContentType(input.contentType),
|
|
101
|
+
sizeBytes: input.sizeBytes,
|
|
102
|
+
storageKey,
|
|
103
|
+
reservationExpiresAt,
|
|
104
|
+
}, {
|
|
105
|
+
maxTotalBytes: OUTBOX_ATTACHMENT_MAX_TOTAL_BYTES,
|
|
106
|
+
maxCount: OUTBOX_ATTACHMENT_MAX_COUNT,
|
|
107
|
+
nowSeconds,
|
|
108
|
+
});
|
|
109
|
+
if (reserved.outcome === "OverCountCap") {
|
|
110
|
+
return this.reject(OutboxAttachmentRejectionReason.TooManyAttachments, `This message already carries ${OUTBOX_ATTACHMENT_MAX_COUNT} files, the most one message can hold. Remove one to attach "${filename}".`, reserved.usedBytes);
|
|
111
|
+
}
|
|
112
|
+
if (reserved.outcome === "OverByteCap") {
|
|
113
|
+
return this.reject(OutboxAttachmentRejectionReason.MessageTooLarge, `"${filename}" is ${formatBytes(input.sizeBytes)} and this message already carries ${formatBytes(reserved.usedBytes)}, over the ${formatBytes(OUTBOX_ATTACHMENT_MAX_TOTAL_BYTES)} it can send.`, reserved.usedBytes);
|
|
114
|
+
}
|
|
115
|
+
const target = await this.storage.createOutboxAttachmentUploadUrl({
|
|
116
|
+
accountConfigId: input.accountConfigId,
|
|
117
|
+
accountId: outbox.accountId,
|
|
118
|
+
outboxMessageId: input.outboxMessageId,
|
|
119
|
+
outboxAttachmentId: reserved.item.outboxAttachmentId,
|
|
120
|
+
sizeBytes: input.sizeBytes,
|
|
121
|
+
expiresAt: reservationExpiresAt,
|
|
122
|
+
});
|
|
123
|
+
return {
|
|
124
|
+
outcome: "Minted",
|
|
125
|
+
reservation: {
|
|
126
|
+
outboxAttachmentId: reserved.item.outboxAttachmentId,
|
|
127
|
+
outboxMessageId: input.outboxMessageId,
|
|
128
|
+
filename: reserved.item.filename,
|
|
129
|
+
contentType: reserved.item.contentType,
|
|
130
|
+
sizeBytes: reserved.item.sizeBytes,
|
|
131
|
+
contentId: outboxAttachmentContentId(reserved.item.outboxAttachmentId),
|
|
132
|
+
uploadUrl: target.uploadUrl,
|
|
133
|
+
uploadExpiresAt: reservationExpiresAt,
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
};
|
|
137
|
+
/**
|
|
138
|
+
* Turn an upload into an attachment, on storage's word rather than the
|
|
139
|
+
* client's.
|
|
140
|
+
*
|
|
141
|
+
* On a hosted deployment the bytes went straight to block storage and this is
|
|
142
|
+
* the first the API hears of them, so the size is read back before the row is
|
|
143
|
+
* moved to Stored. An object that is absent, or not the size reserved for it,
|
|
144
|
+
* does not become an attachment — phase 4 sends these bytes, and an
|
|
145
|
+
* attachment recorded over nothing is a message that cannot be built.
|
|
146
|
+
*/
|
|
147
|
+
complete = async (input) => {
|
|
148
|
+
const outbox = await this.getWritableDraft(input.accountConfigId, input.outboxMessageId);
|
|
149
|
+
const held = await this.attachments.listByOutboxMessage(input.accountConfigId, input.outboxMessageId);
|
|
150
|
+
const nowSeconds = this.now();
|
|
151
|
+
const usedBytes = held
|
|
152
|
+
.filter((item) => holdsRoom(item, nowSeconds))
|
|
153
|
+
.reduce((total, item) => total + item.sizeBytes, 0);
|
|
154
|
+
const row = held.find((item) => item.outboxAttachmentId === input.outboxAttachmentId);
|
|
155
|
+
if (!row || !holdsRoom(row, nowSeconds)) {
|
|
156
|
+
return this.reject(OutboxAttachmentRejectionReason.ReservationExpired, "That upload took too long and its reservation has lapsed. Attach the file again.", usedBytes);
|
|
157
|
+
}
|
|
158
|
+
// Completing twice is a retry, not a fault: the second call is told what
|
|
159
|
+
// the first was.
|
|
160
|
+
if (row.state === "Stored") {
|
|
161
|
+
return { outcome: "Completed", attachment: row };
|
|
162
|
+
}
|
|
163
|
+
const stored = await this.storage.statOutboxAttachment(input.accountConfigId, outbox.accountId, input.outboxMessageId, input.outboxAttachmentId);
|
|
164
|
+
if (stored === null) {
|
|
165
|
+
return this.reject(OutboxAttachmentRejectionReason.UploadMissing, "The file never finished uploading. Attach it again.", usedBytes);
|
|
166
|
+
}
|
|
167
|
+
if (stored.sizeBytes !== row.sizeBytes) {
|
|
168
|
+
// What landed is not what was announced. Take the row and the bytes,
|
|
169
|
+
// so the room goes back and nothing points at a file that is not there.
|
|
170
|
+
await this.attachments.deleteMany(input.accountConfigId, [
|
|
171
|
+
input.outboxAttachmentId,
|
|
172
|
+
]);
|
|
173
|
+
await this.storage.deleteOutboxAttachment(input.accountConfigId, outbox.accountId, input.outboxMessageId, input.outboxAttachmentId);
|
|
174
|
+
return this.reject(OutboxAttachmentRejectionReason.SizeMismatch, "What arrived is not the file that was announced. Attach it again.", Math.max(0, usedBytes - row.sizeBytes));
|
|
175
|
+
}
|
|
176
|
+
const confirmed = await this.attachments.markStored(input.accountConfigId, input.outboxAttachmentId, stored.sizeBytes);
|
|
177
|
+
if (confirmed === null) {
|
|
178
|
+
// Another completion moved it between the read and the update. Read it
|
|
179
|
+
// back rather than guess.
|
|
180
|
+
const settled = await this.attachments.get(input.accountConfigId, input.outboxAttachmentId);
|
|
181
|
+
return { outcome: "Completed", attachment: settled };
|
|
182
|
+
}
|
|
183
|
+
return { outcome: "Completed", attachment: confirmed };
|
|
184
|
+
};
|
|
185
|
+
/**
|
|
186
|
+
* Whether an attachment is still owed its bytes. The self-hosted upload route
|
|
187
|
+
* asks before it writes, so a URL minted before a discard cannot put bytes
|
|
188
|
+
* back under a draft that is gone.
|
|
189
|
+
*/
|
|
190
|
+
hasLiveReservation = async (accountConfigId, outboxAttachmentId) => {
|
|
191
|
+
const row = await this.attachments
|
|
192
|
+
.get(accountConfigId, outboxAttachmentId)
|
|
193
|
+
.catch(() => null);
|
|
194
|
+
// Pending only. A Stored row has already been confirmed at a size, and its
|
|
195
|
+
// URL stays signed for the rest of its window — accepting a second write
|
|
196
|
+
// would let the same-length bytes behind a confirmed attachment be
|
|
197
|
+
// replaced.
|
|
198
|
+
return (row !== null &&
|
|
199
|
+
row.state === "Pending" &&
|
|
200
|
+
row.reservationExpiresAt >= this.now());
|
|
201
|
+
};
|
|
202
|
+
/**
|
|
203
|
+
* What a draft holds, as the composer should see it: reservations that have
|
|
204
|
+
* lapsed are not files, and showing one with no way to tell it apart is worse
|
|
205
|
+
* than not showing it.
|
|
206
|
+
*/
|
|
207
|
+
listFor = async (accountConfigId, outboxMessageId) => {
|
|
208
|
+
const nowSeconds = this.now();
|
|
209
|
+
const held = await this.attachments.listByOutboxMessage(accountConfigId, outboxMessageId);
|
|
210
|
+
return held.filter((item) => holdsRoom(item, nowSeconds));
|
|
211
|
+
};
|
|
212
|
+
/**
|
|
213
|
+
* The files a message goes out with, bytes included. The Sent copy is built
|
|
214
|
+
* from the same rows the wire copy was, so the two carry the same parts.
|
|
215
|
+
*/
|
|
216
|
+
contentsFor = (accountConfigId, accountId, outboxMessageId) => loadOutboxAttachmentContents({ attachments: this.attachments, storage: this.storage }, { accountConfigId, accountId, outboxMessageId }, this.now());
|
|
217
|
+
/**
|
|
218
|
+
* The file that stops a send, if any: one still uploading, or one whose
|
|
219
|
+
* upload never finished. A message must not leave without a file its sender
|
|
220
|
+
* can see in the composer.
|
|
221
|
+
*/
|
|
222
|
+
unfinishedUpload = async (accountConfigId, outboxMessageId) => {
|
|
223
|
+
const live = await this.listFor(accountConfigId, outboxMessageId);
|
|
224
|
+
return live.find((item) => item.state !== "Stored");
|
|
225
|
+
};
|
|
226
|
+
/**
|
|
227
|
+
* Drop the draft's lapsed reservations and answer with the ids that are still
|
|
228
|
+
* good. The sweep runs this before it decides what to collect: a lapsed row
|
|
229
|
+
* would otherwise keep vouching for bytes nothing will ever send, and the
|
|
230
|
+
* object would never be collected while the row that names it survives.
|
|
231
|
+
*/
|
|
232
|
+
reapAndListLive = async (accountConfigId, outboxMessageId) => {
|
|
233
|
+
await this.attachments.deleteLapsedReservations(accountConfigId, outboxMessageId, this.now());
|
|
234
|
+
const held = await this.attachments.listByOutboxMessage(accountConfigId, outboxMessageId);
|
|
235
|
+
return held.map((item) => item.outboxAttachmentId);
|
|
236
|
+
};
|
|
237
|
+
/**
|
|
238
|
+
* Keep only the named attachments, deleting the rest with their bytes. This
|
|
239
|
+
* is how a composer removes a file: the update states what the draft keeps.
|
|
240
|
+
*/
|
|
241
|
+
retainOnly = async (accountConfigId, accountId, outboxMessageId, keepIds) => {
|
|
242
|
+
const held = await this.attachments.listByOutboxMessage(accountConfigId, outboxMessageId);
|
|
243
|
+
const keep = new Set(keepIds);
|
|
244
|
+
const drop = held.filter((item) => !keep.has(item.outboxAttachmentId));
|
|
245
|
+
if (drop.length === 0)
|
|
246
|
+
return;
|
|
247
|
+
// Bytes first, rows second — the same order as `discardAll`, and for the
|
|
248
|
+
// same reason: while a row exists its object is accounted for, so a
|
|
249
|
+
// failure part-way leaves something the sweep can still finish. One
|
|
250
|
+
// object that will not delete must not strand the rest, so the failures
|
|
251
|
+
// are collected and raised together once the rows are gone.
|
|
252
|
+
const failures = [];
|
|
253
|
+
for (const item of drop) {
|
|
254
|
+
await this.storage
|
|
255
|
+
.deleteOutboxAttachment(accountConfigId, accountId, outboxMessageId, item.outboxAttachmentId)
|
|
256
|
+
.catch((error) => failures.push(error));
|
|
257
|
+
}
|
|
258
|
+
await this.attachments.deleteMany(accountConfigId, drop.map((item) => item.outboxAttachmentId));
|
|
259
|
+
if (failures.length > 0) {
|
|
260
|
+
throw new AggregateError(failures, `Could not delete ${failures.length} of ${drop.length} attachment objects; their rows are gone and the sweep will collect them`);
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
/**
|
|
264
|
+
* Drop every file a draft holds, rows and bytes, as the draft is retired —
|
|
265
|
+
* a discard, or the APPEND to Sent that removes the row once the message has
|
|
266
|
+
* left. Rows go last: while they exist the objects are accounted for, and a
|
|
267
|
+
* failure part-way leaves the sweep able to finish the job.
|
|
268
|
+
*/
|
|
269
|
+
discardAll = async (accountConfigId, accountId, outboxMessageId) => {
|
|
270
|
+
await this.storage.deleteOutboxAttachments(accountConfigId, accountId, outboxMessageId);
|
|
271
|
+
await this.attachments.deleteByOutboxMessage(accountConfigId, outboxMessageId);
|
|
272
|
+
};
|
|
273
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { IAccountRepository, IOutboxMessageRepository, OutboxMessageItem, QueueProducer } from "@remit/data-ports";
|
|
2
|
+
import type { OutboxAttachmentService } from "./outbox-attachment.js";
|
|
3
|
+
export interface SendMessageEvent {
|
|
4
|
+
type: "SEND_MESSAGE";
|
|
5
|
+
eventId: string;
|
|
6
|
+
timestamp: number;
|
|
7
|
+
accountId: string;
|
|
8
|
+
outboxMessageId: string;
|
|
9
|
+
}
|
|
10
|
+
export interface OutboxQueueLogger {
|
|
11
|
+
info(obj: Record<string, unknown>, msg: string): void;
|
|
12
|
+
error(obj: Record<string, unknown>, msg: string): void;
|
|
13
|
+
}
|
|
14
|
+
export interface OutboxQueueConfig {
|
|
15
|
+
outboxMessageService: IOutboxMessageRepository;
|
|
16
|
+
outboxAttachmentService: OutboxAttachmentService;
|
|
17
|
+
accountService: IAccountRepository;
|
|
18
|
+
queue: QueueProducer<SendMessageEvent>;
|
|
19
|
+
logger?: OutboxQueueLogger;
|
|
20
|
+
}
|
|
21
|
+
export interface CreateDraftInput {
|
|
22
|
+
accountId: string;
|
|
23
|
+
accountConfigId: string;
|
|
24
|
+
fromAddress: string;
|
|
25
|
+
fromName?: string;
|
|
26
|
+
toAddresses: string[];
|
|
27
|
+
ccAddresses?: string[];
|
|
28
|
+
bccAddresses?: string[];
|
|
29
|
+
subject?: string;
|
|
30
|
+
textBody?: string;
|
|
31
|
+
htmlBody?: string;
|
|
32
|
+
inReplyTo?: string;
|
|
33
|
+
references?: string[];
|
|
34
|
+
}
|
|
35
|
+
export interface UpdateDraftInput {
|
|
36
|
+
toAddresses?: string[];
|
|
37
|
+
ccAddresses?: string[];
|
|
38
|
+
bccAddresses?: string[];
|
|
39
|
+
subject?: string;
|
|
40
|
+
textBody?: string;
|
|
41
|
+
htmlBody?: string;
|
|
42
|
+
inReplyTo?: string;
|
|
43
|
+
references?: string[];
|
|
44
|
+
}
|
|
45
|
+
export declare class OutboxQueueService {
|
|
46
|
+
private outboxMessageService;
|
|
47
|
+
private outboxAttachmentService;
|
|
48
|
+
private accountService;
|
|
49
|
+
private queue;
|
|
50
|
+
private log;
|
|
51
|
+
constructor(config: OutboxQueueConfig);
|
|
52
|
+
createDraft: (input: CreateDraftInput) => Promise<OutboxMessageItem>;
|
|
53
|
+
updateDraft: (accountConfigId: string, outboxMessageId: string, input: UpdateDraftInput) => Promise<OutboxMessageItem>;
|
|
54
|
+
private refuseWithoutMail;
|
|
55
|
+
send: (accountConfigId: string, outboxMessageId: string) => Promise<OutboxMessageItem>;
|
|
56
|
+
createAndSend: (input: CreateDraftInput) => Promise<OutboxMessageItem>;
|
|
57
|
+
deleteDraft: (accountConfigId: string, outboxMessageId: string) => Promise<void>;
|
|
58
|
+
/**
|
|
59
|
+
* Take a row back out of `queued` after the enqueue reported a failure.
|
|
60
|
+
*
|
|
61
|
+
* Conditional on `queued`, because the event may have landed regardless — an
|
|
62
|
+
* error from `SendMessage` says the response was lost, not that the broker
|
|
63
|
+
* refused it. If the worker already has the row, it holds the newer truth and
|
|
64
|
+
* this write does nothing.
|
|
65
|
+
*
|
|
66
|
+
* Never rejects. The enqueue failure is what went wrong and what the caller
|
|
67
|
+
* has to hear; a settle that also fails would replace it with the wrong
|
|
68
|
+
* cause, so it is logged and the row is left where #936 found it — which the
|
|
69
|
+
* caller's error at least names.
|
|
70
|
+
*/
|
|
71
|
+
private settleUnqueued;
|
|
72
|
+
private enqueueSend;
|
|
73
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { BadRequestError, ConflictError, MailSyncOffError, } from "@remit/data-ports/errors";
|
|
3
|
+
import { bareMessageId } from "@remit/data-ports/id";
|
|
4
|
+
import { AccountService, OutboxMessageStatus } from "@remit/domain-enums";
|
|
5
|
+
import { isOpenForWork } from "./outbox-status.js";
|
|
6
|
+
const noopLogger = {
|
|
7
|
+
info: () => { },
|
|
8
|
+
error: () => { },
|
|
9
|
+
};
|
|
10
|
+
const MAX_REFERENCES = 21;
|
|
11
|
+
const capReferences = (ids) => ids.length <= MAX_REFERENCES
|
|
12
|
+
? ids
|
|
13
|
+
: [ids[0], ...ids.slice(ids.length - (MAX_REFERENCES - 1))];
|
|
14
|
+
const bareThreadingHeaders = (input) => ({
|
|
15
|
+
...(input.inReplyTo !== undefined && {
|
|
16
|
+
inReplyTo: bareMessageId(input.inReplyTo),
|
|
17
|
+
}),
|
|
18
|
+
...(input.references !== undefined && {
|
|
19
|
+
references: capReferences([
|
|
20
|
+
...new Set(input.references.map(bareMessageId).filter((id) => id !== "")),
|
|
21
|
+
]),
|
|
22
|
+
}),
|
|
23
|
+
});
|
|
24
|
+
/**
|
|
25
|
+
* Nobody to send to. `@minItems(1)` on the create input is the only thing that
|
|
26
|
+
* has ever stood between a zero-recipient message and nodemailer, which refuses
|
|
27
|
+
* an empty envelope — and refuses it inside the SMTP worker, where the failure
|
|
28
|
+
* lands in the DLQ rather than in front of the person who pressed Send. Neither
|
|
29
|
+
* route into the queue passes that schema: a draft is created with a recipient
|
|
30
|
+
* and can be edited down to none, and `send` never sees the create input at all.
|
|
31
|
+
*
|
|
32
|
+
* Cc and Bcc count. A message addressed only in Bcc is a real message with a
|
|
33
|
+
* real envelope; only a message with no address anywhere has nowhere to go.
|
|
34
|
+
*/
|
|
35
|
+
const hasNowhereToGo = (message) => (message.toAddresses?.length ?? 0) === 0 &&
|
|
36
|
+
(message.ccAddresses?.length ?? 0) === 0 &&
|
|
37
|
+
(message.bccAddresses?.length ?? 0) === 0;
|
|
38
|
+
const NO_RECIPIENT_MESSAGE = "This message has nobody to send to. Add a recipient before sending it.";
|
|
39
|
+
const MAIL_SYNC_OFF_SEND_MESSAGE = "Mail is turned off for this account, so it cannot send. Turn Mail back on for this account in Settings to send from it.";
|
|
40
|
+
const ENQUEUE_FAILED_MESSAGE = "This message could not be handed to the outgoing queue, so it was not sent. Send it again.";
|
|
41
|
+
const MOVED_WHILE_EDITING_MESSAGE = "This message started sending while it was being edited, so the change was not saved. Open the Outbox to see where it stands.";
|
|
42
|
+
const MOVED_WHILE_SENDING_MESSAGE = "This message already left the queue and cannot be sent again. Open the Outbox to see where it stands.";
|
|
43
|
+
/**
|
|
44
|
+
* Where a row goes when the enqueue reports a failure — never `draft`, and
|
|
45
|
+
* never back to `draft` where it came from.
|
|
46
|
+
*
|
|
47
|
+
* A throw from `SendMessage` does not prove the broker refused the event. This
|
|
48
|
+
* queue is not FIFO on the send path and carries no deduplication id, so a
|
|
49
|
+
* timeout or a lost response leaves an event that may still be delivered. And
|
|
50
|
+
* `draft` is inside the worker's send fence (`SENDABLE_STATUSES`, smtp-worker
|
|
51
|
+
* `send-message-core.ts`): a row put back there is sendable by the landed event
|
|
52
|
+
* and by the user pressing Send, which is two copies of one message. `failed`
|
|
53
|
+
* is outside that fence, so the landed event is dropped on arrival, and a
|
|
54
|
+
* `failed` row is editable and sendable by hand (#933) — the recovery survives.
|
|
55
|
+
*
|
|
56
|
+
* `blocked` is outside the fence too and names a cause the enqueue did not
|
|
57
|
+
* change, so a row that came from there goes back to it.
|
|
58
|
+
*/
|
|
59
|
+
const settledStatusFor = (priorStatus) => priorStatus === OutboxMessageStatus.blocked
|
|
60
|
+
? OutboxMessageStatus.blocked
|
|
61
|
+
: OutboxMessageStatus.failed;
|
|
62
|
+
const generateMessageId = (domain) => {
|
|
63
|
+
const timestamp = Date.now();
|
|
64
|
+
const random = randomUUID().replace(/-/g, "").slice(0, 16);
|
|
65
|
+
return `${timestamp}.${random}@${domain}`;
|
|
66
|
+
};
|
|
67
|
+
const extractDomain = (email) => {
|
|
68
|
+
const atIndex = email.lastIndexOf("@");
|
|
69
|
+
if (atIndex === -1)
|
|
70
|
+
return "localhost";
|
|
71
|
+
return email.slice(atIndex + 1);
|
|
72
|
+
};
|
|
73
|
+
export class OutboxQueueService {
|
|
74
|
+
outboxMessageService;
|
|
75
|
+
outboxAttachmentService;
|
|
76
|
+
accountService;
|
|
77
|
+
queue;
|
|
78
|
+
log;
|
|
79
|
+
constructor(config) {
|
|
80
|
+
this.outboxMessageService = config.outboxMessageService;
|
|
81
|
+
this.outboxAttachmentService = config.outboxAttachmentService;
|
|
82
|
+
this.accountService = config.accountService;
|
|
83
|
+
this.queue = config.queue;
|
|
84
|
+
this.log = config.logger ?? noopLogger;
|
|
85
|
+
}
|
|
86
|
+
createDraft = async (input) => {
|
|
87
|
+
const domain = extractDomain(input.fromAddress);
|
|
88
|
+
const messageIdValue = generateMessageId(domain);
|
|
89
|
+
const outbox = await this.outboxMessageService.create({
|
|
90
|
+
accountId: input.accountId,
|
|
91
|
+
accountConfigId: input.accountConfigId,
|
|
92
|
+
fromAddress: input.fromAddress,
|
|
93
|
+
fromName: input.fromName,
|
|
94
|
+
toAddresses: input.toAddresses,
|
|
95
|
+
ccAddresses: input.ccAddresses,
|
|
96
|
+
bccAddresses: input.bccAddresses,
|
|
97
|
+
subject: input.subject,
|
|
98
|
+
textBody: input.textBody,
|
|
99
|
+
htmlBody: input.htmlBody,
|
|
100
|
+
...bareThreadingHeaders(input),
|
|
101
|
+
messageIdValue,
|
|
102
|
+
status: OutboxMessageStatus.draft,
|
|
103
|
+
});
|
|
104
|
+
this.log.info({ outboxMessageId: outbox.outboxMessageId, accountId: input.accountId }, "Created draft outbox message");
|
|
105
|
+
return outbox;
|
|
106
|
+
};
|
|
107
|
+
updateDraft = async (accountConfigId, outboxMessageId, input) => {
|
|
108
|
+
const existing = await this.outboxMessageService.get(accountConfigId, outboxMessageId, "act");
|
|
109
|
+
if (!isOpenForWork(existing.status)) {
|
|
110
|
+
throw new ConflictError(`This message is already ${existing.status} and can no longer be edited as a draft. Start a new message to change it.`);
|
|
111
|
+
}
|
|
112
|
+
// Editing a settled failure returns the row to `draft`: it is no longer the
|
|
113
|
+
// message that failed, and the Outbox renders a `failed` row with its
|
|
114
|
+
// `lastError` — a failure reported against text that never went out. The
|
|
115
|
+
// same row moves to Drafts carrying its content, recipients and
|
|
116
|
+
// attachments, so there is no copy to reconcile.
|
|
117
|
+
//
|
|
118
|
+
// Conditional on the status this decision was read from. A concurrent send
|
|
119
|
+
// can move the row to `queued` between the two, and an unconditional write
|
|
120
|
+
// would pull it back to `draft` with its event already on the wire — the
|
|
121
|
+
// worker's fence takes `draft`, so that row goes out and stays sendable.
|
|
122
|
+
const updated = await this.outboxMessageService.updateIfStatus(accountConfigId, outboxMessageId, existing.status, {
|
|
123
|
+
...(existing.status !== OutboxMessageStatus.draft && {
|
|
124
|
+
status: OutboxMessageStatus.draft,
|
|
125
|
+
}),
|
|
126
|
+
...(input.toAddresses !== undefined && {
|
|
127
|
+
toAddresses: input.toAddresses,
|
|
128
|
+
}),
|
|
129
|
+
...(input.ccAddresses !== undefined && {
|
|
130
|
+
ccAddresses: input.ccAddresses,
|
|
131
|
+
}),
|
|
132
|
+
...(input.bccAddresses !== undefined && {
|
|
133
|
+
bccAddresses: input.bccAddresses,
|
|
134
|
+
}),
|
|
135
|
+
...(input.subject !== undefined && { subject: input.subject }),
|
|
136
|
+
...(input.textBody !== undefined && { textBody: input.textBody }),
|
|
137
|
+
...(input.htmlBody !== undefined && { htmlBody: input.htmlBody }),
|
|
138
|
+
...bareThreadingHeaders(input),
|
|
139
|
+
});
|
|
140
|
+
if (!updated)
|
|
141
|
+
throw new ConflictError(MOVED_WHILE_EDITING_MESSAGE);
|
|
142
|
+
this.log.info({ outboxMessageId }, "Updated draft outbox message");
|
|
143
|
+
return updated;
|
|
144
|
+
};
|
|
145
|
+
refuseWithoutMail = async (accountId) => {
|
|
146
|
+
const account = await this.accountService.get(accountId);
|
|
147
|
+
if (account.syncedServices.includes(AccountService.Mail))
|
|
148
|
+
return;
|
|
149
|
+
throw new MailSyncOffError(MAIL_SYNC_OFF_SEND_MESSAGE, accountId);
|
|
150
|
+
};
|
|
151
|
+
send = async (accountConfigId, outboxMessageId) => {
|
|
152
|
+
const existing = await this.outboxMessageService.get(accountConfigId, outboxMessageId, "act");
|
|
153
|
+
if (!isOpenForWork(existing.status)) {
|
|
154
|
+
throw new ConflictError(`This message is already ${existing.status} and cannot be sent again. Open the Outbox to see where it stands.`);
|
|
155
|
+
}
|
|
156
|
+
if (hasNowhereToGo(existing)) {
|
|
157
|
+
throw new BadRequestError(NO_RECIPIENT_MESSAGE);
|
|
158
|
+
}
|
|
159
|
+
await this.refuseWithoutMail(existing.accountId);
|
|
160
|
+
const unfinished = await this.outboxAttachmentService.unfinishedUpload(accountConfigId, outboxMessageId);
|
|
161
|
+
if (unfinished) {
|
|
162
|
+
throw new BadRequestError(`"${unfinished.filename}" has not finished uploading. Wait for it, or remove it, and send again.`);
|
|
163
|
+
}
|
|
164
|
+
// Conditional on the status this send was decided against, so two presses —
|
|
165
|
+
// or a press racing the worker — produce one queued row and one conflict
|
|
166
|
+
// rather than two events for the same message.
|
|
167
|
+
const updated = await this.outboxMessageService.updateIfStatus(accountConfigId, outboxMessageId, existing.status, { status: OutboxMessageStatus.queued });
|
|
168
|
+
if (!updated)
|
|
169
|
+
throw new ConflictError(MOVED_WHILE_SENDING_MESSAGE);
|
|
170
|
+
// A reservation made from another tab between the check above and the
|
|
171
|
+
// move to `queued` got past it; one made after the move is refused, so a
|
|
172
|
+
// second look now is the last word.
|
|
173
|
+
const lateUpload = await this.outboxAttachmentService.unfinishedUpload(accountConfigId, outboxMessageId);
|
|
174
|
+
if (lateUpload) {
|
|
175
|
+
await this.outboxMessageService.updateIfStatus(accountConfigId, outboxMessageId, OutboxMessageStatus.queued, { status: existing.status });
|
|
176
|
+
throw new BadRequestError(`"${lateUpload.filename}" has not finished uploading. Wait for it, or remove it, and send again.`);
|
|
177
|
+
}
|
|
178
|
+
// `queued` is a dead end for a row the queue never accepted: `send` takes
|
|
179
|
+
// draft, failed and blocked, `deleteDraft` those three plus unfiled, so a
|
|
180
|
+
// row parked at `queued` by a failed enqueue is neither sendable nor
|
|
181
|
+
// discardable (#845.8). Settle it and let the enqueue failure surface —
|
|
182
|
+
// the row stays reachable, the caller still hears no.
|
|
183
|
+
await this.enqueueSend(existing.accountId, outboxMessageId).catch(async (error) => {
|
|
184
|
+
await this.settleUnqueued(accountConfigId, outboxMessageId, existing.status);
|
|
185
|
+
throw error;
|
|
186
|
+
});
|
|
187
|
+
this.log.info({ outboxMessageId, accountId: existing.accountId }, "Queued outbox message for sending");
|
|
188
|
+
return updated;
|
|
189
|
+
};
|
|
190
|
+
createAndSend = async (input) => {
|
|
191
|
+
if (hasNowhereToGo(input)) {
|
|
192
|
+
throw new BadRequestError(NO_RECIPIENT_MESSAGE);
|
|
193
|
+
}
|
|
194
|
+
await this.refuseWithoutMail(input.accountId);
|
|
195
|
+
const domain = extractDomain(input.fromAddress);
|
|
196
|
+
const messageIdValue = generateMessageId(domain);
|
|
197
|
+
const outbox = await this.outboxMessageService.create({
|
|
198
|
+
accountId: input.accountId,
|
|
199
|
+
accountConfigId: input.accountConfigId,
|
|
200
|
+
fromAddress: input.fromAddress,
|
|
201
|
+
fromName: input.fromName,
|
|
202
|
+
toAddresses: input.toAddresses,
|
|
203
|
+
ccAddresses: input.ccAddresses,
|
|
204
|
+
bccAddresses: input.bccAddresses,
|
|
205
|
+
subject: input.subject,
|
|
206
|
+
textBody: input.textBody,
|
|
207
|
+
htmlBody: input.htmlBody,
|
|
208
|
+
...bareThreadingHeaders(input),
|
|
209
|
+
messageIdValue,
|
|
210
|
+
status: OutboxMessageStatus.queued,
|
|
211
|
+
});
|
|
212
|
+
// Same dead end as in `send`: a row parked at `queued` by an enqueue that
|
|
213
|
+
// threw is neither sendable nor discardable (#845.8, #931, #936). This row
|
|
214
|
+
// is new, so there is no prior status — it settles at `failed`, carrying
|
|
215
|
+
// the reason, which keeps the composed text editable and sendable while
|
|
216
|
+
// staying outside the fence a landed event has to pass.
|
|
217
|
+
await this.enqueueSend(input.accountId, outbox.outboxMessageId).catch(async (error) => {
|
|
218
|
+
await this.settleUnqueued(input.accountConfigId, outbox.outboxMessageId, OutboxMessageStatus.failed);
|
|
219
|
+
throw error;
|
|
220
|
+
});
|
|
221
|
+
this.log.info({ outboxMessageId: outbox.outboxMessageId, accountId: input.accountId }, "Created and queued outbox message for sending");
|
|
222
|
+
return outbox;
|
|
223
|
+
};
|
|
224
|
+
deleteDraft = async (accountConfigId, outboxMessageId) => {
|
|
225
|
+
const existing = await this.outboxMessageService.get(accountConfigId, outboxMessageId, "act");
|
|
226
|
+
// `unfiled` is discardable: the message was delivered but never landed in
|
|
227
|
+
// a Sent folder, so this row is the only copy the user has and dismissing
|
|
228
|
+
// it is their acknowledgement.
|
|
229
|
+
if (existing.status !== OutboxMessageStatus.draft &&
|
|
230
|
+
existing.status !== OutboxMessageStatus.failed &&
|
|
231
|
+
existing.status !== OutboxMessageStatus.blocked &&
|
|
232
|
+
existing.status !== OutboxMessageStatus.unfiled) {
|
|
233
|
+
throw new ConflictError(`This message is already ${existing.status} and can no longer be discarded. Open the Outbox to see where it stands.`);
|
|
234
|
+
}
|
|
235
|
+
// Files first, row second. Nothing but this row points at those objects,
|
|
236
|
+
// so a row deleted ahead of a sweep that then fails leaves bytes no one
|
|
237
|
+
// can reach; the other order leaves a draft whose files are gone, which
|
|
238
|
+
// is at least visible. A storage failure aborts the discard outright.
|
|
239
|
+
await this.outboxAttachmentService.discardAll(accountConfigId, existing.accountId, outboxMessageId);
|
|
240
|
+
await this.outboxMessageService.delete(accountConfigId, outboxMessageId);
|
|
241
|
+
this.log.info({ outboxMessageId }, "Deleted outbox message");
|
|
242
|
+
};
|
|
243
|
+
/**
|
|
244
|
+
* Take a row back out of `queued` after the enqueue reported a failure.
|
|
245
|
+
*
|
|
246
|
+
* Conditional on `queued`, because the event may have landed regardless — an
|
|
247
|
+
* error from `SendMessage` says the response was lost, not that the broker
|
|
248
|
+
* refused it. If the worker already has the row, it holds the newer truth and
|
|
249
|
+
* this write does nothing.
|
|
250
|
+
*
|
|
251
|
+
* Never rejects. The enqueue failure is what went wrong and what the caller
|
|
252
|
+
* has to hear; a settle that also fails would replace it with the wrong
|
|
253
|
+
* cause, so it is logged and the row is left where #936 found it — which the
|
|
254
|
+
* caller's error at least names.
|
|
255
|
+
*/
|
|
256
|
+
settleUnqueued = async (accountConfigId, outboxMessageId, priorStatus) => {
|
|
257
|
+
const status = settledStatusFor(priorStatus);
|
|
258
|
+
await this.outboxMessageService
|
|
259
|
+
.updateIfStatus(accountConfigId, outboxMessageId, OutboxMessageStatus.queued, {
|
|
260
|
+
status,
|
|
261
|
+
...(status === OutboxMessageStatus.failed && {
|
|
262
|
+
lastError: ENQUEUE_FAILED_MESSAGE,
|
|
263
|
+
}),
|
|
264
|
+
})
|
|
265
|
+
.catch((settleError) => {
|
|
266
|
+
this.log.error({ outboxMessageId, settleError: String(settleError) }, "Could not settle an outbox message the queue refused");
|
|
267
|
+
});
|
|
268
|
+
};
|
|
269
|
+
enqueueSend = async (accountId, outboxMessageId) => {
|
|
270
|
+
const event = {
|
|
271
|
+
type: "SEND_MESSAGE",
|
|
272
|
+
eventId: randomUUID(),
|
|
273
|
+
timestamp: Date.now(),
|
|
274
|
+
accountId,
|
|
275
|
+
outboxMessageId,
|
|
276
|
+
};
|
|
277
|
+
await this.queue.send({ body: event });
|
|
278
|
+
this.log.info({ eventId: event.eventId, accountId, outboxMessageId }, "Enqueued SEND_MESSAGE event");
|
|
279
|
+
};
|
|
280
|
+
}
|
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
import type { OutboxMessageItem } from "@remit/data-ports";
|
|
2
|
-
import { OutboxMessageStatus } from "@remit/domain-enums";
|
|
3
|
-
|
|
4
2
|
/**
|
|
5
3
|
* Whether the message is still the user's to work on: nothing of it is on the
|
|
6
4
|
* wire, so Send, Edit and its attachments all apply.
|
|
@@ -15,7 +13,4 @@ import { OutboxMessageStatus } from "@remit/domain-enums";
|
|
|
15
13
|
* Everything absent is in the worker's hands or settled: `queued` and `sending`
|
|
16
14
|
* are mid-flight, `sent` and `unfiled` were delivered.
|
|
17
15
|
*/
|
|
18
|
-
export const isOpenForWork
|
|
19
|
-
status === OutboxMessageStatus.draft ||
|
|
20
|
-
status === OutboxMessageStatus.failed ||
|
|
21
|
-
status === OutboxMessageStatus.blocked;
|
|
16
|
+
export declare const isOpenForWork: (status: OutboxMessageItem["status"]) => boolean;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { OutboxMessageStatus } from "@remit/domain-enums";
|
|
2
|
+
/**
|
|
3
|
+
* Whether the message is still the user's to work on: nothing of it is on the
|
|
4
|
+
* wire, so Send, Edit and its attachments all apply.
|
|
5
|
+
*
|
|
6
|
+
* One predicate for all three because they answer the same question, and
|
|
7
|
+
* splitting them is what dead-ended a refused message. Sending took `failed`
|
|
8
|
+
* while editing took only `draft`, so a message the server turned away for a
|
|
9
|
+
* bad address could be re-sent unchanged or deleted, and nothing else — Retry
|
|
10
|
+
* queued the same envelope, and Edit took a 409 on the flush that precedes the
|
|
11
|
+
* send (#933).
|
|
12
|
+
*
|
|
13
|
+
* Everything absent is in the worker's hands or settled: `queued` and `sending`
|
|
14
|
+
* are mid-flight, `sent` and `unfiled` were delivered.
|
|
15
|
+
*/
|
|
16
|
+
export const isOpenForWork = (status) => status === OutboxMessageStatus.draft ||
|
|
17
|
+
status === OutboxMessageStatus.failed ||
|
|
18
|
+
status === OutboxMessageStatus.blocked;
|