@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.
- package/README.md +147 -0
- package/package.json +65 -0
- package/src/account-credentials.ts +128 -0
- package/src/adversarial-mime.e2e.test.ts +311 -0
- package/src/attribute-mapper.test.ts +78 -0
- package/src/attribute-mapper.ts +147 -0
- package/src/body-part-mapper.property.test.ts +354 -0
- package/src/body-part-mapper.ts +360 -0
- package/src/body-sync-queue.test.ts +124 -0
- package/src/body-sync-queue.ts +131 -0
- package/src/body-sync-terminal.test.ts +265 -0
- package/src/body-sync-terminal.ts +172 -0
- package/src/body-sync.ts +1202 -0
- package/src/connection-factory.ts +102 -0
- package/src/connection-test.ts +208 -0
- package/src/credentials.test.ts +181 -0
- package/src/filters/match.test.ts +181 -0
- package/src/filters/match.ts +143 -0
- package/src/filters/pipeline.ts +243 -0
- package/src/flag-push-terminal.test.ts +156 -0
- package/src/flag-push-terminal.ts +106 -0
- package/src/flag-push.test.ts +408 -0
- package/src/flag-push.ts +198 -0
- package/src/flag-queue.ts +500 -0
- package/src/heuristics/classifyByHeaders.test.ts +578 -0
- package/src/heuristics/classifyByHeaders.ts +369 -0
- package/src/heuristics/classifyPlacement.ts +96 -0
- package/src/heuristics/socialDomains.ts +23 -0
- package/src/heuristics/transactionalDomains.ts +26 -0
- package/src/imapflow-connect-retry.test.ts +108 -0
- package/src/imapflow-connection.e2e.test.ts +355 -0
- package/src/imapflow-connection.integ.test.ts +327 -0
- package/src/imapflow-connection.test.ts +161 -0
- package/src/imapflow-connection.ts +1374 -0
- package/src/index.ts +214 -0
- package/src/mailbox-cursor-rebuild.test.ts +171 -0
- package/src/mailbox-cursor-rebuild.ts +146 -0
- package/src/mailbox-cursor.test.ts +249 -0
- package/src/mailbox-cursor.ts +177 -0
- package/src/mailbox-management.test.ts +103 -0
- package/src/mailbox-management.ts +235 -0
- package/src/mailbox-queue.ts +258 -0
- package/src/mailbox-sync.test.ts +153 -0
- package/src/mailbox-sync.ts +590 -0
- package/src/message-move.ts +825 -0
- package/src/message-parser.ts +26 -0
- package/src/message-sync.ts +1021 -0
- package/src/mime-walker.test.ts +311 -0
- package/src/mime-walker.ts +305 -0
- package/src/outbox-queue.ts +294 -0
- package/src/pass-through-unit-of-work.ts +17 -0
- package/src/placement-move-terminal.test.ts +179 -0
- package/src/placement-move-terminal.ts +104 -0
- package/src/placement-move.ts +224 -0
- package/src/snippet.test.ts +213 -0
- package/src/snippet.ts +152 -0
- package/src/stale-message-reconcile.test.ts +82 -0
- package/src/stale-message-reconcile.ts +55 -0
- package/src/test-helpers/isolated-mailbox.ts +44 -0
- package/src/test-helpers/mailfuzz-connection.ts +27 -0
- package/src/test-helpers/mime-tree-generator.ts +461 -0
- package/src/text/normalizer.test.ts +223 -0
- package/src/text/normalizer.ts +140 -0
- package/src/types/mailparser-augment.d.ts +15 -0
- package/src/types/natural-porter-stemmer.d.ts +50 -0
- package/src/types/stopword.d.ts +69 -0
- package/src/types.ts +349 -0
- package/test/fixtures/mime/01-flat-text-plain.bodyparts.json +8 -0
- package/test/fixtures/mime/01-flat-text-plain.eml +10 -0
- package/test/fixtures/mime/01-flat-text-plain.expected.json +10 -0
- package/test/fixtures/mime/02-flat-text-html.bodyparts.json +8 -0
- package/test/fixtures/mime/02-flat-text-html.eml +10 -0
- package/test/fixtures/mime/02-flat-text-html.expected.json +10 -0
- package/test/fixtures/mime/03-alternative-text-html.bodyparts.json +20 -0
- package/test/fixtures/mime/03-alternative-text-html.eml +19 -0
- package/test/fixtures/mime/03-alternative-text-html.expected.json +16 -0
- package/test/fixtures/mime/04-mixed-html-pdf-attachment.bodyparts.json +22 -0
- package/test/fixtures/mime/04-mixed-html-pdf-attachment.eml +20 -0
- package/test/fixtures/mime/04-mixed-html-pdf-attachment.expected.json +16 -0
- package/test/fixtures/mime/05-mixed-html-octet-stream-pdf.bodyparts.json +22 -0
- package/test/fixtures/mime/05-mixed-html-octet-stream-pdf.eml +21 -0
- package/test/fixtures/mime/05-mixed-html-octet-stream-pdf.expected.json +16 -0
- package/test/fixtures/mime/06-related-html-cid-image.bodyparts.json +23 -0
- package/test/fixtures/mime/06-related-html-cid-image.eml +21 -0
- package/test/fixtures/mime/06-related-html-cid-image.expected.json +16 -0
- package/test/fixtures/mime/07-mixed-alternative-related-attachment.bodyparts.json +55 -0
- package/test/fixtures/mime/07-mixed-alternative-related-attachment.eml +47 -0
- package/test/fixtures/mime/07-mixed-alternative-related-attachment.expected.json +34 -0
- package/test/fixtures/mime/08-two-pdfs-same-filename.bodyparts.json +30 -0
- package/test/fixtures/mime/08-two-pdfs-same-filename.eml +26 -0
- package/test/fixtures/mime/08-two-pdfs-same-filename.expected.json +22 -0
- package/test/fixtures/mime/09-attachment-no-filename.bodyparts.json +21 -0
- package/test/fixtures/mime/09-attachment-no-filename.eml +20 -0
- package/test/fixtures/mime/09-attachment-no-filename.expected.json +16 -0
- package/test/fixtures/mime/10-calendar-invite.bodyparts.json +22 -0
- package/test/fixtures/mime/10-calendar-invite.eml +33 -0
- package/test/fixtures/mime/10-calendar-invite.expected.json +16 -0
- package/test/fixtures/mime/11-quoted-printable-text.bodyparts.json +8 -0
- package/test/fixtures/mime/11-quoted-printable-text.eml +10 -0
- package/test/fixtures/mime/11-quoted-printable-text.expected.json +10 -0
- package/test/fixtures/mime/12-base64-binary.bodyparts.json +22 -0
- package/test/fixtures/mime/12-base64-binary.eml +20 -0
- package/test/fixtures/mime/12-base64-binary.expected.json +16 -0
- package/test/fixtures/mime/13-message-rfc822-forward.bodyparts.json +22 -0
- package/test/fixtures/mime/13-message-rfc822-forward.eml +28 -0
- package/test/fixtures/mime/13-message-rfc822-forward.expected.json +16 -0
- package/test/fixtures/mime/14-deeply-nested-5-levels.bodyparts.json +38 -0
- package/test/fixtures/mime/14-deeply-nested-5-levels.eml +34 -0
- package/test/fixtures/mime/14-deeply-nested-5-levels.expected.json +10 -0
- package/test/fixtures/mime/15-empty-text-part.bodyparts.json +21 -0
- package/test/fixtures/mime/15-empty-text-part.eml +18 -0
- package/test/fixtures/mime/15-empty-text-part.expected.json +16 -0
- package/test/fixtures/mime/16-empty-non-text-part.bodyparts.json +30 -0
- package/test/fixtures/mime/16-empty-non-text-part.eml +24 -0
- package/test/fixtures/mime/16-empty-non-text-part.expected.json +22 -0
- package/test/fixtures/mime-adversarial-e2e/01-octet-stream-pdf.eml +21 -0
- package/test/fixtures/mime-adversarial-e2e/02-nested-related-cid-image.eml +31 -0
- package/test/fixtures/mime-adversarial-e2e/03-calendar-invite.eml +33 -0
- package/test/fixtures/mime-adversarial-e2e/04-forwarded-rfc822-with-attachments.eml +42 -0
- package/test/fixtures/mime-adversarial-e2e/05-tnef-winmail.eml +25 -0
- package/test/fixtures/mime-adversarial-e2e/06-8bit-non-ascii.eml +15 -0
- package/test/fixtures/mime-adversarial-e2e/07-quoted-printable-soft-breaks.eml +17 -0
- package/test/fixtures/mime-adversarial-e2e/08-alternative-empty-text.eml +18 -0
- 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
|
+
});
|