@remit/mailbox-service 0.0.1

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 (124) hide show
  1. package/README.md +147 -0
  2. package/package.json +65 -0
  3. package/src/account-credentials.ts +128 -0
  4. package/src/adversarial-mime.e2e.test.ts +311 -0
  5. package/src/attribute-mapper.test.ts +78 -0
  6. package/src/attribute-mapper.ts +147 -0
  7. package/src/body-part-mapper.property.test.ts +354 -0
  8. package/src/body-part-mapper.ts +360 -0
  9. package/src/body-sync-queue.test.ts +124 -0
  10. package/src/body-sync-queue.ts +131 -0
  11. package/src/body-sync-terminal.test.ts +265 -0
  12. package/src/body-sync-terminal.ts +172 -0
  13. package/src/body-sync.ts +1202 -0
  14. package/src/connection-factory.ts +102 -0
  15. package/src/connection-test.ts +208 -0
  16. package/src/credentials.test.ts +181 -0
  17. package/src/filters/match.test.ts +181 -0
  18. package/src/filters/match.ts +143 -0
  19. package/src/filters/pipeline.ts +243 -0
  20. package/src/flag-push-terminal.test.ts +156 -0
  21. package/src/flag-push-terminal.ts +106 -0
  22. package/src/flag-push.test.ts +408 -0
  23. package/src/flag-push.ts +198 -0
  24. package/src/flag-queue.ts +500 -0
  25. package/src/heuristics/classifyByHeaders.test.ts +578 -0
  26. package/src/heuristics/classifyByHeaders.ts +369 -0
  27. package/src/heuristics/classifyPlacement.ts +96 -0
  28. package/src/heuristics/socialDomains.ts +23 -0
  29. package/src/heuristics/transactionalDomains.ts +26 -0
  30. package/src/imapflow-connect-retry.test.ts +108 -0
  31. package/src/imapflow-connection.e2e.test.ts +355 -0
  32. package/src/imapflow-connection.integ.test.ts +327 -0
  33. package/src/imapflow-connection.test.ts +161 -0
  34. package/src/imapflow-connection.ts +1374 -0
  35. package/src/index.ts +214 -0
  36. package/src/mailbox-cursor-rebuild.test.ts +171 -0
  37. package/src/mailbox-cursor-rebuild.ts +146 -0
  38. package/src/mailbox-cursor.test.ts +249 -0
  39. package/src/mailbox-cursor.ts +177 -0
  40. package/src/mailbox-management.test.ts +103 -0
  41. package/src/mailbox-management.ts +235 -0
  42. package/src/mailbox-queue.ts +258 -0
  43. package/src/mailbox-sync.test.ts +153 -0
  44. package/src/mailbox-sync.ts +590 -0
  45. package/src/message-move.ts +825 -0
  46. package/src/message-parser.ts +26 -0
  47. package/src/message-sync.ts +1021 -0
  48. package/src/mime-walker.test.ts +311 -0
  49. package/src/mime-walker.ts +305 -0
  50. package/src/outbox-queue.ts +294 -0
  51. package/src/pass-through-unit-of-work.ts +17 -0
  52. package/src/placement-move-terminal.test.ts +179 -0
  53. package/src/placement-move-terminal.ts +104 -0
  54. package/src/placement-move.ts +224 -0
  55. package/src/snippet.test.ts +213 -0
  56. package/src/snippet.ts +152 -0
  57. package/src/stale-message-reconcile.test.ts +82 -0
  58. package/src/stale-message-reconcile.ts +55 -0
  59. package/src/test-helpers/isolated-mailbox.ts +44 -0
  60. package/src/test-helpers/mailfuzz-connection.ts +27 -0
  61. package/src/test-helpers/mime-tree-generator.ts +461 -0
  62. package/src/text/normalizer.test.ts +223 -0
  63. package/src/text/normalizer.ts +140 -0
  64. package/src/types/mailparser-augment.d.ts +15 -0
  65. package/src/types/natural-porter-stemmer.d.ts +50 -0
  66. package/src/types/stopword.d.ts +69 -0
  67. package/src/types.ts +349 -0
  68. package/test/fixtures/mime/01-flat-text-plain.bodyparts.json +8 -0
  69. package/test/fixtures/mime/01-flat-text-plain.eml +10 -0
  70. package/test/fixtures/mime/01-flat-text-plain.expected.json +10 -0
  71. package/test/fixtures/mime/02-flat-text-html.bodyparts.json +8 -0
  72. package/test/fixtures/mime/02-flat-text-html.eml +10 -0
  73. package/test/fixtures/mime/02-flat-text-html.expected.json +10 -0
  74. package/test/fixtures/mime/03-alternative-text-html.bodyparts.json +20 -0
  75. package/test/fixtures/mime/03-alternative-text-html.eml +19 -0
  76. package/test/fixtures/mime/03-alternative-text-html.expected.json +16 -0
  77. package/test/fixtures/mime/04-mixed-html-pdf-attachment.bodyparts.json +22 -0
  78. package/test/fixtures/mime/04-mixed-html-pdf-attachment.eml +20 -0
  79. package/test/fixtures/mime/04-mixed-html-pdf-attachment.expected.json +16 -0
  80. package/test/fixtures/mime/05-mixed-html-octet-stream-pdf.bodyparts.json +22 -0
  81. package/test/fixtures/mime/05-mixed-html-octet-stream-pdf.eml +21 -0
  82. package/test/fixtures/mime/05-mixed-html-octet-stream-pdf.expected.json +16 -0
  83. package/test/fixtures/mime/06-related-html-cid-image.bodyparts.json +23 -0
  84. package/test/fixtures/mime/06-related-html-cid-image.eml +21 -0
  85. package/test/fixtures/mime/06-related-html-cid-image.expected.json +16 -0
  86. package/test/fixtures/mime/07-mixed-alternative-related-attachment.bodyparts.json +55 -0
  87. package/test/fixtures/mime/07-mixed-alternative-related-attachment.eml +47 -0
  88. package/test/fixtures/mime/07-mixed-alternative-related-attachment.expected.json +34 -0
  89. package/test/fixtures/mime/08-two-pdfs-same-filename.bodyparts.json +30 -0
  90. package/test/fixtures/mime/08-two-pdfs-same-filename.eml +26 -0
  91. package/test/fixtures/mime/08-two-pdfs-same-filename.expected.json +22 -0
  92. package/test/fixtures/mime/09-attachment-no-filename.bodyparts.json +21 -0
  93. package/test/fixtures/mime/09-attachment-no-filename.eml +20 -0
  94. package/test/fixtures/mime/09-attachment-no-filename.expected.json +16 -0
  95. package/test/fixtures/mime/10-calendar-invite.bodyparts.json +22 -0
  96. package/test/fixtures/mime/10-calendar-invite.eml +33 -0
  97. package/test/fixtures/mime/10-calendar-invite.expected.json +16 -0
  98. package/test/fixtures/mime/11-quoted-printable-text.bodyparts.json +8 -0
  99. package/test/fixtures/mime/11-quoted-printable-text.eml +10 -0
  100. package/test/fixtures/mime/11-quoted-printable-text.expected.json +10 -0
  101. package/test/fixtures/mime/12-base64-binary.bodyparts.json +22 -0
  102. package/test/fixtures/mime/12-base64-binary.eml +20 -0
  103. package/test/fixtures/mime/12-base64-binary.expected.json +16 -0
  104. package/test/fixtures/mime/13-message-rfc822-forward.bodyparts.json +22 -0
  105. package/test/fixtures/mime/13-message-rfc822-forward.eml +28 -0
  106. package/test/fixtures/mime/13-message-rfc822-forward.expected.json +16 -0
  107. package/test/fixtures/mime/14-deeply-nested-5-levels.bodyparts.json +38 -0
  108. package/test/fixtures/mime/14-deeply-nested-5-levels.eml +34 -0
  109. package/test/fixtures/mime/14-deeply-nested-5-levels.expected.json +10 -0
  110. package/test/fixtures/mime/15-empty-text-part.bodyparts.json +21 -0
  111. package/test/fixtures/mime/15-empty-text-part.eml +18 -0
  112. package/test/fixtures/mime/15-empty-text-part.expected.json +16 -0
  113. package/test/fixtures/mime/16-empty-non-text-part.bodyparts.json +30 -0
  114. package/test/fixtures/mime/16-empty-non-text-part.eml +24 -0
  115. package/test/fixtures/mime/16-empty-non-text-part.expected.json +22 -0
  116. package/test/fixtures/mime-adversarial-e2e/01-octet-stream-pdf.eml +21 -0
  117. package/test/fixtures/mime-adversarial-e2e/02-nested-related-cid-image.eml +31 -0
  118. package/test/fixtures/mime-adversarial-e2e/03-calendar-invite.eml +33 -0
  119. package/test/fixtures/mime-adversarial-e2e/04-forwarded-rfc822-with-attachments.eml +42 -0
  120. package/test/fixtures/mime-adversarial-e2e/05-tnef-winmail.eml +25 -0
  121. package/test/fixtures/mime-adversarial-e2e/06-8bit-non-ascii.eml +15 -0
  122. package/test/fixtures/mime-adversarial-e2e/07-quoted-printable-soft-breaks.eml +17 -0
  123. package/test/fixtures/mime-adversarial-e2e/08-alternative-empty-text.eml +18 -0
  124. package/tsconfig.json +8 -0
