@remit/mailbox-service 0.0.7 → 0.0.8
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/package.json +1 -1
- package/src/flag-queue-stars.test.ts +171 -0
- package/src/flag-queue.ts +31 -22
- package/src/message-sync-flags.test.ts +131 -0
- package/src/message-sync.ts +14 -2
package/package.json
CHANGED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, test } from "node:test";
|
|
3
|
+
import type {
|
|
4
|
+
IMessageFlagRepository,
|
|
5
|
+
IMessageRepository,
|
|
6
|
+
IThreadMessageRepository,
|
|
7
|
+
MessageItem,
|
|
8
|
+
ThreadMessageItem,
|
|
9
|
+
UpdateThreadMessageInput,
|
|
10
|
+
} from "@remit/data-ports";
|
|
11
|
+
import { MessageSystemFlag } from "@remit/domain-enums";
|
|
12
|
+
import type { FlagPushService } from "./flag-push.js";
|
|
13
|
+
import { FlagQueueService } from "./flag-queue.js";
|
|
14
|
+
|
|
15
|
+
// #44: `hasStars` is the boolean of record and `star` its presentation colour.
|
|
16
|
+
// The UI's star toggle sends only `isStarred`, so a star that left `star` at
|
|
17
|
+
// the `none` sentinel disagreed with `hasStars` and every read site that
|
|
18
|
+
// consulted the colour rejected the row. The colour now follows the boolean.
|
|
19
|
+
|
|
20
|
+
const ACCOUNT_CONFIG_ID = "cfg-1";
|
|
21
|
+
const ACCOUNT_ID = "acct-1";
|
|
22
|
+
const MESSAGE_ID = "msg-1";
|
|
23
|
+
const MAILBOX_ID = "mbx-1";
|
|
24
|
+
const THREAD_MESSAGE_ID = "tm-1";
|
|
25
|
+
|
|
26
|
+
const threadMessage = {
|
|
27
|
+
threadMessageId: THREAD_MESSAGE_ID,
|
|
28
|
+
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
29
|
+
mailboxId: MAILBOX_ID,
|
|
30
|
+
sentDate: 1,
|
|
31
|
+
isRead: false,
|
|
32
|
+
isDeleted: false,
|
|
33
|
+
hasStars: false,
|
|
34
|
+
hasAttachment: false,
|
|
35
|
+
} as unknown as ThreadMessageItem;
|
|
36
|
+
|
|
37
|
+
const buildService = ({
|
|
38
|
+
alreadyFlagged = false,
|
|
39
|
+
}: {
|
|
40
|
+
alreadyFlagged?: boolean;
|
|
41
|
+
} = {}): {
|
|
42
|
+
service: FlagQueueService;
|
|
43
|
+
updates: UpdateThreadMessageInput[];
|
|
44
|
+
flips: Array<{ flagName: string; operation: string }>;
|
|
45
|
+
} => {
|
|
46
|
+
const updates: UpdateThreadMessageInput[] = [];
|
|
47
|
+
const flips: Array<{ flagName: string; operation: string }> = [];
|
|
48
|
+
|
|
49
|
+
const messageService = {
|
|
50
|
+
get: async () => ({ mailboxId: MAILBOX_ID }) as unknown as MessageItem,
|
|
51
|
+
} as unknown as IMessageRepository;
|
|
52
|
+
|
|
53
|
+
const messageFlagService = {
|
|
54
|
+
hasFlag: async (_messageId: string, flagName: string) =>
|
|
55
|
+
flagName === MessageSystemFlag.Flagged ? alreadyFlagged : false,
|
|
56
|
+
addFlag: async () => {},
|
|
57
|
+
removeFlag: async () => {},
|
|
58
|
+
} as unknown as IMessageFlagRepository;
|
|
59
|
+
|
|
60
|
+
const threadMessageService = {
|
|
61
|
+
findAllByMessageId: async () => [threadMessage],
|
|
62
|
+
update: async (
|
|
63
|
+
_accountConfigId: string,
|
|
64
|
+
_threadMessageId: string,
|
|
65
|
+
input: UpdateThreadMessageInput,
|
|
66
|
+
) => {
|
|
67
|
+
updates.push(input);
|
|
68
|
+
return threadMessage;
|
|
69
|
+
},
|
|
70
|
+
} as unknown as IThreadMessageRepository;
|
|
71
|
+
|
|
72
|
+
const flagPushService = {
|
|
73
|
+
flip: async (event: { flagName: string; operation: string }) => {
|
|
74
|
+
flips.push({ flagName: event.flagName, operation: event.operation });
|
|
75
|
+
},
|
|
76
|
+
} as unknown as FlagPushService;
|
|
77
|
+
|
|
78
|
+
const service = new FlagQueueService({
|
|
79
|
+
messageFlagService,
|
|
80
|
+
messageService,
|
|
81
|
+
threadMessageService,
|
|
82
|
+
flagPushService,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
return { service, updates, flips };
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
describe("updateFlags keeps hasStars and the star colour in step", () => {
|
|
89
|
+
test("starring without a colour sets both the boolean and a visible colour", async () => {
|
|
90
|
+
const { service, updates } = buildService();
|
|
91
|
+
|
|
92
|
+
await service.updateFlags(ACCOUNT_CONFIG_ID, MESSAGE_ID, ACCOUNT_ID, {
|
|
93
|
+
isStarred: true,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
assert.deepEqual(updates, [{ hasStars: true, star: "yellow" }]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("unstarring clears the colour back to the none sentinel", async () => {
|
|
100
|
+
const { service, updates } = buildService({ alreadyFlagged: true });
|
|
101
|
+
|
|
102
|
+
await service.updateFlags(ACCOUNT_CONFIG_ID, MESSAGE_ID, ACCOUNT_ID, {
|
|
103
|
+
isStarred: false,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
assert.deepEqual(updates, [{ hasStars: false, star: "none" }]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("an explicit colour wins over the default", async () => {
|
|
110
|
+
const { service, updates } = buildService();
|
|
111
|
+
|
|
112
|
+
await service.updateFlags(ACCOUNT_CONFIG_ID, MESSAGE_ID, ACCOUNT_ID, {
|
|
113
|
+
isStarred: true,
|
|
114
|
+
starColor: "red",
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
assert.deepEqual(updates, [{ hasStars: true, star: "red" }]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// A colour-only request is legal on the wire. Before, it wrote the colour
|
|
121
|
+
// and left `hasStars` alone, so a coloured-but-unstarred row rendered
|
|
122
|
+
// unstarred, stayed out of Starred, and never pushed \Flagged.
|
|
123
|
+
test("a colour alone stars the message and pushes the flag", async () => {
|
|
124
|
+
const { service, updates, flips } = buildService();
|
|
125
|
+
|
|
126
|
+
await service.updateFlags(ACCOUNT_CONFIG_ID, MESSAGE_ID, ACCOUNT_ID, {
|
|
127
|
+
starColor: "blue",
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
assert.deepEqual(updates, [{ hasStars: true, star: "blue" }]);
|
|
131
|
+
assert.deepEqual(flips, [
|
|
132
|
+
{ flagName: MessageSystemFlag.Flagged, operation: "add" },
|
|
133
|
+
]);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("the none colour unstars the message", async () => {
|
|
137
|
+
const { service, updates, flips } = buildService({ alreadyFlagged: true });
|
|
138
|
+
|
|
139
|
+
await service.updateFlags(ACCOUNT_CONFIG_ID, MESSAGE_ID, ACCOUNT_ID, {
|
|
140
|
+
starColor: "none",
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
assert.deepEqual(updates, [{ hasStars: false, star: "none" }]);
|
|
144
|
+
assert.deepEqual(flips, [
|
|
145
|
+
{ flagName: MessageSystemFlag.Flagged, operation: "remove" },
|
|
146
|
+
]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("isStarred decides the boolean when both fields are sent", async () => {
|
|
150
|
+
const { service, updates } = buildService();
|
|
151
|
+
|
|
152
|
+
await service.updateFlags(ACCOUNT_CONFIG_ID, MESSAGE_ID, ACCOUNT_ID, {
|
|
153
|
+
isStarred: true,
|
|
154
|
+
starColor: "none",
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
assert.deepEqual(updates, [{ hasStars: true, star: "none" }]);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("starring pushes the Flagged keyword", async () => {
|
|
161
|
+
const { service, flips } = buildService();
|
|
162
|
+
|
|
163
|
+
await service.updateFlags(ACCOUNT_CONFIG_ID, MESSAGE_ID, ACCOUNT_ID, {
|
|
164
|
+
isStarred: true,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
assert.deepEqual(flips, [
|
|
168
|
+
{ flagName: MessageSystemFlag.Flagged, operation: "add" },
|
|
169
|
+
]);
|
|
170
|
+
});
|
|
171
|
+
});
|
package/src/flag-queue.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type {
|
|
|
4
4
|
IThreadMessageRepository,
|
|
5
5
|
} from "@remit/data-ports";
|
|
6
6
|
import { NotFoundError } from "@remit/data-ports/errors";
|
|
7
|
-
import { MessageSystemFlag,
|
|
7
|
+
import { MessageSystemFlag, StarColor } from "@remit/domain-enums";
|
|
8
8
|
import type { FlagPushOperationValue, FlagPushService } from "./flag-push.js";
|
|
9
9
|
|
|
10
10
|
/**
|
|
@@ -452,32 +452,41 @@ export class FlagQueueService {
|
|
|
452
452
|
}
|
|
453
453
|
}
|
|
454
454
|
|
|
455
|
-
//
|
|
455
|
+
// `hasStars` is the boolean of record (the byStarred index sort key) and
|
|
456
|
+
// the server-side \Flagged keyword; `star` is its presentation colour,
|
|
457
|
+
// whose absent state is the None sentinel. The two must never disagree,
|
|
458
|
+
// whichever field the caller sent:
|
|
459
|
+
//
|
|
460
|
+
// isStarred alone → colour follows the boolean (standard colour on,
|
|
461
|
+
// None off)
|
|
462
|
+
// starColor alone → boolean follows the colour, so `none` unstars and
|
|
463
|
+
// any real colour stars
|
|
464
|
+
// both → isStarred decides the boolean, starColor the colour
|
|
465
|
+
//
|
|
466
|
+
// The \Flagged push follows the resulting boolean in every case, so a
|
|
467
|
+
// colour-only request still reaches the server. `flipFlag` no-ops when the
|
|
468
|
+
// flag already holds the desired state.
|
|
456
469
|
if (input.isStarred !== undefined || input.starColor !== undefined) {
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
message.mailboxId,
|
|
463
|
-
MessageSystemFlag.Flagged,
|
|
464
|
-
input.isStarred ? "add" : "remove",
|
|
465
|
-
);
|
|
466
|
-
}
|
|
470
|
+
const starred =
|
|
471
|
+
input.isStarred ??
|
|
472
|
+
(input.starColor ?? StarColor.None) !== StarColor.None;
|
|
473
|
+
const color =
|
|
474
|
+
input.starColor ?? (starred ? StarColor.Yellow : StarColor.None);
|
|
467
475
|
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
if (input.isStarred !== undefined) {
|
|
471
|
-
starUpdates.hasStars = input.isStarred;
|
|
472
|
-
}
|
|
473
|
-
if (input.starColor !== undefined) {
|
|
474
|
-
starUpdates.star = input.starColor;
|
|
475
|
-
}
|
|
476
|
-
await this.updateThreadMessageStars(
|
|
476
|
+
await this.flipFlag(
|
|
477
|
+
accountId,
|
|
477
478
|
accountConfigId,
|
|
478
479
|
messageId,
|
|
479
|
-
|
|
480
|
+
message.mailboxId,
|
|
481
|
+
MessageSystemFlag.Flagged,
|
|
482
|
+
starred ? "add" : "remove",
|
|
480
483
|
);
|
|
484
|
+
|
|
485
|
+
// Update ThreadMessage hasStars and star color for ALL instances
|
|
486
|
+
await this.updateThreadMessageStars(accountConfigId, messageId, {
|
|
487
|
+
hasStars: starred,
|
|
488
|
+
star: color,
|
|
489
|
+
});
|
|
481
490
|
}
|
|
482
491
|
|
|
483
492
|
// Return current state
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, test } from "node:test";
|
|
3
|
+
import type {
|
|
4
|
+
CreateThreadMessageInput,
|
|
5
|
+
IAddressRepository,
|
|
6
|
+
IEnvelopeRepository,
|
|
7
|
+
IMailboxRepository,
|
|
8
|
+
IMessageRepository,
|
|
9
|
+
IThreadMessageRepository,
|
|
10
|
+
ThreadMessageItem,
|
|
11
|
+
} from "@remit/data-ports";
|
|
12
|
+
import type { ManagedConnectionFactory } from "./connection-factory.js";
|
|
13
|
+
import { MessageSyncService } from "./message-sync.js";
|
|
14
|
+
import type { ImapEnvelope } from "./types.js";
|
|
15
|
+
|
|
16
|
+
// #44: initial sync hardcoded `hasStars: false` on row create, so mail flagged
|
|
17
|
+
// in another client arrived unstarred and never appeared in Flagged. The
|
|
18
|
+
// server's \Flagged keyword is the star; these cover the mapping on create.
|
|
19
|
+
|
|
20
|
+
const ACCOUNT_ID = "acct-1";
|
|
21
|
+
const ACCOUNT_CONFIG_ID = "cfg-1";
|
|
22
|
+
const MAILBOX_ID = "mbx-1";
|
|
23
|
+
const MESSAGE_ID = "msg-1";
|
|
24
|
+
|
|
25
|
+
const envelope: ImapEnvelope = {
|
|
26
|
+
date: new Date(0).toISOString(),
|
|
27
|
+
messageId: "<root@example.com>",
|
|
28
|
+
subject: "Subject",
|
|
29
|
+
from: [{ name: "Sender", mailbox: "sender", host: "example.com" }],
|
|
30
|
+
sender: [],
|
|
31
|
+
replyTo: [],
|
|
32
|
+
to: [],
|
|
33
|
+
cc: [],
|
|
34
|
+
bcc: [],
|
|
35
|
+
inReplyTo: "",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Capture the ThreadMessage create input the sync path builds. Only `create` is
|
|
40
|
+
* exercised; the rest of the port is unreachable from this code path.
|
|
41
|
+
*/
|
|
42
|
+
const captureCreate = (): {
|
|
43
|
+
repo: IThreadMessageRepository;
|
|
44
|
+
inputs: CreateThreadMessageInput[];
|
|
45
|
+
} => {
|
|
46
|
+
const inputs: CreateThreadMessageInput[] = [];
|
|
47
|
+
const repo = {
|
|
48
|
+
create: async (input: CreateThreadMessageInput) => {
|
|
49
|
+
inputs.push(input);
|
|
50
|
+
return input as unknown as ThreadMessageItem;
|
|
51
|
+
},
|
|
52
|
+
} as unknown as IThreadMessageRepository;
|
|
53
|
+
return { repo, inputs };
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const stub = <T>(): T => ({}) as T;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* `createThreadForMessage` is private to the service — it is only ever reached
|
|
60
|
+
* through a live IMAP fetch. Reach it directly so the flag mapping is covered
|
|
61
|
+
* without standing up a server.
|
|
62
|
+
*/
|
|
63
|
+
type CreateThreadForMessage = (
|
|
64
|
+
threadMessageService: IThreadMessageRepository,
|
|
65
|
+
messageId: string,
|
|
66
|
+
mailboxId: string,
|
|
67
|
+
accountId: string,
|
|
68
|
+
accountConfigId: string,
|
|
69
|
+
uid: number,
|
|
70
|
+
internalDate: number,
|
|
71
|
+
sentDate: number,
|
|
72
|
+
envelope: ImapEnvelope,
|
|
73
|
+
flags: string[],
|
|
74
|
+
references?: string[],
|
|
75
|
+
hasAttachment?: boolean,
|
|
76
|
+
) => Promise<void>;
|
|
77
|
+
|
|
78
|
+
const createThreadWithFlags = async (
|
|
79
|
+
flags: string[],
|
|
80
|
+
): Promise<CreateThreadMessageInput> => {
|
|
81
|
+
const service = new MessageSyncService(
|
|
82
|
+
stub<ManagedConnectionFactory>(),
|
|
83
|
+
stub<IMailboxRepository>(),
|
|
84
|
+
stub<IMessageRepository>(),
|
|
85
|
+
stub<IEnvelopeRepository>(),
|
|
86
|
+
stub<IAddressRepository>(),
|
|
87
|
+
stub<IThreadMessageRepository>(),
|
|
88
|
+
);
|
|
89
|
+
const { repo, inputs } = captureCreate();
|
|
90
|
+
const now = Date.now();
|
|
91
|
+
|
|
92
|
+
await (
|
|
93
|
+
service as unknown as { createThreadForMessage: CreateThreadForMessage }
|
|
94
|
+
).createThreadForMessage(
|
|
95
|
+
repo,
|
|
96
|
+
MESSAGE_ID,
|
|
97
|
+
MAILBOX_ID,
|
|
98
|
+
ACCOUNT_ID,
|
|
99
|
+
ACCOUNT_CONFIG_ID,
|
|
100
|
+
42,
|
|
101
|
+
now,
|
|
102
|
+
now,
|
|
103
|
+
envelope,
|
|
104
|
+
flags,
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
assert.equal(inputs.length, 1);
|
|
108
|
+
const [input] = inputs;
|
|
109
|
+
assert.ok(input);
|
|
110
|
+
return input;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
describe("message sync maps IMAP flags onto the created ThreadMessage", () => {
|
|
114
|
+
test("a message carrying \\Flagged is created starred", async () => {
|
|
115
|
+
const input = await createThreadWithFlags(["\\Flagged"]);
|
|
116
|
+
assert.equal(input.hasStars, true);
|
|
117
|
+
assert.equal(input.star, "yellow");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("a message without \\Flagged is created unstarred", async () => {
|
|
121
|
+
const input = await createThreadWithFlags(["\\Seen"]);
|
|
122
|
+
assert.equal(input.hasStars, false);
|
|
123
|
+
assert.equal(input.star, "none");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("\\Flagged and \\Seen are mapped independently", async () => {
|
|
127
|
+
const input = await createThreadWithFlags(["\\Seen", "\\Flagged"]);
|
|
128
|
+
assert.equal(input.isRead, true);
|
|
129
|
+
assert.equal(input.hasStars, true);
|
|
130
|
+
});
|
|
131
|
+
});
|
package/src/message-sync.ts
CHANGED
|
@@ -17,7 +17,11 @@ import {
|
|
|
17
17
|
deriveThreadId,
|
|
18
18
|
isValidMessageId,
|
|
19
19
|
} from "@remit/data-ports/id";
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
AddressRole,
|
|
22
|
+
MailboxCursorState,
|
|
23
|
+
StarColor,
|
|
24
|
+
} from "@remit/domain-enums";
|
|
21
25
|
import pMap from "p-map";
|
|
22
26
|
import type { ManagedConnectionFactory } from "./connection-factory.js";
|
|
23
27
|
import { guardMailboxCursor, isCursorRebuildNeeded } from "./mailbox-cursor.js";
|
|
@@ -935,6 +939,13 @@ export class MessageSyncService {
|
|
|
935
939
|
// Check if message is read based on IMAP flags
|
|
936
940
|
const isRead = flags.includes("\\Seen");
|
|
937
941
|
|
|
942
|
+
// The server's \Flagged keyword is the star. Mail flagged in another
|
|
943
|
+
// client must arrive starred, so carry it through on create rather than
|
|
944
|
+
// defaulting every row to unstarred. Compared as a literal for the same
|
|
945
|
+
// reason \Seen is above: the generated MessageSystemFlag members drop the
|
|
946
|
+
// leading backslash, so they do not match a wire flag.
|
|
947
|
+
const hasStars = flags.includes("\\Flagged");
|
|
948
|
+
|
|
938
949
|
// Extract sender info. When the server could not parse the From address,
|
|
939
950
|
// omit fromEmail rather than persist a fabricated string — a display name
|
|
940
951
|
// may still be present and useful, so keep it.
|
|
@@ -967,7 +978,8 @@ export class MessageSyncService {
|
|
|
967
978
|
isRead,
|
|
968
979
|
isDeleted: false,
|
|
969
980
|
hasAttachment,
|
|
970
|
-
hasStars
|
|
981
|
+
hasStars,
|
|
982
|
+
star: hasStars ? StarColor.Yellow : StarColor.None,
|
|
971
983
|
})
|
|
972
984
|
.catch((error: unknown) => {
|
|
973
985
|
// Ignore conflict errors (idempotent create)
|