@remit/mailbox-service 0.0.7 → 0.0.9

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/mailbox-service",
3
- "version": "0.0.7",
3
+ "version": "0.0.9",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -1,6 +1,6 @@
1
1
  import assert from "node:assert";
2
2
  import { describe, it } from "node:test";
3
- import { MailboxSpecialUse } from "@remit/domain-enums";
3
+ import { MailboxAttribute, MailboxSpecialUse } from "@remit/domain-enums";
4
4
  import {
5
5
  hasChildren,
6
6
  isNoSelect,
@@ -50,7 +50,33 @@ describe("parseImapAttributes special-use", () => {
50
50
  it("separates standard attributes from special-use", () => {
51
51
  const parsed = parseImapAttributes(["\\HasChildren", "\\Sent"]);
52
52
  assert.deepStrictEqual(parsed.specialUse, [MailboxSpecialUse.Sent]);
53
- assert.deepStrictEqual(parsed.attributes, ["HasChildren"]);
53
+ assert.deepStrictEqual(parsed.attributes, ["\\HasChildren"]);
54
+ });
55
+
56
+ it("keeps standard attributes in the RFC 9051 backslash-prefixed form", () => {
57
+ const parsed = parseImapAttributes([
58
+ "\\NonExistent",
59
+ "\\Noinferiors",
60
+ "\\Noselect",
61
+ "\\HasChildren",
62
+ "\\HasNoChildren",
63
+ "\\Marked",
64
+ "\\Unmarked",
65
+ "\\Subscribed",
66
+ "\\Remote",
67
+ ]);
68
+ assert.deepStrictEqual(parsed.attributes, [
69
+ "\\NonExistent",
70
+ "\\Noinferiors",
71
+ "\\Noselect",
72
+ "\\HasChildren",
73
+ "\\HasNoChildren",
74
+ "\\Marked",
75
+ "\\Unmarked",
76
+ "\\Subscribed",
77
+ "\\Remote",
78
+ ]);
79
+ assert.deepStrictEqual(parsed.attributes, Object.values(MailboxAttribute));
54
80
  });
55
81
 
56
82
  it("collects unknown attributes without dropping them", () => {
@@ -11,8 +11,9 @@ type MailboxSpecialUseValue =
11
11
  (typeof MailboxSpecialUse)[keyof typeof MailboxSpecialUse];
12
12
 
13
13
  /**
14
- * Map from IMAP attribute strings to MailboxAttribute enum values
15
- * IMAP attributes have backslash prefix, our enum values don't
14
+ * Map from IMAP attribute strings to MailboxAttribute enum values.
15
+ * MailboxAttribute keeps the RFC 9051 backslash prefix; MailboxSpecialUse
16
+ * below is a canonical bare designation, so only that map strips it.
16
17
  */
17
18
  const ATTRIBUTE_MAP: Record<string, MailboxAttributeValue> = {
18
19
  "\\NonExistent": MailboxAttribute.NonExistent,
@@ -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, type StarColor } from "@remit/domain-enums";
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
- // Handle isStarred -> \Flagged flag and ThreadMessage.hasStars/star
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
- if (input.isStarred !== undefined) {
458
- await this.flipFlag(
459
- accountId,
460
- accountConfigId,
461
- messageId,
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
- // Update ThreadMessage hasStars and star color for ALL instances
469
- const starUpdates: { hasStars?: boolean; star?: StarColorValue } = {};
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
- starUpdates,
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
+ });
@@ -17,7 +17,11 @@ import {
17
17
  deriveThreadId,
18
18
  isValidMessageId,
19
19
  } from "@remit/data-ports/id";
20
- import { AddressRole, MailboxCursorState } from "@remit/domain-enums";
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: false,
981
+ hasStars,
982
+ star: hasStars ? StarColor.Yellow : StarColor.None,
971
983
  })
972
984
  .catch((error: unknown) => {
973
985
  // Ignore conflict errors (idempotent create)
@@ -0,0 +1,77 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import {
4
+ MailboxAttribute,
5
+ MessageKeywordFlag,
6
+ MessageSystemFlag,
7
+ } from "@remit/domain-enums";
8
+
9
+ /**
10
+ * RFC 9051 §2.3.2 system flags and §7.3.1 mailbox attributes are
11
+ * backslash-prefixed on the wire, and these enum values reach an IMAP server
12
+ * verbatim: `handleFlagPush` passes a marker's `flagName` straight to
13
+ * `ImapFlowConnection.addFlags`/`removeFlags`. A value that has lost its
14
+ * backslash is still a legal STORE argument — the server takes it as a custom
15
+ * keyword — so the push silently sets the wrong thing rather than failing.
16
+ *
17
+ * These enums are generated, and the emitter writes their values into
18
+ * JavaScript source where an unescaped `\S` collapses to `S` (issue #64).
19
+ * Pin the exact bytes so a regression fails here instead of on a real
20
+ * mailbox.
21
+ */
22
+ const SYSTEM_FLAG_WIRE_FORMAT = {
23
+ Seen: "\\Seen",
24
+ Answered: "\\Answered",
25
+ Flagged: "\\Flagged",
26
+ Deleted: "\\Deleted",
27
+ Draft: "\\Draft",
28
+ } as const;
29
+
30
+ const MAILBOX_ATTRIBUTE_WIRE_FORMAT = {
31
+ NonExistent: "\\NonExistent",
32
+ NoInferiors: "\\Noinferiors",
33
+ NoSelect: "\\Noselect",
34
+ HasChildren: "\\HasChildren",
35
+ HasNoChildren: "\\HasNoChildren",
36
+ Marked: "\\Marked",
37
+ Unmarked: "\\Unmarked",
38
+ Subscribed: "\\Subscribed",
39
+ Remote: "\\Remote",
40
+ } as const;
41
+
42
+ describe("IMAP system flag wire format (issue #64)", () => {
43
+ it("carries the RFC 9051 backslash prefix on every member", () => {
44
+ assert.deepEqual({ ...MessageSystemFlag }, { ...SYSTEM_FLAG_WIRE_FORMAT });
45
+ });
46
+
47
+ for (const [name, wireValue] of Object.entries(SYSTEM_FLAG_WIRE_FORMAT)) {
48
+ it(`${name} is one backslash followed by the flag name`, () => {
49
+ assert.equal(wireValue.charCodeAt(0), 0x5c);
50
+ assert.equal(wireValue.slice(1), name);
51
+ assert.equal(wireValue.length, name.length + 1);
52
+ });
53
+ }
54
+
55
+ it("leaves keyword flags unprefixed", () => {
56
+ for (const wireValue of Object.values(MessageKeywordFlag)) {
57
+ assert.equal(wireValue.startsWith("\\"), false);
58
+ assert.equal(wireValue.startsWith("$"), true);
59
+ }
60
+ });
61
+ });
62
+
63
+ describe("IMAP mailbox attribute wire format (issue #64)", () => {
64
+ it("carries the RFC 9051 backslash prefix on every member", () => {
65
+ assert.deepEqual(
66
+ { ...MailboxAttribute },
67
+ { ...MAILBOX_ATTRIBUTE_WIRE_FORMAT },
68
+ );
69
+ });
70
+
71
+ it("prefixes every value with exactly one backslash", () => {
72
+ for (const wireValue of Object.values(MailboxAttribute)) {
73
+ assert.equal(wireValue.charCodeAt(0), 0x5c);
74
+ assert.equal(wireValue.charCodeAt(1) === 0x5c, false);
75
+ }
76
+ });
77
+ });