@chainlesschain/personal-data-hub 0.1.0
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/README.md +241 -0
- package/__tests__/adapter-spec.test.js +78 -0
- package/__tests__/adapters/email-adapter.test.js +605 -0
- package/__tests__/adapters/email-imap-session.test.js +334 -0
- package/__tests__/adapters/email-parser.test.js +244 -0
- package/__tests__/adapters/email-providers.test.js +84 -0
- package/__tests__/analysis.test.js +302 -0
- package/__tests__/batch.test.js +133 -0
- package/__tests__/bridges-cc-kg.test.js +231 -0
- package/__tests__/bridges-cc-llm.test.js +191 -0
- package/__tests__/bridges-cc-rag.test.js +162 -0
- package/__tests__/ids.test.js +45 -0
- package/__tests__/key-providers.test.js +126 -0
- package/__tests__/kg-derive.test.js +219 -0
- package/__tests__/llm-client.test.js +122 -0
- package/__tests__/mock-adapter.test.js +93 -0
- package/__tests__/prompt-builder.test.js +204 -0
- package/__tests__/query-parser.test.js +150 -0
- package/__tests__/rag-derive.test.js +169 -0
- package/__tests__/registry.test.js +304 -0
- package/__tests__/schemas.test.js +331 -0
- package/__tests__/vault.test.js +506 -0
- package/lib/adapter-spec.js +155 -0
- package/lib/adapters/email-imap/email-adapter.js +398 -0
- package/lib/adapters/email-imap/email-parser.js +177 -0
- package/lib/adapters/email-imap/imap-session.js +294 -0
- package/lib/adapters/email-imap/index.js +26 -0
- package/lib/adapters/email-imap/providers.js +111 -0
- package/lib/analysis.js +226 -0
- package/lib/batch.js +123 -0
- package/lib/bridges/cc-kg-sink.js +264 -0
- package/lib/bridges/cc-llm-adapter.js +169 -0
- package/lib/bridges/cc-rag-sink.js +118 -0
- package/lib/bridges/index.js +44 -0
- package/lib/constants.js +92 -0
- package/lib/ids.js +103 -0
- package/lib/index.js +141 -0
- package/lib/key-providers.js +146 -0
- package/lib/kg-derive.js +214 -0
- package/lib/llm-client.js +171 -0
- package/lib/migrations.js +246 -0
- package/lib/mock-adapter.js +199 -0
- package/lib/prompt-builder.js +205 -0
- package/lib/query-parser.js +250 -0
- package/lib/rag-derive.js +186 -0
- package/lib/registry.js +398 -0
- package/lib/schemas.js +379 -0
- package/lib/vault.js +883 -0
- package/package.json +63 -0
- package/vitest.config.js +10 -0
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect } from "vitest";
|
|
4
|
+
|
|
5
|
+
const {
|
|
6
|
+
ImapSession,
|
|
7
|
+
ImapAuthFailedError,
|
|
8
|
+
ImapConnectionFailedError,
|
|
9
|
+
ImapMailboxNotFoundError,
|
|
10
|
+
} = require("../../lib/adapters/email-imap/imap-session");
|
|
11
|
+
|
|
12
|
+
function makeFakeFactory({
|
|
13
|
+
connectThrows = null,
|
|
14
|
+
mailboxThrows = null,
|
|
15
|
+
fetchRows = [],
|
|
16
|
+
mailboxInfo = { uidValidity: 1, uidNext: 100, exists: 0 },
|
|
17
|
+
} = {}) {
|
|
18
|
+
return function FakeImapFlow(_opts) {
|
|
19
|
+
return {
|
|
20
|
+
connect: async () => {
|
|
21
|
+
if (connectThrows) throw connectThrows;
|
|
22
|
+
},
|
|
23
|
+
mailboxOpen: async (name) => {
|
|
24
|
+
if (mailboxThrows) throw mailboxThrows;
|
|
25
|
+
return { path: name, name, ...mailboxInfo };
|
|
26
|
+
},
|
|
27
|
+
list: async () => [
|
|
28
|
+
{ name: "INBOX", path: "INBOX", flags: [], specialUse: null },
|
|
29
|
+
{ name: "Sent", path: "Sent", flags: [], specialUse: "\\Sent" },
|
|
30
|
+
],
|
|
31
|
+
fetch: function () {
|
|
32
|
+
const rows = fetchRows.slice();
|
|
33
|
+
let i = 0;
|
|
34
|
+
return {
|
|
35
|
+
[Symbol.asyncIterator]() {
|
|
36
|
+
return {
|
|
37
|
+
next: async () =>
|
|
38
|
+
i < rows.length
|
|
39
|
+
? { value: rows[i++], done: false }
|
|
40
|
+
: { value: undefined, done: true },
|
|
41
|
+
};
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
},
|
|
45
|
+
logout: async () => {},
|
|
46
|
+
close: async () => {},
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe("ImapSession construction", () => {
|
|
52
|
+
it("rejects missing required opts", () => {
|
|
53
|
+
expect(() => new ImapSession()).toThrow();
|
|
54
|
+
expect(() => new ImapSession({})).toThrow(/host/);
|
|
55
|
+
expect(() => new ImapSession({ host: "x", port: 993, user: "u" })).toThrow(/authCode/);
|
|
56
|
+
expect(() => new ImapSession({ host: "x", port: 993, authCode: "z" })).toThrow(/user/);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("constructs cleanly with required opts", () => {
|
|
60
|
+
const s = new ImapSession({
|
|
61
|
+
host: "imap.test", port: 993, secure: true, user: "u@test.com", authCode: "abc",
|
|
62
|
+
});
|
|
63
|
+
expect(s.host).toBe("imap.test");
|
|
64
|
+
expect(s.port).toBe(993);
|
|
65
|
+
expect(s.secure).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("defaults secure=true when not specified", () => {
|
|
69
|
+
const s = new ImapSession({ host: "x", port: 993, user: "u@t", authCode: "z" });
|
|
70
|
+
expect(s.secure).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("ImapSession.connect", () => {
|
|
75
|
+
it("connects successfully with injected factory", async () => {
|
|
76
|
+
const s = new ImapSession({
|
|
77
|
+
host: "x", port: 993, user: "u@t", authCode: "z",
|
|
78
|
+
imapFlowFactory: makeFakeFactory(),
|
|
79
|
+
});
|
|
80
|
+
await expect(s.connect()).resolves.toBeUndefined();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("classifies auth failure as ImapAuthFailedError", async () => {
|
|
84
|
+
const s = new ImapSession({
|
|
85
|
+
host: "x", port: 993, user: "u@t", authCode: "z",
|
|
86
|
+
imapFlowFactory: makeFakeFactory({
|
|
87
|
+
connectThrows: new Error("Authentication failed - invalid credentials"),
|
|
88
|
+
}),
|
|
89
|
+
});
|
|
90
|
+
await expect(s.connect()).rejects.toBeInstanceOf(ImapAuthFailedError);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("classifies generic network errors as ImapConnectionFailedError", async () => {
|
|
94
|
+
const s = new ImapSession({
|
|
95
|
+
host: "x", port: 993, user: "u@t", authCode: "z",
|
|
96
|
+
imapFlowFactory: makeFakeFactory({
|
|
97
|
+
connectThrows: new Error("ECONNREFUSED"),
|
|
98
|
+
}),
|
|
99
|
+
});
|
|
100
|
+
await expect(s.connect()).rejects.toBeInstanceOf(ImapConnectionFailedError);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("enforces connectTimeoutMs", async () => {
|
|
104
|
+
function slowFactory() {
|
|
105
|
+
return {
|
|
106
|
+
connect: () => new Promise(() => {}),
|
|
107
|
+
close: async () => {},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
const s = new ImapSession({
|
|
111
|
+
host: "x", port: 993, user: "u@t", authCode: "z",
|
|
112
|
+
connectTimeoutMs: 50,
|
|
113
|
+
imapFlowFactory: slowFactory,
|
|
114
|
+
});
|
|
115
|
+
await expect(s.connect()).rejects.toThrow(/timed out/);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("requires connect() before openMailbox", async () => {
|
|
119
|
+
const s = new ImapSession({
|
|
120
|
+
host: "x", port: 993, user: "u@t", authCode: "z",
|
|
121
|
+
imapFlowFactory: makeFakeFactory(),
|
|
122
|
+
});
|
|
123
|
+
await expect(s.openMailbox("INBOX")).rejects.toThrow(/not connected/);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("ImapSession.openMailbox", () => {
|
|
128
|
+
it("returns uidValidity / uidNext / exists snapshot", async () => {
|
|
129
|
+
const s = new ImapSession({
|
|
130
|
+
host: "x", port: 993, user: "u@t", authCode: "z",
|
|
131
|
+
imapFlowFactory: makeFakeFactory({
|
|
132
|
+
mailboxInfo: { uidValidity: 42, uidNext: 200, exists: 150 },
|
|
133
|
+
}),
|
|
134
|
+
});
|
|
135
|
+
await s.connect();
|
|
136
|
+
const r = await s.openMailbox("INBOX");
|
|
137
|
+
expect(r.uidValidity).toBe(42);
|
|
138
|
+
expect(r.uidNext).toBe(200);
|
|
139
|
+
expect(r.exists).toBe(150);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("converts 'not found' messages to ImapMailboxNotFoundError", async () => {
|
|
143
|
+
const s = new ImapSession({
|
|
144
|
+
host: "x", port: 993, user: "u@t", authCode: "z",
|
|
145
|
+
imapFlowFactory: makeFakeFactory({
|
|
146
|
+
mailboxThrows: new Error("Mailbox doesn't exist: NoSuch"),
|
|
147
|
+
}),
|
|
148
|
+
});
|
|
149
|
+
await s.connect();
|
|
150
|
+
await expect(s.openMailbox("NoSuch")).rejects.toBeInstanceOf(ImapMailboxNotFoundError);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("rejects non-string mailbox name", async () => {
|
|
154
|
+
const s = new ImapSession({
|
|
155
|
+
host: "x", port: 993, user: "u@t", authCode: "z",
|
|
156
|
+
imapFlowFactory: makeFakeFactory(),
|
|
157
|
+
});
|
|
158
|
+
await s.connect();
|
|
159
|
+
await expect(s.openMailbox("")).rejects.toThrow(/non-empty string/);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe("ImapSession.listMailboxes", () => {
|
|
164
|
+
it("returns name / path / flags / specialUse", async () => {
|
|
165
|
+
const s = new ImapSession({
|
|
166
|
+
host: "x", port: 993, user: "u@t", authCode: "z",
|
|
167
|
+
imapFlowFactory: makeFakeFactory(),
|
|
168
|
+
});
|
|
169
|
+
await s.connect();
|
|
170
|
+
const list = await s.listMailboxes();
|
|
171
|
+
expect(list).toHaveLength(2);
|
|
172
|
+
expect(list[0].name).toBe("INBOX");
|
|
173
|
+
expect(list[1].specialUse).toBe("\\Sent");
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe("ImapSession.fetchEnvelopesSince", () => {
|
|
178
|
+
it("yields normalized envelope rows", async () => {
|
|
179
|
+
const fakeRow = {
|
|
180
|
+
uid: 42,
|
|
181
|
+
internalDate: new Date("2026-04-01T10:00:00Z"),
|
|
182
|
+
flags: new Set(["\\Seen"]),
|
|
183
|
+
size: 1234,
|
|
184
|
+
envelope: {
|
|
185
|
+
messageId: "<msg-1@example.com>",
|
|
186
|
+
subject: "Hello",
|
|
187
|
+
from: [{ name: "Alice", address: "alice@example.com" }],
|
|
188
|
+
to: [{ name: "Me", address: "me@example.com" }],
|
|
189
|
+
cc: [],
|
|
190
|
+
date: new Date("2026-04-01T10:00:00Z"),
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
const s = new ImapSession({
|
|
194
|
+
host: "x", port: 993, user: "u@t", authCode: "z",
|
|
195
|
+
imapFlowFactory: makeFakeFactory({ fetchRows: [fakeRow] }),
|
|
196
|
+
});
|
|
197
|
+
await s.connect();
|
|
198
|
+
await s.openMailbox("INBOX");
|
|
199
|
+
const got = [];
|
|
200
|
+
for await (const env of s.fetchEnvelopesSince(0)) got.push(env);
|
|
201
|
+
expect(got).toHaveLength(1);
|
|
202
|
+
expect(got[0].uid).toBe(42);
|
|
203
|
+
expect(got[0].subject).toBe("Hello");
|
|
204
|
+
expect(got[0].messageId).toBe("<msg-1@example.com>");
|
|
205
|
+
expect(got[0].from[0].address).toBe("alice@example.com");
|
|
206
|
+
expect(got[0].flags).toEqual(["\\Seen"]);
|
|
207
|
+
expect(got[0].size).toBe(1234);
|
|
208
|
+
expect(got[0].internalDate).toBeInstanceOf(Date);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("emits empty for an empty mailbox", async () => {
|
|
212
|
+
const s = new ImapSession({
|
|
213
|
+
host: "x", port: 993, user: "u@t", authCode: "z",
|
|
214
|
+
imapFlowFactory: makeFakeFactory({ fetchRows: [] }),
|
|
215
|
+
});
|
|
216
|
+
await s.connect();
|
|
217
|
+
await s.openMailbox("INBOX");
|
|
218
|
+
const got = [];
|
|
219
|
+
for await (const env of s.fetchEnvelopesSince(0)) got.push(env);
|
|
220
|
+
expect(got).toEqual([]);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("preserves order from underlying iterator", async () => {
|
|
224
|
+
const rows = [10, 20, 30].map((uid) => ({
|
|
225
|
+
uid,
|
|
226
|
+
internalDate: new Date(),
|
|
227
|
+
flags: [],
|
|
228
|
+
size: 0,
|
|
229
|
+
envelope: { messageId: `<m-${uid}@x>`, subject: `s${uid}`, from: [], to: [], cc: [], date: new Date() },
|
|
230
|
+
}));
|
|
231
|
+
const s = new ImapSession({
|
|
232
|
+
host: "x", port: 993, user: "u@t", authCode: "z",
|
|
233
|
+
imapFlowFactory: makeFakeFactory({ fetchRows: rows }),
|
|
234
|
+
});
|
|
235
|
+
await s.connect();
|
|
236
|
+
await s.openMailbox("INBOX");
|
|
237
|
+
const uids = [];
|
|
238
|
+
for await (const env of s.fetchEnvelopesSince(0)) uids.push(env.uid);
|
|
239
|
+
expect(uids).toEqual([10, 20, 30]);
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
describe("ImapSession.fetchFullSince", () => {
|
|
244
|
+
it("yields source bytes alongside envelope", async () => {
|
|
245
|
+
const raw = Buffer.from("RAW RFC822 SOURCE BYTES", "utf8");
|
|
246
|
+
const fakeRow = {
|
|
247
|
+
uid: 100,
|
|
248
|
+
internalDate: new Date(),
|
|
249
|
+
flags: [],
|
|
250
|
+
size: raw.length,
|
|
251
|
+
source: raw,
|
|
252
|
+
envelope: {
|
|
253
|
+
messageId: "<m@x>",
|
|
254
|
+
subject: "s",
|
|
255
|
+
from: [{ name: "A", address: "a@b" }],
|
|
256
|
+
to: [],
|
|
257
|
+
cc: [],
|
|
258
|
+
date: new Date(),
|
|
259
|
+
},
|
|
260
|
+
};
|
|
261
|
+
const s = new ImapSession({
|
|
262
|
+
host: "x", port: 993, user: "u@t", authCode: "z",
|
|
263
|
+
imapFlowFactory: makeFakeFactory({ fetchRows: [fakeRow] }),
|
|
264
|
+
});
|
|
265
|
+
await s.connect();
|
|
266
|
+
await s.openMailbox("INBOX");
|
|
267
|
+
const got = [];
|
|
268
|
+
for await (const row of s.fetchFullSince(0)) got.push(row);
|
|
269
|
+
expect(got).toHaveLength(1);
|
|
270
|
+
expect(Buffer.isBuffer(got[0].source)).toBe(true);
|
|
271
|
+
expect(got[0].source.toString()).toBe("RAW RFC822 SOURCE BYTES");
|
|
272
|
+
expect(got[0].uid).toBe(100);
|
|
273
|
+
expect(got[0].subject).toBe("s");
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("emits empty Buffer when server returns no source", async () => {
|
|
277
|
+
const fakeRow = {
|
|
278
|
+
uid: 1,
|
|
279
|
+
internalDate: new Date(),
|
|
280
|
+
flags: [],
|
|
281
|
+
size: 0,
|
|
282
|
+
// no `source` field
|
|
283
|
+
envelope: { messageId: "<m@x>", subject: "", from: [], to: [], cc: [], date: new Date() },
|
|
284
|
+
};
|
|
285
|
+
const s = new ImapSession({
|
|
286
|
+
host: "x", port: 993, user: "u@t", authCode: "z",
|
|
287
|
+
imapFlowFactory: makeFakeFactory({ fetchRows: [fakeRow] }),
|
|
288
|
+
});
|
|
289
|
+
await s.connect();
|
|
290
|
+
await s.openMailbox("INBOX");
|
|
291
|
+
const got = [];
|
|
292
|
+
for await (const row of s.fetchFullSince(0)) got.push(row);
|
|
293
|
+
expect(Buffer.isBuffer(got[0].source)).toBe(true);
|
|
294
|
+
expect(got[0].source.length).toBe(0);
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
describe("ImapSession.close", () => {
|
|
299
|
+
it("is idempotent on never-connected session", async () => {
|
|
300
|
+
const s = new ImapSession({
|
|
301
|
+
host: "x", port: 993, user: "u@t", authCode: "z",
|
|
302
|
+
imapFlowFactory: makeFakeFactory(),
|
|
303
|
+
});
|
|
304
|
+
await expect(s.close()).resolves.toBeUndefined();
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("clears client reference so post-close calls fail clean", async () => {
|
|
308
|
+
const s = new ImapSession({
|
|
309
|
+
host: "x", port: 993, user: "u@t", authCode: "z",
|
|
310
|
+
imapFlowFactory: makeFakeFactory(),
|
|
311
|
+
});
|
|
312
|
+
await s.connect();
|
|
313
|
+
await s.close();
|
|
314
|
+
await expect(s.openMailbox("INBOX")).rejects.toThrow(/not connected/);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it("doesn't fail when underlying logout throws", async () => {
|
|
318
|
+
let logoutCalled = false;
|
|
319
|
+
function factory() {
|
|
320
|
+
return {
|
|
321
|
+
connect: async () => {},
|
|
322
|
+
logout: async () => { logoutCalled = true; throw new Error("conn already dead"); },
|
|
323
|
+
close: async () => {},
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
const s = new ImapSession({
|
|
327
|
+
host: "x", port: 993, user: "u@t", authCode: "z",
|
|
328
|
+
imapFlowFactory: factory,
|
|
329
|
+
});
|
|
330
|
+
await s.connect();
|
|
331
|
+
await expect(s.close()).resolves.toBeUndefined();
|
|
332
|
+
expect(logoutCalled).toBe(true);
|
|
333
|
+
});
|
|
334
|
+
});
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect } from "vitest";
|
|
4
|
+
|
|
5
|
+
const { parseRawEmail } = require("../../lib/adapters/email-imap/email-parser");
|
|
6
|
+
|
|
7
|
+
// ─── Fixture builders ───────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
function plaintextEmail({
|
|
10
|
+
from = "alice@example.com",
|
|
11
|
+
to = "bob@example.com",
|
|
12
|
+
subject = "Hello",
|
|
13
|
+
body = "Hello, world!",
|
|
14
|
+
date = "Fri, 19 May 2026 10:00:00 +0800",
|
|
15
|
+
messageId = "<m-1@example.com>",
|
|
16
|
+
} = {}) {
|
|
17
|
+
return [
|
|
18
|
+
`From: ${from}`,
|
|
19
|
+
`To: ${to}`,
|
|
20
|
+
`Subject: ${subject}`,
|
|
21
|
+
`Date: ${date}`,
|
|
22
|
+
`Message-ID: ${messageId}`,
|
|
23
|
+
`Content-Type: text/plain; charset=utf-8`,
|
|
24
|
+
`Content-Transfer-Encoding: 7bit`,
|
|
25
|
+
``,
|
|
26
|
+
body,
|
|
27
|
+
].join("\r\n");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function multipartAlternative({ text = "plain version", html = "<p>html <b>version</b></p>" } = {}) {
|
|
31
|
+
const boundary = "BOUNDARY_42";
|
|
32
|
+
return [
|
|
33
|
+
`From: sender@example.com`,
|
|
34
|
+
`To: me@example.com`,
|
|
35
|
+
`Subject: Multipart`,
|
|
36
|
+
`MIME-Version: 1.0`,
|
|
37
|
+
`Content-Type: multipart/alternative; boundary="${boundary}"`,
|
|
38
|
+
``,
|
|
39
|
+
`--${boundary}`,
|
|
40
|
+
`Content-Type: text/plain; charset=utf-8`,
|
|
41
|
+
``,
|
|
42
|
+
text,
|
|
43
|
+
`--${boundary}`,
|
|
44
|
+
`Content-Type: text/html; charset=utf-8`,
|
|
45
|
+
``,
|
|
46
|
+
html,
|
|
47
|
+
`--${boundary}--`,
|
|
48
|
+
].join("\r\n");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function multipartWithAttachment({ filename = "report.pdf", contentType = "application/pdf", body = "%PDF-1.4\n/Encrypt placeholder\n" } = {}) {
|
|
52
|
+
const boundary = "MIXED_99";
|
|
53
|
+
const b64 = Buffer.from(body).toString("base64");
|
|
54
|
+
return [
|
|
55
|
+
`From: bank@example.com`,
|
|
56
|
+
`To: me@example.com`,
|
|
57
|
+
`Subject: Your statement`,
|
|
58
|
+
`MIME-Version: 1.0`,
|
|
59
|
+
`Content-Type: multipart/mixed; boundary="${boundary}"`,
|
|
60
|
+
``,
|
|
61
|
+
`--${boundary}`,
|
|
62
|
+
`Content-Type: text/plain`,
|
|
63
|
+
``,
|
|
64
|
+
`See attached statement.`,
|
|
65
|
+
`--${boundary}`,
|
|
66
|
+
`Content-Type: ${contentType}; name="${filename}"`,
|
|
67
|
+
`Content-Disposition: attachment; filename="${filename}"`,
|
|
68
|
+
`Content-Transfer-Encoding: base64`,
|
|
69
|
+
``,
|
|
70
|
+
b64,
|
|
71
|
+
`--${boundary}--`,
|
|
72
|
+
].join("\r\n");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ─── parseRawEmail ──────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
describe("parseRawEmail — basic shape", () => {
|
|
78
|
+
it("rejects null/missing input", async () => {
|
|
79
|
+
await expect(parseRawEmail(null)).rejects.toThrow(/required/);
|
|
80
|
+
await expect(parseRawEmail()).rejects.toThrow(/required/);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("accepts a Buffer", async () => {
|
|
84
|
+
const buf = Buffer.from(plaintextEmail(), "utf8");
|
|
85
|
+
const r = await parseRawEmail(buf);
|
|
86
|
+
expect(r.subject).toBe("Hello");
|
|
87
|
+
expect(r.textBody).toContain("Hello, world!");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("accepts a string", async () => {
|
|
91
|
+
const r = await parseRawEmail(plaintextEmail());
|
|
92
|
+
expect(r.subject).toBe("Hello");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("returns sourceBytes + contentSha256", async () => {
|
|
96
|
+
const raw = plaintextEmail();
|
|
97
|
+
const r = await parseRawEmail(raw);
|
|
98
|
+
expect(r.sourceBytes).toBe(Buffer.byteLength(raw));
|
|
99
|
+
expect(r.contentSha256).toMatch(/^[0-9a-f]{64}$/);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("flattens headers to lowercased-keyed object", async () => {
|
|
103
|
+
const r = await parseRawEmail(plaintextEmail());
|
|
104
|
+
expect(r.headers["subject"]).toBe("Hello");
|
|
105
|
+
expect(r.headers["from"]).toBeDefined();
|
|
106
|
+
expect(r.headers["message-id"]).toBe("<m-1@example.com>");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("parses Date header", async () => {
|
|
110
|
+
const r = await parseRawEmail(plaintextEmail());
|
|
111
|
+
expect(r.date).toBeInstanceOf(Date);
|
|
112
|
+
expect(r.date.getUTCFullYear()).toBe(2026);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("parseRawEmail — multipart", () => {
|
|
117
|
+
it("multipart/alternative yields both text + html bodies", async () => {
|
|
118
|
+
const r = await parseRawEmail(multipartAlternative());
|
|
119
|
+
expect(r.textBody).toContain("plain version");
|
|
120
|
+
expect(r.htmlBody).toContain("html");
|
|
121
|
+
expect(r.htmlBody).toContain("version");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("multipart/mixed with attachment yields attachment metadata", async () => {
|
|
125
|
+
const r = await parseRawEmail(multipartWithAttachment());
|
|
126
|
+
expect(r.attachments).toHaveLength(1);
|
|
127
|
+
const a = r.attachments[0];
|
|
128
|
+
expect(a.filename).toBe("report.pdf");
|
|
129
|
+
expect(a.contentType).toContain("pdf");
|
|
130
|
+
expect(a.size).toBeGreaterThan(0);
|
|
131
|
+
expect(a.sha256).toMatch(/^[0-9a-f]{64}$/);
|
|
132
|
+
expect(a.contentDisposition).toBe("attachment");
|
|
133
|
+
expect(a.isInline).toBe(false);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("flags encrypted PDFs heuristically (/Encrypt header in body)", async () => {
|
|
137
|
+
const r = await parseRawEmail(multipartWithAttachment({
|
|
138
|
+
filename: "encrypted.pdf",
|
|
139
|
+
body: "%PDF-1.4\n%fake header\n/Encrypt <<\n/V 4\n>>\n",
|
|
140
|
+
}));
|
|
141
|
+
expect(r.attachments[0].isEncrypted).toBe(true);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("doesn't flag plaintext PDFs as encrypted", async () => {
|
|
145
|
+
const r = await parseRawEmail(multipartWithAttachment({
|
|
146
|
+
body: "%PDF-1.4\nplain content no encrypt token\n",
|
|
147
|
+
}));
|
|
148
|
+
expect(r.attachments[0].isEncrypted).toBe(false);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("attachment buffer is omitted by default", async () => {
|
|
152
|
+
const r = await parseRawEmail(multipartWithAttachment());
|
|
153
|
+
expect(r.attachments[0].buffer).toBeUndefined();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("keepAttachmentBuffers:true attaches the decoded buffer", async () => {
|
|
157
|
+
const r = await parseRawEmail(multipartWithAttachment(), { keepAttachmentBuffers: true });
|
|
158
|
+
expect(Buffer.isBuffer(r.attachments[0].buffer)).toBe(true);
|
|
159
|
+
expect(r.attachments[0].buffer.toString()).toContain("%PDF-1.4");
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe("parseRawEmail — encoding handling", () => {
|
|
164
|
+
it("decodes UTF-8 body correctly", async () => {
|
|
165
|
+
const utf8Body = "妈妈生日蛋白粉 ¥288.50";
|
|
166
|
+
const r = await parseRawEmail(plaintextEmail({ body: utf8Body }));
|
|
167
|
+
expect(r.textBody).toContain("妈妈生日蛋白粉");
|
|
168
|
+
expect(r.textBody).toContain("¥288.50");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("decodes GBK-encoded body (via Content-Type charset)", async () => {
|
|
172
|
+
// Build raw bytes: header in UTF-8, body in GBK.
|
|
173
|
+
const headerAscii =
|
|
174
|
+
`From: sender@example.com\r\n` +
|
|
175
|
+
`To: me@example.com\r\n` +
|
|
176
|
+
`Subject: 中文\r\n` +
|
|
177
|
+
`MIME-Version: 1.0\r\n` +
|
|
178
|
+
`Content-Type: text/plain; charset=GBK\r\n\r\n`;
|
|
179
|
+
// GBK bytes for "妈妈生日": dc e8 dc e8 c9 fa c8 d5
|
|
180
|
+
const gbkBody = Buffer.from([0xc2, 0xe8, 0xc2, 0xe8, 0xc9, 0xfa, 0xc8, 0xd5]);
|
|
181
|
+
const raw = Buffer.concat([Buffer.from(headerAscii, "ascii"), gbkBody]);
|
|
182
|
+
const r = await parseRawEmail(raw);
|
|
183
|
+
// mailparser+iconv-lite converts GBK to UTF-8. We can't assert exact
|
|
184
|
+
// unicode without locking in iconv tables, but we can verify the
|
|
185
|
+
// body is non-empty + got past the GBK byte sequence (which is
|
|
186
|
+
// invalid UTF-8 — would error if left as raw).
|
|
187
|
+
expect(r.textBody.length).toBeGreaterThan(0);
|
|
188
|
+
expect(r.headers["content-type"]).toBeDefined();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("RFC 2047 encoded subject is decoded", async () => {
|
|
192
|
+
const raw = [
|
|
193
|
+
`From: alice@example.com`,
|
|
194
|
+
`To: me@example.com`,
|
|
195
|
+
// =?UTF-8?B?5aaI5aaIc/eIuOWlh+aBpQ==?= (just a sample base64)
|
|
196
|
+
`Subject: =?UTF-8?B?5aaI5aaI?=`,
|
|
197
|
+
`Date: Fri, 19 May 2026 10:00:00 +0800`,
|
|
198
|
+
`Message-ID: <m@x>`,
|
|
199
|
+
`Content-Type: text/plain; charset=utf-8`,
|
|
200
|
+
``,
|
|
201
|
+
`body`,
|
|
202
|
+
].join("\r\n");
|
|
203
|
+
const r = await parseRawEmail(raw);
|
|
204
|
+
expect(r.subject).toBe("妈妈");
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe("parseRawEmail — truncation + edge cases", () => {
|
|
209
|
+
it("truncates bodies longer than maxBodyChars", async () => {
|
|
210
|
+
const longBody = "X".repeat(500_000);
|
|
211
|
+
const r = await parseRawEmail(plaintextEmail({ body: longBody }), { maxBodyChars: 1000 });
|
|
212
|
+
expect(r.textBody.length).toBeLessThan(longBody.length);
|
|
213
|
+
expect(r.textBody).toMatch(/truncated/);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("returns empty bodies for emails with no payload", async () => {
|
|
217
|
+
const raw = [
|
|
218
|
+
`From: x@x.com`,
|
|
219
|
+
`To: y@y.com`,
|
|
220
|
+
`Subject: empty`,
|
|
221
|
+
`Date: Fri, 19 May 2026 10:00:00 +0800`,
|
|
222
|
+
`Message-ID: <m@x>`,
|
|
223
|
+
``,
|
|
224
|
+
``,
|
|
225
|
+
].join("\r\n");
|
|
226
|
+
const r = await parseRawEmail(raw);
|
|
227
|
+
expect(r.textBody).toBe("");
|
|
228
|
+
expect(r.htmlBody).toBe("");
|
|
229
|
+
expect(r.attachments).toEqual([]);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("contentSha256 is deterministic for identical input", async () => {
|
|
233
|
+
const raw = plaintextEmail();
|
|
234
|
+
const r1 = await parseRawEmail(raw);
|
|
235
|
+
const r2 = await parseRawEmail(raw);
|
|
236
|
+
expect(r1.contentSha256).toBe(r2.contentSha256);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("contentSha256 differs across different inputs", async () => {
|
|
240
|
+
const r1 = await parseRawEmail(plaintextEmail({ body: "v1" }));
|
|
241
|
+
const r2 = await parseRawEmail(plaintextEmail({ body: "v2" }));
|
|
242
|
+
expect(r1.contentSha256).not.toBe(r2.contentSha256);
|
|
243
|
+
});
|
|
244
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect } from "vitest";
|
|
4
|
+
|
|
5
|
+
const {
|
|
6
|
+
PROVIDERS,
|
|
7
|
+
resolveProvider,
|
|
8
|
+
} = require("../../lib/adapters/email-imap/providers");
|
|
9
|
+
|
|
10
|
+
describe("EMAIL_PROVIDERS preset table", () => {
|
|
11
|
+
it("exposes qq / 189 / 163 / outlook / gmail", () => {
|
|
12
|
+
expect(PROVIDERS.qq.host).toBe("imap.qq.com");
|
|
13
|
+
expect(PROVIDERS["189"].host).toBe("imap.189.cn");
|
|
14
|
+
expect(PROVIDERS["163"].host).toBe("imap.163.com");
|
|
15
|
+
expect(PROVIDERS.outlook.host).toBe("outlook.office365.com");
|
|
16
|
+
expect(PROVIDERS.gmail.host).toBe("imap.gmail.com");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("every preset uses port 993 + TLS by default", () => {
|
|
20
|
+
for (const p of Object.values(PROVIDERS)) {
|
|
21
|
+
expect(p.port).toBe(993);
|
|
22
|
+
expect(p.secure).toBe(true);
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("each preset advertises an authNote pointing at authorization-code, not password", () => {
|
|
27
|
+
for (const p of Object.values(PROVIDERS)) {
|
|
28
|
+
expect(p.authNote.length).toBeGreaterThan(10);
|
|
29
|
+
}
|
|
30
|
+
expect(PROVIDERS.qq.authNote).toMatch(/授权码|auth.*code|app password/i);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("resolveProvider", () => {
|
|
35
|
+
it("returns preset config when no overrides", () => {
|
|
36
|
+
const r = resolveProvider({ provider: "qq" });
|
|
37
|
+
expect(r.host).toBe("imap.qq.com");
|
|
38
|
+
expect(r.port).toBe(993);
|
|
39
|
+
expect(r.secure).toBe(true);
|
|
40
|
+
expect(r.folders).toEqual(["INBOX", "Sent Messages"]);
|
|
41
|
+
expect(r.providerId).toBe("qq");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("respects user overrides", () => {
|
|
45
|
+
const r = resolveProvider({
|
|
46
|
+
provider: "qq",
|
|
47
|
+
host: "imap.example.com",
|
|
48
|
+
port: 143,
|
|
49
|
+
secure: false,
|
|
50
|
+
folders: ["INBOX", "Drafts", "Custom"],
|
|
51
|
+
displayName: "My Mailbox",
|
|
52
|
+
});
|
|
53
|
+
expect(r.host).toBe("imap.example.com");
|
|
54
|
+
expect(r.port).toBe(143);
|
|
55
|
+
expect(r.secure).toBe(false);
|
|
56
|
+
expect(r.folders).toEqual(["INBOX", "Drafts", "Custom"]);
|
|
57
|
+
expect(r.displayName).toBe("My Mailbox");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("rejects unknown provider", () => {
|
|
61
|
+
expect(() => resolveProvider({ provider: "myproto" })).toThrow(/unknown provider/i);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("custom provider requires host", () => {
|
|
65
|
+
expect(() => resolveProvider({ provider: "custom" })).toThrow(/host/);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("custom provider applies sensible defaults", () => {
|
|
69
|
+
const r = resolveProvider({
|
|
70
|
+
provider: "custom",
|
|
71
|
+
host: "mail.acme.test",
|
|
72
|
+
});
|
|
73
|
+
expect(r.host).toBe("mail.acme.test");
|
|
74
|
+
expect(r.port).toBe(993);
|
|
75
|
+
expect(r.secure).toBe(true);
|
|
76
|
+
expect(r.folders).toEqual(["INBOX"]);
|
|
77
|
+
expect(r.providerId).toBe("custom");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("rejects null / wrong-type account", () => {
|
|
81
|
+
expect(() => resolveProvider()).toThrow();
|
|
82
|
+
expect(() => resolveProvider(null)).toThrow();
|
|
83
|
+
});
|
|
84
|
+
});
|