@remit/mailbox-service 0.0.112 → 0.0.113

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.
Files changed (270) hide show
  1. package/dist/account-credentials.d.ts +83 -0
  2. package/dist/account-credentials.js +104 -0
  3. package/dist/attribute-mapper.d.ts +36 -0
  4. package/dist/attribute-mapper.js +119 -0
  5. package/dist/body-parse.d.ts +28 -0
  6. package/{src/body-parse.ts → dist/body-parse.js} +18 -26
  7. package/dist/body-part-mapper.d.ts +92 -0
  8. package/dist/body-part-mapper.js +301 -0
  9. package/dist/body-sync-queue.d.ts +59 -0
  10. package/dist/body-sync-queue.js +56 -0
  11. package/dist/body-sync-terminal.d.ts +58 -0
  12. package/dist/body-sync-terminal.js +95 -0
  13. package/dist/body-sync.d.ts +351 -0
  14. package/dist/body-sync.js +1204 -0
  15. package/dist/calendar-parts.d.ts +23 -0
  16. package/dist/calendar-parts.js +18 -0
  17. package/dist/classification-backfill.d.ts +91 -0
  18. package/dist/classification-backfill.js +180 -0
  19. package/dist/classify-message.d.ts +31 -0
  20. package/dist/classify-message.js +89 -0
  21. package/dist/connection-factory.d.ts +54 -0
  22. package/dist/connection-factory.js +54 -0
  23. package/dist/connection-test.d.ts +41 -0
  24. package/dist/connection-test.js +152 -0
  25. package/dist/external-move.d.ts +94 -0
  26. package/dist/external-move.js +173 -0
  27. package/dist/filter-operations.d.ts +42 -0
  28. package/dist/filter-operations.js +211 -0
  29. package/dist/filters/filter-config.d.ts +14 -0
  30. package/dist/filters/filter-config.js +10 -0
  31. package/{src/filters/list-id.ts → dist/filters/list-id.d.ts} +2 -16
  32. package/dist/filters/list-id.js +26 -0
  33. package/dist/filters/match.d.ts +69 -0
  34. package/dist/filters/match.js +165 -0
  35. package/dist/filters/pipeline.d.ts +64 -0
  36. package/dist/filters/pipeline.js +100 -0
  37. package/{src/flag-push-terminal.ts → dist/flag-push-terminal.d.ts} +16 -72
  38. package/dist/flag-push-terminal.js +63 -0
  39. package/dist/flag-push.d.ts +69 -0
  40. package/dist/flag-push.js +111 -0
  41. package/dist/flag-queue.d.ts +175 -0
  42. package/dist/flag-queue.js +291 -0
  43. package/dist/folder-operations.d.ts +39 -0
  44. package/dist/folder-operations.js +112 -0
  45. package/dist/folder-placement.d.ts +13 -0
  46. package/dist/folder-placement.js +23 -0
  47. package/dist/heuristics/authenticationResults.d.ts +10 -0
  48. package/dist/heuristics/authenticationResults.js +187 -0
  49. package/dist/heuristics/classifyByHeaders.d.ts +82 -0
  50. package/dist/heuristics/classifyByHeaders.js +365 -0
  51. package/dist/heuristics/classifyPlacement.d.ts +67 -0
  52. package/dist/heuristics/classifyPlacement.js +153 -0
  53. package/dist/heuristics/machineSenders.d.ts +21 -0
  54. package/{src/heuristics/machineSenders.ts → dist/heuristics/machineSenders.js} +34 -40
  55. package/dist/heuristics/senderMismatch.d.ts +81 -0
  56. package/dist/heuristics/senderMismatch.js +192 -0
  57. package/dist/heuristics/socialDomains.d.ts +14 -0
  58. package/{src/heuristics/socialDomains.ts → dist/heuristics/socialDomains.js} +9 -9
  59. package/dist/heuristics/transactionalDomains.d.ts +18 -0
  60. package/{src/heuristics/transactionalDomains.ts → dist/heuristics/transactionalDomains.js} +8 -8
  61. package/dist/imapflow-connection.d.ts +377 -0
  62. package/dist/imapflow-connection.js +1147 -0
  63. package/dist/index.d.ts +63 -0
  64. package/dist/index.js +65 -0
  65. package/dist/list-id-backfill.d.ts +83 -0
  66. package/dist/list-id-backfill.js +132 -0
  67. package/dist/mailbox-cursor-rebuild.d.ts +74 -0
  68. package/dist/mailbox-cursor-rebuild.js +71 -0
  69. package/{src/mailbox-cursor.ts → dist/mailbox-cursor.d.ts} +20 -98
  70. package/dist/mailbox-cursor.js +102 -0
  71. package/dist/mailbox-intent.d.ts +41 -0
  72. package/{src/mailbox-intent.ts → dist/mailbox-intent.js} +38 -64
  73. package/dist/mailbox-management.d.ts +240 -0
  74. package/dist/mailbox-management.js +576 -0
  75. package/dist/mailbox-presence.d.ts +52 -0
  76. package/{src/mailbox-presence.ts → dist/mailbox-presence.js} +19 -30
  77. package/dist/mailbox-queue.d.ts +126 -0
  78. package/dist/mailbox-queue.js +214 -0
  79. package/dist/mailbox-sync.d.ts +93 -0
  80. package/dist/mailbox-sync.js +490 -0
  81. package/dist/mailbox-upstream.d.ts +38 -0
  82. package/{src/mailbox-upstream.ts → dist/mailbox-upstream.js} +37 -47
  83. package/dist/message-category.d.ts +51 -0
  84. package/{src/message-category.ts → dist/message-category.js} +28 -71
  85. package/dist/message-move.d.ts +285 -0
  86. package/dist/message-move.js +858 -0
  87. package/dist/message-parser.d.ts +14 -0
  88. package/dist/message-parser.js +17 -0
  89. package/{src/message-presence.ts → dist/message-presence.d.ts} +1 -13
  90. package/dist/message-presence.js +29 -0
  91. package/dist/message-read.d.ts +51 -0
  92. package/dist/message-read.js +31 -0
  93. package/dist/message-sync.d.ts +345 -0
  94. package/dist/message-sync.js +1358 -0
  95. package/dist/mime-walker.d.ts +102 -0
  96. package/dist/mime-walker.js +198 -0
  97. package/dist/organize-job.d.ts +20 -0
  98. package/dist/organize-job.js +37 -0
  99. package/dist/outbox-attachment-content.d.ts +37 -0
  100. package/dist/outbox-attachment-content.js +50 -0
  101. package/dist/outbox-attachment-filename.d.ts +19 -0
  102. package/dist/outbox-attachment-filename.js +64 -0
  103. package/dist/outbox-attachment.d.ts +148 -0
  104. package/dist/outbox-attachment.js +273 -0
  105. package/dist/outbox-queue.d.ts +73 -0
  106. package/dist/outbox-queue.js +284 -0
  107. package/{src/outbox-status.ts → dist/outbox-status.d.ts} +1 -6
  108. package/dist/outbox-status.js +18 -0
  109. package/dist/ownership.d.ts +24 -0
  110. package/dist/ownership.js +83 -0
  111. package/dist/placement-move-terminal.d.ts +67 -0
  112. package/dist/placement-move-terminal.js +88 -0
  113. package/dist/placement-move.d.ts +83 -0
  114. package/dist/placement-move.js +216 -0
  115. package/{src/placement-settled.ts → dist/placement-settled.d.ts} +13 -68
  116. package/dist/placement-settled.js +75 -0
  117. package/dist/quarantine.d.ts +95 -0
  118. package/dist/quarantine.js +131 -0
  119. package/dist/refusal.d.ts +31 -0
  120. package/dist/refusal.js +3 -0
  121. package/{src/restore-source-placement.ts → dist/restore-source-placement.d.ts} +29 -97
  122. package/dist/restore-source-placement.js +79 -0
  123. package/dist/search-text-backfill.d.ts +15 -0
  124. package/dist/search-text-backfill.js +33 -0
  125. package/dist/snippet.d.ts +61 -0
  126. package/dist/snippet.js +129 -0
  127. package/dist/spam-report.d.ts +86 -0
  128. package/dist/spam-report.js +241 -0
  129. package/dist/sync-warning.d.ts +21 -0
  130. package/dist/sync-warning.js +26 -0
  131. package/dist/sync-watermarks.d.ts +138 -0
  132. package/dist/sync-watermarks.js +205 -0
  133. package/dist/test-helpers/fake-imap.d.ts +151 -0
  134. package/dist/test-helpers/fake-imap.js +508 -0
  135. package/dist/test-helpers/folder-roles.d.ts +34 -0
  136. package/dist/test-helpers/folder-roles.js +47 -0
  137. package/dist/test-helpers/mime-tree-generator.d.ts +82 -0
  138. package/dist/test-helpers/mime-tree-generator.js +329 -0
  139. package/dist/test-helpers/test-store.d.ts +69 -0
  140. package/dist/test-helpers/test-store.js +193 -0
  141. package/dist/text/normalizer.d.ts +20 -0
  142. package/dist/text/normalizer.js +102 -0
  143. package/dist/thread-message-rows.d.ts +69 -0
  144. package/dist/thread-message-rows.js +40 -0
  145. package/dist/thread-root.d.ts +7 -0
  146. package/dist/thread-root.js +25 -0
  147. package/dist/types.d.ts +343 -0
  148. package/dist/types.js +19 -0
  149. package/package.json +86 -88
  150. package/src/account-credentials.ts +0 -210
  151. package/src/attribute-mapper.test.ts +0 -104
  152. package/src/attribute-mapper.ts +0 -148
  153. package/src/body-parse.test.ts +0 -55
  154. package/src/body-part-mapper.property.test.ts +0 -354
  155. package/src/body-part-mapper.ts +0 -410
  156. package/src/body-sync-queue.test.ts +0 -64
  157. package/src/body-sync-queue.ts +0 -131
  158. package/src/body-sync-terminal.test.ts +0 -148
  159. package/src/body-sync-terminal.ts +0 -174
  160. package/src/body-sync.test.ts +0 -925
  161. package/src/body-sync.ts +0 -1891
  162. package/src/calendar-parts.ts +0 -41
  163. package/src/classification-backfill.test.ts +0 -285
  164. package/src/classification-backfill.ts +0 -363
  165. package/src/classify-message.test.ts +0 -142
  166. package/src/classify-message.ts +0 -116
  167. package/src/connection-factory.ts +0 -102
  168. package/src/connection-test.ts +0 -208
  169. package/src/credentials.test.ts +0 -181
  170. package/src/external-move.test.ts +0 -262
  171. package/src/external-move.ts +0 -285
  172. package/src/filter-operations.test.ts +0 -319
  173. package/src/filter-operations.ts +0 -377
  174. package/src/filter-update.test.ts +0 -313
  175. package/src/filters/filter-config.test.ts +0 -87
  176. package/src/filters/filter-config.ts +0 -34
  177. package/src/filters/list-id.test.ts +0 -50
  178. package/src/filters/match.test.ts +0 -392
  179. package/src/filters/match.ts +0 -225
  180. package/src/filters/pipeline.test.ts +0 -199
  181. package/src/filters/pipeline.ts +0 -170
  182. package/src/flag-push.test.ts +0 -112
  183. package/src/flag-push.ts +0 -198
  184. package/src/flag-queue.test.ts +0 -197
  185. package/src/flag-queue.ts +0 -509
  186. package/src/folder-operations.test.ts +0 -273
  187. package/src/folder-operations.ts +0 -217
  188. package/src/folder-placement.ts +0 -56
  189. package/src/forwarded-message.test.ts +0 -122
  190. package/src/heuristics/authenticationResults.test.ts +0 -189
  191. package/src/heuristics/authenticationResults.ts +0 -212
  192. package/src/heuristics/classifyByHeaders.test.ts +0 -889
  193. package/src/heuristics/classifyByHeaders.ts +0 -467
  194. package/src/heuristics/classifyPlacement.test.ts +0 -366
  195. package/src/heuristics/classifyPlacement.ts +0 -211
  196. package/src/heuristics/classifyRealMail.test.ts +0 -242
  197. package/src/heuristics/machineSenders.test.ts +0 -101
  198. package/src/heuristics/senderMismatch.test.ts +0 -346
  199. package/src/heuristics/senderMismatch.ts +0 -259
  200. package/src/imapflow-connect-retry.test.ts +0 -97
  201. package/src/imapflow-connection.test.ts +0 -503
  202. package/src/imapflow-connection.ts +0 -1476
  203. package/src/index.ts +0 -397
  204. package/src/list-id-backfill.test.ts +0 -207
  205. package/src/list-id-backfill.ts +0 -274
  206. package/src/mailbox-cursor-rebuild.test.ts +0 -171
  207. package/src/mailbox-cursor-rebuild.ts +0 -146
  208. package/src/mailbox-cursor.test.ts +0 -141
  209. package/src/mailbox-delete-intent.test.ts +0 -119
  210. package/src/mailbox-management.test.ts +0 -243
  211. package/src/mailbox-management.ts +0 -838
  212. package/src/mailbox-presence.test.ts +0 -74
  213. package/src/mailbox-queue.ts +0 -397
  214. package/src/mailbox-rename-intent.test.ts +0 -220
  215. package/src/mailbox-rename-subtree.test.ts +0 -287
  216. package/src/mailbox-sync.test.ts +0 -286
  217. package/src/mailbox-sync.ts +0 -724
  218. package/src/message-move.test.ts +0 -726
  219. package/src/message-move.ts +0 -1518
  220. package/src/message-parser.ts +0 -26
  221. package/src/message-presence.test.ts +0 -50
  222. package/src/message-read.ts +0 -87
  223. package/src/message-save-retry.test.ts +0 -90
  224. package/src/message-sync-flags.test.ts +0 -167
  225. package/src/message-sync.test.ts +0 -762
  226. package/src/message-sync.ts +0 -2133
  227. package/src/mime-walker.test.ts +0 -311
  228. package/src/mime-walker.ts +0 -306
  229. package/src/organize-job.ts +0 -93
  230. package/src/outbox-attachment-content.test.ts +0 -153
  231. package/src/outbox-attachment-content.ts +0 -104
  232. package/src/outbox-attachment-filename.test.ts +0 -132
  233. package/src/outbox-attachment-filename.ts +0 -76
  234. package/src/outbox-attachment.test.ts +0 -626
  235. package/src/outbox-attachment.ts +0 -536
  236. package/src/outbox-failed-recovery.test.ts +0 -299
  237. package/src/outbox-queue.test.ts +0 -315
  238. package/src/outbox-queue.ts +0 -525
  239. package/src/ownership.ts +0 -140
  240. package/src/placement-move-terminal.ts +0 -145
  241. package/src/placement-move.ts +0 -363
  242. package/src/placement-settled.test.ts +0 -147
  243. package/src/placement.test.ts +0 -404
  244. package/src/quarantine.test.ts +0 -133
  245. package/src/quarantine.ts +0 -229
  246. package/src/refusal.ts +0 -29
  247. package/src/snippet.test.ts +0 -213
  248. package/src/snippet.ts +0 -152
  249. package/src/spam-report.test.ts +0 -329
  250. package/src/spam-report.ts +0 -357
  251. package/src/sync-warning.test.ts +0 -213
  252. package/src/sync-warning.ts +0 -56
  253. package/src/sync-watermarks.test.ts +0 -409
  254. package/src/sync-watermarks.ts +0 -319
  255. package/src/system-flag-wire-format.test.ts +0 -77
  256. package/src/test-helpers/fake-imap.ts +0 -690
  257. package/src/test-helpers/folder-roles.ts +0 -66
  258. package/src/test-helpers/mime-tree-generator.ts +0 -461
  259. package/src/test-helpers/test-queue.ts +0 -56
  260. package/src/test-helpers/test-store.ts +0 -299
  261. package/src/text/normalizer.test.ts +0 -223
  262. package/src/text/normalizer.ts +0 -140
  263. package/src/thread-message-rows.ts +0 -77
  264. package/src/thread-root.test.ts +0 -29
  265. package/src/thread-root.ts +0 -51
  266. package/src/types/mailparser-augment.d.ts +0 -23
  267. package/src/types/natural-porter-stemmer.d.ts +0 -50
  268. package/src/types/stopword.d.ts +0 -69
  269. package/src/types.ts +0 -371
  270. 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 { FifoQueueProducer, IAccountRepository, IOutboxMessageRepository, OutboxMessageItem } 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: FifoQueueProducer<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,284 @@
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({
278
+ body: event,
279
+ groupId: accountId,
280
+ deduplicationId: event.eventId,
281
+ });
282
+ this.log.info({ eventId: event.eventId, accountId, outboxMessageId }, "Enqueued SEND_MESSAGE event");
283
+ };
284
+ }
@@ -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 = (status: OutboxMessageItem["status"]): boolean =>
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;