@remit/data-ports 0.0.10 → 0.0.12

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.10",
3
+ "version": "0.0.12",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -28,6 +28,10 @@
28
28
  "./wellknown": {
29
29
  "types": "./src/wellknown.ts",
30
30
  "default": "./src/wellknown.ts"
31
+ },
32
+ "./update-manifest": {
33
+ "types": "./src/update-manifest.ts",
34
+ "default": "./src/update-manifest.ts"
31
35
  }
32
36
  },
33
37
  "scripts": {
package/src/id.test.ts CHANGED
@@ -1,6 +1,11 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { describe, it } from "node:test";
3
- import { deriveQuarantineId, quarantineMessageIdHash } from "./id.js";
3
+ import {
4
+ deriveCopyMessageId,
5
+ deriveMessageId,
6
+ deriveQuarantineId,
7
+ quarantineMessageIdHash,
8
+ } from "./id.js";
4
9
 
5
10
  const ACCOUNT = "acct-1";
6
11
  const MAILBOX = "mbx-1";
@@ -30,6 +35,38 @@ describe("deriveQuarantineId", () => {
30
35
  });
31
36
  });
32
37
 
38
+ describe("deriveCopyMessageId", () => {
39
+ const SOURCE = deriveMessageId(ACCOUNT, "<abc@example.com>");
40
+ const DEST = "mbx-archive";
41
+
42
+ it("is stable for the same source and destination (idempotent copy)", () => {
43
+ assert.equal(
44
+ deriveCopyMessageId(SOURCE, DEST),
45
+ deriveCopyMessageId(SOURCE, DEST),
46
+ );
47
+ });
48
+
49
+ it("separates a copy of one mail into two folders", () => {
50
+ assert.notEqual(
51
+ deriveCopyMessageId(SOURCE, DEST),
52
+ deriveCopyMessageId(SOURCE, "mbx-other"),
53
+ );
54
+ });
55
+
56
+ it("separates copies of two different mails into one folder", () => {
57
+ assert.notEqual(
58
+ deriveCopyMessageId(SOURCE, DEST),
59
+ deriveCopyMessageId(deriveMessageId(ACCOUNT, "<xyz@example.com>"), DEST),
60
+ );
61
+ });
62
+
63
+ it("never equals the folder-independent id sync derives", () => {
64
+ // Sync keys a message on deriveMessageId; the copy id folds in the
65
+ // destination mailbox, so the two can never collide.
66
+ assert.notEqual(deriveCopyMessageId(SOURCE, DEST), SOURCE);
67
+ });
68
+ });
69
+
33
70
  describe("quarantineMessageIdHash", () => {
34
71
  it("hashes a real Message-ID to a pinned sha256 value", () => {
35
72
  const hash = quarantineMessageIdHash("<abc@example.com>");
package/src/id.ts CHANGED
@@ -73,6 +73,29 @@ export const deriveMessageIdFromSource = (
73
73
  source: MessageIdSource,
74
74
  ): string => deriveMessageId(accountId, normalizeMessageIdHeader(source));
75
75
 
76
+ /**
77
+ * Identity of a message copied into another folder (issue #75). Derived from
78
+ * the source message and the destination mailbox so a copy is deterministic:
79
+ * copying the same mail into the same folder twice — a replayed COPY event or a
80
+ * repeated user copy — resolves to one row instead of a fresh unreachable
81
+ * duplicate each time. A random id, which is what the copy path used before,
82
+ * gave every attempt a new identity that no later sync or delete could reach.
83
+ *
84
+ * The destination mailbox is part of the name so a copy of one mail into two
85
+ * folders is two rows, one per folder. It also keeps this id out of the space
86
+ * sync assigns: sync keys a message on `deriveMessageId(accountId,
87
+ * messageIdHeader)`, which is folder-independent, so the copy row can never
88
+ * collide with, or be overwritten by, the canonical synced row.
89
+ */
90
+ export const deriveCopyMessageId = (
91
+ sourceMessageId: string,
92
+ destinationMailboxId: string,
93
+ ): string =>
94
+ base36uuidv5(
95
+ `messagecopy:${sourceMessageId}:${destinationMailboxId}`,
96
+ REMIT_NAMESPACE,
97
+ );
98
+
76
99
  export const deriveThreadId = (
77
100
  accountId: string,
78
101
  rootMessageIdHeader: string,
@@ -0,0 +1,100 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { UpdateManifestSchema } from "./update-manifest.js";
4
+
5
+ const GOOD_MANIFEST = {
6
+ version: "v1.5.0",
7
+ publishedAt: "2026-07-18T09:00:00Z",
8
+ summary: "Faster search and a fix for attachments over 25 MB.",
9
+ releaseNotesUrl: "https://github.com/remit-mail/reader/releases/tag/v1.5.0",
10
+ registry: "ghcr.io/remit-mail/reader",
11
+ };
12
+
13
+ describe("UpdateManifestSchema", () => {
14
+ it("accepts the documented manifest shape", () => {
15
+ assert.deepEqual(UpdateManifestSchema.parse(GOOD_MANIFEST), GOOD_MANIFEST);
16
+ });
17
+
18
+ it("rejects a version that is not vX.Y.Z", () => {
19
+ assert.throws(() =>
20
+ UpdateManifestSchema.parse({ ...GOOD_MANIFEST, version: "1.5.0" }),
21
+ );
22
+ assert.throws(() =>
23
+ UpdateManifestSchema.parse({ ...GOOD_MANIFEST, version: "v1.5" }),
24
+ );
25
+ assert.throws(() =>
26
+ UpdateManifestSchema.parse({ ...GOOD_MANIFEST, version: "v1.5.0-rc1" }),
27
+ );
28
+ });
29
+
30
+ it("rejects a publishedAt that is not ISO 8601", () => {
31
+ assert.throws(() =>
32
+ UpdateManifestSchema.parse({
33
+ ...GOOD_MANIFEST,
34
+ publishedAt: "18 July 2026",
35
+ }),
36
+ );
37
+ });
38
+
39
+ it("rejects an empty summary", () => {
40
+ assert.throws(() =>
41
+ UpdateManifestSchema.parse({ ...GOOD_MANIFEST, summary: "" }),
42
+ );
43
+ });
44
+
45
+ it("rejects a summary over 140 characters", () => {
46
+ assert.throws(() =>
47
+ UpdateManifestSchema.parse({
48
+ ...GOOD_MANIFEST,
49
+ summary: "x".repeat(141),
50
+ }),
51
+ );
52
+ });
53
+
54
+ it("accepts a summary at exactly 140 characters", () => {
55
+ const summary = "x".repeat(140);
56
+ assert.equal(
57
+ UpdateManifestSchema.parse({ ...GOOD_MANIFEST, summary }).summary,
58
+ summary,
59
+ );
60
+ });
61
+
62
+ it("rejects a releaseNotesUrl that is not a URL", () => {
63
+ assert.throws(() =>
64
+ UpdateManifestSchema.parse({
65
+ ...GOOD_MANIFEST,
66
+ releaseNotesUrl: "not-a-url",
67
+ }),
68
+ );
69
+ });
70
+
71
+ it("rejects a releaseNotesUrl that is not https", () => {
72
+ assert.throws(() =>
73
+ UpdateManifestSchema.parse({
74
+ ...GOOD_MANIFEST,
75
+ releaseNotesUrl:
76
+ "http://github.com/remit-mail/reader/releases/tag/v1.5.0",
77
+ }),
78
+ );
79
+ });
80
+
81
+ it("rejects a releaseNotesUrl using a non-http(s) scheme", () => {
82
+ assert.throws(() =>
83
+ UpdateManifestSchema.parse({
84
+ ...GOOD_MANIFEST,
85
+ releaseNotesUrl: "javascript:alert(1)",
86
+ }),
87
+ );
88
+ });
89
+
90
+ it("rejects an empty registry", () => {
91
+ assert.throws(() =>
92
+ UpdateManifestSchema.parse({ ...GOOD_MANIFEST, registry: "" }),
93
+ );
94
+ });
95
+
96
+ it("rejects a manifest missing a required field", () => {
97
+ const { summary, ...rest } = GOOD_MANIFEST;
98
+ assert.throws(() => UpdateManifestSchema.parse(rest));
99
+ });
100
+ });
@@ -0,0 +1,18 @@
1
+ import { z } from "zod";
2
+
3
+ export const SUMMARY_MAX_LENGTH = 140;
4
+
5
+ export const UpdateManifestSchema = z.object({
6
+ version: z.string().regex(/^v\d+\.\d+\.\d+$/),
7
+ publishedAt: z.string().datetime(),
8
+ summary: z.string().min(1).max(SUMMARY_MAX_LENGTH),
9
+ releaseNotesUrl: z
10
+ .string()
11
+ .url()
12
+ .refine((url) => url.startsWith("https://"), {
13
+ message: "releaseNotesUrl must be an https:// URL",
14
+ }),
15
+ registry: z.string().min(1),
16
+ });
17
+
18
+ export type UpdateManifest = z.infer<typeof UpdateManifestSchema>;