diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eac067244b5..0bf8d497e0f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -555,6 +555,87 @@ importers: specifier: ^5.9.3 version: 5.9.3 + spacetime-resend-ts: + dependencies: + '@spacetimedb/crypto': + specifier: workspace:^ + version: link:../spacetime-crypto-ts + valibot: + specifier: ^1.4.2 + version: 1.5.0(typescript@5.9.3) + devDependencies: + '@types/node': + specifier: ^22.10.2 + version: 22.18.0 + eslint: + specifier: ^9.17.0 + version: 9.33.0(jiti@2.6.1) + prettier: + specifier: ^3.3.3 + version: 3.6.2 + spacetimedb: + specifier: workspace:* + version: link:../crates/bindings-typescript + tsx: + specifier: ^4.21.0 + version: 4.21.0 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + spacetime-resend-ts/example: + dependencies: + '@spacetimedb/example-ui': + specifier: workspace:* + version: link:../../spacetime-example-ui-ts + dotenv: + specifier: ^16.4.7 + version: 16.6.1 + express: + specifier: ^4.21.2 + version: 4.21.2 + spacetimedb: + specifier: workspace:* + version: link:../../crates/bindings-typescript + devDependencies: + '@types/express': + specifier: ^4.17.21 + version: 4.17.23 + '@types/node': + specifier: ^22.10.2 + version: 22.18.0 + esbuild: + specifier: ^0.28.0 + version: 0.28.2 + resend: + specifier: ^6.12.2 + version: 6.30.0 + tsx: + specifier: ^4.21.0 + version: 4.21.0 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + spacetime-resend-ts/example/spacetimedb: + dependencies: + '@spacetimedb/rate-limit': + specifier: workspace:* + version: link:../../../spacetime-rate-limit-ts + '@spacetimedb/resend': + specifier: workspace:* + version: link:../.. + spacetimedb: + specifier: workspace:* + version: link:../../../crates/bindings-typescript + devDependencies: + '@types/node': + specifier: ^22.10.2 + version: 22.18.0 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + spacetime-retry-ts: devDependencies: '@types/node': @@ -6418,6 +6499,9 @@ packages: '@speed-highlight/core@1.2.14': resolution: {integrity: sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA==} + '@stablelib/base64@1.0.1': + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} + '@standard-schema/spec@1.0.0': resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} @@ -9618,6 +9702,9 @@ packages: fast-npm-meta@0.4.8: resolution: {integrity: sha512-ybZVlDZ2PkO79dosM+6CLZfKWRH8MF0PiWlw8M4mVWJl8IEJrPfxYc7Tsu830Dwj/R96LKXfePGTSzKWbPJ08w==} + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} @@ -12268,6 +12355,9 @@ packages: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} + postal-mime@2.7.6: + resolution: {integrity: sha512-UUlyE2KlxmvwMGq060onF/VWU3zD6dVW31FwokQ5v26jWd35fbs2MoTFzh+jXnPwAyV2MULMKXcBInGOl5TO6Q==} + postcss-attribute-case-insensitive@7.0.1: resolution: {integrity: sha512-Uai+SupNSqzlschRyNx3kbCTWgY/2hcwtHEI/ej2LJWc9JJ77qKgGptd8DHwY1mXtZ7Aoh4z4yxfwMBue9eNgw==} engines: {node: '>=18'} @@ -13425,6 +13515,15 @@ packages: requires-port@1.0.0: resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + resend@6.30.0: + resolution: {integrity: sha512-rED9rVw8N6cUvjmpYQwfZKBzd/cgWT+jURekVxtjFV2+YyK2/Tu2JOnE68sgqXlMBSYRoWgI6R0xlx3ANc/xeA==} + engines: {node: '>=20'} + peerDependencies: + '@react-email/render': '*' + peerDependenciesMeta: + '@react-email/render': + optional: true + reserved-identifiers@1.2.0: resolution: {integrity: sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw==} engines: {node: '>=18'} @@ -13917,6 +14016,9 @@ packages: standard-as-callback@2.1.0: resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + standardwebhooks@1.1.1: + resolution: {integrity: sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==} + statuses@1.5.0: resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} engines: {node: '>= 0.6'} @@ -14830,6 +14932,14 @@ packages: typescript: optional: true + valibot@1.5.0: + resolution: {integrity: sha512-nil6AkP2TChWL43Z5uJ6GTxX01CUA+g8LWUM+N/rB9NBbkUMaUsi9PUNzlUPgoKASgmx9f7eGOYpJ04/fSa6FQ==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + validate-npm-package-license@3.0.4: resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} @@ -21707,6 +21817,8 @@ snapshots: '@speed-highlight/core@1.2.14': {} + '@stablelib/base64@1.0.1': {} + '@standard-schema/spec@1.0.0': {} '@supabase/auth-js@2.97.0': @@ -26203,6 +26315,8 @@ snapshots: fast-npm-meta@0.4.8: {} + fast-sha256@1.3.0: {} + fast-uri@3.1.0: {} fastq@1.19.1: @@ -29758,6 +29872,8 @@ snapshots: possible-typed-array-names@1.1.0: {} + postal-mime@2.7.6: {} + postcss-attribute-case-insensitive@7.0.1(postcss@8.5.6): dependencies: postcss: 8.5.6 @@ -31057,6 +31173,11 @@ snapshots: requires-port@1.0.0: {} + resend@6.30.0: + dependencies: + postal-mime: 2.7.6 + standardwebhooks: 1.1.1 + reserved-identifiers@1.2.0: {} resolve-alpn@1.2.1: {} @@ -31674,6 +31795,11 @@ snapshots: standard-as-callback@2.1.0: {} + standardwebhooks@1.1.1: + dependencies: + '@stablelib/base64': 1.0.1 + fast-sha256: 1.3.0 + statuses@1.5.0: {} statuses@2.0.1: {} @@ -32649,6 +32775,10 @@ snapshots: optionalDependencies: typescript: 5.6.3 + valibot@1.5.0(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + validate-npm-package-license@3.0.4: dependencies: spdx-correct: 3.2.0 diff --git a/spacetime-resend-ts/.npmrc b/spacetime-resend-ts/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-resend-ts/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-resend-ts/LICENSE.txt b/spacetime-resend-ts/LICENSE.txt new file mode 120000 index 00000000000..cc1ff9bdd72 --- /dev/null +++ b/spacetime-resend-ts/LICENSE.txt @@ -0,0 +1 @@ +../licenses/apache2.txt \ No newline at end of file diff --git a/spacetime-resend-ts/README.md b/spacetime-resend-ts/README.md new file mode 100644 index 00000000000..7ba61661644 --- /dev/null +++ b/spacetime-resend-ts/README.md @@ -0,0 +1,243 @@ +# @spacetimedb/resend + +Send email from your SpacetimeDB application through [Resend](https://resend.com). +Use it for welcome messages, receipts, and notifications, then track whether +each message was delivered, delayed, or bounced. + +The submodule stores delivery status in your database and updates it from +Resend webhooks. Your application decides who can send email and see its history. + +## Install + +```bash +npm install @spacetimedb/resend spacetimedb +``` + +Requires SpacetimeDB 2.8.3 or later for submodule mounting. + +## Integrate into an application + +Add Resend to your module and initialize it: + +```ts +import { schema } from 'spacetimedb/server'; +import * as resend from '@spacetimedb/resend/submodule'; + +const spacetimedb = schema({ resend }); +export default spacetimedb; + +export const init = spacetimedb.init(ctx => { + resend.install(ctx.as.resend); +}); +``` + +After publishing, configure your Resend API key, sender address, and webhook +signing secret as the database owner or a Resend administrator. When mounted +as `resend`, use `resend.set_resend_config`; the arguments are shown in +[Standalone configuration](#standalone-configuration). + +Call `resend.sendEmailRequest` from a procedure that checks who may send email +and which recipients they may contact. Apply rate limits before sending: + +```ts +resend.sendEmailRequest(ctx.as.resend, { + to: ['delivered@resend.dev'], + subject: 'Welcome', + html: '

Hello.

', + tagsJson: JSON.stringify([ + { name: 'userId', value: 'u_123' }, + { name: 'orgId', value: 'launch' }, + ]), +}); +``` + +For delivery updates, register `makeResendWebhookHandler()` on an HTTP route +and add that URL in Resend. See the [Dispatch example](./example/) for a +complete send procedure, webhook route, and views that show each user only +their own email history. + +### Standalone configuration + +When running Resend as its own database, configure it with the unprefixed +operation below. Credentials are private, and the publishing owner is the +first administrator. + +```bash +spacetime call --server http://127.0.0.1:3000 spacetime-resend set_resend_config \ + '"re_..."' \ + '{"some":"whsec_..."}' \ + '{"some":"onboarding@resend.dev"}' +``` + +Args: `apiKey`, `webhookSigningSecret` (required for webhook ingest), `defaultFrom` (optional). +For `t.option(...)` CLI arguments, use `null` for no value and +`{"some":"value"}` for a string value. + +Verify: + +```bash +spacetime call --server http://127.0.0.1:3000 spacetime-resend get_resend_config_status '{}' +``` + +## Private tables + +| Table | Key | Notes | +| ----------------------- | ----------- | ------------------------------------------------------------ | +| `resend_email` | `resend_id` | one row per outbound email; `status` reflects latest webhook | +| `resend_delivery_event` | `event_id` | append-only audit log of every event for an email | + +- `resend_webhook_event` - idempotency log +- `resend_config` - credentials singleton +- `resend_admin_identity` - admin allowlist + +None of the Resend base tables are subscribable. Email recipients, subject, +body, tracking state, webhook payloads, and signature headers remain private. +Host modules should expose caller- or tenant-scoped views over `userId` or +`orgId`; the included example demonstrates this pattern. + +## API + +**Setup** + +- `set_resend_config(apiKey, webhookSigningSecret, defaultFrom)` +- `get_resend_config_status()` - `{ isConfigured, hasWebhookSecret, apiKeyLength, ... }` +- `add_admin_identity(identity)` / `remove_admin_identity(identity)` + +**Outbound** + +- `send_email({ from, to, subject, html, text, cc, bcc, replyTo, tagsJson, headersJson, scheduledAt, idempotencyKey})` - admin-gated; inserts a `resend_email` row with `status = queued`, returns `{ resendId }`. The first webhook flips it to `sent`/`delivered`. +- `cancel_email({ resendId })` - admin-gated +- `resend_api_request({ method, path, jsonBody, idempotencyKey })` - admin-gated + request to a relative `api.resend.com` path; accepted methods are `GET`, `POST`, + `PATCH`, and `DELETE` + +Email sends accept up to 100 combined `to`, `cc`, and `bcc` recipients. Address +fields are capped at 320 characters, subjects at 998 characters, HTML and text +at 200,000 characters each, and tag or header JSON at 16 KiB. Control characters +in address, subject, and schedule fields are rejected before provider HTTP. + +The generated client calls your application's send procedure, such as the +`sendDispatch` procedure in the example: + +```ts +const result = await conn.procedures.sendDispatch({ + to: 'delivered@resend.dev', + subject: 'Welcome', + message: 'Your workspace is ready.', +}); + +if (!result.ok) throw new Error(result.message); +``` + +Subscribe to a caller-scoped host view for delivery status. Keep the private +Resend tables and generic administrative send operation restricted to +operators. + +**Webhook ingest / replay** + +- `ingest_resend_webhook(eventId, eventType, payloadJson, signatureHeader, timestampHeader)` - deduplicates events by the `svix-id` header passed as `eventId` +- `replay_webhook_event(eventId)` - re-applies a stored event +- `makeResendWebhookHandler()` builds a direct HTTP webhook handler for a host + router. + +The HTTP handler returns 200 after applying an event, for an already processed +event, and for signed event types the submodule does not handle, which are stored +as `Ignored`. Invalid payloads return 400 and retain a failed event record; +redelivery retries failed records. Unexpected transaction failures propagate to +the HTTP runtime. + +The reducer throws, writing nothing, when metadata, size, headers, secret, or +signature checks reject a request. Once a signed event is stored, the reducer +commits: an invalid payload keeps its `Failed` event record with the error +message. Callers read the outcome with `get_webhook_event`. + +Delivery timestamps use the provider's event time. Older events cannot replace +newer status, and an earlier delivery stage cannot replace a later one. Queued +send responses preserve any webhook state already recorded. Opens, clicks, and +complaints update their own fields without changing delivery status. Error, +bounce, and failure details change only when their event sets the status. + +**Admin queries** + +- `get_email`, `list_emails_by_user_id`, `list_emails_by_org_id`, `list_emails_by_status` +- `list_delivery_events_for_email` +- `get_webhook_event` - stored event with `status` (`Received`, `Processed`, + `Ignored`, or `Failed`) and `errorMessage` + +List procedures return at most 1,000 rows. Host applications should expose +caller-scoped, paginated views for product-facing history. + +**Errors** + +`errors` from `@spacetimedb/resend/submodule` lists the `resend.*` codes the +submodule throws. + +Package entrypoints: + +- `@spacetimedb/resend` can run as a standalone email database. +- `@spacetimedb/resend/submodule` supplies submodule configuration, delivery, + webhook, and query helpers. + +## Webhook events handled + +``` +email.sent email.delivered +email.delivery_delayed email.bounced +email.complained email.failed +email.opened email.clicked +``` + +Other signed event types, such as `email.received`, `contact.*`, and `domain.*`, +are acknowledged and stored as `Ignored`. + +`opened` / `clicked` / `complained` are recorded as **flags + timestamps** and leave `status` unchanged. Terminal states (`delivered`, `bounced`, `failed`, `cancelled`) and in-flight states (`queued`, `sent`, `delivery_delayed`) live in the `status` column. + +## Webhook signature verification + +The `ingest_resend_webhook` reducer and the `makeResendWebhookHandler()` route verify Standard Webhooks signatures (svix) using the configured `webhookSigningSecret`. A rejected signature returns 401 with `resend.webhook_signature_mismatch:`, where the reason is a `@spacetimedb/crypto` `errors` code such as `crypto.timestamp_outside_tolerance`. `signatureHeader` and `timestampHeader` are stored on `resend_webhook_event` for forensic replay. + +## Tagging + +`userId` / `orgId` are extracted from Resend `tags` and indexed for per-user / per-org listing. + +- `send_email` takes `tagsJson` as a JSON string in the array form Resend's send API expects: `[{"name":"userId","value":"u_123"}]`. +- Resend webhook payloads carry tags in object form: `{"userId": "u_123", "orgId": "o_456"}`. + +## Integration testing + +```bash +# Build + publish + event paths, idempotency, replay, signed-type checks, and authorization checks +pnpm run test:smoke +``` + +The smoke test publishes only to the dedicated `resend-ts-smoke-test` database. + +For real Resend test-mode: + +```bash +# Bootstrap once with your real key: +spacetime call --server http://127.0.0.1:3000 spacetime-resend set_resend_config '"re_..."' null '{"some":"onboarding@resend.dev"}' + +# Then send to one of Resend's test addresses (delivered@/bounced@/complained@): +spacetime call --server http://127.0.0.1:3000 spacetime-resend send_email \ + null \ + '["delivered@resend.dev"]' \ + '"Test from SpacetimeDB"' \ + '{"some":"

Hello.

"}' \ + null null null null \ + '{"some":"[{\"name\":\"userId\",\"value\":\"u_123\"}]"}' \ + null null null +``` + +The standalone module has no HTTP route. To receive webhooks from Resend, mount the submodule in a host module that registers `makeResendWebhookHandler()` on a router, as the [example](./example/spacetimedb/) does at `/webhook/resend`. Expose the database through a public tunnel such as ngrok and register `https:///v1/database//route/webhook/resend` in Resend's dashboard with the same `whsec_...` you passed to `set_resend_config`. + +## Testing + +```bash +pnpm test +pnpm run lint +``` + +## License + +Apache-2.0. diff --git a/spacetime-resend-ts/example/.env.example b/spacetime-resend-ts/example/.env.example new file mode 100644 index 00000000000..c162013b626 --- /dev/null +++ b/spacetime-resend-ts/example/.env.example @@ -0,0 +1,28 @@ +# Copy to .env. The example server loads this on startup. + +# ---------------- Resend ---------------- +# Required to send email from Dispatch. +RESEND_API_KEY=re_... + +# Required to accept inbound webhooks (Resend dashboard, Webhooks -> your endpoint -> Signing secret). +# The module rejects requests when this is unset or the signature is invalid. +RESEND_WEBHOOK_SECRET=whsec_... + +# Optional comma-separated external recipients. Provider test recipients remain +# available automatically. The module rejects every address outside this list. +RESEND_ALLOWED_RECIPIENTS=you@example.com + +# ---------------- SpacetimeDB ---------------- +STDB_URI=ws://127.0.0.1:3000 +STDB_HTTP=http://127.0.0.1:3000 +SPACETIMEDB_DB_NAME=spacetime-resend-example +# Optional. When unset, the server creates a persistent local identity token in +# .stdb-server-token and the logged-in publishing identity authorizes it. +# STDB_SERVER_TOKEN= + +# ---------------- Static server ---------------- +PORT=8790 +HOST=127.0.0.1 + +# Default "from" address (must be a verified sender in your Resend account, or onboarding@resend.dev for testing). +DEFAULT_FROM=onboarding@resend.dev diff --git a/spacetime-resend-ts/example/.npmrc b/spacetime-resend-ts/example/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-resend-ts/example/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-resend-ts/example/README.md b/spacetime-resend-ts/example/README.md new file mode 100644 index 00000000000..562dbb2de2f --- /dev/null +++ b/spacetime-resend-ts/example/README.md @@ -0,0 +1,94 @@ +# Dispatch email example + +Send an email with Resend and watch its delivery status update in the app. + +## Run it locally + +Requires Node.js 20+, pnpm 10, and the SpacetimeDB CLI and server built from +this checkout. + +Start SpacetimeDB in a separate terminal: + +```bash +spacetime start +``` + +From `spacetime-resend-ts/example`, copy [.env.example](./.env.example) to `.env`. + +You also need a Resend API key. In `.env`, replace `RESEND_API_KEY` with your +key. Leave `RESEND_WEBHOOK_SECRET` empty until you set up a webhook below. +Remove the sample `RESEND_ALLOWED_RECIPIENTS` address or replace it with an +address you control. + +Then publish the example and start its web server: + +```bash +pnpm install +pnpm run build:module +pnpm run dev +``` + +Open . + +## Try it + +1. Click **Delivered** to use Resend's test recipient. +2. Write a subject and message, then send the email. +3. The message appears in the list. To see it change to Delivered, complete + the webhook setup below. +4. Try the **Bounced** and **Complaint** test recipients to see other outcomes. + +To send to a real address, add it to +`RESEND_ALLOWED_RECIPIENTS` and restart the example server. Use a sender that +Resend permits for your account; `DEFAULT_FROM` starts as +`onboarding@resend.dev`. + +## Receive delivery updates + +1. Use a tunnel to expose the example's `/webhook/resend` route. +2. Add that public URL as a webhook endpoint in Resend and select the email + events you want to receive. +3. Copy the endpoint's signing secret into `RESEND_WEBHOOK_SECRET`. +4. Restart `pnpm run dev` and send another test email. + +The local route is `http://127.0.0.1:8790/webhook/resend`. You can also forward +directly to the database route: + +```text +http://127.0.0.1:3000/v1/database/spacetime-resend-example/route/webhook/resend +``` + +The app rejects webhook requests with a missing or invalid signature. +Without a reachable webhook, email status stays Queued. + +## Before deploying + +Anyone who can open this demo can send to the allowed recipients. It limits +each browser identity to five sends per ten minutes and all users to 25 per +hour. Add account-based access checks before making the app public. Expose +only the webhook route when testing through a tunnel. + +The server saves its admin credential in `.stdb-server-token`. Keep that file +and `.env` private. + +## Troubleshooting + +- **Sending fails:** check the API key, allowed recipients, and sender address. +- **Status stays Queued:** check the public webhook URL and signing secret. +- **`resend.not_authorized`:** use the CLI account that published the database, + then restart the example server. + +## Change the example + +- [spacetimedb/src/index.ts](./spacetimedb/src/index.ts): email sending and delivery updates. +- [server.ts](./server.ts): Resend setup and webhook forwarding. +- [src/app.ts](./src/app.ts): email form and message list. + +After changing server code, run `pnpm run build:module`. Restart +`pnpm run dev` after changing browser code or `.env`. + +To start over, run `pnpm run build:module:fresh`. **This deletes all data in +the local `spacetime-resend-example` database.** + +To use the submodule in your own app, see the +[package integration guide](../README.md#integrate-into-an-application). diff --git a/spacetime-resend-ts/example/package.json b/spacetime-resend-ts/example/package.json new file mode 100644 index 00000000000..2dc1f7f6aa2 --- /dev/null +++ b/spacetime-resend-ts/example/package.json @@ -0,0 +1,30 @@ +{ + "name": "spacetime-resend-example", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "spacetime:generate": "spacetime generate --lang typescript --out-dir src/module_bindings --module-path ./spacetimedb -y", + "build:module": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local && pnpm run spacetime:generate && pnpm run build:app", + "build:module:fresh": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local:reset && pnpm run spacetime:generate && pnpm run build:app", + "check": "tsc --noEmit", + "test": "tsx scripts/test-message.ts", + "build:app": "pnpm run check && esbuild src/app.ts --bundle --format=esm --outfile=public/app.js --target=es2022 --sourcemap", + "build": "pnpm run spacetime:generate && pnpm run build:app", + "dev": "pnpm run build && tsx server.ts" + }, + "dependencies": { + "@spacetimedb/example-ui": "workspace:*", + "dotenv": "^16.4.7", + "express": "^4.21.2", + "spacetimedb": "workspace:*" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^22.10.2", + "esbuild": "^0.28.0", + "resend": "^6.12.2", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-resend-ts/example/public/index.html b/spacetime-resend-ts/example/public/index.html new file mode 100644 index 00000000000..c788c2ea747 --- /dev/null +++ b/spacetime-resend-ts/example/public/index.html @@ -0,0 +1,151 @@ + + + + + + + Dispatch + + + +
+
+
+ +
+

Dispatch

+ Send an email, watch its delivery resolve live +
+
+
+ +
+
+
+

Compose

+

+ Dispatched through the mounted Resend submodule. +

+
+ +
+ + +
+
+ Message +
+ + +
+
+ + +
+ +
+
+ +
+ Resend test addresses +
+ + + +
+
+
+ +
+
+
+

Live delivery

+

+ Every dispatch and its lifecycle, streamed from SpacetimeDB. +

+
+
+ 0 sent + +
+
+ +
+
+ 0Sent +
+
+ 0Delivered +
+
+ 0Opened +
+
+ 0Bounced +
+
+ +
+
+
+ +
+ Built on + SpacetimeDB +
+
+ + + + diff --git a/spacetime-resend-ts/example/public/styles.css b/spacetime-resend-ts/example/public/styles.css new file mode 100644 index 00000000000..91cc3c7532b --- /dev/null +++ b/spacetime-resend-ts/example/public/styles.css @@ -0,0 +1,885 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=IBM+Plex+Mono:wght@400;500;600&display=swap'); + +:root { + /* Tokens from spacetimedb.com/app/styles/variables.css */ + --font-inter: 'Inter', ui-sans-serif, system-ui, -apple-system, sans-serif; + --font-ibm: 'IBM Plex Mono', ui-monospace, monospace; + + --color-green: #4cf490; + --color-green-10: #4cf4901a; + --color-green-20: #4cf49033; + --color-white: #d7d8d9; + --color-yellow: #fbdc8e; + --color-purple: #a880ff; + --color-orange: #ff9e9e; + --color-blue: #02befa; + --color-pink: #ff80fb; + --color-teal: #00ccb4; + --color-red: #ff4c4c; + + --color-n1: #e6e9f0; + --color-n2: #ced3e0; + --color-n3: #b6c0cf; + --color-n4: #6f7987; + --color-n5: #363840; + --color-n8: #060606; + + --color-shade1: #162d38; + --color-shade4: #121e24; + --color-shade5: #0f191f; + --color-shade6: #0e161a; + --color-shade7: #0b1114; + --color-shade8: #0b0e12; + + --radius-sm: 6px; + --radius: 10px; + --radius-lg: 14px; +} + +* { + box-sizing: border-box; +} +html, +body { + margin: 0; + min-height: 100%; +} +body { + color: var(--color-white); + background: var(--color-shade7); + font-family: var(--font-inter); + -webkit-font-smoothing: antialiased; +} +::selection { + background: var(--color-green); + color: var(--color-n8); +} +a { + color: var(--color-green); + text-decoration: none; +} + +* { + scrollbar-width: thin; + scrollbar-color: var(--color-shade4) transparent; +} +*::-webkit-scrollbar { + width: 6px; + height: 6px; +} +*::-webkit-scrollbar-track { + background: transparent; +} +*::-webkit-scrollbar-thumb { + background: var(--color-shade4); + border-radius: 3px; +} +*::-webkit-scrollbar-thumb:hover { + background: var(--color-shade1); +} + +button, +input, +textarea { + font: inherit; +} +button { + height: 34px; + padding: 0 16px; + border: 1px solid var(--color-shade4); + border-radius: var(--radius-sm); + background: var(--color-shade7); + color: var(--color-n2); + font-family: var(--font-inter); + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: + background 0.18s, + border-color 0.18s, + color 0.18s; +} +button:hover:not(:disabled) { + background: var(--color-shade4); + color: var(--color-white); +} +button:focus-visible { + outline: 2px solid var(--color-green); + outline-offset: 2px; +} +button.primary { + background: var(--color-n3); + border-color: var(--color-n3); + color: var(--color-n8); +} +button.primary:hover:not(:disabled) { + background: var(--color-white); + border-color: var(--color-white); + color: var(--color-n8); +} +button.primary:active:not(:disabled) { + background: var(--color-green); + border-color: var(--color-green); +} +button.primary.sent { + background: var(--color-green); + border-color: var(--color-green); + color: var(--color-n8); + opacity: 1; +} +button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +input, +textarea { + width: 100%; + padding: 10px 12px; + border: 1px solid var(--color-shade4); + border-radius: var(--radius-sm); + background: var(--color-shade8); + color: var(--color-white); + outline: none; + transition: + border-color 0.15s, + box-shadow 0.15s; +} +input { + height: 40px; + padding: 0 12px; +} +textarea { + min-height: 120px; + resize: none; + line-height: 1.5; + font-size: 14px; +} +input::placeholder, +textarea::placeholder { + color: var(--color-n4); +} +input:focus, +textarea:focus { + border-color: var(--color-green); + box-shadow: 0 0 0 3px var(--color-green-10); +} +label { + display: grid; + gap: 7px; + color: var(--color-n4); + font-family: var(--font-ibm); + font-size: 11px; + letter-spacing: 0.07em; + text-transform: uppercase; +} +h1, +h2, +h3, +p { + margin: 0; +} +.muted { + color: var(--color-n4); +} + +.shell { + width: min(1200px, calc(100% - 32px)); + margin: 0 auto; + padding: 16px 0 0; + /* Viewport height gives the feed its own scroll area. Mobile uses auto + height in the media query. */ + height: 100vh; + display: flex; + flex-direction: column; +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + border: 1px solid #17303b; + border-radius: var(--radius-lg); + background: linear-gradient(180deg, #0d1920, #0b1319); + box-shadow: inset 0 1px 0 #26435166; + padding: 11px 16px; + margin-bottom: 14px; +} +.brand { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} +.brand-mark { + flex: 0 0 auto; + width: 34px; + height: 34px; + border-radius: var(--radius); + display: grid; + place-items: center; + color: var(--color-green); + background: var(--color-green-10); + border: 1px solid var(--color-green-20); +} +.brand-mark svg { + width: 18px; + height: 18px; +} +.brand-text { + display: grid; + gap: 1px; + min-width: 0; +} +.brand-text h1 { + margin: 0; + font-size: 15px; + font-weight: 700; + line-height: 1.2; + color: var(--color-n1); +} +.brand-sub { + color: var(--color-n4); + font-size: 12px; +} + +.workspace { + flex: 1; + display: grid; + grid-template-columns: 372px minmax(0, 1fr); + /* One row that is at least 520px and otherwise fills the viewport, so both + panels are always equal height and grow when the window grows. */ + grid-template-rows: minmax(520px, 1fr); + gap: 14px; + align-items: stretch; + min-height: 0; + margin-bottom: 16px; +} +.panel { + border: 1px solid var(--color-shade4); + border-radius: var(--radius-lg); + background: linear-gradient(180deg, var(--color-shade5), var(--color-shade6)); +} + +/* Compose */ +.compose { + padding: 18px; + display: flex; + flex-direction: column; + gap: 16px; +} +.compose-head { + display: grid; + gap: 3px; +} +.compose-head h2 { + font-size: 15px; + font-weight: 700; + color: var(--color-n1); +} +.compose-head p { + font-size: 13px; +} +.form-grid { + display: flex; + flex-direction: column; + gap: 13px; + flex: 1; + min-height: 0; +} +.form-grid > button.primary { + height: 42px; + font-size: 14px; +} +.field { + display: flex; + flex-direction: column; + gap: 7px; + flex: 1; + min-height: 0; +} +.field textarea, +.field .mail-preview { + flex: 1; + min-height: 120px; +} + +.test-block { + display: grid; + gap: 9px; +} +.test-label { + color: var(--color-n4); + font-family: var(--font-ibm); + font-size: 11px; + letter-spacing: 0.07em; + text-transform: uppercase; +} +.test-row { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} +.test-row button { + min-width: 0; + height: 32px; + padding: 0 6px; + font-size: 12px; +} +.test-row button b { + display: block; + font-size: 12px; + font-weight: 600; + color: var(--color-n2); +} + +/* Message field with write/preview tabs */ +.field-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} +.field-label { + color: var(--color-n4); + font-family: var(--font-ibm); + font-size: 11px; + letter-spacing: 0.07em; + text-transform: uppercase; +} +.msg-tabs { + display: inline-flex; + gap: 2px; + padding: 2px; + border: 1px solid var(--color-shade4); + border-radius: var(--radius-sm); + background: var(--color-shade8); +} +.msg-tab { + height: 24px; + padding: 0 12px; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--color-n4); + font-family: var(--font-ibm); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; +} +.msg-tab:hover:not(.active) { + background: transparent; + color: var(--color-n2); +} +.msg-tab.active { + background: var(--color-shade4); + color: var(--color-n1); +} + +.mail-preview { + min-height: 128px; + border: 1px solid var(--color-shade4); + border-radius: var(--radius-sm); + background: #ffffff; + overflow: hidden; +} +.mp-head { + padding: 12px 14px; + border-bottom: 1px solid #e6e8ec; + background: #f6f7f9; +} +.mp-subject { + font-size: 14px; + font-weight: 600; + color: #1f2933; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.mp-from { + margin-top: 3px; + font-size: 12px; + color: #6b7280; +} +.mp-body { + padding: 14px; + font-size: 14px; + color: #1f2933; + line-height: 1.6; + word-break: break-word; +} +.mp-body .mp-empty { + color: #9aa3af; + font-style: italic; +} + +.form-error { + color: var(--color-orange); + font-family: var(--font-ibm); + font-size: 12px; + line-height: 1.4; +} +.form-error:empty { + display: none; +} + +/* Live delivery console */ +.console { + display: grid; + grid-template-rows: auto auto minmax(0, 1fr); + min-height: 0; +} +.console-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + padding: 16px 18px; + border-bottom: 1px solid var(--color-shade4); +} +.console-copy { + display: grid; + gap: 3px; +} +.console-copy h2 { + font-size: 15px; + font-weight: 700; + color: var(--color-n1); +} +.console-copy p { + font-size: 13px; +} +.console-right { + display: flex; + align-items: center; + gap: 12px; +} +.feed-count { + color: var(--color-n5); + font-family: var(--font-ibm); + font-size: 11px; +} +#clear-btn { + height: 30px; + padding: 0 12px; + font-size: 12px; +} +#clear-btn:disabled { + opacity: 0.4; +} + +.metrics { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; + padding: 16px 18px; + border-bottom: 1px solid var(--color-shade4); +} +.metric { + display: grid; + gap: 7px; + padding: 13px; + border: 1px solid var(--color-shade4); + border-radius: var(--radius); + background: var(--color-shade7); +} +.metric strong { + font-size: 25px; + font-weight: 700; + line-height: 1; + color: var(--color-white); + font-variant-numeric: tabular-nums; +} +.metric span { + color: var(--color-n4); + font-family: var(--font-ibm); + font-size: 10px; + letter-spacing: 0.06em; + text-transform: uppercase; +} +.metric.delivered strong { + color: var(--color-green); +} +.metric.opened strong { + color: var(--color-green); +} +.metric.bounced strong { + color: var(--color-red); +} + +.feed { + min-height: 0; + overflow-y: auto; + padding: 14px 18px 18px; + display: grid; + gap: 11px; + align-content: start; +} + +.mail { + border: 1px solid var(--color-shade4); + border-radius: var(--radius); + background: var(--color-shade7); + padding: 14px 15px 16px; + transition: border-color 0.15s; +} +.mail:hover { + border-color: var(--color-shade1); +} +.mail.bad { + border-color: #4a2626; +} +.mail-head { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto auto; + gap: 11px; + align-items: center; +} +.mail-del { + width: 26px; + height: 26px; + padding: 0; + flex: 0 0 auto; + border: 1px solid transparent; + border-radius: var(--radius-sm); + background: transparent; + color: var(--color-n5); + font-size: 17px; + line-height: 1; + display: grid; + place-items: center; +} +.mail-del:hover:not(:disabled) { + background: #1a0f0f; + border-color: #4a2626; + color: var(--color-red); +} +.avatar { + width: 36px; + height: 36px; + flex: 0 0 auto; + border-radius: var(--radius); + display: grid; + place-items: center; + font-weight: 700; + font-size: 12px; + color: var(--av, var(--color-n3)); + background: color-mix(in srgb, var(--av, #555) 15%, transparent); + border: 1px solid color-mix(in srgb, var(--av, #555) 32%, transparent); +} +.mail-who { + min-width: 0; +} +.mail-to { + font-size: 14px; + color: var(--color-n1); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.mail-sub { + margin-top: 2px; + font-size: 13px; + color: var(--color-n4); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.mail-when { + flex: 0 0 auto; + text-align: right; + color: var(--color-n5); + font-family: var(--font-ibm); + font-size: 11px; +} + +/* Delivery timeline */ +.track { + display: flex; + margin-top: 15px; +} +.node { + flex: 1; + position: relative; + text-align: center; + min-width: 0; +} +.node::before { + content: ''; + position: absolute; + top: 6px; + left: -50%; + width: 100%; + height: 2px; + background: var(--color-shade4); +} +.node:first-child::before { + display: none; +} +.bead { + position: relative; + z-index: 1; + display: block; + width: 14px; + height: 14px; + margin: 0 auto; + border-radius: 50%; + background: var(--color-shade4); + border: 2px solid var(--color-shade6); +} +.node-label { + display: block; + margin-top: 7px; + font-family: var(--font-ibm); + font-size: 10px; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--color-n5); +} +.node-time { + display: block; + margin-top: 2px; + font-family: var(--font-ibm); + font-size: 10px; + color: var(--color-n5); + min-height: 12px; +} + +.node.done::before { + background: var(--color-green); +} +.node.done .bead { + background: var(--color-green); + box-shadow: 0 0 0 3px var(--color-green-10); +} +.node.done .node-label { + color: var(--color-n2); +} +.node.done .node-time { + color: var(--color-n4); +} + +.node.live::before { + background: var(--color-green); +} +.node.live .bead { + background: var(--color-green); + box-shadow: 0 0 0 5px color-mix(in srgb, var(--color-green) 26%, transparent); + animation: live-pulse 1.8s ease-in-out infinite; +} +.node.live .node-label { + color: var(--color-green); +} + +.node.bad::before { + background: var(--color-red); +} +.node.bad .bead { + background: var(--color-red); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-red) 20%, transparent); +} +.node.bad .node-label { + color: var(--color-red); +} + +.node.warn::before { + background: var(--color-yellow); +} +.node.warn .bead { + background: var(--color-yellow); +} +.node.warn .node-label { + color: var(--color-yellow); +} + +/* One-shot: the node that just reached its state ripples out from the bead. + Applied only to the node that changed this render. */ +.node.just-lit .bead::after { + content: ''; + position: absolute; + inset: -3px; + border-radius: 50%; + border: 2px solid var(--color-green); + animation: bead-ripple 0.65s ease-out forwards; +} +.node.just-lit.bad .bead::after { + border-color: var(--color-red); +} +.node.just-lit.warn .bead::after { + border-color: var(--color-yellow); +} +.node.just-lit::before { + animation: connector-sweep 0.5s ease-out; +} +.node.just-lit .node-label, +.node.just-lit .node-time { + animation: label-in 0.5s ease-out; +} + +.mail.card-enter { + animation: card-enter 0.4s cubic-bezier(0.2, 0.8, 0.2, 1); +} +/* Odometer roll: the current number exits as the incoming value enters. + JavaScript injects the clip window for the animation. */ +.metric strong .odo-clip { + display: block; + overflow: hidden; + height: 1em; +} +.metric strong .odo { + display: block; +} +.metric strong .odo-line { + height: 1em; + line-height: 1; +} +.metric strong .odo.roll-up { + animation: odo-up 0.42s cubic-bezier(0.3, 0.9, 0.3, 1) forwards; +} +.metric strong .odo.roll-down { + animation: odo-down 0.42s cubic-bezier(0.3, 0.9, 0.3, 1) forwards; +} + +@keyframes live-pulse { + 0%, + 100% { + box-shadow: 0 0 0 5px + color-mix(in srgb, var(--color-green) 26%, transparent); + } + 50% { + box-shadow: 0 0 0 8px color-mix(in srgb, var(--color-green) 8%, transparent); + } +} +@keyframes bead-ripple { + 0% { + transform: scale(1); + opacity: 0.9; + } + 100% { + transform: scale(2.6); + opacity: 0; + } +} +@keyframes connector-sweep { + 0% { + clip-path: inset(0 100% 0 0); + } + 100% { + clip-path: inset(0 0 0 0); + } +} +@keyframes label-in { + 0% { + opacity: 0.35; + } + 100% { + opacity: 1; + } +} +@keyframes card-enter { + 0% { + opacity: 0; + transform: translateY(-8px) scale(0.985); + } + 100% { + opacity: 1; + transform: none; + } +} +@keyframes odo-up { + from { + transform: translateY(0); + } + to { + transform: translateY(-1em); + } +} +@keyframes odo-down { + from { + transform: translateY(-1em); + } + to { + transform: translateY(0); + } +} +@media (prefers-reduced-motion: reduce) { + .node.live .bead, + .node.just-lit .bead::after, + .node.just-lit::before, + .node.just-lit .node-label, + .node.just-lit .node-time, + .mail.card-enter, + .metric strong .odo { + animation: none; + } +} + +.mail-err { + margin-top: 12px; + padding: 8px 10px; + border: 1px solid #4a2626; + border-radius: var(--radius-sm); + background: #1a0f0f; + color: var(--color-orange); + font-family: var(--font-ibm); + font-size: 11px; + line-height: 1.4; +} + +.empty { + border: 1px dashed var(--color-shade4); + border-radius: var(--radius); + padding: 34px 18px; + color: var(--color-n4); + text-align: center; + font-size: 13px; + line-height: 1.5; +} + +.page-foot { + min-height: 58px; + display: flex; + align-items: center; + justify-content: center; + gap: 11px; + border-top: 1px solid var(--color-shade4); +} +.page-foot .by { + color: var(--color-n4); + font-family: var(--font-ibm); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; +} +.page-foot img { + height: 26px; + width: auto; + opacity: 0.85; +} + +@media (max-width: 1040px) { + /* Stack the panels at their natural heights and use page-level scrolling. */ + .shell { + height: auto; + min-height: 100vh; + } + .workspace { + grid-template-columns: 1fr; + grid-template-rows: none; + align-items: start; + } + .console { + min-height: 560px; + } +} +@media (max-width: 560px) { + .metrics { + grid-template-columns: repeat(2, 1fr); + } + .node-label { + font-size: 9px; + } +} diff --git a/spacetime-resend-ts/example/scripts/test-message.ts b/spacetime-resend-ts/example/scripts/test-message.ts new file mode 100644 index 00000000000..c7d2169c2c0 --- /dev/null +++ b/spacetime-resend-ts/example/scripts/test-message.ts @@ -0,0 +1,15 @@ +import assert from 'node:assert/strict'; +import { messageHtml } from '../spacetimedb/src/message'; + +const html = messageHtml( + 'Hello & 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..5232d87182a --- /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/example-ui/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..fccdac9112a --- /dev/null +++ b/spacetime-resend-ts/example/spacetimedb/src/index.ts @@ -0,0 +1,273 @@ +// 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(), + } +); + +// Removed dispatches stay in the Resend tables so later webhooks update the +// existing row instead of recreating it. The views skip these ids. +const removedDispatch = table( + { name: 'removed_dispatch', public: false }, + { resendId: t.string().primaryKey() } +); + +const spacetimedb = schema({ + resend, + rateLimit, + dispatchPolicy, + removedDispatch, +}); + +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))].filter( + email => !ctx.db.removedDispatch.resendId.find(email.resendId) + ) +); + +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) + )) { + if (ctx.db.removedDispatch.resendId.find(email.resendId)) continue; + 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; +const callerLimiter = rateLimit.client({ + scope: 'dispatch.send.caller', + limit: CALLER_SEND_LIMIT, + windowSeconds: CALLER_WINDOW_SECONDS, +}); +const globalLimiter = rateLimit.client({ + scope: 'dispatch.send.global', + limit: GLOBAL_SEND_LIMIT, + windowSeconds: GLOBAL_WINDOW_SECONDS, +}); + +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 = callerLimiter.consume(tx.as.rateLimit, { + key: subjectFor(ctx), + }); + if (!caller.allowed) return 'rate_limited' as const; + const global = globalLimiter.consume(tx.as.rateLimit, { key: 'global' }); + 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 dispatches from the caller's log. This is a demo convenience so the +// log can be pruned. +export const deleteDispatch = spacetimedb.procedure( + { resendId: t.string() }, + dispatchDeleteResult, + (ctx, args) => { + return ctx.withTx(tx => { + const row = tx.db.resend.resendEmail.resendId.find(args.resendId); + if ( + row?.userId !== subjectFor(ctx) || + tx.db.removedDispatch.resendId.find(args.resendId) + ) { + return { ok: true, removed: 0 }; + } + tx.db.removedDispatch.insert({ resendId: args.resendId }); + return { ok: true, removed: 1 }; + }); + } +); + +export const clearDispatches = spacetimedb.procedure( + {}, + dispatchDeleteResult, + ctx => { + return ctx.withTx(tx => { + let removed = 0; + for (const row of tx.db.resend.resendEmail.byUserId.filter( + subjectFor(ctx) + )) { + if (tx.db.removedDispatch.resendId.find(row.resendId)) continue; + tx.db.removedDispatch.insert({ resendId: row.resendId }); + 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.install(ctx.as.resend); + rateLimit.install(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..4d2fadb97d6 --- /dev/null +++ b/spacetime-resend-ts/example/src/module_bindings/index.ts @@ -0,0 +1,242 @@ +// 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.11.0 (commit dfdccc7e468cc3d378901eac5ee498b4243f4442). + +/* 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_RemoveRateLimitAdminReducer from "./rateLimit/remove_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_GetWebhookEventProcedure from "./resend/get_webhook_event_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_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), + "rate_limit.rate_limit_config": __table({ + name: 'rate_limit.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), + "rate_limit.admin_rate_limit_buckets": __table({ + name: 'rate_limit.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, "resend.ingestResendWebhook"), + __reducerSchema("resend.replay_webhook_event", Resend_ReplayWebhookEventReducer, "resend.replayWebhookEvent"), + __reducerSchema("rate_limit.add_rate_limit_admin", RateLimit_AddRateLimitAdminReducer, "rateLimit.addRateLimitAdmin"), + __reducerSchema("rate_limit.remove_rate_limit_admin", RateLimit_RemoveRateLimitAdminReducer, "rateLimit.removeRateLimitAdmin"), + __reducerSchema("rate_limit.reset_buckets", RateLimit_ResetBucketsReducer, "rateLimit.resetBuckets"), + __reducerSchema("rate_limit.update_config", RateLimit_UpdateConfigReducer, "rateLimit.updateConfig"), +); + +/** 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, "resend.addAdminIdentity"), + __procedureSchema("resend.cancel_email", Resend_CancelEmailProcedure.params, Resend_CancelEmailProcedure.returnType, "resend.cancelEmail"), + __procedureSchema("resend.get_email", Resend_GetEmailProcedure.params, Resend_GetEmailProcedure.returnType, "resend.getEmail"), + __procedureSchema("resend.get_resend_config_status", Resend_GetResendConfigStatusProcedure.params, Resend_GetResendConfigStatusProcedure.returnType, "resend.getResendConfigStatus"), + __procedureSchema("resend.get_webhook_event", Resend_GetWebhookEventProcedure.params, Resend_GetWebhookEventProcedure.returnType, "resend.getWebhookEvent"), + __procedureSchema("resend.list_delivery_events_for_email", Resend_ListDeliveryEventsForEmailProcedure.params, Resend_ListDeliveryEventsForEmailProcedure.returnType, "resend.listDeliveryEventsForEmail"), + __procedureSchema("resend.list_emails_by_org_id", Resend_ListEmailsByOrgIdProcedure.params, Resend_ListEmailsByOrgIdProcedure.returnType, "resend.listEmailsByOrgId"), + __procedureSchema("resend.list_emails_by_status", Resend_ListEmailsByStatusProcedure.params, Resend_ListEmailsByStatusProcedure.returnType, "resend.listEmailsByStatus"), + __procedureSchema("resend.list_emails_by_user_id", Resend_ListEmailsByUserIdProcedure.params, Resend_ListEmailsByUserIdProcedure.returnType, "resend.listEmailsByUserId"), + __procedureSchema("resend.remove_admin_identity", Resend_RemoveAdminIdentityProcedure.params, Resend_RemoveAdminIdentityProcedure.returnType, "resend.removeAdminIdentity"), + __procedureSchema("resend.resend_api_request", Resend_ResendApiRequestProcedure.params, Resend_ResendApiRequestProcedure.returnType, "resend.resendApiRequest"), + __procedureSchema("resend.send_email", Resend_SendEmailProcedure.params, Resend_SendEmailProcedure.returnType, "resend.sendEmail"), + __procedureSchema("resend.set_resend_config", Resend_SetResendConfigProcedure.params, Resend_SetResendConfigProcedure.returnType, "resend.setResendConfig"), + __procedureSchema("rate_limit.run_sweep", RateLimit_RunSweepProcedure.params, RateLimit_RunSweepProcedure.returnType, "rateLimit.runSweep"), +); + +/** The remote SpacetimeDB module schema, both runtime and type information. */ +const REMOTE_MODULE = { + versionInfo: { + cliVersion: "2.11.0" 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["rate_limit.rate_limit_config"], + adminRateLimitBuckets: __qb["rate_limit.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"], + removeRateLimitAdmin: __reducerAccessors["rateLimit.removeRateLimitAdmin"], + 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: { + runSweep: __procedureAccessors["rateLimit.runSweep"], + }, + resend: { + addAdminIdentity: __procedureAccessors["resend.addAdminIdentity"], + cancelEmail: __procedureAccessors["resend.cancelEmail"], + getEmail: __procedureAccessors["resend.getEmail"], + getResendConfigStatus: __procedureAccessors["resend.getResendConfigStatus"], + getWebhookEvent: __procedureAccessors["resend.getWebhookEvent"], + 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..da3ee29f660 --- /dev/null +++ b/spacetime-resend-ts/example/src/module_bindings/my_dispatch_emails_table.ts @@ -0,0 +1,46 @@ +// 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"), + statusUpdatedAt: __t.option(__t.timestamp()).name("status_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/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/remove_rate_limit_admin_reducer.ts b/spacetime-resend-ts/example/src/module_bindings/rateLimit/remove_rate_limit_admin_reducer.ts new file mode 100644 index 00000000000..e39846ca8d9 --- /dev/null +++ b/spacetime-resend-ts/example/src/module_bindings/rateLimit/remove_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/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..d14931b1fb9 --- /dev/null +++ b/spacetime-resend-ts/example/src/module_bindings/rateLimit/types.ts @@ -0,0 +1,44 @@ +// 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(), + addedAt: __t.timestamp(), +}); +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 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/get_webhook_event_procedure.ts b/spacetime-resend-ts/example/src/module_bindings/resend/get_webhook_event_procedure.ts new file mode 100644 index 00000000000..8421d69c668 --- /dev/null +++ b/spacetime-resend-ts/example/src/module_bindings/resend/get_webhook_event_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 { + ResendWebhookEvent, +} from "./types"; + +export const params = { + eventId: __t.string(), +}; +export const returnType = __t.option(ResendWebhookEvent) \ 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..505864052b1 --- /dev/null +++ b/spacetime-resend-ts/example/src/module_bindings/resend/types.ts @@ -0,0 +1,124 @@ +// 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(), + addedAt: __t.timestamp(), +}); +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(), + statusUpdatedAt: __t.option(__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..3a76237db76 --- /dev/null +++ b/spacetime-resend-ts/example/src/module_bindings/types.ts @@ -0,0 +1,97 @@ +// 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 RemovedDispatch = __t.object("RemovedDispatch", { + resendId: __t.string(), +}); +export type RemovedDispatch = __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(), + statusUpdatedAt: __t.option(__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..ec58ab75835 --- /dev/null +++ b/spacetime-resend-ts/package.json @@ -0,0 +1,86 @@ +{ + "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": [ + "dist", + "LICENSE", + "README.md" + ], + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./submodule": { + "types": "./dist/submodule.d.ts", + "default": "./dist/submodule.js" + } + } + }, + "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": { + "prepack": "pnpm run build && node -e \"require('node:fs').copyFileSync('../licenses/apache2.txt', 'LICENSE')\"", + "postpack": "node -e \"require('node:fs').unlinkSync('LICENSE')\"", + "build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.build.json", + "build:module": "spacetime build", + "format": "prettier . --write --ignore-path ../.prettierignore", + "lint": "eslint . && prettier . --check --ignore-path ../.prettierignore", + "typecheck": "tsc --noEmit", + "test": "tsx scripts/test-unit.ts && node --js-explicit-resource-management --import tsx --import ./scripts/sys-abi-test-register.mjs scripts/test-webhooks.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/sys-abi-test-register.mjs b/spacetime-resend-ts/scripts/sys-abi-test-register.mjs new file mode 100644 index 00000000000..30107456e71 --- /dev/null +++ b/spacetime-resend-ts/scripts/sys-abi-test-register.mjs @@ -0,0 +1,27 @@ +import { register } from 'node:module'; + +// Host syscall modules exist only inside SpacetimeDB. Stub the bindings that +// `spacetimedb/server` reads while loading. +const stubs = { + 'spacetime:sys@2.0': + 'export const moduleHooks = Symbol(); export function row_iter_bsatn_close() {}', + 'spacetime:sys@2.1': 'export {};', + 'spacetime:sys@2.2': 'export function env_get() {}', +}; + +const loaderSource = ` + const stubs = ${JSON.stringify(stubs)}; + export function resolve(specifier, context, nextResolve) { + return specifier in stubs + ? { + shortCircuit: true, + url: 'data:text/javascript,' + encodeURIComponent(stubs[specifier]), + } + : nextResolve(specifier, context); + } +`; + +register( + `data:text/javascript,${encodeURIComponent(loaderSource)}`, + import.meta.url +); 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..0b62390a589 --- /dev/null +++ b/spacetime-resend-ts/scripts/test-resend-smoke.ts @@ -0,0 +1,602 @@ +// Synthetic signed events only; no provider requests. Add --example to test the native HTTP route. + +import { spawn } from 'node:child_process'; +import { createHmac } from 'node:crypto'; + +type Options = { + server: string; + database: string; + skipBuildPublish: boolean; + example: 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, + example: 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 === 'example') opts.example = 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, + opts.example ? `resend.${name}` : 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, + opts.example ? `resend.${name}` : 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; + createdAt?: string; +}): 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: args.createdAt ?? '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]!; +} + +const WEBHOOK_EVENT_STATUS = [ + 'Received', + 'Processed', + 'Ignored', + 'Failed', +] as const; + +function webhookEventStatus(rowText: string): string { + const parsed = JSON.parse(rowText); + const row = parsed?.[1]; + const variant = row?.[5]; + const tag = variant?.[0]; + if ( + typeof tag !== 'number' || + tag < 0 || + tag >= WEBHOOK_EVENT_STATUS.length + ) { + throw new Error( + `could not parse webhook event status from row: ${rowText}` + ); + } + return WEBHOOK_EVENT_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 modulePath = opts.example + ? ['--module-path', 'example/spacetimedb'] + : []; + const build = await run('spacetime', ['build', ...modulePath]); + 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', + ...modulePath, + 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'), + ]); + + if (opts.example) { + step('HTTP rejects failed payloads on first delivery and redelivery'); + const eventId = evt('invalid_http'); + const payloadJson = JSON.stringify({ type: 'email.sent', data: {} }); + for (let attempt = 0; attempt < 2; attempt++) { + const timestamp = String(Math.floor(Date.now() / 1000)); + const response = await fetch( + `${opts.server}/v1/database/${opts.database}/route/webhook/resend`, + { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'svix-id': eventId, + 'svix-timestamp': timestamp, + 'svix-signature': svixSignature({ + eventId, + timestamp, + payloadJson, + }), + }, + body: payloadJson, + } + ); + if (response.status !== 400) + throw new Error( + `expected invalid payload HTTP 400 on attempt ${attempt}, got ${response.status}: ${await response.text()}` + ); + } + } + + 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)}` + ); + } + + step('reducer keeps a Failed row for an invalid signed payload'); + const invalidEventId = evt('invalid_reducer'); + await ingestWebhook( + opts, + invalidEventId, + 'email.sent', + JSON.stringify({ type: 'email.sent', data: {} }) + ); + const readInvalidEvent = () => + callReducer(opts, 'get_webhook_event', [quote(invalidEventId)]); + let stored = await readInvalidEvent(); + if (webhookEventStatus(stored) !== 'Failed') + throw new Error(`expected Failed webhook row after ingest: ${stored}`); + await callReducer(opts, 'replay_webhook_event', [quote(invalidEventId)]); + stored = await readInvalidEvent(); + if (webhookEventStatus(stored) !== 'Failed') + throw new Error(`expected Failed webhook row after replay: ${stored}`); + + step('signed event types the submodule does not handle are acknowledged'); + await ingestWebhook( + opts, + evt('contact'), + 'contact.created', + JSON.stringify({ + type: 'contact.created', + created_at: '2026-05-04T00:00:00Z', + data: { id: 'contact_smoke' }, + }) + ); + + // 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}`); + } + + step('late sent, delayed, and failed events do not undo a bounce'); + for (const type of ['email.sent', 'email.delivery_delayed']) { + await ingestWebhook( + opts, + evt(`late_${type}`), + type, + eventPayload({ + type, + emailId: em('c'), + createdAt: '2026-05-03T23:59:00Z', + }) + ); + const current = await callReducer(opts, 'get_email', [quote(em('c'))]); + if (emailStatus(current) !== 'Bounced') + throw new Error(`late ${type} changed bounce: ${current}`); + } + await ingestWebhook( + opts, + evt('late_failed'), + 'email.failed', + eventPayload({ + type: 'email.failed', + emailId: em('c'), + extra: { failed: { reason: 'late failure' } }, + }) + ); + const afterLateFailure = await callReducer(opts, 'get_email', [ + quote(em('c')), + ]); + if ( + emailStatus(afterLateFailure) !== 'Bounced' || + afterLateFailure.includes('late failure') + ) + throw new Error(`late failure changed bounce: ${afterLateFailure}`); + await callReducer(opts, 'replay_webhook_event', [ + quote(evt('late_email.sent')), + ]); + if ( + emailStatus(await callReducer(opts, 'get_email', [quote(em('c'))])) !== + 'Bounced' + ) { + throw new Error('administrative replay regressed email status'); + } + + // 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: signed webhook, status, idempotency, replay, and rejection checks passed' + ); +} + +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..01ac89d0cbf --- /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'; +import { validateEmailInput } from '../src/submodule/email-input'; +import { parseResendEventType } from '../src/submodule/webhook-metadata'; + +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/scripts/test-webhooks.ts b/spacetime-resend-ts/scripts/test-webhooks.ts new file mode 100644 index 00000000000..85b3c54bfa0 --- /dev/null +++ b/spacetime-resend-ts/scripts/test-webhooks.ts @@ -0,0 +1,115 @@ +import * as assert from 'node:assert/strict'; +import { createHmac } from 'node:crypto'; +import { Timestamp } from 'spacetimedb'; +import { SenderError } from 'spacetimedb/server'; +import { + ingestResendWebhook, + makeResendWebhookHandler, +} from '../src/submodule/webhooks'; + +const secretRaw = 'resend_unit_test_secret'; +const secret = `whsec_${Buffer.from(secretRaw).toString('base64')}`; +const nowSeconds = 1_800_000_000; +const timestamp = String(nowSeconds); +const payloadJson = JSON.stringify({ + type: 'contact.created', + created_at: '2027-01-15T08:00:00Z', + data: { id: 'contact_1' }, +}); + +// Signed and handled, but fails payload validation. +const invalidPayloadJson = JSON.stringify({ + type: 'email.sent', + created_at: '2027-01-15T08:00:00Z', + data: {}, +}); + +function sign(eventId: string, body = payloadJson): string { + const digest = createHmac('sha256', secretRaw) + .update(`${eventId}.${timestamp}.${body}`) + .digest('base64'); + return `v1,${digest}`; +} + +// The mock context implements only the tables these paths read and write. +type EventRow = { eventId: string; status: { tag: string } }; +const webhookEvents = new Map(); +const tx = { + timestamp: new Timestamp(BigInt(nowSeconds) * 1_000_000n), + db: { + resendConfig: { + singleton: { find: () => ({ webhookSigningSecret: secret }) }, + }, + resendWebhookEvent: { + eventId: { + find: (id: string) => webhookEvents.get(id), + update: (row: EventRow) => webhookEvents.set(row.eventId, row), + }, + insert: (row: EventRow) => webhookEvents.set(row.eventId, row), + }, + }, +}; + +const handler = makeResendWebhookHandler(); +function post(eventId: string, signature: string, body = payloadJson) { + const headers = new Map([ + ['svix-id', eventId], + ['svix-timestamp', timestamp], + ['svix-signature', signature], + ]); + const req = { + method: 'POST', + headers: { get: (name: string) => headers.get(name) ?? null }, + text: () => body, + }; + return handler( + { withTx: (fn: (ctx: typeof tx) => unknown) => fn(tx) } as never, + req as never + ); +} + +const rejected = post('evt_bad_http', sign('evt_other')); +assert.equal(rejected.status, 401); +assert.match(rejected.text(), /resend\.webhook_signature_mismatch/); +assert.equal(webhookEvents.size, 0); + +assert.equal(post('evt_good_http', sign('evt_good_http')).status, 200); +assert.ok(webhookEvents.has('evt_good_http')); + +const invalidHttp = post( + 'evt_invalid_http', + sign('evt_invalid_http', invalidPayloadJson), + invalidPayloadJson +); +assert.equal(invalidHttp.status, 400); +assert.match(invalidHttp.text(), /resend\.webhook_payload_invalid/); +assert.equal(webhookEvents.get('evt_invalid_http')?.status.tag, 'Failed'); + +const ingest = (eventId: string, signature: string, body = payloadJson) => + ingestResendWebhook(tx as never, { + eventId, + eventType: JSON.parse(body).type, + payloadJson: body, + signatureHeader: signature, + timestampHeader: timestamp, + }); + +assert.throws( + () => ingest('evt_bad_reducer', sign('evt_other')), + (error: unknown) => + error instanceof SenderError && + /resend\.webhook_signature_mismatch/.test(error.message) +); +assert.ok(!webhookEvents.has('evt_bad_reducer')); +ingest('evt_good_reducer', sign('evt_good_reducer')); +assert.ok(webhookEvents.has('evt_good_reducer')); + +// The reducer returns so the Failed row commits. +ingest( + 'evt_invalid_reducer', + sign('evt_invalid_reducer', invalidPayloadJson), + invalidPayloadJson +); +assert.equal(webhookEvents.get('evt_invalid_reducer')?.status.tag, 'Failed'); + +process.stdout.write('resend webhook tests passed\n'); diff --git a/spacetime-resend-ts/src/index.ts b/spacetime-resend-ts/src/index.ts new file mode 100644 index 00000000000..72d8ebde3b1 --- /dev/null +++ b/spacetime-resend-ts/src/index.ts @@ -0,0 +1,19 @@ +export { default, init } from './submodule/schema.js'; +export { + ingestResendWebhook, + replayWebhookEvent, +} from './submodule/webhooks.js'; + +export { setResendConfig, getResendConfigStatus } from './submodule/config.js'; +export { addAdminIdentity, removeAdminIdentity } from './submodule/auth.js'; +export { + cancelEmail, + getEmail, + getWebhookEvent, + listDeliveryEventsForEmail, + listEmailsByOrgId, + listEmailsByStatus, + listEmailsByUserId, + resendApiRequest, + sendEmail, +} from './submodule/operations.js'; diff --git a/spacetime-resend-ts/src/submodule.ts b/spacetime-resend-ts/src/submodule.ts new file mode 100644 index 00000000000..6df49341a3f --- /dev/null +++ b/spacetime-resend-ts/src/submodule.ts @@ -0,0 +1,30 @@ +export { default } from './submodule/schema.js'; +export { + resendDeliveryEventTable, + resendEmailTable, + t, +} from './submodule/schema.js'; +export { install } from './submodule/install.js'; +export { errors } from './submodule/errors.js'; +export { + ingestResendWebhook, + replayWebhookEvent, + makeResendWebhookHandler, + type ResendWebhookIngestArgs, +} from './submodule/webhooks.js'; +export { + sendEmailRequest, + sendEmail, + cancelEmail, + getEmail, + getWebhookEvent, + listEmailsByUserId, + listEmailsByOrgId, + listEmailsByStatus, + listDeliveryEventsForEmail, + resendApiRequest, + type SendEmailArgs, +} from './submodule/operations.js'; + +export { setResendConfig, getResendConfigStatus } from './submodule/config.js'; +export { addAdminIdentity, removeAdminIdentity } from './submodule/auth.js'; diff --git a/spacetime-resend-ts/src/submodule/auth.ts b/spacetime-resend-ts/src/submodule/auth.ts new file mode 100644 index 00000000000..83d435636b0 --- /dev/null +++ b/spacetime-resend-ts/src/submodule/auth.ts @@ -0,0 +1,67 @@ +import { + spacetimedb, + t, + type ProcedureModuleCtx, + type WriteCtx, +} from './schema.js'; +import { throwSenderError } from './validation.js'; +import { errors } from './errors.js'; + +// 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']; + +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(errors.notAuthorized); +} + +export function requireAdmin(ctx: WriteCtx, sender: Sender): void { + if (!isAdmin(ctx, sender)) throwSenderError(errors.notAuthorized); +} + +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, + addedAt: ctx.timestamp, + }); + } + }); + 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(errors.cannotRemoveLastAdmin); + } + 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..0f1e546f60c --- /dev/null +++ b/spacetime-resend-ts/src/submodule/config.ts @@ -0,0 +1,108 @@ +import { + spacetimedb, + t, + type ProcedureModuleCtx, + type WriteCtx, +} from './schema.js'; +import { adminVerdict, denyIfNotAdmin } from './auth.js'; +import { throwSenderError } from './validation.js'; +import { errors } from './errors.js'; + +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( + `${errors.configNotSet}: 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..1b52a077e12 --- /dev/null +++ b/spacetime-resend-ts/src/submodule/email-input.ts @@ -0,0 +1,106 @@ +import { errors } from './errors.js'; +import { hasControlCharacter } from './text-validation.js'; + +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(code); +} + +function validateAddressList( + values: string[] | undefined, + tooMany: string, + invalidAddress: string +): void { + if (values === undefined) return; + if (values.length > 100) fail(tooMany); + for (const value of values) { + if ( + value.length === 0 || + value.length > 320 || + hasControlCharacter(value) + ) { + fail(invalidAddress); + } + } +} + +function validateJson( + value: string | undefined, + tooLarge: string, + invalidJson: string, + maxLength: number +): void { + if (value === undefined) return; + if (value.length > maxLength) fail(tooLarge); + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + fail(invalidJson); + } + if (parsed === null || typeof parsed !== 'object') fail(invalidJson); +} + +export function validateEmailInput(args: EmailInput): void { + if (args.to.length === 0) fail(errors.sendEmailNoRecipients); + validateAddressList(args.to, errors.toTooMany, errors.toInvalidAddress); + validateAddressList(args.cc, errors.ccTooMany, errors.ccInvalidAddress); + validateAddressList(args.bcc, errors.bccTooMany, errors.bccInvalidAddress); + validateAddressList( + args.replyTo, + errors.replyToTooMany, + errors.replyToInvalidAddress + ); + const recipientCount = + args.to.length + (args.cc?.length ?? 0) + (args.bcc?.length ?? 0); + if (recipientCount > 100) fail(errors.sendEmailTooManyRecipients); + if ( + args.from !== undefined && + (args.from.length === 0 || + args.from.length > 320 || + hasControlCharacter(args.from)) + ) + fail(errors.sendEmailInvalidFrom); + if ( + args.subject.length === 0 || + args.subject.length > 998 || + hasControlCharacter(args.subject) + ) { + fail(errors.sendEmailInvalidSubject); + } + if (args.html === undefined && args.text === undefined) + fail(errors.sendEmailMissingContent); + if ((args.html?.length ?? 0) > 200_000) fail(errors.sendEmailHtmlTooLarge); + if ((args.text?.length ?? 0) > 200_000) fail(errors.sendEmailTextTooLarge); + validateJson( + args.tagsJson, + errors.tagsTooLarge, + errors.tagsInvalidJson, + 16_384 + ); + validateJson( + args.headersJson, + errors.headersTooLarge, + errors.headersInvalidJson, + 16_384 + ); + if ( + (args.scheduledAt?.length ?? 0) > 128 || + hasControlCharacter(args.scheduledAt ?? '') + ) { + fail(errors.sendEmailInvalidSchedule); + } +} 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..1519cb7b101 --- /dev/null +++ b/spacetime-resend-ts/src/submodule/email_writes.ts @@ -0,0 +1,99 @@ +import { + EmailStatus, + type EmailStatusValue, + type ModuleTimestamp, + type WriteCtx, +} from './schema.js'; + +const STATUS_ORDER = { + Queued: 0, + Sent: 1, + DeliveryDelayed: 2, + Delivered: 3, + Cancelled: 4, + Failed: 5, + Bounced: 6, +}; + +// 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; + statusUpdatedAt?: ModuleTimestamp; + 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 statusTime = args.statusUpdatedAt ?? now; + // Late webhooks and send responses cannot undo a later delivery stage. + const replaceStatus = + args.status !== undefined && + (!existing || + (STATUS_ORDER[args.status.tag] >= STATUS_ORDER[existing.status.tag] && + (existing.status.tag === 'Queued' || + !existing.statusUpdatedAt || + statusTime.microsSinceUnixEpoch >= + existing.statusUpdatedAt.microsSinceUnixEpoch))); + // Error and bounce details describe the status that produced them. + const statusDetail = replaceStatus ? args : undefined; + 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: + replaceStatus && args.status + ? args.status + : (existing?.status ?? EmailStatus.Queued), + statusUpdatedAt: replaceStatus ? statusTime : existing?.statusUpdatedAt, + lastError: statusDetail?.lastError ?? existing?.lastError, + bouncedAt: statusDetail?.bouncedAt ?? existing?.bouncedAt, + bounceJson: statusDetail?.bounceJson ?? existing?.bounceJson, + failedAt: statusDetail?.failedAt ?? existing?.failedAt, + failureReason: statusDetail?.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/errors.ts b/spacetime-resend-ts/src/submodule/errors.ts new file mode 100644 index 00000000000..20afc973521 --- /dev/null +++ b/spacetime-resend-ts/src/submodule/errors.ts @@ -0,0 +1,44 @@ +export const errors = { + notAuthorized: 'resend.not_authorized', + cannotRemoveLastAdmin: 'resend.cannot_remove_last_admin', + configNotSet: 'resend.config_not_set', + requestInvalid: 'resend.request_invalid', + requestPathInvalid: 'resend.request_path_invalid', + requestMethodInvalid: 'resend.request_method_invalid', + requestBodyTooLarge: 'resend.request_body_too_large', + idempotencyKeyTooLong: 'resend.idempotency_key_too_long', + sendEmailInvalidInput: 'resend.send_email_invalid_input', + sendEmailNoRecipients: 'resend.send_email_no_recipients', + sendEmailTooManyRecipients: 'resend.send_email_too_many_recipients', + sendEmailInvalidFrom: 'resend.send_email_invalid_from', + sendEmailInvalidSubject: 'resend.send_email_invalid_subject', + sendEmailMissingContent: 'resend.send_email_missing_content', + sendEmailHtmlTooLarge: 'resend.send_email_html_too_large', + sendEmailTextTooLarge: 'resend.send_email_text_too_large', + sendEmailInvalidSchedule: 'resend.send_email_invalid_schedule', + sendEmailMissingFrom: 'resend.send_email_missing_from', + sendEmailFailed: 'resend.send_email_failed', + sendEmailInvalidResponse: 'resend.send_email_invalid_response', + toTooMany: 'resend.to_too_many', + toInvalidAddress: 'resend.to_invalid_address', + ccTooMany: 'resend.cc_too_many', + ccInvalidAddress: 'resend.cc_invalid_address', + bccTooMany: 'resend.bcc_too_many', + bccInvalidAddress: 'resend.bcc_invalid_address', + replyToTooMany: 'resend.reply_to_too_many', + replyToInvalidAddress: 'resend.reply_to_invalid_address', + tagsTooLarge: 'resend.tags_too_large', + tagsInvalidJson: 'resend.tags_invalid_json', + headersTooLarge: 'resend.headers_too_large', + headersInvalidJson: 'resend.headers_invalid_json', + cancelEmailFailed: 'resend.cancel_email_failed', + webhookMethodNotAllowed: 'resend.webhook_method_not_allowed', + webhookMetadataInvalid: 'resend.webhook_metadata_invalid', + webhookMetadataMismatch: 'resend.webhook_metadata_mismatch', + webhookPayloadTooLarge: 'resend.webhook_payload_too_large', + webhookHeaderTooLarge: 'resend.webhook_header_too_large', + webhookSecretNotConfigured: 'resend.webhook_secret_not_configured', + webhookSignatureMismatch: 'resend.webhook_signature_mismatch', + webhookPayloadInvalid: 'resend.webhook_payload_invalid', + webhookEventNotFound: 'resend.webhook_event_not_found', +} as const; diff --git a/spacetime-resend-ts/src/submodule/http.ts b/spacetime-resend-ts/src/submodule/http.ts new file mode 100644 index 00000000000..fc924a1184d --- /dev/null +++ b/spacetime-resend-ts/src/submodule/http.ts @@ -0,0 +1,68 @@ +import * as v from 'valibot'; +import { type ProcedureModuleCtx, vResendErrorBody } from './schema.js'; +import { safeJsonParse, throwSenderError } from './validation.js'; +import { buildResendHttpRequest } from './request.js'; +import { errors } from './errors.js'; + +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 : errors.requestInvalid + ); + } + 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..493ec8558cf --- /dev/null +++ b/spacetime-resend-ts/src/submodule/install.ts @@ -0,0 +1,9 @@ +import type { ReducerModuleCtx } from './schema.js'; + +export function install(ctx: ReducerModuleCtx) { + if (ctx.db.resendAdminIdentity.identity.find(ctx.sender) != null) return; + ctx.db.resendAdminIdentity.insert({ + identity: ctx.sender, + addedAt: ctx.timestamp, + }); +} diff --git a/spacetime-resend-ts/src/submodule/operations.ts b/spacetime-resend-ts/src/submodule/operations.ts new file mode 100644 index 00000000000..e5ba032dc11 --- /dev/null +++ b/spacetime-resend-ts/src/submodule/operations.ts @@ -0,0 +1,370 @@ +import * as v from 'valibot'; +import { + EmailStatus, + emailStatus, + resendDeliveryEventTable, + resendEmailTable, + resendWebhookEventTable, + sendEmailResult, + spacetimedb, + t, + vSendEmailResponse, + type ProcedureModuleCtx, + type WriteCtx, +} from './schema.js'; +import { callResend, ensureOkOrThrow } from './http.js'; +import { + safeJsonParse, + summarizeIssues, + throwSenderError, +} from './validation.js'; +import { upsertEmail } from './email_writes.js'; +import { loadConfigOrThrowFromProcedure } from './config.js'; +import { adminVerdict, denyIfNotAdmin } from './auth.js'; +import { validateEmailInput } from './email-input.js'; +import { errors } from './errors.js'; + +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; +} + +// Resend's send API takes tags as `[{ name, value }]`. +const vSendTags = v.array(v.object({ name: v.string(), value: v.string() })); + +function extractTagFieldsFromJson(tagsJson: string | undefined): { + userId: string | undefined; + orgId: string | undefined; +} { + const result = v.safeParse(vSendTags, tagsJson && safeJsonParse(tagsJson)); + if (!result.success) return { userId: undefined, orgId: undefined }; + const tags = result.output; + return { + userId: tags.find(tag => tag.name === 'userId')?.value, + orgId: tags.find(tag => tag.name === 'orgId')?.value, + }; +} + +// 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 : errors.sendEmailInvalidInput + ); + } + const cfg = loadConfigOrThrowFromProcedure(ctx); + const fromAddress = args.from ?? cfg.defaultFrom; + if (!fromAddress) throwSenderError(errors.sendEmailMissingFrom); + + 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, errors.sendEmailFailed); + + const parsed = safeJsonParse(response.body); + if (parsed === undefined) throwSenderError(errors.sendEmailInvalidResponse); + const result = v.safeParse(vSendEmailResponse, parsed); + if (!result.success) { + throwSenderError( + `${errors.sendEmailInvalidResponse}:${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, errors.cancelEmailFailed); + + ctx.withTx(tx => { + const existing = tx.db.resendEmail.resendId.find(args.resendId); + if (!existing) return; + const updated = { + ...existing, + status: EmailStatus.Cancelled, + statusUpdatedAt: ctx.timestamp, + 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 getWebhookEvent = spacetimedb.procedure( + { eventId: t.string() }, + t.option(resendWebhookEventTable.rowType), + (ctx, { eventId }) => { + requireProcedureAdmin(ctx); + return ctx.withTx( + tx => tx.db.resendWebhookEvent.eventId.find(eventId) ?? 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..bbf13cec563 --- /dev/null +++ b/spacetime-resend-ts/src/submodule/request.ts @@ -0,0 +1,68 @@ +import { errors } from './errors.js'; +import { hasControlCharacter } from './text-validation.js'; + +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(errors.requestPathInvalid); + } + if (normalized.includes('\\') || normalized.includes('#')) { + throw new Error(errors.requestPathInvalid); + } + if (normalized.length > MAX_PATH_LENGTH || hasControlCharacter(normalized)) { + throw new Error(errors.requestPathInvalid); + } + 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(errors.requestMethodInvalid); + } + + 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(errors.requestBodyTooLarge); + } + if ( + args.idempotencyKey && + args.idempotencyKey.length > MAX_IDEMPOTENCY_KEY_LENGTH + ) { + throw new Error(errors.idempotencyKeyTooLong); + } + + 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..44083445f4f --- /dev/null +++ b/spacetime-resend-ts/src/submodule/schema.ts @@ -0,0 +1,283 @@ +import { + schema, + table, + t, + Range, + SenderError, + type ProcedureCtx, + type ReducerCtx, + type TransactionCtx, +} from 'spacetimedb/server'; +import * as v from 'valibot'; +import { install } from './install.js'; + +// Webhook delivery state. Received = ingest accepted. Processed = applied. +// Ignored = event type this submodule does not handle. Failed = invalid payload. +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(), + statusUpdatedAt: t.option(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(), + addedAt: t.timestamp(), +}; + +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'] }, + ], + }, + resendEmailRow +); + +export const resendDeliveryEventTable = table( + { + name: 'resend_delivery_event', + public: false, + indexes: [ + { accessor: 'byResendId', algorithm: 'btree', columns: ['resendId'] }, + ], + }, + 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 => { + install(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 { 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..b249d22d20d --- /dev/null +++ b/spacetime-resend-ts/src/submodule/webhooks.ts @@ -0,0 +1,402 @@ +import * as v from 'valibot'; +import { Timestamp } from 'spacetimedb'; +import { + EmailStatus, + WebhookEventStatus, + spacetimedb, + t, + vEmailEvent, + type EmailEvent, + type EmailStatusValue, + type ModuleTimestamp, + type ReducerModuleCtx, + type WebhookEventStatusValue, + type WriteCtx, +} from './schema.js'; +import { upsertEmail } from './email_writes.js'; +import { requireAdmin } from './auth.js'; +import { verifySvixSignature } from '@spacetimedb/crypto'; +import { + SyncResponse, + type Request, + type HandlerContext, +} from 'spacetimedb/server'; +import { + assertExhaustive, + safeJsonParse, + summarizeIssues, + throwSenderError, +} from './validation.js'; +import { parseResendEventType } from './webhook-metadata.js'; +import { errors } from './errors.js'; + +type ResendTags = EmailEvent['data']['tags']; + +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 }; + 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 !== 'Received'; + 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 tagFields = extractTagFields(data.tags); + const tagsJson = tagsToJson(data.tags); + const subject = data.subject; + + const base = { + resendId: data.email_id, + fromAddress: data.from, + toAddressesJson: JSON.stringify(data.to), + 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); + } +} + +// Resend also sends event types this submodule does not track, such as +// `contact.*`, `domain.*`, and `email.received`. Those are acknowledged as Ignored. +const vHandledEventType = v.object({ + type: v.picklist( + vEmailEvent.options.map(option => option.entries.type.literal) + ), +}); + +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) { + if (!v.is(vHandledEventType, parsed)) { + return { status: WebhookEventStatus.Ignored, error: undefined }; + } + return { + status: WebhookEventStatus.Failed, + error: `payload validation failed: ${summarizeIssues(result.issues)}`, + }; + } + + const event = result.output; + const now = ctx.timestamp; + const eventMillis = Date.parse(event.created_at); + if (!Number.isFinite(eventMillis)) { + return { + status: WebhookEventStatus.Failed, + error: 'invalid event timestamp', + }; + } + const eventTime = new Timestamp(BigInt(eventMillis) * 1000n); + upsertEmail(ctx, now, { + ...makeEmailUpsertArgs(event, eventTime), + statusUpdatedAt: eventTime, + }); + 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; + +type WebhookRejection = { status: number; code: string }; + +// The reducer and HTTP route verify, store, and apply events in one transaction. +// Returns a rejection when the request is refused before anything is written, +// otherwise the stored event status. +function applyResendWebhook( + ctx: WriteCtx, + args: Omit +): WebhookRejection | WebhookEventStatusValue { + if ( + args.eventId.length === 0 || + args.eventId.length > MAX_WEBHOOK_METADATA_LENGTH + ) { + return { status: 400, code: errors.webhookMetadataInvalid }; + } + if (args.payloadJson.length > MAX_WEBHOOK_BODY_LENGTH) { + return { status: 413, code: errors.webhookPayloadTooLarge }; + } + if ( + (args.signatureHeader?.length ?? 0) > MAX_WEBHOOK_HEADER_LENGTH || + (args.timestampHeader?.length ?? 0) > MAX_WEBHOOK_HEADER_LENGTH + ) { + return { status: 400, code: errors.webhookHeaderTooLarge }; + } + + const cfg = ctx.db.resendConfig.singleton.find(true); + if (!cfg?.webhookSigningSecret) { + return { status: 500, code: errors.webhookSecretNotConfigured }; + } + const nowSeconds = Number(ctx.timestamp.microsSinceUnixEpoch / 1_000_000n); + const signature = verifySvixSignature({ + svixId: args.eventId, + svixTimestamp: args.timestampHeader ?? '', + svixSignature: args.signatureHeader ?? '', + rawBody: args.payloadJson, + secret: cfg.webhookSigningSecret, + nowSeconds, + }); + if (!signature.ok) { + return { + status: 401, + code: `${errors.webhookSignatureMismatch}:${signature.reason}`, + }; + } + + const signedEventType = parseResendEventType(args.payloadJson); + if ( + !signedEventType || + signedEventType.length > MAX_WEBHOOK_METADATA_LENGTH + ) { + return { status: 400, code: errors.webhookPayloadInvalid }; + } + + const existing = ctx.db.resendWebhookEvent.eventId.find(args.eventId); + if ( + existing?.status.tag === 'Processed' || + existing?.status.tag === 'Ignored' + ) { + return existing.status; + } + + if (!existing) + 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, + existing?.payloadJson ?? args.payloadJson + ); + updateWebhookStatus( + ctx as ReducerModuleCtx, + args.eventId, + outcome.status, + outcome.error + ); + return outcome.status; +} + +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, { eventType, ...args }) => { + if (eventType !== parseResendEventType(args.payloadJson)) { + throwSenderError(errors.webhookMetadataMismatch); + } + // A Failed event row commits so it can be read and replayed. + const result = applyResendWebhook(ctx, args); + if ('code' in result) 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( + { ok: false, code: errors.webhookMethodNotAllowed }, + 405 + ); + } + + const args = { + eventId: req.headers.get('svix-id') ?? '', + payloadJson: req.text(), + signatureHeader: req.headers.get('svix-signature') ?? undefined, + timestampHeader: req.headers.get('svix-timestamp') ?? undefined, + }; + const result = ctx.withTx(tx => applyResendWebhook(tx as WriteCtx, args)); + if ('code' in result) + return webhookJson({ ok: false, code: result.code }, result.status); + return result.tag === 'Failed' + ? webhookJson({ ok: false, code: errors.webhookPayloadInvalid }, 400) + : webhookJson({ ok: true, code: 'ok' }, 200); + }; +} + +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(`${errors.webhookEventNotFound}:${eventId}`); + const outcome = applyResendEvent(ctx, eventId, event.payloadJson); + updateWebhookStatus(ctx, eventId, outcome.status, outcome.error); + } +); diff --git a/spacetime-resend-ts/tsconfig.build.json b/spacetime-resend-ts/tsconfig.build.json new file mode 100644 index 00000000000..8635027968f --- /dev/null +++ b/spacetime-resend-ts/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/spacetime-resend-ts/tsconfig.json b/spacetime-resend-ts/tsconfig.json new file mode 100644 index 00000000000..d4766a1709a --- /dev/null +++ b/spacetime-resend-ts/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "strict": true, + "declaration": false, + "emitDeclarationOnly": false, + "noEmit": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "noImplicitAny": true, + "moduleResolution": "Bundler", + "isolatedDeclarations": false, + "esModuleInterop": false, + "allowSyntheticDefaultImports": false, + "useDefineForClassFields": true, + "isolatedModules": true + }, + "include": ["src/**/*.ts", "scripts/**/*.ts"], + "exclude": ["node_modules", "dist/**/*"] +}