& friends\nNext line\n\nSecond paragraph'
+);
+
+assert.match(html, /BlinkMacSystemFont/);
+assert.match(html, /max-width:560px/);
+assert.match(html, /Hello <team> & friends Next line/);
+assert.match(html, /Second paragraph/);
+assert.equal((html.match(//);
+
+console.log('resend message tests passed');
diff --git a/spacetime-resend-ts/example/server.ts b/spacetime-resend-ts/example/server.ts
new file mode 100644
index 00000000000..4d3412e9dde
--- /dev/null
+++ b/spacetime-resend-ts/example/server.ts
@@ -0,0 +1,237 @@
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { existsSync, readFileSync } from 'node:fs';
+import express, { type Request, type Response } from 'express';
+import dotenv from 'dotenv';
+import {
+ discardStoredServerToken,
+ exampleUiAssetsDir,
+ grantServerIdentity,
+ loadServerToken,
+ saveServerToken,
+} from '@spacetimedb/submodule-shared/server';
+import { DbConnection, type ErrorContext } from './src/module_bindings';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+const inheritedEnv = new Set(Object.keys(process.env));
+
+function loadEnv(pathname: string, override: boolean): void {
+ if (!existsSync(pathname)) return;
+
+ const parsed = dotenv.parse(readFileSync(pathname));
+ for (const [key, value] of Object.entries(parsed)) {
+ if (value.trim() === '') continue;
+ if (inheritedEnv.has(key)) continue;
+ if (override || process.env[key] === undefined) {
+ process.env[key] = value;
+ }
+ }
+}
+
+// Shared env supplies secrets/defaults; example-local env wins for app settings.
+// Explicit process environment has highest priority.
+loadEnv(path.resolve(__dirname, '..', '..', '.env'), false);
+loadEnv(path.resolve(__dirname, '..', '.env'), false);
+loadEnv(path.resolve(__dirname, '.env'), true);
+
+const PORT = Number.parseInt(process.env.PORT ?? '8790', 10);
+const HOST = process.env.HOST?.trim() || '127.0.0.1';
+const STDB_URI = process.env.STDB_URI ?? 'ws://127.0.0.1:3000';
+const STDB_HTTP = process.env.STDB_HTTP ?? 'http://127.0.0.1:3000';
+const DB_NAME = process.env.SPACETIMEDB_DB_NAME ?? 'spacetime-resend-example';
+const SPACETIME_BIN = process.env.SPACETIME_BIN?.trim() || 'spacetime';
+const RESEND_API_KEY = process.env.RESEND_API_KEY ?? '';
+const RESEND_WEBHOOK_SECRET = process.env.RESEND_WEBHOOK_SECRET ?? '';
+const DEFAULT_FROM = process.env.DEFAULT_FROM ?? 'onboarding@resend.dev';
+const RESEND_TEST_RECIPIENTS = [
+ 'delivered@resend.dev',
+ 'bounced@resend.dev',
+ 'complained@resend.dev',
+];
+const ALLOWED_RECIPIENTS = [
+ ...new Set([
+ ...RESEND_TEST_RECIPIENTS,
+ ...(process.env.RESEND_ALLOWED_RECIPIENTS ?? '')
+ .split(',')
+ .map(value => value.trim().toLowerCase())
+ .filter(Boolean),
+ ]),
+];
+const SERVER_TOKEN_PATH = path.resolve(__dirname, '.stdb-server-token');
+
+let stdb: DbConnection | null = null;
+let resendConfigured = false;
+
+type ConnectedServer = {
+ connection: DbConnection;
+ identity: string;
+};
+
+function connectAttempt(token: string | undefined): Promise {
+ return new Promise((resolve, reject) => {
+ let builder = DbConnection.builder()
+ .withUri(STDB_URI)
+ .withDatabaseName(DB_NAME)
+ .onConnect((connection, identity, nextToken) => {
+ if (!process.env.STDB_SERVER_TOKEN?.trim()) {
+ saveServerToken(SERVER_TOKEN_PATH, nextToken);
+ }
+ resolve({ connection, identity: identity.toHexString() });
+ })
+ .onDisconnect((_ctx, err) => {
+ console.error(
+ `[stdb] disconnected: ${err?.message ?? 'unknown'} - exiting for supervisor restart`
+ );
+ process.exit(1);
+ })
+ .onConnectError((_ctx: ErrorContext, err) => reject(err));
+ if (token) builder = builder.withToken(token);
+ builder.build();
+ });
+}
+
+async function connect(): Promise {
+ const stored = loadServerToken(
+ SERVER_TOKEN_PATH,
+ process.env.STDB_SERVER_TOKEN
+ );
+ try {
+ return await connectAttempt(stored.token);
+ } catch (error) {
+ if (stored.source !== 'file') throw error;
+ discardStoredServerToken(SERVER_TOKEN_PATH);
+ console.warn(
+ '[stdb] stored server token was rejected; creating a new identity'
+ );
+ return connectAttempt(undefined);
+ }
+}
+
+function requireStdb(): DbConnection {
+ if (!stdb) throw new Error('STDB not connected yet');
+ return stdb;
+}
+
+const app = express();
+
+// Webhook uses the raw body because Svix signs raw bytes. Register it before express.json().
+app.post(
+ '/webhook/resend',
+ express.raw({ type: '*/*', limit: '512kb' }),
+ handleResendWebhook
+);
+
+app.use(express.json({ limit: '512kb' }));
+app.use('/assets', express.static(exampleUiAssetsDir));
+app.use(express.static(path.join(__dirname, 'public')));
+
+app.get('/api/health', (_req: Request, res: Response) => {
+ res.json({ ok: true, databaseName: DB_NAME });
+});
+
+app.get('/api/config', (_req: Request, res: Response) => {
+ res.json({
+ spacetimeUri: STDB_URI,
+ databaseName: DB_NAME,
+ resendConfigured,
+ defaultFrom: DEFAULT_FROM,
+ allowedRecipients: ALLOWED_RECIPIENTS,
+ });
+});
+
+async function handleResendWebhook(req: Request, res: Response): Promise {
+ const rawBody =
+ req.body instanceof Buffer ? req.body : Buffer.from(String(req.body ?? ''));
+ const url = `${STDB_HTTP}/v1/database/${DB_NAME}/route/webhook/resend`;
+
+ const headers: Record = {
+ 'content-type': 'application/json',
+ };
+ for (const name of ['svix-id', 'svix-timestamp', 'svix-signature']) {
+ const value = req.headers[name];
+ if (typeof value === 'string') headers[name] = value;
+ else if (Array.isArray(value)) headers[name] = value.join(',');
+ }
+
+ try {
+ const upstream = await fetch(url, {
+ method: 'POST',
+ headers,
+ body: rawBody,
+ });
+ const text = await upstream.text();
+ res.status(upstream.status).send(text);
+ } catch (err) {
+ const reason = err instanceof Error ? err.message : String(err);
+ console.error(`[webhook] passthrough to STDB route failed: ${reason}`);
+ res.status(502).send(`passthrough failed: ${reason}`);
+ }
+}
+
+async function bootstrapResendConfig(): Promise {
+ if (!RESEND_API_KEY) {
+ resendConfigured = false;
+ process.stdout.write(
+ ' ! RESEND_API_KEY missing: Dispatch connects; email sends require configuration\n'
+ );
+ return;
+ }
+
+ await requireStdb().procedures['resend.setResendConfig']({
+ apiKey: RESEND_API_KEY,
+ webhookSigningSecret: RESEND_WEBHOOK_SECRET || undefined,
+ defaultFrom: DEFAULT_FROM,
+ });
+ await requireStdb().procedures.setDispatchPolicy({
+ allowedRecipientsJson: JSON.stringify(ALLOWED_RECIPIENTS),
+ });
+ resendConfigured = true;
+ process.stdout.write(' + Resend config loaded from server environment\n');
+}
+
+(async () => {
+ console.log(`[stdb] connecting to ${STDB_URI}/${DB_NAME} ...`);
+ try {
+ const connected = await connect();
+ stdb = connected.connection;
+ grantServerIdentity({
+ spacetimeBin: SPACETIME_BIN,
+ server: STDB_HTTP,
+ database: DB_NAME,
+ procedure: 'resend.add_admin_identity',
+ identity: connected.identity,
+ });
+ console.log(`[stdb] connected as authorized server ${connected.identity}`);
+ } catch (err) {
+ console.error(
+ `[stdb] connection or authorization failed: ${err instanceof Error ? err.message : String(err)}`
+ );
+ console.error(
+ '[stdb] is the SpacetimeDB host running and the module published?'
+ );
+ process.exit(1);
+ }
+
+ try {
+ await bootstrapResendConfig();
+ } catch (err) {
+ console.error(
+ `[resend] configuration failed: ${err instanceof Error ? err.message : String(err)}`
+ );
+ process.exit(1);
+ }
+
+ app.listen(PORT, HOST, () => {
+ process.stdout.write(`\nDispatch running at http://${HOST}:${PORT}\n`);
+ if (!RESEND_WEBHOOK_SECRET) {
+ process.stdout.write(
+ ' ! RESEND_WEBHOOK_SECRET not set - incoming webhooks are rejected\n'
+ );
+ }
+ process.stdout.write(
+ ` webhook endpoint: POST http://127.0.0.1:${PORT}/webhook/resend\n`
+ );
+ process.stdout.write(` database: ${STDB_URI}/${DB_NAME}\n\n`);
+ });
+})();
diff --git a/spacetime-resend-ts/example/spacetimedb/.npmrc b/spacetime-resend-ts/example/spacetimedb/.npmrc
new file mode 100644
index 00000000000..44bdf80d1df
--- /dev/null
+++ b/spacetime-resend-ts/example/spacetimedb/.npmrc
@@ -0,0 +1 @@
+minimum-release-age=1440
diff --git a/spacetime-resend-ts/example/spacetimedb/package.json b/spacetime-resend-ts/example/spacetimedb/package.json
new file mode 100644
index 00000000000..375d324832a
--- /dev/null
+++ b/spacetime-resend-ts/example/spacetimedb/package.json
@@ -0,0 +1,20 @@
+{
+ "name": "spacetime-resend-example-module",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "build": "spacetime build",
+ "publish:local": "spacetime publish --server local --yes spacetime-resend-example",
+ "publish:local:reset": "spacetime publish --server local --yes --delete-data=always spacetime-resend-example"
+ },
+ "dependencies": {
+ "@spacetimedb/rate-limit": "workspace:*",
+ "@spacetimedb/resend": "workspace:*",
+ "spacetimedb": "workspace:*"
+ },
+ "devDependencies": {
+ "@types/node": "^22.10.2",
+ "typescript": "^5.9.3"
+ }
+}
diff --git a/spacetime-resend-ts/example/spacetimedb/src/index.ts b/spacetime-resend-ts/example/spacetimedb/src/index.ts
new file mode 100644
index 00000000000..f7e029d14b2
--- /dev/null
+++ b/spacetime-resend-ts/example/spacetimedb/src/index.ts
@@ -0,0 +1,267 @@
+// The browser never talks to Resend directly and is never granted admin. It calls
+// host procedures that forward to the submodule through `ctx.as.resend`. Resend's
+// base tables stay private; caller-scoped views below expose only the current
+// connection's dispatches.
+
+import { schema, table, t, SenderError, Router } from 'spacetimedb/server';
+import * as resend from '@spacetimedb/resend/submodule';
+import * as rateLimit from '@spacetimedb/rate-limit/submodule';
+import { messageHtml } from './message';
+
+const dispatchPolicy = table(
+ { name: 'dispatch_policy', public: false },
+ {
+ singleton: t.bool().primaryKey(),
+ allowedRecipientsJson: t.string(),
+ updatedAt: t.timestamp(),
+ }
+);
+
+const spacetimedb = schema({
+ resend,
+ rateLimit,
+ dispatchPolicy,
+});
+
+function subjectFor(ctx: { sender: { toHexString(): string } }): string {
+ return ctx.sender.toHexString();
+}
+
+export const myDispatchEmails = spacetimedb.view(
+ { name: 'my_dispatch_emails', public: true },
+ resend.t.array(resend.resendEmailTable.rowType),
+ ctx => [...ctx.db.resend.resendEmail.byUserId.filter(subjectFor(ctx))]
+);
+
+export const myDispatchDeliveryEvents = spacetimedb.view(
+ { name: 'my_dispatch_delivery_events', public: true },
+ resend.t.array(resend.resendDeliveryEventTable.rowType),
+ ctx => {
+ const out = [];
+ for (const email of ctx.db.resend.resendEmail.byUserId.filter(
+ subjectFor(ctx)
+ )) {
+ for (const event of ctx.db.resend.resendDeliveryEvent.byResendId.filter(
+ email.resendId
+ )) {
+ out.push(event);
+ }
+ }
+ return out;
+ }
+);
+
+const MAX_SUBJECT = 200;
+const MAX_MESSAGE = 5000;
+const MAX_ALLOWED_RECIPIENTS = 10;
+const MAX_RECIPIENT_POLICY_LENGTH = 4096;
+const CALLER_SEND_LIMIT = 5;
+const CALLER_WINDOW_SECONDS = 10 * 60;
+const GLOBAL_SEND_LIMIT = 25;
+const GLOBAL_WINDOW_SECONDS = 60 * 60;
+
+function fail(message: string): never {
+ throw new SenderError(`dispatch.${message}`);
+}
+
+function normalizeEmail(email: string): string {
+ const out = email.trim().toLowerCase();
+ if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(out)) fail('invalid_email');
+ return out;
+}
+
+function parseAllowedRecipients(value: string): string[] {
+ if (value.length > MAX_RECIPIENT_POLICY_LENGTH) {
+ fail('recipient_policy_too_large');
+ }
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(value);
+ } catch {
+ fail('invalid_recipient_policy');
+ }
+ if (!Array.isArray(parsed) || parsed.length === 0) {
+ fail('invalid_recipient_policy');
+ }
+ const recipients = [
+ ...new Set(parsed.map(value => normalizeEmail(String(value)))),
+ ];
+ if (recipients.length > MAX_ALLOWED_RECIPIENTS) {
+ fail('too_many_allowed_recipients');
+ }
+ return recipients;
+}
+
+function clean(
+ value: string | undefined,
+ fallback: string,
+ max: number
+): string {
+ const out = (value ?? '').trim().replace(/\s+/g, ' ');
+ return (out || fallback).slice(0, max);
+}
+
+const dispatchSendResult = t.object('DispatchSendResult', {
+ ok: t.bool(),
+ resendId: t.option(t.string()),
+ message: t.string(),
+});
+
+export const setDispatchPolicy = spacetimedb.procedure(
+ { allowedRecipientsJson: t.string() },
+ t.bool(),
+ (ctx, args) => {
+ const isAdmin = ctx.withTx(
+ tx => tx.db.resend.resendAdminIdentity.identity.find(ctx.sender) != null
+ );
+ if (!isAdmin) fail('not_authorized');
+ const recipients = parseAllowedRecipients(args.allowedRecipientsJson);
+ ctx.withTx(tx => {
+ const existing = tx.db.dispatchPolicy.singleton.find(true);
+ const row = {
+ singleton: true,
+ allowedRecipientsJson: JSON.stringify(recipients),
+ updatedAt: ctx.timestamp,
+ };
+ if (existing) tx.db.dispatchPolicy.singleton.update(row);
+ else tx.db.dispatchPolicy.insert(row);
+ });
+ return true;
+ }
+);
+
+// The host policy restricts recipients and applies caller and global quotas before
+// delegating delivery through the private Resend configuration.
+export const sendDispatch = spacetimedb.procedure(
+ {
+ to: t.string(),
+ subject: t.string(),
+ message: t.string(),
+ },
+ dispatchSendResult,
+ (ctx, args) => {
+ const to = normalizeEmail(args.to);
+ const subject = clean(args.subject, '(no subject)', MAX_SUBJECT);
+ const message = (args.message ?? '').trim().slice(0, MAX_MESSAGE);
+ if (!message) fail('empty_message');
+
+ const policyJson = ctx.withTx(
+ tx =>
+ tx.db.dispatchPolicy.singleton.find(true)?.allowedRecipientsJson ?? null
+ );
+ if (policyJson == null) fail('policy_missing');
+ const allowed = parseAllowedRecipients(policyJson);
+ if (!allowed.includes(to)) fail('recipient_not_allowed');
+
+ const authorization = ctx.withTx(tx => {
+ const caller = rateLimit.consumeRateLimit(tx.as.rateLimit, {
+ key: `dispatch:caller:${subjectFor(ctx)}`,
+ scope: 'dispatch.send.caller',
+ limit: CALLER_SEND_LIMIT,
+ windowSeconds: CALLER_WINDOW_SECONDS,
+ });
+ if (!caller.allowed) return 'rate_limited' as const;
+ const global = rateLimit.consumeRateLimit(tx.as.rateLimit, {
+ key: 'dispatch:global',
+ scope: 'dispatch.send.global',
+ limit: GLOBAL_SEND_LIMIT,
+ windowSeconds: GLOBAL_WINDOW_SECONDS,
+ });
+ return global.allowed ? ('allowed' as const) : ('rate_limited' as const);
+ });
+ if (authorization !== 'allowed') fail(authorization);
+
+ try {
+ const result = resend.sendEmailRequest(ctx.as.resend, {
+ to: [to],
+ subject,
+ html: messageHtml(message),
+ text: message,
+ tagsJson: JSON.stringify([
+ { name: 'source', value: 'dispatch' },
+ { name: 'userId', value: subjectFor(ctx) },
+ ]),
+ });
+ return {
+ ok: true,
+ resendId: result.resendId,
+ message: `Dispatched to ${to}.`,
+ };
+ } catch {
+ return {
+ ok: false,
+ resendId: undefined,
+ message: 'dispatch.delivery_failed',
+ };
+ }
+ }
+);
+
+const dispatchDeleteResult = t.object('DispatchDeleteResult', {
+ ok: t.bool(),
+ removed: t.u32(),
+});
+
+// Remove a single dispatch and any delivery events it collected. This is a demo
+// convenience so the log can be pruned; it writes directly to the submodule tables.
+export const deleteDispatch = spacetimedb.procedure(
+ { resendId: t.string() },
+ dispatchDeleteResult,
+ (ctx, args) => {
+ return ctx.withTx(tx => {
+ let removed = 0;
+ const row = tx.db.resend.resendEmail.resendId.find(args.resendId);
+ if (row && row.userId === subjectFor(ctx)) {
+ tx.db.resend.resendEmail.delete(row);
+ removed += 1;
+ for (const event of tx.db.resend.resendDeliveryEvent.byResendId.filter(
+ args.resendId
+ )) {
+ tx.db.resend.resendDeliveryEvent.delete(event);
+ }
+ }
+ return { ok: true, removed };
+ });
+ }
+);
+
+export const clearDispatches = spacetimedb.procedure(
+ {},
+ dispatchDeleteResult,
+ ctx => {
+ return ctx.withTx(tx => {
+ let removed = 0;
+ const owned = [
+ ...tx.db.resend.resendEmail.byUserId.filter(subjectFor(ctx)),
+ ];
+ for (const row of owned) {
+ for (const event of tx.db.resend.resendDeliveryEvent.byResendId.filter(
+ row.resendId
+ )) {
+ tx.db.resend.resendDeliveryEvent.delete(event);
+ }
+ tx.db.resend.resendEmail.delete(row);
+ removed += 1;
+ }
+ return { ok: true, removed };
+ });
+ }
+);
+
+// Resend posts delivery webhooks straight to the database over a native STDB HTTP
+// route. The submodule verifies the svix signature in-module (via crypto-ts) and
+// ingests. No Node relay does any of this work.
+const resendWebhookHandler = resend.makeResendWebhookHandler();
+export const resendWebhook = spacetimedb.httpHandler((ctx, req) =>
+ resendWebhookHandler(ctx.as.resend, req)
+);
+export const router = spacetimedb.httpRouter(
+ new Router().post('/webhook/resend', resendWebhook)
+);
+
+export const init = spacetimedb.init(ctx => {
+ resend.installResend(ctx.as.resend);
+ rateLimit.installRateLimit(ctx.as.rateLimit);
+});
+
+export default spacetimedb;
diff --git a/spacetime-resend-ts/example/spacetimedb/src/message.ts b/spacetime-resend-ts/example/spacetimedb/src/message.ts
new file mode 100644
index 00000000000..38656a3766c
--- /dev/null
+++ b/spacetime-resend-ts/example/spacetimedb/src/message.ts
@@ -0,0 +1,20 @@
+function escapeHtml(value: string): string {
+ return value
+ .replace(/&/g, '&')
+ .replace(//g, '>');
+}
+
+export function messageHtml(message: string): string {
+ const paragraphs = message
+ .split(/\n{2,}/)
+ .map(block => escapeHtml(block).replace(/\n/g, ' '))
+ .map(block => `${block}
`)
+ .join('');
+ return [
+ "',
+ paragraphs,
+ '
',
+ ].join('');
+}
diff --git a/spacetime-resend-ts/example/spacetimedb/tsconfig.json b/spacetime-resend-ts/example/spacetimedb/tsconfig.json
new file mode 100644
index 00000000000..f004a6cbc79
--- /dev/null
+++ b/spacetime-resend-ts/example/spacetimedb/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "compilerOptions": {
+ "target": "ESNext",
+ "module": "ESNext",
+ "strict": true,
+ "declaration": false,
+ "noEmit": true,
+ "skipLibCheck": true,
+ "moduleResolution": "Bundler",
+ "isolatedModules": true,
+ "allowImportingTsExtensions": true
+ },
+ "include": ["src/**/*.ts"],
+ "exclude": ["node_modules"]
+}
diff --git a/spacetime-resend-ts/example/src/app.ts b/spacetime-resend-ts/example/src/app.ts
new file mode 100644
index 00000000000..1193f9e73f4
--- /dev/null
+++ b/spacetime-resend-ts/example/src/app.ts
@@ -0,0 +1,673 @@
+import {
+ DbConnection,
+ tables,
+ type ErrorContext,
+ type EventContext,
+ type SubscriptionEventContext,
+} from './module_bindings';
+import type { Timestamp } from 'spacetimedb';
+import { messageHtml } from '../spacetimedb/src/message';
+
+type TableAccessor = {
+ iter(): Iterable;
+ onInsert(cb: (ctx: EventContext, row: T) => void): void;
+ onUpdate(cb: (ctx: EventContext, old: T, row: T) => void): void;
+ onDelete(cb: (ctx: EventContext, row: T) => void): void;
+};
+
+// Mirrors the caller-scoped my_dispatch_emails view. Options arrive as
+// `T | undefined`; status is a tagged enum.
+type ResendEmail = {
+ resendId: string;
+ fromAddress: string;
+ toAddressesJson: string;
+ subject?: string;
+ status: { tag: string } | string;
+ lastError?: string;
+ bouncedAt?: Timestamp;
+ failedAt?: Timestamp;
+ failureReason?: string;
+ complained: boolean;
+ complainedAt?: Timestamp;
+ opened: boolean;
+ openedAt?: Timestamp;
+ clicked: boolean;
+ clickedAt?: Timestamp;
+ deliveredAt?: Timestamp;
+ sentAt?: Timestamp;
+ createdAt: Timestamp;
+ updatedAt: Timestamp;
+};
+
+type SendResult = {
+ ok: boolean;
+ resendId?: string;
+ message: string;
+};
+
+type ServerConfig = {
+ spacetimeUri: string;
+ databaseName: string;
+ resendConfigured: boolean;
+ defaultFrom: string;
+ allowedRecipients: string[];
+};
+
+const $ = (id: string) =>
+ document.getElementById(id) as T;
+
+let conn: DbConnection | null = null;
+let currentConfig: ServerConfig | null = null;
+let sending = false;
+
+function escapeHtml(value: unknown): string {
+ return String(value ?? '')
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
+function tagOf(value: { tag: string } | string | undefined): string {
+ if (value && typeof value === 'object' && 'tag' in value) return value.tag;
+ return String(value ?? '');
+}
+
+function timestampMs(ts: Timestamp | undefined): number {
+ if (!ts) return 0;
+ return Number(ts.microsSinceUnixEpoch / 1000n);
+}
+
+function timeLabel(ts: Timestamp | undefined): string {
+ const ms = timestampMs(ts);
+ if (!ms) return '';
+ return new Date(ms).toLocaleTimeString([], {
+ hour: 'numeric',
+ minute: '2-digit',
+ second: '2-digit',
+ });
+}
+
+const AVATAR_COLORS = [
+ '#4cf490',
+ '#02befa',
+ '#a880ff',
+ '#fbdc8e',
+ '#ff9e9e',
+ '#00ccb4',
+ '#ff80fb',
+];
+
+// The Resend test addresses always get the same, recognizable colour.
+const KNOWN_AVATAR_COLORS: Record = {
+ 'delivered@resend.dev': '#4cf490', // green
+ 'bounced@resend.dev': '#ff9e9e', // orange
+ 'complained@resend.dev': '#a880ff', // purple
+};
+
+function avatarColor(seed: string): string {
+ const known = KNOWN_AVATAR_COLORS[seed.toLowerCase()];
+ if (known) return known;
+ let h = 0;
+ for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) >>> 0;
+ return AVATAR_COLORS[h % AVATAR_COLORS.length];
+}
+
+function recipient(row: ResendEmail): string {
+ try {
+ const parsed = JSON.parse(row.toAddressesJson);
+ if (Array.isArray(parsed) && parsed.length > 0) return String(parsed[0]);
+ } catch {
+ /* fall through */
+ }
+ return row.toAddressesJson;
+}
+
+function initials(email: string): string {
+ const local = email.split('@')[0] ?? email;
+ return (local.slice(0, 2) || '?').toUpperCase();
+}
+
+function showError(message: string) {
+ $('form-error').textContent = message;
+}
+
+function clearError() {
+ $('form-error').textContent = '';
+}
+
+let flashTimer: ReturnType | undefined;
+
+function flashSent() {
+ const btn = $('send-btn') as HTMLButtonElement;
+ btn.classList.add('sent');
+ btn.textContent = 'Sent';
+ if (flashTimer) clearTimeout(flashTimer);
+ flashTimer = setTimeout(() => {
+ btn.classList.remove('sent');
+ if (!sending) btn.textContent = 'Send';
+ }, 1800);
+}
+
+async function loadServerConfig(): Promise {
+ const res = await fetch('/api/config');
+ if (!res.ok) throw new Error(`/api/config returned ${res.status}`);
+ return (await res.json()) as ServerConfig;
+}
+
+async function connect(config: ServerConfig): Promise {
+ return new Promise((resolve, reject) => {
+ DbConnection.builder()
+ .withUri(config.spacetimeUri)
+ .withDatabaseName(config.databaseName)
+ .onConnect(c => resolve(c))
+ .onDisconnect((_ctx, err) => {
+ showError(`Disconnected: ${err?.message ?? 'connection lost'}`);
+ })
+ .onConnectError((_ctx: ErrorContext, err) => reject(err))
+ .build();
+ });
+}
+
+function getTableAccessor(name: string): TableAccessor | undefined {
+ const db = (conn?.db ?? {}) as Record | undefined>;
+ return db[name];
+}
+
+function emails(): ResendEmail[] {
+ return [
+ ...(getTableAccessor('myDispatchEmails')?.iter() ?? []),
+ ].sort((a, b) => {
+ return timestampMs(b.createdAt) - timestampMs(a.createdAt);
+ });
+}
+
+type Node = {
+ key: string;
+ label: string;
+ at?: Timestamp;
+ state: 'done' | 'live' | 'pending' | 'bad' | 'warn';
+};
+
+// Turn a stored email into an ordered set of lifecycle nodes. The EmailStatus enum
+// tops out at Delivered; Opened/Clicked are booleans; negatives are terminal.
+function timeline(row: ResendEmail): Node[] {
+ const status = tagOf(row.status);
+ const nodes: Node[] = [
+ { key: 'queued', label: 'Queued', at: row.createdAt, state: 'done' },
+ ];
+
+ const sentReached =
+ Boolean(row.sentAt) ||
+ Boolean(row.deliveredAt) ||
+ row.opened ||
+ row.clicked ||
+ status === 'Sent' ||
+ status === 'Delivered';
+ nodes.push({
+ key: 'sent',
+ label: 'Sent',
+ at: row.sentAt,
+ state: sentReached ? 'done' : 'pending',
+ });
+
+ if (status === 'Bounced') {
+ nodes.push({
+ key: 'bounced',
+ label: 'Bounced',
+ at: row.bouncedAt,
+ state: 'bad',
+ });
+ return nodes;
+ }
+ if (status === 'Failed') {
+ nodes.push({
+ key: 'failed',
+ label: 'Failed',
+ at: row.failedAt,
+ state: 'bad',
+ });
+ return nodes;
+ }
+ if (status === 'Cancelled') {
+ nodes.push({ key: 'cancelled', label: 'Cancelled', state: 'warn' });
+ return nodes;
+ }
+
+ // A complaint (marked as spam) implies the mail was delivered, and it is terminal.
+ const deliveredReached =
+ Boolean(row.deliveredAt) ||
+ row.opened ||
+ row.clicked ||
+ status === 'Delivered' ||
+ row.complained;
+ const delayed = status === 'DeliveryDelayed' && !deliveredReached;
+ nodes.push({
+ key: 'delivered',
+ label: delayed ? 'Delayed' : 'Delivered',
+ at: row.deliveredAt,
+ state: deliveredReached ? 'done' : delayed ? 'warn' : 'pending',
+ });
+
+ if (row.complained) {
+ // Terminal: show completed positive steps followed by Complaint.
+ // No trailing "pending" steps and no live pulse - the lifecycle is over.
+ if (row.opened)
+ nodes.push({
+ key: 'opened',
+ label: 'Opened',
+ at: row.openedAt,
+ state: 'done',
+ });
+ if (row.clicked)
+ nodes.push({
+ key: 'clicked',
+ label: 'Clicked',
+ at: row.clickedAt,
+ state: 'done',
+ });
+ nodes.push({
+ key: 'complaint',
+ label: 'Complaint',
+ at: row.complainedAt,
+ state: 'bad',
+ });
+ return nodes;
+ }
+
+ nodes.push({
+ key: 'opened',
+ label: 'Opened',
+ at: row.openedAt,
+ state: row.opened ? 'done' : 'pending',
+ });
+ nodes.push({
+ key: 'clicked',
+ label: 'Clicked',
+ at: row.clickedAt,
+ state: row.clicked ? 'done' : 'pending',
+ });
+
+ // The furthest reached positive node is the live frontier. A completed later
+ // node makes the final completed positive node the subtle accent.
+ const lastDone = nodes.map(n => n.state === 'done').lastIndexOf(true);
+ if (
+ lastDone > 0 &&
+ nodes[lastDone].state === 'done' &&
+ nodes.some(n => n.state === 'pending')
+ ) {
+ nodes[lastDone].state = 'live';
+ }
+ return nodes;
+}
+
+function headPill(row: ResendEmail): { cls: string; text: string } {
+ const status = tagOf(row.status);
+ if (status === 'Bounced') return { cls: 'red', text: 'Bounced' };
+ if (status === 'Failed') return { cls: 'red', text: 'Failed' };
+ if (status === 'Cancelled') return { cls: 'muted', text: 'Cancelled' };
+ if (row.complained) return { cls: 'red', text: 'Complaint' };
+ if (row.clicked) return { cls: 'green', text: 'Clicked' };
+ if (row.opened) return { cls: 'green', text: 'Opened' };
+ if (row.deliveredAt || status === 'Delivered')
+ return { cls: 'green', text: 'Delivered' };
+ if (status === 'DeliveryDelayed') return { cls: 'yellow', text: 'Delayed' };
+ if (row.sentAt || status === 'Sent') return { cls: 'green', text: 'Sent' };
+ return { cls: 'muted', text: 'Queued' };
+}
+
+// Per-email rendered-node keys ensure each changed transition animates once.
+const lastReached = new Map>();
+const knownEmails = new Set();
+const lastCounts: Record = {
+ sent: -1,
+ delivered: -1,
+ opened: -1,
+ bounced: -1,
+};
+
+function prefersReducedMotion(): boolean {
+ return (
+ typeof matchMedia === 'function' &&
+ matchMedia('(prefers-reduced-motion: reduce)').matches
+ );
+}
+
+// Odometer roll: stack the current and incoming values in a clip window. An
+// increase rolls up from below; a decrease rolls down.
+function rollNumber(el: HTMLElement, from: number, to: number) {
+ const up = to > from;
+ const top = up ? from : to;
+ const bottom = up ? to : from;
+ el.innerHTML =
+ `` +
+ `${top} ${bottom} ` +
+ ` `;
+ const odo = el.querySelector('.odo');
+ if (!odo) {
+ el.textContent = String(to);
+ return;
+ }
+ // Collapse back to a plain number once the roll finishes (or is superseded).
+ const settle = () => {
+ if (el.contains(odo)) el.textContent = String(to);
+ };
+ odo.addEventListener('animationend', settle, { once: true });
+ setTimeout(settle, 600);
+}
+
+function bumpMetric(id: string, key: string, value: number) {
+ const el = $(id);
+ const prev = lastCounts[key];
+ lastCounts[key] = value;
+ if (prev < 0 || prev === value || prefersReducedMotion()) {
+ el.textContent = String(value);
+ return;
+ }
+ rollNumber(el, prev, value);
+}
+
+function renderMetrics(list: ResendEmail[]) {
+ const delivered = list.filter(
+ r =>
+ r.deliveredAt || r.opened || r.clicked || tagOf(r.status) === 'Delivered'
+ ).length;
+ const opened = list.filter(r => r.opened).length;
+ const bounced = list.filter(r => {
+ const s = tagOf(r.status);
+ return s === 'Bounced' || s === 'Failed' || r.complained;
+ }).length;
+ bumpMetric('count-sent', 'sent', list.length);
+ bumpMetric('count-delivered', 'delivered', delivered);
+ bumpMetric('count-opened', 'opened', opened);
+ bumpMetric('count-bounced', 'bounced', bounced);
+}
+
+function nodeHtml(node: Node, lit: boolean): string {
+ const time = node.at ? timeLabel(node.at) : '';
+ return `
+
+
+ ${escapeHtml(node.label)}
+ ${escapeHtml(time)}
+
`;
+}
+
+function mailHtml(
+ row: ResendEmail,
+ nodes: Node[],
+ newlyLit: Set,
+ isNew: boolean
+): string {
+ const to = recipient(row);
+ const pill = headPill(row);
+ const bad = pill.cls === 'red';
+ const subject =
+ row.subject && row.subject.length ? row.subject : '(no subject)';
+ const track = nodes.map(n => nodeHtml(n, newlyLit.has(n.key))).join('');
+ const error =
+ bad && row.failureReason
+ ? `${escapeHtml(row.failureReason)}
`
+ : bad && row.lastError
+ ? `${escapeHtml(row.lastError)}
`
+ : '';
+ return `
+
+
+ ${escapeHtml(initials(to))}
+
+ ${escapeHtml(to)}
+ ${escapeHtml(subject)}
+
+ ${escapeHtml(timeLabel(row.createdAt))}
+ ×
+
+ ${track}
+ ${error}
+ `;
+}
+
+function reachedKeys(nodes: Node[]): Set {
+ return new Set(nodes.filter(n => n.state !== 'pending').map(n => n.key));
+}
+
+function render() {
+ const list = emails();
+ renderMetrics(list);
+ $('feed-count').textContent = `${list.length} sent`;
+ ($('clear-btn') as HTMLButtonElement).disabled = list.length === 0;
+
+ if (list.length === 0) {
+ $('feed').innerHTML =
+ `No dispatches yet. Compose a message and hit Send to watch it move.
`;
+ lastReached.clear();
+ knownEmails.clear();
+ return;
+ }
+
+ const entries = list.map(row => ({ row, nodes: timeline(row) }));
+
+ $('feed').innerHTML = entries
+ .map(({ row, nodes }) => {
+ const reached = reachedKeys(nodes);
+ const prev = lastReached.get(row.resendId);
+ const isNew = !knownEmails.has(row.resendId);
+ const newlyLit = new Set();
+ if (prev) for (const k of reached) if (!prev.has(k)) newlyLit.add(k);
+ return mailHtml(row, nodes, newlyLit, isNew);
+ })
+ .join('');
+
+ // Record post-render state so the next render can diff against it.
+ const currentIds = new Set();
+ for (const { row, nodes } of entries) {
+ lastReached.set(row.resendId, reachedKeys(nodes));
+ knownEmails.add(row.resendId);
+ currentIds.add(row.resendId);
+ }
+ for (const id of [...knownEmails]) {
+ if (!currentIds.has(id)) {
+ knownEmails.delete(id);
+ lastReached.delete(id);
+ }
+ }
+}
+
+// Coalesce the burst of table events from a single webhook (email row update +
+// delivery-event insert land together) into one render, so the change diff sees the
+// whole transition at once and animates the node that advanced.
+let renderScheduled = false;
+function scheduleRender() {
+ if (renderScheduled) return;
+ renderScheduled = true;
+ setTimeout(() => {
+ renderScheduled = false;
+ render();
+ }, 0);
+}
+
+function registerCallbacksForTable(name: string): void {
+ const accessor = getTableAccessor(name);
+ if (!accessor) throw new Error(`missing table accessor: ${name}`);
+ accessor.onInsert(() => scheduleRender());
+ accessor.onUpdate(() => scheduleRender());
+ accessor.onDelete(() => scheduleRender());
+}
+
+function registerRowCallbacks(): void {
+ for (const name of ['myDispatchEmails', 'myDispatchDeliveryEvents']) {
+ registerCallbacksForTable(name);
+ }
+}
+
+function subscribeToTables(connection: DbConnection): void {
+ connection
+ .subscriptionBuilder()
+ .onApplied((_ctx: SubscriptionEventContext) => {
+ render();
+ if (!currentConfig?.resendConfigured) {
+ showError(
+ 'RESEND_API_KEY is missing in this example server environment.'
+ );
+ }
+ })
+ .onError((ctx: ErrorContext) => {
+ console.error('subscription error:', ctx.event);
+ showError('Subscription failed. Check the server console.');
+ })
+ .subscribe([tables.myDispatchEmails, tables.myDispatchDeliveryEvents]);
+}
+
+async function sendDispatch(
+ to: string,
+ subject: string,
+ message: string
+): Promise {
+ if (!conn) throw new Error('not connected');
+ return conn.procedures.sendDispatch({ to, subject, message });
+}
+
+async function deleteDispatch(resendId: string): Promise {
+ if (!conn) throw new Error('not connected');
+ await conn.procedures.deleteDispatch({ resendId });
+}
+
+async function clearDispatches(): Promise {
+ if (!conn) throw new Error('not connected');
+ await conn.procedures.clearDispatches({});
+}
+
+function setSending(next: boolean) {
+ sending = next;
+ const btn = $('send-btn') as HTMLButtonElement;
+ btn.disabled = next;
+ if (next) btn.textContent = 'Sending...';
+ else if (!btn.classList.contains('sent')) btn.textContent = 'Send';
+}
+
+function renderPreview() {
+ const subject =
+ ($('subject-input') as HTMLInputElement).value.trim() || '(no subject)';
+ const message = ($('message-input') as HTMLTextAreaElement).value.trim();
+ const from = currentConfig?.defaultFrom || 'onboarding@resend.dev';
+ const body = message
+ ? messageHtml(message)
+ : 'Nothing to preview yet. ';
+ $('message-preview').innerHTML = `
+
+
${escapeHtml(subject)}
+
From ${escapeHtml(from)}
+
+ ${body}
`;
+}
+
+function setMsgTab(tab: 'write' | 'preview') {
+ const isPreview = tab === 'preview';
+ if (isPreview) renderPreview();
+ ($('message-input') as HTMLTextAreaElement).hidden = isPreview;
+ $('message-preview').hidden = !isPreview;
+ document.querySelectorAll('.msg-tab').forEach(b => {
+ b.classList.toggle('active', b.dataset.tab === tab);
+ });
+}
+
+async function submitCompose() {
+ if (sending) return;
+ const to = ($('to-input') as HTMLInputElement).value.trim();
+ const subject = ($('subject-input') as HTMLInputElement).value.trim();
+ const message = ($('message-input') as HTMLTextAreaElement).value.trim();
+ if (!to) {
+ showError('Add a recipient first.');
+ return;
+ }
+ if (!message) {
+ showError('Write a message before sending.');
+ return;
+ }
+ clearError();
+ setSending(true);
+ try {
+ const result = await sendDispatch(to, subject, message);
+ setSending(false);
+ if (result.ok) {
+ ($('subject-input') as HTMLInputElement).value = '';
+ ($('message-input') as HTMLTextAreaElement).value = '';
+ setMsgTab('write');
+ flashSent();
+ } else {
+ showError(result.message);
+ }
+ } catch (err) {
+ setSending(false);
+ showError(err instanceof Error ? err.message : String(err));
+ }
+}
+
+function registerUiHandlers(): void {
+ $('compose-form').addEventListener('submit', event => {
+ event.preventDefault();
+ submitCompose().catch(err => {
+ console.error(err);
+ showError(err instanceof Error ? err.message : String(err));
+ });
+ });
+
+ document.querySelectorAll('.msg-tab').forEach(button => {
+ button.addEventListener('click', () => {
+ setMsgTab(button.dataset.tab === 'preview' ? 'preview' : 'write');
+ });
+ });
+
+ document
+ .querySelectorAll('[data-fill]')
+ .forEach(button => {
+ button.addEventListener('click', () => {
+ const email = button.dataset.fill ?? 'delivered@resend.dev';
+ ($('to-input') as HTMLInputElement).value = email;
+ const subject = $('subject-input') as HTMLInputElement;
+ const message = $('message-input') as HTMLTextAreaElement;
+ if (!subject.value.trim()) subject.value = 'Hello from Dispatch';
+ if (!message.value.trim())
+ message.value = 'Watching this one travel through the pipeline.';
+ clearError();
+ ($('to-input') as HTMLInputElement).focus();
+ });
+ });
+
+ $('feed').addEventListener('click', event => {
+ const btn = (event.target as HTMLElement).closest(
+ '[data-del]'
+ );
+ if (!btn) return;
+ const resendId = btn.dataset.del;
+ if (!resendId) return;
+ btn.setAttribute('disabled', 'true');
+ deleteDispatch(resendId).catch(err => {
+ console.error(err);
+ showError(err instanceof Error ? err.message : String(err));
+ });
+ });
+
+ $('clear-btn').addEventListener('click', () => {
+ clearDispatches().catch(err => {
+ console.error(err);
+ showError(err instanceof Error ? err.message : String(err));
+ });
+ });
+}
+
+async function main() {
+ registerUiHandlers();
+ currentConfig = await loadServerConfig();
+ const recipientInput = $('to-input') as HTMLInputElement;
+ recipientInput.value = currentConfig.allowedRecipients[0] ?? '';
+ conn = await connect(currentConfig);
+ registerRowCallbacks();
+ subscribeToTables(conn);
+}
+
+main().catch(err => {
+ console.error(err);
+ showError(err instanceof Error ? err.message : String(err));
+});
diff --git a/spacetime-resend-ts/example/src/module_bindings/clear_dispatches_procedure.ts b/spacetime-resend-ts/example/src/module_bindings/clear_dispatches_procedure.ts
new file mode 100644
index 00000000000..6621a844b9c
--- /dev/null
+++ b/spacetime-resend-ts/example/src/module_bindings/clear_dispatches_procedure.ts
@@ -0,0 +1,19 @@
+// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
+// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
+
+/* eslint-disable */
+/* tslint:disable */
+import {
+ TypeBuilder as __TypeBuilder,
+ t as __t,
+ type AlgebraicTypeType as __AlgebraicTypeType,
+ type Infer as __Infer,
+} from "spacetimedb";
+
+import {
+ DispatchDeleteResult,
+} from "./types";
+
+export const params = {
+};
+export const returnType = DispatchDeleteResult
\ No newline at end of file
diff --git a/spacetime-resend-ts/example/src/module_bindings/delete_dispatch_procedure.ts b/spacetime-resend-ts/example/src/module_bindings/delete_dispatch_procedure.ts
new file mode 100644
index 00000000000..9e246ad1174
--- /dev/null
+++ b/spacetime-resend-ts/example/src/module_bindings/delete_dispatch_procedure.ts
@@ -0,0 +1,20 @@
+// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
+// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
+
+/* eslint-disable */
+/* tslint:disable */
+import {
+ TypeBuilder as __TypeBuilder,
+ t as __t,
+ type AlgebraicTypeType as __AlgebraicTypeType,
+ type Infer as __Infer,
+} from "spacetimedb";
+
+import {
+ DispatchDeleteResult,
+} from "./types";
+
+export const params = {
+ resendId: __t.string(),
+};
+export const returnType = DispatchDeleteResult
\ No newline at end of file
diff --git a/spacetime-resend-ts/example/src/module_bindings/index.ts b/spacetime-resend-ts/example/src/module_bindings/index.ts
new file mode 100644
index 00000000000..79f4f099cee
--- /dev/null
+++ b/spacetime-resend-ts/example/src/module_bindings/index.ts
@@ -0,0 +1,239 @@
+// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
+// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
+
+// This was generated using spacetimedb cli version 2.10.1 (commit 5418e2f229c57f503d4306ae9e5fb6df29533f7d).
+
+/* eslint-disable */
+/* tslint:disable */
+import {
+ DbConnectionBuilder as __DbConnectionBuilder,
+ DbConnectionImpl as __DbConnectionImpl,
+ SubscriptionBuilderImpl as __SubscriptionBuilderImpl,
+ TypeBuilder as __TypeBuilder,
+ Uuid as __Uuid,
+ convertToAccessorMap as __convertToAccessorMap,
+ makeQueryBuilder as __makeQueryBuilder,
+ procedureSchema as __procedureSchema,
+ procedures as __procedures,
+ reducerSchema as __reducerSchema,
+ reducers as __reducers,
+ schema as __schema,
+ t as __t,
+ table as __table,
+ type AlgebraicTypeType as __AlgebraicTypeType,
+ type DbConnectionConfig as __DbConnectionConfig,
+ type ErrorContextInterface as __ErrorContextInterface,
+ type Event as __Event,
+ type EventContextInterface as __EventContextInterface,
+ type Infer as __Infer,
+ type QueryBuilder as __QueryBuilder,
+ type ReducerEventContextInterface as __ReducerEventContextInterface,
+ type RemoteModule as __RemoteModule,
+ type SubscriptionEventContextInterface as __SubscriptionEventContextInterface,
+ type SubscriptionHandleImpl as __SubscriptionHandleImpl,
+} from "spacetimedb";
+
+// Import all reducer arg schemas
+
+// Import all procedure arg schemas
+import * as ClearDispatchesProcedure from "./clear_dispatches_procedure";
+import * as DeleteDispatchProcedure from "./delete_dispatch_procedure";
+import * as SendDispatchProcedure from "./send_dispatch_procedure";
+import * as SetDispatchPolicyProcedure from "./set_dispatch_policy_procedure";
+
+// Import all table schema definitions
+import MyDispatchDeliveryEventsRow from "./my_dispatch_delivery_events_table";
+import MyDispatchEmailsRow from "./my_dispatch_emails_table";
+
+// Import namespace table schema definitions
+import RateLimit_RateLimitConfigRow from "./rateLimit/rate_limit_config_table";
+import RateLimit_AdminRateLimitBucketsRow from "./rateLimit/admin_rate_limit_buckets_table";
+
+// Import namespace reducer arg schemas
+import Resend_IngestResendWebhookReducer from "./resend/ingest_resend_webhook_reducer";
+import Resend_ReplayWebhookEventReducer from "./resend/replay_webhook_event_reducer";
+import RateLimit_AddRateLimitAdminReducer from "./rateLimit/add_rate_limit_admin_reducer";
+import RateLimit_ResetBucketsReducer from "./rateLimit/reset_buckets_reducer";
+import RateLimit_UpdateConfigReducer from "./rateLimit/update_config_reducer";
+
+// Import namespace procedure arg schemas
+import * as Resend_AddAdminIdentityProcedure from "./resend/add_admin_identity_procedure";
+import * as Resend_CancelEmailProcedure from "./resend/cancel_email_procedure";
+import * as Resend_GetEmailProcedure from "./resend/get_email_procedure";
+import * as Resend_GetResendConfigStatusProcedure from "./resend/get_resend_config_status_procedure";
+import * as Resend_ListDeliveryEventsForEmailProcedure from "./resend/list_delivery_events_for_email_procedure";
+import * as Resend_ListEmailsByOrgIdProcedure from "./resend/list_emails_by_org_id_procedure";
+import * as Resend_ListEmailsByStatusProcedure from "./resend/list_emails_by_status_procedure";
+import * as Resend_ListEmailsByUserIdProcedure from "./resend/list_emails_by_user_id_procedure";
+import * as Resend_RemoveAdminIdentityProcedure from "./resend/remove_admin_identity_procedure";
+import * as Resend_ResendApiRequestProcedure from "./resend/resend_api_request_procedure";
+import * as Resend_SendEmailProcedure from "./resend/send_email_procedure";
+import * as Resend_SetResendConfigProcedure from "./resend/set_resend_config_procedure";
+import * as RateLimit_ConsumeProcedure from "./rateLimit/consume_procedure";
+import * as RateLimit_RunSweepProcedure from "./rateLimit/run_sweep_procedure";
+
+/** Type-only namespace exports for generated type groups. */
+
+/** The schema information for all tables in this module. This is defined the same was as the tables would have been defined in the server. */
+const tablesSchema = __schema({
+ myDispatchDeliveryEvents: __table({
+ name: 'my_dispatch_delivery_events',
+ indexes: [
+ ],
+ constraints: [
+ ],
+ }, MyDispatchDeliveryEventsRow),
+ myDispatchEmails: __table({
+ name: 'my_dispatch_emails',
+ indexes: [
+ ],
+ constraints: [
+ ],
+ }, MyDispatchEmailsRow),
+ "rateLimit.rate_limit_config": __table({
+ name: 'rateLimit.rate_limit_config',
+ indexes: [
+ { accessor: 'singleton', name: 'rate_limit_config_singleton_idx_btree', algorithm: 'btree', columns: [
+ 'singleton',
+ ] },
+ ],
+ constraints: [
+ { name: 'rate_limit_config_singleton_key', constraint: 'unique', columns: ['singleton'] },
+ ],
+ }, RateLimit_RateLimitConfigRow),
+ "rateLimit.admin_rate_limit_buckets": __table({
+ name: 'rateLimit.admin_rate_limit_buckets',
+ indexes: [
+ ],
+ constraints: [
+ ],
+ }, RateLimit_AdminRateLimitBucketsRow),
+});
+
+/** The schema information for all reducers in this module. This is defined the same way as the reducers would have been defined in the server, except the body of the reducer is omitted in code generation. */
+const reducersSchema = __reducers(
+ __reducerSchema("resend.ingest_resend_webhook", Resend_IngestResendWebhookReducer),
+ __reducerSchema("resend.replay_webhook_event", Resend_ReplayWebhookEventReducer),
+ __reducerSchema("rateLimit.add_rate_limit_admin", RateLimit_AddRateLimitAdminReducer),
+ __reducerSchema("rateLimit.reset_buckets", RateLimit_ResetBucketsReducer),
+ __reducerSchema("rateLimit.update_config", RateLimit_UpdateConfigReducer),
+);
+
+/** The schema information for all procedures in this module. This is defined the same way as the procedures would have been defined in the server. */
+const proceduresSchema = __procedures(
+ __procedureSchema("clear_dispatches", ClearDispatchesProcedure.params, ClearDispatchesProcedure.returnType),
+ __procedureSchema("delete_dispatch", DeleteDispatchProcedure.params, DeleteDispatchProcedure.returnType),
+ __procedureSchema("send_dispatch", SendDispatchProcedure.params, SendDispatchProcedure.returnType),
+ __procedureSchema("set_dispatch_policy", SetDispatchPolicyProcedure.params, SetDispatchPolicyProcedure.returnType),
+ __procedureSchema("resend.add_admin_identity", Resend_AddAdminIdentityProcedure.params, Resend_AddAdminIdentityProcedure.returnType),
+ __procedureSchema("resend.cancel_email", Resend_CancelEmailProcedure.params, Resend_CancelEmailProcedure.returnType),
+ __procedureSchema("resend.get_email", Resend_GetEmailProcedure.params, Resend_GetEmailProcedure.returnType),
+ __procedureSchema("resend.get_resend_config_status", Resend_GetResendConfigStatusProcedure.params, Resend_GetResendConfigStatusProcedure.returnType),
+ __procedureSchema("resend.list_delivery_events_for_email", Resend_ListDeliveryEventsForEmailProcedure.params, Resend_ListDeliveryEventsForEmailProcedure.returnType),
+ __procedureSchema("resend.list_emails_by_org_id", Resend_ListEmailsByOrgIdProcedure.params, Resend_ListEmailsByOrgIdProcedure.returnType),
+ __procedureSchema("resend.list_emails_by_status", Resend_ListEmailsByStatusProcedure.params, Resend_ListEmailsByStatusProcedure.returnType),
+ __procedureSchema("resend.list_emails_by_user_id", Resend_ListEmailsByUserIdProcedure.params, Resend_ListEmailsByUserIdProcedure.returnType),
+ __procedureSchema("resend.remove_admin_identity", Resend_RemoveAdminIdentityProcedure.params, Resend_RemoveAdminIdentityProcedure.returnType),
+ __procedureSchema("resend.resend_api_request", Resend_ResendApiRequestProcedure.params, Resend_ResendApiRequestProcedure.returnType),
+ __procedureSchema("resend.send_email", Resend_SendEmailProcedure.params, Resend_SendEmailProcedure.returnType),
+ __procedureSchema("resend.set_resend_config", Resend_SetResendConfigProcedure.params, Resend_SetResendConfigProcedure.returnType),
+ __procedureSchema("rateLimit.consume", RateLimit_ConsumeProcedure.params, RateLimit_ConsumeProcedure.returnType),
+ __procedureSchema("rateLimit.run_sweep", RateLimit_RunSweepProcedure.params, RateLimit_RunSweepProcedure.returnType),
+);
+
+/** The remote SpacetimeDB module schema, both runtime and type information. */
+const REMOTE_MODULE = {
+ versionInfo: {
+ cliVersion: "2.10.1" as const,
+ },
+ tables: tablesSchema.schemaType.tables,
+ reducers: reducersSchema.reducersType.reducers,
+ ...proceduresSchema,
+} satisfies __RemoteModule<
+ typeof tablesSchema.schemaType,
+ typeof reducersSchema.reducersType,
+ typeof proceduresSchema
+>;
+
+/** The tables available in this remote SpacetimeDB module. Each table reference doubles as a query builder. */
+const __qb = __makeQueryBuilder(tablesSchema.schemaType);
+export const tables = {
+ myDispatchDeliveryEvents: __qb.myDispatchDeliveryEvents,
+ myDispatchEmails: __qb.myDispatchEmails,
+ rateLimit: {
+ rateLimitConfig: __qb["rateLimit.rate_limit_config"],
+ adminRateLimitBuckets: __qb["rateLimit.admin_rate_limit_buckets"],
+ },
+} as const;
+
+/** The reducers available in this remote SpacetimeDB module. */
+const __reducerAccessors = __convertToAccessorMap(reducersSchema.reducersType.reducers);
+export const reducers = {
+ rateLimit: {
+ addRateLimitAdmin: __reducerAccessors["rateLimit.addRateLimitAdmin"],
+ resetBuckets: __reducerAccessors["rateLimit.resetBuckets"],
+ updateConfig: __reducerAccessors["rateLimit.updateConfig"],
+ },
+ resend: {
+ ingestResendWebhook: __reducerAccessors["resend.ingestResendWebhook"],
+ replayWebhookEvent: __reducerAccessors["resend.replayWebhookEvent"],
+ },
+} as const;
+
+/** The procedures available in this remote SpacetimeDB module. */
+const __procedureAccessors = __convertToAccessorMap(proceduresSchema.procedures);
+export const procedures = {
+ clearDispatches: __procedureAccessors.clearDispatches,
+ deleteDispatch: __procedureAccessors.deleteDispatch,
+ sendDispatch: __procedureAccessors.sendDispatch,
+ setDispatchPolicy: __procedureAccessors.setDispatchPolicy,
+ rateLimit: {
+ consume: __procedureAccessors["rateLimit.consume"],
+ runSweep: __procedureAccessors["rateLimit.runSweep"],
+ },
+ resend: {
+ addAdminIdentity: __procedureAccessors["resend.addAdminIdentity"],
+ cancelEmail: __procedureAccessors["resend.cancelEmail"],
+ getEmail: __procedureAccessors["resend.getEmail"],
+ getResendConfigStatus: __procedureAccessors["resend.getResendConfigStatus"],
+ listDeliveryEventsForEmail: __procedureAccessors["resend.listDeliveryEventsForEmail"],
+ listEmailsByOrgId: __procedureAccessors["resend.listEmailsByOrgId"],
+ listEmailsByStatus: __procedureAccessors["resend.listEmailsByStatus"],
+ listEmailsByUserId: __procedureAccessors["resend.listEmailsByUserId"],
+ removeAdminIdentity: __procedureAccessors["resend.removeAdminIdentity"],
+ resendApiRequest: __procedureAccessors["resend.resendApiRequest"],
+ sendEmail: __procedureAccessors["resend.sendEmail"],
+ setResendConfig: __procedureAccessors["resend.setResendConfig"],
+ },
+} as const;
+
+/** The context type returned in callbacks for all possible events. */
+export type EventContext = __EventContextInterface;
+/** The context type returned in callbacks for reducer events. */
+export type ReducerEventContext = __ReducerEventContextInterface;
+/** The context type returned in callbacks for subscription events. */
+export type SubscriptionEventContext = __SubscriptionEventContextInterface;
+/** The context type returned in callbacks for error events. */
+export type ErrorContext = __ErrorContextInterface;
+/** The subscription handle type to manage active subscriptions created from a {@link SubscriptionBuilder}. */
+export type SubscriptionHandle = __SubscriptionHandleImpl;
+
+/** Builder class to configure a new subscription to the remote SpacetimeDB instance. */
+export class SubscriptionBuilder extends __SubscriptionBuilderImpl {}
+
+/** Builder class to configure a new database connection to the remote SpacetimeDB instance. */
+export class DbConnectionBuilder extends __DbConnectionBuilder {}
+
+/** The typed database connection to manage connections to the remote SpacetimeDB instance. This class has type information specific to the generated module. */
+export class DbConnection extends __DbConnectionImpl {
+ /** Creates a new {@link DbConnectionBuilder} to configure and connect to the remote SpacetimeDB instance. */
+ static builder = (): DbConnectionBuilder => {
+ return new DbConnectionBuilder(REMOTE_MODULE, (config: __DbConnectionConfig) => new DbConnection(config));
+ };
+
+ /** Creates a new {@link SubscriptionBuilder} to configure a subscription to the remote SpacetimeDB instance. */
+ override subscriptionBuilder = (): SubscriptionBuilder => {
+ return new SubscriptionBuilder(this);
+ };
+}
+
diff --git a/spacetime-resend-ts/example/src/module_bindings/my_dispatch_delivery_events_table.ts b/spacetime-resend-ts/example/src/module_bindings/my_dispatch_delivery_events_table.ts
new file mode 100644
index 00000000000..bcd9654503f
--- /dev/null
+++ b/spacetime-resend-ts/example/src/module_bindings/my_dispatch_delivery_events_table.ts
@@ -0,0 +1,20 @@
+// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
+// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
+
+/* eslint-disable */
+/* tslint:disable */
+import {
+ TypeBuilder as __TypeBuilder,
+ t as __t,
+ type AlgebraicTypeType as __AlgebraicTypeType,
+ type Infer as __Infer,
+} from "spacetimedb";
+
+export default __t.row({
+ eventId: __t.string().primaryKey().name("event_id"),
+ resendId: __t.string().name("resend_id"),
+ eventType: __t.string().name("event_type"),
+ createdAtIso: __t.string().name("created_at_iso"),
+ detailJson: __t.option(__t.string()).name("detail_json"),
+ insertedAt: __t.timestamp().name("inserted_at"),
+});
diff --git a/spacetime-resend-ts/example/src/module_bindings/my_dispatch_emails_table.ts b/spacetime-resend-ts/example/src/module_bindings/my_dispatch_emails_table.ts
new file mode 100644
index 00000000000..4e11a588405
--- /dev/null
+++ b/spacetime-resend-ts/example/src/module_bindings/my_dispatch_emails_table.ts
@@ -0,0 +1,45 @@
+// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
+// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
+
+/* eslint-disable */
+/* tslint:disable */
+import {
+ TypeBuilder as __TypeBuilder,
+ t as __t,
+ type AlgebraicTypeType as __AlgebraicTypeType,
+ type Infer as __Infer,
+} from "spacetimedb";
+import {
+ EmailStatus,
+} from "./types";
+
+
+export default __t.row({
+ resendId: __t.string().primaryKey().name("resend_id"),
+ fromAddress: __t.string().name("from_address"),
+ toAddressesJson: __t.string().name("to_addresses_json"),
+ subject: __t.option(__t.string()),
+ get status() {
+ return EmailStatus;
+ },
+ lastError: __t.option(__t.string()).name("last_error"),
+ bouncedAt: __t.option(__t.timestamp()).name("bounced_at"),
+ bounceJson: __t.option(__t.string()).name("bounce_json"),
+ failedAt: __t.option(__t.timestamp()).name("failed_at"),
+ failureReason: __t.option(__t.string()).name("failure_reason"),
+ complained: __t.bool(),
+ complainedAt: __t.option(__t.timestamp()).name("complained_at"),
+ opened: __t.bool(),
+ openedAt: __t.option(__t.timestamp()).name("opened_at"),
+ clicked: __t.bool(),
+ clickedAt: __t.option(__t.timestamp()).name("clicked_at"),
+ deliveredAt: __t.option(__t.timestamp()).name("delivered_at"),
+ sentAt: __t.option(__t.timestamp()).name("sent_at"),
+ html: __t.option(__t.string()),
+ text: __t.option(__t.string()),
+ tagsJson: __t.option(__t.string()).name("tags_json"),
+ userId: __t.option(__t.string()).name("user_id"),
+ orgId: __t.option(__t.string()).name("org_id"),
+ createdAt: __t.timestamp().name("created_at"),
+ updatedAt: __t.timestamp().name("updated_at"),
+});
diff --git a/spacetime-resend-ts/example/src/module_bindings/rateLimit/add_rate_limit_admin_reducer.ts b/spacetime-resend-ts/example/src/module_bindings/rateLimit/add_rate_limit_admin_reducer.ts
new file mode 100644
index 00000000000..e39846ca8d9
--- /dev/null
+++ b/spacetime-resend-ts/example/src/module_bindings/rateLimit/add_rate_limit_admin_reducer.ts
@@ -0,0 +1,15 @@
+// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
+// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
+
+/* eslint-disable */
+/* tslint:disable */
+import {
+ TypeBuilder as __TypeBuilder,
+ t as __t,
+ type AlgebraicTypeType as __AlgebraicTypeType,
+ type Infer as __Infer,
+} from "spacetimedb";
+
+export default {
+ identity: __t.identity(),
+};
diff --git a/spacetime-resend-ts/example/src/module_bindings/rateLimit/admin_rate_limit_buckets_table.ts b/spacetime-resend-ts/example/src/module_bindings/rateLimit/admin_rate_limit_buckets_table.ts
new file mode 100644
index 00000000000..189f539a043
--- /dev/null
+++ b/spacetime-resend-ts/example/src/module_bindings/rateLimit/admin_rate_limit_buckets_table.ts
@@ -0,0 +1,20 @@
+// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
+// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
+
+/* eslint-disable */
+/* tslint:disable */
+import {
+ TypeBuilder as __TypeBuilder,
+ t as __t,
+ type AlgebraicTypeType as __AlgebraicTypeType,
+ type Infer as __Infer,
+} from "spacetimedb";
+
+export default __t.row({
+ key: __t.string().primaryKey(),
+ scope: __t.string(),
+ windowStart: __t.timestamp().name("window_start"),
+ expiresAt: __t.timestamp().name("expires_at"),
+ count: __t.u32(),
+ updatedAt: __t.timestamp().name("updated_at"),
+});
diff --git a/spacetime-resend-ts/example/src/module_bindings/rateLimit/consume_procedure.ts b/spacetime-resend-ts/example/src/module_bindings/rateLimit/consume_procedure.ts
new file mode 100644
index 00000000000..a98b8588aad
--- /dev/null
+++ b/spacetime-resend-ts/example/src/module_bindings/rateLimit/consume_procedure.ts
@@ -0,0 +1,24 @@
+// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
+// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
+
+/* eslint-disable */
+/* tslint:disable */
+import {
+ TypeBuilder as __TypeBuilder,
+ t as __t,
+ type AlgebraicTypeType as __AlgebraicTypeType,
+ type Infer as __Infer,
+} from "spacetimedb";
+
+import {
+ RateLimitConsumeResult,
+} from "./types";
+
+export const params = {
+ scope: __t.string(),
+ actorKey: __t.string(),
+ limit: __t.u32(),
+ windowSeconds: __t.u32(),
+ cost: __t.option(__t.u32()),
+};
+export const returnType = RateLimitConsumeResult
\ No newline at end of file
diff --git a/spacetime-resend-ts/example/src/module_bindings/rateLimit/rate_limit_config_table.ts b/spacetime-resend-ts/example/src/module_bindings/rateLimit/rate_limit_config_table.ts
new file mode 100644
index 00000000000..66ffe86e399
--- /dev/null
+++ b/spacetime-resend-ts/example/src/module_bindings/rateLimit/rate_limit_config_table.ts
@@ -0,0 +1,17 @@
+// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
+// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
+
+/* eslint-disable */
+/* tslint:disable */
+import {
+ TypeBuilder as __TypeBuilder,
+ t as __t,
+ type AlgebraicTypeType as __AlgebraicTypeType,
+ type Infer as __Infer,
+} from "spacetimedb";
+
+export default __t.row({
+ singleton: __t.bool().primaryKey(),
+ sweepBatch: __t.u32().name("sweep_batch"),
+ updatedAt: __t.timestamp().name("updated_at"),
+});
diff --git a/spacetime-resend-ts/example/src/module_bindings/rateLimit/reset_buckets_reducer.ts b/spacetime-resend-ts/example/src/module_bindings/rateLimit/reset_buckets_reducer.ts
new file mode 100644
index 00000000000..a7c5cc5274f
--- /dev/null
+++ b/spacetime-resend-ts/example/src/module_bindings/rateLimit/reset_buckets_reducer.ts
@@ -0,0 +1,15 @@
+// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
+// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
+
+/* eslint-disable */
+/* tslint:disable */
+import {
+ TypeBuilder as __TypeBuilder,
+ t as __t,
+ type AlgebraicTypeType as __AlgebraicTypeType,
+ type Infer as __Infer,
+} from "spacetimedb";
+
+export default {
+ maxRows: __t.option(__t.u32()),
+};
diff --git a/spacetime-resend-ts/example/src/module_bindings/rateLimit/run_sweep_procedure.ts b/spacetime-resend-ts/example/src/module_bindings/rateLimit/run_sweep_procedure.ts
new file mode 100644
index 00000000000..9815c99eb38
--- /dev/null
+++ b/spacetime-resend-ts/example/src/module_bindings/rateLimit/run_sweep_procedure.ts
@@ -0,0 +1,16 @@
+// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
+// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
+
+/* eslint-disable */
+/* tslint:disable */
+import {
+ TypeBuilder as __TypeBuilder,
+ t as __t,
+ type AlgebraicTypeType as __AlgebraicTypeType,
+ type Infer as __Infer,
+} from "spacetimedb";
+
+export const params = {
+ maxRows: __t.option(__t.u32()),
+};
+export const returnType = __t.u32()
\ No newline at end of file
diff --git a/spacetime-resend-ts/example/src/module_bindings/rateLimit/types.ts b/spacetime-resend-ts/example/src/module_bindings/rateLimit/types.ts
new file mode 100644
index 00000000000..151a90e827f
--- /dev/null
+++ b/spacetime-resend-ts/example/src/module_bindings/rateLimit/types.ts
@@ -0,0 +1,56 @@
+// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
+// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
+
+/* eslint-disable */
+/* tslint:disable */
+import {
+ TypeBuilder as __TypeBuilder,
+ t as __t,
+ type AlgebraicTypeType as __AlgebraicTypeType,
+ type Infer as __Infer,
+} from "spacetimedb";
+
+export const AdminRateLimitBuckets = __t.object("AdminRateLimitBuckets", {});
+export type AdminRateLimitBuckets = __Infer;
+
+export const RateLimitAdminIdentity = __t.object("RateLimitAdminIdentity", {
+ identity: __t.identity(),
+ addedAtMicros: __t.i64(),
+});
+export type RateLimitAdminIdentity = __Infer;
+
+export const RateLimitBucket = __t.object("RateLimitBucket", {
+ key: __t.string(),
+ scope: __t.string(),
+ windowStart: __t.timestamp(),
+ expiresAt: __t.timestamp(),
+ count: __t.u32(),
+ updatedAt: __t.timestamp(),
+});
+export type RateLimitBucket = __Infer;
+
+export const RateLimitConfig = __t.object("RateLimitConfig", {
+ singleton: __t.bool(),
+ sweepBatch: __t.u32(),
+ updatedAt: __t.timestamp(),
+});
+export type RateLimitConfig = __Infer;
+
+export const RateLimitConsumeResult = __t.object("RateLimitConsumeResult", {
+ allowed: __t.bool(),
+ scope: __t.string(),
+ key: __t.string(),
+ limit: __t.u32(),
+ used: __t.u32(),
+ remaining: __t.u32(),
+ retryAfterSeconds: __t.u32(),
+ resetAt: __t.timestamp(),
+});
+export type RateLimitConsumeResult = __Infer;
+
+export const RateLimitSweepTick = __t.object("RateLimitSweepTick", {
+ scheduledId: __t.u64(),
+ scheduledAt: __t.scheduleAt(),
+});
+export type RateLimitSweepTick = __Infer;
+
diff --git a/spacetime-resend-ts/example/src/module_bindings/rateLimit/update_config_reducer.ts b/spacetime-resend-ts/example/src/module_bindings/rateLimit/update_config_reducer.ts
new file mode 100644
index 00000000000..54fcf361af1
--- /dev/null
+++ b/spacetime-resend-ts/example/src/module_bindings/rateLimit/update_config_reducer.ts
@@ -0,0 +1,15 @@
+// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
+// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
+
+/* eslint-disable */
+/* tslint:disable */
+import {
+ TypeBuilder as __TypeBuilder,
+ t as __t,
+ type AlgebraicTypeType as __AlgebraicTypeType,
+ type Infer as __Infer,
+} from "spacetimedb";
+
+export default {
+ sweepBatch: __t.u32(),
+};
diff --git a/spacetime-resend-ts/example/src/module_bindings/resend/add_admin_identity_procedure.ts b/spacetime-resend-ts/example/src/module_bindings/resend/add_admin_identity_procedure.ts
new file mode 100644
index 00000000000..bfd93108ec4
--- /dev/null
+++ b/spacetime-resend-ts/example/src/module_bindings/resend/add_admin_identity_procedure.ts
@@ -0,0 +1,16 @@
+// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
+// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
+
+/* eslint-disable */
+/* tslint:disable */
+import {
+ TypeBuilder as __TypeBuilder,
+ t as __t,
+ type AlgebraicTypeType as __AlgebraicTypeType,
+ type Infer as __Infer,
+} from "spacetimedb";
+
+export const params = {
+ identity: __t.identity(),
+};
+export const returnType = __t.unit()
\ No newline at end of file
diff --git a/spacetime-resend-ts/example/src/module_bindings/resend/cancel_email_procedure.ts b/spacetime-resend-ts/example/src/module_bindings/resend/cancel_email_procedure.ts
new file mode 100644
index 00000000000..ec778b46cd0
--- /dev/null
+++ b/spacetime-resend-ts/example/src/module_bindings/resend/cancel_email_procedure.ts
@@ -0,0 +1,16 @@
+// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
+// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
+
+/* eslint-disable */
+/* tslint:disable */
+import {
+ TypeBuilder as __TypeBuilder,
+ t as __t,
+ type AlgebraicTypeType as __AlgebraicTypeType,
+ type Infer as __Infer,
+} from "spacetimedb";
+
+export const params = {
+ resendId: __t.string(),
+};
+export const returnType = __t.unit()
\ No newline at end of file
diff --git a/spacetime-resend-ts/example/src/module_bindings/resend/get_email_procedure.ts b/spacetime-resend-ts/example/src/module_bindings/resend/get_email_procedure.ts
new file mode 100644
index 00000000000..0744ed77aab
--- /dev/null
+++ b/spacetime-resend-ts/example/src/module_bindings/resend/get_email_procedure.ts
@@ -0,0 +1,20 @@
+// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
+// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
+
+/* eslint-disable */
+/* tslint:disable */
+import {
+ TypeBuilder as __TypeBuilder,
+ t as __t,
+ type AlgebraicTypeType as __AlgebraicTypeType,
+ type Infer as __Infer,
+} from "spacetimedb";
+
+import {
+ ResendEmail,
+} from "./types";
+
+export const params = {
+ resendId: __t.string(),
+};
+export const returnType = __t.option(ResendEmail)
\ No newline at end of file
diff --git a/spacetime-resend-ts/example/src/module_bindings/resend/get_resend_config_status_procedure.ts b/spacetime-resend-ts/example/src/module_bindings/resend/get_resend_config_status_procedure.ts
new file mode 100644
index 00000000000..badd9ab9cc8
--- /dev/null
+++ b/spacetime-resend-ts/example/src/module_bindings/resend/get_resend_config_status_procedure.ts
@@ -0,0 +1,19 @@
+// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
+// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
+
+/* eslint-disable */
+/* tslint:disable */
+import {
+ TypeBuilder as __TypeBuilder,
+ t as __t,
+ type AlgebraicTypeType as __AlgebraicTypeType,
+ type Infer as __Infer,
+} from "spacetimedb";
+
+import {
+ ResendConfigStatus,
+} from "./types";
+
+export const params = {
+};
+export const returnType = ResendConfigStatus
\ No newline at end of file
diff --git a/spacetime-resend-ts/example/src/module_bindings/resend/ingest_resend_webhook_reducer.ts b/spacetime-resend-ts/example/src/module_bindings/resend/ingest_resend_webhook_reducer.ts
new file mode 100644
index 00000000000..1e7afcc3703
--- /dev/null
+++ b/spacetime-resend-ts/example/src/module_bindings/resend/ingest_resend_webhook_reducer.ts
@@ -0,0 +1,19 @@
+// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
+// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
+
+/* eslint-disable */
+/* tslint:disable */
+import {
+ TypeBuilder as __TypeBuilder,
+ t as __t,
+ type AlgebraicTypeType as __AlgebraicTypeType,
+ type Infer as __Infer,
+} from "spacetimedb";
+
+export default {
+ eventId: __t.string(),
+ eventType: __t.string(),
+ payloadJson: __t.string(),
+ signatureHeader: __t.option(__t.string()),
+ timestampHeader: __t.option(__t.string()),
+};
diff --git a/spacetime-resend-ts/example/src/module_bindings/resend/list_delivery_events_for_email_procedure.ts b/spacetime-resend-ts/example/src/module_bindings/resend/list_delivery_events_for_email_procedure.ts
new file mode 100644
index 00000000000..27a3a808053
--- /dev/null
+++ b/spacetime-resend-ts/example/src/module_bindings/resend/list_delivery_events_for_email_procedure.ts
@@ -0,0 +1,20 @@
+// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
+// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
+
+/* eslint-disable */
+/* tslint:disable */
+import {
+ TypeBuilder as __TypeBuilder,
+ t as __t,
+ type AlgebraicTypeType as __AlgebraicTypeType,
+ type Infer as __Infer,
+} from "spacetimedb";
+
+import {
+ ResendDeliveryEvent,
+} from "./types";
+
+export const params = {
+ resendId: __t.string(),
+};
+export const returnType = __t.array(ResendDeliveryEvent)
\ No newline at end of file
diff --git a/spacetime-resend-ts/example/src/module_bindings/resend/list_emails_by_org_id_procedure.ts b/spacetime-resend-ts/example/src/module_bindings/resend/list_emails_by_org_id_procedure.ts
new file mode 100644
index 00000000000..cd1063291da
--- /dev/null
+++ b/spacetime-resend-ts/example/src/module_bindings/resend/list_emails_by_org_id_procedure.ts
@@ -0,0 +1,20 @@
+// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
+// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
+
+/* eslint-disable */
+/* tslint:disable */
+import {
+ TypeBuilder as __TypeBuilder,
+ t as __t,
+ type AlgebraicTypeType as __AlgebraicTypeType,
+ type Infer as __Infer,
+} from "spacetimedb";
+
+import {
+ ResendEmail,
+} from "./types";
+
+export const params = {
+ orgId: __t.string(),
+};
+export const returnType = __t.array(ResendEmail)
\ No newline at end of file
diff --git a/spacetime-resend-ts/example/src/module_bindings/resend/list_emails_by_status_procedure.ts b/spacetime-resend-ts/example/src/module_bindings/resend/list_emails_by_status_procedure.ts
new file mode 100644
index 00000000000..c24fc71c444
--- /dev/null
+++ b/spacetime-resend-ts/example/src/module_bindings/resend/list_emails_by_status_procedure.ts
@@ -0,0 +1,23 @@
+// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
+// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
+
+/* eslint-disable */
+/* tslint:disable */
+import {
+ TypeBuilder as __TypeBuilder,
+ t as __t,
+ type AlgebraicTypeType as __AlgebraicTypeType,
+ type Infer as __Infer,
+} from "spacetimedb";
+
+import {
+ ResendEmail,
+ EmailStatus,
+} from "./types";
+
+export const params = {
+ get status() {
+ return EmailStatus;
+ },
+};
+export const returnType = __t.array(ResendEmail)
\ No newline at end of file
diff --git a/spacetime-resend-ts/example/src/module_bindings/resend/list_emails_by_user_id_procedure.ts b/spacetime-resend-ts/example/src/module_bindings/resend/list_emails_by_user_id_procedure.ts
new file mode 100644
index 00000000000..f712b807b32
--- /dev/null
+++ b/spacetime-resend-ts/example/src/module_bindings/resend/list_emails_by_user_id_procedure.ts
@@ -0,0 +1,20 @@
+// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
+// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
+
+/* eslint-disable */
+/* tslint:disable */
+import {
+ TypeBuilder as __TypeBuilder,
+ t as __t,
+ type AlgebraicTypeType as __AlgebraicTypeType,
+ type Infer as __Infer,
+} from "spacetimedb";
+
+import {
+ ResendEmail,
+} from "./types";
+
+export const params = {
+ userId: __t.string(),
+};
+export const returnType = __t.array(ResendEmail)
\ No newline at end of file
diff --git a/spacetime-resend-ts/example/src/module_bindings/resend/remove_admin_identity_procedure.ts b/spacetime-resend-ts/example/src/module_bindings/resend/remove_admin_identity_procedure.ts
new file mode 100644
index 00000000000..bfd93108ec4
--- /dev/null
+++ b/spacetime-resend-ts/example/src/module_bindings/resend/remove_admin_identity_procedure.ts
@@ -0,0 +1,16 @@
+// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
+// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
+
+/* eslint-disable */
+/* tslint:disable */
+import {
+ TypeBuilder as __TypeBuilder,
+ t as __t,
+ type AlgebraicTypeType as __AlgebraicTypeType,
+ type Infer as __Infer,
+} from "spacetimedb";
+
+export const params = {
+ identity: __t.identity(),
+};
+export const returnType = __t.unit()
\ No newline at end of file
diff --git a/spacetime-resend-ts/example/src/module_bindings/resend/replay_webhook_event_reducer.ts b/spacetime-resend-ts/example/src/module_bindings/resend/replay_webhook_event_reducer.ts
new file mode 100644
index 00000000000..590a453de82
--- /dev/null
+++ b/spacetime-resend-ts/example/src/module_bindings/resend/replay_webhook_event_reducer.ts
@@ -0,0 +1,15 @@
+// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
+// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
+
+/* eslint-disable */
+/* tslint:disable */
+import {
+ TypeBuilder as __TypeBuilder,
+ t as __t,
+ type AlgebraicTypeType as __AlgebraicTypeType,
+ type Infer as __Infer,
+} from "spacetimedb";
+
+export default {
+ eventId: __t.string(),
+};
diff --git a/spacetime-resend-ts/example/src/module_bindings/resend/resend_api_request_procedure.ts b/spacetime-resend-ts/example/src/module_bindings/resend/resend_api_request_procedure.ts
new file mode 100644
index 00000000000..aff60351ebf
--- /dev/null
+++ b/spacetime-resend-ts/example/src/module_bindings/resend/resend_api_request_procedure.ts
@@ -0,0 +1,23 @@
+// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
+// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
+
+/* eslint-disable */
+/* tslint:disable */
+import {
+ TypeBuilder as __TypeBuilder,
+ t as __t,
+ type AlgebraicTypeType as __AlgebraicTypeType,
+ type Infer as __Infer,
+} from "spacetimedb";
+
+import {
+ ResendApiRequestResult,
+} from "./types";
+
+export const params = {
+ method: __t.string(),
+ path: __t.string(),
+ jsonBody: __t.option(__t.string()),
+ idempotencyKey: __t.option(__t.string()),
+};
+export const returnType = ResendApiRequestResult
\ No newline at end of file
diff --git a/spacetime-resend-ts/example/src/module_bindings/resend/send_email_procedure.ts b/spacetime-resend-ts/example/src/module_bindings/resend/send_email_procedure.ts
new file mode 100644
index 00000000000..67babe6715f
--- /dev/null
+++ b/spacetime-resend-ts/example/src/module_bindings/resend/send_email_procedure.ts
@@ -0,0 +1,31 @@
+// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
+// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
+
+/* eslint-disable */
+/* tslint:disable */
+import {
+ TypeBuilder as __TypeBuilder,
+ t as __t,
+ type AlgebraicTypeType as __AlgebraicTypeType,
+ type Infer as __Infer,
+} from "spacetimedb";
+
+import {
+ SendEmailResult,
+} from "./types";
+
+export const params = {
+ from: __t.option(__t.string()),
+ to: __t.array(__t.string()),
+ subject: __t.string(),
+ html: __t.option(__t.string()),
+ text: __t.option(__t.string()),
+ cc: __t.option(__t.array(__t.string())),
+ bcc: __t.option(__t.array(__t.string())),
+ replyTo: __t.option(__t.array(__t.string())),
+ tagsJson: __t.option(__t.string()),
+ headersJson: __t.option(__t.string()),
+ scheduledAt: __t.option(__t.string()),
+ idempotencyKey: __t.option(__t.string()),
+};
+export const returnType = SendEmailResult
\ No newline at end of file
diff --git a/spacetime-resend-ts/example/src/module_bindings/resend/set_resend_config_procedure.ts b/spacetime-resend-ts/example/src/module_bindings/resend/set_resend_config_procedure.ts
new file mode 100644
index 00000000000..645c952bd4d
--- /dev/null
+++ b/spacetime-resend-ts/example/src/module_bindings/resend/set_resend_config_procedure.ts
@@ -0,0 +1,18 @@
+// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
+// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
+
+/* eslint-disable */
+/* tslint:disable */
+import {
+ TypeBuilder as __TypeBuilder,
+ t as __t,
+ type AlgebraicTypeType as __AlgebraicTypeType,
+ type Infer as __Infer,
+} from "spacetimedb";
+
+export const params = {
+ apiKey: __t.string(),
+ webhookSigningSecret: __t.option(__t.string()),
+ defaultFrom: __t.option(__t.string()),
+};
+export const returnType = __t.unit()
\ No newline at end of file
diff --git a/spacetime-resend-ts/example/src/module_bindings/resend/types.ts b/spacetime-resend-ts/example/src/module_bindings/resend/types.ts
new file mode 100644
index 00000000000..206a44b1f74
--- /dev/null
+++ b/spacetime-resend-ts/example/src/module_bindings/resend/types.ts
@@ -0,0 +1,123 @@
+// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
+// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
+
+/* eslint-disable */
+/* tslint:disable */
+import {
+ TypeBuilder as __TypeBuilder,
+ t as __t,
+ type AlgebraicTypeType as __AlgebraicTypeType,
+ type Infer as __Infer,
+} from "spacetimedb";
+
+// The tagged union or sum type for the algebraic type `EmailStatus`.
+export const EmailStatus = __t.enum("EmailStatus", {
+ Queued: __t.unit(),
+ Sent: __t.unit(),
+ Delivered: __t.unit(),
+ DeliveryDelayed: __t.unit(),
+ Bounced: __t.unit(),
+ Failed: __t.unit(),
+ Cancelled: __t.unit(),
+});
+export type EmailStatus = __Infer;
+
+export const ResendAdminIdentity = __t.object("ResendAdminIdentity", {
+ identity: __t.identity(),
+ addedAtMicros: __t.i64(),
+});
+export type ResendAdminIdentity = __Infer;
+
+export const ResendApiRequestResult = __t.object("ResendApiRequestResult", {
+ status: __t.u16(),
+ body: __t.string(),
+});
+export type ResendApiRequestResult = __Infer;
+
+export const ResendConfig = __t.object("ResendConfig", {
+ singleton: __t.bool(),
+ apiKey: __t.string(),
+ webhookSigningSecret: __t.option(__t.string()),
+ defaultFrom: __t.option(__t.string()),
+ updatedAt: __t.timestamp(),
+});
+export type ResendConfig = __Infer;
+
+export const ResendConfigStatus = __t.object("ResendConfigStatus", {
+ isConfigured: __t.bool(),
+ hasWebhookSecret: __t.bool(),
+ defaultFrom: __t.option(__t.string()),
+ apiKeyLength: __t.u16(),
+});
+export type ResendConfigStatus = __Infer;
+
+export const ResendDeliveryEvent = __t.object("ResendDeliveryEvent", {
+ eventId: __t.string(),
+ resendId: __t.string(),
+ eventType: __t.string(),
+ createdAtIso: __t.string(),
+ detailJson: __t.option(__t.string()),
+ insertedAt: __t.timestamp(),
+});
+export type ResendDeliveryEvent = __Infer;
+
+export const ResendEmail = __t.object("ResendEmail", {
+ resendId: __t.string(),
+ fromAddress: __t.string(),
+ toAddressesJson: __t.string(),
+ subject: __t.option(__t.string()),
+ get status() {
+ return EmailStatus;
+ },
+ lastError: __t.option(__t.string()),
+ bouncedAt: __t.option(__t.timestamp()),
+ bounceJson: __t.option(__t.string()),
+ failedAt: __t.option(__t.timestamp()),
+ failureReason: __t.option(__t.string()),
+ complained: __t.bool(),
+ complainedAt: __t.option(__t.timestamp()),
+ opened: __t.bool(),
+ openedAt: __t.option(__t.timestamp()),
+ clicked: __t.bool(),
+ clickedAt: __t.option(__t.timestamp()),
+ deliveredAt: __t.option(__t.timestamp()),
+ sentAt: __t.option(__t.timestamp()),
+ html: __t.option(__t.string()),
+ text: __t.option(__t.string()),
+ tagsJson: __t.option(__t.string()),
+ userId: __t.option(__t.string()),
+ orgId: __t.option(__t.string()),
+ createdAt: __t.timestamp(),
+ updatedAt: __t.timestamp(),
+});
+export type ResendEmail = __Infer;
+
+export const ResendWebhookEvent = __t.object("ResendWebhookEvent", {
+ eventId: __t.string(),
+ eventType: __t.string(),
+ payloadJson: __t.string(),
+ signatureHeader: __t.option(__t.string()),
+ timestampHeader: __t.option(__t.string()),
+ get status() {
+ return WebhookEventStatus;
+ },
+ errorMessage: __t.option(__t.string()),
+ receivedAt: __t.timestamp(),
+ processedAt: __t.option(__t.timestamp()),
+});
+export type ResendWebhookEvent = __Infer;
+
+export const SendEmailResult = __t.object("SendEmailResult", {
+ resendId: __t.string(),
+});
+export type SendEmailResult = __Infer;
+
+// The tagged union or sum type for the algebraic type `WebhookEventStatus`.
+export const WebhookEventStatus = __t.enum("WebhookEventStatus", {
+ Received: __t.unit(),
+ Processed: __t.unit(),
+ Ignored: __t.unit(),
+ Failed: __t.unit(),
+});
+export type WebhookEventStatus = __Infer;
+
diff --git a/spacetime-resend-ts/example/src/module_bindings/send_dispatch_procedure.ts b/spacetime-resend-ts/example/src/module_bindings/send_dispatch_procedure.ts
new file mode 100644
index 00000000000..5a200f044d9
--- /dev/null
+++ b/spacetime-resend-ts/example/src/module_bindings/send_dispatch_procedure.ts
@@ -0,0 +1,22 @@
+// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
+// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
+
+/* eslint-disable */
+/* tslint:disable */
+import {
+ TypeBuilder as __TypeBuilder,
+ t as __t,
+ type AlgebraicTypeType as __AlgebraicTypeType,
+ type Infer as __Infer,
+} from "spacetimedb";
+
+import {
+ DispatchSendResult,
+} from "./types";
+
+export const params = {
+ to: __t.string(),
+ subject: __t.string(),
+ message: __t.string(),
+};
+export const returnType = DispatchSendResult
\ No newline at end of file
diff --git a/spacetime-resend-ts/example/src/module_bindings/set_dispatch_policy_procedure.ts b/spacetime-resend-ts/example/src/module_bindings/set_dispatch_policy_procedure.ts
new file mode 100644
index 00000000000..d27751fdf5d
--- /dev/null
+++ b/spacetime-resend-ts/example/src/module_bindings/set_dispatch_policy_procedure.ts
@@ -0,0 +1,16 @@
+// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
+// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
+
+/* eslint-disable */
+/* tslint:disable */
+import {
+ TypeBuilder as __TypeBuilder,
+ t as __t,
+ type AlgebraicTypeType as __AlgebraicTypeType,
+ type Infer as __Infer,
+} from "spacetimedb";
+
+export const params = {
+ allowedRecipientsJson: __t.string(),
+};
+export const returnType = __t.bool()
\ No newline at end of file
diff --git a/spacetime-resend-ts/example/src/module_bindings/types.ts b/spacetime-resend-ts/example/src/module_bindings/types.ts
new file mode 100644
index 00000000000..5275af6375d
--- /dev/null
+++ b/spacetime-resend-ts/example/src/module_bindings/types.ts
@@ -0,0 +1,91 @@
+// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
+// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
+
+/* eslint-disable */
+/* tslint:disable */
+import {
+ TypeBuilder as __TypeBuilder,
+ t as __t,
+ type AlgebraicTypeType as __AlgebraicTypeType,
+ type Infer as __Infer,
+} from "spacetimedb";
+
+export const DispatchDeleteResult = __t.object("DispatchDeleteResult", {
+ ok: __t.bool(),
+ removed: __t.u32(),
+});
+export type DispatchDeleteResult = __Infer;
+
+export const DispatchPolicy = __t.object("DispatchPolicy", {
+ singleton: __t.bool(),
+ allowedRecipientsJson: __t.string(),
+ updatedAt: __t.timestamp(),
+});
+export type DispatchPolicy = __Infer;
+
+export const DispatchSendResult = __t.object("DispatchSendResult", {
+ ok: __t.bool(),
+ resendId: __t.option(__t.string()),
+ message: __t.string(),
+});
+export type DispatchSendResult = __Infer;
+
+// The tagged union or sum type for the algebraic type `EmailStatus`.
+export const EmailStatus = __t.enum("EmailStatus", {
+ Queued: __t.unit(),
+ Sent: __t.unit(),
+ Delivered: __t.unit(),
+ DeliveryDelayed: __t.unit(),
+ Bounced: __t.unit(),
+ Failed: __t.unit(),
+ Cancelled: __t.unit(),
+});
+export type EmailStatus = __Infer;
+
+export const MyDispatchDeliveryEvents = __t.object("MyDispatchDeliveryEvents", {});
+export type MyDispatchDeliveryEvents = __Infer;
+
+export const MyDispatchEmails = __t.object("MyDispatchEmails", {});
+export type MyDispatchEmails = __Infer;
+
+export const ResendDeliveryEvent = __t.object("ResendDeliveryEvent", {
+ eventId: __t.string(),
+ resendId: __t.string(),
+ eventType: __t.string(),
+ createdAtIso: __t.string(),
+ detailJson: __t.option(__t.string()),
+ insertedAt: __t.timestamp(),
+});
+export type ResendDeliveryEvent = __Infer;
+
+export const ResendEmail = __t.object("ResendEmail", {
+ resendId: __t.string(),
+ fromAddress: __t.string(),
+ toAddressesJson: __t.string(),
+ subject: __t.option(__t.string()),
+ get status() {
+ return EmailStatus;
+ },
+ lastError: __t.option(__t.string()),
+ bouncedAt: __t.option(__t.timestamp()),
+ bounceJson: __t.option(__t.string()),
+ failedAt: __t.option(__t.timestamp()),
+ failureReason: __t.option(__t.string()),
+ complained: __t.bool(),
+ complainedAt: __t.option(__t.timestamp()),
+ opened: __t.bool(),
+ openedAt: __t.option(__t.timestamp()),
+ clicked: __t.bool(),
+ clickedAt: __t.option(__t.timestamp()),
+ deliveredAt: __t.option(__t.timestamp()),
+ sentAt: __t.option(__t.timestamp()),
+ html: __t.option(__t.string()),
+ text: __t.option(__t.string()),
+ tagsJson: __t.option(__t.string()),
+ userId: __t.option(__t.string()),
+ orgId: __t.option(__t.string()),
+ createdAt: __t.timestamp(),
+ updatedAt: __t.timestamp(),
+});
+export type ResendEmail = __Infer;
+
diff --git a/spacetime-resend-ts/example/src/module_bindings/types/procedures.ts b/spacetime-resend-ts/example/src/module_bindings/types/procedures.ts
new file mode 100644
index 00000000000..13622fae9a4
--- /dev/null
+++ b/spacetime-resend-ts/example/src/module_bindings/types/procedures.ts
@@ -0,0 +1,22 @@
+// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
+// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
+
+/* eslint-disable */
+/* tslint:disable */
+import { type Infer as __Infer } from "spacetimedb";
+
+// Import all procedure arg schemas
+import * as ClearDispatchesProcedure from "../clear_dispatches_procedure";
+import * as DeleteDispatchProcedure from "../delete_dispatch_procedure";
+import * as SendDispatchProcedure from "../send_dispatch_procedure";
+import * as SetDispatchPolicyProcedure from "../set_dispatch_policy_procedure";
+
+export type ClearDispatchesArgs = __Infer;
+export type ClearDispatchesResult = __Infer;
+export type DeleteDispatchArgs = __Infer;
+export type DeleteDispatchResult = __Infer;
+export type SendDispatchArgs = __Infer;
+export type SendDispatchResult = __Infer;
+export type SetDispatchPolicyArgs = __Infer;
+export type SetDispatchPolicyResult = __Infer;
+
diff --git a/spacetime-resend-ts/example/src/module_bindings/types/reducers.ts b/spacetime-resend-ts/example/src/module_bindings/types/reducers.ts
new file mode 100644
index 00000000000..c701027cbf1
--- /dev/null
+++ b/spacetime-resend-ts/example/src/module_bindings/types/reducers.ts
@@ -0,0 +1,10 @@
+// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
+// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
+
+/* eslint-disable */
+/* tslint:disable */
+import { type Infer as __Infer } from "spacetimedb";
+
+// Import all reducer arg schemas
+
+
diff --git a/spacetime-resend-ts/example/tsconfig.json b/spacetime-resend-ts/example/tsconfig.json
new file mode 100644
index 00000000000..8e5e2bbddf0
--- /dev/null
+++ b/spacetime-resend-ts/example/tsconfig.json
@@ -0,0 +1,17 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "strict": true,
+ "noEmit": true,
+ "skipLibCheck": true,
+ "allowImportingTsExtensions": false,
+ "esModuleInterop": true,
+ "isolatedModules": true,
+ "useDefineForClassFields": true,
+ "lib": ["ES2022", "DOM", "DOM.Iterable"]
+ },
+ "include": ["src/**/*.ts", "scripts/**/*.ts", "server.ts"],
+ "exclude": ["node_modules", "public", "dist"]
+}
diff --git a/spacetime-resend-ts/package.json b/spacetime-resend-ts/package.json
new file mode 100644
index 00000000000..8115e033977
--- /dev/null
+++ b/spacetime-resend-ts/package.json
@@ -0,0 +1,71 @@
+{
+ "name": "@spacetimedb/resend",
+ "description": "Resend email delivery, webhook verification, and event storage for SpacetimeDB TypeScript modules.",
+ "version": "0.1.0",
+ "license": "Apache-2.0",
+ "type": "module",
+ "main": "./src/index.ts",
+ "types": "./src/index.ts",
+ "exports": {
+ ".": {
+ "types": "./src/index.ts",
+ "default": "./src/index.ts"
+ },
+ "./submodule": {
+ "types": "./src/submodule.ts",
+ "default": "./src/submodule.ts"
+ }
+ },
+ "files": [
+ "src",
+ "LICENSE.txt",
+ "README.md"
+ ],
+ "publishConfig": {
+ "access": "public",
+ "registry": "https://registry.npmjs.org/"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/clockworklabs/SpacetimeDB.git",
+ "directory": "spacetime-resend-ts"
+ },
+ "homepage": "https://github.com/clockworklabs/SpacetimeDB/tree/master/spacetime-resend-ts#readme",
+ "bugs": {
+ "url": "https://github.com/clockworklabs/SpacetimeDB/issues"
+ },
+ "keywords": [
+ "spacetimedb",
+ "resend",
+ "email",
+ "webhooks"
+ ],
+ "scripts": {
+ "build": "spacetime build",
+ "format": "prettier . --write --ignore-path ../.prettierignore",
+ "lint": "eslint . && prettier . --check --ignore-path ../.prettierignore",
+ "typecheck": "tsc --noEmit",
+ "test": "tsx scripts/test-unit.ts",
+ "spacetime:generate": "spacetime generate --lang typescript --out-dir ts-codegen",
+ "publish:module": "spacetime publish",
+ "publish:local": "spacetime publish --server local --yes spacetime-resend",
+ "publish:local:reset": "spacetime publish --server local --yes --delete-data=always spacetime-resend",
+ "test:smoke": "tsx scripts/test-resend-smoke.ts",
+ "test:gate:local": "tsx scripts/test-resend-smoke.ts"
+ },
+ "dependencies": {
+ "@spacetimedb/crypto": "workspace:^",
+ "valibot": "^1.4.2"
+ },
+ "peerDependencies": {
+ "spacetimedb": "workspace:^"
+ },
+ "devDependencies": {
+ "eslint": "^9.17.0",
+ "prettier": "^3.3.3",
+ "@types/node": "^22.10.2",
+ "spacetimedb": "workspace:*",
+ "tsx": "^4.21.0",
+ "typescript": "^5.9.3"
+ }
+}
diff --git a/spacetime-resend-ts/scripts/test-resend-smoke.ts b/spacetime-resend-ts/scripts/test-resend-smoke.ts
new file mode 100644
index 00000000000..8bd9ddeca06
--- /dev/null
+++ b/spacetime-resend-ts/scripts/test-resend-smoke.ts
@@ -0,0 +1,466 @@
+// Smoke test: build, publish, ingest synthetic events, verify state. No API key or real webhooks needed. Usage: pnpm run test:resend:smoke [-- --skip-build-publish]
+
+import { spawn } from 'node:child_process';
+import { createHmac } from 'node:crypto';
+
+type Options = {
+ server: string;
+ database: string;
+ skipBuildPublish: boolean;
+};
+
+function parseArgs(argv: string[]): Options {
+ const opts: Options = {
+ server: 'http://127.0.0.1:3000',
+ // Dedicated DB so smoke test never overwrites the dev module's real config.
+ database: 'resend-ts-smoke-test',
+ skipBuildPublish: false,
+ };
+ for (let i = 0; i < argv.length; i++) {
+ const raw = argv[i]!;
+ const flag = raw.replace(/^-+/, '').toLowerCase();
+ if (flag === 'skip-build-publish') opts.skipBuildPublish = true;
+ if (flag === 'server') opts.server = argv[++i]!;
+ if (flag === 'database') opts.database = argv[++i]!;
+ }
+ return opts;
+}
+
+function step(name: string) {
+ process.stdout.write(`\n==> ${name}\n`);
+}
+
+function run(
+ cmd: string,
+ args: string[]
+): Promise<{ code: number; stdout: string; stderr: string }> {
+ return new Promise(resolve => {
+ const child = spawn(cmd, args, { shell: false });
+ let stdout = '';
+ let stderr = '';
+ child.stdout?.on('data', d => (stdout += String(d)));
+ child.stderr?.on('data', d => (stderr += String(d)));
+ child.on('close', code => resolve({ code: code ?? 1, stdout, stderr }));
+ child.on('error', err =>
+ resolve({ code: 1, stdout, stderr: stderr + String(err) })
+ );
+ });
+}
+
+async function callReducer(
+ opts: Options,
+ name: string,
+ args: string[]
+): Promise {
+ const result = await run('spacetime', [
+ 'call',
+ '--server',
+ opts.server,
+ opts.database,
+ name,
+ ...args,
+ ]);
+ if (result.code !== 0) {
+ throw new Error(
+ `spacetime call ${name} failed: code=${result.code}\nstderr: ${result.stderr}\nstdout: ${result.stdout}`
+ );
+ }
+ return result.stdout;
+}
+
+// Expects the call to fail. Returns combined stderr/stdout for assertion.
+async function expectCallFails(
+ opts: Options,
+ name: string,
+ args: string[],
+ anonymous = false
+): Promise {
+ const result = await run('spacetime', [
+ 'call',
+ ...(anonymous ? ['--anonymous'] : []),
+ '--server',
+ opts.server,
+ opts.database,
+ name,
+ ...args,
+ ]);
+ if (result.code === 0) {
+ throw new Error(
+ `expected ${name} to fail but it succeeded:\nstdout: ${result.stdout}`
+ );
+ }
+ return result.stderr + result.stdout;
+}
+
+function quote(s: string): string {
+ return JSON.stringify(s);
+}
+
+function some(s: string): string {
+ return JSON.stringify({ some: s });
+}
+
+const RESEND_WEBHOOK_SECRET_RAW = 'resend_smoke_test_secret';
+const RESEND_WEBHOOK_SECRET = `whsec_${Buffer.from(RESEND_WEBHOOK_SECRET_RAW).toString('base64')}`;
+
+function svixSignature(args: {
+ eventId: string;
+ timestamp: string;
+ payloadJson: string;
+}): string {
+ const digest = createHmac('sha256', RESEND_WEBHOOK_SECRET_RAW)
+ .update(`${args.eventId}.${args.timestamp}.${args.payloadJson}`)
+ .digest('base64');
+ return `v1,${digest}`;
+}
+
+async function ingestWebhook(
+ opts: Options,
+ eventId: string,
+ eventType: string,
+ payloadJson: string
+) {
+ const timestamp = String(Math.floor(Date.now() / 1000));
+ await callReducer(opts, 'ingest_resend_webhook', [
+ quote(eventId),
+ quote(eventType),
+ quote(payloadJson),
+ some(svixSignature({ eventId, timestamp, payloadJson })),
+ some(timestamp),
+ ]);
+}
+
+function eventPayload(args: {
+ type: string;
+ emailId: string;
+ from?: string;
+ to?: string[];
+ subject?: string;
+ extra?: Record;
+}): string {
+ const data: Record = {
+ email_id: args.emailId,
+ created_at: '2026-05-04T00:00:00Z',
+ from: args.from ?? 'onboarding@resend.dev',
+ to: args.to ?? ['delivered@resend.dev'],
+ subject: args.subject ?? 'smoke test',
+ ...(args.extra ?? {}),
+ };
+ return JSON.stringify({
+ type: args.type,
+ created_at: '2026-05-04T00:00:00Z',
+ data,
+ });
+}
+
+const EMAIL_STATUS = [
+ 'Queued',
+ 'Sent',
+ 'Delivered',
+ 'DeliveryDelayed',
+ 'Bounced',
+ 'Failed',
+ 'Cancelled',
+] as const;
+
+function emailStatus(rowText: string): string {
+ const parsed = JSON.parse(rowText);
+ const row = parsed?.[1];
+ const variant = row?.[4];
+ const tag = variant?.[0];
+ if (typeof tag !== 'number' || tag < 0 || tag >= EMAIL_STATUS.length) {
+ throw new Error(`could not parse email status from row: ${rowText}`);
+ }
+ return EMAIL_STATUS[tag]!;
+}
+
+async function main() {
+ const opts = parseArgs(process.argv.slice(2));
+ // Unique run id so re-runs don't collide on idempotent webhook IDs.
+ const runId = Date.now().toString(36);
+ const evt = (suffix: string) => `evt_${runId}_${suffix}`;
+ const em = (suffix: string) => `em_${runId}_${suffix}`;
+
+ if (!opts.skipBuildPublish) {
+ step('spacetime build');
+ const build = await run('spacetime', ['build']);
+ if (build.code !== 0) {
+ process.stderr.write(build.stderr);
+ throw new Error('build failed');
+ }
+
+ step(`spacetime publish --server ${opts.server} ${opts.database}`);
+ const publish = await run('spacetime', [
+ 'publish',
+ '--server',
+ opts.server,
+ '--yes',
+ '--delete-data',
+ opts.database,
+ ]);
+ if (publish.code !== 0) {
+ process.stderr.write(publish.stderr);
+ throw new Error('publish failed');
+ }
+ }
+
+ // Negative path: send_email refuses cleanly when config is not set.
+ step('negative: send_email before config, expect failure');
+ const preBootstrap = await expectCallFails(opts, 'send_email', [
+ 'null',
+ JSON.stringify(['delivered@resend.dev']),
+ quote('hello'),
+ some('hello
'),
+ 'null',
+ 'null',
+ 'null',
+ 'null',
+ 'null',
+ 'null',
+ 'null',
+ 'null',
+ ]);
+ if (!preBootstrap.toLowerCase().includes('config')) {
+ throw new Error(
+ `expected error to mention config; got: ${preBootstrap.slice(0, 400)}`
+ );
+ }
+
+ step('set_resend_config');
+ await callReducer(opts, 'set_resend_config', [
+ quote('re_smoke_placeholder'),
+ some(RESEND_WEBHOOK_SECRET),
+ some('onboarding@resend.dev'),
+ ]);
+
+ step('negative: anonymous callers cannot query email state');
+ const unauthorized = await expectCallFails(
+ opts,
+ 'get_email',
+ [quote(em('a'))],
+ true
+ );
+ if (!unauthorized.toLowerCase().includes('not_authorized')) {
+ throw new Error(
+ `expected get_email to reject a non-admin caller: ${unauthorized.slice(0, 400)}`
+ );
+ }
+
+ step('negative: signed webhook type must match supplied event type');
+ const mismatchEventId = evt('metadata_mismatch');
+ const mismatchPayload = eventPayload({
+ type: 'email.delivered',
+ emailId: em('metadata_mismatch'),
+ });
+ const mismatchTimestamp = String(Math.floor(Date.now() / 1000));
+ const mismatch = await expectCallFails(opts, 'ingest_resend_webhook', [
+ quote(mismatchEventId),
+ quote('email.bounced'),
+ quote(mismatchPayload),
+ some(
+ svixSignature({
+ eventId: mismatchEventId,
+ timestamp: mismatchTimestamp,
+ payloadJson: mismatchPayload,
+ })
+ ),
+ some(mismatchTimestamp),
+ ]);
+ if (!mismatch.toLowerCase().includes('metadata')) {
+ throw new Error(
+ `expected signed metadata mismatch failure: ${mismatch.slice(0, 400)}`
+ );
+ }
+
+ // Email A happy path: queued -> sent -> delivered, then flag overlays must not roll status back.
+ step(`ingest email.sent for ${em('a')}`);
+ await ingestWebhook(
+ opts,
+ evt('a'),
+ 'email.sent',
+ eventPayload({ type: 'email.sent', emailId: em('a') })
+ );
+
+ step(`ingest email.delivered for ${em('a')}`);
+ await ingestWebhook(
+ opts,
+ evt('b'),
+ 'email.delivered',
+ eventPayload({ type: 'email.delivered', emailId: em('a') })
+ );
+
+ step(`verify status=delivered for ${em('a')}`);
+ let row = await callReducer(opts, 'get_email', [quote(em('a'))]);
+ if (emailStatus(row) !== 'Delivered') {
+ throw new Error(`expected delivered for ${em('a')}, got: ${row}`);
+ }
+
+ step(`ingest email.complained for ${em('a')} (flag, must NOT change status)`);
+ await ingestWebhook(
+ opts,
+ evt('e'),
+ 'email.complained',
+ eventPayload({ type: 'email.complained', emailId: em('a') })
+ );
+ row = await callReducer(opts, 'get_email', [quote(em('a'))]);
+ if (emailStatus(row) !== 'Delivered') {
+ throw new Error(`complained should not flip status: ${row}`);
+ }
+
+ step(`ingest email.opened for ${em('a')} (flag)`);
+ await ingestWebhook(
+ opts,
+ evt('g'),
+ 'email.opened',
+ eventPayload({ type: 'email.opened', emailId: em('a') })
+ );
+ row = await callReducer(opts, 'get_email', [quote(em('a'))]);
+ if (emailStatus(row) !== 'Delivered') {
+ throw new Error(`opened should not flip status: ${row}`);
+ }
+
+ step(`ingest email.clicked for ${em('a')} (flag + detail captured)`);
+ await ingestWebhook(
+ opts,
+ evt('h'),
+ 'email.clicked',
+ eventPayload({
+ type: 'email.clicked',
+ emailId: em('a'),
+ extra: {
+ click: {
+ ipAddress: '203.0.113.42',
+ link: 'https://spacetimedb.com',
+ timestamp: '2026-05-04T00:00:00Z',
+ userAgent: 'Mozilla/5.0 (smoke-test)',
+ },
+ },
+ })
+ );
+ const clickEvents = await callReducer(
+ opts,
+ 'list_delivery_events_for_email',
+ [quote(em('a'))]
+ );
+ const clickRows: unknown = JSON.parse(clickEvents);
+ const clickRow = Array.isArray(clickRows)
+ ? clickRows.find(row => Array.isArray(row) && row[2] === 'email.clicked')
+ : undefined;
+ if (!clickRow) {
+ throw new Error(`expected click event in delivery log: ${clickEvents}`);
+ }
+ const detailOption = Array.isArray(clickRow) ? clickRow[4] : undefined;
+ const detailJson =
+ Array.isArray(detailOption) &&
+ detailOption[0] === 0 &&
+ typeof detailOption[1] === 'string'
+ ? detailOption[1]
+ : undefined;
+ const clickDetail = detailJson ? JSON.parse(detailJson) : undefined;
+ if (clickDetail?.link !== 'https://spacetimedb.com') {
+ throw new Error(`expected click detail (link) preserved: ${clickEvents}`);
+ }
+
+ // Email C bounce path with structured detail.
+ step(`ingest email.bounced for ${em('c')}`);
+ await ingestWebhook(
+ opts,
+ evt('c'),
+ 'email.bounced',
+ eventPayload({
+ type: 'email.bounced',
+ emailId: em('c'),
+ to: ['bounced@resend.dev'],
+ extra: {
+ bounce: {
+ message: 'Mailbox does not exist',
+ subType: 'NoEmail',
+ type: 'Permanent',
+ },
+ },
+ })
+ );
+ const bouncedRow = await callReducer(opts, 'get_email', [quote(em('c'))]);
+ if (emailStatus(bouncedRow) !== 'Bounced') {
+ throw new Error(`expected bounced for ${em('c')}: ${bouncedRow}`);
+ }
+ if (!bouncedRow.includes('Mailbox does not exist')) {
+ throw new Error(`expected bounce reason in row: ${bouncedRow}`);
+ }
+
+ // Email D delivery_delayed status path.
+ step(`ingest email.delivery_delayed for ${em('d')}`);
+ await ingestWebhook(
+ opts,
+ evt('d'),
+ 'email.delivery_delayed',
+ eventPayload({ type: 'email.delivery_delayed', emailId: em('d') })
+ );
+ const delayed = await callReducer(opts, 'get_email', [quote(em('d'))]);
+ if (emailStatus(delayed) !== 'DeliveryDelayed') {
+ throw new Error(`expected delivery_delayed for ${em('d')}: ${delayed}`);
+ }
+
+ // Email F failed status with reason.
+ step(`ingest email.failed for ${em('f')}`);
+ await ingestWebhook(
+ opts,
+ evt('f'),
+ 'email.failed',
+ eventPayload({
+ type: 'email.failed',
+ emailId: em('f'),
+ extra: { failed: { reason: 'rate limited by destination MTA' } },
+ })
+ );
+ const failedRow = await callReducer(opts, 'get_email', [quote(em('f'))]);
+ if (emailStatus(failedRow) !== 'Failed') {
+ throw new Error(`expected failed for ${em('f')}: ${failedRow}`);
+ }
+ if (!failedRow.includes('rate limited')) {
+ throw new Error(`expected failure reason in row: ${failedRow}`);
+ }
+
+ // Idempotency + replay.
+ step(
+ `verify idempotency: re-ingest ${evt('a')} (already processed, should no-op)`
+ );
+ await ingestWebhook(
+ opts,
+ evt('a'),
+ 'email.sent',
+ eventPayload({ type: 'email.sent', emailId: em('a') })
+ );
+ row = await callReducer(opts, 'get_email', [quote(em('a'))]);
+ if (emailStatus(row) !== 'Delivered') {
+ throw new Error(`replay broke status: ${row}`);
+ }
+
+ step(`replay_webhook_event re-applies ${evt('b')} (status stays delivered)`);
+ await callReducer(opts, 'replay_webhook_event', [quote(evt('b'))]);
+ row = await callReducer(opts, 'get_email', [quote(em('a'))]);
+ if (emailStatus(row) !== 'Delivered') {
+ throw new Error(`replay reducer altered state: ${row}`);
+ }
+
+ step('negative: replay unknown event_id, expect failure');
+ const replayMissing = await expectCallFails(opts, 'replay_webhook_event', [
+ quote('evt_does_not_exist_xyz'),
+ ]);
+ if (!replayMissing.toLowerCase().includes('not_found')) {
+ throw new Error(
+ `expected not_found error; got: ${replayMissing.slice(0, 400)}`
+ );
+ }
+
+ step(
+ 'done: smoke test passed (8 event types + idempotency + replay + 2 negative)'
+ );
+}
+
+main().catch(err => {
+ process.stderr.write(
+ `\nSMOKE TEST FAILED: ${err instanceof Error ? err.message : String(err)}\n`
+ );
+ process.exit(1);
+});
diff --git a/spacetime-resend-ts/scripts/test-unit.ts b/spacetime-resend-ts/scripts/test-unit.ts
new file mode 100644
index 00000000000..c4cffb97510
--- /dev/null
+++ b/spacetime-resend-ts/scripts/test-unit.ts
@@ -0,0 +1,98 @@
+import * as assert from 'node:assert/strict';
+import { buildResendHttpRequest } from '../src/submodule/request.ts';
+import { validateEmailInput } from '../src/submodule/email-input.ts';
+import { parseResendEventType } from '../src/submodule/webhook-metadata.ts';
+
+assert.equal(
+ parseResendEventType('{"type":"email.delivered","data":{}}'),
+ 'email.delivered'
+);
+assert.equal(parseResendEventType('{"type":"","data":{}}'), undefined);
+assert.equal(parseResendEventType('{"data":{}}'), undefined);
+assert.equal(parseResendEventType('{bad json'), undefined);
+assert.equal(parseResendEventType('[]'), undefined);
+
+const request = buildResendHttpRequest({
+ method: 'post',
+ path: '/emails',
+ apiKey: 're_test_secret',
+ jsonBody: '{"to":["user@example.com"]}',
+ idempotencyKey: 'email-user-1',
+});
+assert.equal(request.url, 'https://api.resend.com/emails');
+assert.equal(request.method, 'POST');
+assert.equal(request.headers.Authorization, 'Bearer re_test_secret');
+assert.throws(
+ () =>
+ buildResendHttpRequest({
+ method: 'GET',
+ path: 'https://attacker.example/collect',
+ apiKey: 're_test_secret',
+ jsonBody: undefined,
+ idempotencyKey: undefined,
+ }),
+ /resend\.request_path_invalid/
+);
+assert.throws(
+ () =>
+ buildResendHttpRequest({
+ method: 'GET',
+ path: '/emails\u007fblocked',
+ apiKey: 're_test_secret',
+ jsonBody: undefined,
+ idempotencyKey: undefined,
+ }),
+ /resend\.request_path_invalid/
+);
+assert.throws(
+ () =>
+ buildResendHttpRequest({
+ method: 'TRACE',
+ path: '/emails',
+ apiKey: 're_test_secret',
+ jsonBody: undefined,
+ idempotencyKey: undefined,
+ }),
+ /resend\.request_method_invalid/
+);
+
+assert.doesNotThrow(() =>
+ validateEmailInput({
+ to: ['user@example.com'],
+ subject: 'Welcome',
+ text: 'Hello',
+ })
+);
+assert.throws(
+ () => validateEmailInput({ to: [], subject: 'Welcome', text: 'Hello' }),
+ /resend\.send_email_no_recipients/
+);
+assert.throws(
+ () =>
+ validateEmailInput({
+ to: ['user@example.com\u007fblocked'],
+ subject: 'Welcome',
+ text: 'Hello',
+ }),
+ /resend\.to_invalid_address/
+);
+assert.throws(
+ () =>
+ validateEmailInput({
+ to: ['user@example.com'],
+ subject: 'Welcome\r\nx-injected: yes',
+ text: 'Hello',
+ }),
+ /resend\.send_email_invalid_subject/
+);
+assert.throws(
+ () =>
+ validateEmailInput({
+ to: Array.from({ length: 101 }, (_, index) => `user${index}@example.com`),
+ subject: 'Welcome',
+ text: 'Hello',
+ }),
+ /resend\.to_too_many/
+);
+
+console.log('resend unit tests passed');
diff --git a/spacetime-resend-ts/src/index.ts b/spacetime-resend-ts/src/index.ts
new file mode 100644
index 00000000000..4df9f3f54a5
--- /dev/null
+++ b/spacetime-resend-ts/src/index.ts
@@ -0,0 +1,15 @@
+export { default, init } from './submodule/schema';
+export { ingestResendWebhook, replayWebhookEvent } from './submodule/webhooks';
+
+export { setResendConfig, getResendConfigStatus } from './submodule/config';
+export { addAdminIdentity, removeAdminIdentity } from './submodule/auth';
+export {
+ cancelEmail,
+ getEmail,
+ listDeliveryEventsForEmail,
+ listEmailsByOrgId,
+ listEmailsByStatus,
+ listEmailsByUserId,
+ resendApiRequest,
+ sendEmail,
+} from './submodule/operations';
diff --git a/spacetime-resend-ts/src/submodule.ts b/spacetime-resend-ts/src/submodule.ts
new file mode 100644
index 00000000000..c23525805a9
--- /dev/null
+++ b/spacetime-resend-ts/src/submodule.ts
@@ -0,0 +1,12 @@
+export { default } from './submodule/schema';
+export {
+ resendDeliveryEventTable,
+ resendEmailTable,
+ t,
+} from './submodule/schema';
+export { installResend } from './submodule/install';
+export * from './submodule/webhooks';
+export * from './submodule/operations';
+
+export { setResendConfig, getResendConfigStatus } from './submodule/config';
+export { addAdminIdentity, removeAdminIdentity } from './submodule/auth';
diff --git a/spacetime-resend-ts/src/submodule/auth.ts b/spacetime-resend-ts/src/submodule/auth.ts
new file mode 100644
index 00000000000..8e1ae16e708
--- /dev/null
+++ b/spacetime-resend-ts/src/submodule/auth.ts
@@ -0,0 +1,80 @@
+import {
+ spacetimedb,
+ t,
+ type ProcedureModuleCtx,
+ type WriteCtx,
+} from './schema';
+import { throwSenderError } from './validation';
+
+// Admin gate. Fresh publishes seed the owner via init. Public submodule calls
+// never bootstrap admin state from "first caller wins". Procedure callers must
+// pass the outer ctx.sender explicitly; transaction ctx may not carry sender.
+type Sender = WriteCtx['sender'];
+type ModuleTimestamp = WriteCtx['timestamp'];
+
+export type AdminVerdict = 'admin' | 'denied';
+
+export function isAdmin(ctx: WriteCtx, sender: Sender): boolean {
+ return ctx.db.resendAdminIdentity.identity.find(sender) != null;
+}
+
+export function adminVerdict(ctx: WriteCtx, sender: Sender): AdminVerdict {
+ return isAdmin(ctx, sender) ? 'admin' : 'denied';
+}
+
+export function denyIfNotAdmin(verdict: AdminVerdict): void {
+ if (verdict === 'denied') throwSenderError('resend.not_authorized');
+}
+
+export function requireAdmin(ctx: WriteCtx, sender: Sender): void {
+ if (!isAdmin(ctx, sender)) throwSenderError('resend.not_authorized');
+}
+
+// For owner-gated repair/setup code only. Do not call from a public bootstrap path.
+export function seedAdmin(
+ ctx: WriteCtx,
+ sender: Sender,
+ timestamp: ModuleTimestamp
+) {
+ if (ctx.db.resendAdminIdentity.identity.find(sender) != null) return;
+ ctx.db.resendAdminIdentity.insert({
+ identity: sender,
+ addedAtMicros: timestamp.microsSinceUnixEpoch,
+ });
+}
+
+export const addAdminIdentity = spacetimedb.procedure(
+ { identity: t.identity() },
+ t.unit(),
+ (ctx: ProcedureModuleCtx, { identity }) => {
+ const verdict = ctx.withTx(tx => adminVerdict(tx, ctx.sender));
+ denyIfNotAdmin(verdict);
+ ctx.withTx(tx => {
+ if (tx.db.resendAdminIdentity.identity.find(identity) == null) {
+ tx.db.resendAdminIdentity.insert({
+ identity,
+ addedAtMicros: ctx.timestamp.microsSinceUnixEpoch,
+ });
+ }
+ });
+ return {};
+ }
+);
+
+export const removeAdminIdentity = spacetimedb.procedure(
+ { identity: t.identity() },
+ t.unit(),
+ (ctx: ProcedureModuleCtx, { identity }) => {
+ const verdict = ctx.withTx(tx => adminVerdict(tx, ctx.sender));
+ denyIfNotAdmin(verdict);
+ ctx.withTx(tx => {
+ const existing = tx.db.resendAdminIdentity.identity.find(identity);
+ if (!existing) return;
+ if (tx.db.resendAdminIdentity.count() <= 1n) {
+ throwSenderError('resend.cannot_remove_last_admin');
+ }
+ tx.db.resendAdminIdentity.delete(existing);
+ });
+ return {};
+ }
+);
diff --git a/spacetime-resend-ts/src/submodule/config.ts b/spacetime-resend-ts/src/submodule/config.ts
new file mode 100644
index 00000000000..a868ef6e80f
--- /dev/null
+++ b/spacetime-resend-ts/src/submodule/config.ts
@@ -0,0 +1,107 @@
+import {
+ spacetimedb,
+ t,
+ type ProcedureModuleCtx,
+ type WriteCtx,
+} from './schema';
+import { adminVerdict, denyIfNotAdmin } from './auth';
+import { throwSenderError } from './validation';
+
+export type ResendConfig = {
+ apiKey: string;
+ webhookSigningSecret: string | undefined;
+ defaultFrom: string | undefined;
+};
+
+export function loadConfigOrThrow(ctx: WriteCtx): ResendConfig {
+ const row = ctx.db.resendConfig.singleton.find(true);
+ if (!row) {
+ throwSenderError(
+ 'resend.config_not_set: call set_resend_config(...) first'
+ );
+ }
+ return {
+ apiKey: row.apiKey,
+ webhookSigningSecret: row.webhookSigningSecret,
+ defaultFrom: row.defaultFrom,
+ };
+}
+
+export function loadConfigOrThrowFromProcedure(
+ ctx: ProcedureModuleCtx
+): ResendConfig {
+ return ctx.withTx(tx => loadConfigOrThrow(tx));
+}
+
+function upsertConfig(
+ ctx: WriteCtx,
+ args: {
+ apiKey: string;
+ webhookSigningSecret?: string | undefined;
+ defaultFrom?: string | undefined;
+ }
+) {
+ const existing = ctx.db.resendConfig.singleton.find(true);
+ const row = {
+ singleton: true,
+ apiKey: args.apiKey,
+ webhookSigningSecret:
+ args.webhookSigningSecret ?? existing?.webhookSigningSecret,
+ defaultFrom: args.defaultFrom ?? existing?.defaultFrom,
+ updatedAt: ctx.timestamp,
+ };
+ if (!existing) {
+ ctx.db.resendConfig.insert(row);
+ return;
+ }
+ ctx.db.resendConfig.singleton.update(row);
+}
+
+// Requires an admin seeded by the database owner; no public first-call bootstrap.
+export const setResendConfig = spacetimedb.procedure(
+ {
+ apiKey: t.string(),
+ webhookSigningSecret: t.option(t.string()),
+ defaultFrom: t.option(t.string()),
+ },
+ t.unit(),
+ (ctx, args) => {
+ const verdict = ctx.withTx(tx => adminVerdict(tx, ctx.sender));
+ denyIfNotAdmin(verdict);
+ ctx.withTx(tx => {
+ upsertConfig(tx, args);
+ });
+ return {};
+ }
+);
+
+export const getResendConfigStatus = spacetimedb.procedure(
+ {},
+ t.object('ResendConfigStatus', {
+ isConfigured: t.bool(),
+ hasWebhookSecret: t.bool(),
+ defaultFrom: t.option(t.string()),
+ apiKeyLength: t.u16(),
+ }),
+ ctx => {
+ const verdict = ctx.withTx(tx => adminVerdict(tx, ctx.sender));
+ denyIfNotAdmin(verdict);
+ return ctx.withTx(tx => {
+ const row = tx.db.resendConfig.singleton.find(true);
+ if (!row) {
+ return {
+ isConfigured: false,
+ hasWebhookSecret: false,
+ defaultFrom: undefined,
+ apiKeyLength: 0,
+ };
+ }
+ return {
+ isConfigured: true,
+ hasWebhookSecret: row.webhookSigningSecret !== undefined,
+ defaultFrom: row.defaultFrom,
+ apiKeyLength: row.apiKey.length,
+ };
+ });
+ }
+);
diff --git a/spacetime-resend-ts/src/submodule/email-input.ts b/spacetime-resend-ts/src/submodule/email-input.ts
new file mode 100644
index 00000000000..c61608ff2a6
--- /dev/null
+++ b/spacetime-resend-ts/src/submodule/email-input.ts
@@ -0,0 +1,90 @@
+import { hasControlCharacter } from './text-validation';
+
+export type EmailInput = {
+ from?: string | undefined;
+ to: string[];
+ subject: string;
+ html?: string | undefined;
+ text?: string | undefined;
+ cc?: string[] | undefined;
+ bcc?: string[] | undefined;
+ replyTo?: string[] | undefined;
+ tagsJson?: string | undefined;
+ headersJson?: string | undefined;
+ scheduledAt?: string | undefined;
+};
+
+function fail(code: string): never {
+ throw new Error(`resend.${code}`);
+}
+
+function validateAddressList(
+ values: string[] | undefined,
+ field: string
+): void {
+ if (values === undefined) return;
+ if (values.length > 100) fail(`${field}_too_many`);
+ for (const value of values) {
+ if (
+ value.length === 0 ||
+ value.length > 320 ||
+ hasControlCharacter(value)
+ ) {
+ fail(`${field}_invalid_address`);
+ }
+ }
+}
+
+function validateJson(
+ value: string | undefined,
+ field: string,
+ maxLength: number
+): void {
+ if (value === undefined) return;
+ if (value.length > maxLength) fail(`${field}_too_large`);
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(value);
+ } catch {
+ fail(`${field}_invalid_json`);
+ }
+ if (parsed === null || typeof parsed !== 'object')
+ fail(`${field}_invalid_json`);
+}
+
+export function validateEmailInput(args: EmailInput): void {
+ if (args.to.length === 0) fail('send_email_no_recipients');
+ validateAddressList(args.to, 'to');
+ validateAddressList(args.cc, 'cc');
+ validateAddressList(args.bcc, 'bcc');
+ validateAddressList(args.replyTo, 'reply_to');
+ const recipientCount =
+ args.to.length + (args.cc?.length ?? 0) + (args.bcc?.length ?? 0);
+ if (recipientCount > 100) fail('send_email_too_many_recipients');
+ if (
+ args.from !== undefined &&
+ (args.from.length === 0 ||
+ args.from.length > 320 ||
+ hasControlCharacter(args.from))
+ )
+ fail('send_email_invalid_from');
+ if (
+ args.subject.length === 0 ||
+ args.subject.length > 998 ||
+ hasControlCharacter(args.subject)
+ ) {
+ fail('send_email_invalid_subject');
+ }
+ if (args.html === undefined && args.text === undefined)
+ fail('send_email_missing_content');
+ if ((args.html?.length ?? 0) > 200_000) fail('send_email_html_too_large');
+ if ((args.text?.length ?? 0) > 200_000) fail('send_email_text_too_large');
+ validateJson(args.tagsJson, 'tags', 16_384);
+ validateJson(args.headersJson, 'headers', 16_384);
+ if (
+ (args.scheduledAt?.length ?? 0) > 128 ||
+ hasControlCharacter(args.scheduledAt ?? '')
+ ) {
+ fail('send_email_invalid_schedule');
+ }
+}
diff --git a/spacetime-resend-ts/src/submodule/email_writes.ts b/spacetime-resend-ts/src/submodule/email_writes.ts
new file mode 100644
index 00000000000..d10a465eb8f
--- /dev/null
+++ b/spacetime-resend-ts/src/submodule/email_writes.ts
@@ -0,0 +1,72 @@
+import {
+ EmailStatus,
+ type EmailStatusValue,
+ type ModuleTimestamp,
+ type WriteCtx,
+} from './schema';
+
+// Any field passed as `undefined` preserves the existing row's value. Used by webhooks (sparse per-event fields).
+export function upsertEmail(
+ ctx: WriteCtx,
+ now: ModuleTimestamp,
+ args: {
+ resendId: string;
+ fromAddress: string;
+ toAddressesJson: string;
+ subject: string | undefined;
+ html: string | undefined;
+ text: string | undefined;
+ status: EmailStatusValue | undefined;
+ lastError: string | undefined;
+ bouncedAt: ModuleTimestamp | undefined;
+ bounceJson: string | undefined;
+ failedAt: ModuleTimestamp | undefined;
+ failureReason: string | undefined;
+ complained: boolean;
+ complainedAt: ModuleTimestamp | undefined;
+ opened: boolean;
+ openedAt: ModuleTimestamp | undefined;
+ clicked: boolean;
+ clickedAt: ModuleTimestamp | undefined;
+ deliveredAt: ModuleTimestamp | undefined;
+ sentAt: ModuleTimestamp | undefined;
+ tagsJson: string | undefined;
+ userId: string | undefined;
+ orgId: string | undefined;
+ }
+) {
+ const existing = ctx.db.resendEmail.resendId.find(args.resendId);
+ const row = {
+ resendId: args.resendId,
+ fromAddress: args.fromAddress,
+ toAddressesJson: args.toAddressesJson,
+ subject: args.subject ?? existing?.subject,
+ html: args.html ?? existing?.html,
+ text: args.text ?? existing?.text,
+ status: args.status ?? existing?.status ?? EmailStatus.Queued,
+ lastError: args.lastError ?? existing?.lastError,
+ bouncedAt: args.bouncedAt ?? existing?.bouncedAt,
+ bounceJson: args.bounceJson ?? existing?.bounceJson,
+ failedAt: args.failedAt ?? existing?.failedAt,
+ failureReason: args.failureReason ?? existing?.failureReason,
+ complained: args.complained || (existing?.complained ?? false),
+ complainedAt: args.complainedAt ?? existing?.complainedAt,
+ opened: args.opened || (existing?.opened ?? false),
+ openedAt: args.openedAt ?? existing?.openedAt,
+ clicked: args.clicked || (existing?.clicked ?? false),
+ clickedAt: args.clickedAt ?? existing?.clickedAt,
+ deliveredAt: args.deliveredAt ?? existing?.deliveredAt,
+ sentAt: args.sentAt ?? existing?.sentAt,
+ tagsJson: args.tagsJson ?? existing?.tagsJson,
+ userId: args.userId ?? existing?.userId,
+ orgId: args.orgId ?? existing?.orgId,
+ createdAt: existing?.createdAt ?? now,
+ updatedAt: now,
+ };
+
+ if (!existing) {
+ ctx.db.resendEmail.insert(row);
+ return;
+ }
+ ctx.db.resendEmail.resendId.update(row);
+}
diff --git a/spacetime-resend-ts/src/submodule/http.ts b/spacetime-resend-ts/src/submodule/http.ts
new file mode 100644
index 00000000000..aa10b491124
--- /dev/null
+++ b/spacetime-resend-ts/src/submodule/http.ts
@@ -0,0 +1,67 @@
+import * as v from 'valibot';
+import { type ProcedureModuleCtx, vResendErrorBody } from './schema';
+import { safeJsonParse, throwSenderError } from './validation';
+import { buildResendHttpRequest } from './request';
+
+export type ResendHttpResponse = {
+ status: number;
+ body: string;
+};
+
+export function callResend(
+ ctx: ProcedureModuleCtx,
+ args: {
+ method: string;
+ path: string;
+ apiKey: string;
+ jsonBody: string | undefined;
+ idempotencyKey: string | undefined;
+ }
+): ResendHttpResponse {
+ let request;
+ try {
+ request = buildResendHttpRequest(args);
+ } catch (error) {
+ throwSenderError(
+ error instanceof Error ? error.message : 'resend.request_invalid'
+ );
+ }
+ const response = ctx.http.fetch(request.url, {
+ method: request.method,
+ headers: request.headers,
+ body: request.body,
+ });
+ return {
+ status: response.status,
+ body: response.text(),
+ };
+}
+export function ensureOkOrThrow(
+ response: ResendHttpResponse,
+ errorPrefix: string
+): void {
+ if (response.status >= 200 && response.status < 300) return;
+ throwSenderError(
+ `${errorPrefix}:${response.status}${resendErrorSuffix(response.body)}`
+ );
+}
+
+export function resendErrorSuffix(body: string): string {
+ const parsed = safeJsonParse(body);
+ if (parsed !== undefined) {
+ const result = v.safeParse(vResendErrorBody, parsed);
+ if (result.success) {
+ const parts: string[] = [];
+ if (result.output.name) parts.push(`name=${result.output.name}`);
+ if (result.output.message) {
+ parts.push(
+ `msg=${result.output.message.replace(/\s+/g, ' ').slice(0, 240)}`
+ );
+ }
+ if (parts.length > 0) return `:${parts.join('|')}`;
+ }
+ }
+ const compact = body.replace(/\s+/g, ' ').trim();
+ if (!compact) return '';
+ return `:body=${compact.slice(0, 240)}`;
+}
diff --git a/spacetime-resend-ts/src/submodule/install.ts b/spacetime-resend-ts/src/submodule/install.ts
new file mode 100644
index 00000000000..67858e196b6
--- /dev/null
+++ b/spacetime-resend-ts/src/submodule/install.ts
@@ -0,0 +1,9 @@
+import type { ReducerModuleCtx } from './schema';
+
+export function installResend(ctx: ReducerModuleCtx) {
+ if (ctx.db.resendAdminIdentity.identity.find(ctx.sender) != null) return;
+ ctx.db.resendAdminIdentity.insert({
+ identity: ctx.sender,
+ addedAtMicros: ctx.timestamp.microsSinceUnixEpoch,
+ });
+}
diff --git a/spacetime-resend-ts/src/submodule/operations.ts b/spacetime-resend-ts/src/submodule/operations.ts
new file mode 100644
index 00000000000..dc1f80ff77f
--- /dev/null
+++ b/spacetime-resend-ts/src/submodule/operations.ts
@@ -0,0 +1,366 @@
+import * as v from 'valibot';
+import {
+ EmailStatus,
+ emailStatus,
+ resendDeliveryEventTable,
+ resendEmailTable,
+ sendEmailResult,
+ spacetimedb,
+ t,
+ vSendEmailResponse,
+ type ProcedureModuleCtx,
+ type WriteCtx,
+} from './schema';
+import { callResend, ensureOkOrThrow } from './http';
+import { safeJsonParse, summarizeIssues, throwSenderError } from './validation';
+import { upsertEmail } from './email_writes';
+import { loadConfigOrThrowFromProcedure } from './config';
+import { adminVerdict, denyIfNotAdmin } from './auth';
+import { validateEmailInput } from './email-input';
+
+function requireProcedureAdmin(ctx: ProcedureModuleCtx): void {
+ const verdict = ctx.withTx(tx => adminVerdict(tx, ctx.sender));
+ denyIfNotAdmin(verdict);
+}
+
+const MAX_QUERY_ROWS = 1000;
+
+function takeRows(rows: Iterable): T[] {
+ const out: T[] = [];
+ for (const row of rows) {
+ if (out.length >= MAX_QUERY_ROWS) break;
+ out.push(row);
+ }
+ return out;
+}
+
+const vTagsForExtraction = v.union([
+ v.record(v.string(), v.string()),
+ v.array(v.object({ name: v.string(), value: v.string() })),
+]);
+
+function extractTagFieldsFromJson(tagsJson: string | undefined): {
+ userId: string | undefined;
+ orgId: string | undefined;
+} {
+ if (!tagsJson) return { userId: undefined, orgId: undefined };
+ const parsed = safeJsonParse(tagsJson);
+ if (parsed === undefined) return { userId: undefined, orgId: undefined };
+ const result = v.safeParse(vTagsForExtraction, parsed);
+ if (!result.success) {
+ return { userId: undefined, orgId: undefined };
+ }
+ const tags = result.output;
+ if (Array.isArray(tags)) {
+ let userId: string | undefined;
+ let orgId: string | undefined;
+ for (const tag of tags) {
+ if (tag.name === 'userId') userId = tag.value;
+ if (tag.name === 'orgId') orgId = tag.value;
+ }
+ return { userId, orgId };
+ }
+ return { userId: tags['userId'], orgId: tags['orgId'] };
+}
+
+// Resend expects snake_case fields in the POST /emails request body.
+type ResendSendEmailBody = {
+ from: string;
+ to: string[];
+ subject: string;
+ html?: string;
+ text?: string;
+ cc?: string[];
+ bcc?: string[];
+ reply_to?: string[];
+ scheduled_at?: string;
+ tags?: unknown;
+ headers?: unknown;
+};
+
+function buildSendEmailBody(args: {
+ from: string;
+ to: string[];
+ subject: string;
+ html: string | undefined;
+ text: string | undefined;
+ cc: string[] | undefined;
+ bcc: string[] | undefined;
+ replyTo: string[] | undefined;
+ tagsJson: string | undefined;
+ headersJson: string | undefined;
+ scheduledAt: string | undefined;
+}): string {
+ const body: ResendSendEmailBody = {
+ from: args.from,
+ to: args.to,
+ subject: args.subject,
+ };
+ if (args.html !== undefined) body.html = args.html;
+ if (args.text !== undefined) body.text = args.text;
+ if (args.cc !== undefined && args.cc.length > 0) body.cc = args.cc;
+ if (args.bcc !== undefined && args.bcc.length > 0) body.bcc = args.bcc;
+ if (args.replyTo !== undefined && args.replyTo.length > 0) {
+ body.reply_to = args.replyTo;
+ }
+ if (args.scheduledAt !== undefined) body.scheduled_at = args.scheduledAt;
+ if (args.tagsJson !== undefined) {
+ const parsed = safeJsonParse(args.tagsJson);
+ if (parsed !== undefined) body.tags = parsed;
+ }
+ if (args.headersJson !== undefined) {
+ const parsed = safeJsonParse(args.headersJson);
+ if (parsed !== undefined) body.headers = parsed;
+ }
+ return JSON.stringify(body);
+}
+
+function recordQueuedEmail(
+ ctx: WriteCtx,
+ now: ProcedureModuleCtx['timestamp'],
+ args: {
+ resendId: string;
+ from: string;
+ to: string[];
+ subject: string;
+ html: string | undefined;
+ text: string | undefined;
+ tagsJson: string | undefined;
+ }
+) {
+ const tagFields = extractTagFieldsFromJson(args.tagsJson);
+ upsertEmail(ctx, now, {
+ resendId: args.resendId,
+ fromAddress: args.from,
+ toAddressesJson: JSON.stringify(args.to),
+ subject: args.subject,
+ html: args.html,
+ text: args.text,
+ status: EmailStatus.Queued,
+ lastError: undefined,
+ bouncedAt: undefined,
+ bounceJson: undefined,
+ failedAt: undefined,
+ failureReason: undefined,
+ complained: false,
+ complainedAt: undefined,
+ opened: false,
+ openedAt: undefined,
+ clicked: false,
+ clickedAt: undefined,
+ deliveredAt: undefined,
+ sentAt: undefined,
+ tagsJson: args.tagsJson,
+ userId: tagFields.userId,
+ orgId: tagFields.orgId,
+ });
+}
+
+export type SendEmailArgs = {
+ from?: string | undefined;
+ to: string[];
+ subject: string;
+ html?: string | undefined;
+ text?: string | undefined;
+ cc?: string[] | undefined;
+ bcc?: string[] | undefined;
+ replyTo?: string[] | undefined;
+ tagsJson?: string | undefined;
+ headersJson?: string | undefined;
+ scheduledAt?: string | undefined;
+ idempotencyKey?: string | undefined;
+};
+
+export function sendEmailRequest(ctx: ProcedureModuleCtx, args: SendEmailArgs) {
+ try {
+ validateEmailInput(args);
+ } catch (error) {
+ throwSenderError(
+ error instanceof Error ? error.message : 'resend.send_email_invalid_input'
+ );
+ }
+ const cfg = loadConfigOrThrowFromProcedure(ctx);
+ const fromAddress = args.from ?? cfg.defaultFrom;
+ if (!fromAddress) throwSenderError('resend.send_email_missing_from');
+
+ const jsonBody = buildSendEmailBody({
+ from: fromAddress,
+ to: args.to,
+ subject: args.subject,
+ html: args.html,
+ text: args.text,
+ cc: args.cc,
+ bcc: args.bcc,
+ replyTo: args.replyTo,
+ tagsJson: args.tagsJson,
+ headersJson: args.headersJson,
+ scheduledAt: args.scheduledAt,
+ });
+
+ const response = callResend(ctx, {
+ method: 'POST',
+ path: '/emails',
+ apiKey: cfg.apiKey,
+ jsonBody,
+ idempotencyKey: args.idempotencyKey,
+ });
+ ensureOkOrThrow(response, 'resend.send_email_failed');
+
+ const parsed = safeJsonParse(response.body);
+ if (parsed === undefined)
+ throwSenderError('resend.send_email_invalid_response');
+ const result = v.safeParse(vSendEmailResponse, parsed);
+ if (!result.success) {
+ throwSenderError(
+ `resend.send_email_invalid_response:${summarizeIssues(result.issues)}`
+ );
+ }
+
+ const resendId = result.output.id;
+ ctx.withTx(tx => {
+ recordQueuedEmail(tx, ctx.timestamp, {
+ resendId,
+ from: fromAddress,
+ to: args.to,
+ subject: args.subject,
+ html: args.html,
+ text: args.text,
+ tagsJson: args.tagsJson,
+ });
+ });
+ return { resendId };
+}
+
+const sendEmailArgs = {
+ from: t.option(t.string()),
+ to: t.array(t.string()),
+ subject: t.string(),
+ html: t.option(t.string()),
+ text: t.option(t.string()),
+ cc: t.option(t.array(t.string())),
+ bcc: t.option(t.array(t.string())),
+ replyTo: t.option(t.array(t.string())),
+ tagsJson: t.option(t.string()),
+ headersJson: t.option(t.string()),
+ scheduledAt: t.option(t.string()),
+ idempotencyKey: t.option(t.string()),
+};
+
+export const sendEmail = spacetimedb.procedure(
+ sendEmailArgs,
+ sendEmailResult,
+ (ctx, args) => {
+ const verdict = ctx.withTx(tx => adminVerdict(tx, ctx.sender));
+ denyIfNotAdmin(verdict);
+ return sendEmailRequest(ctx, args);
+ }
+);
+
+export const cancelEmail = spacetimedb.procedure(
+ { resendId: t.string() },
+ t.unit(),
+ (ctx, args) => {
+ const verdict = ctx.withTx(tx => adminVerdict(tx, ctx.sender));
+ denyIfNotAdmin(verdict);
+ const cfg = loadConfigOrThrowFromProcedure(ctx);
+ const response = callResend(ctx, {
+ method: 'POST',
+ path: `/emails/${args.resendId}/cancel`,
+ apiKey: cfg.apiKey,
+ jsonBody: undefined,
+ idempotencyKey: undefined,
+ });
+ ensureOkOrThrow(response, 'resend.cancel_email_failed');
+
+ ctx.withTx(tx => {
+ const existing = tx.db.resendEmail.resendId.find(args.resendId);
+ if (!existing) return;
+ const updated = {
+ ...existing,
+ status: EmailStatus.Cancelled,
+ updatedAt: ctx.timestamp,
+ };
+ tx.db.resendEmail.resendId.update(updated);
+ });
+ return {};
+ }
+);
+
+export const getEmail = spacetimedb.procedure(
+ { resendId: t.string() },
+ t.option(resendEmailTable.rowType),
+ (ctx, { resendId }) => {
+ requireProcedureAdmin(ctx);
+ return ctx.withTx(
+ tx => tx.db.resendEmail.resendId.find(resendId) ?? undefined
+ );
+ }
+);
+
+export const listEmailsByUserId = spacetimedb.procedure(
+ { userId: t.string() },
+ t.array(resendEmailTable.rowType),
+ (ctx, { userId }) => {
+ requireProcedureAdmin(ctx);
+ return ctx.withTx(tx =>
+ takeRows(tx.db.resendEmail.byUserId.filter(userId))
+ );
+ }
+);
+
+export const listEmailsByOrgId = spacetimedb.procedure(
+ { orgId: t.string() },
+ t.array(resendEmailTable.rowType),
+ (ctx, { orgId }) => {
+ requireProcedureAdmin(ctx);
+ return ctx.withTx(tx => takeRows(tx.db.resendEmail.byOrgId.filter(orgId)));
+ }
+);
+
+export const listEmailsByStatus = spacetimedb.procedure(
+ { status: emailStatus },
+ t.array(resendEmailTable.rowType),
+ (ctx, { status }) => {
+ requireProcedureAdmin(ctx);
+ return ctx.withTx(tx =>
+ takeRows(tx.db.resendEmail.byStatus.filter(status))
+ );
+ }
+);
+
+export const listDeliveryEventsForEmail = spacetimedb.procedure(
+ { resendId: t.string() },
+ t.array(resendDeliveryEventTable.rowType),
+ (ctx, { resendId }) => {
+ requireProcedureAdmin(ctx);
+ return ctx.withTx(tx =>
+ takeRows(tx.db.resendDeliveryEvent.byResendId.filter(resendId))
+ );
+ }
+);
+
+export const resendApiRequest = spacetimedb.procedure(
+ {
+ method: t.string(),
+ path: t.string(),
+ jsonBody: t.option(t.string()),
+ idempotencyKey: t.option(t.string()),
+ },
+ t.object('ResendApiRequestResult', {
+ status: t.u16(),
+ body: t.string(),
+ }),
+ (ctx, args) => {
+ // Administrators may make authenticated Resend calls with the stored key.
+ const verdict = ctx.withTx(tx => adminVerdict(tx, ctx.sender));
+ denyIfNotAdmin(verdict);
+ const cfg = loadConfigOrThrowFromProcedure(ctx);
+ return callResend(ctx, {
+ method: args.method,
+ path: args.path,
+ apiKey: cfg.apiKey,
+ jsonBody: args.jsonBody,
+ idempotencyKey: args.idempotencyKey,
+ });
+ }
+);
diff --git a/spacetime-resend-ts/src/submodule/request.ts b/spacetime-resend-ts/src/submodule/request.ts
new file mode 100644
index 00000000000..b7c067c77b6
--- /dev/null
+++ b/spacetime-resend-ts/src/submodule/request.ts
@@ -0,0 +1,67 @@
+import { hasControlCharacter } from './text-validation';
+
+const RESEND_API_BASE = 'https://api.resend.com';
+const ALLOWED_METHODS = new Set(['GET', 'POST', 'PATCH', 'DELETE']);
+const MAX_PATH_LENGTH = 2048;
+const MAX_JSON_BODY_LENGTH = 256 * 1024;
+const MAX_IDEMPOTENCY_KEY_LENGTH = 256;
+
+export type ResendHttpRequest = {
+ method: string;
+ url: string;
+ headers: Record;
+ body: string | undefined;
+};
+
+function validatePath(path: string): string {
+ const normalized = path.trim();
+ if (!normalized.startsWith('/') || normalized.startsWith('//')) {
+ throw new Error('resend.request_path_invalid');
+ }
+ if (normalized.includes('\\') || normalized.includes('#')) {
+ throw new Error('resend.request_path_invalid');
+ }
+ if (normalized.length > MAX_PATH_LENGTH || hasControlCharacter(normalized)) {
+ throw new Error('resend.request_path_invalid');
+ }
+ return normalized;
+}
+
+export function buildResendHttpRequest(args: {
+ method: string;
+ path: string;
+ apiKey: string;
+ jsonBody: string | undefined;
+ idempotencyKey: string | undefined;
+}): ResendHttpRequest {
+ const method = args.method.trim().toUpperCase();
+ if (!ALLOWED_METHODS.has(method)) {
+ throw new Error('resend.request_method_invalid');
+ }
+
+ const path = validatePath(args.path);
+ const body = args.jsonBody?.length ? args.jsonBody : undefined;
+ if (body !== undefined && body.length > MAX_JSON_BODY_LENGTH) {
+ throw new Error('resend.request_body_too_large');
+ }
+ if (
+ args.idempotencyKey &&
+ args.idempotencyKey.length > MAX_IDEMPOTENCY_KEY_LENGTH
+ ) {
+ throw new Error('resend.idempotency_key_too_long');
+ }
+
+ const headers: Record = {
+ Authorization: `Bearer ${args.apiKey}`,
+ Accept: 'application/json',
+ };
+ if (body !== undefined) headers['Content-Type'] = 'application/json';
+ if (args.idempotencyKey) headers['Idempotency-Key'] = args.idempotencyKey;
+
+ return {
+ method,
+ url: `${RESEND_API_BASE}${path}`,
+ headers,
+ body,
+ };
+}
diff --git a/spacetime-resend-ts/src/submodule/schema.ts b/spacetime-resend-ts/src/submodule/schema.ts
new file mode 100644
index 00000000000..47faa95f1a7
--- /dev/null
+++ b/spacetime-resend-ts/src/submodule/schema.ts
@@ -0,0 +1,293 @@
+import {
+ schema,
+ table,
+ t,
+ Range,
+ SenderError,
+ type ProcedureCtx,
+ type ReducerCtx,
+ type TransactionCtx,
+} from 'spacetimedb/server';
+import * as v from 'valibot';
+import { installResend } from './install';
+
+// Webhook delivery state. Received = ingest accepted. Processed = applied.
+// Ignored = duplicate/unknown event type. Failed = signature/format error.
+export const webhookEventStatus = t.enum('WebhookEventStatus', [
+ 'Received',
+ 'Processed',
+ 'Ignored',
+ 'Failed',
+]);
+export const WebhookEventStatus = {
+ Received: { tag: 'Received' as const },
+ Processed: { tag: 'Processed' as const },
+ Ignored: { tag: 'Ignored' as const },
+ Failed: { tag: 'Failed' as const },
+};
+
+// Lifecycle of a tracked outbound email. Tags match Resend's
+// `email.` webhook events for direct ingestion mapping.
+export const emailStatus = t.enum('EmailStatus', [
+ 'Queued',
+ 'Sent',
+ 'Delivered',
+ 'DeliveryDelayed',
+ 'Bounced',
+ 'Failed',
+ 'Cancelled',
+]);
+export const EmailStatus = {
+ Queued: { tag: 'Queued' as const },
+ Sent: { tag: 'Sent' as const },
+ Delivered: { tag: 'Delivered' as const },
+ DeliveryDelayed: { tag: 'DeliveryDelayed' as const },
+ Bounced: { tag: 'Bounced' as const },
+ Failed: { tag: 'Failed' as const },
+ Cancelled: { tag: 'Cancelled' as const },
+};
+export type EmailStatusValue = (typeof EmailStatus)[keyof typeof EmailStatus];
+export type WebhookEventStatusValue =
+ (typeof WebhookEventStatus)[keyof typeof WebhookEventStatus];
+
+export const resendEmailRow = {
+ resendId: t.string().primaryKey(),
+ fromAddress: t.string(),
+ toAddressesJson: t.string(),
+ subject: t.option(t.string()),
+ status: emailStatus,
+ lastError: t.option(t.string()),
+ bouncedAt: t.option(t.timestamp()),
+ bounceJson: t.option(t.string()),
+ failedAt: t.option(t.timestamp()),
+ failureReason: t.option(t.string()),
+ complained: t.bool(),
+ complainedAt: t.option(t.timestamp()),
+ opened: t.bool(),
+ openedAt: t.option(t.timestamp()),
+ clicked: t.bool(),
+ clickedAt: t.option(t.timestamp()),
+ deliveredAt: t.option(t.timestamp()),
+ sentAt: t.option(t.timestamp()),
+ html: t.option(t.string()),
+ text: t.option(t.string()),
+ tagsJson: t.option(t.string()),
+ userId: t.option(t.string()),
+ orgId: t.option(t.string()),
+ createdAt: t.timestamp(),
+ updatedAt: t.timestamp(),
+};
+
+export const resendDeliveryEventRow = {
+ eventId: t.string().primaryKey(),
+ resendId: t.string(),
+ eventType: t.string(),
+ createdAtIso: t.string(),
+ detailJson: t.option(t.string()),
+ insertedAt: t.timestamp(),
+};
+
+export const resendWebhookEventRow = {
+ eventId: t.string().primaryKey(),
+ eventType: t.string(),
+ payloadJson: t.string(),
+ signatureHeader: t.option(t.string()),
+ timestampHeader: t.option(t.string()),
+ status: webhookEventStatus,
+ errorMessage: t.option(t.string()),
+ receivedAt: t.timestamp(),
+ processedAt: t.option(t.timestamp()),
+};
+
+// Private singleton; secrets never leak via subscription.
+export const resendConfigRow = {
+ singleton: t.bool().primaryKey(),
+ apiKey: t.string(),
+ webhookSigningSecret: t.option(t.string()),
+ defaultFrom: t.option(t.string()),
+ updatedAt: t.timestamp(),
+};
+
+// Fresh publishes seed the owner via init; public procedures never bootstrap admin state.
+export const resendAdminIdentityRow = {
+ identity: t.identity().primaryKey(),
+ addedAtMicros: t.i64(),
+};
+
+export const resendEmailTable = table(
+ {
+ name: 'resend_email',
+ public: false,
+ indexes: [
+ { accessor: 'byUserId', algorithm: 'btree', columns: ['userId'] },
+ { accessor: 'byOrgId', algorithm: 'btree', columns: ['orgId'] },
+ { accessor: 'byStatus', algorithm: 'btree', columns: ['status'] },
+ { accessor: 'byUpdatedAt', algorithm: 'btree', columns: ['updatedAt'] },
+ ],
+ },
+ resendEmailRow
+);
+
+export const resendDeliveryEventTable = table(
+ {
+ name: 'resend_delivery_event',
+ public: false,
+ indexes: [
+ { accessor: 'byResendId', algorithm: 'btree', columns: ['resendId'] },
+ {
+ accessor: 'byResendIdEventType',
+ algorithm: 'btree',
+ columns: ['resendId', 'eventType'],
+ },
+ ],
+ },
+ resendDeliveryEventRow
+);
+
+// Private because the raw payload and signature headers are operational data.
+// Host modules can expose an admin-only view when needed.
+export const resendWebhookEventTable = table(
+ {
+ name: 'resend_webhook_event',
+ public: false,
+ indexes: [
+ { accessor: 'byStatus', algorithm: 'btree', columns: ['status'] },
+ ],
+ },
+ resendWebhookEventRow
+);
+
+export const resendConfigTable = table(
+ { name: 'resend_config', public: false, indexes: [] },
+ resendConfigRow
+);
+
+export const resendAdminIdentityTable = table(
+ { name: 'resend_admin_identity', public: false, indexes: [] },
+ resendAdminIdentityRow
+);
+
+export const spacetimedb = schema({
+ resendEmail: resendEmailTable,
+ resendDeliveryEvent: resendDeliveryEventTable,
+ resendWebhookEvent: resendWebhookEventTable,
+ resendConfig: resendConfigTable,
+ resendAdminIdentity: resendAdminIdentityTable,
+});
+
+export const init = spacetimedb.init(ctx => {
+ installResend(ctx);
+});
+
+export default spacetimedb;
+
+export type ReducerModuleCtx = ReducerCtx;
+export type ProcedureModuleCtx = ProcedureCtx;
+export type TransactionModuleCtx = TransactionCtx<
+ typeof spacetimedb.schemaType
+>;
+export type WriteCtx = ReducerModuleCtx | TransactionModuleCtx;
+export type ModuleTimestamp = ReducerModuleCtx['timestamp'];
+
+export const sendEmailResult = t.object('SendEmailResult', {
+ resendId: t.string(),
+});
+
+export const resendHttpResponse = t.object('ResendHttpResponse', {
+ status: t.u16(),
+ body: t.string(),
+});
+
+export { Range, SenderError, t };
+
+// Mirror Resend SDK's BaseEmailEventData.
+const vBaseEmailEventData = {
+ broadcast_id: v.optional(v.string()),
+ created_at: v.string(),
+ email_id: v.string(),
+ from: v.string(),
+ to: v.array(v.string()),
+ subject: v.string(),
+ template_id: v.optional(v.string()),
+ tags: v.optional(v.record(v.string(), v.string())),
+};
+
+const vBaseEvent = {
+ created_at: v.string(),
+};
+
+export const vEmailEvent = v.variant('type', [
+ v.object({
+ ...vBaseEvent,
+ type: v.literal('email.sent'),
+ data: v.object(vBaseEmailEventData),
+ }),
+ v.object({
+ ...vBaseEvent,
+ type: v.literal('email.delivered'),
+ data: v.object(vBaseEmailEventData),
+ }),
+ v.object({
+ ...vBaseEvent,
+ type: v.literal('email.delivery_delayed'),
+ data: v.object(vBaseEmailEventData),
+ }),
+ v.object({
+ ...vBaseEvent,
+ type: v.literal('email.complained'),
+ data: v.object(vBaseEmailEventData),
+ }),
+ v.object({
+ ...vBaseEvent,
+ type: v.literal('email.bounced'),
+ data: v.object({
+ ...vBaseEmailEventData,
+ bounce: v.object({
+ message: v.string(),
+ subType: v.string(),
+ type: v.string(),
+ }),
+ }),
+ }),
+ v.object({
+ ...vBaseEvent,
+ type: v.literal('email.opened'),
+ data: v.object(vBaseEmailEventData),
+ }),
+ v.object({
+ ...vBaseEvent,
+ type: v.literal('email.clicked'),
+ data: v.object({
+ ...vBaseEmailEventData,
+ click: v.object({
+ ipAddress: v.string(),
+ link: v.string(),
+ timestamp: v.string(),
+ userAgent: v.string(),
+ }),
+ }),
+ }),
+ v.object({
+ ...vBaseEvent,
+ type: v.literal('email.failed'),
+ data: v.object({
+ ...vBaseEmailEventData,
+ failed: v.object({
+ reason: v.string(),
+ }),
+ }),
+ }),
+]);
+
+export type EmailEvent = v.InferOutput;
+export type EmailEventType = EmailEvent['type'];
+
+export const vResendErrorBody = v.object({
+ name: v.optional(v.string()),
+ message: v.optional(v.string()),
+ statusCode: v.optional(v.union([v.number(), v.null()])),
+});
+
+export const vSendEmailResponse = v.object({
+ id: v.string(),
+});
diff --git a/spacetime-resend-ts/src/submodule/text-validation.ts b/spacetime-resend-ts/src/submodule/text-validation.ts
new file mode 100644
index 00000000000..5409d6119b8
--- /dev/null
+++ b/spacetime-resend-ts/src/submodule/text-validation.ts
@@ -0,0 +1,7 @@
+export function hasControlCharacter(value: string): boolean {
+ for (let index = 0; index < value.length; index++) {
+ const code = value.charCodeAt(index);
+ if (code <= 0x1f || code === 0x7f) return true;
+ }
+ return false;
+}
diff --git a/spacetime-resend-ts/src/submodule/validation.ts b/spacetime-resend-ts/src/submodule/validation.ts
new file mode 100644
index 00000000000..181f0f5e9ad
--- /dev/null
+++ b/spacetime-resend-ts/src/submodule/validation.ts
@@ -0,0 +1,32 @@
+import * as v from 'valibot';
+import { SenderError } from 'spacetimedb/server';
+
+export function assertExhaustive(value: never): never {
+ throw new Error(`Unhandled discriminant: ${value as string}`);
+}
+
+export function throwSenderError(message: string): never {
+ throw new SenderError(message);
+}
+
+export function safeJsonParse(input: string): unknown {
+ try {
+ return JSON.parse(input);
+ } catch {
+ return undefined;
+ }
+}
+
+export function summarizeIssues(issues: v.BaseIssue[]): string {
+ if (issues.length === 0) return 'no issues';
+ const head = issues[0]!;
+ const path = (head.path ?? [])
+ .map(p =>
+ typeof p.key === 'string' || typeof p.key === 'number'
+ ? String(p.key)
+ : '?'
+ )
+ .join('.');
+ const where = path ? ` at ${path}` : '';
+ return `${head.message}${where}`;
+}
diff --git a/spacetime-resend-ts/src/submodule/webhook-metadata.ts b/spacetime-resend-ts/src/submodule/webhook-metadata.ts
new file mode 100644
index 00000000000..62b77b6e133
--- /dev/null
+++ b/spacetime-resend-ts/src/submodule/webhook-metadata.ts
@@ -0,0 +1,13 @@
+export function parseResendEventType(payloadJson: string): string | undefined {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(payloadJson);
+ } catch {
+ return undefined;
+ }
+ if (typeof parsed !== 'object' || parsed === null) return undefined;
+ const eventType = (parsed as Record).type;
+ return typeof eventType === 'string' && eventType.length > 0
+ ? eventType
+ : undefined;
+}
diff --git a/spacetime-resend-ts/src/submodule/webhooks.ts b/spacetime-resend-ts/src/submodule/webhooks.ts
new file mode 100644
index 00000000000..398826fff38
--- /dev/null
+++ b/spacetime-resend-ts/src/submodule/webhooks.ts
@@ -0,0 +1,390 @@
+import * as v from 'valibot';
+import {
+ EmailStatus,
+ WebhookEventStatus,
+ spacetimedb,
+ t,
+ vEmailEvent,
+ type EmailEvent,
+ type EmailStatusValue,
+ type ModuleTimestamp,
+ type ReducerModuleCtx,
+ type WebhookEventStatusValue,
+ type WriteCtx,
+} from './schema';
+import { upsertEmail } from './email_writes';
+import { requireAdmin } from './auth';
+import { verifySvixSignature } from '@spacetimedb/crypto';
+import {
+ SyncResponse,
+ type Request,
+ type HandlerContext,
+} from 'spacetimedb/server';
+import {
+ assertExhaustive,
+ safeJsonParse,
+ summarizeIssues,
+ throwSenderError,
+} from './validation';
+import { parseResendEventType } from './webhook-metadata';
+
+type ResendTags = EmailEvent['data']['tags'];
+
+function toAddressArray(value: string | string[]): string[] {
+ return Array.isArray(value) ? value : [value];
+}
+
+function tagsToJson(tags: ResendTags): string | undefined {
+ if (!tags) return undefined;
+ try {
+ return JSON.stringify(tags);
+ } catch {
+ return undefined;
+ }
+}
+
+function extractTagFields(tags: ResendTags): {
+ userId: string | undefined;
+ orgId: string | undefined;
+} {
+ if (!tags) return { userId: undefined, orgId: undefined };
+ if (Array.isArray(tags)) {
+ let userId: string | undefined;
+ let orgId: string | undefined;
+ for (const tag of tags) {
+ if (tag.name === 'userId') userId = tag.value;
+ if (tag.name === 'orgId') orgId = tag.value;
+ }
+ return { userId, orgId };
+ }
+ return { userId: tags['userId'], orgId: tags['orgId'] };
+}
+
+function recordDeliveryEvent(
+ ctx: WriteCtx,
+ now: ModuleTimestamp,
+ args: {
+ eventId: string;
+ resendId: string;
+ eventType: string;
+ createdAtIso: string;
+ detailJson: string | undefined;
+ }
+) {
+ if (ctx.db.resendDeliveryEvent.eventId.find(args.eventId)) return;
+ ctx.db.resendDeliveryEvent.insert({
+ eventId: args.eventId,
+ resendId: args.resendId,
+ eventType: args.eventType,
+ createdAtIso: args.createdAtIso,
+ detailJson: args.detailJson,
+ insertedAt: now,
+ });
+}
+
+function updateWebhookStatus(
+ ctx: ReducerModuleCtx,
+ eventId: string,
+ status: WebhookEventStatusValue,
+ errorMessage: string | undefined
+) {
+ const existing = ctx.db.resendWebhookEvent.eventId.find(eventId);
+ if (!existing) return;
+
+ const isTerminal = status.tag === 'Processed' || status.tag === 'Failed';
+ const updated = {
+ ...existing,
+ status,
+ errorMessage,
+ processedAt: isTerminal ? ctx.timestamp : existing.processedAt,
+ };
+
+ ctx.db.resendWebhookEvent.eventId.update(updated);
+}
+
+function makeEmailUpsertArgs(
+ event: EmailEvent,
+ now: ModuleTimestamp
+): Parameters[2] {
+ const data = event.data;
+ const fromAddress = Array.isArray(data.from) ? data.from[0]! : data.from;
+ const toAddresses = toAddressArray(data.to);
+ const tagFields = extractTagFields(data.tags);
+ const tagsJson = tagsToJson(data.tags);
+ const subject = data.subject;
+
+ const base = {
+ resendId: data.email_id,
+ fromAddress,
+ toAddressesJson: JSON.stringify(toAddresses),
+ subject,
+ // undefined preserves whatever send_email recorded.
+ html: undefined,
+ text: undefined,
+ lastError: undefined,
+ bouncedAt: undefined,
+ bounceJson: undefined,
+ failedAt: undefined,
+ failureReason: undefined,
+ complained: false,
+ complainedAt: undefined,
+ opened: false,
+ openedAt: undefined,
+ clicked: false,
+ clickedAt: undefined,
+ deliveredAt: undefined,
+ sentAt: undefined,
+ tagsJson,
+ userId: tagFields.userId,
+ orgId: tagFields.orgId,
+ // status undefined = preserve existing or default to queued; branches override.
+ status: undefined as EmailStatusValue | undefined,
+ } satisfies Parameters[2];
+
+ switch (event.type) {
+ case 'email.sent':
+ return { ...base, status: EmailStatus.Sent, sentAt: now };
+ case 'email.delivered':
+ return { ...base, status: EmailStatus.Delivered, deliveredAt: now };
+ case 'email.delivery_delayed':
+ return { ...base, status: EmailStatus.DeliveryDelayed };
+ case 'email.bounced':
+ return {
+ ...base,
+ status: EmailStatus.Bounced,
+ bouncedAt: now,
+ bounceJson: JSON.stringify(event.data.bounce),
+ lastError: event.data.bounce.message,
+ };
+ case 'email.failed':
+ return {
+ ...base,
+ status: EmailStatus.Failed,
+ failedAt: now,
+ failureReason: event.data.failed.reason,
+ lastError: event.data.failed.reason,
+ };
+ case 'email.complained':
+ return { ...base, complained: true, complainedAt: now };
+ case 'email.opened':
+ return { ...base, opened: true, openedAt: now };
+ case 'email.clicked':
+ return { ...base, clicked: true, clickedAt: now };
+ default:
+ return assertExhaustive(event);
+ }
+}
+
+function detailJsonForEvent(event: EmailEvent): string | undefined {
+ switch (event.type) {
+ case 'email.bounced':
+ return JSON.stringify(event.data.bounce);
+ case 'email.failed':
+ return JSON.stringify(event.data.failed);
+ case 'email.clicked':
+ return JSON.stringify(event.data.click);
+ case 'email.sent':
+ case 'email.delivered':
+ case 'email.delivery_delayed':
+ case 'email.complained':
+ case 'email.opened':
+ return undefined;
+ default:
+ return assertExhaustive(event);
+ }
+}
+
+function applyResendEvent(
+ ctx: ReducerModuleCtx,
+ eventId: string,
+ payloadJson: string
+): { status: WebhookEventStatusValue; error: string | undefined } {
+ const parsed = safeJsonParse(payloadJson);
+ if (parsed === undefined) {
+ return { status: WebhookEventStatus.Failed, error: 'invalid JSON payload' };
+ }
+
+ const result = v.safeParse(vEmailEvent, parsed);
+ if (!result.success) {
+ return {
+ status: WebhookEventStatus.Failed,
+ error: `payload validation failed: ${summarizeIssues(result.issues)}`,
+ };
+ }
+
+ const event = result.output;
+ const now = ctx.timestamp;
+ upsertEmail(ctx, now, makeEmailUpsertArgs(event, now));
+ recordDeliveryEvent(ctx, now, {
+ eventId,
+ resendId: event.data.email_id,
+ eventType: event.type,
+ createdAtIso: event.created_at,
+ detailJson: detailJsonForEvent(event),
+ });
+ return { status: WebhookEventStatus.Processed, error: undefined };
+}
+
+export interface ResendWebhookIngestArgs {
+ eventId: string;
+ eventType: string;
+ payloadJson: string;
+ signatureHeader?: string | undefined;
+ timestampHeader?: string | undefined;
+}
+
+const MAX_WEBHOOK_BODY_LENGTH = 1024 * 1024;
+const MAX_WEBHOOK_HEADER_LENGTH = 8192;
+const MAX_WEBHOOK_METADATA_LENGTH = 255;
+
+// The reducer and HTTP route verify, store, and apply events in one transaction.
+function applyResendWebhook(
+ ctx: WriteCtx,
+ args: ResendWebhookIngestArgs
+): { status: number; code: string } {
+ if (
+ args.eventId.length === 0 ||
+ args.eventId.length > MAX_WEBHOOK_METADATA_LENGTH ||
+ args.eventType.length === 0 ||
+ args.eventType.length > MAX_WEBHOOK_METADATA_LENGTH
+ ) {
+ return { status: 400, code: 'resend.webhook_metadata_invalid' };
+ }
+ if (args.payloadJson.length > MAX_WEBHOOK_BODY_LENGTH) {
+ return { status: 413, code: 'resend.webhook_payload_too_large' };
+ }
+ if (
+ (args.signatureHeader?.length ?? 0) > MAX_WEBHOOK_HEADER_LENGTH ||
+ (args.timestampHeader?.length ?? 0) > MAX_WEBHOOK_HEADER_LENGTH
+ ) {
+ return { status: 400, code: 'resend.webhook_header_too_large' };
+ }
+
+ const cfg = ctx.db.resendConfig.singleton.find(true);
+ if (!cfg?.webhookSigningSecret) {
+ return { status: 500, code: 'resend.webhook_secret_not_configured' };
+ }
+ const nowSeconds = Number(ctx.timestamp.microsSinceUnixEpoch / 1_000_000n);
+ const sigOk = verifySvixSignature({
+ svixId: args.eventId,
+ svixTimestamp: args.timestampHeader ?? '',
+ svixSignature: args.signatureHeader ?? '',
+ rawBody: args.payloadJson,
+ secret: cfg.webhookSigningSecret,
+ nowSeconds,
+ });
+ if (!sigOk) return { status: 401, code: 'resend.webhook_signature_mismatch' };
+
+ const signedEventType = parseResendEventType(args.payloadJson);
+ if (!signedEventType || signedEventType !== args.eventType) {
+ return { status: 400, code: 'resend.webhook_metadata_mismatch' };
+ }
+
+ // Idempotent: svix redelivers, so a known event id is a success no-op.
+ if (ctx.db.resendWebhookEvent.eventId.find(args.eventId)) {
+ return { status: 200, code: 'ok' };
+ }
+
+ ctx.db.resendWebhookEvent.insert({
+ eventId: args.eventId,
+ eventType: signedEventType,
+ payloadJson: args.payloadJson,
+ signatureHeader: args.signatureHeader,
+ timestampHeader: args.timestampHeader,
+ status: WebhookEventStatus.Received,
+ errorMessage: undefined,
+ receivedAt: ctx.timestamp,
+ processedAt: undefined,
+ });
+
+ const outcome = applyResendEvent(
+ ctx as ReducerModuleCtx,
+ args.eventId,
+ args.payloadJson
+ );
+ updateWebhookStatus(
+ ctx as ReducerModuleCtx,
+ args.eventId,
+ outcome.status,
+ outcome.error
+ );
+ return { status: 200, code: 'ok' };
+}
+
+export const ingestResendWebhook = spacetimedb.reducer(
+ {
+ eventId: t.string(),
+ eventType: t.string(),
+ payloadJson: t.string(),
+ signatureHeader: t.option(t.string()),
+ timestampHeader: t.option(t.string()),
+ },
+ (ctx, args) => {
+ const result = applyResendWebhook(ctx, args);
+ if (result.status !== 200) throwSenderError(result.code);
+ }
+);
+
+function webhookJson(body: unknown, status: number): SyncResponse {
+ return new SyncResponse(JSON.stringify(body), {
+ status,
+ headers: { 'content-type': 'application/json' },
+ });
+}
+
+// Native SpacetimeDB HTTP route handler. Host modules register it on a router so
+// Resend can post directly to the database.
+export function makeResendWebhookHandler() {
+ // The host passes the submodule-scoped context, so this handler remains schema-agnostic.
+ return function resendWebhook(
+ ctx: HandlerContext,
+ req: Request
+ ): SyncResponse {
+ if (req.method.toUpperCase() !== 'POST') {
+ return webhookJson({ error: 'method_not_allowed' }, 405);
+ }
+
+ const rawBody = req.text();
+ const svixId = req.headers.get('svix-id') ?? '';
+ const svixTimestamp = req.headers.get('svix-timestamp') ?? undefined;
+ const svixSignature = req.headers.get('svix-signature') ?? undefined;
+ if (!svixId) return webhookJson({ error: 'missing_svix_id' }, 400);
+
+ let eventType: string | undefined;
+ const parsed = safeJsonParse(rawBody);
+ if (
+ parsed &&
+ typeof parsed === 'object' &&
+ typeof (parsed as { type?: unknown }).type === 'string'
+ ) {
+ eventType = (parsed as { type: string }).type;
+ }
+ if (!eventType) return webhookJson({ error: 'missing_event_type' }, 400);
+
+ const result = ctx.withTx(tx =>
+ applyResendWebhook(tx as WriteCtx, {
+ eventId: svixId,
+ eventType,
+ payloadJson: rawBody,
+ signatureHeader: svixSignature,
+ timestampHeader: svixTimestamp,
+ })
+ );
+ return webhookJson(
+ { ok: result.status === 200, code: result.code },
+ result.status
+ );
+ };
+}
+
+export const replayWebhookEvent = spacetimedb.reducer(
+ { eventId: t.string() },
+ (ctx, { eventId }) => {
+ // Administrators may run this operation over stored events.
+ requireAdmin(ctx, ctx.sender);
+ const event = ctx.db.resendWebhookEvent.eventId.find(eventId);
+ if (!event) throwSenderError(`resend.webhook_event_not_found:${eventId}`);
+ const outcome = applyResendEvent(ctx, eventId, event.payloadJson);
+ updateWebhookStatus(ctx, eventId, outcome.status, outcome.error);
+ }
+);
diff --git a/spacetime-resend-ts/tsconfig.json b/spacetime-resend-ts/tsconfig.json
new file mode 100644
index 00000000000..c659d97428a
--- /dev/null
+++ b/spacetime-resend-ts/tsconfig.json
@@ -0,0 +1,22 @@
+{
+ "compilerOptions": {
+ "target": "ESNext",
+ "module": "ESNext",
+ "strict": true,
+ "declaration": false,
+ "emitDeclarationOnly": false,
+ "noEmit": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "allowImportingTsExtensions": true,
+ "noImplicitAny": true,
+ "moduleResolution": "Bundler",
+ "isolatedDeclarations": false,
+ "esModuleInterop": false,
+ "allowSyntheticDefaultImports": false,
+ "useDefineForClassFields": true,
+ "isolatedModules": true
+ },
+ "include": ["src/**/*.ts", "scripts/**/*.ts"],
+ "exclude": ["node_modules", "dist/**/*"]
+}