@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 +5 -1
- package/src/id.test.ts +38 -1
- package/src/id.ts +23 -0
- package/src/update-manifest.test.ts +100 -0
- package/src/update-manifest.ts +18 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/data-ports",
|
|
3
|
-
"version": "0.0.
|
|
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 {
|
|
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>;
|