@holon-run/agentinbox 1.7.2 → 1.8.1
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 +22 -0
- package/dist/src/cli.js +150 -8
- package/dist/src/client.js +52 -0
- package/dist/src/email_attachment.js +199 -0
- package/dist/src/email_attachment_content.js +759 -0
- package/dist/src/email_body.js +392 -0
- package/dist/src/http.js +147 -3
- package/dist/src/service.js +61 -3
- package/dist/src/source_schema.js +4 -1
- package/dist/src/sources/email.js +72 -3
- package/dist/src/sources/remote_modules.js +2 -1
- package/dist/src/store.js +451 -1
- package/drizzle/migrations/0006_email_body_cache.sql +19 -0
- package/drizzle/migrations/0007_email_attachment_materializations.sql +20 -0
- package/drizzle/migrations/0008_email_attachment_audit.sql +17 -0
- package/drizzle/schema.ts +62 -0
- package/package.json +2 -2
- package/skills/agentinbox/SKILL.md +20 -0
package/README.md
CHANGED
|
@@ -176,9 +176,31 @@ cat filter.json | agentinbox subscription add <source_id> --filter-stdin
|
|
|
176
176
|
agentinbox source event <source_id> --native-id demo-1 --event local.demo
|
|
177
177
|
agentinbox inbox read
|
|
178
178
|
agentinbox inbox read --agent-id <agent_id>
|
|
179
|
+
agentinbox inbox read <entry_id> --agent-id <agent_id>
|
|
180
|
+
agentinbox inbox attachment inspect <attachment_ref> --agent-id <agent_id>
|
|
181
|
+
agentinbox inbox attachment get <attachment_ref> --agent-id <agent_id> --output ./attachment.bin
|
|
182
|
+
agentinbox inbox attachment delete <attachment_ref> --agent-id <agent_id>
|
|
179
183
|
agentinbox inbox ack --agent-id <agent_id> --through <last_entry_id>
|
|
180
184
|
```
|
|
181
185
|
|
|
186
|
+
For an email entry, the positional form reads its normalized body using the
|
|
187
|
+
`entryId` as the only message selector. It uses local content first and may ask
|
|
188
|
+
UXC to retrieve missing content unless `--no-fetch` is set. The default page is
|
|
189
|
+
32 KiB; use `--max-bytes <n>` to change the page budget and repeat the command
|
|
190
|
+
with `--cursor <nextCursor>` when `body.hasMore` is true. Body reads do not ack
|
|
191
|
+
the inbox entry, mark the provider message as read, download attachments, or
|
|
192
|
+
expose the internal UXC message reference. Public attachment metadata uses an
|
|
193
|
+
opaque `attachmentRef`; provider handles and credentials remain internal.
|
|
194
|
+
Email sources default to `attachmentPolicy.mode=metadata`. Set the source
|
|
195
|
+
policy to `store_reference` to allow explicit, bounded `attachment get`
|
|
196
|
+
requests. AgentInbox stores the bytes under its managed data directory, while
|
|
197
|
+
the CLI writes the returned binary response to `--output` without overwriting
|
|
198
|
+
an existing file. Explicit `attachment delete` permanently tombstones that
|
|
199
|
+
attachment reference for its owning agent and reclaims the managed object once
|
|
200
|
+
no live attachment references remain. Retention and the global managed-byte
|
|
201
|
+
limit are also enforced during inbox garbage collection; quarantined content
|
|
202
|
+
remains unreadable and is subject to the same lifecycle rules.
|
|
203
|
+
|
|
182
204
|
The HTTP `POST /subscriptions` endpoint always returns
|
|
183
205
|
`{ "subscriptions": [...] }`, even when only one subscription is created. That
|
|
184
206
|
keeps shortcut expansion consistent for external callers.
|
package/dist/src/cli.js
CHANGED
|
@@ -19,6 +19,7 @@ const service_1 = require("./service");
|
|
|
19
19
|
const store_1 = require("./store");
|
|
20
20
|
const terminal_1 = require("./terminal");
|
|
21
21
|
const util_1 = require("./util");
|
|
22
|
+
const email_attachment_1 = require("./email_attachment");
|
|
22
23
|
async function main() {
|
|
23
24
|
const rawArgs = process.argv.slice(2);
|
|
24
25
|
const detectedArgs = normalizeHelpArgs(rawArgs);
|
|
@@ -747,11 +748,111 @@ async function main() {
|
|
|
747
748
|
await printRemote(client, `/agents/${encodeURIComponent(agentId)}/inbox`, undefined, "GET");
|
|
748
749
|
return;
|
|
749
750
|
}
|
|
751
|
+
if (command === "inbox" && normalized[1] === "attachment" && normalized[2] === "inspect") {
|
|
752
|
+
const args = normalized.slice(3);
|
|
753
|
+
const positionals = positionalArgs(args, ["--agent-id"]);
|
|
754
|
+
const attachmentRef = positionals[0];
|
|
755
|
+
if (positionals.length !== 1
|
|
756
|
+
|| unexpectedFlags(args, ["--agent-id"]).length > 0
|
|
757
|
+
|| !attachmentRef) {
|
|
758
|
+
throw new Error("usage: agentinbox inbox attachment inspect <attachmentRef> [--agent-id ID]");
|
|
759
|
+
}
|
|
760
|
+
const selection = await selectAgentForCommand(client, {
|
|
761
|
+
explicitAgentId: takeFlagValue(normalized, "--agent-id"),
|
|
762
|
+
autoRegister: false,
|
|
763
|
+
});
|
|
764
|
+
const response = await requestRemote(client, `/agents/${encodeURIComponent(selection.agentId)}/inbox/attachments/${encodeURIComponent(attachmentRef)}`, undefined, "GET");
|
|
765
|
+
console.log((0, util_1.jsonResponse)(withCommandMetadata(response.data, selection)));
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
if (command === "inbox" && normalized[1] === "attachment" && normalized[2] === "get") {
|
|
769
|
+
const args = normalized.slice(3);
|
|
770
|
+
const positionals = positionalArgs(args, ["--agent-id", "--output"]);
|
|
771
|
+
const attachmentRef = positionals[0];
|
|
772
|
+
const output = takeFlagValue(args, "--output");
|
|
773
|
+
if (positionals.length !== 1
|
|
774
|
+
|| unexpectedFlags(args, ["--agent-id", "--output"]).length > 0
|
|
775
|
+
|| !attachmentRef
|
|
776
|
+
|| !output) {
|
|
777
|
+
throw new Error("usage: agentinbox inbox attachment get <attachmentRef> [--agent-id ID] --output PATH");
|
|
778
|
+
}
|
|
779
|
+
const selection = await selectAgentForCommand(client, {
|
|
780
|
+
explicitAgentId: takeFlagValue(args, "--agent-id"),
|
|
781
|
+
autoRegister: false,
|
|
782
|
+
});
|
|
783
|
+
const endpoint = `/agents/${encodeURIComponent(selection.agentId)}/inbox/attachments/${encodeURIComponent(attachmentRef)}`;
|
|
784
|
+
const materialized = await requestRemote(client, `${endpoint}/materialize`, {}, "POST");
|
|
785
|
+
const response = await client.requestBytes(`${endpoint}/content`);
|
|
786
|
+
if (response.statusCode < 200 || response.statusCode >= 300) {
|
|
787
|
+
throw new Error(`request GET ${endpoint}/content failed with HTTP ${response.statusCode}: ${binaryErrorText(response.data)}`);
|
|
788
|
+
}
|
|
789
|
+
const contentLength = singleHeaderValue(response.headers["content-length"]);
|
|
790
|
+
if (contentLength != null && Number(contentLength) !== response.data.length) {
|
|
791
|
+
throw new Error(`attachment response length mismatch: expected ${contentLength} bytes, received ${response.data.length}`);
|
|
792
|
+
}
|
|
793
|
+
const outputPath = node_path_1.default.resolve(output);
|
|
794
|
+
writeExclusiveFile(outputPath, response.data);
|
|
795
|
+
console.log((0, util_1.jsonResponse)(withCommandMetadata({
|
|
796
|
+
attachment: materialized.data,
|
|
797
|
+
output: outputPath,
|
|
798
|
+
bytes: response.data.length,
|
|
799
|
+
}, selection)));
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
if (command === "inbox" && normalized[1] === "attachment" && normalized[2] === "delete") {
|
|
803
|
+
const args = normalized.slice(3);
|
|
804
|
+
const positionals = positionalArgs(args, ["--agent-id"]);
|
|
805
|
+
const attachmentRef = positionals[0];
|
|
806
|
+
if (positionals.length !== 1
|
|
807
|
+
|| unexpectedFlags(args, ["--agent-id"]).length > 0
|
|
808
|
+
|| !attachmentRef) {
|
|
809
|
+
throw new Error("usage: agentinbox inbox attachment delete <attachmentRef> [--agent-id ID]");
|
|
810
|
+
}
|
|
811
|
+
const selection = await selectAgentForCommand(client, {
|
|
812
|
+
explicitAgentId: takeFlagValue(args, "--agent-id"),
|
|
813
|
+
autoRegister: false,
|
|
814
|
+
});
|
|
815
|
+
const response = await requestRemote(client, `/agents/${encodeURIComponent(selection.agentId)}/inbox/attachments/${encodeURIComponent(attachmentRef)}/content`, undefined, "DELETE");
|
|
816
|
+
console.log((0, util_1.jsonResponse)(withCommandMetadata(response.data, selection)));
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
750
819
|
if (command === "inbox" && normalized[1] === "read") {
|
|
751
820
|
const args = normalized.slice(2);
|
|
752
|
-
const
|
|
753
|
-
|
|
754
|
-
|
|
821
|
+
const positionals = positionalArgs(args, ["--agent-id", "--after-entry", "--limit", "--max-bytes", "--cursor"]);
|
|
822
|
+
const allowedFlags = [
|
|
823
|
+
"--agent-id", "--after-entry", "--include-acked", "--limit", "--full",
|
|
824
|
+
"--max-bytes", "--no-fetch", "--cursor",
|
|
825
|
+
];
|
|
826
|
+
if (positionals.length > 1 || unexpectedFlags(args, allowedFlags).length > 0) {
|
|
827
|
+
throw new Error("usage: agentinbox inbox read [<entryId> [--agent-id ID] [--max-bytes N] [--no-fetch] [--cursor TOKEN] [--full] | --agent-id ID [--after-entry ID] [--include-acked] [--limit N] [--full]]");
|
|
828
|
+
}
|
|
829
|
+
const entryId = positionals[0];
|
|
830
|
+
if (entryId) {
|
|
831
|
+
if (hasFlag(normalized, "--include-acked") ||
|
|
832
|
+
takeFlagValue(normalized, "--after-entry") !== undefined ||
|
|
833
|
+
takeFlagValue(normalized, "--limit") !== undefined) {
|
|
834
|
+
throw new Error("usage: agentinbox inbox read <entryId> [--agent-id ID] [--max-bytes N] [--no-fetch] [--cursor TOKEN] [--full]");
|
|
835
|
+
}
|
|
836
|
+
const selection = await selectAgentForCommand(client, {
|
|
837
|
+
explicitAgentId: takeFlagValue(normalized, "--agent-id"),
|
|
838
|
+
autoRegister: false,
|
|
839
|
+
});
|
|
840
|
+
const query = buildQuery({
|
|
841
|
+
max_bytes: takeFlagValue(normalized, "--max-bytes"),
|
|
842
|
+
fetch: hasFlag(normalized, "--no-fetch") ? "false" : undefined,
|
|
843
|
+
cursor: takeFlagValue(normalized, "--cursor"),
|
|
844
|
+
});
|
|
845
|
+
const response = await requestRemote(client, `/agents/${encodeURIComponent(selection.agentId)}/inbox/entries/${encodeURIComponent(entryId)}/body${query}`, undefined, "GET");
|
|
846
|
+
console.log((0, util_1.jsonResponse)(withCommandMetadata(response.data, selection)));
|
|
847
|
+
if (response.data.status === "unavailable") {
|
|
848
|
+
process.exitCode = 1;
|
|
849
|
+
}
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
if (takeFlagValue(normalized, "--max-bytes") !== undefined ||
|
|
853
|
+
takeFlagValue(normalized, "--cursor") !== undefined ||
|
|
854
|
+
hasFlag(normalized, "--no-fetch")) {
|
|
855
|
+
throw new Error("usage: agentinbox inbox read <entryId> [--agent-id ID] [--max-bytes N] [--no-fetch] [--cursor TOKEN] [--full]");
|
|
755
856
|
}
|
|
756
857
|
const selection = await selectAgentForCommand(client, {
|
|
757
858
|
explicitAgentId: takeFlagValue(normalized, "--agent-id"),
|
|
@@ -764,8 +865,10 @@ async function main() {
|
|
|
764
865
|
});
|
|
765
866
|
const response = await requestRemote(client, `/agents/${encodeURIComponent(selection.agentId)}/inbox/entries${query}`, undefined, "GET");
|
|
766
867
|
const data = response.data;
|
|
767
|
-
if (
|
|
768
|
-
data.entries = data.entries.map(
|
|
868
|
+
if (Array.isArray(data.entries)) {
|
|
869
|
+
data.entries = data.entries.map((entry) => hasFlag(normalized, "--full")
|
|
870
|
+
? (0, email_attachment_1.projectPublicInboxEntry)(entry)
|
|
871
|
+
: compactInboxEntry(entry));
|
|
769
872
|
}
|
|
770
873
|
console.log((0, util_1.jsonResponse)(withCommandMetadata(data, selection)));
|
|
771
874
|
return;
|
|
@@ -811,9 +914,11 @@ async function main() {
|
|
|
811
914
|
if (event.event !== "items") {
|
|
812
915
|
continue;
|
|
813
916
|
}
|
|
814
|
-
const output =
|
|
917
|
+
const output = {
|
|
815
918
|
...event,
|
|
816
|
-
entries: event.entries.map((
|
|
919
|
+
entries: event.entries.map((entry) => fullMode
|
|
920
|
+
? (0, email_attachment_1.projectPublicInboxEntry)(entry)
|
|
921
|
+
: compactInboxEntry(entry)),
|
|
817
922
|
};
|
|
818
923
|
console.log((0, util_1.jsonResponse)(output));
|
|
819
924
|
}
|
|
@@ -1248,6 +1353,39 @@ async function requestRemote(client, endpoint, body, method = "POST") {
|
|
|
1248
1353
|
}
|
|
1249
1354
|
return { data: response.data };
|
|
1250
1355
|
}
|
|
1356
|
+
function binaryErrorText(data) {
|
|
1357
|
+
const text = data.toString("utf8");
|
|
1358
|
+
if (!text) {
|
|
1359
|
+
return "{}";
|
|
1360
|
+
}
|
|
1361
|
+
try {
|
|
1362
|
+
return (0, util_1.jsonResponse)(JSON.parse(text));
|
|
1363
|
+
}
|
|
1364
|
+
catch {
|
|
1365
|
+
return text;
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
function singleHeaderValue(value) {
|
|
1369
|
+
return Array.isArray(value) ? (value[0] ?? null) : (value ?? null);
|
|
1370
|
+
}
|
|
1371
|
+
function writeExclusiveFile(filePath, data) {
|
|
1372
|
+
const fd = node_fs_1.default.openSync(filePath, "wx", 0o600);
|
|
1373
|
+
let written = false;
|
|
1374
|
+
try {
|
|
1375
|
+
let offset = 0;
|
|
1376
|
+
while (offset < data.length) {
|
|
1377
|
+
offset += node_fs_1.default.writeSync(fd, data, offset, data.length - offset);
|
|
1378
|
+
}
|
|
1379
|
+
node_fs_1.default.fsyncSync(fd);
|
|
1380
|
+
written = true;
|
|
1381
|
+
}
|
|
1382
|
+
finally {
|
|
1383
|
+
node_fs_1.default.closeSync(fd);
|
|
1384
|
+
if (!written) {
|
|
1385
|
+
node_fs_1.default.rmSync(filePath, { force: true });
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1251
1389
|
function noCurrentAgentMessage() {
|
|
1252
1390
|
return "no current agent is registered for this terminal/runtime context; run `agentinbox agent list` to inspect offline agents, then `agentinbox agent register` or `agentinbox agent register --force-rebind --agent-id <agentId>` to rebind one";
|
|
1253
1391
|
}
|
|
@@ -1805,7 +1943,11 @@ Usage:
|
|
|
1805
1943
|
Usage:
|
|
1806
1944
|
agentinbox inbox list [--limit N]
|
|
1807
1945
|
agentinbox inbox show <agentId>
|
|
1808
|
-
agentinbox inbox read [--agent-id ID] [--after-entry ID] [--include-acked] [--limit N] [--full] (aliases: inbox get, inbox entries, inbox peek)
|
|
1946
|
+
agentinbox inbox read [--agent-id ID] [--after-entry ID] [--include-acked] [--limit N] [--full] (list; aliases: inbox get, inbox entries, inbox peek)
|
|
1947
|
+
agentinbox inbox read <entryId> [--agent-id ID] [--max-bytes N] [--no-fetch] [--cursor TOKEN] [--full] (read one email body)
|
|
1948
|
+
agentinbox inbox attachment inspect <attachmentRef> [--agent-id ID]
|
|
1949
|
+
agentinbox inbox attachment get <attachmentRef> [--agent-id ID] --output PATH
|
|
1950
|
+
agentinbox inbox attachment delete <attachmentRef> [--agent-id ID]
|
|
1809
1951
|
agentinbox inbox send --agent-id ID --message TEXT [--sender SENDER]
|
|
1810
1952
|
agentinbox inbox watch [--agent-id ID] [--after-entry ID] [--include-acked] [--heartbeat-ms N] [--full]
|
|
1811
1953
|
agentinbox inbox ack [--agent-id ID] (--through <entryId> | --through-entry-id <entryId> | --entry <entryId> | --entry-id <entryId> | --all)
|
package/dist/src/client.js
CHANGED
|
@@ -17,6 +17,12 @@ class AgentInboxClient {
|
|
|
17
17
|
}
|
|
18
18
|
return requestViaUrl(this.transport.baseUrl, endpoint, body, method);
|
|
19
19
|
}
|
|
20
|
+
async requestBytes(endpoint, method = "GET") {
|
|
21
|
+
if (this.transport.kind === "socket") {
|
|
22
|
+
return requestBytesViaSocket(this.transport.socketPath, endpoint, method);
|
|
23
|
+
}
|
|
24
|
+
return requestBytesViaUrl(this.transport.baseUrl, endpoint, method);
|
|
25
|
+
}
|
|
20
26
|
watchInbox(agentId, options = {}) {
|
|
21
27
|
const query = new URLSearchParams();
|
|
22
28
|
if (options.afterEntryId) {
|
|
@@ -36,6 +42,36 @@ class AgentInboxClient {
|
|
|
36
42
|
}
|
|
37
43
|
}
|
|
38
44
|
exports.AgentInboxClient = AgentInboxClient;
|
|
45
|
+
function requestBytesViaSocket(socketPath, endpoint, method) {
|
|
46
|
+
return new Promise((resolve, reject) => {
|
|
47
|
+
const req = node_http_1.default.request({
|
|
48
|
+
socketPath,
|
|
49
|
+
path: normalizeEndpoint(endpoint),
|
|
50
|
+
method,
|
|
51
|
+
}, (res) => {
|
|
52
|
+
void collectBinaryResponse(res).then(resolve, reject);
|
|
53
|
+
});
|
|
54
|
+
req.on("error", reject);
|
|
55
|
+
req.end();
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
function requestBytesViaUrl(baseUrl, endpoint, method) {
|
|
59
|
+
return new Promise((resolve, reject) => {
|
|
60
|
+
const url = new URL(normalizeEndpoint(endpoint), baseUrl);
|
|
61
|
+
const transport = url.protocol === "https:" ? node_https_1.default : node_http_1.default;
|
|
62
|
+
const req = transport.request({
|
|
63
|
+
protocol: url.protocol,
|
|
64
|
+
hostname: url.hostname,
|
|
65
|
+
port: url.port,
|
|
66
|
+
path: `${url.pathname}${url.search}`,
|
|
67
|
+
method,
|
|
68
|
+
}, (res) => {
|
|
69
|
+
void collectBinaryResponse(res).then(resolve, reject);
|
|
70
|
+
});
|
|
71
|
+
req.on("error", reject);
|
|
72
|
+
req.end();
|
|
73
|
+
});
|
|
74
|
+
}
|
|
39
75
|
function requestViaSocket(socketPath, endpoint, body, method) {
|
|
40
76
|
return new Promise((resolve, reject) => {
|
|
41
77
|
const payload = body ? JSON.stringify(body) : undefined;
|
|
@@ -118,6 +154,22 @@ function watchViaUrl(baseUrl, endpoint) {
|
|
|
118
154
|
return req;
|
|
119
155
|
});
|
|
120
156
|
}
|
|
157
|
+
function collectBinaryResponse(res) {
|
|
158
|
+
return new Promise((resolve, reject) => {
|
|
159
|
+
const chunks = [];
|
|
160
|
+
res.on("data", (chunk) => {
|
|
161
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
162
|
+
});
|
|
163
|
+
res.on("end", () => {
|
|
164
|
+
resolve({
|
|
165
|
+
statusCode: res.statusCode ?? 500,
|
|
166
|
+
data: Buffer.concat(chunks),
|
|
167
|
+
headers: res.headers,
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
res.on("error", reject);
|
|
171
|
+
});
|
|
172
|
+
}
|
|
121
173
|
function collectResponse(res) {
|
|
122
174
|
return new Promise((resolve, reject) => {
|
|
123
175
|
const chunks = [];
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.publicEmailAttachmentCollection = publicEmailAttachmentCollection;
|
|
7
|
+
exports.findPublicEmailAttachment = findPublicEmailAttachment;
|
|
8
|
+
exports.resolveEmailAttachment = resolveEmailAttachment;
|
|
9
|
+
exports.parseEmailAttachmentRef = parseEmailAttachmentRef;
|
|
10
|
+
exports.projectPublicInboxEntry = projectPublicInboxEntry;
|
|
11
|
+
exports.redactInternalEmailRefs = redactInternalEmailRefs;
|
|
12
|
+
const node_crypto_1 = __importDefault(require("node:crypto"));
|
|
13
|
+
const ATTACHMENT_REF_PREFIX = "att_v1";
|
|
14
|
+
const ATTACHMENT_SELECTOR_LENGTH = 22;
|
|
15
|
+
const ATTACHMENT_REF_PATTERN = new RegExp(`^${ATTACHMENT_REF_PREFIX}\\.([A-Za-z0-9_-]+)\\.([A-Za-z0-9_-]{${ATTACHMENT_SELECTOR_LENGTH}})$`);
|
|
16
|
+
function publicEmailAttachmentCollection(itemId, metadata, materializationLookup) {
|
|
17
|
+
const rawAttachments = Array.isArray(metadata?.attachments)
|
|
18
|
+
? metadata.attachments.filter(isRecord)
|
|
19
|
+
: [];
|
|
20
|
+
const attachmentCount = optionalInteger(metadata?.attachmentCount ?? metadata?.attachment_count);
|
|
21
|
+
const hasAttachments = rawAttachments.length > 0
|
|
22
|
+
|| (attachmentCount !== null && attachmentCount > 0)
|
|
23
|
+
|| metadata?.hasAttachments === true
|
|
24
|
+
|| metadata?.has_attachments === true;
|
|
25
|
+
const explicitCompleteness = optionalBoolean(metadata?.attachmentsComplete ?? metadata?.attachments_complete);
|
|
26
|
+
const attachmentsComplete = explicitCompleteness
|
|
27
|
+
?? (attachmentCount !== null
|
|
28
|
+
? attachmentCount === rawAttachments.length
|
|
29
|
+
: (metadata?.hasAttachments === false || metadata?.has_attachments === false)
|
|
30
|
+
&& rawAttachments.length === 0);
|
|
31
|
+
return {
|
|
32
|
+
hasAttachments,
|
|
33
|
+
attachmentCount,
|
|
34
|
+
attachmentsComplete,
|
|
35
|
+
attachments: rawAttachments.map((attachment, ordinal) => {
|
|
36
|
+
const selector = selectorForPublicAttachment(itemId, attachment, ordinal);
|
|
37
|
+
return publicEmailAttachment(itemId, attachment, ordinal, materializationLookup?.(itemId, selector) ?? null);
|
|
38
|
+
}),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function findPublicEmailAttachment(item, attachmentRef) {
|
|
42
|
+
return resolveEmailAttachment(item, attachmentRef)?.attachment ?? null;
|
|
43
|
+
}
|
|
44
|
+
function resolveEmailAttachment(item, attachmentRef, materializationLookup) {
|
|
45
|
+
const parsed = parseEmailAttachmentRef(attachmentRef);
|
|
46
|
+
if (!parsed || parsed.itemId !== item.itemId) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
const rawAttachments = Array.isArray(item.metadata?.attachments)
|
|
50
|
+
? item.metadata.attachments.filter(isRecord)
|
|
51
|
+
: [];
|
|
52
|
+
for (const [ordinal, rawAttachment] of rawAttachments.entries()) {
|
|
53
|
+
const selector = selectorForPublicAttachment(item.itemId, rawAttachment, ordinal);
|
|
54
|
+
if (selector !== parsed.selector) {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
attachmentSelector: selector,
|
|
59
|
+
attachment: publicEmailAttachment(item.itemId, rawAttachment, ordinal, materializationLookup?.(item.itemId, selector) ?? null),
|
|
60
|
+
handle: isEmailAttachmentHandle(rawAttachment.handle) ? rawAttachment.handle : null,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
function parseEmailAttachmentRef(attachmentRef) {
|
|
66
|
+
const match = ATTACHMENT_REF_PATTERN.exec(attachmentRef);
|
|
67
|
+
return match ? { itemId: match[1], selector: match[2] } : null;
|
|
68
|
+
}
|
|
69
|
+
function projectPublicInboxEntry(value, materializationLookup) {
|
|
70
|
+
if (!isRecord(value)) {
|
|
71
|
+
return redactInternalEmailRefs(value);
|
|
72
|
+
}
|
|
73
|
+
const eventVariant = optionalString(value.eventVariant);
|
|
74
|
+
const itemId = optionalString(value.itemId);
|
|
75
|
+
const projected = redactInternalEmailRefs(value);
|
|
76
|
+
if (eventVariant !== "email.message.received" || !itemId) {
|
|
77
|
+
return projected;
|
|
78
|
+
}
|
|
79
|
+
projected.metadata = projectEmailMetadata(itemId, isRecord(value.metadata) ? value.metadata : {}, materializationLookup);
|
|
80
|
+
projected.rawPayload = projectEmailRawPayload(itemId, isRecord(value.rawPayload) ? value.rawPayload : {}, materializationLookup);
|
|
81
|
+
delete projected.providerRawPayload;
|
|
82
|
+
if (isRecord(value.item) && isRecord(projected.item)) {
|
|
83
|
+
projected.item.metadata = projectEmailMetadata(itemId, isRecord(value.item.metadata) ? value.item.metadata : {}, materializationLookup);
|
|
84
|
+
projected.item.rawPayload = projectEmailRawPayload(itemId, isRecord(value.item.rawPayload) ? value.item.rawPayload : {}, materializationLookup);
|
|
85
|
+
delete projected.item.providerRawPayload;
|
|
86
|
+
}
|
|
87
|
+
return projected;
|
|
88
|
+
}
|
|
89
|
+
function redactInternalEmailRefs(value) {
|
|
90
|
+
if (Array.isArray(value)) {
|
|
91
|
+
return value.map(redactInternalEmailRefs);
|
|
92
|
+
}
|
|
93
|
+
if (!isRecord(value)) {
|
|
94
|
+
return value;
|
|
95
|
+
}
|
|
96
|
+
const result = {};
|
|
97
|
+
for (const [key, child] of Object.entries(value)) {
|
|
98
|
+
if (key === "message_ref" || key === "messageRef") {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (key === "handle" && isEmailAttachmentHandle(child)) {
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
result[key] = redactInternalEmailRefs(child);
|
|
105
|
+
}
|
|
106
|
+
return result;
|
|
107
|
+
}
|
|
108
|
+
function projectEmailMetadata(itemId, metadata, materializationLookup) {
|
|
109
|
+
return {
|
|
110
|
+
...redactInternalEmailRefs(metadata),
|
|
111
|
+
...publicEmailAttachmentCollection(itemId, metadata, materializationLookup),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
function projectEmailRawPayload(itemId, payload, materializationLookup) {
|
|
115
|
+
const projected = redactInternalEmailRefs(payload);
|
|
116
|
+
if (!isRecord(payload.message) || !isRecord(projected.message)) {
|
|
117
|
+
return projected;
|
|
118
|
+
}
|
|
119
|
+
const collection = publicEmailAttachmentCollection(itemId, {
|
|
120
|
+
attachments: payload.message.attachments,
|
|
121
|
+
has_attachments: payload.message.has_attachments,
|
|
122
|
+
attachment_count: payload.message.attachment_count,
|
|
123
|
+
attachments_complete: payload.message.attachments_complete,
|
|
124
|
+
}, materializationLookup);
|
|
125
|
+
projected.message.attachments = collection.attachments;
|
|
126
|
+
projected.message.attachments_complete = collection.attachmentsComplete;
|
|
127
|
+
return projected;
|
|
128
|
+
}
|
|
129
|
+
function publicEmailAttachment(itemId, attachment, ordinal, materialization) {
|
|
130
|
+
const attachmentSelector = selectorForPublicAttachment(itemId, attachment, ordinal);
|
|
131
|
+
const handleRetrievable = isEmailAttachmentHandle(attachment.handle);
|
|
132
|
+
const status = materialization?.status
|
|
133
|
+
?? emailAttachmentStatus(attachment.status)
|
|
134
|
+
?? (handleRetrievable ? "remote_only" : "metadata_only");
|
|
135
|
+
return {
|
|
136
|
+
attachmentRef: `${ATTACHMENT_REF_PREFIX}.${itemId}.${attachmentSelector}`,
|
|
137
|
+
filename: optionalString(attachment.filename),
|
|
138
|
+
contentType: optionalString(attachment.content_type) ?? optionalString(attachment.contentType),
|
|
139
|
+
detectedContentType: materialization?.detectedContentType
|
|
140
|
+
?? optionalString(attachment.detectedContentType)
|
|
141
|
+
?? optionalString(attachment.detected_content_type),
|
|
142
|
+
size: optionalInteger(attachment.size),
|
|
143
|
+
disposition: attachment.disposition === "attachment" || attachment.disposition === "inline"
|
|
144
|
+
? attachment.disposition
|
|
145
|
+
: null,
|
|
146
|
+
contentId: optionalString(attachment.content_id) ?? optionalString(attachment.contentId),
|
|
147
|
+
status,
|
|
148
|
+
retrievable: handleRetrievable || status === "available",
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
function selectorForPublicAttachment(itemId, attachment, ordinal) {
|
|
152
|
+
const existingRef = optionalString(attachment.attachmentRef);
|
|
153
|
+
const parsed = existingRef ? parseEmailAttachmentRef(existingRef) : null;
|
|
154
|
+
return parsed?.itemId === itemId ? parsed.selector : attachmentSelector(attachment, ordinal);
|
|
155
|
+
}
|
|
156
|
+
function attachmentSelector(attachment, ordinal) {
|
|
157
|
+
const stableId = optionalString(attachment.id);
|
|
158
|
+
const identity = stableId
|
|
159
|
+
? { id: stableId }
|
|
160
|
+
: {
|
|
161
|
+
ordinal,
|
|
162
|
+
filename: optionalString(attachment.filename),
|
|
163
|
+
contentType: optionalString(attachment.content_type) ?? optionalString(attachment.contentType),
|
|
164
|
+
size: optionalInteger(attachment.size),
|
|
165
|
+
disposition: optionalString(attachment.disposition),
|
|
166
|
+
contentId: optionalString(attachment.content_id) ?? optionalString(attachment.contentId),
|
|
167
|
+
};
|
|
168
|
+
return node_crypto_1.default.createHash("sha256")
|
|
169
|
+
.update(JSON.stringify(identity))
|
|
170
|
+
.digest("base64url")
|
|
171
|
+
.slice(0, ATTACHMENT_SELECTOR_LENGTH);
|
|
172
|
+
}
|
|
173
|
+
function isEmailAttachmentHandle(value) {
|
|
174
|
+
return isRecord(value) && value.type === "email_attachment";
|
|
175
|
+
}
|
|
176
|
+
function emailAttachmentStatus(value) {
|
|
177
|
+
return value === "metadata_only"
|
|
178
|
+
|| value === "remote_only"
|
|
179
|
+
|| value === "pending"
|
|
180
|
+
|| value === "available"
|
|
181
|
+
|| value === "quarantined"
|
|
182
|
+
|| value === "rejected"
|
|
183
|
+
|| value === "failed"
|
|
184
|
+
|| value === "deleted"
|
|
185
|
+
? value
|
|
186
|
+
: null;
|
|
187
|
+
}
|
|
188
|
+
function isRecord(value) {
|
|
189
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
190
|
+
}
|
|
191
|
+
function optionalString(value) {
|
|
192
|
+
return typeof value === "string" ? value : null;
|
|
193
|
+
}
|
|
194
|
+
function optionalInteger(value) {
|
|
195
|
+
return Number.isSafeInteger(value) && Number(value) >= 0 ? Number(value) : null;
|
|
196
|
+
}
|
|
197
|
+
function optionalBoolean(value) {
|
|
198
|
+
return typeof value === "boolean" ? value : null;
|
|
199
|
+
}
|