@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.
package/src/index.ts ADDED
@@ -0,0 +1,128 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @abraca/resend — entry point.
4
+ *
5
+ * Environment variables:
6
+ * ABRA_URL (required) — Abracadabra server URL
7
+ * ABRA_SPACE_ID — Pin to a specific space (default: first visible)
8
+ * ABRA_KEY_FILE — Ed25519 seed path (default ~/.abracadabra/resend.key)
9
+ * ABRA_INVITE_CODE — Used only on first-run register
10
+ * ABRA_AGENT_NAME — Display name (default "Resend Bridge")
11
+ *
12
+ * RESEND_API_KEY (required) — Resend API key
13
+ * RESEND_FROM (required) — Default `from` address
14
+ * RESEND_INBOUND_ENABLED — "false" to skip the webhook server (default true)
15
+ * RESEND_INBOUND_PORT — Bind port for /inbound (default 0 = ephemeral)
16
+ * RESEND_INBOUND_HOST — Bind host (default 0.0.0.0)
17
+ * RESEND_INBOUND_SECRET — Required when inbound is enabled (Svix signing secret)
18
+ */
19
+ import { bootstrap } from "./bootstrap.ts";
20
+ import { InboundServer } from "./inbound-server.ts";
21
+ import { OutboxWatcher } from "./outbox-watcher.ts";
22
+ import { ResendClient } from "./resend-client.ts";
23
+ import { AbracadabraResendServer } from "./server.ts";
24
+
25
+ function required(name: string): string {
26
+ const value = process.env[name];
27
+ if (!value || value.trim().length === 0) {
28
+ console.error(`[abracadabra-resend] Missing required env var: ${name}`);
29
+ process.exit(1);
30
+ }
31
+ return value;
32
+ }
33
+
34
+ function boolEnv(name: string, fallback: boolean): boolean {
35
+ const v = process.env[name];
36
+ if (v === undefined) return fallback;
37
+ const norm = v.trim().toLowerCase();
38
+ if (["0", "false", "no", "off", ""].includes(norm)) return false;
39
+ return true;
40
+ }
41
+
42
+ async function main(): Promise<void> {
43
+ const url = required("ABRA_URL");
44
+ const resendApiKey = required("RESEND_API_KEY");
45
+ const defaultFrom = required("RESEND_FROM");
46
+
47
+ const inboundEnabled = boolEnv("RESEND_INBOUND_ENABLED", true);
48
+ const inboundSecret = inboundEnabled
49
+ ? required("RESEND_INBOUND_SECRET")
50
+ : process.env.RESEND_INBOUND_SECRET ?? "";
51
+ const inboundPort = process.env.RESEND_INBOUND_PORT
52
+ ? Number(process.env.RESEND_INBOUND_PORT)
53
+ : 0;
54
+ const inboundHost = process.env.RESEND_INBOUND_HOST ?? "0.0.0.0";
55
+
56
+ const server = new AbracadabraResendServer({
57
+ url,
58
+ spaceId: process.env.ABRA_SPACE_ID,
59
+ keyFile: process.env.ABRA_KEY_FILE,
60
+ inviteCode: process.env.ABRA_INVITE_CODE,
61
+ agentName: process.env.ABRA_AGENT_NAME,
62
+ });
63
+
64
+ try {
65
+ await server.connect();
66
+ } catch (err: any) {
67
+ console.error(`[abracadabra-resend] Failed to connect: ${err?.message ?? err}`);
68
+ process.exit(1);
69
+ }
70
+
71
+ const boot = await bootstrap(server);
72
+ console.error(
73
+ `[abracadabra-resend] Bootstrapped Inbox=${boot.inboxId} Outbox=${boot.outboxId}`,
74
+ );
75
+
76
+ const sender = new ResendClient(resendApiKey);
77
+ const watcher = new OutboxWatcher({ server, sender, bootstrap: boot, defaultFrom });
78
+ watcher.start();
79
+
80
+ let inbound: InboundServer | null = null;
81
+ if (inboundEnabled) {
82
+ inbound = new InboundServer({
83
+ server,
84
+ bootstrap: boot,
85
+ secret: inboundSecret,
86
+ port: Number.isFinite(inboundPort) ? inboundPort : 0,
87
+ host: inboundHost,
88
+ });
89
+ try {
90
+ await inbound.start();
91
+ } catch (err: any) {
92
+ console.error(
93
+ `[abracadabra-resend] Inbound server failed to start: ${err?.message ?? err}`,
94
+ );
95
+ process.exit(1);
96
+ }
97
+ } else {
98
+ console.error(
99
+ "[abracadabra-resend] Inbound disabled (RESEND_INBOUND_ENABLED=false)",
100
+ );
101
+ }
102
+
103
+ console.error("[abracadabra-resend] Ready.");
104
+
105
+ const shutdown = async () => {
106
+ console.error("[abracadabra-resend] Shutting down...");
107
+ watcher.stop();
108
+ if (inbound) await inbound.stop();
109
+ await server.destroy();
110
+ process.exit(0);
111
+ };
112
+ process.on("SIGINT", shutdown);
113
+ process.on("SIGTERM", shutdown);
114
+ }
115
+
116
+ main().catch((err) => {
117
+ console.error("[abracadabra-resend] Fatal error:", err);
118
+ process.exit(1);
119
+ });
120
+
121
+ export { AbracadabraResendServer } from "./server.ts";
122
+ export { bootstrap } from "./bootstrap.ts";
123
+ export { InboundServer } from "./inbound-server.ts";
124
+ export { OutboxWatcher } from "./outbox-watcher.ts";
125
+ export { ResendClient } from "./resend-client.ts";
126
+ export type { ResendSender, SendResult } from "./resend-client.ts";
127
+ export { renderEmail, RenderError } from "./render.ts";
128
+ export type { EmailPayload } from "./render.ts";
@@ -0,0 +1,283 @@
1
+ /**
2
+ * Outbox watcher. Observes the doc-tree map (deeply, so per-entry parentId
3
+ * updates surface) and dispatches any doc that lands under the "Ready" column.
4
+ *
5
+ * Dispatch flow per doc:
6
+ * render → resend.send → patch meta.resendId/sentAt → move to Sent
7
+ * On failure:
8
+ * patch meta.error/errorAt → move to Failed (left for human triage)
9
+ *
10
+ * Idempotency:
11
+ * - `meta.resendId` already set → skip (recovers from a restart that landed
12
+ * between Resend ack and the post-send move).
13
+ * - In-flight `Set<docId>` prevents the same observe burst from double-firing.
14
+ */
15
+ import { patchEntry, toPlain } from "@abraca/dabra";
16
+ import type * as Y from "yjs";
17
+ import type { BootstrapResult } from "./bootstrap.ts";
18
+ import { renderEmail, RenderError } from "./render.ts";
19
+ import type { ResendSender } from "./resend-client.ts";
20
+ import type { AbracadabraResendServer } from "./server.ts";
21
+
22
+ export interface OutboxWatcherOptions {
23
+ server: AbracadabraResendServer;
24
+ sender: ResendSender;
25
+ bootstrap: BootstrapResult;
26
+ defaultFrom: string;
27
+ }
28
+
29
+ interface EntryView {
30
+ id: string;
31
+ label: string;
32
+ parentId: string | null;
33
+ meta: Record<string, unknown>;
34
+ }
35
+
36
+ function readEntry(treeMap: Y.Map<any>, id: string): EntryView | null {
37
+ const raw = treeMap.get(id);
38
+ if (!raw) return null;
39
+ const plain = toPlain(raw) as any;
40
+ if (!plain || typeof plain !== "object") return null;
41
+ return {
42
+ id,
43
+ label: typeof plain.label === "string" ? plain.label : "Untitled",
44
+ parentId: plain.parentId ?? null,
45
+ meta:
46
+ plain.meta && typeof plain.meta === "object"
47
+ ? (plain.meta as Record<string, unknown>)
48
+ : {},
49
+ };
50
+ }
51
+
52
+ export class OutboxWatcher {
53
+ private readonly server: AbracadabraResendServer;
54
+ private readonly sender: ResendSender;
55
+ private readonly bootstrap: BootstrapResult;
56
+ private readonly defaultFrom: string;
57
+ private readonly inFlight = new Set<string>();
58
+ private readonly handled = new Set<string>();
59
+ private observer: ((events: Y.YEvent<any>[]) => void) | null = null;
60
+ private treeMap: Y.Map<any> | null = null;
61
+ private rootDoc: Y.Doc | null = null;
62
+ private txHandler: ((tx: Y.Transaction) => void) | null = null;
63
+
64
+ constructor(opts: OutboxWatcherOptions) {
65
+ this.server = opts.server;
66
+ this.sender = opts.sender;
67
+ this.bootstrap = opts.bootstrap;
68
+ this.defaultFrom = opts.defaultFrom;
69
+ }
70
+
71
+ start(): void {
72
+ const treeMap = this.server.getTreeMap();
73
+ if (!treeMap) {
74
+ throw new Error("OutboxWatcher.start: server is not connected");
75
+ }
76
+ this.treeMap = treeMap;
77
+
78
+ const rootDoc = this.server.rootDocument;
79
+ if (!rootDoc) {
80
+ throw new Error("OutboxWatcher.start: root doc not connected");
81
+ }
82
+ this.rootDoc = rootDoc;
83
+
84
+ // Initial scan — pick up anything already in Ready before we attached.
85
+ void this.scan("init");
86
+
87
+ // observeDeep on the tree map catches all the common edits (patchEntry
88
+ // mutating a nested Y.Map, treeMap.set replacing an entry).
89
+ const obs = (events: Y.YEvent<any>[]) => {
90
+ console.error(
91
+ `[abracadabra-resend] observeDeep fired (${events.length} events)`,
92
+ );
93
+ void this.scan("observeDeep");
94
+ };
95
+ treeMap.observeDeep(obs);
96
+ this.observer = obs;
97
+
98
+ // afterTransaction on the root Y.Doc fires for EVERY transaction applied
99
+ // to the doc — local writes and updates dispatched into this doc.
100
+ const onTx = (tx: Y.Transaction) => {
101
+ const changed: string[] = [];
102
+ for (const [type, events] of tx.changedParentTypes.entries()) {
103
+ const name = (type as any)._item ? (type as any)._item.parentSub : type.constructor.name;
104
+ const keys = events.flatMap((ev: any) => Array.from(ev.keysChanged ?? []));
105
+ changed.push(`${name ?? "type"}[${keys.join(",")}]`);
106
+ }
107
+ console.error(
108
+ `[abracadabra-resend] root afterTransaction (local=${tx.local}, origin=${String(tx.origin)}, changed=${changed.join(" | ") || "(none)"})`,
109
+ );
110
+ void this.scan("afterTransaction");
111
+ };
112
+ rootDoc.on("afterTransaction", onTx);
113
+ this.txHandler = onTx;
114
+
115
+ // Belt-and-suspenders: subdocs. abracadabra-rs may route some space-tree
116
+ // edits through subdoc payloads that don't fire transactions on the root
117
+ // doc directly. Watch every subdoc's transactions too.
118
+ const onSubdocs = (changes: { added: Set<Y.Doc>; removed: Set<Y.Doc>; loaded: Set<Y.Doc> }) => {
119
+ console.error(
120
+ `[abracadabra-resend] subdocs: added=${changes.added.size} loaded=${changes.loaded.size}`,
121
+ );
122
+ for (const sub of [...changes.added, ...changes.loaded]) {
123
+ sub.on("afterTransaction", (tx: Y.Transaction) => {
124
+ console.error(
125
+ `[abracadabra-resend] subdoc afterTransaction (guid=${sub.guid}, local=${tx.local})`,
126
+ );
127
+ void this.scan("subdoc");
128
+ });
129
+ }
130
+ };
131
+ rootDoc.on("subdocs", onSubdocs);
132
+
133
+ // Raw doc update — fires for every binary Yjs update applied to the doc,
134
+ // from any origin. If THIS doesn't fire when a remote writer changes
135
+ // state, the bridge's WS isn't delivering updates.
136
+ rootDoc.on("update", (_update: Uint8Array, origin: unknown) => {
137
+ console.error(
138
+ `[abracadabra-resend] root update applied (origin=${String(origin)})`,
139
+ );
140
+ void this.scan("update");
141
+ });
142
+
143
+ console.error(
144
+ `[abracadabra-resend] Outbox watcher attached (ready column ${this.bootstrap.columns.Ready})`,
145
+ );
146
+ }
147
+
148
+ stop(): void {
149
+ if (this.rootDoc && this.txHandler) {
150
+ this.rootDoc.off("afterTransaction", this.txHandler);
151
+ }
152
+ if (this.treeMap && this.observer) {
153
+ this.treeMap.unobserveDeep(this.observer);
154
+ }
155
+ this.txHandler = null;
156
+ this.rootDoc = null;
157
+ this.observer = null;
158
+ this.treeMap = null;
159
+ }
160
+
161
+ private async scan(reason: string): Promise<void> {
162
+ const treeMap = this.treeMap;
163
+ if (!treeMap) return;
164
+ const readyColId = this.bootstrap.columns.Ready;
165
+ const outboxId = this.bootstrap.outboxId;
166
+ const columnIds = new Set(Object.values(this.bootstrap.columns));
167
+ const candidates: EntryView[] = [];
168
+ let inReadyCount = 0;
169
+ let totalEntries = 0;
170
+ // Dump every entry whose ancestor is Outbox so we can compare bridge
171
+ // view vs cou-sh view.
172
+ const outboxSubtree: Array<{ id: string; label: string; parentId: string | null; under: string }> = [];
173
+ treeMap.forEach((_raw: any, id: string) => {
174
+ totalEntries++;
175
+ const e = readEntry(treeMap, id);
176
+ if (!e) return;
177
+ // classify under outbox: direct child of Outbox, or grandchild via a column
178
+ if (e.parentId === outboxId || (e.parentId && columnIds.has(e.parentId))) {
179
+ outboxSubtree.push({
180
+ id,
181
+ label: e.label,
182
+ parentId: e.parentId,
183
+ under: e.parentId === outboxId ? "Outbox" :
184
+ e.parentId === this.bootstrap.columns.Draft ? "Draft" :
185
+ e.parentId === this.bootstrap.columns.Ready ? "Ready" :
186
+ e.parentId === this.bootstrap.columns.Sent ? "Sent" :
187
+ e.parentId === this.bootstrap.columns.Failed ? "Failed" : "?",
188
+ });
189
+ }
190
+ if (e.parentId !== readyColId) return;
191
+ inReadyCount++;
192
+ if (this.inFlight.has(id) || this.handled.has(id)) return;
193
+ if (typeof e.meta.resendId === "string" && e.meta.resendId.length > 0) {
194
+ this.handled.add(id);
195
+ this.moveTo(id, this.bootstrap.columns.Sent);
196
+ return;
197
+ }
198
+ candidates.push(e);
199
+ });
200
+ console.error(
201
+ `[abracadabra-resend] scan(${reason}): ${totalEntries} entries, ${inReadyCount} in Ready, ${candidates.length} to dispatch | Outbox subtree (${outboxSubtree.length}): ${outboxSubtree.map((e) => `${e.under}:"${e.label}"`).join(", ")}`,
202
+ );
203
+
204
+ for (const entry of candidates) {
205
+ this.inFlight.add(entry.id);
206
+ void this.dispatch(entry).finally(() => {
207
+ this.inFlight.delete(entry.id);
208
+ });
209
+ }
210
+ }
211
+
212
+ private async dispatch(entry: EntryView): Promise<void> {
213
+ const { id, label } = entry;
214
+ try {
215
+ const payload = await renderEmail(this.server, id, this.defaultFrom);
216
+ console.error(
217
+ `[abracadabra-resend] sending "${payload.subject}" → ${payload.to.join(", ")} (doc=${id})`,
218
+ );
219
+ const { id: resendId } = await this.sender.send(payload, {
220
+ "X-Abra-Doc-Id": id,
221
+ });
222
+ this.handled.add(id);
223
+ this.patchMeta(id, { resendId, sentAt: Date.now(), error: null });
224
+ this.moveTo(id, this.bootstrap.columns.Sent);
225
+ console.error(
226
+ `[abracadabra-resend] sent "${payload.subject}" (resend=${resendId}, doc=${id})`,
227
+ );
228
+ } catch (err: any) {
229
+ this.handled.add(id);
230
+ const message =
231
+ err instanceof RenderError
232
+ ? err.message
233
+ : err?.message
234
+ ? String(err.message)
235
+ : String(err);
236
+ console.error(
237
+ `[abracadabra-resend] send failed for "${label}" (${id}): ${message}`,
238
+ );
239
+ this.patchMeta(id, { error: message, errorAt: Date.now() });
240
+ this.moveTo(id, this.bootstrap.columns.Failed);
241
+ }
242
+ }
243
+
244
+ private patchMeta(id: string, patch: Record<string, unknown>): void {
245
+ const treeMap = this.treeMap;
246
+ const rootDoc = this.server.rootDocument;
247
+ if (!treeMap || !rootDoc) return;
248
+ const current = readEntry(treeMap, id);
249
+ if (!current) return;
250
+ const nextMeta = { ...current.meta, ...patch };
251
+ for (const [k, v] of Object.entries(patch)) {
252
+ if (v === null) delete nextMeta[k];
253
+ }
254
+ rootDoc.transact(() => {
255
+ patchEntry(treeMap, id, {
256
+ meta: nextMeta,
257
+ updatedAt: Date.now(),
258
+ });
259
+ });
260
+ }
261
+
262
+ private moveTo(id: string, newParentId: string): void {
263
+ const treeMap = this.treeMap;
264
+ const rootDoc = this.server.rootDocument;
265
+ if (!treeMap || !rootDoc) return;
266
+ const now = Date.now();
267
+ rootDoc.transact(() => {
268
+ patchEntry(treeMap, id, {
269
+ parentId: newParentId,
270
+ order: now,
271
+ updatedAt: now,
272
+ });
273
+ });
274
+ // Best-effort registry reparent so REST/FTS sees the move too.
275
+ void this.server.client
276
+ .updateDocumentMeta(id, { parent_id: newParentId })
277
+ .catch((e) => {
278
+ console.error(
279
+ `[abracadabra-resend] REST reparent failed for ${id}: ${e?.message ?? e}`,
280
+ );
281
+ });
282
+ }
283
+ }
package/src/render.ts ADDED
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Render an outbox document to an email-ready payload.
3
+ *
4
+ * Reads addressing from the doc's tree-entry `meta` (to/cc/bcc/subject/from/
5
+ * replyTo) and renders the body Y.XmlFragment via `@abraca/convert`'s
6
+ * `yjsToHtml` — which already emits a complete `<!DOCTYPE html>` doc with the
7
+ * doc label as `<h1>`, so the result is suitable as-is for Resend's `html`
8
+ * field.
9
+ */
10
+ import { yjsToHtml } from "@abraca/convert";
11
+ import { toPlain } from "@abraca/dabra";
12
+ import type { AbracadabraResendServer } from "./server.ts";
13
+
14
+ export interface EmailPayload {
15
+ subject: string;
16
+ html: string;
17
+ to: string[];
18
+ cc?: string[];
19
+ bcc?: string[];
20
+ from?: string;
21
+ replyTo?: string;
22
+ }
23
+
24
+ export class RenderError extends Error {
25
+ constructor(message: string) {
26
+ super(message);
27
+ this.name = "RenderError";
28
+ }
29
+ }
30
+
31
+ function asStringArray(value: unknown): string[] {
32
+ if (Array.isArray(value)) {
33
+ return value
34
+ .filter((v): v is string => typeof v === "string" && v.trim().length > 0)
35
+ .map((v) => v.trim());
36
+ }
37
+ if (typeof value === "string" && value.trim().length > 0) {
38
+ // Allow `to: "alice@x.com, bob@y.com"` as a convenience.
39
+ return value
40
+ .split(",")
41
+ .map((s) => s.trim())
42
+ .filter((s) => s.length > 0);
43
+ }
44
+ return [];
45
+ }
46
+
47
+ function asOptionalString(value: unknown): string | undefined {
48
+ return typeof value === "string" && value.trim().length > 0
49
+ ? value.trim()
50
+ : undefined;
51
+ }
52
+
53
+ interface OutboxEntry {
54
+ label: string;
55
+ meta: Record<string, unknown>;
56
+ }
57
+
58
+ function readEntry(
59
+ server: AbracadabraResendServer,
60
+ docId: string,
61
+ ): OutboxEntry | null {
62
+ const treeMap = server.getTreeMap();
63
+ if (!treeMap) return null;
64
+ const raw = treeMap.get(docId);
65
+ if (!raw) return null;
66
+ const plain = toPlain(raw) as any;
67
+ if (!plain || typeof plain !== "object") return null;
68
+ return {
69
+ label: typeof plain.label === "string" ? plain.label : "(no subject)",
70
+ meta:
71
+ plain.meta && typeof plain.meta === "object"
72
+ ? (plain.meta as Record<string, unknown>)
73
+ : {},
74
+ };
75
+ }
76
+
77
+ export async function renderEmail(
78
+ server: AbracadabraResendServer,
79
+ docId: string,
80
+ defaultFrom: string,
81
+ ): Promise<EmailPayload> {
82
+ const entry = readEntry(server, docId);
83
+ if (!entry) {
84
+ throw new RenderError(`Doc ${docId} has no tree entry.`);
85
+ }
86
+
87
+ const to = asStringArray(entry.meta.to);
88
+ if (to.length === 0) {
89
+ throw new RenderError(
90
+ `Doc ${docId} has no recipients — set meta.to (array of email addresses).`,
91
+ );
92
+ }
93
+ const cc = asStringArray(entry.meta.cc);
94
+ const bcc = asStringArray(entry.meta.bcc);
95
+ const from = asOptionalString(entry.meta.from) ?? defaultFrom;
96
+ const replyTo = asOptionalString(entry.meta.replyTo);
97
+ const subject = asOptionalString(entry.meta.subject) ?? entry.label;
98
+
99
+ const provider = await server.getChildProvider(docId);
100
+ const fragment = provider.document.getXmlFragment("default");
101
+ const html = yjsToHtml(fragment, subject);
102
+
103
+ return {
104
+ subject,
105
+ html,
106
+ to,
107
+ cc: cc.length ? cc : undefined,
108
+ bcc: bcc.length ? bcc : undefined,
109
+ from,
110
+ replyTo,
111
+ };
112
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Thin wrapper around the official `resend` SDK. Kept separate so tests can
3
+ * substitute a fake without mocking the SDK directly.
4
+ */
5
+ import { Resend } from "resend";
6
+ import type { EmailPayload } from "./render.ts";
7
+
8
+ export interface SendResult {
9
+ id: string;
10
+ }
11
+
12
+ export interface ResendSender {
13
+ send(payload: EmailPayload, headers?: Record<string, string>): Promise<SendResult>;
14
+ }
15
+
16
+ export class ResendClient implements ResendSender {
17
+ private readonly resend: Resend;
18
+
19
+ constructor(apiKey: string) {
20
+ this.resend = new Resend(apiKey);
21
+ }
22
+
23
+ async send(
24
+ payload: EmailPayload,
25
+ headers?: Record<string, string>,
26
+ ): Promise<SendResult> {
27
+ if (!payload.from) {
28
+ throw new Error("ResendClient.send: payload.from is required");
29
+ }
30
+ const { data, error } = await this.resend.emails.send({
31
+ from: payload.from,
32
+ to: payload.to,
33
+ cc: payload.cc,
34
+ bcc: payload.bcc,
35
+ subject: payload.subject,
36
+ html: payload.html,
37
+ replyTo: payload.replyTo,
38
+ headers,
39
+ });
40
+ if (error) {
41
+ throw new Error(
42
+ `Resend rejected the message: ${error.message ?? JSON.stringify(error)}`,
43
+ );
44
+ }
45
+ if (!data?.id) {
46
+ throw new Error("Resend returned no id for the dispatched message");
47
+ }
48
+ return { id: data.id };
49
+ }
50
+ }