From 451ced9aa894eccac19489ad70245f2b8d2ec309 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Diamond?= <32074058+Andre-Diamond@users.noreply.github.com> Date: Wed, 17 Jun 2026 08:01:19 +0200 Subject: [PATCH 1/5] feat: implement email notification system for wallet signatures - Add notification center for handling signature required notifications. - Create email channel for sending notifications via Resend. - Define types for email messages and send results. - Implement event types and notification statuses for email notifications. - Create outbox for managing notification deliveries. - Resolve signature recipients based on notification settings. - Develop email templates for signature required and email verification notifications. - Implement worker to drain notification outbox and send emails. - Create API endpoints for draining notifications and verifying email addresses. - Add TRPC router for managing wallet signer notification settings and sending reminders. --- .env.example | 7 + .gitignore | 1 + docs/notification-center-plan.md | 446 ++++++++++++++++++ package-lock.json | 70 ++- package.json | 1 + .../migration.sql | 97 ++++ prisma/schema.prisma | 61 +++ src/__tests__/notifications.test.ts | 125 +++++ src/components/pages/wallet/info/index.tsx | 2 + .../info/wallet-notification-settings.tsx | 242 ++++++++++ src/env.js | 15 +- src/lib/notifications/center.ts | 194 ++++++++ .../notifications/channels/email/resend.ts | 44 ++ src/lib/notifications/channels/email/types.ts | 11 + src/lib/notifications/events.ts | 51 ++ src/lib/notifications/outbox.ts | 47 ++ src/lib/notifications/recipients.ts | 125 +++++ src/lib/notifications/templates/shared.ts | 74 +++ .../templates/signatureRequired.ts | 66 +++ .../notifications/templates/verifyEmail.ts | 39 ++ src/lib/notifications/worker.ts | 118 +++++ .../createPendingMultisigTransaction.ts | 32 +- src/pages/api/notifications/drain.ts | 45 ++ src/pages/api/notifications/email/verify.ts | 98 ++++ src/pages/api/v1/submitDatum.ts | 27 ++ src/server/api/root.ts | 2 + src/server/api/routers/notifications.ts | 330 +++++++++++++ src/server/api/routers/signable.ts | 21 +- src/server/api/routers/transactions.ts | 31 +- 29 files changed, 2397 insertions(+), 25 deletions(-) create mode 100644 docs/notification-center-plan.md create mode 100644 prisma/migrations/20260617070000_add_notification_center/migration.sql create mode 100644 src/__tests__/notifications.test.ts create mode 100644 src/components/pages/wallet/info/wallet-notification-settings.tsx create mode 100644 src/lib/notifications/center.ts create mode 100644 src/lib/notifications/channels/email/resend.ts create mode 100644 src/lib/notifications/channels/email/types.ts create mode 100644 src/lib/notifications/events.ts create mode 100644 src/lib/notifications/outbox.ts create mode 100644 src/lib/notifications/recipients.ts create mode 100644 src/lib/notifications/templates/shared.ts create mode 100644 src/lib/notifications/templates/signatureRequired.ts create mode 100644 src/lib/notifications/templates/verifyEmail.ts create mode 100644 src/lib/notifications/worker.ts create mode 100644 src/pages/api/notifications/drain.ts create mode 100644 src/pages/api/notifications/email/verify.ts create mode 100644 src/server/api/routers/notifications.ts diff --git a/.env.example b/.env.example index 6369a1d8..3a8a0bf2 100644 --- a/.env.example +++ b/.env.example @@ -58,3 +58,10 @@ NEXT_PUBLIC_BLOCKFROST_API_KEY_PREPROD="your-blockfrost-preprod-api-key" # DISCORD_CLIENT_SECRET="your-discord-client-secret" # DISCORD_BOT_TOKEN="your-discord-bot-token" # DISCORD_GUILD_ID="your-discord-guild-id" + +# Optional: Email notifications via Resend +# RESEND_API_KEY="re_..." +# EMAIL_FROM="Mesh Multisig " +# EMAIL_REPLY_TO="support@your-domain.example" +# NOTIFICATION_DRAIN_SECRET="your-notification-drain-secret" +# NOTIFICATIONS_EMAIL_ENABLED="false" diff --git a/.gitignore b/.gitignore index 2c85f24b..870d4384 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,7 @@ yarn-error.log* # do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables .env .env*.local +.env.playwright # bot ref client config (contains secret) scripts/bot-ref/bot-config.json diff --git a/docs/notification-center-plan.md b/docs/notification-center-plan.md new file mode 100644 index 00000000..1ad88681 --- /dev/null +++ b/docs/notification-center-plan.md @@ -0,0 +1,446 @@ +# Notification Center Implementation Plan + +## Goal + +Build a reusable notification center that can send email notifications when a wallet signer needs to act, starting with "signature required" notifications for pending multisig transactions and signable datum payloads. + +The first channel will be email via Resend. A signer only receives email if they have provided and verified an email address, so notification delivery is opt-in and does not block wallet or transaction creation. + +## Current Codebase Context + +- Wallet signer data is currently stored as parallel arrays on `Wallet` and `NewWallet` in `prisma/schema.prisma`: `signersAddresses`, `signersStakeKeys`, `signersDRepKeys`, and `signersDescriptions`. +- User records in `User` are keyed by wallet `address` and currently include `stakeAddress`, `drepKeyHash`, optional `nostrKey`, and `discordId`, but no email field. +- Pending transaction creation happens in multiple places: + - `src/hooks/useTransaction.ts` for in-app transaction creation. + - `src/server/api/routers/transactions.ts` for tRPC create/import. + - `src/lib/server/createPendingMultisigTransaction.ts` for server/API/bot/proxy transaction builders. + - `src/pages/api/v1/addTransaction.ts` for external transaction submission. +- Pending datum/signable creation happens through: + - `src/server/api/routers/signable.ts`. + - `src/pages/api/v1/submitDatum.ts`. +- Existing reminders are Discord-only and client-triggered: + - `src/components/pages/wallet/transactions/transaction-card.tsx`. + - `src/components/pages/wallet/signing/signable-card.tsx`. + - `src/components/pages/wallet/new-transaction/index.tsx`. +- Existing observability uses append-only `AuditLog`; notification delivery should follow the same audit-friendly posture. + +## Design Principles + +- Reusable first: notification orchestration should not know about Resend directly. +- Server-owned delivery: do not send transactional notifications from React components. +- Non-blocking: transaction/signable creation should succeed even if notification dispatch fails. +- Idempotent: one event-recipient-channel combination should not send duplicate emails. +- Consent-aware: only verified or explicit opt-in email addresses receive messages. +- Email-client realistic: HTML emails should include a plain text fallback and avoid decorative assets in the first version. + +## Phase 1: Email Identity and Preferences + +### Data model + +Add signer notification metadata without adding more parallel arrays to `Wallet`. + +Recommended Prisma models: + +```prisma +model SignerNotificationProfile { + id String @id @default(cuid()) + address String @unique + email String? + emailNormalized String? + emailVerifiedAt DateTime? + emailOptIn Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([emailNormalized]) +} + +model NotificationPreference { + id String @id @default(cuid()) + address String + eventType String + channel String + enabled Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([address, eventType, channel]) + @@index([address]) +} +``` + +Optional later model if wallet-specific routing is needed: + +```prisma +model WalletSignerNotificationSetting { + walletId String + signerAddress String + eventType String + channel String + enabled Boolean @default(true) + + @@id([walletId, signerAddress, eventType, channel]) + @@index([signerAddress]) + @@index([walletId]) +} +``` + +Why not add `signersEmails String[]`: + +- The existing signer arrays already need index alignment. Adding another parallel array would make wallet updates and imports more fragile. +- Email belongs to a signer identity and can be reused across wallets. +- Verification, opt-in, and unsubscribe state do not belong in `Wallet`. + +### User model relationship + +Do not rely only on `User.email`. + +`User` is only present for onboarded wallet users. Some signers can exist in wallet arrays before they have joined or created a user record. `SignerNotificationProfile` lets the notification center resolve email by signer address even before a full `User` profile exists. + +If desired, add `email` and `emailVerifiedAt` to `User` too, but treat `SignerNotificationProfile` as the delivery source of truth and keep it synchronized when the current user changes email. + +### Email verification + +Add a simple verification token model: + +```prisma +model EmailVerificationToken { + id String @id @default(cuid()) + address String + emailNormalized String + tokenHash String @unique + expiresAt DateTime + consumedAt DateTime? + createdAt DateTime @default(now()) + + @@index([address]) + @@index([expiresAt]) +} +``` + +Flow: + +1. Signer enters an email in their user/profile page or during invite acceptance. +2. Server validates and normalizes it. +3. Server stores a pending token hash and sends a verification email. +4. Clicking `/api/notifications/email/verify?token=...` sets `emailVerifiedAt`. +5. Only verified emails are eligible for signature-required notifications. + +## Phase 2: Notification Outbox + +Add an outbox so notification creation and notification delivery are separate concerns. + +Recommended Prisma model: + +```prisma +model NotificationDelivery { + id String @id @default(cuid()) + eventType String + channel String + recipientAddress String + recipientEmail String? + resourceType String + resourceId String + walletId String? + idempotencyKey String @unique + subject String + payload Json + status String @default("pending") + provider String? + providerMessageId String? + attempts Int @default(0) + lastError String? + nextAttemptAt DateTime @default(now()) + sentAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([status, nextAttemptAt]) + @@index([recipientAddress]) + @@index([walletId]) + @@index([resourceType, resourceId]) +} +``` + +Postgres notes: + +- Keep `idempotencyKey` unique so retries and concurrent trigger paths cannot duplicate sends. +- Index `status, nextAttemptAt` because the worker/drain endpoint will repeatedly query pending rows. +- Index recipient, wallet, and resource columns because the notification center UI will filter by those fields. +- Consider a partial index for pending rows in a SQL migration if Prisma does not express the exact desired index well: + +```sql +create index "NotificationDelivery_pending_idx" +on "NotificationDelivery" ("nextAttemptAt", "createdAt") +where "status" in ('pending', 'retrying'); +``` + +## Phase 3: Notification Library Layout + +Create a reusable module under `src/lib/notifications/`. + +Recommended structure: + +```text +src/lib/notifications/ + center.ts + events.ts + recipients.ts + outbox.ts + templates/ + shared.ts + signatureRequired.ts + verifyEmail.ts + channels/ + email/ + resend.ts + types.ts + worker.ts +``` + +Responsibilities: + +- `events.ts`: event names, payload types, and resource metadata. +- `recipients.ts`: resolve signer addresses to verified email profiles and preferences. +- `outbox.ts`: create idempotent `NotificationDelivery` rows. +- `center.ts`: public API used by wallet, transaction, signable, and future features. +- `templates/*`: pure functions that return `{ subject, html, text }`. +- `channels/email/resend.ts`: the only place that imports the Resend SDK. +- `worker.ts`: drains pending deliveries, handles retry/backoff, and records provider responses. + +Public API sketch: + +```ts +await notificationCenter.enqueueSignatureRequired({ + walletId, + walletName, + resourceType: "transaction", + resourceId: transaction.id, + requiredSignerAddresses, + alreadySignedAddresses, + createdByAddress, + actionUrl, + description, +}); +``` + +The reusable lower-level API should support future features: + +```ts +await notificationCenter.enqueue({ + eventType: "signature.required", + channel: "email", + recipientAddress, + resourceType, + resourceId, + walletId, + payload, +}); +``` + +## Phase 4: Resend Integration + +Install: + +```bash +npm install resend +``` + +Add server env vars in `src/env.js` and `.env.example`: + +```text +RESEND_API_KEY= +EMAIL_FROM="Mesh Multisig " +EMAIL_REPLY_TO= +NOTIFICATION_DRAIN_SECRET= +``` + +Implementation details: + +- Use `new Resend(env.RESEND_API_KEY)` inside `src/lib/notifications/channels/email/resend.ts`. +- Send with `from`, `to`, `subject`, `html`, and `text`. +- Store Resend's returned message id on `NotificationDelivery.providerMessageId`. +- Use outbox-level idempotency before provider calls. If the Resend SDK supports passing an HTTP idempotency header in the current version at implementation time, also pass the delivery idempotency key to Resend. +- Tag messages when possible with stable ASCII tags such as `event:signature_required`, `resource:transaction`, and `wallet:` for provider-side filtering. + +## Phase 5: Signature Required Triggering + +### Recipient resolver + +For each wallet action, compute: + +```ts +requiredSignerAddresses = + wallet.signersAddresses + .filter((address) => !signedAddresses.includes(address)) + .filter((address) => !rejectedAddresses.includes(address)) + .filter((address) => address !== createdByAddress); +``` + +Notes: + +- For `type === "any"` or `numRequiredSigners === 1`, there may be no pending notification because the transaction can submit immediately. +- For `atLeast`, notify unsigned signers until the threshold is met. After threshold completion and submission, do not send further reminders. +- For `all`, notify every unsigned signer. + +### Hook points + +Add notification enqueue calls after a pending row is successfully created. + +Primary hook points: + +- `src/lib/server/createPendingMultisigTransaction.ts`: notify for server-built pending transactions used by bot, proxy, staking, governance, and v1 flows. +- `src/server/api/routers/transactions.ts`: notify for tRPC-created and imported pending transactions. Longer term, consider routing all creation through the server helper so this is not duplicated. +- `src/pages/api/v1/addTransaction.ts`: if it does not route through the helper in every pending path, add notification enqueue after DB create. +- `src/server/api/routers/signable.ts`: notify for `createSignable` when `state === 0`. +- `src/pages/api/v1/submitDatum.ts`: notify for API-created `Signable` rows. + +Avoid adding new email logic to: + +- `src/components/pages/wallet/new-transaction/index.tsx` +- `src/components/pages/wallet/transactions/transaction-card.tsx` +- `src/components/pages/wallet/signing/signable-card.tsx` + +Those should eventually call a server "send reminder" mutation or endpoint if manual reminders remain. + +## Phase 6: Notification Center UI + +Add a notification/preferences section to `src/pages/user/index.tsx`. + +Minimum UI: + +- Email address field. +- Verification status. +- "Send verification email" action. +- Channel preferences: + - Signature required for transactions. + - Signature required for datum/signable payloads. +- Opt out action. + +Add a wallet-level notification center later under wallet info if needed: + +- Show which signers have email enabled without revealing full emails to other signers. +- Show delivery status for recent notifications: + - pending + - sent + - retrying + - failed + - skipped-no-email + - skipped-not-verified + - skipped-opted-out + +## Phase 7: Email Template Design + +Use HTML emails with table-safe layout and inline styles. + +Template requirements: + +- `subject`: clear and action-oriented, for example `Signature required: `. +- `text`: plain text fallback with wallet name, action link, and why the user is receiving it. +- `html`: branded transactional email with: + - wallet name + - resource type + - transaction/signable description if present + - signer progress, for example `1 of 3 signatures collected` + - CTA button to open `/wallets//transactions` or `/wallets//signing` + - unsubscribe/preferences link + +Keep the first version simple: + +- Use HTML and inline CSS only. +- Do not include animated SVGs or embedded SVG markup. +- Do not include JavaScript or external decorative assets. +- Keep all critical CTA content as readable HTML text/buttons. +- Test in Gmail, Apple Mail, and Outlook before production rollout. + +Suggested template files: + +```text +src/lib/notifications/templates/signatureRequired.ts +src/lib/notifications/templates/verifyEmail.ts +src/lib/notifications/templates/shared.ts +``` + +## Phase 8: Delivery Worker + +Start simple: + +- After enqueueing notifications, call a best-effort `drainNotificationOutbox({ limit: 10 })` server-side. +- Add `src/pages/api/notifications/drain.ts` protected by `NOTIFICATION_DRAIN_SECRET`. +- Configure a scheduled job later to call the drain endpoint every few minutes. + +Retry behavior: + +- Attempt 1 immediately. +- Retry after 5 minutes, 30 minutes, then 2 hours. +- Mark `failed` after a small max attempt count, for example 5. +- Store short error strings only; do not store provider secrets or full request bodies. + +## Phase 9: Manual Reminders + +Replace Discord-only client reminders with a server endpoint/mutation: + +```ts +api.notification.sendSignatureReminder.useMutation(...) +``` + +Rules: + +- Caller must be a wallet signer or owner. +- Recipient must be a wallet signer. +- Recipient must still need to sign. +- Apply rate limits per `walletId + resourceId + recipientAddress`. +- Enqueue `signature.reminder` using the same channel/template stack. + +Keep Discord as optional later channel if desired, but route it through the same notification center rather than calling `sendDiscordMessage` directly from components. + +## Phase 10: Testing + +Unit tests: + +- recipient resolution excludes already signed, rejected, and creator addresses. +- unverified email is skipped. +- opted-out signer is skipped. +- idempotency key prevents duplicate delivery rows. +- email templates escape dynamic values and include text fallback. + +Integration tests: + +- pending tRPC transaction creates notification deliveries. +- server-built pending transaction through `createPendingMultisigTransaction` creates notification deliveries. +- `submitDatum` creates signable notification deliveries. +- drain worker calls the Resend adapter once per pending delivery and records message ids. +- Resend failure moves row to retrying without breaking transaction creation. + +Manual QA: + +- Create a 2-of-3 wallet with one signer email verified and one missing email. +- Create a pending transaction. +- Confirm one email delivery and one skipped/no-email outcome. +- Sign with another signer until threshold is met. +- Confirm no more signature-required notifications are created. +- Open the email in Gmail, Apple Mail, and Outlook. + +## Phase 11: Rollout + +1. Add schema and env validation. +2. Add profile/preference UI and email verification. +3. Add outbox and Resend adapter behind feature flag `NOTIFICATIONS_EMAIL_ENABLED`. +4. Add transaction/signable enqueue hooks. +5. Enable drain endpoint in staging. +6. Send test notifications from staging domain. +7. Enable production for verified internal/test signers. +8. Remove or migrate client-side Discord reminder calls after email path is stable. + +## Open Questions + +- Should wallet creators be allowed to enter another signer's email, or should emails only be entered and verified by the signer themselves? +- Should emails be global per signer address or configurable per wallet? +- Should notification history be visible to all wallet signers or only to the recipient/current user? +- What production sending domain should be verified in Resend? +- Should Discord remain as a supported channel after email launches? + +## External References + +- Resend Node.js quickstart: https://resend.com/docs/send-with-nodejs +- Resend Send Email API: https://resend.com/docs/api-reference/emails/send-email diff --git a/package-lock.json b/package-lock.json index 002958a7..9c06738b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63,6 +63,7 @@ "react-dropzone": "^14.3.5", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", + "resend": "^6.12.4", "superjson": "^2.2.1", "swagger-jsdoc": "^6.2.8", "swagger-ui-react": "^5.22.0", @@ -245,17 +246,6 @@ } } }, - "node_modules/@auth/prisma-adapter/node_modules/@simplewebauthn/browser": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-9.0.1.tgz", - "integrity": "sha512-wD2WpbkaEP4170s13/HUxPcAV5y4ZXaKo1TfNklS5zDefPinIgXOpgz1kpEvobAsaLPa2KeH7AKKX/od1mrBJw==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@simplewebauthn/types": "^9.0.1" - } - }, "node_modules/@auth/prisma-adapter/node_modules/jose": { "version": "6.2.3", "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", @@ -6739,15 +6729,6 @@ "integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==", "license": "MIT" }, - "node_modules/@simplewebauthn/types": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/@simplewebauthn/types/-/types-9.0.1.tgz", - "integrity": "sha512-tGSRP1QvsAvsJmnOlRQyw/mvK9gnPtjEc5fg2+m8n+QUa+D7rvrKkOYyfpy42GTs90X3RDOnqJgfHt+qO67/+w==", - "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/@sinclair/typebox": { "version": "0.34.49", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", @@ -6775,6 +6756,12 @@ "@sinonjs/commons": "^3.0.1" } }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -12420,6 +12407,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/fast-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", @@ -19634,6 +19627,12 @@ "node": ">= 0.4" } }, + "node_modules/postal-mime": { + "version": "2.7.4", + "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.4.tgz", + "integrity": "sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==", + "license": "MIT-0" + }, "node_modules/postcss": { "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", @@ -20896,6 +20895,27 @@ "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", "license": "MIT" }, + "node_modules/resend": { + "version": "6.12.4", + "resolved": "https://registry.npmjs.org/resend/-/resend-6.12.4.tgz", + "integrity": "sha512-lRpJ2Hxd+ht+JPDm97juRcUp9HOMuZyxaRFRFmc9Tx8iNWiei94Dx9v6SWufgKk2667C/uCeKKspMotOHSpCSg==", + "license": "MIT", + "dependencies": { + "postal-mime": "2.7.4", + "standardwebhooks": "1.0.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@react-email/render": "*" + }, + "peerDependenciesMeta": { + "@react-email/render": { + "optional": true + } + } + }, "node_modules/resolve": { "version": "2.0.0-next.7", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", @@ -21563,6 +21583,16 @@ "node": ">=8" } }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/stats-gl": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/stats-gl/-/stats-gl-2.4.2.tgz", diff --git a/package.json b/package.json index c2c0c07c..808a7d37 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,7 @@ "react-dropzone": "^14.3.5", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", + "resend": "^6.12.4", "superjson": "^2.2.1", "swagger-jsdoc": "^6.2.8", "swagger-ui-react": "^5.22.0", diff --git a/prisma/migrations/20260617070000_add_notification_center/migration.sql b/prisma/migrations/20260617070000_add_notification_center/migration.sql new file mode 100644 index 00000000..9b7fb7f0 --- /dev/null +++ b/prisma/migrations/20260617070000_add_notification_center/migration.sql @@ -0,0 +1,97 @@ +-- CreateTable +CREATE TABLE "WalletSignerNotificationSetting" ( + "id" TEXT NOT NULL, + "walletId" TEXT NOT NULL, + "signerAddress" TEXT NOT NULL, + "email" TEXT, + "emailNormalized" TEXT, + "emailVerifiedAt" TIMESTAMP(3), + "emailOptIn" BOOLEAN NOT NULL DEFAULT true, + "notifyTransactionSignatures" BOOLEAN NOT NULL DEFAULT true, + "notifySignableSignatures" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "WalletSignerNotificationSetting_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "EmailVerificationToken" ( + "id" TEXT NOT NULL, + "walletId" TEXT NOT NULL, + "signerAddress" TEXT NOT NULL, + "emailNormalized" TEXT NOT NULL, + "tokenHash" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "consumedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "EmailVerificationToken_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "NotificationDelivery" ( + "id" TEXT NOT NULL, + "eventType" TEXT NOT NULL, + "channel" TEXT NOT NULL, + "recipientAddress" TEXT NOT NULL, + "recipientEmail" TEXT, + "resourceType" TEXT NOT NULL, + "resourceId" TEXT NOT NULL, + "walletId" TEXT, + "idempotencyKey" TEXT NOT NULL, + "subject" TEXT NOT NULL, + "payload" JSONB NOT NULL, + "status" TEXT NOT NULL DEFAULT 'pending', + "provider" TEXT, + "providerMessageId" TEXT, + "attempts" INTEGER NOT NULL DEFAULT 0, + "lastError" TEXT, + "nextAttemptAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "sentAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "NotificationDelivery_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletSignerNotificationSetting_walletId_signerAddress_key" ON "WalletSignerNotificationSetting"("walletId", "signerAddress"); + +-- CreateIndex +CREATE INDEX "WalletSignerNotificationSetting_walletId_idx" ON "WalletSignerNotificationSetting"("walletId"); + +-- CreateIndex +CREATE INDEX "WalletSignerNotificationSetting_signerAddress_idx" ON "WalletSignerNotificationSetting"("signerAddress"); + +-- CreateIndex +CREATE INDEX "WalletSignerNotificationSetting_emailNormalized_idx" ON "WalletSignerNotificationSetting"("emailNormalized"); + +-- CreateIndex +CREATE UNIQUE INDEX "EmailVerificationToken_tokenHash_key" ON "EmailVerificationToken"("tokenHash"); + +-- CreateIndex +CREATE INDEX "EmailVerificationToken_walletId_signerAddress_idx" ON "EmailVerificationToken"("walletId", "signerAddress"); + +-- CreateIndex +CREATE INDEX "EmailVerificationToken_expiresAt_idx" ON "EmailVerificationToken"("expiresAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "NotificationDelivery_idempotencyKey_key" ON "NotificationDelivery"("idempotencyKey"); + +-- CreateIndex +CREATE INDEX "NotificationDelivery_status_nextAttemptAt_idx" ON "NotificationDelivery"("status", "nextAttemptAt"); + +-- CreateIndex +CREATE INDEX "NotificationDelivery_recipientAddress_idx" ON "NotificationDelivery"("recipientAddress"); + +-- CreateIndex +CREATE INDEX "NotificationDelivery_walletId_idx" ON "NotificationDelivery"("walletId"); + +-- CreateIndex +CREATE INDEX "NotificationDelivery_resourceType_resourceId_idx" ON "NotificationDelivery"("resourceType", "resourceId"); + +-- CreateIndex +CREATE INDEX "NotificationDelivery_pending_idx" +ON "NotificationDelivery" ("nextAttemptAt", "createdAt") +WHERE "status" IN ('pending', 'retrying'); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f753e5b2..5bbc49bf 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -211,6 +211,67 @@ model Contact { @@index([address]) } +model WalletSignerNotificationSetting { + id String @id @default(cuid()) + walletId String + signerAddress String + email String? + emailNormalized String? + emailVerifiedAt DateTime? + emailOptIn Boolean @default(true) + notifyTransactionSignatures Boolean @default(true) + notifySignableSignatures Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([walletId, signerAddress]) + @@index([walletId]) + @@index([signerAddress]) + @@index([emailNormalized]) +} + +model EmailVerificationToken { + id String @id @default(cuid()) + walletId String + signerAddress String + emailNormalized String + tokenHash String @unique + expiresAt DateTime + consumedAt DateTime? + createdAt DateTime @default(now()) + + @@index([walletId, signerAddress]) + @@index([expiresAt]) +} + +model NotificationDelivery { + id String @id @default(cuid()) + eventType String + channel String + recipientAddress String + recipientEmail String? + resourceType String + resourceId String + walletId String? + idempotencyKey String @unique + subject String + payload Json + status String @default("pending") + provider String? + providerMessageId String? + attempts Int @default(0) + lastError String? + nextAttemptAt DateTime @default(now()) + sentAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([status, nextAttemptAt]) + @@index([recipientAddress]) + @@index([walletId]) + @@index([resourceType, resourceId]) +} + model BotKey { id String @id @default(cuid()) ownerAddress String // Human creator diff --git a/src/__tests__/notifications.test.ts b/src/__tests__/notifications.test.ts new file mode 100644 index 00000000..ac8803ef --- /dev/null +++ b/src/__tests__/notifications.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it, jest } from "@jest/globals"; + +import { + NOTIFICATION_STATUS_SKIPPED_DISABLED, + NOTIFICATION_STATUS_SKIPPED_NO_EMAIL, + NOTIFICATION_STATUS_SKIPPED_NOT_VERIFIED, + NOTIFICATION_STATUS_SKIPPED_OPTED_OUT, +} from "@/lib/notifications/events"; +import { resolveSignatureRecipients } from "@/lib/notifications/recipients"; +import { renderSignatureRequiredEmail } from "@/lib/notifications/templates/signatureRequired"; + +describe("notification recipient resolution", () => { + it("returns only verified opted-in signers that still need to sign", async () => { + const db = { + walletSignerNotificationSetting: { + findMany: jest.fn(async () => [ + { + signerAddress: "addr_verified", + email: "Signer@Example.com", + emailNormalized: "signer@example.com", + emailVerifiedAt: new Date(), + emailOptIn: true, + notifyTransactionSignatures: true, + notifySignableSignatures: true, + }, + { + signerAddress: "addr_unverified", + email: "unverified@example.com", + emailNormalized: "unverified@example.com", + emailVerifiedAt: null, + emailOptIn: true, + notifyTransactionSignatures: true, + notifySignableSignatures: true, + }, + { + signerAddress: "addr_opted_out", + email: "out@example.com", + emailNormalized: "out@example.com", + emailVerifiedAt: new Date(), + emailOptIn: false, + notifyTransactionSignatures: true, + notifySignableSignatures: true, + }, + { + signerAddress: "addr_disabled", + email: "disabled@example.com", + emailNormalized: "disabled@example.com", + emailVerifiedAt: new Date(), + emailOptIn: true, + notifyTransactionSignatures: false, + notifySignableSignatures: true, + }, + ]), + }, + }; + + const result = await resolveSignatureRecipients(db as any, { + walletId: "wallet_1", + signerAddresses: [ + "addr_creator", + "addr_signed", + "addr_rejected", + "addr_verified", + "addr_unverified", + "addr_opted_out", + "addr_disabled", + "addr_missing", + ], + resourceType: "transaction", + signedAddresses: ["addr_signed"], + rejectedAddresses: ["addr_rejected"], + creatorAddress: "addr_creator", + }); + + expect(result.eligible).toEqual([ + { + address: "addr_verified", + email: "Signer@Example.com", + emailNormalized: "signer@example.com", + }, + ]); + expect(result.skipped).toEqual( + expect.arrayContaining([ + { + address: "addr_unverified", + reason: NOTIFICATION_STATUS_SKIPPED_NOT_VERIFIED, + }, + { + address: "addr_opted_out", + reason: NOTIFICATION_STATUS_SKIPPED_OPTED_OUT, + }, + { + address: "addr_disabled", + reason: NOTIFICATION_STATUS_SKIPPED_DISABLED, + }, + { + address: "addr_missing", + reason: NOTIFICATION_STATUS_SKIPPED_NO_EMAIL, + }, + ]), + ); + }); +}); + +describe("notification email templates", () => { + it("escapes dynamic values and returns html plus text bodies", () => { + const email = renderSignatureRequiredEmail({ + walletName: "", + resourceType: "transaction", + description: "", + signedCount: 1, + requiredCount: 2, + totalSigners: 3, + actionUrl: "https://example.com/sign?x=", + preferencesUrl: "https://example.com/preferences", + }); + + expect(email.subject).toBe("Signature required: "); + expect(email.html).toContain("<Vault>"); + expect(email.html).toContain("<script>alert('x')</script>"); + expect(email.html).not.toContain("