@remit/backend 0.0.143 → 0.0.145

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 (279) hide show
  1. package/dist/dev-server/content-handler.d.ts +38 -0
  2. package/dist/dev-server/content-handler.js +46 -0
  3. package/dist/src/api.d.ts +5 -0
  4. package/dist/src/api.js +73 -0
  5. package/dist/src/auth.d.ts +24 -0
  6. package/dist/src/auth.js +81 -0
  7. package/dist/src/config/api-config.d.ts +20 -0
  8. package/dist/src/config/api-config.js +1 -0
  9. package/dist/src/config/from-env.d.ts +2 -0
  10. package/dist/src/config/from-env.js +65 -0
  11. package/dist/src/config/msoauth-secrets-manager.d.ts +2 -0
  12. package/dist/src/config/msoauth-secrets-manager.js +28 -0
  13. package/dist/src/config/msoauth.d.ts +19 -0
  14. package/dist/src/config/msoauth.js +6 -0
  15. package/{src/derive/autoMoved.ts → dist/src/derive/autoMoved.d.ts} +1 -29
  16. package/dist/src/derive/autoMoved.js +49 -0
  17. package/dist/src/derive/contentSignature.d.ts +40 -0
  18. package/{src/derive/contentSignature.ts → dist/src/derive/contentSignature.js} +23 -77
  19. package/dist/src/derive/contentUrl.d.ts +33 -0
  20. package/dist/src/derive/contentUrl.js +27 -0
  21. package/{src/derive/deriveMuted.ts → dist/src/derive/deriveMuted.d.ts} +1 -3
  22. package/dist/src/derive/deriveMuted.js +7 -0
  23. package/dist/src/derive/enrichThreadRows.d.ts +118 -0
  24. package/dist/src/derive/enrichThreadRows.js +151 -0
  25. package/dist/src/derive/filterThreadCriteria.d.ts +23 -0
  26. package/dist/src/derive/filterThreadCriteria.js +24 -0
  27. package/{src/derive/pendingMoveCounts.ts → dist/src/derive/pendingMoveCounts.d.ts} +5 -53
  28. package/dist/src/derive/pendingMoveCounts.js +53 -0
  29. package/{src/derive/senderTrust.ts → dist/src/derive/senderTrust.d.ts} +1 -9
  30. package/dist/src/derive/senderTrust.js +19 -0
  31. package/dist/src/error.d.ts +21 -0
  32. package/dist/src/error.js +85 -0
  33. package/dist/src/handlers/account-guards.d.ts +36 -0
  34. package/dist/src/handlers/account-guards.js +107 -0
  35. package/dist/src/handlers/account-oauth.d.ts +15 -0
  36. package/dist/src/handlers/account-oauth.js +334 -0
  37. package/dist/src/handlers/account-overrides.d.ts +53 -0
  38. package/dist/src/handlers/account-overrides.js +233 -0
  39. package/dist/src/handlers/account-signature.d.ts +28 -0
  40. package/dist/src/handlers/account-signature.js +85 -0
  41. package/dist/src/handlers/account.d.ts +7 -0
  42. package/dist/src/handlers/account.js +337 -0
  43. package/dist/src/handlers/address.d.ts +35 -0
  44. package/dist/src/handlers/address.js +133 -0
  45. package/dist/src/handlers/calendar-event.d.ts +70 -0
  46. package/dist/src/handlers/calendar-event.js +417 -0
  47. package/dist/src/handlers/calendar-feed.d.ts +47 -0
  48. package/dist/src/handlers/calendar-feed.js +184 -0
  49. package/dist/src/handlers/calendar-suggestion.d.ts +47 -0
  50. package/dist/src/handlers/calendar-suggestion.js +204 -0
  51. package/dist/src/handlers/calendar.d.ts +116 -0
  52. package/dist/src/handlers/calendar.js +323 -0
  53. package/dist/src/handlers/config.d.ts +43 -0
  54. package/dist/src/handlers/config.js +164 -0
  55. package/dist/src/handlers/ensure-account-config.d.ts +9 -0
  56. package/dist/src/handlers/ensure-account-config.js +27 -0
  57. package/dist/src/handlers/filter.d.ts +3 -0
  58. package/dist/src/handlers/filter.js +88 -0
  59. package/dist/src/handlers/folder-role-appointments.d.ts +68 -0
  60. package/dist/src/handlers/folder-role-appointments.js +168 -0
  61. package/dist/src/handlers/folder-role-labels.d.ts +26 -0
  62. package/dist/src/handlers/folder-role-labels.js +40 -0
  63. package/dist/src/handlers/folder-role.d.ts +9 -0
  64. package/dist/src/handlers/folder-role.js +37 -0
  65. package/dist/src/handlers/index.d.ts +88 -0
  66. package/dist/src/handlers/index.js +62 -0
  67. package/dist/src/handlers/label.d.ts +43 -0
  68. package/dist/src/handlers/label.js +107 -0
  69. package/dist/src/handlers/mailbox-bindings.d.ts +41 -0
  70. package/dist/src/handlers/mailbox-bindings.js +61 -0
  71. package/dist/src/handlers/mailbox.d.ts +23 -0
  72. package/dist/src/handlers/mailbox.js +211 -0
  73. package/dist/src/handlers/me.d.ts +2 -0
  74. package/dist/src/handlers/me.js +117 -0
  75. package/dist/src/handlers/message.d.ts +82 -0
  76. package/dist/src/handlers/message.js +595 -0
  77. package/dist/src/handlers/organize.d.ts +10 -0
  78. package/dist/src/handlers/organize.js +103 -0
  79. package/dist/src/handlers/outbox-attachment.d.ts +25 -0
  80. package/dist/src/handlers/outbox-attachment.js +77 -0
  81. package/dist/src/handlers/outbox.d.ts +4 -0
  82. package/dist/src/handlers/outbox.js +136 -0
  83. package/dist/src/handlers/sync-progress.d.ts +46 -0
  84. package/dist/src/handlers/sync-progress.js +63 -0
  85. package/dist/src/handlers/sync.d.ts +28 -0
  86. package/dist/src/handlers/sync.js +73 -0
  87. package/dist/src/handlers/system-update.d.ts +2 -0
  88. package/dist/src/handlers/system-update.js +159 -0
  89. package/dist/src/handlers/thread.d.ts +148 -0
  90. package/dist/src/handlers/thread.js +228 -0
  91. package/dist/src/handlers/unified-threads.d.ts +247 -0
  92. package/dist/src/handlers/unified-threads.js +406 -0
  93. package/{src/handlers/vip-suggestions.ts → dist/src/handlers/vip-suggestions.d.ts} +1 -13
  94. package/dist/src/handlers/vip-suggestions.js +16 -0
  95. package/dist/src/index.d.ts +5 -0
  96. package/dist/src/index.js +53 -0
  97. package/{src/json.ts → dist/src/json.d.ts} +1 -4
  98. package/dist/src/json.js +10 -0
  99. package/dist/src/jwt-auth.d.ts +15 -0
  100. package/dist/src/jwt-auth.js +45 -0
  101. package/dist/src/lambda.d.ts +13 -0
  102. package/dist/src/lambda.js +33 -0
  103. package/dist/src/request.d.ts +9 -0
  104. package/dist/src/request.js +17 -0
  105. package/dist/src/response.d.ts +44 -0
  106. package/dist/src/response.js +80 -0
  107. package/dist/src/serve.d.ts +31 -0
  108. package/dist/src/serve.js +117 -0
  109. package/dist/src/service/account-deletion.d.ts +20 -0
  110. package/dist/src/service/account-deletion.js +33 -0
  111. package/dist/src/service/compose-sqlite.d.ts +3 -0
  112. package/dist/src/service/compose-sqlite.js +13 -0
  113. package/dist/src/service/create-remit-client.d.ts +109 -0
  114. package/dist/src/service/create-remit-client.js +188 -0
  115. package/dist/src/service/data-client.d.ts +12 -0
  116. package/dist/src/service/data-client.js +42 -0
  117. package/dist/src/service/export-identity.d.ts +4 -0
  118. package/dist/src/service/export-identity.js +33 -0
  119. package/dist/src/service/fire-and-forget.d.ts +54 -0
  120. package/dist/src/service/fire-and-forget.js +37 -0
  121. package/dist/src/service/organize.d.ts +181 -0
  122. package/dist/src/service/organize.js +367 -0
  123. package/dist/src/service/queues.d.ts +12 -0
  124. package/dist/src/service/queues.js +1 -0
  125. package/dist/src/service/refusal.d.ts +4 -0
  126. package/dist/src/service/refusal.js +20 -0
  127. package/dist/src/service/sender-category.d.ts +51 -0
  128. package/{src/service/sender-category.ts → dist/src/service/sender-category.js} +40 -81
  129. package/dist/src/service/shared-deps.d.ts +6 -0
  130. package/dist/src/service/shared-deps.js +24 -0
  131. package/dist/src/service/trigger-sync.d.ts +57 -0
  132. package/dist/src/service/trigger-sync.js +31 -0
  133. package/dist/src/types.d.ts +72 -0
  134. package/dist/src/types.js +1 -0
  135. package/package.json +125 -98
  136. package/.env.test +0 -4
  137. package/dev-server/content-auth.test.ts +0 -113
  138. package/dev-server/content-auth.ts +0 -54
  139. package/dev-server/content-handler.test.ts +0 -105
  140. package/dev-server/content-handler.ts +0 -79
  141. package/dev-server/content-path.test.ts +0 -37
  142. package/dev-server/content-path.ts +0 -27
  143. package/dev-server/cors.test.ts +0 -48
  144. package/dev-server/cors.ts +0 -25
  145. package/dev-server/keep-alive.test.ts +0 -24
  146. package/dev-server/lambda-helpers.ts +0 -69
  147. package/dev-server/log-output.test.ts +0 -173
  148. package/dev-server/oauth-callback-route.test.ts +0 -66
  149. package/dev-server/relational-health.test.ts +0 -83
  150. package/dev-server/relational-health.ts +0 -39
  151. package/dev-server/server.ts +0 -384
  152. package/dev-server/sync-age.test.ts +0 -91
  153. package/dev-server/sync-age.ts +0 -55
  154. package/dev-server/upload-handler.ts +0 -132
  155. package/dev-server/upload-route.test.ts +0 -196
  156. package/scripts/backfill-classification.ts +0 -91
  157. package/scripts/backfill-list-id.ts +0 -83
  158. package/scripts/config-save.ts +0 -128
  159. package/src/auth.test.ts +0 -12
  160. package/src/auth.ts +0 -92
  161. package/src/config/msoauth.test.ts +0 -48
  162. package/src/config/msoauth.ts +0 -48
  163. package/src/data-backend.test.ts +0 -23
  164. package/src/data-backend.ts +0 -14
  165. package/src/derive/autoMoved.test.ts +0 -104
  166. package/src/derive/contentSignature.test.ts +0 -161
  167. package/src/derive/contentUrl.test.ts +0 -172
  168. package/src/derive/contentUrl.ts +0 -86
  169. package/src/derive/deriveMuted.test.ts +0 -34
  170. package/src/derive/enrichThreadRows.test.ts +0 -360
  171. package/src/derive/enrichThreadRows.ts +0 -234
  172. package/src/derive/filterThreadCriteria.test.ts +0 -126
  173. package/src/derive/filterThreadCriteria.ts +0 -51
  174. package/src/derive/senderTrust.test.ts +0 -67
  175. package/src/error.test.ts +0 -201
  176. package/src/error.ts +0 -123
  177. package/src/handlers/account-guards.test.ts +0 -67
  178. package/src/handlers/account-guards.ts +0 -169
  179. package/src/handlers/account-oauth-callback.test.ts +0 -155
  180. package/src/handlers/account-oauth-consent.test.ts +0 -336
  181. package/src/handlers/account-oauth-start.test.ts +0 -191
  182. package/src/handlers/account-oauth.test.ts +0 -439
  183. package/src/handlers/account-oauth.ts +0 -635
  184. package/src/handlers/account-overrides.test.ts +0 -88
  185. package/src/handlers/account-overrides.ts +0 -323
  186. package/src/handlers/account-ownership.ts +0 -25
  187. package/src/handlers/account-signature.ts +0 -117
  188. package/src/handlers/account-synced-services.test.ts +0 -245
  189. package/src/handlers/account.ts +0 -552
  190. package/src/handlers/address.test.ts +0 -381
  191. package/src/handlers/address.ts +0 -200
  192. package/src/handlers/calendar-event.test.ts +0 -685
  193. package/src/handlers/calendar-event.ts +0 -738
  194. package/src/handlers/calendar-feed-validators.test.ts +0 -228
  195. package/src/handlers/calendar-feed.test.ts +0 -439
  196. package/src/handlers/calendar-feed.ts +0 -291
  197. package/src/handlers/calendar-sqlite-fixture.ts +0 -36
  198. package/src/handlers/calendar-suggestion.test.ts +0 -1269
  199. package/src/handlers/calendar-suggestion.ts +0 -425
  200. package/src/handlers/calendar.test.ts +0 -1080
  201. package/src/handlers/calendar.ts +0 -517
  202. package/src/handlers/config.test.ts +0 -245
  203. package/src/handlers/config.ts +0 -332
  204. package/src/handlers/ensure-account-config.ts +0 -30
  205. package/src/handlers/filter.test.ts +0 -644
  206. package/src/handlers/filter.ts +0 -518
  207. package/src/handlers/folder-role-appointments.test.ts +0 -438
  208. package/src/handlers/folder-role-appointments.ts +0 -249
  209. package/src/handlers/folder-role-labels.ts +0 -63
  210. package/src/handlers/folder-role.test.ts +0 -209
  211. package/src/handlers/folder-role.ts +0 -80
  212. package/src/handlers/index.ts +0 -83
  213. package/src/handlers/label.test.ts +0 -112
  214. package/src/handlers/label.ts +0 -210
  215. package/src/handlers/mailbox-bindings.ts +0 -111
  216. package/src/handlers/mailbox-rename-refusal.test.ts +0 -247
  217. package/src/handlers/mailbox-settled-target.test.ts +0 -251
  218. package/src/handlers/mailbox.test.ts +0 -280
  219. package/src/handlers/mailbox.ts +0 -581
  220. package/src/handlers/me.test.ts +0 -81
  221. package/src/handlers/me.ts +0 -203
  222. package/src/handlers/message-read-backfill.test.ts +0 -103
  223. package/src/handlers/message.test.ts +0 -131
  224. package/src/handlers/message.ts +0 -1131
  225. package/src/handlers/organize.test.ts +0 -277
  226. package/src/handlers/organize.ts +0 -197
  227. package/src/handlers/outbox-attachment.test.ts +0 -765
  228. package/src/handlers/outbox-attachment.ts +0 -126
  229. package/src/handlers/outbox-list.test.ts +0 -179
  230. package/src/handlers/outbox.test.ts +0 -772
  231. package/src/handlers/outbox.ts +0 -258
  232. package/src/handlers/search.test.ts +0 -161
  233. package/src/handlers/search.ts +0 -105
  234. package/src/handlers/sync-progress.test.ts +0 -205
  235. package/src/handlers/sync-progress.ts +0 -99
  236. package/src/handlers/sync.test.ts +0 -168
  237. package/src/handlers/sync.ts +0 -118
  238. package/src/handlers/system-update.test.ts +0 -497
  239. package/src/handlers/system-update.ts +0 -230
  240. package/src/handlers/thread.test.ts +0 -396
  241. package/src/handlers/thread.ts +0 -427
  242. package/src/handlers/unified-threads-mail-sources.sqlite.test.ts +0 -197
  243. package/src/handlers/unified-threads.test.ts +0 -785
  244. package/src/handlers/unified-threads.ts +0 -669
  245. package/src/index.test.ts +0 -83
  246. package/src/index.ts +0 -228
  247. package/src/jwt-auth.test.ts +0 -152
  248. package/src/jwt-auth.ts +0 -132
  249. package/src/log-redaction.test.ts +0 -144
  250. package/src/openapi-spec.test.ts +0 -118
  251. package/src/request-context.test.ts +0 -61
  252. package/src/request-context.ts +0 -33
  253. package/src/request-pipeline.test.ts +0 -88
  254. package/src/request.ts +0 -10
  255. package/src/response.test.ts +0 -130
  256. package/src/response.ts +0 -144
  257. package/src/service/compose-sqlite.ts +0 -98
  258. package/src/service/config-import.ts +0 -39
  259. package/src/service/create-remit-client.ts +0 -522
  260. package/src/service/data-client.test.ts +0 -97
  261. package/src/service/data-client.ts +0 -58
  262. package/src/service/export-identity.ts +0 -39
  263. package/src/service/filter.ts +0 -40
  264. package/src/service/fire-and-forget.test.ts +0 -126
  265. package/src/service/fire-and-forget.ts +0 -79
  266. package/src/service/localhost-env-config.test.ts +0 -36
  267. package/src/service/organize-queue.ts +0 -98
  268. package/src/service/organize.test.ts +0 -1451
  269. package/src/service/organize.ts +0 -1025
  270. package/src/service/read-path-filter-config.test.ts +0 -243
  271. package/src/service/semantic-capability.test.ts +0 -78
  272. package/src/service/semantic-capability.ts +0 -95
  273. package/src/service/sender-category.test.ts +0 -235
  274. package/src/service/sqs.ts +0 -4
  275. package/src/service/trigger-sync.test.ts +0 -205
  276. package/src/service/trigger-sync.ts +0 -100
  277. package/src/stdout-capture-fixture.ts +0 -40
  278. package/src/types.ts +0 -271
  279. package/tsconfig.json +0 -7