@@ -0,0 +1,235 @@
1
+ import type { IMailboxRepository } from "@remit/data-ports";
2
+ import { MailboxSyncStatus } from "@remit/domain-enums";
3
+ import type { IImapConnection } from "./types.js";
4
+
5
+ /**
6
+ * Input for creating a mailbox
7
+ */
8
+ export interface CreateMailboxInput {
9
+ accountId: string;
10
+ path: string;
11
+ subscribe?: boolean;
12
+ }
13
+
14
+ /**
15
+ * Input for renaming a mailbox
16
+ */
17
+ export interface RenameMailboxInput {
18
+ mailboxId: string;
19
+ newPath: string;
20
+ }
21
+
22
+ /**
23
+ * Input for deleting a mailbox
24
+ */
25
+ export interface DeleteMailboxInput {
26
+ mailboxId: string;
27
+ force?: boolean;
28
+ }
29
+
30
+ /**
31
+ * Result of syncing mailbox operation to IMAP
32
+ */
33
+ export interface MailboxManagementSyncResult {
34
+ success: boolean;
35
+ error?: string;
36
+ }
37
+
38
+ /**
39
+ * Logger interface for MailboxManagementService
40
+ */
41
+ export interface MailboxManagementLogger {
42
+ info(obj: Record<string, unknown>, msg: string): void;
43
+ error(obj: Record<string, unknown>, msg: string): void;
44
+ }
45
+
46
+ const noopLogger: MailboxManagementLogger = {
47
+ info: () => {},
48
+ error: () => {},
49
+ };
50
+
51
+ /**
52
+ * Parse a mailbox path to extract name and parent path
53
+ */
54
+ export const parseMailboxPath = (
55
+ path: string,
56
+ delimiter = "/",
57
+ ): { name: string; parent: string | null; depth: number } => {
58
+ const parts = path.split(delimiter);
59
+ return {
60
+ name: parts[parts.length - 1],
61
+ parent: parts.length > 1 ? parts.slice(0, -1).join(delimiter) : null,
62
+ depth: parts.length,
63
+ };
64
+ };
65
+
66
+ /**
67
+ * Validate mailbox path for invalid characters and operations
68
+ */
69
+ export const validateMailboxPath = (path: string): void => {
70
+ if (!path || path.trim().length === 0) {
71
+ throw new Error("Mailbox path cannot be empty");
72
+ }
73
+
74
+ // Check for double delimiters
75
+ if (path.includes("//")) {
76
+ throw new Error("Mailbox path cannot contain empty hierarchy levels");
77
+ }
78
+
79
+ // Check for leading/trailing delimiters
80
+ if (path.startsWith("/") || path.endsWith("/")) {
81
+ throw new Error(
82
+ "Mailbox path cannot start or end with hierarchy delimiter",
83
+ );
84
+ }
85
+ };
86
+
87
+ /**
88
+ * Validate that an operation can be performed on a mailbox path
89
+ */
90
+ export const validateMailboxOperation = (
91
+ operation: "delete" | "rename",
92
+ path: string,
93
+ ): void => {
94
+ if (path.toUpperCase() === "INBOX" && operation === "delete") {
95
+ throw new Error("Cannot delete INBOX");
96
+ }
97
+ };
98
+
99
+ /**
100
+ * Service for managing mailbox operations (create, rename, delete).
101
+ *
102
+ * Implements an optimistic local-first pattern:
103
+ * 1. Updates are applied locally first (DynamoDB)
104
+ * 2. Changes are queued for IMAP sync via SQS
105
+ * 3. Worker processes queue and syncs to IMAP server
106
+ */
107
+ export class MailboxManagementService {
108
+ private log: MailboxManagementLogger;
109
+
110
+ constructor(
111
+ private mailboxService: IMailboxRepository,
112
+ logger?: MailboxManagementLogger,
113
+ ) {
114
+ this.log = logger ?? noopLogger;
115
+ }
116
+
117
+ /**
118
+ * Sync a CREATE operation to IMAP.
119
+ * Called by worker after dequeuing MAILBOX_CREATE event.
120
+ *
121
+ * @param accountId - Account that owns the mailbox (tenant scope)
122
+ * @param mailboxId - ID of the mailbox to create
123
+ * @param path - Path of the mailbox to create
124
+ * @param getConnection - Factory to get IMAP connection
125
+ * @param subscribe - Whether to subscribe after creation
126
+ */
127
+ syncCreate = async (
128
+ accountId: string,
129
+ mailboxId: string,
130
+ path: string,
131
+ getConnection: () => Promise<IImapConnection>,
132
+ subscribe?: boolean,
133
+ ): Promise<MailboxManagementSyncResult> => {
134
+ const connection = await getConnection();
135
+
136
+ const result = await connection.createMailbox(path);
137
+
138
+ this.log.info(
139
+ { mailboxId, path, created: result.created },
140
+ "Created mailbox on IMAP server",
141
+ );
142
+
143
+ if (subscribe) {
144
+ await connection.subscribeMailbox(path);
145
+ this.log.info({ mailboxId, path }, "Subscribed to mailbox");
146
+ }
147
+
148
+ // Refresh mailbox list to get UIDVALIDITY and other attributes
149
+ const mailboxes = await connection.listMailboxes();
150
+ const mailboxInfo = mailboxes.find((m) => m.fullPath === path);
151
+
152
+ if (mailboxInfo) {
153
+ // Open the mailbox to get UIDVALIDITY and other status info
154
+ const status = await connection.openBox(path, true);
155
+
156
+ await this.mailboxService.update(accountId, mailboxId, {
157
+ uidValidity: status.uidvalidity,
158
+ uidNext: status.uidnext,
159
+ messageCount: status.messages.total,
160
+ syncStatus: MailboxSyncStatus.synced,
161
+ });
162
+
163
+ await connection.closeBox();
164
+ } else {
165
+ // Mark as synced even if we couldn't get full info
166
+ await this.mailboxService.update(accountId, mailboxId, {
167
+ syncStatus: MailboxSyncStatus.synced,
168
+ });
169
+ }
170
+
171
+ return { success: true };
172
+ };
173
+
174
+ /**
175
+ * Sync a RENAME operation to IMAP.
176
+ * Called by worker after dequeuing MAILBOX_RENAME event.
177
+ *
178
+ * @param accountId - Account that owns the mailbox (tenant scope)
179
+ * @param mailboxId - ID of the mailbox to rename
180
+ * @param oldPath - Current path of the mailbox
181
+ * @param newPath - New path for the mailbox
182
+ * @param getConnection - Factory to get IMAP connection
183
+ */
184
+ syncRename = async (
185
+ accountId: string,
186
+ mailboxId: string,
187
+ oldPath: string,
188
+ newPath: string,
189
+ getConnection: () => Promise<IImapConnection>,
190
+ ): Promise<MailboxManagementSyncResult> => {
191
+ const connection = await getConnection();
192
+
193
+ await connection.renameMailbox(oldPath, newPath);
194
+
195
+ this.log.info(
196
+ { mailboxId, oldPath, newPath },
197
+ "Renamed mailbox on IMAP server",
198
+ );
199
+
200
+ // Clear oldPath and mark as synced
201
+ await this.mailboxService.update(accountId, mailboxId, {
202
+ oldPath: undefined,
203
+ syncStatus: MailboxSyncStatus.synced,
204
+ });
205
+
206
+ return { success: true };
207
+ };
208
+
209
+ /**
210
+ * Sync a DELETE operation to IMAP.
211
+ * Called by worker after dequeuing MAILBOX_DELETE event.
212
+ *
213
+ * @param accountId - Account that owns the mailbox (tenant scope)
214
+ * @param mailboxId - ID of the mailbox to delete
215
+ * @param path - Path of the mailbox to delete
216
+ * @param getConnection - Factory to get IMAP connection
217
+ */
218
+ syncDelete = async (
219
+ accountId: string,
220
+ mailboxId: string,
221
+ path: string,
222
+ getConnection: () => Promise<IImapConnection>,
223
+ ): Promise<MailboxManagementSyncResult> => {
224
+ const connection = await getConnection();
225
+
226
+ await connection.deleteMailbox(path);
227
+
228
+ this.log.info({ mailboxId, path }, "Deleted mailbox on IMAP server");
229
+
230
+ // Delete the mailbox entity from DynamoDB
231
+ await this.mailboxService.delete(accountId, mailboxId);
232
+
233
+ return { success: true };
234
+ };
235
+ }
@@ -0,0 +1,258 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { SendMessageCommand, type SQSClient } from "@aws-sdk/client-sqs";
3
+ import type {
4
+ CreateMailboxInput,
5
+ IMailboxRepository,
6
+ MailboxItem,
7
+ } from "@remit/data-ports";
8
+ import { MailboxSyncStatus } from "@remit/domain-enums";
9
+ import { createQueueProducer } from "@remit/sqs-client/producer";
10
+
11
+ /**
12
+ * MAILBOX_CREATE event structure (matches remit-imap-worker/events.ts)
13
+ */
14
+ interface MailboxCreateEvent {
15
+ type: "MAILBOX_CREATE";
16
+ eventId: string;
17
+ timestamp: number;
18
+ accountId: string;
19
+ mailboxId: string;
20
+ path: string;
21
+ subscribe?: boolean;
22
+ }
23
+
24
+ /**
25
+ * MAILBOX_RENAME event structure (matches remit-imap-worker/events.ts)
26
+ */
27
+ interface MailboxRenameEvent {
28
+ type: "MAILBOX_RENAME";
29
+ eventId: string;
30
+ timestamp: number;
31
+ accountId: string;
32
+ mailboxId: string;
33
+ oldPath: string;
34
+ newPath: string;
35
+ }
36
+
37
+ /**
38
+ * MAILBOX_DELETE event structure (matches remit-imap-worker/events.ts)
39
+ */
40
+ interface MailboxDeleteEvent {
41
+ type: "MAILBOX_DELETE";
42
+ eventId: string;
43
+ timestamp: number;
44
+ accountId: string;
45
+ mailboxId: string;
46
+ path: string;
47
+ }
48
+
49
+ type MailboxManagementEvent =
50
+ | MailboxCreateEvent
51
+ | MailboxRenameEvent
52
+ | MailboxDeleteEvent;
53
+
54
+ /**
55
+ * Logger interface
56
+ */
57
+ export interface MailboxQueueLogger {
58
+ info(obj: Record<string, unknown>, msg: string): void;
59
+ error(obj: Record<string, unknown>, msg: string): void;
60
+ }
61
+
62
+ const noopLogger: MailboxQueueLogger = {
63
+ info: () => {},
64
+ error: () => {},
65
+ };
66
+
67
+ /**
68
+ * Input for creating a mailbox via the queue service
69
+ */
70
+ export type CreateMailboxQueueInput = Omit<CreateMailboxInput, "syncStatus">;
71
+
72
+ /**
73
+ * Configuration for MailboxQueueService
74
+ */
75
+ export interface MailboxQueueConfig {
76
+ mailboxService: IMailboxRepository;
77
+ sqsQueueUrl: string;
78
+ sqsEndpoint?: string;
79
+ logger?: MailboxQueueLogger;
80
+ }
81
+
82
+ /**
83
+ * Service for mailbox management with automatic IMAP sync queueing.
84
+ *
85
+ * Implements optimistic local-first pattern:
86
+ * 1. Updates local DynamoDB state immediately
87
+ * 2. Enqueues mailbox management event to SQS for worker to sync to IMAP
88
+ *
89
+ * This follows the same pattern as FlagQueueService (RFC 014).
90
+ */
91
+ export class MailboxQueueService {
92
+ private mailboxService: IMailboxRepository;
93
+ private sqs: SQSClient;
94
+ private queueUrl: string;
95
+ private log: MailboxQueueLogger;
96
+
97
+ constructor(config: MailboxQueueConfig) {
98
+ const { mailboxService, sqsQueueUrl, sqsEndpoint } = config;
99
+ this.mailboxService = mailboxService;
100
+ this.queueUrl = sqsQueueUrl;
101
+ this.log = config.logger ?? noopLogger;
102
+
103
+ this.sqs = createQueueProducer({
104
+ queueUrl: sqsQueueUrl,
105
+ endpoint: sqsEndpoint,
106
+ });
107
+ }
108
+
109
+ /**
110
+ * Create a new mailbox.
111
+ * Updates local state (with syncStatus=pending) and enqueues IMAP CREATE.
112
+ *
113
+ * @param input - The mailbox creation input (without syncStatus)
114
+ * @param accountId - The account ID for the IMAP sync event
115
+ * @param subscribe - Whether to subscribe to the mailbox after creation
116
+ * @returns The created mailbox
117
+ */
118
+ createMailbox = async (
119
+ input: CreateMailboxQueueInput,
120
+ accountId: string,
121
+ subscribe?: boolean,
122
+ ): Promise<MailboxItem> => {
123
+ // Create local mailbox with pending status
124
+ const mailbox = await this.mailboxService.create({
125
+ ...input,
126
+ syncStatus: MailboxSyncStatus.pending,
127
+ });
128
+
129
+ this.log.info(
130
+ { mailboxId: mailbox.mailboxId, path: mailbox.fullPath },
131
+ "Created mailbox (local)",
132
+ );
133
+
134
+ // Enqueue IMAP sync
135
+ await this.enqueueEvent({
136
+ type: "MAILBOX_CREATE",
137
+ eventId: randomUUID(),
138
+ timestamp: Date.now(),
139
+ accountId,
140
+ mailboxId: mailbox.mailboxId,
141
+ path: mailbox.fullPath,
142
+ subscribe,
143
+ });
144
+
145
+ return mailbox;
146
+ };
147
+
148
+ /**
149
+ * Rename a mailbox.
150
+ * Updates local state (including children) and enqueues IMAP RENAME.
151
+ *
152
+ * @param mailboxId - The mailbox to rename
153
+ * @param newPath - The new path for the mailbox
154
+ * @param accountId - The account ID for the IMAP sync event
155
+ * @returns The updated mailbox
156
+ */
157
+ renameMailbox = async (
158
+ mailboxId: string,
159
+ newPath: string,
160
+ accountId: string,
161
+ ): Promise<MailboxItem> => {
162
+ // Get current mailbox to capture old path
163
+ const mailbox = await this.mailboxService.get(accountId, mailboxId);
164
+ const oldPath = mailbox.fullPath;
165
+
166
+ // Update the mailbox path and set syncStatus to pending
167
+ const updated = await this.mailboxService.update(accountId, mailboxId, {
168
+ fullPath: newPath,
169
+ syncStatus: MailboxSyncStatus.pending,
170
+ });
171
+
172
+ // Update child mailbox paths
173
+ await this.mailboxService.renameChildPaths(
174
+ mailbox.accountId,
175
+ oldPath,
176
+ newPath,
177
+ mailbox.hierarchyDelimiter,
178
+ );
179
+
180
+ this.log.info({ mailboxId, oldPath, newPath }, "Renamed mailbox (local)");
181
+
182
+ // Enqueue IMAP sync
183
+ await this.enqueueEvent({
184
+ type: "MAILBOX_RENAME",
185
+ eventId: randomUUID(),
186
+ timestamp: Date.now(),
187
+ accountId,
188
+ mailboxId,
189
+ oldPath,
190
+ newPath,
191
+ });
192
+
193
+ return updated;
194
+ };
195
+
196
+ /**
197
+ * Delete a mailbox.
198
+ * Marks for deletion (syncStatus=deleting) and enqueues IMAP DELETE.
199
+ *
200
+ * @param mailboxId - The mailbox to delete
201
+ * @param accountId - The account ID for the IMAP sync event
202
+ */
203
+ deleteMailbox = async (
204
+ mailboxId: string,
205
+ accountId: string,
206
+ ): Promise<void> => {
207
+ // Get current mailbox to capture path
208
+ const mailbox = await this.mailboxService.get(accountId, mailboxId);
209
+
210
+ // Mark as deleting (soft delete - worker will do actual delete after IMAP sync)
211
+ await this.mailboxService.update(accountId, mailboxId, {
212
+ syncStatus: MailboxSyncStatus.deleting,
213
+ });
214
+
215
+ this.log.info(
216
+ { mailboxId, path: mailbox.fullPath },
217
+ "Marked mailbox for deletion (local)",
218
+ );
219
+
220
+ // Enqueue IMAP sync
221
+ await this.enqueueEvent({
222
+ type: "MAILBOX_DELETE",
223
+ eventId: randomUUID(),
224
+ timestamp: Date.now(),
225
+ accountId,
226
+ mailboxId,
227
+ path: mailbox.fullPath,
228
+ });
229
+ };
230
+
231
+ /**
232
+ * Enqueue a mailbox management event to SQS.
233
+ *
234
+ * FIFO queues require MessageGroupId; standard queues reject it. We detect
235
+ * FIFO queues by the `.fifo` suffix on the queue URL and scope ordering to
236
+ * the account so events for different accounts can be processed in parallel.
237
+ */
238
+ private enqueueEvent = async (
239
+ event: MailboxManagementEvent,
240
+ ): Promise<void> => {
241
+ const useFifo = this.queueUrl.endsWith(".fifo");
242
+ await this.sqs.send(
243
+ new SendMessageCommand({
244
+ QueueUrl: this.queueUrl,
245
+ MessageBody: JSON.stringify(event),
246
+ ...(useFifo && {
247
+ MessageGroupId: event.accountId,
248
+ MessageDeduplicationId: `${event.type}:${event.mailboxId}:${event.eventId}`,
249
+ }),
250
+ }),
251
+ );
252
+
253
+ this.log.info(
254
+ { eventId: event.eventId, type: event.type, mailboxId: event.mailboxId },
255
+ `Enqueued ${event.type} event`,
256
+ );
257
+ };
258
+ }
@@ -0,0 +1,153 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import type {
4
+ IMailboxRepository,
5
+ IMailboxSpecialUseRepository,
6
+ } from "@remit/data-ports";
7
+ import { MailboxCursorState, MailboxSpecialUse } from "@remit/domain-enums";
8
+ import { parseImapAttributes } from "./attribute-mapper.js";
9
+ import { MailboxSyncService } from "./mailbox-sync.js";
10
+ import type { IImapConnection, ImapNamespaces } from "./types.js";
11
+
12
+ describe("parseImapAttributes – locale invariance (#194)", () => {
13
+ it("recognizes \\Sent regardless of folder name", () => {
14
+ const dutch = parseImapAttributes(["\\HasNoChildren", "\\Sent"]);
15
+ assert.deepEqual(dutch.specialUse, [MailboxSpecialUse.Sent]);
16
+
17
+ const english = parseImapAttributes(["\\Sent"]);
18
+ assert.deepEqual(english.specialUse, [MailboxSpecialUse.Sent]);
19
+ });
20
+
21
+ it("recognizes \\Drafts on a localized 'Concepten' folder", () => {
22
+ // IMAP server tells us the flag — the folder name is irrelevant.
23
+ const parsed = parseImapAttributes(["\\Drafts"]);
24
+ assert.deepEqual(parsed.specialUse, [MailboxSpecialUse.Drafts]);
25
+ });
26
+
27
+ it("returns an empty list when no flag is present", () => {
28
+ // A real user folder like Outlook NL's "Nieuwsbrieven" carries no
29
+ // SPECIAL-USE attributes — leave it untouched downstream.
30
+ const parsed = parseImapAttributes(["\\HasNoChildren"]);
31
+ assert.deepEqual(parsed.specialUse, []);
32
+ });
33
+
34
+ it("normalizes case: \\sent is treated the same as \\Sent", () => {
35
+ const parsed = parseImapAttributes(["\\sent"]);
36
+ assert.deepEqual(parsed.specialUse, [MailboxSpecialUse.Sent]);
37
+ });
38
+ });
39
+
40
+ describe("MailboxSyncService.syncMailboxes — UIDVALIDITY cursor detection (#1272)", () => {
41
+ const namespaces: ImapNamespaces = {
42
+ personal: [{ prefix: "", delimiter: "/" }],
43
+ other: [],
44
+ shared: [],
45
+ };
46
+
47
+ const buildConnection = (uidValidity: number): IImapConnection =>
48
+ ({
49
+ getNamespaces: async () => namespaces,
50
+ listMailboxes: async () => [
51
+ {
52
+ fullPath: "INBOX",
53
+ name: "INBOX",
54
+ delimiter: "/",
55
+ attributes: [],
56
+ parentPath: null,
57
+ },
58
+ ],
59
+ getMailboxStatus: async () => ({
60
+ messages: 5,
61
+ recent: 0,
62
+ unseen: 1,
63
+ uidNext: 100,
64
+ uidValidity,
65
+ highestModseq: 0,
66
+ deletedCount: 0,
67
+ }),
68
+ }) as unknown as IImapConnection;
69
+
70
+ const buildServices = (
71
+ existingUidValidity: number,
72
+ existingCursorState?: string,
73
+ ) => {
74
+ const updateCalls: Array<Record<string, unknown>> = [];
75
+ const mailboxService = {
76
+ listByAccount: async () => ({
77
+ items: [
78
+ {
79
+ mailboxId: "mbx-1",
80
+ fullPath: "INBOX",
81
+ uidNext: 100,
82
+ uidValidity: existingUidValidity,
83
+ messageCount: 5,
84
+ unseenCount: 1,
85
+ deletedCount: 0,
86
+ highestModseq: 0,
87
+ specialUse: undefined,
88
+ cursorState: existingCursorState,
89
+ },
90
+ ],
91
+ continuationToken: undefined,
92
+ }),
93
+ update: async (
94
+ _accountId: string,
95
+ _mailboxId: string,
96
+ patch: Record<string, unknown>,
97
+ ) => {
98
+ updateCalls.push(patch);
99
+ return {};
100
+ },
101
+ delete: async () => undefined,
102
+ create: async () => ({}),
103
+ } as unknown as IMailboxRepository;
104
+
105
+ const specialUseService = {
106
+ listByMailboxId: async () => [],
107
+ deleteByMailboxId: async () => undefined,
108
+ createMany: async () => undefined,
109
+ } as unknown as IMailboxSpecialUseRepository;
110
+
111
+ return { mailboxService, specialUseService, updateCalls };
112
+ };
113
+
114
+ it("trips cursor_invalid when the STATUS sweep observes a changed UIDVALIDITY", async () => {
115
+ const { mailboxService, specialUseService, updateCalls } = buildServices(1);
116
+ const service = new MailboxSyncService(mailboxService, specialUseService);
117
+ const connection = buildConnection(2);
118
+
119
+ await service.syncMailboxes({ accountId: "acc-1" }, connection);
120
+
121
+ const uidValidityUpdate = updateCalls.find((c) => "uidValidity" in c);
122
+ assert.ok(uidValidityUpdate, "expected the sweep to write the mailbox");
123
+ assert.equal(
124
+ uidValidityUpdate?.cursorState,
125
+ MailboxCursorState.cursor_invalid,
126
+ );
127
+ });
128
+
129
+ it("does not write anything when UIDVALIDITY (and everything else) is unchanged", async () => {
130
+ const { mailboxService, specialUseService, updateCalls } = buildServices(1);
131
+ const service = new MailboxSyncService(mailboxService, specialUseService);
132
+ const connection = buildConnection(1);
133
+
134
+ await service.syncMailboxes({ accountId: "acc-1" }, connection);
135
+
136
+ assert.equal(updateCalls.length, 0);
137
+ });
138
+
139
+ it("does not re-trip (no cursorState write) when the mailbox is already cursor_invalid", async () => {
140
+ const { mailboxService, specialUseService, updateCalls } = buildServices(
141
+ 1,
142
+ MailboxCursorState.cursor_invalid,
143
+ );
144
+ const service = new MailboxSyncService(mailboxService, specialUseService);
145
+ const connection = buildConnection(2);
146
+
147
+ await service.syncMailboxes({ accountId: "acc-1" }, connection);
148
+
149
+ const uidValidityUpdate = updateCalls.find((c) => "uidValidity" in c);
150
+ assert.ok(uidValidityUpdate);
151
+ assert.equal("cursorState" in (uidValidityUpdate ?? {}), false);
152
+ });
153
+ });