@remit/data-ports 0.0.8 → 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/data-ports",
3
- "version": "0.0.8",
3
+ "version": "0.0.9",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -31,7 +31,8 @@
31
31
  }
32
32
  },
33
33
  "scripts": {
34
- "test:typecheck": "tsgo --noEmit"
34
+ "test:typecheck": "tsgo --noEmit",
35
+ "test:run": "node --import tsx --test 'src/**/*.test.ts'"
35
36
  },
36
37
  "dependencies": {
37
38
  "@remit/domain-enums": "*",
package/src/id.test.ts ADDED
@@ -0,0 +1,55 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { deriveQuarantineId, quarantineMessageIdHash } from "./id.js";
4
+
5
+ const ACCOUNT = "acct-1";
6
+ const MAILBOX = "mbx-1";
7
+
8
+ describe("deriveQuarantineId", () => {
9
+ it("is stable for the same message", () => {
10
+ assert.equal(
11
+ deriveQuarantineId(ACCOUNT, MAILBOX, 1_700_000_000, 40217),
12
+ deriveQuarantineId(ACCOUNT, MAILBOX, 1_700_000_000, 40217),
13
+ );
14
+ });
15
+
16
+ it("separates the same uid across a UIDVALIDITY bump", () => {
17
+ // A mailbox keeps its mailboxId when the server bumps UIDVALIDITY, so
18
+ // without this the stale entry would name the new message and suppress
19
+ // it from a sync round — losing mail rather than a diagnostic.
20
+ assert.notEqual(
21
+ deriveQuarantineId(ACCOUNT, MAILBOX, 1_700_000_000, 40217),
22
+ deriveQuarantineId(ACCOUNT, MAILBOX, 1_700_000_001, 40217),
23
+ );
24
+ });
25
+
26
+ it("separates mailboxes and accounts", () => {
27
+ const base = deriveQuarantineId(ACCOUNT, MAILBOX, 1, 40217);
28
+ assert.notEqual(base, deriveQuarantineId(ACCOUNT, "mbx-2", 1, 40217));
29
+ assert.notEqual(base, deriveQuarantineId("acct-2", MAILBOX, 1, 40217));
30
+ });
31
+ });
32
+
33
+ describe("quarantineMessageIdHash", () => {
34
+ it("hashes a real Message-ID to a pinned sha256 value", () => {
35
+ const hash = quarantineMessageIdHash("<abc@example.com>");
36
+ assert.match(hash ?? "", /^sha256:[0-9a-f]{64}$/);
37
+ assert.equal(hash, quarantineMessageIdHash("<abc@example.com>"));
38
+ });
39
+
40
+ it("distinguishes different Message-IDs", () => {
41
+ assert.notEqual(
42
+ quarantineMessageIdHash("<a@example.com>"),
43
+ quarantineMessageIdHash("<b@example.com>"),
44
+ );
45
+ });
46
+
47
+ it("refuses to hash a Message-ID the sync path never had", () => {
48
+ // The sync path coerces a missing Message-ID to "". Hashing that would
49
+ // give every such message one shared hash and correlate unrelated
50
+ // reports — the opposite of what the field is for.
51
+ for (const absent of [undefined, "", " ", "<>"]) {
52
+ assert.equal(quarantineMessageIdHash(absent), undefined);
53
+ }
54
+ });
55
+ });
package/src/id.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import shortUuid from "short-uuid";
2
3
  import { v5 as uuidv5 } from "uuid";
3
4
  import type { MessageIdSource } from "./types.js";
@@ -103,3 +104,45 @@ export const deriveBodyPartParameterId = (
103
104
  `bodypartparam:${messageId}:${partPath}:${parameterName.toLowerCase()}`,
104
105
  REMIT_NAMESPACE,
105
106
  );
107
+
108
+ /**
109
+ * Identity of a quarantined message (issue #72). Derived rather than generated
110
+ * so re-quarantining the same message rewrites its own row: the sync path can
111
+ * write without first checking whether an entry already exists.
112
+ *
113
+ * `uidValidity` is part of the name because a uid alone does not identify a
114
+ * message — only uidValidity + uid does, forever (RFC 9051 2.3.1.1). A mailbox
115
+ * keeps its `mailboxId` across a UIDVALIDITY bump (the sync path updates the
116
+ * same row), so without this a stale entry would derive the same id as a later,
117
+ * unrelated message and suppress it from a sync round.
118
+ */
119
+ export const deriveQuarantineId = (
120
+ accountId: string,
121
+ mailboxId: string,
122
+ uidValidity: number,
123
+ uid: number,
124
+ ): string =>
125
+ base36uuidv5(
126
+ `quarantine:${accountId}:${mailboxId}:${uidValidity}:${uid}`,
127
+ REMIT_NAMESPACE,
128
+ );
129
+
130
+ /**
131
+ * The `sha256:`-prefixed Message-ID hash a quarantine record carries, or
132
+ * undefined when the message declared no usable Message-ID.
133
+ *
134
+ * The sync path coerces a missing Message-ID to `""`, and hashing that would
135
+ * give every Message-ID-less message the same hash — so the one field whose
136
+ * purpose is correlating reports of the same message would silently correlate
137
+ * unrelated ones. Absent is the honest value, and this is the only way the
138
+ * value is produced, so the empty hash cannot be written by accident.
139
+ */
140
+ export const quarantineMessageIdHash = (
141
+ messageIdHeader: string | undefined,
142
+ ): string | undefined => {
143
+ if (!messageIdHeader || !isValidMessageId(messageIdHeader)) return undefined;
144
+ const digest = createHash("sha256")
145
+ .update(messageIdHeader.trim())
146
+ .digest("hex");
147
+ return `sha256:${digest}`;
148
+ };
package/src/index.ts CHANGED
@@ -17,6 +17,7 @@ export type { IMessageLabelRepository } from "./interfaces/message-label.js";
17
17
  export type { IMessagePlacementMoveRepository } from "./interfaces/message-placement-move.js";
18
18
  export type { IOrganizeJobRequestRepository } from "./interfaces/organize-job-request.js";
19
19
  export type { IOutboxMessageRepository } from "./interfaces/outbox-message.js";
20
+ export type { IQuarantineRepository } from "./interfaces/quarantine.js";
20
21
  export type { IThreadMessageRepository } from "./interfaces/thread-message.js";
21
22
  export type {
22
23
  IUnitOfWork,
@@ -80,6 +81,8 @@ export type {
80
81
  OutboxMessageItem,
81
82
  PutMessageFlagPushInput,
82
83
  PutMessagePlacementMoveInput,
84
+ QuarantineItem,
85
+ QuarantineMimeNodeItem,
83
86
  RawMessageStorageItem,
84
87
  ResultList,
85
88
  SearchOptions,
@@ -0,0 +1,10 @@
1
+ import type { QuarantineItem } from "../types.js";
2
+
3
+ export interface IQuarantineRepository {
4
+ /**
5
+ * Every quarantined message for one user, newest first. Unpaginated: the
6
+ * list is small by design, and it is read whole both by the settings
7
+ * surface and by a sync round that keeps it in memory.
8
+ */
9
+ listByAccountConfigId(accountConfigId: string): Promise<QuarantineItem[]>;
10
+ }
package/src/types.ts CHANGED
@@ -22,6 +22,7 @@ import type {
22
22
  MessageSchema,
23
23
  OrganizeJobRequestSchema,
24
24
  OutboxMessageSchema,
25
+ QuarantineSchema,
25
26
  RawMessageStorageSchema,
26
27
  ThreadMessageSchema,
27
28
  } from "@remit/api-zod-schemas";
@@ -85,6 +86,8 @@ export type MessagePlacementMoveItem = z.infer<
85
86
  typeof MessagePlacementMoveSchema
86
87
  >;
87
88
  export type MessageFlagPushItem = z.infer<typeof MessageFlagPushSchema>;
89
+ export type QuarantineItem = z.infer<typeof QuarantineSchema>;
90
+ export type QuarantineMimeNodeItem = QuarantineItem["structure"][number];
88
91
 
89
92
  export type MailboxSpecialUseValue = z.infer<typeof MailboxSpecialUseSchema>;
90
93