@remit/mailbox-service 0.0.112 → 0.0.113
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 +126 -0
- package/dist/mailbox-queue.js +214 -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 +284 -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,69 @@
|
|
|
1
|
+
import type { FifoQueueProducer, IMessageFlagPushRepository } from "@remit/data-ports";
|
|
2
|
+
/**
|
|
3
|
+
* Event the reconciler (imap-worker `handleFlagPush`) drains. Carries ONLY
|
|
4
|
+
* our own message id + the flag field — never a UID and never the desired
|
|
5
|
+
* operation (that lives on the marker, resolved fresh at push time, epic
|
|
6
|
+
* #1281 invariant 1). The event stays valid across any amount of queue delay
|
|
7
|
+
* or a later local flip of the SAME field (which replaces the marker before
|
|
8
|
+
* this event is ever processed).
|
|
9
|
+
*/
|
|
10
|
+
export interface FlagPushEvent {
|
|
11
|
+
type: "FLAG_PUSH";
|
|
12
|
+
eventId: string;
|
|
13
|
+
timestamp: number;
|
|
14
|
+
accountId: string;
|
|
15
|
+
accountConfigId: string;
|
|
16
|
+
messageId: string;
|
|
17
|
+
flagName: string;
|
|
18
|
+
}
|
|
19
|
+
export interface FlagPushLogger {
|
|
20
|
+
info(obj: Record<string, unknown>, msg: string): void;
|
|
21
|
+
error(obj: Record<string, unknown>, msg: string): void;
|
|
22
|
+
}
|
|
23
|
+
export interface FlagPushConfig {
|
|
24
|
+
markerService: IMessageFlagPushRepository;
|
|
25
|
+
queue: FifoQueueProducer<FlagPushEvent>;
|
|
26
|
+
logger?: FlagPushLogger;
|
|
27
|
+
}
|
|
28
|
+
export type FlagPushOperationValue = "add" | "remove";
|
|
29
|
+
/**
|
|
30
|
+
* Writes a pending flag-push marker and best-effort enqueues a wake-up hint
|
|
31
|
+
* (issue #1273, epic #1281). Called AFTER the caller (`FlagQueueService`) has
|
|
32
|
+
* already durably written the local `MessageFlag`/`ThreadMessage` state in
|
|
33
|
+
* the SAME request — the user's intent is durable the moment `flip` writes
|
|
34
|
+
* the marker, before the enqueue even runs.
|
|
35
|
+
*
|
|
36
|
+
* Mirrors `PlacementMoveService` (#1289)'s marker lifecycle, scoped per
|
|
37
|
+
* (`messageId`, `flagName`) instead of per `messageId` — a message can carry
|
|
38
|
+
* an independent pending read-state marker and pending star marker at once
|
|
39
|
+
* (epic FAQ: "a flag flip replaces a pending flag flip", scoped per field).
|
|
40
|
+
* `put` (not `create`) always resets the marker to `pending`, so a later flip
|
|
41
|
+
* of the SAME field simply replaces the earlier marker — "later intent wins
|
|
42
|
+
* locally".
|
|
43
|
+
*
|
|
44
|
+
* Unlike #1289's placement move (where an enqueue failure must propagate,
|
|
45
|
+
* because nothing besides the marker+enqueue sequence has committed the
|
|
46
|
+
* user's intent), the flag flip here is durable the instant the marker write
|
|
47
|
+
* lands — the enqueue is purely a latency optimization so the push does not
|
|
48
|
+
* have to wait for the next periodic sync tick. An enqueue failure is
|
|
49
|
+
* therefore swallowed on purpose: it must never fail the caller's request.
|
|
50
|
+
* It must also never be silent — logged with an `alert`-shaped entry so a
|
|
51
|
+
* string of failures (SQS itself unhealthy) is operationally visible, same
|
|
52
|
+
* convention as the enqueue this replaces (`flag-queue.ts`'s prior
|
|
53
|
+
* `flag_sync_enqueue_failed`).
|
|
54
|
+
*/
|
|
55
|
+
export declare class FlagPushService {
|
|
56
|
+
private markerService;
|
|
57
|
+
private queue;
|
|
58
|
+
private log;
|
|
59
|
+
constructor(config: FlagPushConfig);
|
|
60
|
+
flip: (params: {
|
|
61
|
+
accountId: string;
|
|
62
|
+
accountConfigId: string;
|
|
63
|
+
messageId: string;
|
|
64
|
+
mailboxId: string;
|
|
65
|
+
flagName: string;
|
|
66
|
+
operation: FlagPushOperationValue;
|
|
67
|
+
}) => Promise<void>;
|
|
68
|
+
private enqueueHint;
|
|
69
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
const noopLogger = {
|
|
3
|
+
info: () => { },
|
|
4
|
+
error: () => { },
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* Writes a pending flag-push marker and best-effort enqueues a wake-up hint
|
|
8
|
+
* (issue #1273, epic #1281). Called AFTER the caller (`FlagQueueService`) has
|
|
9
|
+
* already durably written the local `MessageFlag`/`ThreadMessage` state in
|
|
10
|
+
* the SAME request — the user's intent is durable the moment `flip` writes
|
|
11
|
+
* the marker, before the enqueue even runs.
|
|
12
|
+
*
|
|
13
|
+
* Mirrors `PlacementMoveService` (#1289)'s marker lifecycle, scoped per
|
|
14
|
+
* (`messageId`, `flagName`) instead of per `messageId` — a message can carry
|
|
15
|
+
* an independent pending read-state marker and pending star marker at once
|
|
16
|
+
* (epic FAQ: "a flag flip replaces a pending flag flip", scoped per field).
|
|
17
|
+
* `put` (not `create`) always resets the marker to `pending`, so a later flip
|
|
18
|
+
* of the SAME field simply replaces the earlier marker — "later intent wins
|
|
19
|
+
* locally".
|
|
20
|
+
*
|
|
21
|
+
* Unlike #1289's placement move (where an enqueue failure must propagate,
|
|
22
|
+
* because nothing besides the marker+enqueue sequence has committed the
|
|
23
|
+
* user's intent), the flag flip here is durable the instant the marker write
|
|
24
|
+
* lands — the enqueue is purely a latency optimization so the push does not
|
|
25
|
+
* have to wait for the next periodic sync tick. An enqueue failure is
|
|
26
|
+
* therefore swallowed on purpose: it must never fail the caller's request.
|
|
27
|
+
* It must also never be silent — logged with an `alert`-shaped entry so a
|
|
28
|
+
* string of failures (SQS itself unhealthy) is operationally visible, same
|
|
29
|
+
* convention as the enqueue this replaces (`flag-queue.ts`'s prior
|
|
30
|
+
* `flag_sync_enqueue_failed`).
|
|
31
|
+
*/
|
|
32
|
+
export class FlagPushService {
|
|
33
|
+
markerService;
|
|
34
|
+
queue;
|
|
35
|
+
log;
|
|
36
|
+
constructor(config) {
|
|
37
|
+
this.markerService = config.markerService;
|
|
38
|
+
this.queue = config.queue;
|
|
39
|
+
this.log = config.logger ?? noopLogger;
|
|
40
|
+
}
|
|
41
|
+
flip = async (params) => {
|
|
42
|
+
const { accountId, accountConfigId, messageId, mailboxId, flagName, operation, } = params;
|
|
43
|
+
await this.markerService.put({
|
|
44
|
+
messageId,
|
|
45
|
+
flagName,
|
|
46
|
+
accountId,
|
|
47
|
+
accountConfigId,
|
|
48
|
+
mailboxId,
|
|
49
|
+
operation,
|
|
50
|
+
});
|
|
51
|
+
await this.enqueueHint({ accountId, accountConfigId, messageId, flagName });
|
|
52
|
+
};
|
|
53
|
+
enqueueHint = async (params) => {
|
|
54
|
+
const event = {
|
|
55
|
+
type: "FLAG_PUSH",
|
|
56
|
+
eventId: randomUUID(),
|
|
57
|
+
timestamp: Date.now(),
|
|
58
|
+
...params,
|
|
59
|
+
};
|
|
60
|
+
// The marker persisted in `flip` is the durable record — the queue,
|
|
61
|
+
// per the issue's own words. This send is only a wake-up hint: a
|
|
62
|
+
// queue-down failure must NOT fail the caller's flip (intent is
|
|
63
|
+
// already durable) and must NOT be silent either (standing rule: our
|
|
64
|
+
// infra failing is never a quiet blip). The periodic per-mailbox
|
|
65
|
+
// drain (issue #1273) still finds and pushes the marker regardless.
|
|
66
|
+
await this.queue
|
|
67
|
+
.send({
|
|
68
|
+
body: event,
|
|
69
|
+
groupId: params.accountId,
|
|
70
|
+
deduplicationId: event.eventId,
|
|
71
|
+
})
|
|
72
|
+
.then(async () => {
|
|
73
|
+
// Only advance past `pending` once the hint is CONFIRMED sent — a
|
|
74
|
+
// marker still `pending` is exactly the periodic drain's query
|
|
75
|
+
// target (`sync-messages.ts`'s `drainPendingFlagPushes`), so a
|
|
76
|
+
// failed send below must leave it there, never here.
|
|
77
|
+
await this.markerService
|
|
78
|
+
.updateState(params.messageId, params.flagName, "queued")
|
|
79
|
+
.catch(async (error) => {
|
|
80
|
+
// Fast-path race (review finding on #1292): the worker can
|
|
81
|
+
// drain AND delete the marker before this transition runs —
|
|
82
|
+
// SQS delivery + processing can outrun this .then(). The
|
|
83
|
+
// enqueue itself already succeeded, so re-check whether the
|
|
84
|
+
// marker still exists before treating this as an infra
|
|
85
|
+
// failure: if it's already gone, the push already completed
|
|
86
|
+
// and this is expected, routine — never the
|
|
87
|
+
// flag_push_hint_enqueue_failed alarm shape. Any OTHER
|
|
88
|
+
// failure (e.g. the re-check itself fails because the
|
|
89
|
+
// backend is genuinely down) still propagates to the outer
|
|
90
|
+
// catch below.
|
|
91
|
+
const stillPending = await this.markerService.find(params.messageId, params.flagName);
|
|
92
|
+
if (!stillPending) {
|
|
93
|
+
this.log.info({ eventId: event.eventId, ...params }, "Marker already drained before the queued-state transition (fast-path race, harmless)");
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
throw error;
|
|
97
|
+
});
|
|
98
|
+
this.log.info({ eventId: event.eventId, ...params }, "Enqueued FLAG_PUSH wake-up hint");
|
|
99
|
+
})
|
|
100
|
+
.catch((error) => {
|
|
101
|
+
this.log.error({
|
|
102
|
+
alert: "flag_push_hint_enqueue_failed",
|
|
103
|
+
eventId: event.eventId,
|
|
104
|
+
...params,
|
|
105
|
+
errorName: error?.name,
|
|
106
|
+
errorCode: error?.Code ??
|
|
107
|
+
error?.code,
|
|
108
|
+
}, "Failed to enqueue FLAG_PUSH wake-up hint (marker persisted; the periodic sync tick will still drain it)");
|
|
109
|
+
});
|
|
110
|
+
};
|
|
111
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import type { IMessageFlagRepository, IMessageRepository, IThreadMessageRepository } from "@remit/data-ports";
|
|
2
|
+
import { StarColor } from "@remit/domain-enums";
|
|
3
|
+
import type { FlagPushService } from "./flag-push.js";
|
|
4
|
+
/**
|
|
5
|
+
* StarColor type derived from the StarColor const object
|
|
6
|
+
*/
|
|
7
|
+
type StarColorValue = (typeof StarColor)[keyof typeof StarColor];
|
|
8
|
+
/**
|
|
9
|
+
* Logger interface
|
|
10
|
+
*/
|
|
11
|
+
export interface FlagQueueLogger {
|
|
12
|
+
info(obj: Record<string, unknown>, msg: string): void;
|
|
13
|
+
error(obj: Record<string, unknown>, msg: string): void;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Input for updateFlags API method
|
|
17
|
+
*/
|
|
18
|
+
export interface UpdateFlagsInput {
|
|
19
|
+
isRead?: boolean;
|
|
20
|
+
isStarred?: boolean;
|
|
21
|
+
starColor?: StarColorValue;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Result of updateFlags API method
|
|
25
|
+
*/
|
|
26
|
+
export interface UpdateFlagsResult {
|
|
27
|
+
messageId: string;
|
|
28
|
+
isRead: boolean;
|
|
29
|
+
isStarred: boolean;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Configuration for FlagQueueService
|
|
33
|
+
*/
|
|
34
|
+
export interface FlagQueueConfig {
|
|
35
|
+
messageFlagService: IMessageFlagRepository;
|
|
36
|
+
messageService: IMessageRepository;
|
|
37
|
+
threadMessageService: IThreadMessageRepository;
|
|
38
|
+
flagPushService: FlagPushService;
|
|
39
|
+
logger?: FlagQueueLogger;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Service for marking messages as read/unread/starred, local-first with a
|
|
43
|
+
* durable pending-flag marker (issue #1273, epic #1281).
|
|
44
|
+
*
|
|
45
|
+
* Every flip below follows the SAME sequence: persist the pending marker
|
|
46
|
+
* FIRST via {@link FlagPushService.flip} (the durable record IMAP still owes
|
|
47
|
+
* this push, and best-effort enqueues a wake-up hint), THEN apply locally
|
|
48
|
+
* (MessageFlag + ThreadMessage). The marker write is what makes the user's
|
|
49
|
+
* intent durable — not the enqueue, which may fail freely (see
|
|
50
|
+
* `FlagPushService`'s own doc) — and writing it first means a crash before
|
|
51
|
+
* the local write leaves an unapplied local flip behind an already-durable
|
|
52
|
+
* marker, never a flipped flag with no marker (review finding on #1292;
|
|
53
|
+
* mirrors #1289's `PlacementMoveService.moveMessage` ordering). This class
|
|
54
|
+
* therefore no longer touches
|
|
55
|
+
* `Mailbox.unseenCount` at all: that field is a pure projection, recomputed
|
|
56
|
+
* only from IMAP (`doc/rules/data-flow.md`); the displayed count is adjusted
|
|
57
|
+
* at READ TIME from pending markers (`applyPendingMoveCountPrediction`,
|
|
58
|
+
* `packages/backend/src/derive/pendingMoveCounts.ts`).
|
|
59
|
+
*
|
|
60
|
+
* The service updates BOTH entities locally:
|
|
61
|
+
* - MessageFlag: The canonical flag record
|
|
62
|
+
* - ThreadMessage.isRead / hasStars / star: Denormalized for efficient queries
|
|
63
|
+
*/
|
|
64
|
+
export declare class FlagQueueService {
|
|
65
|
+
private messageFlagService;
|
|
66
|
+
private messageService;
|
|
67
|
+
private threadMessageService;
|
|
68
|
+
private flagPushService;
|
|
69
|
+
private log;
|
|
70
|
+
constructor(config: FlagQueueConfig);
|
|
71
|
+
/**
|
|
72
|
+
* Update ThreadMessage.isRead for ALL ThreadMessages matching this messageId.
|
|
73
|
+
*
|
|
74
|
+
* A message can exist in multiple mailboxes (e.g., inbox and archive), so we
|
|
75
|
+
* must update all instances to keep the isRead status consistent.
|
|
76
|
+
*
|
|
77
|
+
* The `composites` map carries the CURRENT values of every attribute that
|
|
78
|
+
* participates in a sort key. ElectroDB uses them for the conditional check
|
|
79
|
+
* on the existing row and combines them with `set()` to recompute the new
|
|
80
|
+
* sort keys. Passing the NEW `isRead` value here would make the conditional
|
|
81
|
+
* check fail (existing row still has the old value) and the patch would be
|
|
82
|
+
* misreported as NotFoundError, silently dropping the update.
|
|
83
|
+
*
|
|
84
|
+
* Handles race condition where ThreadMessage may be deleted between find and update.
|
|
85
|
+
*/
|
|
86
|
+
private updateThreadMessageIsRead;
|
|
87
|
+
/**
|
|
88
|
+
* Update ThreadMessage.hasStars and star color for ALL ThreadMessages matching this messageId.
|
|
89
|
+
*
|
|
90
|
+
* A message can exist in multiple mailboxes (e.g., inbox and archive), so we
|
|
91
|
+
* must update all instances to keep the star status consistent.
|
|
92
|
+
*
|
|
93
|
+
* Handles race condition where ThreadMessage may be deleted between find and update.
|
|
94
|
+
*/
|
|
95
|
+
private updateThreadMessageStars;
|
|
96
|
+
/**
|
|
97
|
+
* Flip one flag field: persist the pending marker FIRST, then apply
|
|
98
|
+
* locally. Every caller in this class routes through here so the marker
|
|
99
|
+
* write is never skipped. Returns whether a change actually happened —
|
|
100
|
+
* `false` for a redundant flip (already in the desired state), which
|
|
101
|
+
* every caller uses to skip the (otherwise redundant) ThreadMessage
|
|
102
|
+
* update too.
|
|
103
|
+
*
|
|
104
|
+
* Ordering matters (review finding on #1292): a marker written AFTER the
|
|
105
|
+
* local `MessageFlag` flip means a crash between the two awaits strands a
|
|
106
|
+
* flipped flag with no marker — no hint was ever sent, the pending-only
|
|
107
|
+
* drain never finds it (no marker to find), and message-sync never
|
|
108
|
+
* rewrites `MessageFlag` from IMAP for an existing row, so nothing else
|
|
109
|
+
* reconciles it either. That is a permanent, silent divergence — exactly
|
|
110
|
+
* the defect #1273 exists to kill. Writing the marker first (matching
|
|
111
|
+
* #1289's `PlacementMoveService.moveMessage` ordering) makes that
|
|
112
|
+
* scenario impossible: the marker's existence is the record of how far
|
|
113
|
+
* the sequence got, so a crash after it can strand at worst an
|
|
114
|
+
* un-applied LOCAL write behind an already-durable marker — recoverable
|
|
115
|
+
* (the caller's natural retry re-applies both steps; `put` and
|
|
116
|
+
* `addFlag`/`removeFlag` are both idempotent) — never the reverse.
|
|
117
|
+
*
|
|
118
|
+
* The current-state check (review finding on #1292) matters for a
|
|
119
|
+
* different reason: `markAsRead`/`markAsUnread`/`updateFlags` take the
|
|
120
|
+
* DESIRED boolean from the caller rather than deriving it from current
|
|
121
|
+
* state (unlike `toggleFlagged`, which already reads `hasFlag` first and
|
|
122
|
+
* so can never generate a redundant marker). A redundant "mark as read"
|
|
123
|
+
* on an already-read message would otherwise still write a fresh `add
|
|
124
|
+
* \Seen` marker; the read-time unseenCount prediction
|
|
125
|
+
* (`applyPendingMoveCountPrediction`) would then subtract one for a
|
|
126
|
+
* message IMAP already counts as seen, transiently under-counting the
|
|
127
|
+
* badge until the marker clears. Skipping the marker (and the local
|
|
128
|
+
* write) entirely when the flag already matches removes the false
|
|
129
|
+
* prediction at the source, not just its symptom.
|
|
130
|
+
*/
|
|
131
|
+
private flipFlag;
|
|
132
|
+
/**
|
|
133
|
+
* Mark a message as read (add \Seen flag).
|
|
134
|
+
* Updates MessageFlag, ThreadMessage.isRead, and persists a pending
|
|
135
|
+
* flag-push marker for IMAP sync.
|
|
136
|
+
*
|
|
137
|
+
* @param accountConfigId - The owning account config (tenant scope)
|
|
138
|
+
* @param messageId - The message to mark as read
|
|
139
|
+
* @param accountId - The account ID for the IMAP sync event
|
|
140
|
+
*/
|
|
141
|
+
markAsRead: (accountConfigId: string, messageId: string, accountId: string) => Promise<void>;
|
|
142
|
+
/**
|
|
143
|
+
* Mark a message as unread (remove \Seen flag).
|
|
144
|
+
* Updates MessageFlag, ThreadMessage.isRead, and persists a pending
|
|
145
|
+
* flag-push marker for IMAP sync.
|
|
146
|
+
*
|
|
147
|
+
* @param accountConfigId - The owning account config (tenant scope)
|
|
148
|
+
* @param messageId - The message to mark as unread
|
|
149
|
+
* @param accountId - The account ID for the IMAP sync event
|
|
150
|
+
*/
|
|
151
|
+
markAsUnread: (accountConfigId: string, messageId: string, accountId: string) => Promise<void>;
|
|
152
|
+
/**
|
|
153
|
+
* Toggle the starred/flagged status of a message.
|
|
154
|
+
* Updates local state and persists a pending flag-push marker for IMAP
|
|
155
|
+
* sync.
|
|
156
|
+
*
|
|
157
|
+
* @param accountConfigId - The owning account config (tenant scope)
|
|
158
|
+
* @param messageId - The message to toggle
|
|
159
|
+
* @param accountId - The account ID for the IMAP sync event
|
|
160
|
+
* @returns true if flag was added, false if removed
|
|
161
|
+
*/
|
|
162
|
+
toggleFlagged: (accountConfigId: string, messageId: string, accountId: string) => Promise<boolean>;
|
|
163
|
+
/**
|
|
164
|
+
* Update message flags using API-friendly input format.
|
|
165
|
+
* Maps isRead/isStarred to IMAP flags and updates ThreadMessage accordingly.
|
|
166
|
+
*
|
|
167
|
+
* @param accountConfigId - The owning account config (tenant scope)
|
|
168
|
+
* @param messageId - The message to update
|
|
169
|
+
* @param accountId - The account ID for the IMAP sync event
|
|
170
|
+
* @param input - The flag updates to apply
|
|
171
|
+
* @returns The current flag state after updates
|
|
172
|
+
*/
|
|
173
|
+
updateFlags: (accountConfigId: string, messageId: string, accountId: string, input: UpdateFlagsInput) => Promise<UpdateFlagsResult>;
|
|
174
|
+
}
|
|
175
|
+
export {};
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { NotFoundError } from "@remit/data-ports/errors";
|
|
2
|
+
import { MessageSystemFlag, StarColor } from "@remit/domain-enums";
|
|
3
|
+
const noopLogger = {
|
|
4
|
+
info: () => { },
|
|
5
|
+
error: () => { },
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* Service for marking messages as read/unread/starred, local-first with a
|
|
9
|
+
* durable pending-flag marker (issue #1273, epic #1281).
|
|
10
|
+
*
|
|
11
|
+
* Every flip below follows the SAME sequence: persist the pending marker
|
|
12
|
+
* FIRST via {@link FlagPushService.flip} (the durable record IMAP still owes
|
|
13
|
+
* this push, and best-effort enqueues a wake-up hint), THEN apply locally
|
|
14
|
+
* (MessageFlag + ThreadMessage). The marker write is what makes the user's
|
|
15
|
+
* intent durable — not the enqueue, which may fail freely (see
|
|
16
|
+
* `FlagPushService`'s own doc) — and writing it first means a crash before
|
|
17
|
+
* the local write leaves an unapplied local flip behind an already-durable
|
|
18
|
+
* marker, never a flipped flag with no marker (review finding on #1292;
|
|
19
|
+
* mirrors #1289's `PlacementMoveService.moveMessage` ordering). This class
|
|
20
|
+
* therefore no longer touches
|
|
21
|
+
* `Mailbox.unseenCount` at all: that field is a pure projection, recomputed
|
|
22
|
+
* only from IMAP (`doc/rules/data-flow.md`); the displayed count is adjusted
|
|
23
|
+
* at READ TIME from pending markers (`applyPendingMoveCountPrediction`,
|
|
24
|
+
* `packages/backend/src/derive/pendingMoveCounts.ts`).
|
|
25
|
+
*
|
|
26
|
+
* The service updates BOTH entities locally:
|
|
27
|
+
* - MessageFlag: The canonical flag record
|
|
28
|
+
* - ThreadMessage.isRead / hasStars / star: Denormalized for efficient queries
|
|
29
|
+
*/
|
|
30
|
+
export class FlagQueueService {
|
|
31
|
+
messageFlagService;
|
|
32
|
+
messageService;
|
|
33
|
+
threadMessageService;
|
|
34
|
+
flagPushService;
|
|
35
|
+
log;
|
|
36
|
+
constructor(config) {
|
|
37
|
+
const { messageFlagService, messageService, threadMessageService, flagPushService, } = config;
|
|
38
|
+
this.messageFlagService = messageFlagService;
|
|
39
|
+
this.messageService = messageService;
|
|
40
|
+
this.threadMessageService = threadMessageService;
|
|
41
|
+
this.flagPushService = flagPushService;
|
|
42
|
+
this.log = config.logger ?? noopLogger;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Update ThreadMessage.isRead for ALL ThreadMessages matching this messageId.
|
|
46
|
+
*
|
|
47
|
+
* A message can exist in multiple mailboxes (e.g., inbox and archive), so we
|
|
48
|
+
* must update all instances to keep the isRead status consistent.
|
|
49
|
+
*
|
|
50
|
+
* The `composites` map carries the CURRENT values of every attribute that
|
|
51
|
+
* participates in a sort key. ElectroDB uses them for the conditional check
|
|
52
|
+
* on the existing row and combines them with `set()` to recompute the new
|
|
53
|
+
* sort keys. Passing the NEW `isRead` value here would make the conditional
|
|
54
|
+
* check fail (existing row still has the old value) and the patch would be
|
|
55
|
+
* misreported as NotFoundError, silently dropping the update.
|
|
56
|
+
*
|
|
57
|
+
* Handles race condition where ThreadMessage may be deleted between find and update.
|
|
58
|
+
*/
|
|
59
|
+
updateThreadMessageIsRead = async (accountConfigId, messageId, isRead) => {
|
|
60
|
+
const threadMessages = await this.threadMessageService.findAllByMessageId(accountConfigId, messageId);
|
|
61
|
+
if (threadMessages.length === 0) {
|
|
62
|
+
this.log.info({ messageId }, "ThreadMessage not found for messageId - skipping isRead update");
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
for (const threadMessage of threadMessages) {
|
|
66
|
+
try {
|
|
67
|
+
await this.threadMessageService.update(threadMessage.accountConfigId, threadMessage.threadMessageId, { isRead }, {
|
|
68
|
+
composites: {
|
|
69
|
+
sentDate: threadMessage.sentDate,
|
|
70
|
+
mailboxId: threadMessage.mailboxId,
|
|
71
|
+
isRead: threadMessage.isRead,
|
|
72
|
+
isDeleted: threadMessage.isDeleted,
|
|
73
|
+
hasStars: threadMessage.hasStars,
|
|
74
|
+
hasAttachment: threadMessage.hasAttachment,
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
this.log.info({ messageId, threadMessageId: threadMessage.threadMessageId, isRead }, "Updated ThreadMessage.isRead");
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
if (err instanceof NotFoundError) {
|
|
81
|
+
this.log.info({ messageId, threadMessageId: threadMessage.threadMessageId }, "ThreadMessage deleted during update - skipping isRead update");
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
throw err;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
/**
|
|
89
|
+
* Update ThreadMessage.hasStars and star color for ALL ThreadMessages matching this messageId.
|
|
90
|
+
*
|
|
91
|
+
* A message can exist in multiple mailboxes (e.g., inbox and archive), so we
|
|
92
|
+
* must update all instances to keep the star status consistent.
|
|
93
|
+
*
|
|
94
|
+
* Handles race condition where ThreadMessage may be deleted between find and update.
|
|
95
|
+
*/
|
|
96
|
+
updateThreadMessageStars = async (accountConfigId, messageId, updates) => {
|
|
97
|
+
if (Object.keys(updates).length === 0)
|
|
98
|
+
return;
|
|
99
|
+
const threadMessages = await this.threadMessageService.findAllByMessageId(accountConfigId, messageId);
|
|
100
|
+
if (threadMessages.length === 0) {
|
|
101
|
+
this.log.info({ messageId }, "ThreadMessage not found for messageId - skipping star update");
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
for (const threadMessage of threadMessages) {
|
|
105
|
+
try {
|
|
106
|
+
await this.threadMessageService.update(threadMessage.accountConfigId, threadMessage.threadMessageId, updates, {
|
|
107
|
+
composites: {
|
|
108
|
+
sentDate: threadMessage.sentDate,
|
|
109
|
+
mailboxId: threadMessage.mailboxId,
|
|
110
|
+
isRead: threadMessage.isRead,
|
|
111
|
+
isDeleted: threadMessage.isDeleted,
|
|
112
|
+
hasStars: threadMessage.hasStars,
|
|
113
|
+
hasAttachment: threadMessage.hasAttachment,
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
this.log.info({
|
|
117
|
+
messageId,
|
|
118
|
+
threadMessageId: threadMessage.threadMessageId,
|
|
119
|
+
updates,
|
|
120
|
+
}, "Updated ThreadMessage stars");
|
|
121
|
+
}
|
|
122
|
+
catch (err) {
|
|
123
|
+
if (err instanceof NotFoundError) {
|
|
124
|
+
this.log.info({ messageId, threadMessageId: threadMessage.threadMessageId }, "ThreadMessage deleted during update - skipping star update");
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
throw err;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
/**
|
|
132
|
+
* Flip one flag field: persist the pending marker FIRST, then apply
|
|
133
|
+
* locally. Every caller in this class routes through here so the marker
|
|
134
|
+
* write is never skipped. Returns whether a change actually happened —
|
|
135
|
+
* `false` for a redundant flip (already in the desired state), which
|
|
136
|
+
* every caller uses to skip the (otherwise redundant) ThreadMessage
|
|
137
|
+
* update too.
|
|
138
|
+
*
|
|
139
|
+
* Ordering matters (review finding on #1292): a marker written AFTER the
|
|
140
|
+
* local `MessageFlag` flip means a crash between the two awaits strands a
|
|
141
|
+
* flipped flag with no marker — no hint was ever sent, the pending-only
|
|
142
|
+
* drain never finds it (no marker to find), and message-sync never
|
|
143
|
+
* rewrites `MessageFlag` from IMAP for an existing row, so nothing else
|
|
144
|
+
* reconciles it either. That is a permanent, silent divergence — exactly
|
|
145
|
+
* the defect #1273 exists to kill. Writing the marker first (matching
|
|
146
|
+
* #1289's `PlacementMoveService.moveMessage` ordering) makes that
|
|
147
|
+
* scenario impossible: the marker's existence is the record of how far
|
|
148
|
+
* the sequence got, so a crash after it can strand at worst an
|
|
149
|
+
* un-applied LOCAL write behind an already-durable marker — recoverable
|
|
150
|
+
* (the caller's natural retry re-applies both steps; `put` and
|
|
151
|
+
* `addFlag`/`removeFlag` are both idempotent) — never the reverse.
|
|
152
|
+
*
|
|
153
|
+
* The current-state check (review finding on #1292) matters for a
|
|
154
|
+
* different reason: `markAsRead`/`markAsUnread`/`updateFlags` take the
|
|
155
|
+
* DESIRED boolean from the caller rather than deriving it from current
|
|
156
|
+
* state (unlike `toggleFlagged`, which already reads `hasFlag` first and
|
|
157
|
+
* so can never generate a redundant marker). A redundant "mark as read"
|
|
158
|
+
* on an already-read message would otherwise still write a fresh `add
|
|
159
|
+
* \Seen` marker; the read-time unseenCount prediction
|
|
160
|
+
* (`applyPendingMoveCountPrediction`) would then subtract one for a
|
|
161
|
+
* message IMAP already counts as seen, transiently under-counting the
|
|
162
|
+
* badge until the marker clears. Skipping the marker (and the local
|
|
163
|
+
* write) entirely when the flag already matches removes the false
|
|
164
|
+
* prediction at the source, not just its symptom.
|
|
165
|
+
*/
|
|
166
|
+
flipFlag = async (accountId, accountConfigId, messageId, mailboxId, flagName, operation) => {
|
|
167
|
+
const alreadyInState = await this.messageFlagService.hasFlag(messageId, flagName);
|
|
168
|
+
if ((operation === "add" && alreadyInState) ||
|
|
169
|
+
(operation === "remove" && !alreadyInState)) {
|
|
170
|
+
this.log.info({ messageId, flagName, operation }, "Flag already in the desired state — skipping redundant marker + local write");
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
await this.flagPushService.flip({
|
|
174
|
+
accountId,
|
|
175
|
+
accountConfigId,
|
|
176
|
+
messageId,
|
|
177
|
+
mailboxId,
|
|
178
|
+
flagName,
|
|
179
|
+
operation,
|
|
180
|
+
});
|
|
181
|
+
if (operation === "add") {
|
|
182
|
+
await this.messageFlagService.addFlag(messageId, flagName);
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
await this.messageFlagService.removeFlag(messageId, flagName);
|
|
186
|
+
}
|
|
187
|
+
return true;
|
|
188
|
+
};
|
|
189
|
+
/**
|
|
190
|
+
* Mark a message as read (add \Seen flag).
|
|
191
|
+
* Updates MessageFlag, ThreadMessage.isRead, and persists a pending
|
|
192
|
+
* flag-push marker for IMAP sync.
|
|
193
|
+
*
|
|
194
|
+
* @param accountConfigId - The owning account config (tenant scope)
|
|
195
|
+
* @param messageId - The message to mark as read
|
|
196
|
+
* @param accountId - The account ID for the IMAP sync event
|
|
197
|
+
*/
|
|
198
|
+
markAsRead = async (accountConfigId, messageId, accountId) => {
|
|
199
|
+
const message = await this.messageService.get(messageId);
|
|
200
|
+
const changed = await this.flipFlag(accountId, accountConfigId, messageId, message.mailboxId, MessageSystemFlag.Seen, "add");
|
|
201
|
+
if (changed) {
|
|
202
|
+
await this.updateThreadMessageIsRead(accountConfigId, messageId, true);
|
|
203
|
+
}
|
|
204
|
+
this.log.info({ messageId, changed }, "Marked message as read (local, push pending)");
|
|
205
|
+
};
|
|
206
|
+
/**
|
|
207
|
+
* Mark a message as unread (remove \Seen flag).
|
|
208
|
+
* Updates MessageFlag, ThreadMessage.isRead, and persists a pending
|
|
209
|
+
* flag-push marker for IMAP sync.
|
|
210
|
+
*
|
|
211
|
+
* @param accountConfigId - The owning account config (tenant scope)
|
|
212
|
+
* @param messageId - The message to mark as unread
|
|
213
|
+
* @param accountId - The account ID for the IMAP sync event
|
|
214
|
+
*/
|
|
215
|
+
markAsUnread = async (accountConfigId, messageId, accountId) => {
|
|
216
|
+
const message = await this.messageService.get(messageId);
|
|
217
|
+
const changed = await this.flipFlag(accountId, accountConfigId, messageId, message.mailboxId, MessageSystemFlag.Seen, "remove");
|
|
218
|
+
if (changed) {
|
|
219
|
+
await this.updateThreadMessageIsRead(accountConfigId, messageId, false);
|
|
220
|
+
}
|
|
221
|
+
this.log.info({ messageId, changed }, "Marked message as unread (local, push pending)");
|
|
222
|
+
};
|
|
223
|
+
/**
|
|
224
|
+
* Toggle the starred/flagged status of a message.
|
|
225
|
+
* Updates local state and persists a pending flag-push marker for IMAP
|
|
226
|
+
* sync.
|
|
227
|
+
*
|
|
228
|
+
* @param accountConfigId - The owning account config (tenant scope)
|
|
229
|
+
* @param messageId - The message to toggle
|
|
230
|
+
* @param accountId - The account ID for the IMAP sync event
|
|
231
|
+
* @returns true if flag was added, false if removed
|
|
232
|
+
*/
|
|
233
|
+
toggleFlagged = async (accountConfigId, messageId, accountId) => {
|
|
234
|
+
const message = await this.messageService.get(messageId);
|
|
235
|
+
const hasFlag = await this.messageFlagService.hasFlag(messageId, MessageSystemFlag.Flagged);
|
|
236
|
+
const operation = hasFlag ? "remove" : "add";
|
|
237
|
+
await this.flipFlag(accountId, accountConfigId, messageId, message.mailboxId, MessageSystemFlag.Flagged, operation);
|
|
238
|
+
this.log.info({ messageId, operation }, "Toggled flagged status (local, push pending)");
|
|
239
|
+
return operation === "add";
|
|
240
|
+
};
|
|
241
|
+
/**
|
|
242
|
+
* Update message flags using API-friendly input format.
|
|
243
|
+
* Maps isRead/isStarred to IMAP flags and updates ThreadMessage accordingly.
|
|
244
|
+
*
|
|
245
|
+
* @param accountConfigId - The owning account config (tenant scope)
|
|
246
|
+
* @param messageId - The message to update
|
|
247
|
+
* @param accountId - The account ID for the IMAP sync event
|
|
248
|
+
* @param input - The flag updates to apply
|
|
249
|
+
* @returns The current flag state after updates
|
|
250
|
+
*/
|
|
251
|
+
updateFlags = async (accountConfigId, messageId, accountId, input) => {
|
|
252
|
+
const message = await this.messageService.get(messageId);
|
|
253
|
+
// Handle isRead -> \Seen flag
|
|
254
|
+
if (input.isRead !== undefined) {
|
|
255
|
+
const changed = await this.flipFlag(accountId, accountConfigId, messageId, message.mailboxId, MessageSystemFlag.Seen, input.isRead ? "add" : "remove");
|
|
256
|
+
if (changed) {
|
|
257
|
+
await this.updateThreadMessageIsRead(accountConfigId, messageId, input.isRead);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
// `hasStars` is the boolean of record (the byStarred index sort key) and
|
|
261
|
+
// the server-side \Flagged keyword; `star` is its presentation colour,
|
|
262
|
+
// whose absent state is the None sentinel. The two must never disagree,
|
|
263
|
+
// whichever field the caller sent:
|
|
264
|
+
//
|
|
265
|
+
// isStarred alone → colour follows the boolean (standard colour on,
|
|
266
|
+
// None off)
|
|
267
|
+
// starColor alone → boolean follows the colour, so `none` unstars and
|
|
268
|
+
// any real colour stars
|
|
269
|
+
// both → isStarred decides the boolean, starColor the colour
|
|
270
|
+
//
|
|
271
|
+
// The \Flagged push follows the resulting boolean in every case, so a
|
|
272
|
+
// colour-only request still reaches the server. `flipFlag` no-ops when the
|
|
273
|
+
// flag already holds the desired state.
|
|
274
|
+
if (input.isStarred !== undefined || input.starColor !== undefined) {
|
|
275
|
+
const starred = input.isStarred ??
|
|
276
|
+
(input.starColor ?? StarColor.None) !== StarColor.None;
|
|
277
|
+
const color = input.starColor ?? (starred ? StarColor.Yellow : StarColor.None);
|
|
278
|
+
await this.flipFlag(accountId, accountConfigId, messageId, message.mailboxId, MessageSystemFlag.Flagged, starred ? "add" : "remove");
|
|
279
|
+
// Update ThreadMessage hasStars and star color for ALL instances
|
|
280
|
+
await this.updateThreadMessageStars(accountConfigId, messageId, {
|
|
281
|
+
hasStars: starred,
|
|
282
|
+
star: color,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
// Return current state
|
|
286
|
+
const isRead = await this.messageFlagService.hasFlag(messageId, MessageSystemFlag.Seen);
|
|
287
|
+
const isStarred = await this.messageFlagService.hasFlag(messageId, MessageSystemFlag.Flagged);
|
|
288
|
+
this.log.info({ messageId, isRead, isStarred, input }, "Updated message flags");
|
|
289
|
+
return { messageId, isRead, isStarred };
|
|
290
|
+
};
|
|
291
|
+
}
|