@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.
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 +129 -0
  78. package/dist/mailbox-queue.js +216 -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 +280 -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
@@ -1,525 +0,0 @@
1
- import { randomUUID } from "node:crypto";
2
- import { SendMessageCommand, type SQSClient } from "@aws-sdk/client-sqs";
3
- import type {
4
- IAccountRepository,
5
- IOutboxMessageRepository,
6
- OutboxMessageItem,
7
- } from "@remit/data-ports";
8
- import {
9
- BadRequestError,
10
- ConflictError,
11
- MailSyncOffError,
12
- } from "@remit/data-ports/errors";
13
- import { bareMessageId } from "@remit/data-ports/id";
14
- import { AccountService, OutboxMessageStatus } from "@remit/domain-enums";
15
- import { createQueueProducer } from "@remit/sqs-client/producer";
16
- import type { OutboxAttachmentService } from "./outbox-attachment.js";
17
- import { isOpenForWork } from "./outbox-status.js";
18
-
19
- interface SendMessageEvent {
20
- type: "SEND_MESSAGE";
21
- eventId: string;
22
- timestamp: number;
23
- accountId: string;
24
- outboxMessageId: string;
25
- }
26
-
27
- export interface OutboxQueueLogger {
28
- info(obj: Record<string, unknown>, msg: string): void;
29
- error(obj: Record<string, unknown>, msg: string): void;
30
- }
31
-
32
- const noopLogger: OutboxQueueLogger = {
33
- info: () => {},
34
- error: () => {},
35
- };
36
-
37
- export interface OutboxQueueConfig {
38
- outboxMessageService: IOutboxMessageRepository;
39
- outboxAttachmentService: OutboxAttachmentService;
40
- accountService: IAccountRepository;
41
- sqsSmtpQueueUrl: string;
42
- sqsEndpoint?: string;
43
- sqsClient?: SQSClient;
44
- logger?: OutboxQueueLogger;
45
- }
46
-
47
- export interface CreateDraftInput {
48
- accountId: string;
49
- accountConfigId: string;
50
- fromAddress: string;
51
- fromName?: string;
52
- toAddresses: string[];
53
- ccAddresses?: string[];
54
- bccAddresses?: string[];
55
- subject?: string;
56
- textBody?: string;
57
- htmlBody?: string;
58
- inReplyTo?: string;
59
- references?: string[];
60
- }
61
-
62
- export interface UpdateDraftInput {
63
- toAddresses?: string[];
64
- ccAddresses?: string[];
65
- bccAddresses?: string[];
66
- subject?: string;
67
- textBody?: string;
68
- htmlBody?: string;
69
- inReplyTo?: string;
70
- references?: string[];
71
- }
72
-
73
- const MAX_REFERENCES = 21;
74
-
75
- const capReferences = (ids: string[]): string[] =>
76
- ids.length <= MAX_REFERENCES
77
- ? ids
78
- : [ids[0], ...ids.slice(ids.length - (MAX_REFERENCES - 1))];
79
-
80
- const bareThreadingHeaders = (input: {
81
- inReplyTo?: string;
82
- references?: string[];
83
- }): { inReplyTo?: string; references?: string[] } => ({
84
- ...(input.inReplyTo !== undefined && {
85
- inReplyTo: bareMessageId(input.inReplyTo),
86
- }),
87
- ...(input.references !== undefined && {
88
- references: capReferences([
89
- ...new Set(input.references.map(bareMessageId).filter((id) => id !== "")),
90
- ]),
91
- }),
92
- });
93
-
94
- /**
95
- * Nobody to send to. `@minItems(1)` on the create input is the only thing that
96
- * has ever stood between a zero-recipient message and nodemailer, which refuses
97
- * an empty envelope — and refuses it inside the SMTP worker, where the failure
98
- * lands in the DLQ rather than in front of the person who pressed Send. Neither
99
- * route into the queue passes that schema: a draft is created with a recipient
100
- * and can be edited down to none, and `send` never sees the create input at all.
101
- *
102
- * Cc and Bcc count. A message addressed only in Bcc is a real message with a
103
- * real envelope; only a message with no address anywhere has nowhere to go.
104
- */
105
- const hasNowhereToGo = (message: {
106
- toAddresses?: string[];
107
- ccAddresses?: string[];
108
- bccAddresses?: string[];
109
- }): boolean =>
110
- (message.toAddresses?.length ?? 0) === 0 &&
111
- (message.ccAddresses?.length ?? 0) === 0 &&
112
- (message.bccAddresses?.length ?? 0) === 0;
113
-
114
- const NO_RECIPIENT_MESSAGE =
115
- "This message has nobody to send to. Add a recipient before sending it.";
116
-
117
- const MAIL_SYNC_OFF_SEND_MESSAGE =
118
- "Mail is turned off for this account, so it cannot send. Turn Mail back on for this account in Settings to send from it.";
119
-
120
- const ENQUEUE_FAILED_MESSAGE =
121
- "This message could not be handed to the outgoing queue, so it was not sent. Send it again.";
122
-
123
- const MOVED_WHILE_EDITING_MESSAGE =
124
- "This message started sending while it was being edited, so the change was not saved. Open the Outbox to see where it stands.";
125
-
126
- const MOVED_WHILE_SENDING_MESSAGE =
127
- "This message already left the queue and cannot be sent again. Open the Outbox to see where it stands.";
128
-
129
- /**
130
- * Where a row goes when the enqueue reports a failure — never `draft`, and
131
- * never back to `draft` where it came from.
132
- *
133
- * A throw from `SendMessage` does not prove the broker refused the event. This
134
- * queue is not FIFO on the send path and carries no deduplication id, so a
135
- * timeout or a lost response leaves an event that may still be delivered. And
136
- * `draft` is inside the worker's send fence (`SENDABLE_STATUSES`, smtp-worker
137
- * `send-message-core.ts`): a row put back there is sendable by the landed event
138
- * and by the user pressing Send, which is two copies of one message. `failed`
139
- * is outside that fence, so the landed event is dropped on arrival, and a
140
- * `failed` row is editable and sendable by hand (#933) — the recovery survives.
141
- *
142
- * `blocked` is outside the fence too and names a cause the enqueue did not
143
- * change, so a row that came from there goes back to it.
144
- */
145
- const settledStatusFor = (
146
- priorStatus: OutboxMessageItem["status"],
147
- ): OutboxMessageItem["status"] =>
148
- priorStatus === OutboxMessageStatus.blocked
149
- ? OutboxMessageStatus.blocked
150
- : OutboxMessageStatus.failed;
151
-
152
- const generateMessageId = (domain: string): string => {
153
- const timestamp = Date.now();
154
- const random = randomUUID().replace(/-/g, "").slice(0, 16);
155
- return `${timestamp}.${random}@${domain}`;
156
- };
157
-
158
- const extractDomain = (email: string): string => {
159
- const atIndex = email.lastIndexOf("@");
160
- if (atIndex === -1) return "localhost";
161
- return email.slice(atIndex + 1);
162
- };
163
-
164
- export class OutboxQueueService {
165
- private outboxMessageService: IOutboxMessageRepository;
166
- private outboxAttachmentService: OutboxAttachmentService;
167
- private accountService: IAccountRepository;
168
- private sqs: SQSClient;
169
- private queueUrl: string;
170
- private log: OutboxQueueLogger;
171
-
172
- constructor(config: OutboxQueueConfig) {
173
- const {
174
- outboxMessageService,
175
- outboxAttachmentService,
176
- accountService,
177
- sqsSmtpQueueUrl,
178
- sqsEndpoint,
179
- } = config;
180
- this.outboxMessageService = outboxMessageService;
181
- this.outboxAttachmentService = outboxAttachmentService;
182
- this.accountService = accountService;
183
- this.queueUrl = sqsSmtpQueueUrl;
184
- this.log = config.logger ?? noopLogger;
185
-
186
- this.sqs =
187
- config.sqsClient ??
188
- createQueueProducer({
189
- queueUrl: sqsSmtpQueueUrl,
190
- endpoint: sqsEndpoint,
191
- });
192
- }
193
-
194
- createDraft = async (input: CreateDraftInput): Promise<OutboxMessageItem> => {
195
- const domain = extractDomain(input.fromAddress);
196
- const messageIdValue = generateMessageId(domain);
197
-
198
- const outbox = await this.outboxMessageService.create({
199
- accountId: input.accountId,
200
- accountConfigId: input.accountConfigId,
201
- fromAddress: input.fromAddress,
202
- fromName: input.fromName,
203
- toAddresses: input.toAddresses,
204
- ccAddresses: input.ccAddresses,
205
- bccAddresses: input.bccAddresses,
206
- subject: input.subject,
207
- textBody: input.textBody,
208
- htmlBody: input.htmlBody,
209
- ...bareThreadingHeaders(input),
210
- messageIdValue,
211
- status: OutboxMessageStatus.draft,
212
- });
213
-
214
- this.log.info(
215
- { outboxMessageId: outbox.outboxMessageId, accountId: input.accountId },
216
- "Created draft outbox message",
217
- );
218
-
219
- return outbox;
220
- };
221
-
222
- updateDraft = async (
223
- accountConfigId: string,
224
- outboxMessageId: string,
225
- input: UpdateDraftInput,
226
- ): Promise<OutboxMessageItem> => {
227
- const existing = await this.outboxMessageService.get(
228
- accountConfigId,
229
- outboxMessageId,
230
- "act",
231
- );
232
- if (!isOpenForWork(existing.status)) {
233
- throw new ConflictError(
234
- `This message is already ${existing.status} and can no longer be edited as a draft. Start a new message to change it.`,
235
- );
236
- }
237
-
238
- // Editing a settled failure returns the row to `draft`: it is no longer the
239
- // message that failed, and the Outbox renders a `failed` row with its
240
- // `lastError` — a failure reported against text that never went out. The
241
- // same row moves to Drafts carrying its content, recipients and
242
- // attachments, so there is no copy to reconcile.
243
- //
244
- // Conditional on the status this decision was read from. A concurrent send
245
- // can move the row to `queued` between the two, and an unconditional write
246
- // would pull it back to `draft` with its event already on the wire — the
247
- // worker's fence takes `draft`, so that row goes out and stays sendable.
248
- const updated = await this.outboxMessageService.updateIfStatus(
249
- accountConfigId,
250
- outboxMessageId,
251
- existing.status,
252
- {
253
- ...(existing.status !== OutboxMessageStatus.draft && {
254
- status: OutboxMessageStatus.draft,
255
- }),
256
- ...(input.toAddresses !== undefined && {
257
- toAddresses: input.toAddresses,
258
- }),
259
- ...(input.ccAddresses !== undefined && {
260
- ccAddresses: input.ccAddresses,
261
- }),
262
- ...(input.bccAddresses !== undefined && {
263
- bccAddresses: input.bccAddresses,
264
- }),
265
- ...(input.subject !== undefined && { subject: input.subject }),
266
- ...(input.textBody !== undefined && { textBody: input.textBody }),
267
- ...(input.htmlBody !== undefined && { htmlBody: input.htmlBody }),
268
- ...bareThreadingHeaders(input),
269
- },
270
- );
271
- if (!updated) throw new ConflictError(MOVED_WHILE_EDITING_MESSAGE);
272
-
273
- this.log.info({ outboxMessageId }, "Updated draft outbox message");
274
-
275
- return updated;
276
- };
277
-
278
- private refuseWithoutMail = async (accountId: string): Promise<void> => {
279
- const account = await this.accountService.get(accountId);
280
- if (account.syncedServices.includes(AccountService.Mail)) return;
281
- throw new MailSyncOffError(MAIL_SYNC_OFF_SEND_MESSAGE, accountId);
282
- };
283
-
284
- send = async (
285
- accountConfigId: string,
286
- outboxMessageId: string,
287
- ): Promise<OutboxMessageItem> => {
288
- const existing = await this.outboxMessageService.get(
289
- accountConfigId,
290
- outboxMessageId,
291
- "act",
292
- );
293
- if (!isOpenForWork(existing.status)) {
294
- throw new ConflictError(
295
- `This message is already ${existing.status} and cannot be sent again. Open the Outbox to see where it stands.`,
296
- );
297
- }
298
-
299
- if (hasNowhereToGo(existing)) {
300
- throw new BadRequestError(NO_RECIPIENT_MESSAGE);
301
- }
302
- await this.refuseWithoutMail(existing.accountId);
303
-
304
- const unfinished = await this.outboxAttachmentService.unfinishedUpload(
305
- accountConfigId,
306
- outboxMessageId,
307
- );
308
- if (unfinished) {
309
- throw new BadRequestError(
310
- `"${unfinished.filename}" has not finished uploading. Wait for it, or remove it, and send again.`,
311
- );
312
- }
313
-
314
- // Conditional on the status this send was decided against, so two presses —
315
- // or a press racing the worker — produce one queued row and one conflict
316
- // rather than two events for the same message.
317
- const updated = await this.outboxMessageService.updateIfStatus(
318
- accountConfigId,
319
- outboxMessageId,
320
- existing.status,
321
- { status: OutboxMessageStatus.queued },
322
- );
323
- if (!updated) throw new ConflictError(MOVED_WHILE_SENDING_MESSAGE);
324
-
325
- // A reservation made from another tab between the check above and the
326
- // move to `queued` got past it; one made after the move is refused, so a
327
- // second look now is the last word.
328
- const lateUpload = await this.outboxAttachmentService.unfinishedUpload(
329
- accountConfigId,
330
- outboxMessageId,
331
- );
332
- if (lateUpload) {
333
- await this.outboxMessageService.updateIfStatus(
334
- accountConfigId,
335
- outboxMessageId,
336
- OutboxMessageStatus.queued,
337
- { status: existing.status },
338
- );
339
- throw new BadRequestError(
340
- `"${lateUpload.filename}" has not finished uploading. Wait for it, or remove it, and send again.`,
341
- );
342
- }
343
-
344
- // `queued` is a dead end for a row the queue never accepted: `send` takes
345
- // draft, failed and blocked, `deleteDraft` those three plus unfiled, so a
346
- // row parked at `queued` by a failed enqueue is neither sendable nor
347
- // discardable (#845.8). Settle it and let the enqueue failure surface —
348
- // the row stays reachable, the caller still hears no.
349
- await this.enqueueSend(existing.accountId, outboxMessageId).catch(
350
- async (error: unknown) => {
351
- await this.settleUnqueued(
352
- accountConfigId,
353
- outboxMessageId,
354
- existing.status,
355
- );
356
- throw error;
357
- },
358
- );
359
-
360
- this.log.info(
361
- { outboxMessageId, accountId: existing.accountId },
362
- "Queued outbox message for sending",
363
- );
364
-
365
- return updated;
366
- };
367
-
368
- createAndSend = async (
369
- input: CreateDraftInput,
370
- ): Promise<OutboxMessageItem> => {
371
- if (hasNowhereToGo(input)) {
372
- throw new BadRequestError(NO_RECIPIENT_MESSAGE);
373
- }
374
- await this.refuseWithoutMail(input.accountId);
375
-
376
- const domain = extractDomain(input.fromAddress);
377
- const messageIdValue = generateMessageId(domain);
378
-
379
- const outbox = await this.outboxMessageService.create({
380
- accountId: input.accountId,
381
- accountConfigId: input.accountConfigId,
382
- fromAddress: input.fromAddress,
383
- fromName: input.fromName,
384
- toAddresses: input.toAddresses,
385
- ccAddresses: input.ccAddresses,
386
- bccAddresses: input.bccAddresses,
387
- subject: input.subject,
388
- textBody: input.textBody,
389
- htmlBody: input.htmlBody,
390
- ...bareThreadingHeaders(input),
391
- messageIdValue,
392
- status: OutboxMessageStatus.queued,
393
- });
394
-
395
- // Same dead end as in `send`: a row parked at `queued` by an enqueue that
396
- // threw is neither sendable nor discardable (#845.8, #931, #936). This row
397
- // is new, so there is no prior status — it settles at `failed`, carrying
398
- // the reason, which keeps the composed text editable and sendable while
399
- // staying outside the fence a landed event has to pass.
400
- await this.enqueueSend(input.accountId, outbox.outboxMessageId).catch(
401
- async (error: unknown) => {
402
- await this.settleUnqueued(
403
- input.accountConfigId,
404
- outbox.outboxMessageId,
405
- OutboxMessageStatus.failed,
406
- );
407
- throw error;
408
- },
409
- );
410
-
411
- this.log.info(
412
- { outboxMessageId: outbox.outboxMessageId, accountId: input.accountId },
413
- "Created and queued outbox message for sending",
414
- );
415
-
416
- return outbox;
417
- };
418
-
419
- deleteDraft = async (
420
- accountConfigId: string,
421
- outboxMessageId: string,
422
- ): Promise<void> => {
423
- const existing = await this.outboxMessageService.get(
424
- accountConfigId,
425
- outboxMessageId,
426
- "act",
427
- );
428
- // `unfiled` is discardable: the message was delivered but never landed in
429
- // a Sent folder, so this row is the only copy the user has and dismissing
430
- // it is their acknowledgement.
431
- if (
432
- existing.status !== OutboxMessageStatus.draft &&
433
- existing.status !== OutboxMessageStatus.failed &&
434
- existing.status !== OutboxMessageStatus.blocked &&
435
- existing.status !== OutboxMessageStatus.unfiled
436
- ) {
437
- throw new ConflictError(
438
- `This message is already ${existing.status} and can no longer be discarded. Open the Outbox to see where it stands.`,
439
- );
440
- }
441
-
442
- // Files first, row second. Nothing but this row points at those objects,
443
- // so a row deleted ahead of a sweep that then fails leaves bytes no one
444
- // can reach; the other order leaves a draft whose files are gone, which
445
- // is at least visible. A storage failure aborts the discard outright.
446
- await this.outboxAttachmentService.discardAll(
447
- accountConfigId,
448
- existing.accountId,
449
- outboxMessageId,
450
- );
451
-
452
- await this.outboxMessageService.delete(accountConfigId, outboxMessageId);
453
-
454
- this.log.info({ outboxMessageId }, "Deleted outbox message");
455
- };
456
-
457
- /**
458
- * Take a row back out of `queued` after the enqueue reported a failure.
459
- *
460
- * Conditional on `queued`, because the event may have landed regardless — an
461
- * error from `SendMessage` says the response was lost, not that the broker
462
- * refused it. If the worker already has the row, it holds the newer truth and
463
- * this write does nothing.
464
- *
465
- * Never rejects. The enqueue failure is what went wrong and what the caller
466
- * has to hear; a settle that also fails would replace it with the wrong
467
- * cause, so it is logged and the row is left where #936 found it — which the
468
- * caller's error at least names.
469
- */
470
- private settleUnqueued = async (
471
- accountConfigId: string,
472
- outboxMessageId: string,
473
- priorStatus: OutboxMessageItem["status"],
474
- ): Promise<void> => {
475
- const status = settledStatusFor(priorStatus);
476
- await this.outboxMessageService
477
- .updateIfStatus(
478
- accountConfigId,
479
- outboxMessageId,
480
- OutboxMessageStatus.queued,
481
- {
482
- status,
483
- ...(status === OutboxMessageStatus.failed && {
484
- lastError: ENQUEUE_FAILED_MESSAGE,
485
- }),
486
- },
487
- )
488
- .catch((settleError: unknown) => {
489
- this.log.error(
490
- { outboxMessageId, settleError: String(settleError) },
491
- "Could not settle an outbox message the queue refused",
492
- );
493
- });
494
- };
495
-
496
- private enqueueSend = async (
497
- accountId: string,
498
- outboxMessageId: string,
499
- ): Promise<void> => {
500
- const event: SendMessageEvent = {
501
- type: "SEND_MESSAGE",
502
- eventId: randomUUID(),
503
- timestamp: Date.now(),
504
- accountId,
505
- outboxMessageId,
506
- };
507
-
508
- const useFifo = this.queueUrl.endsWith(".fifo");
509
- await this.sqs.send(
510
- new SendMessageCommand({
511
- QueueUrl: this.queueUrl,
512
- MessageBody: JSON.stringify(event),
513
- ...(useFifo && {
514
- MessageGroupId: accountId,
515
- MessageDeduplicationId: event.eventId,
516
- }),
517
- }),
518
- );
519
-
520
- this.log.info(
521
- { eventId: event.eventId, accountId, outboxMessageId },
522
- "Enqueued SEND_MESSAGE event",
523
- );
524
- };
525
- }
package/src/ownership.ts DELETED
@@ -1,140 +0,0 @@
1
- import type { AccountItem, MailboxItem } from "@remit/data-ports";
2
- import { MailboxSyncStatus } from "@remit/domain-enums";
3
- import {
4
- type AccessMode,
5
- accept,
6
- invalid,
7
- type Refusable,
8
- refuse,
9
- } from "./refusal.js";
10
-
11
- // A read refuses with NotFound so another tenant's account does not leak its
12
- // existence; an action names the resource already, so it is refused outright.
13
- export const ownedAccount = <
14
- A extends Pick<AccountItem, "accountId" | "accountConfigId">,
15
- >(
16
- account: A,
17
- accountConfigId: string,
18
- mode: AccessMode,
19
- ): Refusable<A> => {
20
- if (account.accountConfigId === accountConfigId) return accept(account);
21
- if (mode === "read") {
22
- return refuse({
23
- kind: "NotFound",
24
- message: `Account not found: ${account.accountId}`,
25
- });
26
- }
27
- return refuse({
28
- kind: "Forbidden",
29
- message: `Account ${account.accountId} not in account config`,
30
- });
31
- };
32
-
33
- // Owning the account in a path proves nothing about the mailboxId beside it.
34
- export const folderInAccount = <
35
- M extends Pick<MailboxItem, "mailboxId" | "accountId">,
36
- >(
37
- mailbox: M,
38
- accountId: string,
39
- mode: AccessMode,
40
- ): Refusable<M> => {
41
- if (mailbox.accountId === accountId) return accept(mailbox);
42
- if (mode === "read") {
43
- return refuse({
44
- kind: "NotFound",
45
- message: `Mailbox not found: ${mailbox.mailboxId}`,
46
- });
47
- }
48
- return refuse({
49
- kind: "Forbidden",
50
- message: `Mailbox ${mailbox.mailboxId} not in account`,
51
- });
52
- };
53
-
54
- const UNSETTLED_REASON: Partial<
55
- Record<MailboxItem["syncStatus"], (path: string) => string>
56
- > = {
57
- [MailboxSyncStatus.pending]: (path) =>
58
- `“${path}” isn’t ready yet — the mail server hasn’t confirmed it.`,
59
- [MailboxSyncStatus.deleting]: (path) => `“${path}” is being deleted.`,
60
- [MailboxSyncStatus.failed]: (path) =>
61
- `The last change to “${path}” failed. Retry or dismiss it first.`,
62
- };
63
-
64
- // A durable reference binds only to a folder the mail server has confirmed
65
- // (imap-mutations R2: wait). This is the server-side floor under the client's
66
- // own wait, because a script calling the API directly gets no wait at all.
67
- export const settledFolder = <
68
- M extends Pick<MailboxItem, "mailboxId" | "fullPath" | "syncStatus">,
69
- >(
70
- target: M,
71
- ): Refusable<M> => {
72
- const reason = UNSETTLED_REASON[target.syncStatus];
73
- if (!reason) return accept(target);
74
- return refuse({
75
- kind: "NotSettled",
76
- message: reason(target.fullPath),
77
- mailboxId: target.mailboxId,
78
- syncStatus: target.syncStatus,
79
- });
80
- };
81
-
82
- export interface MessageOwnershipClient {
83
- message: { get(messageId: string): Promise<{ mailboxId: string }> };
84
- mailbox: {
85
- get(
86
- accountId: string,
87
- mailboxIds: string[],
88
- ): Promise<{ mailboxId: string; accountId: string }[]>;
89
- };
90
- account: {
91
- listAllByAccountConfig(
92
- accountConfigId: string,
93
- ): Promise<{ accountId: string }[]>;
94
- };
95
- }
96
-
97
- // Messages carry no tenant, so ownership resolves through the caller's own
98
- // accounts: a mailbox resolves only under the account that owns it, and the
99
- // whole batch must land on one account because the downstream services take
100
- // a single accountId.
101
- export const ownedMessages = async (
102
- client: MessageOwnershipClient,
103
- messageIds: string[],
104
- callerAccountConfigId: string,
105
- mode: AccessMode,
106
- ): Promise<Refusable<string>> => {
107
- const messages = await Promise.all(
108
- messageIds.map((id) => client.message.get(id)),
109
- );
110
- const mailboxIds = [...new Set(messages.map((m) => m.mailboxId))];
111
-
112
- const ownedAccounts = await client.account.listAllByAccountConfig(
113
- callerAccountConfigId,
114
- );
115
-
116
- const accountByMailbox = new Map<string, string>();
117
- for (const { accountId } of ownedAccounts) {
118
- const rows = await client.mailbox.get(accountId, mailboxIds);
119
- for (const row of rows) {
120
- accountByMailbox.set(row.mailboxId, row.accountId);
121
- }
122
- }
123
-
124
- if (mailboxIds.some((id) => !accountByMailbox.has(id))) {
125
- if (mode === "read") {
126
- return refuse({ kind: "NotFound", message: "Message not found" });
127
- }
128
- return refuse({
129
- kind: "Forbidden",
130
- message: "Message not in account config",
131
- });
132
- }
133
-
134
- const accountIds = [...new Set(accountByMailbox.values())];
135
- const [accountId] = accountIds;
136
- if (accountIds.length !== 1 || accountId === undefined) {
137
- return invalid("All messages must belong to the same account");
138
- }
139
- return accept(accountId);
140
- };