@abraca/resend 2.16.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.
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Find or create the Inbox + Outbox documents under the bound Space.
3
+ *
4
+ * Idempotent: any existing top-level doc labelled "Inbox" or "Outbox" is reused
5
+ * as-is — the user is allowed to rename, recolor, or restructure these docs
6
+ * after they're created. Missing columns under the Outbox kanban are filled in.
7
+ */
8
+ import { makeEntryMap, toPlain } from "@abraca/dabra";
9
+ import * as Y from "yjs";
10
+ import type { AbracadabraResendServer } from "./server.ts";
11
+
12
+ export const INBOX_LABEL = "Inbox";
13
+ export const OUTBOX_LABEL = "Outbox";
14
+
15
+ export const OUTBOX_COLUMNS = ["Draft", "Ready", "Sent", "Failed"] as const;
16
+ export type OutboxColumn = (typeof OUTBOX_COLUMNS)[number];
17
+
18
+ export interface BootstrapResult {
19
+ inboxId: string;
20
+ outboxId: string;
21
+ columns: Record<OutboxColumn, string>;
22
+ }
23
+
24
+ interface TreeEntry {
25
+ id: string;
26
+ label: string;
27
+ parentId: string | null;
28
+ order: number;
29
+ type?: string;
30
+ }
31
+
32
+ function readEntries(
33
+ treeMap: Y.Map<any>,
34
+ selfId: string | null,
35
+ ): TreeEntry[] {
36
+ const entries: TreeEntry[] = [];
37
+ treeMap.forEach((raw: any, id: string) => {
38
+ if (selfId && id === selfId) return;
39
+ const value = toPlain(raw) as any;
40
+ if (typeof value !== "object" || value === null) return;
41
+ entries.push({
42
+ id,
43
+ label: typeof value.label === "string" ? value.label : "Untitled",
44
+ parentId: value.parentId ?? null,
45
+ order: typeof value.order === "number" ? value.order : 0,
46
+ type: typeof value.type === "string" ? value.type : undefined,
47
+ });
48
+ });
49
+ return entries;
50
+ }
51
+
52
+ async function createDoc(
53
+ server: AbracadabraResendServer,
54
+ opts: {
55
+ parentTreeId: string | null;
56
+ restParentId: string;
57
+ label: string;
58
+ type?: string;
59
+ meta?: Record<string, unknown>;
60
+ order?: number;
61
+ },
62
+ ): Promise<string> {
63
+ const treeMap = server.getTreeMap();
64
+ const rootDoc = server.rootDocument;
65
+ if (!treeMap || !rootDoc) {
66
+ throw new Error("Cannot create doc — server is not connected.");
67
+ }
68
+ const id = crypto.randomUUID();
69
+ const now = Date.now();
70
+
71
+ // Register the SQL row first so RBAC inheritance can walk
72
+ // `documents.parent_id` (matches @abraca/mcp's create_document path).
73
+ await server.client.createChild(opts.restParentId, {
74
+ child_id: id,
75
+ label: opts.label,
76
+ doc_type: opts.type,
77
+ kind: "page",
78
+ });
79
+
80
+ rootDoc.transact(() => {
81
+ treeMap.set(
82
+ id,
83
+ makeEntryMap({
84
+ label: opts.label,
85
+ parentId: opts.parentTreeId,
86
+ order: opts.order ?? now,
87
+ type: opts.type,
88
+ meta: opts.meta as any,
89
+ createdAt: now,
90
+ updatedAt: now,
91
+ }),
92
+ );
93
+ });
94
+
95
+ return id;
96
+ }
97
+
98
+ export async function bootstrap(
99
+ server: AbracadabraResendServer,
100
+ ): Promise<BootstrapResult> {
101
+ const treeMap = server.getTreeMap();
102
+ const spaceDocId = server.spaceDocId;
103
+ if (!treeMap || !spaceDocId) {
104
+ throw new Error("Cannot bootstrap — server is not connected.");
105
+ }
106
+ const entries = readEntries(treeMap, spaceDocId);
107
+
108
+ const topLevel = entries.filter((e) => e.parentId === null);
109
+ const existingInbox = topLevel.find(
110
+ (e) => e.label.trim().toLowerCase() === INBOX_LABEL.toLowerCase(),
111
+ );
112
+ const existingOutbox = topLevel.find(
113
+ (e) => e.label.trim().toLowerCase() === OUTBOX_LABEL.toLowerCase(),
114
+ );
115
+
116
+ // ── Inbox ──────────────────────────────────────────────────────────────
117
+ let inboxId: string;
118
+ if (existingInbox) {
119
+ inboxId = existingInbox.id;
120
+ console.error(
121
+ `[abracadabra-resend] Inbox found: ${inboxId} (${existingInbox.label})`,
122
+ );
123
+ } else {
124
+ inboxId = await createDoc(server, {
125
+ parentTreeId: null,
126
+ restParentId: spaceDocId,
127
+ label: INBOX_LABEL,
128
+ type: "gallery",
129
+ meta: { icon: "inbox" },
130
+ });
131
+ console.error(`[abracadabra-resend] Inbox created: ${inboxId}`);
132
+ }
133
+
134
+ // ── Outbox ─────────────────────────────────────────────────────────────
135
+ let outboxId: string;
136
+ if (existingOutbox) {
137
+ outboxId = existingOutbox.id;
138
+ console.error(
139
+ `[abracadabra-resend] Outbox found: ${outboxId} (${existingOutbox.label})`,
140
+ );
141
+ } else {
142
+ outboxId = await createDoc(server, {
143
+ parentTreeId: null,
144
+ restParentId: spaceDocId,
145
+ label: OUTBOX_LABEL,
146
+ type: "kanban",
147
+ meta: { icon: "send" },
148
+ });
149
+ console.error(`[abracadabra-resend] Outbox created: ${outboxId}`);
150
+ }
151
+
152
+ // ── Outbox columns ─────────────────────────────────────────────────────
153
+ // Re-read entries — we may have just created Inbox/Outbox.
154
+ const refreshedEntries = readEntries(treeMap, spaceDocId);
155
+ const existingColumns = refreshedEntries.filter(
156
+ (e) => e.parentId === outboxId,
157
+ );
158
+
159
+ const columns = {} as Record<OutboxColumn, string>;
160
+ for (const colLabel of OUTBOX_COLUMNS) {
161
+ const match = existingColumns.find(
162
+ (e) => e.label.trim().toLowerCase() === colLabel.toLowerCase(),
163
+ );
164
+ if (match) {
165
+ columns[colLabel] = match.id;
166
+ continue;
167
+ }
168
+ const id = await createDoc(server, {
169
+ parentTreeId: outboxId,
170
+ restParentId: outboxId,
171
+ label: colLabel,
172
+ // Kanban columns inherit view from the parent kanban — no type.
173
+ order: Date.now() + OUTBOX_COLUMNS.indexOf(colLabel),
174
+ });
175
+ columns[colLabel] = id;
176
+ console.error(
177
+ `[abracadabra-resend] Outbox column "${colLabel}" created: ${id}`,
178
+ );
179
+ }
180
+
181
+ return { inboxId, outboxId, columns };
182
+ }
package/src/crypto.ts ADDED
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Ed25519 key generation, persistence, and challenge signing for the Resend bridge.
3
+ * Same shape as @abraca/mcp's crypto module; intentionally vendored so this package
4
+ * doesn't depend on @abraca/mcp.
5
+ */
6
+ import * as ed from "@noble/ed25519";
7
+ import { sha512 } from "@noble/hashes/sha2.js";
8
+ import { existsSync } from "node:fs";
9
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
10
+ import { homedir } from "node:os";
11
+ import { dirname, join } from "node:path";
12
+
13
+ ed.hashes.sha512 = sha512;
14
+ ed.hashes.sha512Async = (m: Uint8Array) => Promise.resolve(sha512(m));
15
+
16
+ const DEFAULT_KEY_PATH = join(homedir(), ".abracadabra", "resend.key");
17
+
18
+ function toBase64url(bytes: Uint8Array): string {
19
+ return Buffer.from(bytes).toString("base64url");
20
+ }
21
+
22
+ function fromBase64url(b64: string): Uint8Array {
23
+ return new Uint8Array(Buffer.from(b64, "base64url"));
24
+ }
25
+
26
+ export interface AgentKeypair {
27
+ privateKey: Uint8Array;
28
+ publicKeyB64: string;
29
+ }
30
+
31
+ export async function loadOrCreateKeypair(
32
+ keyPath?: string,
33
+ ): Promise<AgentKeypair> {
34
+ const path = keyPath || DEFAULT_KEY_PATH;
35
+
36
+ if (existsSync(path)) {
37
+ const seed = await readFile(path);
38
+ if (seed.length !== 32) {
39
+ throw new Error(
40
+ `Invalid key file at ${path}: expected 32 bytes, got ${seed.length}`,
41
+ );
42
+ }
43
+ const privateKey = new Uint8Array(seed);
44
+ const publicKey = ed.getPublicKey(privateKey);
45
+ return { privateKey, publicKeyB64: toBase64url(publicKey) };
46
+ }
47
+
48
+ const privateKey = ed.utils.randomSecretKey();
49
+ const publicKey = ed.getPublicKey(privateKey);
50
+
51
+ const dir = dirname(path);
52
+ if (!existsSync(dir)) {
53
+ await mkdir(dir, { recursive: true, mode: 0o700 });
54
+ }
55
+ await writeFile(path, Buffer.from(privateKey), { mode: 0o600 });
56
+
57
+ console.error(`[abracadabra-resend] Generated new agent keypair at ${path}`);
58
+ console.error(
59
+ `[abracadabra-resend] Public key: ${toBase64url(publicKey)}`,
60
+ );
61
+
62
+ return { privateKey, publicKeyB64: toBase64url(publicKey) };
63
+ }
64
+
65
+ export function signChallenge(
66
+ challengeB64: string,
67
+ privateKey: Uint8Array,
68
+ ): string {
69
+ const challenge = fromBase64url(challengeB64);
70
+ const sig = ed.sign(challenge, privateKey);
71
+ return toBase64url(sig);
72
+ }
@@ -0,0 +1,413 @@
1
+ /**
2
+ * Inbound webhook HTTP server.
3
+ *
4
+ * Single POST route: `/inbound`. Verifies Svix signature, parses Resend Inbound
5
+ * payload, creates a child doc under the Inbox, uploads attachments. Operator
6
+ * is responsible for exposing the port publicly (tunnel / reverse proxy) and
7
+ * configuring Resend Inbound to deliver here.
8
+ */
9
+ import { makeEntryMap } from "@abraca/dabra";
10
+ import { populateYDocFromMarkdown as populateBody } from "@abraca/convert";
11
+ import { createHmac, timingSafeEqual } from "node:crypto";
12
+ import {
13
+ createServer,
14
+ type IncomingMessage,
15
+ type Server,
16
+ type ServerResponse,
17
+ } from "node:http";
18
+ import type { BootstrapResult } from "./bootstrap.ts";
19
+ import type { AbracadabraResendServer } from "./server.ts";
20
+ import { waitForSync } from "./utils.ts";
21
+
22
+ export interface InboundServerOptions {
23
+ server: AbracadabraResendServer;
24
+ bootstrap: BootstrapResult;
25
+ secret: string;
26
+ port?: number;
27
+ host?: string;
28
+ /** Reject deliveries whose timestamp is older than this many seconds. */
29
+ toleranceSeconds?: number;
30
+ }
31
+
32
+ interface ResendInboundAttachment {
33
+ filename?: string;
34
+ contentType?: string;
35
+ content?: string; // base64
36
+ }
37
+
38
+ interface ResendInboundData {
39
+ id?: string;
40
+ from?: string | { email?: string; name?: string };
41
+ to?: Array<string | { email?: string; name?: string }> | string;
42
+ cc?: Array<string | { email?: string; name?: string }> | string;
43
+ subject?: string;
44
+ text?: string;
45
+ html?: string;
46
+ headers?: Array<{ name: string; value: string }>;
47
+ attachments?: ResendInboundAttachment[];
48
+ created_at?: string;
49
+ }
50
+
51
+ interface ResendInboundEnvelope {
52
+ type?: string;
53
+ data?: ResendInboundData;
54
+ }
55
+
56
+ function addr(v: unknown): string | undefined {
57
+ if (typeof v === "string") return v.trim() || undefined;
58
+ if (v && typeof v === "object") {
59
+ const obj = v as { email?: string; name?: string };
60
+ if (typeof obj.email === "string") return obj.email.trim() || undefined;
61
+ }
62
+ return undefined;
63
+ }
64
+
65
+ function addrList(v: unknown): string[] {
66
+ if (Array.isArray(v))
67
+ return v.map(addr).filter((s): s is string => !!s);
68
+ const one = addr(v);
69
+ return one ? [one] : [];
70
+ }
71
+
72
+ function pickHeader(
73
+ headers: Array<{ name: string; value: string }> | undefined,
74
+ name: string,
75
+ ): string | undefined {
76
+ if (!headers) return undefined;
77
+ const lower = name.toLowerCase();
78
+ for (const h of headers) {
79
+ if (typeof h?.name === "string" && h.name.toLowerCase() === lower) {
80
+ return h.value;
81
+ }
82
+ }
83
+ return undefined;
84
+ }
85
+
86
+ function decodeSecret(secret: string): Buffer {
87
+ // Resend / Svix webhook secrets are `whsec_<base64>`. The HMAC key is the
88
+ // base64-decoded bytes after the prefix. Accept raw secrets too so the
89
+ // operator can supply a hex/base64 key directly if their plan differs.
90
+ const stripped = secret.startsWith("whsec_") ? secret.slice(6) : secret;
91
+ try {
92
+ return Buffer.from(stripped, "base64");
93
+ } catch {
94
+ return Buffer.from(stripped);
95
+ }
96
+ }
97
+
98
+ function constantTimeEquals(a: Buffer, b: Buffer): boolean {
99
+ if (a.length !== b.length) return false;
100
+ return timingSafeEqual(a, b);
101
+ }
102
+
103
+ /**
104
+ * Verify Svix-style signature. Headers carry `svix-id`, `svix-timestamp`,
105
+ * `svix-signature` (space-separated `v1,<base64>` pairs). The signing input
106
+ * is `${id}.${timestamp}.${body}` HMAC-SHA256 with the decoded secret.
107
+ */
108
+ function verifySignature(
109
+ body: string,
110
+ headers: IncomingMessage["headers"],
111
+ secret: Buffer,
112
+ toleranceSeconds: number,
113
+ ): { ok: true } | { ok: false; reason: string } {
114
+ const id = headers["svix-id"];
115
+ const ts = headers["svix-timestamp"];
116
+ const sig = headers["svix-signature"];
117
+ if (typeof id !== "string" || typeof ts !== "string" || typeof sig !== "string") {
118
+ return { ok: false, reason: "missing Svix headers" };
119
+ }
120
+ const tsNum = Number(ts);
121
+ if (!Number.isFinite(tsNum)) {
122
+ return { ok: false, reason: "invalid svix-timestamp" };
123
+ }
124
+ const ageSec = Math.abs(Math.floor(Date.now() / 1000) - tsNum);
125
+ if (ageSec > toleranceSeconds) {
126
+ return { ok: false, reason: `delivery too old (${ageSec}s)` };
127
+ }
128
+
129
+ const toSign = `${id}.${ts}.${body}`;
130
+ const expected = createHmac("sha256", secret).update(toSign).digest();
131
+
132
+ const candidates = sig.split(" ").map((s) => s.trim()).filter(Boolean);
133
+ for (const candidate of candidates) {
134
+ const [version, value] = candidate.split(",");
135
+ if (version !== "v1" || !value) continue;
136
+ let provided: Buffer;
137
+ try {
138
+ provided = Buffer.from(value, "base64");
139
+ } catch {
140
+ continue;
141
+ }
142
+ if (constantTimeEquals(expected, provided)) return { ok: true };
143
+ }
144
+ return { ok: false, reason: "signature mismatch" };
145
+ }
146
+
147
+ function readBody(req: IncomingMessage, maxBytes = 25 * 1024 * 1024): Promise<string> {
148
+ return new Promise((resolve, reject) => {
149
+ const chunks: Buffer[] = [];
150
+ let size = 0;
151
+ req.on("data", (chunk: Buffer) => {
152
+ size += chunk.length;
153
+ if (size > maxBytes) {
154
+ reject(new Error(`payload too large (>${maxBytes} bytes)`));
155
+ req.destroy();
156
+ return;
157
+ }
158
+ chunks.push(chunk);
159
+ });
160
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
161
+ req.on("error", reject);
162
+ });
163
+ }
164
+
165
+ export class InboundServer {
166
+ private readonly server: AbracadabraResendServer;
167
+ private readonly bootstrap: BootstrapResult;
168
+ private readonly secret: Buffer;
169
+ private readonly toleranceSeconds: number;
170
+ private readonly port: number;
171
+ private readonly host: string;
172
+ private httpServer: Server | null = null;
173
+ /** De-dupes redelivered webhooks. Svix may retry on transient failures. */
174
+ private readonly seenSvixIds = new Set<string>();
175
+
176
+ constructor(opts: InboundServerOptions) {
177
+ this.server = opts.server;
178
+ this.bootstrap = opts.bootstrap;
179
+ this.secret = decodeSecret(opts.secret);
180
+ this.toleranceSeconds = opts.toleranceSeconds ?? 5 * 60;
181
+ this.port = opts.port ?? 0;
182
+ this.host = opts.host ?? "0.0.0.0";
183
+ }
184
+
185
+ async start(): Promise<number> {
186
+ this.httpServer = createServer((req, res) => {
187
+ void this.handle(req, res);
188
+ });
189
+ await new Promise<void>((resolve, reject) => {
190
+ this.httpServer!.once("error", reject);
191
+ this.httpServer!.listen(this.port, this.host, () => resolve());
192
+ });
193
+ const address = this.httpServer.address();
194
+ const boundPort =
195
+ typeof address === "object" && address ? address.port : this.port;
196
+ console.error(
197
+ `[abracadabra-resend] Inbound server listening on http://${this.host}:${boundPort}/inbound`,
198
+ );
199
+ return boundPort;
200
+ }
201
+
202
+ async stop(): Promise<void> {
203
+ if (!this.httpServer) return;
204
+ await new Promise<void>((resolve, reject) => {
205
+ this.httpServer!.close((err) => (err ? reject(err) : resolve()));
206
+ });
207
+ this.httpServer = null;
208
+ }
209
+
210
+ private async handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
211
+ try {
212
+ if (req.method === "GET" && (req.url === "/health" || req.url === "/")) {
213
+ res.writeHead(200, { "content-type": "text/plain" });
214
+ res.end("ok");
215
+ return;
216
+ }
217
+ if (req.method !== "POST" || req.url !== "/inbound") {
218
+ res.writeHead(404, { "content-type": "text/plain" });
219
+ res.end("not found");
220
+ return;
221
+ }
222
+ const body = await readBody(req);
223
+ const check = verifySignature(
224
+ body,
225
+ req.headers,
226
+ this.secret,
227
+ this.toleranceSeconds,
228
+ );
229
+ if (!check.ok) {
230
+ console.error(`[abracadabra-resend] inbound rejected: ${check.reason}`);
231
+ res.writeHead(401, { "content-type": "text/plain" });
232
+ res.end("unauthorized");
233
+ return;
234
+ }
235
+
236
+ const svixId =
237
+ typeof req.headers["svix-id"] === "string"
238
+ ? (req.headers["svix-id"] as string)
239
+ : null;
240
+ if (svixId && this.seenSvixIds.has(svixId)) {
241
+ res.writeHead(200, { "content-type": "text/plain" });
242
+ res.end("duplicate");
243
+ return;
244
+ }
245
+ if (svixId) {
246
+ this.seenSvixIds.add(svixId);
247
+ if (this.seenSvixIds.size > 5000) {
248
+ const first = this.seenSvixIds.values().next().value;
249
+ if (first) this.seenSvixIds.delete(first);
250
+ }
251
+ }
252
+
253
+ let env: ResendInboundEnvelope;
254
+ try {
255
+ env = JSON.parse(body);
256
+ } catch {
257
+ res.writeHead(400, { "content-type": "text/plain" });
258
+ res.end("invalid json");
259
+ return;
260
+ }
261
+
262
+ const data = env?.data ?? (env as unknown as ResendInboundData);
263
+ await this.ingest(data);
264
+
265
+ res.writeHead(200, { "content-type": "text/plain" });
266
+ res.end("ok");
267
+ } catch (err: any) {
268
+ console.error(
269
+ `[abracadabra-resend] inbound handler error: ${err?.message ?? err}`,
270
+ );
271
+ res.writeHead(500, { "content-type": "text/plain" });
272
+ res.end("error");
273
+ }
274
+ }
275
+
276
+ private async ingest(data: ResendInboundData): Promise<void> {
277
+ await this.server.ensureConnected();
278
+ const treeMap = this.server.getTreeMap();
279
+ const rootDoc = this.server.rootDocument;
280
+ if (!treeMap || !rootDoc) {
281
+ throw new Error("Cannot ingest — server is not connected.");
282
+ }
283
+
284
+ const subjectRaw = typeof data.subject === "string" ? data.subject.trim() : "";
285
+ const subject = subjectRaw.length > 0 ? subjectRaw : "(no subject)";
286
+ const from = addr(data.from) ?? "(unknown)";
287
+ const to = addrList(data.to);
288
+ const cc = addrList(data.cc);
289
+ const inReplyTo = pickHeader(data.headers, "In-Reply-To");
290
+ const messageId =
291
+ pickHeader(data.headers, "Message-ID") ?? data.id ?? null;
292
+
293
+ const inboxId = this.bootstrap.inboxId;
294
+ const id = crypto.randomUUID();
295
+ const now = Date.now();
296
+
297
+ const meta: Record<string, unknown> = {
298
+ icon: "mail",
299
+ from,
300
+ to,
301
+ cc: cc.length ? cc : undefined,
302
+ subject,
303
+ receivedAt: now,
304
+ messageId,
305
+ inReplyTo,
306
+ };
307
+ if (typeof data.html === "string" && data.html.length > 0) {
308
+ meta.html = data.html;
309
+ }
310
+
311
+ // SQL row first, then Yjs entry — RBAC cascade depends on it.
312
+ await this.server.client.createChild(inboxId, {
313
+ child_id: id,
314
+ label: subject,
315
+ doc_type: "doc",
316
+ kind: "page",
317
+ });
318
+
319
+ rootDoc.transact(() => {
320
+ treeMap.set(
321
+ id,
322
+ makeEntryMap({
323
+ label: subject,
324
+ parentId: inboxId,
325
+ order: now,
326
+ type: "doc",
327
+ meta: meta as any,
328
+ createdAt: now,
329
+ updatedAt: now,
330
+ }),
331
+ );
332
+ });
333
+
334
+ // Populate the body with the plain-text version (Resend always provides
335
+ // one). Falls back to a notice when neither text nor html is present.
336
+ const provider = await this.server.getChildProvider(id);
337
+ await waitForSync(provider);
338
+ const fragment = provider.document.getXmlFragment("default");
339
+ const body =
340
+ (typeof data.text === "string" && data.text.length > 0
341
+ ? data.text
342
+ : typeof data.html === "string" && data.html.length > 0
343
+ ? `<details><summary>HTML email (no text part)</summary>\n\n\`\`\`html\n${data.html}\n\`\`\`\n</details>`
344
+ : "(empty body)");
345
+ populateBody(fragment, body, subject);
346
+
347
+ // Attachments — base64 → Blob → REST upload. Store upload metadata back
348
+ // on the doc so consumers can render previews / download.
349
+ const attached: Array<{
350
+ id: string;
351
+ filename: string;
352
+ contentType?: string;
353
+ size: number;
354
+ }> = [];
355
+ const attachments = Array.isArray(data.attachments) ? data.attachments : [];
356
+ for (const att of attachments) {
357
+ if (typeof att?.content !== "string") continue;
358
+ const filename =
359
+ typeof att.filename === "string" && att.filename.length > 0
360
+ ? att.filename
361
+ : "attachment";
362
+ try {
363
+ const bytes = Buffer.from(att.content, "base64");
364
+ const blob = new Blob([bytes], {
365
+ type: att.contentType ?? "application/octet-stream",
366
+ });
367
+ const uploaded = await this.server.client.upload(id, blob, filename);
368
+ const uploadedId =
369
+ (uploaded as { id?: string; uploadId?: string })?.id ??
370
+ (uploaded as { uploadId?: string })?.uploadId ??
371
+ "";
372
+ attached.push({
373
+ id: uploadedId,
374
+ filename,
375
+ contentType: att.contentType,
376
+ size: bytes.length,
377
+ });
378
+ } catch (err: any) {
379
+ console.error(
380
+ `[abracadabra-resend] attachment upload failed (${filename}): ${err?.message ?? err}`,
381
+ );
382
+ }
383
+ }
384
+ if (attached.length > 0) {
385
+ const existingRaw = treeMap.get(id);
386
+ if (existingRaw) {
387
+ const existingMeta =
388
+ (typeof (existingRaw as any).toJSON === "function"
389
+ ? (existingRaw as any).toJSON()?.meta
390
+ : (existingRaw as any).meta) ?? {};
391
+ rootDoc.transact(() => {
392
+ treeMap.set(
393
+ id,
394
+ makeEntryMap({
395
+ label: subject,
396
+ parentId: inboxId,
397
+ order: now,
398
+ type: "doc",
399
+ meta: { ...existingMeta, attachments: attached },
400
+ createdAt: now,
401
+ updatedAt: Date.now(),
402
+ }),
403
+ );
404
+ });
405
+ }
406
+ }
407
+
408
+ console.error(
409
+ `[abracadabra-resend] inbound stored: "${subject}" from ${from} → doc ${id}`,
410
+ );
411
+ }
412
+ }
413
+