@@ -0,0 +1,103 @@
1
+ import { BadRequestError, NotFoundError } from "@remit/data-ports/errors";
2
+ import { logger } from "@remit/logger";
3
+ import { ownedAccount, queueOrganizeJob, } from "@remit/mailbox-service";
4
+ import { buildOrganizeMatchDeps, matchOrganize, ORGANIZE_MATCH_LIMIT, organizePredicateRejection, } from "../service/organize.js";
5
+ import { unwrap } from "../service/refusal.js";
6
+ /**
7
+ * Normalize the request body into the flattened predicate the job row and the
8
+ * matcher share.
9
+ */
10
+ export const predicateFromInput = (input) => ({
11
+ matchOperator: input.matchOperator,
12
+ literalClauses: input.literalClauses,
13
+ actionLabelId: input.actionLabelId,
14
+ actionMailboxId: input.actionMailboxId,
15
+ });
16
+ const assertAccount = async (client, accountId, accountConfigId, mode) => {
17
+ const account = await client.account.get(accountId);
18
+ unwrap(ownedAccount(account, accountConfigId, mode));
19
+ };
20
+ const toOrganizeJobResponse = (job) => ({
21
+ organizeJobId: job.organizeJobId,
22
+ accountConfigId: job.accountConfigId,
23
+ userId: job.userId,
24
+ state: job.state,
25
+ filterId: job.filterId,
26
+ matchOperator: job.matchOperator,
27
+ literalClauses: job.literalClauses,
28
+ actionLabelId: job.actionLabelId,
29
+ actionMailboxId: job.actionMailboxId,
30
+ matchedCount: job.matchedCount,
31
+ appliedCount: job.appliedCount,
32
+ failedCount: job.failedCount,
33
+ errorMessage: job.errorMessage,
34
+ createdAt: job.createdAt,
35
+ updatedAt: job.updatedAt,
36
+ });
37
+ /**
38
+ * What a create asks the worker to run. A saved filter is named and read when
39
+ * the job runs, so Run now and a filter's create share one path (#1354); an
40
+ * "all like these" predicate the matcher can never honour is answered here,
41
+ * not with a 202 the worker has to fail later (reader #463).
42
+ */
43
+ const requestedJob = async (client, owner, input) => {
44
+ if (input.filterId) {
45
+ await client.filter.get(owner.accountConfigId, input.filterId);
46
+ return { ...owner, filterId: input.filterId };
47
+ }
48
+ const predicate = predicateFromInput(input);
49
+ const rejection = organizePredicateRejection(predicate);
50
+ if (rejection)
51
+ throw new BadRequestError(rejection.message);
52
+ return { ...owner, predicate };
53
+ };
54
+ export const OrganizeOperations = {
55
+ OrganizeOperations_createOrganizeJob: async (context, ctx) => {
56
+ const accountConfigId = ctx.identity.accountConfigId;
57
+ const { accountId } = context.request.params;
58
+ const input = context.request.requestBody;
59
+ const userId = ctx.identity.sub;
60
+ const client = ctx.client;
61
+ await assertAccount(client, accountId, accountConfigId, "act");
62
+ const job = await queueOrganizeJob({
63
+ organizeJobRequest: client.organizeJobRequest,
64
+ queue: client.queues.accountFanout,
65
+ logger,
66
+ }, await requestedJob(client, { accountConfigId, userId }, input));
67
+ return {
68
+ statusCode: 202,
69
+ organizeJobId: job.organizeJobId,
70
+ state: job.state,
71
+ };
72
+ },
73
+ OrganizeOperations_previewOrganize: async (context, ctx) => {
74
+ const accountConfigId = ctx.identity.accountConfigId;
75
+ const { accountId } = context.request.params;
76
+ const input = context.request.requestBody;
77
+ const client = ctx.client;
78
+ await assertAccount(client, accountId, accountConfigId, "read");
79
+ if (input.filterId) {
80
+ throw new BadRequestError("Preview takes a rule, not a saved filter. Run the filter to apply it.");
81
+ }
82
+ const result = await matchOrganize(buildOrganizeMatchDeps(client), accountConfigId, predicateFromInput(input), ORGANIZE_MATCH_LIMIT);
83
+ if (result.rejected)
84
+ throw new BadRequestError(result.rejected.message);
85
+ return {
86
+ matchedCount: result.messageIds.length,
87
+ messageIds: result.messageIds,
88
+ };
89
+ },
90
+ };
91
+ export const OrganizeJobDetailOperations = {
92
+ OrganizeJobDetailOperations_getOrganizeJob: async (context, ctx) => {
93
+ const accountConfigId = ctx.identity.accountConfigId;
94
+ const { accountId, organizeJobId } = context.request.params;
95
+ const client = ctx.client;
96
+ await assertAccount(client, accountId, accountConfigId, "read");
97
+ const job = await client.organizeJobRequest.get(organizeJobId);
98
+ if (job.accountConfigId !== accountConfigId) {
99
+ throw new NotFoundError(`Organize job not found: ${organizeJobId}`);
100
+ }
101
+ return toOrganizeJobResponse(job);
102
+ },
103
+ };
@@ -0,0 +1,25 @@
1
+ import type { MintOutboxAttachmentResponse, OutboxAttachmentRejection, OutboxAttachmentResponse } from "@remit/api-openapi-types";
2
+ import type { OutboxAttachmentItem } from "@remit/data-ports";
3
+ import type { Context } from "openapi-backend";
4
+ import type { ApiConfig } from "../config/api-config.js";
5
+ import type { HandlerContext } from "../types.js";
6
+ export declare const toOutboxAttachmentResponse: (config: ApiConfig) => (item: OutboxAttachmentItem) => OutboxAttachmentResponse;
7
+ interface RejectionResponse {
8
+ statusCode: number;
9
+ body: OutboxAttachmentRejection;
10
+ }
11
+ /**
12
+ * Reserve room on a draft and hand back somewhere to put the bytes. Small JSON
13
+ * in, small JSON out — the file itself never passes through here, which is what
14
+ * lets the upload live on its own fleet and keeps this operation under any
15
+ * payload ceiling the REST tier has.
16
+ */
17
+ export declare const mintOutboxAttachment: (context: Context, ctx: HandlerContext) => Promise<MintOutboxAttachmentResponse | RejectionResponse>;
18
+ /**
19
+ * Confirm the upload landed. The size in the response is read back from
20
+ * storage — on a deployment where the browser PUT straight to block storage
21
+ * this call is the first the API hears of the bytes, and the client's word for
22
+ * how many there are is not evidence.
23
+ */
24
+ export declare const completeOutboxAttachment: (context: Context, ctx: HandlerContext) => Promise<OutboxAttachmentResponse | RejectionResponse>;
25
+ export {};
@@ -0,0 +1,77 @@
1
+ import { OutboxAttachmentRejectionReason } from "@remit/domain-enums";
2
+ import { outboxAttachmentContentId } from "@remit/mailbox-service/outbox-attachment-content";
3
+ import { getContentSigner } from "../derive/contentSignature.js";
4
+ import { buildStorageContentUrl } from "../derive/contentUrl.js";
5
+ // Every error body the API emits carries a `code` (issue #371). A refusal here
6
+ // is one kind, so the code is one value and `reason` says which refusal it was.
7
+ const ATTACHMENT_REJECTED_CODE = "attachment_rejected";
8
+ const TOO_LARGE = new Set([
9
+ OutboxAttachmentRejectionReason.FileTooLarge,
10
+ OutboxAttachmentRejectionReason.MessageTooLarge,
11
+ OutboxAttachmentRejectionReason.TooManyAttachments,
12
+ ]);
13
+ export const toOutboxAttachmentResponse = (config) => (item) => ({
14
+ outboxAttachmentId: item.outboxAttachmentId,
15
+ outboxMessageId: item.outboxMessageId,
16
+ filename: item.filename,
17
+ contentType: item.contentType,
18
+ sizeBytes: item.sizeBytes,
19
+ state: item.state,
20
+ contentId: outboxAttachmentContentId(item.outboxAttachmentId),
21
+ contentUrl: buildStorageContentUrl({
22
+ domain: config.contentDeliveryDomain,
23
+ storageKey: item.storageKey,
24
+ sign: getContentSigner(config),
25
+ }),
26
+ });
27
+ const refuse = (detail) => ({
28
+ statusCode: TOO_LARGE.has(detail.reason) ? 413 : 400,
29
+ body: {
30
+ code: ATTACHMENT_REJECTED_CODE,
31
+ reason: detail.reason,
32
+ message: detail.message,
33
+ limitBytes: detail.limitBytes,
34
+ usedBytes: detail.usedBytes,
35
+ },
36
+ });
37
+ /**
38
+ * Reserve room on a draft and hand back somewhere to put the bytes. Small JSON
39
+ * in, small JSON out — the file itself never passes through here, which is what
40
+ * lets the upload live on its own fleet and keeps this operation under any
41
+ * payload ceiling the REST tier has.
42
+ */
43
+ export const mintOutboxAttachment = async (context, ctx) => {
44
+ const accountConfigId = ctx.identity.accountConfigId;
45
+ const { outboxMessageId } = context.request.params;
46
+ const input = context.request.requestBody;
47
+ const client = ctx.client;
48
+ const result = await client.outboxAttachment.mint({
49
+ accountConfigId,
50
+ outboxMessageId,
51
+ filename: input.filename,
52
+ contentType: input.contentType,
53
+ sizeBytes: input.sizeBytes,
54
+ });
55
+ if (result.outcome === "Rejected")
56
+ return refuse(result.rejection);
57
+ return result.reservation;
58
+ };
59
+ /**
60
+ * Confirm the upload landed. The size in the response is read back from
61
+ * storage — on a deployment where the browser PUT straight to block storage
62
+ * this call is the first the API hears of the bytes, and the client's word for
63
+ * how many there are is not evidence.
64
+ */
65
+ export const completeOutboxAttachment = async (context, ctx) => {
66
+ const accountConfigId = ctx.identity.accountConfigId;
67
+ const { outboxMessageId, outboxAttachmentId } = context.request.params;
68
+ const client = ctx.client;
69
+ const result = await client.outboxAttachment.complete({
70
+ accountConfigId,
71
+ outboxMessageId,
72
+ outboxAttachmentId,
73
+ });
74
+ if (result.outcome === "Rejected")
75
+ return refuse(result.rejection);
76
+ return toOutboxAttachmentResponse(ctx.config)(result.attachment);
77
+ };
@@ -0,0 +1,4 @@
1
+ import type { OperationHandler, OutboxAttachmentOperationIds, OutboxDetailOperationIds, OutboxOperationIds } from "../types.js";
2
+ export declare const OutboxOperations: Record<OutboxOperationIds, OperationHandler<OutboxOperationIds, "smtp">>;
3
+ export declare const OutboxDetailOperations: Record<OutboxDetailOperationIds, OperationHandler<OutboxDetailOperationIds, "smtp">>;
4
+ export declare const OutboxAttachmentOperations: Record<OutboxAttachmentOperationIds, OperationHandler<OutboxAttachmentOperationIds, "smtp">>;
@@ -0,0 +1,136 @@
1
+ import { ForbiddenError } from "@remit/data-ports/errors";
2
+ import { completeOutboxAttachment, mintOutboxAttachment, toOutboxAttachmentResponse, } from "./outbox-attachment.js";
3
+ const toOutboxMessageResponse = (config, item, attachments = []) => ({
4
+ attachments: attachments.map(toOutboxAttachmentResponse(config)),
5
+ outboxMessageId: item.outboxMessageId,
6
+ accountId: item.accountId,
7
+ fromAddress: item.fromAddress,
8
+ fromName: item.fromName,
9
+ toAddresses: item.toAddresses,
10
+ ccAddresses: item.ccAddresses ?? [],
11
+ bccAddresses: item.bccAddresses ?? [],
12
+ subject: item.subject,
13
+ textBody: item.textBody,
14
+ htmlBody: item.htmlBody,
15
+ inReplyTo: item.inReplyTo,
16
+ references: item.references ?? [],
17
+ status: item.status,
18
+ lastError: item.lastError,
19
+ sentAt: item.sentAt,
20
+ createdAt: item.createdAt,
21
+ updatedAt: item.updatedAt,
22
+ });
23
+ export const OutboxOperations = {
24
+ OutboxOperations_createOutboxMessage: async (context, ctx) => {
25
+ const accountConfigId = ctx.identity.accountConfigId;
26
+ const input = context.request.requestBody;
27
+ const client = ctx.client;
28
+ const account = await client.account.get(input.accountId);
29
+ if (account.accountConfigId !== accountConfigId) {
30
+ throw new ForbiddenError(`Account ${input.accountId} not in account config`);
31
+ }
32
+ const fromAddress = account.email;
33
+ if (input.sendImmediately) {
34
+ const outbox = await client.outboxQueue.createAndSend({
35
+ accountId: input.accountId,
36
+ accountConfigId,
37
+ fromAddress,
38
+ toAddresses: input.toAddresses,
39
+ ccAddresses: input.ccAddresses,
40
+ bccAddresses: input.bccAddresses,
41
+ subject: input.subject,
42
+ textBody: input.textBody,
43
+ htmlBody: input.htmlBody,
44
+ inReplyTo: input.inReplyTo,
45
+ references: input.references,
46
+ });
47
+ return toOutboxMessageResponse(ctx.config, outbox);
48
+ }
49
+ const outbox = await client.outboxQueue.createDraft({
50
+ accountId: input.accountId,
51
+ accountConfigId,
52
+ fromAddress,
53
+ toAddresses: input.toAddresses,
54
+ ccAddresses: input.ccAddresses,
55
+ bccAddresses: input.bccAddresses,
56
+ subject: input.subject,
57
+ textBody: input.textBody,
58
+ htmlBody: input.htmlBody,
59
+ inReplyTo: input.inReplyTo,
60
+ references: input.references,
61
+ });
62
+ return toOutboxMessageResponse(ctx.config, outbox);
63
+ },
64
+ OutboxOperations_listOutboxMessages: async (context, ctx) => {
65
+ const accountConfigId = ctx.identity.accountConfigId;
66
+ const { continuationToken } = context.request.query;
67
+ const client = ctx.client;
68
+ const accounts = await client.account.listAllByAccountConfig(accountConfigId);
69
+ if (accounts.length === 0) {
70
+ return { items: [], continuationToken: null };
71
+ }
72
+ const result = await client.outboxMessage.listByAccounts(accounts.map((account) => account.accountId), { continuationToken });
73
+ return {
74
+ items: result.items.map((item) => toOutboxMessageResponse(ctx.config, item)),
75
+ continuationToken: result.continuationToken,
76
+ };
77
+ },
78
+ };
79
+ export const OutboxDetailOperations = {
80
+ OutboxDetailOperations_getOutboxMessage: async (context, ctx) => {
81
+ const accountConfigId = ctx.identity.accountConfigId;
82
+ const { outboxMessageId } = context.request.params;
83
+ const client = ctx.client;
84
+ // Default mode "read": the scoped get refuses a foreign message with
85
+ // NotFound (404, no existence leak) — no separate check needed.
86
+ const outbox = await client.outboxMessage.get(accountConfigId, outboxMessageId);
87
+ return toOutboxMessageResponse(ctx.config, outbox, await client.outboxAttachment.listFor(accountConfigId, outboxMessageId));
88
+ },
89
+ OutboxDetailOperations_updateOutboxMessage: async (context, ctx) => {
90
+ const accountConfigId = ctx.identity.accountConfigId;
91
+ const { outboxMessageId } = context.request.params;
92
+ const input = context.request.requestBody;
93
+ const client = ctx.client;
94
+ // updateDraft's own scoped get uses mode "act": a foreign message is
95
+ // denied with Forbidden (403), not feigned as NotFound.
96
+ const updated = await client.outboxQueue.updateDraft(accountConfigId, outboxMessageId, {
97
+ toAddresses: input.toAddresses,
98
+ ccAddresses: input.ccAddresses,
99
+ bccAddresses: input.bccAddresses,
100
+ subject: input.subject,
101
+ textBody: input.textBody,
102
+ htmlBody: input.htmlBody,
103
+ inReplyTo: input.inReplyTo,
104
+ references: input.references,
105
+ });
106
+ // Absent means "leave the files alone" — a body that only carries a subject
107
+ // must not strip them. Present states exactly what the draft keeps, so an
108
+ // empty list is a real instruction to remove them all.
109
+ if (input.attachmentIds !== undefined) {
110
+ await client.outboxAttachment.retainOnly(accountConfigId, updated.accountId, outboxMessageId, input.attachmentIds);
111
+ }
112
+ return toOutboxMessageResponse(ctx.config, updated, await client.outboxAttachment.listFor(accountConfigId, outboxMessageId));
113
+ },
114
+ OutboxDetailOperations_deleteOutboxMessage: async (context, ctx) => {
115
+ const accountConfigId = ctx.identity.accountConfigId;
116
+ const { outboxMessageId } = context.request.params;
117
+ const client = ctx.client;
118
+ // deleteDraft's own scoped get uses mode "act": a foreign message is
119
+ // denied with Forbidden (403), not feigned as NotFound.
120
+ await client.outboxQueue.deleteDraft(accountConfigId, outboxMessageId);
121
+ return { statusCode: 204 };
122
+ },
123
+ OutboxDetailOperations_sendOutboxMessage: async (context, ctx) => {
124
+ const accountConfigId = ctx.identity.accountConfigId;
125
+ const { outboxMessageId } = context.request.params;
126
+ const client = ctx.client;
127
+ // send's own scoped get uses mode "act": a foreign message is denied
128
+ // with Forbidden (403), not feigned as NotFound.
129
+ const sent = await client.outboxQueue.send(accountConfigId, outboxMessageId);
130
+ return toOutboxMessageResponse(ctx.config, sent);
131
+ },
132
+ OutboxDetailOperations_mintOutboxAttachment: mintOutboxAttachment,
133
+ };
134
+ export const OutboxAttachmentOperations = {
135
+ OutboxAttachmentOperations_completeOutboxAttachment: completeOutboxAttachment,
136
+ };
@@ -0,0 +1,46 @@
1
+ import type { MailboxItem } from "@remit/data-ports";
2
+ /** The subset of mailbox fields needed to derive sync progress. */
3
+ export type MailboxSyncFields = Pick<MailboxItem, "lastMessageSyncAt" | "initialSyncCompletedAt" | "messageCount" | "lastSyncUid" | "highWaterMarkUid">;
4
+ /** A mailbox row, as far as the sync-status projection reads it. */
5
+ export type MailboxProgressFields = MailboxSyncFields & Pick<MailboxItem, "mailboxId" | "fullPath">;
6
+ /** The per-mailbox entry `getSyncStatus` returns. */
7
+ export interface MailboxSyncProgress {
8
+ mailboxId: string;
9
+ fullPath: string;
10
+ phase: string;
11
+ messagesTotal: number;
12
+ messagesSynced: number;
13
+ highWaterMarkUid: number;
14
+ lastSyncedAt: number | undefined;
15
+ }
16
+ /**
17
+ * Derive a per-mailbox sync phase from worker-written sync state.
18
+ *
19
+ * - pending: never synced (lastMessageSyncAt is 0 / unset)
20
+ * - complete: the imap-worker drained the mailbox and stamped
21
+ * initialSyncCompletedAt after the last batch write
22
+ * (initialSyncCompletedAt >= lastMessageSyncAt), or the
23
+ * mailbox is empty
24
+ * - syncing: sync has started but is not yet complete
25
+ *
26
+ * Note: UID watermarks alone cannot express completion — backfill settles
27
+ * at the mailbox's smallest real UID (UIDs are sparse), so the completion
28
+ * marker written by the worker is the source of truth.
29
+ */
30
+ export declare const deriveMailboxPhase: (mailbox: MailboxSyncFields) => string;
31
+ /**
32
+ * Compute approximate messagesSynced from UID watermarks.
33
+ *
34
+ * UIDs are sparse (not necessarily contiguous), so this is an approximation.
35
+ * We clamp the result to [0, messagesTotal].
36
+ */
37
+ export declare const computeMessagesSynced: (mailbox: MailboxSyncFields) => number;
38
+ /**
39
+ * Project a mailbox row into its sync-status entry.
40
+ *
41
+ * `highWaterMarkUid` is carried through because a total cannot tell an arrival
42
+ * from a departure: a message landing in the same sync round that another one
43
+ * leaves nets to zero, and the client's freshness check saw nothing (#771). The
44
+ * highest UID only ever moves up, so it names the arrival on its own.
45
+ */
46
+ export declare const toMailboxSyncProgress: (mailbox: MailboxProgressFields) => MailboxSyncProgress;
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Derive a per-mailbox sync phase from worker-written sync state.
3
+ *
4
+ * - pending: never synced (lastMessageSyncAt is 0 / unset)
5
+ * - complete: the imap-worker drained the mailbox and stamped
6
+ * initialSyncCompletedAt after the last batch write
7
+ * (initialSyncCompletedAt >= lastMessageSyncAt), or the
8
+ * mailbox is empty
9
+ * - syncing: sync has started but is not yet complete
10
+ *
11
+ * Note: UID watermarks alone cannot express completion — backfill settles
12
+ * at the mailbox's smallest real UID (UIDs are sparse), so the completion
13
+ * marker written by the worker is the source of truth.
14
+ */
15
+ export const deriveMailboxPhase = (mailbox) => {
16
+ if (!mailbox.lastMessageSyncAt)
17
+ return "pending";
18
+ const messageCount = mailbox.messageCount ?? 0;
19
+ const initialSyncCompletedAt = mailbox.initialSyncCompletedAt ?? 0;
20
+ // Completion marker is fresh: no batch was written after the worker
21
+ // recorded the mailbox as drained.
22
+ if (initialSyncCompletedAt > 0 &&
23
+ initialSyncCompletedAt >= mailbox.lastMessageSyncAt) {
24
+ return "complete";
25
+ }
26
+ // Also complete if messageCount is 0 (empty mailbox)
27
+ if (messageCount === 0)
28
+ return "complete";
29
+ return "syncing";
30
+ };
31
+ /**
32
+ * Compute approximate messagesSynced from UID watermarks.
33
+ *
34
+ * UIDs are sparse (not necessarily contiguous), so this is an approximation.
35
+ * We clamp the result to [0, messagesTotal].
36
+ */
37
+ export const computeMessagesSynced = (mailbox) => {
38
+ const messagesTotal = mailbox.messageCount ?? 0;
39
+ const lastSyncUid = mailbox.lastSyncUid ?? 0;
40
+ const highWaterMarkUid = mailbox.highWaterMarkUid ?? 0;
41
+ if (highWaterMarkUid === 0)
42
+ return 0;
43
+ // UID range approximation: number of UIDs in the synced range
44
+ const synced = highWaterMarkUid - lastSyncUid + 1;
45
+ return Math.min(Math.max(synced, 0), messagesTotal);
46
+ };
47
+ /**
48
+ * Project a mailbox row into its sync-status entry.
49
+ *
50
+ * `highWaterMarkUid` is carried through because a total cannot tell an arrival
51
+ * from a departure: a message landing in the same sync round that another one
52
+ * leaves nets to zero, and the client's freshness check saw nothing (#771). The
53
+ * highest UID only ever moves up, so it names the arrival on its own.
54
+ */
55
+ export const toMailboxSyncProgress = (mailbox) => ({
56
+ mailboxId: mailbox.mailboxId,
57
+ fullPath: mailbox.fullPath,
58
+ phase: deriveMailboxPhase(mailbox),
59
+ messagesTotal: mailbox.messageCount ?? 0,
60
+ messagesSynced: computeMessagesSynced(mailbox),
61
+ highWaterMarkUid: mailbox.highWaterMarkUid ?? 0,
62
+ lastSyncedAt: mailbox.lastMessageSyncAt || undefined,
63
+ });
@@ -0,0 +1,28 @@
1
+ import type { FifoQueueProducer } from "@remit/data-ports";
2
+ import { type FireAndForgetLogger } from "../service/fire-and-forget.js";
3
+ import { type SyncMailboxesEvent } from "../service/trigger-sync.js";
4
+ import type { OperationHandler, SyncOperationIds } from "../types.js";
5
+ interface SyncTriggerDeps {
6
+ queue: FifoQueueProducer<SyncMailboxesEvent>;
7
+ logger: FireAndForgetLogger & {
8
+ info: (fields: Record<string, unknown>, message: string) => void;
9
+ };
10
+ }
11
+ /**
12
+ * Fire-and-forget enqueue for the POST /sync kick.
13
+ *
14
+ * POST /sync is a best-effort nudge: the web client polls it to wake a sync and
15
+ * acts on no enqueue result in the response. So the enqueue must never fail —
16
+ * nor leak onto — this request or any concurrent read when the queue is
17
+ * unreachable. Previously this handler `await`ed the SQS send directly; with the
18
+ * queue down (smoke/e2e, or an SQS outage in prod) the awaited rejection escaped
19
+ * the handler's promise and landed on whatever read was in flight on the shared
20
+ * event loop, 500-ing unrelated `/mailboxes`, `/threads` and `/outbox` reads.
21
+ *
22
+ * Routing it through fireAndForget catches the rejection at the source, logs it
23
+ * loudly with the alertable structured fields, and resolves to void. A genuine
24
+ * account-lookup / ownership failure still propagates and 500s as normal.
25
+ */
26
+ export declare const triggerSyncSafe: (accountId: string, accountConfigId: string, deps: SyncTriggerDeps) => Promise<void>;
27
+ export declare const SyncOperations: Record<SyncOperationIds, OperationHandler<SyncOperationIds, "sync">>;
28
+ export {};
@@ -0,0 +1,73 @@
1
+ import { logger } from "@remit/logger";
2
+ import { ownedAccount } from "@remit/mailbox-service";
3
+ import { fireAndForget, } from "../service/fire-and-forget.js";
4
+ import { unwrap } from "../service/refusal.js";
5
+ import { triggerAccountSync, } from "../service/trigger-sync.js";
6
+ import { toMailboxSyncProgress } from "./sync-progress.js";
7
+ /**
8
+ * Fire-and-forget enqueue for the POST /sync kick.
9
+ *
10
+ * POST /sync is a best-effort nudge: the web client polls it to wake a sync and
11
+ * acts on no enqueue result in the response. So the enqueue must never fail —
12
+ * nor leak onto — this request or any concurrent read when the queue is
13
+ * unreachable. Previously this handler `await`ed the SQS send directly; with the
14
+ * queue down (smoke/e2e, or an SQS outage in prod) the awaited rejection escaped
15
+ * the handler's promise and landed on whatever read was in flight on the shared
16
+ * event loop, 500-ing unrelated `/mailboxes`, `/threads` and `/outbox` reads.
17
+ *
18
+ * Routing it through fireAndForget catches the rejection at the source, logs it
19
+ * loudly with the alertable structured fields, and resolves to void. A genuine
20
+ * account-lookup / ownership failure still propagates and 500s as normal.
21
+ */
22
+ export const triggerSyncSafe = async (accountId, accountConfigId, deps) => {
23
+ await fireAndForget(async () => {
24
+ const { eventId } = await triggerAccountSync({
25
+ queue: deps.queue,
26
+ accountId,
27
+ // POST /sync asks for a sync of this account by name — the refresh
28
+ // control, pull-to-refresh, and the client's automatic poll all land
29
+ // here — so it syncs every mailbox regardless of how recently one
30
+ // ran.
31
+ explicitRequest: true,
32
+ });
33
+ deps.logger.info({ accountId, eventId }, "Sync triggered - enqueued SYNC_MAILBOXES event");
34
+ }, {
35
+ source: "trigger_sync",
36
+ message: "Failed to enqueue SYNC_MAILBOXES on POST /sync (best-effort)",
37
+ ids: { accountId, accountConfigId },
38
+ logger: deps.logger,
39
+ });
40
+ };
41
+ export const SyncOperations = {
42
+ SyncOperations_triggerSync: async (context, ctx) => {
43
+ const accountConfigId = ctx.identity.accountConfigId;
44
+ const { accountId } = context.request.params;
45
+ const account = await ctx.client.account.get(accountId);
46
+ unwrap(ownedAccount(account, accountConfigId, "act"));
47
+ void triggerSyncSafe(account.accountId, accountConfigId, {
48
+ queue: ctx.client.queues.sync,
49
+ logger,
50
+ });
51
+ return {
52
+ triggered: true,
53
+ message: `Sync triggered for account ${accountId}`,
54
+ };
55
+ },
56
+ SyncOperations_getSyncStatus: async (context, ctx) => {
57
+ const accountConfigId = ctx.identity.accountConfigId;
58
+ const { accountId } = context.request.params;
59
+ const client = ctx.client;
60
+ const account = await client.account.get(accountId);
61
+ unwrap(ownedAccount(account, accountConfigId, "read"));
62
+ // Collect all mailboxes (read-only — does not write or contend with sync lock)
63
+ const mailboxes = await client.mailbox.listAllByAccount(accountId);
64
+ return {
65
+ accountId: account.accountId,
66
+ syncPhase: account.syncPhase,
67
+ mailboxCountTotal: account.mailboxCountTotal,
68
+ mailboxCountSynced: account.mailboxCountSynced,
69
+ lastSyncAt: account.lastSyncAt,
70
+ mailboxes: mailboxes.map(toMailboxSyncProgress),
71
+ };
72
+ },
73
+ };
@@ -0,0 +1,2 @@
1
+ import type { OperationHandler, SystemOperationIds } from "../types.js";
2
+ export declare const SystemOperations: Record<SystemOperationIds, OperationHandler<SystemOperationIds>>;