@holon-run/agentinbox 1.7.2 → 1.8.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 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 allowedFlags = ["--agent-id", "--after-entry", "--include-acked", "--limit", "--full"];
753
- if (positionalArgs(args, ["--agent-id", "--after-entry", "--limit"]).length > 0 || unexpectedFlags(args, allowedFlags).length > 0) {
754
- throw new Error("usage: agentinbox inbox read [--agent-id ID] [--after-entry ID] [--include-acked] [--limit N] [--full]");
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 (!hasFlag(normalized, "--full") && Array.isArray(data.entries)) {
768
- data.entries = data.entries.map(compactInboxEntry);
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 = fullMode ? event : {
917
+ const output = {
815
918
  ...event,
816
- entries: event.entries.map((e) => compactInboxEntry(e)),
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)
@@ -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
+ }