@remit/mailbox-service 0.0.112 → 0.0.114
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/account-credentials.d.ts +83 -0
- package/dist/account-credentials.js +104 -0
- package/dist/attribute-mapper.d.ts +36 -0
- package/dist/attribute-mapper.js +119 -0
- package/dist/body-parse.d.ts +28 -0
- package/{src/body-parse.ts → dist/body-parse.js} +18 -26
- package/dist/body-part-mapper.d.ts +92 -0
- package/dist/body-part-mapper.js +301 -0
- package/dist/body-sync-queue.d.ts +59 -0
- package/dist/body-sync-queue.js +56 -0
- package/dist/body-sync-terminal.d.ts +58 -0
- package/dist/body-sync-terminal.js +95 -0
- package/dist/body-sync.d.ts +351 -0
- package/dist/body-sync.js +1204 -0
- package/dist/calendar-parts.d.ts +23 -0
- package/dist/calendar-parts.js +18 -0
- package/dist/classification-backfill.d.ts +91 -0
- package/dist/classification-backfill.js +180 -0
- package/dist/classify-message.d.ts +31 -0
- package/dist/classify-message.js +89 -0
- package/dist/connection-factory.d.ts +54 -0
- package/dist/connection-factory.js +54 -0
- package/dist/connection-test.d.ts +41 -0
- package/dist/connection-test.js +152 -0
- package/dist/external-move.d.ts +94 -0
- package/dist/external-move.js +173 -0
- package/dist/filter-operations.d.ts +42 -0
- package/dist/filter-operations.js +211 -0
- package/dist/filters/filter-config.d.ts +14 -0
- package/dist/filters/filter-config.js +10 -0
- package/{src/filters/list-id.ts → dist/filters/list-id.d.ts} +2 -16
- package/dist/filters/list-id.js +26 -0
- package/dist/filters/match.d.ts +69 -0
- package/dist/filters/match.js +165 -0
- package/dist/filters/pipeline.d.ts +64 -0
- package/dist/filters/pipeline.js +100 -0
- package/{src/flag-push-terminal.ts → dist/flag-push-terminal.d.ts} +16 -72
- package/dist/flag-push-terminal.js +63 -0
- package/dist/flag-push.d.ts +69 -0
- package/dist/flag-push.js +111 -0
- package/dist/flag-queue.d.ts +175 -0
- package/dist/flag-queue.js +291 -0
- package/dist/folder-operations.d.ts +39 -0
- package/dist/folder-operations.js +112 -0
- package/dist/folder-placement.d.ts +13 -0
- package/dist/folder-placement.js +23 -0
- package/dist/heuristics/authenticationResults.d.ts +10 -0
- package/dist/heuristics/authenticationResults.js +187 -0
- package/dist/heuristics/classifyByHeaders.d.ts +82 -0
- package/dist/heuristics/classifyByHeaders.js +365 -0
- package/dist/heuristics/classifyPlacement.d.ts +67 -0
- package/dist/heuristics/classifyPlacement.js +153 -0
- package/dist/heuristics/machineSenders.d.ts +21 -0
- package/{src/heuristics/machineSenders.ts → dist/heuristics/machineSenders.js} +34 -40
- package/dist/heuristics/senderMismatch.d.ts +81 -0
- package/dist/heuristics/senderMismatch.js +192 -0
- package/dist/heuristics/socialDomains.d.ts +14 -0
- package/{src/heuristics/socialDomains.ts → dist/heuristics/socialDomains.js} +9 -9
- package/dist/heuristics/transactionalDomains.d.ts +18 -0
- package/{src/heuristics/transactionalDomains.ts → dist/heuristics/transactionalDomains.js} +8 -8
- package/dist/imapflow-connection.d.ts +377 -0
- package/dist/imapflow-connection.js +1147 -0
- package/dist/index.d.ts +63 -0
- package/dist/index.js +65 -0
- package/dist/list-id-backfill.d.ts +83 -0
- package/dist/list-id-backfill.js +132 -0
- package/dist/mailbox-cursor-rebuild.d.ts +74 -0
- package/dist/mailbox-cursor-rebuild.js +71 -0
- package/{src/mailbox-cursor.ts → dist/mailbox-cursor.d.ts} +20 -98
- package/dist/mailbox-cursor.js +102 -0
- package/dist/mailbox-intent.d.ts +41 -0
- package/{src/mailbox-intent.ts → dist/mailbox-intent.js} +38 -64
- package/dist/mailbox-management.d.ts +240 -0
- package/dist/mailbox-management.js +576 -0
- package/dist/mailbox-presence.d.ts +52 -0
- package/{src/mailbox-presence.ts → dist/mailbox-presence.js} +19 -30
- package/dist/mailbox-queue.d.ts +129 -0
- package/dist/mailbox-queue.js +216 -0
- package/dist/mailbox-sync.d.ts +93 -0
- package/dist/mailbox-sync.js +490 -0
- package/dist/mailbox-upstream.d.ts +38 -0
- package/{src/mailbox-upstream.ts → dist/mailbox-upstream.js} +37 -47
- package/dist/message-category.d.ts +51 -0
- package/{src/message-category.ts → dist/message-category.js} +28 -71
- package/dist/message-move.d.ts +285 -0
- package/dist/message-move.js +858 -0
- package/dist/message-parser.d.ts +14 -0
- package/dist/message-parser.js +17 -0
- package/{src/message-presence.ts → dist/message-presence.d.ts} +1 -13
- package/dist/message-presence.js +29 -0
- package/dist/message-read.d.ts +51 -0
- package/dist/message-read.js +31 -0
- package/dist/message-sync.d.ts +345 -0
- package/dist/message-sync.js +1358 -0
- package/dist/mime-walker.d.ts +102 -0
- package/dist/mime-walker.js +198 -0
- package/dist/organize-job.d.ts +20 -0
- package/dist/organize-job.js +37 -0
- package/dist/outbox-attachment-content.d.ts +37 -0
- package/dist/outbox-attachment-content.js +50 -0
- package/dist/outbox-attachment-filename.d.ts +19 -0
- package/dist/outbox-attachment-filename.js +64 -0
- package/dist/outbox-attachment.d.ts +148 -0
- package/dist/outbox-attachment.js +273 -0
- package/dist/outbox-queue.d.ts +73 -0
- package/dist/outbox-queue.js +280 -0
- package/{src/outbox-status.ts → dist/outbox-status.d.ts} +1 -6
- package/dist/outbox-status.js +18 -0
- package/dist/ownership.d.ts +24 -0
- package/dist/ownership.js +83 -0
- package/dist/placement-move-terminal.d.ts +67 -0
- package/dist/placement-move-terminal.js +88 -0
- package/dist/placement-move.d.ts +83 -0
- package/dist/placement-move.js +216 -0
- package/{src/placement-settled.ts → dist/placement-settled.d.ts} +13 -68
- package/dist/placement-settled.js +75 -0
- package/dist/quarantine.d.ts +95 -0
- package/dist/quarantine.js +131 -0
- package/dist/refusal.d.ts +31 -0
- package/dist/refusal.js +3 -0
- package/{src/restore-source-placement.ts → dist/restore-source-placement.d.ts} +29 -97
- package/dist/restore-source-placement.js +79 -0
- package/dist/search-text-backfill.d.ts +15 -0
- package/dist/search-text-backfill.js +33 -0
- package/dist/snippet.d.ts +61 -0
- package/dist/snippet.js +129 -0
- package/dist/spam-report.d.ts +86 -0
- package/dist/spam-report.js +241 -0
- package/dist/sync-warning.d.ts +21 -0
- package/dist/sync-warning.js +26 -0
- package/dist/sync-watermarks.d.ts +138 -0
- package/dist/sync-watermarks.js +205 -0
- package/dist/test-helpers/fake-imap.d.ts +151 -0
- package/dist/test-helpers/fake-imap.js +508 -0
- package/dist/test-helpers/folder-roles.d.ts +34 -0
- package/dist/test-helpers/folder-roles.js +47 -0
- package/dist/test-helpers/mime-tree-generator.d.ts +82 -0
- package/dist/test-helpers/mime-tree-generator.js +329 -0
- package/dist/test-helpers/test-store.d.ts +69 -0
- package/dist/test-helpers/test-store.js +193 -0
- package/dist/text/normalizer.d.ts +20 -0
- package/dist/text/normalizer.js +102 -0
- package/dist/thread-message-rows.d.ts +69 -0
- package/dist/thread-message-rows.js +40 -0
- package/dist/thread-root.d.ts +7 -0
- package/dist/thread-root.js +25 -0
- package/dist/types.d.ts +343 -0
- package/dist/types.js +19 -0
- package/package.json +86 -88
- package/src/account-credentials.ts +0 -210
- package/src/attribute-mapper.test.ts +0 -104
- package/src/attribute-mapper.ts +0 -148
- package/src/body-parse.test.ts +0 -55
- package/src/body-part-mapper.property.test.ts +0 -354
- package/src/body-part-mapper.ts +0 -410
- package/src/body-sync-queue.test.ts +0 -64
- package/src/body-sync-queue.ts +0 -131
- package/src/body-sync-terminal.test.ts +0 -148
- package/src/body-sync-terminal.ts +0 -174
- package/src/body-sync.test.ts +0 -925
- package/src/body-sync.ts +0 -1891
- package/src/calendar-parts.ts +0 -41
- package/src/classification-backfill.test.ts +0 -285
- package/src/classification-backfill.ts +0 -363
- package/src/classify-message.test.ts +0 -142
- package/src/classify-message.ts +0 -116
- package/src/connection-factory.ts +0 -102
- package/src/connection-test.ts +0 -208
- package/src/credentials.test.ts +0 -181
- package/src/external-move.test.ts +0 -262
- package/src/external-move.ts +0 -285
- package/src/filter-operations.test.ts +0 -319
- package/src/filter-operations.ts +0 -377
- package/src/filter-update.test.ts +0 -313
- package/src/filters/filter-config.test.ts +0 -87
- package/src/filters/filter-config.ts +0 -34
- package/src/filters/list-id.test.ts +0 -50
- package/src/filters/match.test.ts +0 -392
- package/src/filters/match.ts +0 -225
- package/src/filters/pipeline.test.ts +0 -199
- package/src/filters/pipeline.ts +0 -170
- package/src/flag-push.test.ts +0 -112
- package/src/flag-push.ts +0 -198
- package/src/flag-queue.test.ts +0 -197
- package/src/flag-queue.ts +0 -509
- package/src/folder-operations.test.ts +0 -273
- package/src/folder-operations.ts +0 -217
- package/src/folder-placement.ts +0 -56
- package/src/forwarded-message.test.ts +0 -122
- package/src/heuristics/authenticationResults.test.ts +0 -189
- package/src/heuristics/authenticationResults.ts +0 -212
- package/src/heuristics/classifyByHeaders.test.ts +0 -889
- package/src/heuristics/classifyByHeaders.ts +0 -467
- package/src/heuristics/classifyPlacement.test.ts +0 -366
- package/src/heuristics/classifyPlacement.ts +0 -211
- package/src/heuristics/classifyRealMail.test.ts +0 -242
- package/src/heuristics/machineSenders.test.ts +0 -101
- package/src/heuristics/senderMismatch.test.ts +0 -346
- package/src/heuristics/senderMismatch.ts +0 -259
- package/src/imapflow-connect-retry.test.ts +0 -97
- package/src/imapflow-connection.test.ts +0 -503
- package/src/imapflow-connection.ts +0 -1476
- package/src/index.ts +0 -397
- package/src/list-id-backfill.test.ts +0 -207
- package/src/list-id-backfill.ts +0 -274
- package/src/mailbox-cursor-rebuild.test.ts +0 -171
- package/src/mailbox-cursor-rebuild.ts +0 -146
- package/src/mailbox-cursor.test.ts +0 -141
- package/src/mailbox-delete-intent.test.ts +0 -119
- package/src/mailbox-management.test.ts +0 -243
- package/src/mailbox-management.ts +0 -838
- package/src/mailbox-presence.test.ts +0 -74
- package/src/mailbox-queue.ts +0 -397
- package/src/mailbox-rename-intent.test.ts +0 -220
- package/src/mailbox-rename-subtree.test.ts +0 -287
- package/src/mailbox-sync.test.ts +0 -286
- package/src/mailbox-sync.ts +0 -724
- package/src/message-move.test.ts +0 -726
- package/src/message-move.ts +0 -1518
- package/src/message-parser.ts +0 -26
- package/src/message-presence.test.ts +0 -50
- package/src/message-read.ts +0 -87
- package/src/message-save-retry.test.ts +0 -90
- package/src/message-sync-flags.test.ts +0 -167
- package/src/message-sync.test.ts +0 -762
- package/src/message-sync.ts +0 -2133
- package/src/mime-walker.test.ts +0 -311
- package/src/mime-walker.ts +0 -306
- package/src/organize-job.ts +0 -93
- package/src/outbox-attachment-content.test.ts +0 -153
- package/src/outbox-attachment-content.ts +0 -104
- package/src/outbox-attachment-filename.test.ts +0 -132
- package/src/outbox-attachment-filename.ts +0 -76
- package/src/outbox-attachment.test.ts +0 -626
- package/src/outbox-attachment.ts +0 -536
- package/src/outbox-failed-recovery.test.ts +0 -299
- package/src/outbox-queue.test.ts +0 -315
- package/src/outbox-queue.ts +0 -525
- package/src/ownership.ts +0 -140
- package/src/placement-move-terminal.ts +0 -145
- package/src/placement-move.ts +0 -363
- package/src/placement-settled.test.ts +0 -147
- package/src/placement.test.ts +0 -404
- package/src/quarantine.test.ts +0 -133
- package/src/quarantine.ts +0 -229
- package/src/refusal.ts +0 -29
- package/src/snippet.test.ts +0 -213
- package/src/snippet.ts +0 -152
- package/src/spam-report.test.ts +0 -329
- package/src/spam-report.ts +0 -357
- package/src/sync-warning.test.ts +0 -213
- package/src/sync-warning.ts +0 -56
- package/src/sync-watermarks.test.ts +0 -409
- package/src/sync-watermarks.ts +0 -319
- package/src/system-flag-wire-format.test.ts +0 -77
- package/src/test-helpers/fake-imap.ts +0 -690
- package/src/test-helpers/folder-roles.ts +0 -66
- package/src/test-helpers/mime-tree-generator.ts +0 -461
- package/src/test-helpers/test-queue.ts +0 -56
- package/src/test-helpers/test-store.ts +0 -299
- package/src/text/normalizer.test.ts +0 -223
- package/src/text/normalizer.ts +0 -140
- package/src/thread-message-rows.ts +0 -77
- package/src/thread-root.test.ts +0 -29
- package/src/thread-root.ts +0 -51
- package/src/types/mailparser-augment.d.ts +0 -23
- package/src/types/natural-porter-stemmer.d.ts +0 -50
- package/src/types/stopword.d.ts +0 -69
- package/src/types.ts +0 -371
- package/tsconfig.json +0 -8
|
@@ -0,0 +1,858 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { QUEUE_BATCH_LIMIT, } from "@remit/data-ports";
|
|
3
|
+
import { FolderRoleUnresolvedError, MessagePlacementUnsettledError, } from "@remit/data-ports/errors";
|
|
4
|
+
import { NO_TRASH_FOLDER_REASON, STALE_TRASH_FOLDER_REASON, trashMailboxAt, UNCONFIRMED_TRASH_FOLDER_REASON, } from "@remit/data-ports/folder-role";
|
|
5
|
+
import { deriveCopyMessageId } from "@remit/data-ports/id";
|
|
6
|
+
import { MUTATION_EVENT_SCHEMA_VERSION } from "@remit/data-ports/mutation-events";
|
|
7
|
+
import { CanonicalMailboxRole, MessageMutation, MessageStatus, MessageSyncStatus, } from "@remit/domain-enums";
|
|
8
|
+
import { carriesForeignUid, placementBindingOf, waitForPlacementToSettle, } from "./placement-settled.js";
|
|
9
|
+
import { restoreSourcePlacement } from "./restore-source-placement.js";
|
|
10
|
+
/** Undo every write whose event never left, then fail with the queue's error. */
|
|
11
|
+
const handBackThenThrow = async (fault, handBacks) => {
|
|
12
|
+
const undoFaults = [];
|
|
13
|
+
for (const handBack of handBacks) {
|
|
14
|
+
await handBack().catch((undoFault) => {
|
|
15
|
+
undoFaults.push(undoFault);
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
if (undoFaults.length > 0) {
|
|
19
|
+
throw new AggregateError([fault, ...undoFaults], "The queue refused the event, and handing the local write back failed too");
|
|
20
|
+
}
|
|
21
|
+
throw fault;
|
|
22
|
+
};
|
|
23
|
+
const queueMessage = (event) => ({
|
|
24
|
+
body: event,
|
|
25
|
+
groupId: event.accountId,
|
|
26
|
+
deduplicationId: event.eventId,
|
|
27
|
+
});
|
|
28
|
+
const noopLogger = {
|
|
29
|
+
info: () => { },
|
|
30
|
+
error: () => { },
|
|
31
|
+
};
|
|
32
|
+
const DEFAULT_MOVE_SETTLE_TIMEOUT_MS = 5_000;
|
|
33
|
+
const DEFAULT_MOVE_SETTLE_POLL_MS = 250;
|
|
34
|
+
/**
|
|
35
|
+
* The account resolves no Trash folder, so a move-to-Trash delete has nowhere
|
|
36
|
+
* to put the mail and an Empty Trash has nothing it may empty. A designed,
|
|
37
|
+
* user-facing outcome carrying its own text and a 409 the backend returns
|
|
38
|
+
* verbatim: the absence of a Trash folder is a blocker the user resolves by
|
|
39
|
+
* appointing one, never a licence to expunge.
|
|
40
|
+
*/
|
|
41
|
+
export class NoTrashMailboxError extends FolderRoleUnresolvedError {
|
|
42
|
+
name = "NoTrashMailboxError";
|
|
43
|
+
constructor(accountId) {
|
|
44
|
+
super(NO_TRASH_FOLDER_REASON, CanonicalMailboxRole.Trash, "none", accountId);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The folder the user appointed as Trash is gone from the server. Distinct from
|
|
49
|
+
* having no Trash at all: there is a decision on file, and the repair is to
|
|
50
|
+
* point it at a folder that exists — not to let reader pick one instead.
|
|
51
|
+
*/
|
|
52
|
+
export class StaleTrashAppointmentError extends FolderRoleUnresolvedError {
|
|
53
|
+
name = "StaleTrashAppointmentError";
|
|
54
|
+
constructor(accountId) {
|
|
55
|
+
super(STALE_TRASH_FOLDER_REASON, CanonicalMailboxRole.Trash, "stale", accountId);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* A folder resolves as Trash by its name alone. Enough to move mail into,
|
|
60
|
+
* never enough to expunge — raised by Empty Trash and by a delete that would
|
|
61
|
+
* expunge a message already sitting in that folder.
|
|
62
|
+
*/
|
|
63
|
+
export class UnconfirmedTrashMailboxError extends FolderRoleUnresolvedError {
|
|
64
|
+
name = "UnconfirmedTrashMailboxError";
|
|
65
|
+
constructor(accountId) {
|
|
66
|
+
super(UNCONFIRMED_TRASH_FOLDER_REASON, CanonicalMailboxRole.Trash, "unconfirmed", accountId);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const REFUSAL_BY_REASON = {
|
|
70
|
+
none: NoTrashMailboxError,
|
|
71
|
+
stale: StaleTrashAppointmentError,
|
|
72
|
+
unconfirmed: UnconfirmedTrashMailboxError,
|
|
73
|
+
};
|
|
74
|
+
/** The coded 409 for one refusal reason. The only place a reason becomes an error. */
|
|
75
|
+
const trashRefusal = (reason, accountId) => new REFUSAL_BY_REASON[reason](accountId);
|
|
76
|
+
/**
|
|
77
|
+
* The Trash folder a verb may act on at the assurance it demands, or the coded
|
|
78
|
+
* refusal naming which of the three reasons stopped it. Every Trash gate goes
|
|
79
|
+
* through here — the delete and the expunge — so no two of them can tell a
|
|
80
|
+
* user different things about the same account.
|
|
81
|
+
*/
|
|
82
|
+
export const requireTrashMailbox = (resolution, level, accountId) => {
|
|
83
|
+
const outcome = trashMailboxAt(resolution, level);
|
|
84
|
+
if (outcome.allowed)
|
|
85
|
+
return outcome.mailbox;
|
|
86
|
+
throw trashRefusal(outcome.reason, accountId);
|
|
87
|
+
};
|
|
88
|
+
/**
|
|
89
|
+
* Service for moving, copying, and deleting messages.
|
|
90
|
+
*
|
|
91
|
+
* Implements optimistic local-first pattern:
|
|
92
|
+
* 1. Updates local state immediately (Message + ThreadMessage)
|
|
93
|
+
* 2. Enqueues event to SQS for worker to sync to IMAP
|
|
94
|
+
*
|
|
95
|
+
* Following RFC 016 for message deletion and moving.
|
|
96
|
+
*/
|
|
97
|
+
export class MessageMoveService {
|
|
98
|
+
messageService;
|
|
99
|
+
mailboxService;
|
|
100
|
+
mailboxSpecialUseService;
|
|
101
|
+
threadMessageService;
|
|
102
|
+
addressService;
|
|
103
|
+
queue;
|
|
104
|
+
log;
|
|
105
|
+
moveSettleTimeoutMs;
|
|
106
|
+
moveSettlePollMs;
|
|
107
|
+
constructor(config) {
|
|
108
|
+
this.messageService = config.messageService;
|
|
109
|
+
this.mailboxService = config.mailboxService;
|
|
110
|
+
this.mailboxSpecialUseService = config.mailboxSpecialUseService;
|
|
111
|
+
this.threadMessageService = config.threadMessageService;
|
|
112
|
+
this.addressService = config.addressService;
|
|
113
|
+
this.queue = config.queue;
|
|
114
|
+
this.log = config.logger ?? noopLogger;
|
|
115
|
+
this.moveSettleTimeoutMs =
|
|
116
|
+
config.moveSettleTimeoutMs ?? DEFAULT_MOVE_SETTLE_TIMEOUT_MS;
|
|
117
|
+
this.moveSettlePollMs =
|
|
118
|
+
config.moveSettlePollMs ?? DEFAULT_MOVE_SETTLE_POLL_MS;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Delete a message. By default moves to Trash.
|
|
122
|
+
*
|
|
123
|
+
* @param messageId - Message to delete
|
|
124
|
+
* @param accountId - Account ID
|
|
125
|
+
* @param options - Delete options (toTrash, permanent)
|
|
126
|
+
*/
|
|
127
|
+
deleteMessage = async (accountConfigId, messageId, accountId, options = { toTrash: true }) => this.deleteMessages(accountConfigId, [messageId], accountId, options);
|
|
128
|
+
/**
|
|
129
|
+
* Delete multiple messages using batch operations.
|
|
130
|
+
* By default moves to Trash, unless permanent is specified or already in Trash.
|
|
131
|
+
*
|
|
132
|
+
* @param messageIds - Messages to delete
|
|
133
|
+
* @param accountId - Account ID
|
|
134
|
+
* @param options - Delete options (toTrash, permanent)
|
|
135
|
+
*/
|
|
136
|
+
deleteMessages = async (accountConfigId, messageIds, accountId, options = { toTrash: true }) => {
|
|
137
|
+
if (messageIds.length === 0)
|
|
138
|
+
return { refusedMessageIds: [] };
|
|
139
|
+
// Batch get all messages
|
|
140
|
+
const rows = await this.messageService.get(messageIds);
|
|
141
|
+
if (rows.length === 0)
|
|
142
|
+
return { refusedMessageIds: [] };
|
|
143
|
+
// Ahead of the Trash gates below, which compare `mailboxId` against the
|
|
144
|
+
// Trash folder: an in-flight move makes that comparison answer for a
|
|
145
|
+
// folder the message has not reached.
|
|
146
|
+
const messages = await this.settledPlacements(rows, accountId);
|
|
147
|
+
// Get unique mailbox IDs and batch fetch mailboxes
|
|
148
|
+
const uniqueMailboxIds = [...new Set(messages.map((m) => m.mailboxId))];
|
|
149
|
+
const mailboxes = await this.mailboxService.get(accountId, uniqueMailboxIds);
|
|
150
|
+
const mailboxMap = new Map(mailboxes.map((m) => [m.mailboxId, m]));
|
|
151
|
+
// A permanent delete is only ever what the caller explicitly asked for.
|
|
152
|
+
// Everything else is a move to Trash, and a Trash folder that does not
|
|
153
|
+
// resolve blocks it — the user was asked "move to Trash?" and expunging
|
|
154
|
+
// their mail instead is unrecoverable and answers a question nobody put
|
|
155
|
+
// to them. Thrown before any local write or event, so the server and the
|
|
156
|
+
// projection are both left untouched.
|
|
157
|
+
const wantsPermanentDelete = options.permanent === true || options.toTrash === false;
|
|
158
|
+
const trashResolution = wantsPermanentDelete
|
|
159
|
+
? null
|
|
160
|
+
: await this.mailboxSpecialUseService.resolveTrashRole(accountId);
|
|
161
|
+
const trashGate = trashResolution
|
|
162
|
+
? trashMailboxAt(trashResolution, "resolved")
|
|
163
|
+
: null;
|
|
164
|
+
if (trashGate && !trashGate.allowed) {
|
|
165
|
+
this.log.error({ accountId, messageCount: messages.length, reason: trashGate.reason }, "Refused to delete: this account's Trash is unresolved");
|
|
166
|
+
throw trashRefusal(trashGate.reason, accountId);
|
|
167
|
+
}
|
|
168
|
+
const trashMailbox = trashGate ? trashGate.mailbox : null;
|
|
169
|
+
// A message already sitting in the folder resolved as Trash deletes by
|
|
170
|
+
// expunging it, not by moving it — the same unrecoverable act Empty
|
|
171
|
+
// Trash performs, so it demands the same confirmed evidence. The name
|
|
172
|
+
// guess ("resolved") is enough to file mail into; it is never enough to
|
|
173
|
+
// destroy mail that is already there. Checked once, before any local
|
|
174
|
+
// write, so a batch that touches such a message is refused whole rather
|
|
175
|
+
// than partially expunged.
|
|
176
|
+
if (!wantsPermanentDelete && trashResolution && trashMailbox) {
|
|
177
|
+
const deletesInPlace = messages.some((message) => message.mailboxId === trashMailbox.mailboxId);
|
|
178
|
+
if (deletesInPlace) {
|
|
179
|
+
const confirmedTrashGate = trashMailboxAt(trashResolution, "confirmed");
|
|
180
|
+
if (!confirmedTrashGate.allowed) {
|
|
181
|
+
this.log.error({
|
|
182
|
+
accountId,
|
|
183
|
+
messageCount: messages.length,
|
|
184
|
+
reason: confirmedTrashGate.reason,
|
|
185
|
+
}, "Refused to delete: this account's Trash is not confirmed");
|
|
186
|
+
throw trashRefusal(confirmedTrashGate.reason, accountId);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const moveToTrashMessages = [];
|
|
191
|
+
const permanentDeleteMessages = [];
|
|
192
|
+
// Rows whose placement changed between the read above and the mark below,
|
|
193
|
+
// so this delete never claimed them. Reported rather than skipped in
|
|
194
|
+
// silence: the client removed them optimistically, and a row that
|
|
195
|
+
// reappears with nothing said is the dead-button failure again (#1229).
|
|
196
|
+
const refused = [];
|
|
197
|
+
for (const message of messages) {
|
|
198
|
+
const sourceMailbox = mailboxMap.get(message.mailboxId);
|
|
199
|
+
if (!sourceMailbox) {
|
|
200
|
+
// The folder lookup is scoped to one account, so a selection that
|
|
201
|
+
// spans accounts — the daily brief lists every account's mail —
|
|
202
|
+
// leaves this row's mailbox unresolved. Nothing about it was
|
|
203
|
+
// written and no event was enqueued, so it is a refusal like any
|
|
204
|
+
// other; dropping it silently answered "deleted" over a message
|
|
205
|
+
// still sitting where it was.
|
|
206
|
+
refused.push(message.messageId);
|
|
207
|
+
this.log.info({ accountId, messageId: message.messageId }, "Refused delete: this message's folder does not belong to this account");
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
const isInTrash = trashMailbox !== null && message.mailboxId === trashMailbox.mailboxId;
|
|
211
|
+
const shouldMoveToTrash = trashMailbox !== null && !isInTrash;
|
|
212
|
+
const entry = {
|
|
213
|
+
messageId: message.messageId,
|
|
214
|
+
message: { mailboxId: message.mailboxId, uid: message.uid },
|
|
215
|
+
sourceMailbox: {
|
|
216
|
+
mailboxId: sourceMailbox.mailboxId,
|
|
217
|
+
fullPath: sourceMailbox.fullPath,
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
if (shouldMoveToTrash) {
|
|
221
|
+
moveToTrashMessages.push(entry);
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
permanentDeleteMessages.push(entry);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
// Collect all events for batch SQS send
|
|
228
|
+
const events = [];
|
|
229
|
+
// Process move to trash
|
|
230
|
+
if (trashMailbox && moveToTrashMessages.length > 0) {
|
|
231
|
+
// Every message in the batch shares the config, so its Junk and Trash
|
|
232
|
+
// folders resolve once rather than once per message.
|
|
233
|
+
const configJunkRoles = await this.mailboxSpecialUseService.resolveJunkRolesForConfig(accountConfigId);
|
|
234
|
+
for (const { messageId, message, sourceMailbox } of moveToTrashMessages) {
|
|
235
|
+
// The optimistic write is a transition off the row this call read
|
|
236
|
+
// (imap-mutations R3). Several folder lookups stand between the read
|
|
237
|
+
// and here, and PLACEMENT_MOVE_PUSH runs on a queue this account's
|
|
238
|
+
// FIFO group does not order, so the row can settle onto a different
|
|
239
|
+
// folder and uid in between. Writing anyway would stamp `moving` and
|
|
240
|
+
// the OLD uid over the new placement, and the event enqueued below
|
|
241
|
+
// would name a uid the source no longer holds — a delete aimed at
|
|
242
|
+
// whatever message has since taken that number.
|
|
243
|
+
const marked = await this.messageService.transitionPlacement(messageId, {
|
|
244
|
+
// `active`, not whatever the row was read as: a row already
|
|
245
|
+
// `moving` or `deleting` is mid-mutation, and marking it again
|
|
246
|
+
// would enqueue a second event against the same uid.
|
|
247
|
+
status: MessageStatus.active,
|
|
248
|
+
mailboxId: message.mailboxId,
|
|
249
|
+
uid: message.uid,
|
|
250
|
+
}, {
|
|
251
|
+
mailboxId: trashMailbox.mailboxId,
|
|
252
|
+
status: MessageStatus.moving,
|
|
253
|
+
syncStatus: MessageSyncStatus.pending,
|
|
254
|
+
abandonedMutation: MessageMutation.none,
|
|
255
|
+
originalMailboxId: sourceMailbox.mailboxId,
|
|
256
|
+
originalUid: message.uid,
|
|
257
|
+
});
|
|
258
|
+
if (!marked) {
|
|
259
|
+
refused.push(messageId);
|
|
260
|
+
this.log.info({ accountId, messageId }, "Refused delete: this message's placement changed after it was read");
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
// Update ThreadMessage
|
|
264
|
+
await this.updateThreadMessageForMove(accountConfigId, messageId, trashMailbox.mailboxId, true);
|
|
265
|
+
await this.addressService.reconcileJunkOnlyForMessage(messageId, configJunkRoles);
|
|
266
|
+
events.push({
|
|
267
|
+
event: {
|
|
268
|
+
type: "MESSAGE_DELETE",
|
|
269
|
+
schemaVersion: MUTATION_EVENT_SCHEMA_VERSION,
|
|
270
|
+
eventId: randomUUID(),
|
|
271
|
+
timestamp: Date.now(),
|
|
272
|
+
accountId,
|
|
273
|
+
messageId,
|
|
274
|
+
mailboxId: sourceMailbox.mailboxId,
|
|
275
|
+
mailboxPath: sourceMailbox.fullPath,
|
|
276
|
+
uid: message.uid,
|
|
277
|
+
operation: "move_to_trash",
|
|
278
|
+
destinationMailboxId: trashMailbox.mailboxId,
|
|
279
|
+
destinationMailboxPath: trashMailbox.fullPath,
|
|
280
|
+
},
|
|
281
|
+
handBack: () => this.handBackUnsentMove(accountConfigId, messageId, sourceMailbox.mailboxId, message.uid),
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
this.log.info({
|
|
285
|
+
count: moveToTrashMessages.length,
|
|
286
|
+
trashMailboxId: trashMailbox.mailboxId,
|
|
287
|
+
}, "Moved messages to trash (local)");
|
|
288
|
+
}
|
|
289
|
+
// Process permanent deletes
|
|
290
|
+
for (const { messageId, message, sourceMailbox, } of permanentDeleteMessages) {
|
|
291
|
+
// A transition off the row this call read, like the trash path above
|
|
292
|
+
// (imap-mutations R3). The expunge below is aimed at a specific uid in
|
|
293
|
+
// a specific folder, so a row that has moved since the read must not be
|
|
294
|
+
// marked for it: a restore out of Trash settling in between would be
|
|
295
|
+
// overwritten with `deleting`, and the event would carry the uid Trash
|
|
296
|
+
// no longer holds.
|
|
297
|
+
const marked = await this.messageService.transitionPlacement(messageId, {
|
|
298
|
+
// `active` for the same reason as the trash path above: a second
|
|
299
|
+
// press on a row already `deleting` would enqueue a duplicate
|
|
300
|
+
// expunge rather than refusing.
|
|
301
|
+
status: MessageStatus.active,
|
|
302
|
+
mailboxId: message.mailboxId,
|
|
303
|
+
uid: message.uid,
|
|
304
|
+
}, {
|
|
305
|
+
status: MessageStatus.deleting,
|
|
306
|
+
syncStatus: MessageSyncStatus.pending,
|
|
307
|
+
abandonedMutation: MessageMutation.none,
|
|
308
|
+
});
|
|
309
|
+
if (!marked) {
|
|
310
|
+
refused.push(messageId);
|
|
311
|
+
this.log.info({ accountId, messageId }, "Refused permanent delete: this message's placement changed after it was read");
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
// Delete ThreadMessage rows up-front (one row per mailbox copy).
|
|
315
|
+
// The IMAP worker also deletes them once the IMAP DELETE succeeds —
|
|
316
|
+
// doing it eagerly here closes the visibility window where the inbox
|
|
317
|
+
// list shows a row whose backing Message is being deleted, leading
|
|
318
|
+
// to "Message not found: <id>" on click. See issue #212.
|
|
319
|
+
await this.deleteThreadMessagesForMessage(accountConfigId, messageId);
|
|
320
|
+
events.push({
|
|
321
|
+
event: {
|
|
322
|
+
type: "MESSAGE_DELETE",
|
|
323
|
+
schemaVersion: MUTATION_EVENT_SCHEMA_VERSION,
|
|
324
|
+
eventId: randomUUID(),
|
|
325
|
+
timestamp: Date.now(),
|
|
326
|
+
accountId,
|
|
327
|
+
messageId,
|
|
328
|
+
mailboxId: sourceMailbox.mailboxId,
|
|
329
|
+
mailboxPath: sourceMailbox.fullPath,
|
|
330
|
+
uid: message.uid,
|
|
331
|
+
operation: "permanent_delete",
|
|
332
|
+
},
|
|
333
|
+
// The listing rows went above, so the local removal finishes, as the
|
|
334
|
+
// worker's `handBackDelete` does; the folder's next sync re-projects it.
|
|
335
|
+
handBack: async () => {
|
|
336
|
+
const claimed = await this.messageService.transitionPlacement(messageId, {
|
|
337
|
+
status: MessageStatus.deleting,
|
|
338
|
+
mailboxId: message.mailboxId,
|
|
339
|
+
uid: message.uid,
|
|
340
|
+
}, { status: MessageStatus.deleted });
|
|
341
|
+
if (claimed)
|
|
342
|
+
await this.messageService.delete(messageId);
|
|
343
|
+
},
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
if (permanentDeleteMessages.length > 0) {
|
|
347
|
+
this.log.info({ count: permanentDeleteMessages.length }, "Marked messages for permanent deletion (local)");
|
|
348
|
+
}
|
|
349
|
+
await this.enqueueEventsBatch(events);
|
|
350
|
+
return { refusedMessageIds: refused };
|
|
351
|
+
};
|
|
352
|
+
/**
|
|
353
|
+
* Move a message to another mailbox.
|
|
354
|
+
*
|
|
355
|
+
* Waits, never reconciles (imap-mutations R2): the folder and uid go to the
|
|
356
|
+
* server as one pair, and once the worker has moved the destination's own
|
|
357
|
+
* message at that uid there is no reference left to repair. The person is
|
|
358
|
+
* looking at the result, so blocking for the sub-second a move takes is the
|
|
359
|
+
* cheap half; on timeout the move refuses ahead of any local write.
|
|
360
|
+
*
|
|
361
|
+
* @param messageId - Message to move
|
|
362
|
+
* @param destinationMailboxId - Destination mailbox ID
|
|
363
|
+
* @param accountId - Account ID
|
|
364
|
+
*/
|
|
365
|
+
moveMessage = async (accountConfigId, messageId, destinationMailboxId, accountId) => {
|
|
366
|
+
await this.moveSettledMessage(accountConfigId, await this.settledPlacement(await this.messageService.get(messageId), accountId), destinationMailboxId, accountId);
|
|
367
|
+
};
|
|
368
|
+
moveSettledMessage = async (accountConfigId, message, destinationMailboxId, accountId) => {
|
|
369
|
+
const messageId = message.messageId;
|
|
370
|
+
// IMAP has no same-mailbox MOVE: the server copies the message, expunges
|
|
371
|
+
// the original and hands back a fresh UID, so a request that asks for the
|
|
372
|
+
// mailbox the message is already in destroys its identity to reach the
|
|
373
|
+
// state it was already in. The requested end state holds, so there is
|
|
374
|
+
// nothing to record as pending and nothing to enqueue. The row is settled
|
|
375
|
+
// by the time this compares, so the folder it names is one the message has
|
|
376
|
+
// actually reached rather than one an in-flight move wrote optimistically.
|
|
377
|
+
if (message.mailboxId === destinationMailboxId) {
|
|
378
|
+
this.log.info({ messageId, mailboxId: destinationMailboxId }, "Skipping move: message is already in the destination mailbox");
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
const sourceMailbox = await this.mailboxService.get(accountId, message.mailboxId);
|
|
382
|
+
const destinationMailbox = await this.mailboxService.get(accountId, destinationMailboxId);
|
|
383
|
+
// Check if moving to/from Trash
|
|
384
|
+
const trashMailbox = await this.mailboxSpecialUseService.findTrashMailbox(accountId);
|
|
385
|
+
const isMovingToTrash = Boolean(trashMailbox && destinationMailboxId === trashMailbox.mailboxId);
|
|
386
|
+
const isMovingFromTrash = Boolean(trashMailbox && message.mailboxId === trashMailbox.mailboxId);
|
|
387
|
+
// The optimistic write is a transition off the row this call read
|
|
388
|
+
// (imap-mutations R3). Three mailbox lookups stand between the read and
|
|
389
|
+
// here, and PLACEMENT_MOVE_PUSH runs on a queue this account's FIFO group
|
|
390
|
+
// does not order, so the row can settle onto a different folder and uid in
|
|
391
|
+
// between. A blind write would stamp `moving` and the OLD uid over that
|
|
392
|
+
// placement, leaving a pair `carriesForeignUid` calls consistent, and the
|
|
393
|
+
// event below would send the worker after whatever now holds the old uid.
|
|
394
|
+
const moved = await this.messageService.transitionPlacement(messageId, {
|
|
395
|
+
status: MessageStatus.active,
|
|
396
|
+
mailboxId: message.mailboxId,
|
|
397
|
+
uid: message.uid,
|
|
398
|
+
}, {
|
|
399
|
+
mailboxId: destinationMailboxId,
|
|
400
|
+
status: MessageStatus.moving,
|
|
401
|
+
syncStatus: MessageSyncStatus.pending,
|
|
402
|
+
abandonedMutation: MessageMutation.none,
|
|
403
|
+
originalMailboxId: sourceMailbox.mailboxId,
|
|
404
|
+
originalUid: message.uid,
|
|
405
|
+
});
|
|
406
|
+
if (!moved) {
|
|
407
|
+
this.log.error({ accountId, messageId, destinationMailboxId }, "Refused: this message's placement changed while the move was being prepared");
|
|
408
|
+
throw new MessagePlacementUnsettledError(`Message ${messageId} was not moved: something else changed where it is while this move was being prepared`, accountId, messageId, "in_flight");
|
|
409
|
+
}
|
|
410
|
+
// Update ThreadMessage
|
|
411
|
+
await this.updateThreadMessageForMove(accountConfigId, messageId, destinationMailboxId, isMovingToTrash);
|
|
412
|
+
await this.addressService.reconcileJunkOnlyForMessage(messageId, await this.mailboxSpecialUseService.resolveJunkRolesForConfig(accountConfigId));
|
|
413
|
+
// If moving FROM Trash, clear isDeleted
|
|
414
|
+
if (isMovingFromTrash) {
|
|
415
|
+
await this.updateThreadMessageDeleted(accountConfigId, messageId, false);
|
|
416
|
+
}
|
|
417
|
+
this.log.info({
|
|
418
|
+
messageId,
|
|
419
|
+
from: sourceMailbox.fullPath,
|
|
420
|
+
to: destinationMailbox.fullPath,
|
|
421
|
+
}, "Moved message (local)");
|
|
422
|
+
// Enqueue IMAP sync
|
|
423
|
+
const event = {
|
|
424
|
+
type: "MESSAGE_MOVE",
|
|
425
|
+
eventId: randomUUID(),
|
|
426
|
+
timestamp: Date.now(),
|
|
427
|
+
accountId,
|
|
428
|
+
messageId,
|
|
429
|
+
sourceMailboxId: sourceMailbox.mailboxId,
|
|
430
|
+
sourceMailboxPath: sourceMailbox.fullPath,
|
|
431
|
+
destinationMailboxId,
|
|
432
|
+
destinationMailboxPath: destinationMailbox.fullPath,
|
|
433
|
+
uid: message.uid,
|
|
434
|
+
};
|
|
435
|
+
await this.enqueueEvent(event, () => this.handBackUnsentMove(accountConfigId, messageId, sourceMailbox.mailboxId, message.uid));
|
|
436
|
+
};
|
|
437
|
+
/**
|
|
438
|
+
* Move multiple messages to another mailbox.
|
|
439
|
+
*
|
|
440
|
+
* @param messageIds - Messages to move
|
|
441
|
+
* @param destinationMailboxId - Destination mailbox ID
|
|
442
|
+
* @param accountId - Account ID
|
|
443
|
+
*/
|
|
444
|
+
moveMessages = async (accountConfigId, messageIds, destinationMailboxId, accountId) => {
|
|
445
|
+
// Every row is settled before the first move is written, so the batch's
|
|
446
|
+
// wait is one row's ceiling rather than the sum across it, and a refusal
|
|
447
|
+
// lands before any part of the batch has committed.
|
|
448
|
+
const sources = await this.settledPlacements(await this.messageService.get(messageIds), accountId);
|
|
449
|
+
for (const source of sources) {
|
|
450
|
+
await this.moveSettledMessage(accountConfigId, source, destinationMailboxId, accountId);
|
|
451
|
+
}
|
|
452
|
+
};
|
|
453
|
+
/**
|
|
454
|
+
* Copy a message to another mailbox.
|
|
455
|
+
* Creates a new message record locally and enqueues IMAP COPY.
|
|
456
|
+
*
|
|
457
|
+
* @param messageId - Message to copy
|
|
458
|
+
* @param destinationMailboxId - Destination mailbox ID
|
|
459
|
+
* @param accountId - Account ID
|
|
460
|
+
* @returns The new message ID for the copy
|
|
461
|
+
*/
|
|
462
|
+
copyMessage = async (accountConfigId, messageId, destinationMailboxId, accountId) => {
|
|
463
|
+
return this.copySettledMessage(accountConfigId, await this.settledPlacement(await this.messageService.get(messageId), accountId), destinationMailboxId, accountId);
|
|
464
|
+
};
|
|
465
|
+
copySettledMessage = async (accountConfigId, sourceMessage, destinationMailboxId, accountId) => {
|
|
466
|
+
const messageId = sourceMessage.messageId;
|
|
467
|
+
const sourceMailbox = await this.mailboxService.get(accountId, sourceMessage.mailboxId);
|
|
468
|
+
const destinationMailbox = await this.mailboxService.get(accountId, destinationMailboxId);
|
|
469
|
+
// Deterministic identity for the copy: derived from the source message and
|
|
470
|
+
// the destination mailbox, so a replayed COPY event or a repeated copy
|
|
471
|
+
// resolves to the same row instead of a fresh unreachable duplicate. A
|
|
472
|
+
// random id (the prior behaviour) left rows no sync or delete could reach
|
|
473
|
+
// (issue #75).
|
|
474
|
+
const newMessageId = deriveCopyMessageId(messageId, destinationMailboxId);
|
|
475
|
+
// Upsert, not create: the id is deterministic, so a repeated copy or a
|
|
476
|
+
// retry after a partial failure (row written, event never delivered)
|
|
477
|
+
// re-derives the same id. create throws on the existing row and strands
|
|
478
|
+
// the copy at uid=0 with an undelivered event; upsert no-ops on the row
|
|
479
|
+
// and lets the event be re-enqueued below, keeping the operation
|
|
480
|
+
// idempotent and the copy re-drivable (issue #75).
|
|
481
|
+
const copyRow = await this.messageService.upsertWithStatus({
|
|
482
|
+
messageId: newMessageId,
|
|
483
|
+
mailboxId: destinationMailboxId,
|
|
484
|
+
uid: 0, // Will be updated by worker after IMAP COPY
|
|
485
|
+
sequenceNumber: 0, // Will be updated by worker
|
|
486
|
+
rfc822Size: sourceMessage.rfc822Size,
|
|
487
|
+
internalDate: sourceMessage.internalDate,
|
|
488
|
+
envelopeId: sourceMessage.envelopeId, // Share envelope with source
|
|
489
|
+
rootBodyPartId: sourceMessage.rootBodyPartId, // Share body parts with source
|
|
490
|
+
status: MessageStatus.moving,
|
|
491
|
+
syncStatus: MessageSyncStatus.pending,
|
|
492
|
+
bodyStorageKey: sourceMessage.bodyStorageKey,
|
|
493
|
+
// The copy inherits the source's stored body, so body-sync's skip guard
|
|
494
|
+
// will never re-derive these. Carry them across or the copy is
|
|
495
|
+
// permanently `uncategorized` while its body says it is fully synced
|
|
496
|
+
// (issue #45).
|
|
497
|
+
category: sourceMessage.category,
|
|
498
|
+
// The record of whether that category was the classifier's answer travels
|
|
499
|
+
// with it (issue #331). Defaulting the copy to `NotExamined` would put a
|
|
500
|
+
// row no classifier will ever reach — its body is already stored — into
|
|
501
|
+
// the cohort a backfill selects as never examined.
|
|
502
|
+
classificationState: sourceMessage.classificationState,
|
|
503
|
+
hasListUnsubscribe: sourceMessage.hasListUnsubscribe,
|
|
504
|
+
});
|
|
505
|
+
await this.messageService.copySearchText(messageId, newMessageId);
|
|
506
|
+
// Copy ThreadMessage entry
|
|
507
|
+
const sourceThreadMessage = await this.threadMessageService.getByMessageId(accountConfigId, messageId);
|
|
508
|
+
const copyListing = await this.threadMessageService.create({
|
|
509
|
+
accountConfigId: sourceThreadMessage.accountConfigId,
|
|
510
|
+
threadId: sourceThreadMessage.threadId,
|
|
511
|
+
messageId: newMessageId,
|
|
512
|
+
mailboxId: destinationMailboxId,
|
|
513
|
+
uid: 0, // Will be updated by worker
|
|
514
|
+
messageIdHeader: sourceThreadMessage.messageIdHeader,
|
|
515
|
+
inReplyTo: sourceThreadMessage.inReplyTo,
|
|
516
|
+
referenceOrder: sourceThreadMessage.referenceOrder,
|
|
517
|
+
fromEmail: sourceThreadMessage.fromEmail,
|
|
518
|
+
fromName: sourceThreadMessage.fromName,
|
|
519
|
+
subject: sourceThreadMessage.subject,
|
|
520
|
+
internalDate: sourceThreadMessage.internalDate,
|
|
521
|
+
sentDate: sourceThreadMessage.sentDate,
|
|
522
|
+
isRead: sourceThreadMessage.isRead,
|
|
523
|
+
hasAttachment: sourceThreadMessage.hasAttachment,
|
|
524
|
+
star: sourceThreadMessage.star,
|
|
525
|
+
hasStars: sourceThreadMessage.hasStars,
|
|
526
|
+
isDeleted: false,
|
|
527
|
+
snippet: sourceThreadMessage.snippet,
|
|
528
|
+
category: sourceThreadMessage.category,
|
|
529
|
+
// Same reason as `category` above, and the same source row: the copy
|
|
530
|
+
// inherits a stored body, so no classifying pass ever runs over it to
|
|
531
|
+
// derive a `listId` of its own (issue #331).
|
|
532
|
+
listId: sourceThreadMessage.listId,
|
|
533
|
+
});
|
|
534
|
+
this.log.info({
|
|
535
|
+
sourceMessageId: messageId,
|
|
536
|
+
newMessageId,
|
|
537
|
+
from: sourceMailbox.fullPath,
|
|
538
|
+
to: destinationMailbox.fullPath,
|
|
539
|
+
}, "Created message copy (local)");
|
|
540
|
+
// Enqueue IMAP sync
|
|
541
|
+
const event = {
|
|
542
|
+
type: "MESSAGE_COPY",
|
|
543
|
+
eventId: randomUUID(),
|
|
544
|
+
timestamp: Date.now(),
|
|
545
|
+
accountId,
|
|
546
|
+
sourceMessageId: messageId,
|
|
547
|
+
newMessageId,
|
|
548
|
+
sourceMailboxId: sourceMailbox.mailboxId,
|
|
549
|
+
sourceMailboxPath: sourceMailbox.fullPath,
|
|
550
|
+
destinationMailboxId,
|
|
551
|
+
destinationMailboxPath: destinationMailbox.fullPath,
|
|
552
|
+
uid: sourceMessage.uid,
|
|
553
|
+
};
|
|
554
|
+
await this.enqueueEvent(event, async () => {
|
|
555
|
+
await this.threadMessageService.deleteMany([
|
|
556
|
+
{
|
|
557
|
+
accountConfigId: copyListing.accountConfigId,
|
|
558
|
+
threadMessageId: copyListing.threadMessageId,
|
|
559
|
+
},
|
|
560
|
+
]);
|
|
561
|
+
if (!copyRow.created)
|
|
562
|
+
return;
|
|
563
|
+
await this.messageService.deleteIfPlacement(newMessageId, {
|
|
564
|
+
status: MessageStatus.moving,
|
|
565
|
+
mailboxId: destinationMailboxId,
|
|
566
|
+
uid: 0,
|
|
567
|
+
});
|
|
568
|
+
});
|
|
569
|
+
return newMessageId;
|
|
570
|
+
};
|
|
571
|
+
/**
|
|
572
|
+
* Copy multiple messages to another mailbox.
|
|
573
|
+
*
|
|
574
|
+
* @param messageIds - Messages to copy
|
|
575
|
+
* @param destinationMailboxId - Destination mailbox ID
|
|
576
|
+
* @param accountId - Account ID
|
|
577
|
+
* @returns Array of new message IDs for the copies
|
|
578
|
+
*/
|
|
579
|
+
copyMessages = async (accountConfigId, messageIds, destinationMailboxId, accountId) => {
|
|
580
|
+
// Every row is settled before the first copy is written, so the batch's
|
|
581
|
+
// wait is one row's ceiling rather than the sum across it, and a refusal
|
|
582
|
+
// lands before any part of the batch has committed.
|
|
583
|
+
const sources = await this.settledPlacements(await this.messageService.get(messageIds), accountId);
|
|
584
|
+
const newMessageIds = [];
|
|
585
|
+
for (const source of sources) {
|
|
586
|
+
newMessageIds.push(await this.copySettledMessage(accountConfigId, source, destinationMailboxId, accountId));
|
|
587
|
+
}
|
|
588
|
+
return newMessageIds;
|
|
589
|
+
};
|
|
590
|
+
/**
|
|
591
|
+
* Restore a message from Trash to its original mailbox.
|
|
592
|
+
*
|
|
593
|
+
* @param messageId - Message to restore
|
|
594
|
+
* @param accountId - Account ID
|
|
595
|
+
*/
|
|
596
|
+
restoreMessage = async (accountConfigId, messageId, accountId) => {
|
|
597
|
+
const message = await this.messageService.get(messageId);
|
|
598
|
+
if (!message.originalMailboxId) {
|
|
599
|
+
throw new Error("Message has no original mailbox to restore to");
|
|
600
|
+
}
|
|
601
|
+
await this.moveMessage(accountConfigId, messageId, message.originalMailboxId, accountId);
|
|
602
|
+
};
|
|
603
|
+
/**
|
|
604
|
+
* Empty the Trash mailbox: an EXPUNGE of everything in it, with no undo.
|
|
605
|
+
*
|
|
606
|
+
* Demands confirmed evidence: the folder the user appointed or the one the
|
|
607
|
+
* server flagged \Trash — never one that merely reads like a trash folder,
|
|
608
|
+
* and never the fallback behind an appointment that went missing. A user
|
|
609
|
+
* with an ordinary folder called `Deleted` would otherwise lose its contents
|
|
610
|
+
* permanently. Each of the three ways that evidence can be absent refuses
|
|
611
|
+
* under its own reason, so the surface can name the remedy instead of
|
|
612
|
+
* offering a silent no-op.
|
|
613
|
+
*
|
|
614
|
+
* Reports the rows it marked, read once here, so the number the caller shows
|
|
615
|
+
* a user is the number this operation acted on. Pressed twice before the
|
|
616
|
+
* worker runs it re-marks the same rows and reports the same number: the
|
|
617
|
+
* folder really does still hold them, and the second expunge finds nothing
|
|
618
|
+
* to do.
|
|
619
|
+
*/
|
|
620
|
+
emptyTrash = async (accountConfigId, accountId) => {
|
|
621
|
+
const trashMailbox = requireTrashMailbox(await this.mailboxSpecialUseService.resolveTrashRole(accountId), "confirmed", accountId);
|
|
622
|
+
// The identity the user consented to, carried on the event so the worker
|
|
623
|
+
// can tell this folder from a different one that has since taken over its
|
|
624
|
+
// path. Read here rather than re-read there: the value at consent time is
|
|
625
|
+
// the whole point, and the stored row is rewritten by every sync.
|
|
626
|
+
const { uidValidity: trashUidValidity } = await this.mailboxService.get(accountId, trashMailbox.mailboxId);
|
|
627
|
+
const rows = await this.messageService.listAllByMailbox(trashMailbox.mailboxId);
|
|
628
|
+
// Reconciles, never waits (imap-mutations R2). A row whose move into
|
|
629
|
+
// Trash has not settled names this folder while still carrying the SOURCE
|
|
630
|
+
// folder's uid, and marking it `deleting` overwrites the only marker that
|
|
631
|
+
// says so: its own MESSAGE_MOVE then reads the row as settled and returns
|
|
632
|
+
// without moving anything, and the worker's expunge binds that borrowed
|
|
633
|
+
// uid against whatever Trash really holds at it. Left as it stands, that
|
|
634
|
+
// move settles the row on its own, so the message is in Trash by the time
|
|
635
|
+
// the sweep reaches it and goes with the rest of this same press — only
|
|
636
|
+
// the count below is one short of what the sweep ends up removing.
|
|
637
|
+
//
|
|
638
|
+
// `carriesForeignUid`, the same predicate the sweep applies (#1217).
|
|
639
|
+
// The binding form reads `status` too, and `status` is exactly what this
|
|
640
|
+
// mark is about to overwrite, so the two halves of one gate would have
|
|
641
|
+
// disagreed the moment the mark landed.
|
|
642
|
+
const candidates = rows.filter((message) => !carriesForeignUid(message));
|
|
643
|
+
// Each mark is a transition off the row as it was just read
|
|
644
|
+
// (imap-mutations R3), not a blind write onto a snapshot. The rows are
|
|
645
|
+
// held by nothing between the listing and here, and PLACEMENT_MOVE_PUSH
|
|
646
|
+
// runs on a standard queue that this account's FIFO group does not order,
|
|
647
|
+
// so a row can move out from under the sweep. A lost predicate means
|
|
648
|
+
// another lane won: the row is left alone and not counted, rather than
|
|
649
|
+
// marked `deleting` for an expunge that would bind somebody else's uid.
|
|
650
|
+
const messages = [];
|
|
651
|
+
const undo = [];
|
|
652
|
+
for (const message of candidates) {
|
|
653
|
+
const marked = await this.messageService.transitionPlacement(message.messageId, {
|
|
654
|
+
status: message.status,
|
|
655
|
+
mailboxId: message.mailboxId,
|
|
656
|
+
uid: message.uid,
|
|
657
|
+
}, {
|
|
658
|
+
status: MessageStatus.deleting,
|
|
659
|
+
syncStatus: MessageSyncStatus.pending,
|
|
660
|
+
});
|
|
661
|
+
if (!marked) {
|
|
662
|
+
this.log.info({ accountId, messageId: message.messageId }, "Empty Trash skipped a message whose placement changed while the folder was being read");
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
665
|
+
messages.push(marked);
|
|
666
|
+
const wasDeleted = await this.updateThreadMessageDeleted(accountConfigId, message.messageId, true);
|
|
667
|
+
undo.push(async () => {
|
|
668
|
+
const restored = await this.messageService.transitionPlacement(message.messageId, {
|
|
669
|
+
status: MessageStatus.deleting,
|
|
670
|
+
mailboxId: message.mailboxId,
|
|
671
|
+
uid: message.uid,
|
|
672
|
+
}, { status: message.status, syncStatus: message.syncStatus });
|
|
673
|
+
if (restored && !wasDeleted) {
|
|
674
|
+
await this.updateThreadMessageDeleted(accountConfigId, message.messageId, false);
|
|
675
|
+
}
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
this.log.info({
|
|
679
|
+
accountId,
|
|
680
|
+
trashMailboxId: trashMailbox.mailboxId,
|
|
681
|
+
count: messages.length,
|
|
682
|
+
skipped: rows.length - messages.length,
|
|
683
|
+
}, "Marked all trash messages for deletion (local)");
|
|
684
|
+
// Enqueue single event for worker to handle batch
|
|
685
|
+
const event = {
|
|
686
|
+
type: "EMPTY_TRASH",
|
|
687
|
+
schemaVersion: MUTATION_EVENT_SCHEMA_VERSION,
|
|
688
|
+
eventId: randomUUID(),
|
|
689
|
+
timestamp: Date.now(),
|
|
690
|
+
accountId,
|
|
691
|
+
trashMailboxId: trashMailbox.mailboxId,
|
|
692
|
+
trashMailboxPath: trashMailbox.fullPath,
|
|
693
|
+
trashUidValidity,
|
|
694
|
+
};
|
|
695
|
+
await this.enqueueEvent(event, async () => {
|
|
696
|
+
for (const restore of undo)
|
|
697
|
+
await restore();
|
|
698
|
+
});
|
|
699
|
+
return { deletedCount: messages.length };
|
|
700
|
+
};
|
|
701
|
+
refusePlacement = (message, accountId) => {
|
|
702
|
+
this.log.error({ accountId, messageId: message.messageId }, "Refused: this message's folder and uid do not name the same message");
|
|
703
|
+
throw new MessagePlacementUnsettledError(`Message ${message.messageId} was not acted on: its folder and uid do not name the same message`, accountId, message.messageId, "in_flight");
|
|
704
|
+
};
|
|
705
|
+
settledPlacement = async (message, accountId) => {
|
|
706
|
+
if (placementBindingOf(message) === "consistent")
|
|
707
|
+
return message;
|
|
708
|
+
const settled = await waitForPlacementToSettle(this.messageService, message.messageId, {
|
|
709
|
+
timeoutMs: this.moveSettleTimeoutMs,
|
|
710
|
+
pollMs: this.moveSettlePollMs,
|
|
711
|
+
});
|
|
712
|
+
if (placementBindingOf(settled) === "consistent")
|
|
713
|
+
return settled;
|
|
714
|
+
return this.refusePlacement(settled, accountId);
|
|
715
|
+
};
|
|
716
|
+
/**
|
|
717
|
+
* Settle a whole batch before any of it is acted on, so one unsettled row
|
|
718
|
+
* refuses the batch whole rather than leaving part of it expunged, and the
|
|
719
|
+
* batch's total wait is one row's ceiling rather than the sum across it.
|
|
720
|
+
*/
|
|
721
|
+
settledPlacements = async (messages, accountId) => Promise.all(messages.map((message) => this.settledPlacement(message, accountId)));
|
|
722
|
+
/**
|
|
723
|
+
* Update ThreadMessage for move operations.
|
|
724
|
+
* Updates mailboxId and optionally isDeleted.
|
|
725
|
+
*/
|
|
726
|
+
updateThreadMessageForMove = async (accountConfigId, messageId, newMailboxId, isDeleted) => {
|
|
727
|
+
const threadMessage = await this.threadMessageService.getByMessageId(accountConfigId, messageId);
|
|
728
|
+
this.log.info({
|
|
729
|
+
messageId,
|
|
730
|
+
accountConfigId: threadMessage.accountConfigId,
|
|
731
|
+
threadMessageId: threadMessage.threadMessageId,
|
|
732
|
+
sentDate: threadMessage.sentDate,
|
|
733
|
+
newMailboxId,
|
|
734
|
+
isDeleted,
|
|
735
|
+
}, "Updating ThreadMessage for move");
|
|
736
|
+
await this.threadMessageService.update(threadMessage.accountConfigId, threadMessage.threadMessageId, { mailboxId: newMailboxId, isDeleted }, {
|
|
737
|
+
// Composites contain CURRENT values for condition checking
|
|
738
|
+
// ElectroDB uses these to verify the item state before updating
|
|
739
|
+
composites: {
|
|
740
|
+
mailboxId: threadMessage.mailboxId,
|
|
741
|
+
sentDate: threadMessage.sentDate,
|
|
742
|
+
isRead: threadMessage.isRead,
|
|
743
|
+
isDeleted: threadMessage.isDeleted,
|
|
744
|
+
hasStars: threadMessage.hasStars,
|
|
745
|
+
hasAttachment: threadMessage.hasAttachment,
|
|
746
|
+
},
|
|
747
|
+
});
|
|
748
|
+
this.log.info({
|
|
749
|
+
messageId,
|
|
750
|
+
threadMessageId: threadMessage.threadMessageId,
|
|
751
|
+
newMailboxId,
|
|
752
|
+
isDeleted,
|
|
753
|
+
}, "Updated ThreadMessage for move");
|
|
754
|
+
};
|
|
755
|
+
/**
|
|
756
|
+
* Delete every ThreadMessage row that points at this messageId.
|
|
757
|
+
*
|
|
758
|
+
* A row is keyed by (threadId, messageId), so this is one row in practice —
|
|
759
|
+
* a message filed in several folders keeps a single row, because its
|
|
760
|
+
* messageId is derived from the Message-ID header and not from the mailbox.
|
|
761
|
+
* The query stays a list because the key permits more than one thread per
|
|
762
|
+
* message and nothing enforces otherwise.
|
|
763
|
+
*
|
|
764
|
+
* Deleting up-front in the permanent-delete optimistic step prevents stale
|
|
765
|
+
* rows from leaking into mailbox listings while IMAP catches up (#212).
|
|
766
|
+
*/
|
|
767
|
+
deleteThreadMessagesForMessage = async (accountConfigId, messageId) => {
|
|
768
|
+
const rows = await this.threadMessageService.findAllByMessageId(accountConfigId, messageId);
|
|
769
|
+
for (const row of rows) {
|
|
770
|
+
await this.threadMessageService.delete(row.accountConfigId, row.threadMessageId);
|
|
771
|
+
}
|
|
772
|
+
this.log.info({ messageId, deletedRows: rows.length }, "Deleted ThreadMessage rows for permanent-delete");
|
|
773
|
+
};
|
|
774
|
+
/**
|
|
775
|
+
* Update ThreadMessage.isDeleted flag only.
|
|
776
|
+
*/
|
|
777
|
+
updateThreadMessageDeleted = async (accountConfigId, messageId, isDeleted) => {
|
|
778
|
+
const threadMessage = await this.threadMessageService.getByMessageId(accountConfigId, messageId);
|
|
779
|
+
await this.threadMessageService.update(threadMessage.accountConfigId, threadMessage.threadMessageId, { isDeleted }, {
|
|
780
|
+
// Composites contain CURRENT values for condition checking
|
|
781
|
+
composites: {
|
|
782
|
+
sentDate: threadMessage.sentDate,
|
|
783
|
+
mailboxId: threadMessage.mailboxId,
|
|
784
|
+
isRead: threadMessage.isRead,
|
|
785
|
+
isDeleted: threadMessage.isDeleted,
|
|
786
|
+
hasStars: threadMessage.hasStars,
|
|
787
|
+
hasAttachment: threadMessage.hasAttachment,
|
|
788
|
+
},
|
|
789
|
+
});
|
|
790
|
+
this.log.info({
|
|
791
|
+
messageId,
|
|
792
|
+
threadMessageId: threadMessage.threadMessageId,
|
|
793
|
+
isDeleted,
|
|
794
|
+
}, "Updated ThreadMessage.isDeleted");
|
|
795
|
+
return threadMessage.isDeleted;
|
|
796
|
+
};
|
|
797
|
+
/**
|
|
798
|
+
* Enqueue a message move/delete event, grouped by accountId so events for
|
|
799
|
+
* different accounts can be processed in parallel.
|
|
800
|
+
*/
|
|
801
|
+
enqueueEvent = async (event, handBack) => {
|
|
802
|
+
await this.queue
|
|
803
|
+
.send(queueMessage(event))
|
|
804
|
+
.catch((fault) => handBackThenThrow(fault, [handBack]));
|
|
805
|
+
this.log.info({ eventId: event.eventId, type: event.type }, "Enqueued message event");
|
|
806
|
+
};
|
|
807
|
+
/**
|
|
808
|
+
* Enqueue multiple events in batches of at most {@link QUEUE_BATCH_LIMIT}.
|
|
809
|
+
*/
|
|
810
|
+
enqueueEventsBatch = async (events) => {
|
|
811
|
+
const enqueued = await this.sendEventsBatch(events);
|
|
812
|
+
if (enqueued.outcome === "sent")
|
|
813
|
+
return;
|
|
814
|
+
await handBackThenThrow(enqueued.fault, enqueued.unsent.map(({ handBack }) => handBack));
|
|
815
|
+
};
|
|
816
|
+
// A batch send can succeed while refusing some of its entries.
|
|
817
|
+
sendEventsBatch = async (events) => {
|
|
818
|
+
for (let i = 0; i < events.length; i += QUEUE_BATCH_LIMIT) {
|
|
819
|
+
const batch = events.slice(i, i + QUEUE_BATCH_LIMIT);
|
|
820
|
+
const sent = await this.queue
|
|
821
|
+
.sendBatch(batch.map(({ event }) => queueMessage(event)))
|
|
822
|
+
.then((result) => ({ refused: result.refused }), (fault) => ({ fault }));
|
|
823
|
+
if ("fault" in sent) {
|
|
824
|
+
return {
|
|
825
|
+
outcome: "unsent",
|
|
826
|
+
unsent: events.slice(i),
|
|
827
|
+
fault: sent.fault,
|
|
828
|
+
};
|
|
829
|
+
}
|
|
830
|
+
if (sent.refused.size > 0) {
|
|
831
|
+
const refused = batch.filter((_, idx) => sent.refused.has(idx));
|
|
832
|
+
return {
|
|
833
|
+
outcome: "unsent",
|
|
834
|
+
unsent: [...refused, ...events.slice(i + QUEUE_BATCH_LIMIT)],
|
|
835
|
+
fault: new Error(`The queue refused ${refused.length} of ${events.length} delete event(s)`),
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
if (events.length > 0) {
|
|
840
|
+
this.log.info({ count: events.length }, "Enqueued message events batch");
|
|
841
|
+
}
|
|
842
|
+
return { outcome: "sent" };
|
|
843
|
+
};
|
|
844
|
+
handBackUnsentMove = async (accountConfigId, messageId, sourceMailboxId, uid) => {
|
|
845
|
+
await restoreSourcePlacement({
|
|
846
|
+
messageService: this.messageService,
|
|
847
|
+
threadMessageService: this.threadMessageService,
|
|
848
|
+
}, {
|
|
849
|
+
accountConfigId,
|
|
850
|
+
messageId,
|
|
851
|
+
sourceMailboxId,
|
|
852
|
+
uid,
|
|
853
|
+
syncStatus: MessageSyncStatus.synced,
|
|
854
|
+
abandonedMutation: MessageMutation.none,
|
|
855
|
+
});
|
|
856
|
+
await this.addressService.reconcileJunkOnlyForMessage(messageId, await this.mailboxSpecialUseService.resolveJunkRolesForConfig(accountConfigId));
|
|
857
|
+
};
|
|
858
|
+
}
|