From fee6f565ffd8618b207450536b5c330281dc6489 Mon Sep 17 00:00:00 2001 From: Agent Date: Thu, 20 Aug 2026 10:55:45 -0400 Subject: [PATCH] Email notifications: settings UI + mail adapter - Add VITE_EMAIL_NOTIFICATION_SERVER env var - Extend notificationSettings with email, emailFrequency, emailAddress - Add emailNotificationState store in push/adapters/common.ts - New src/app/mail/index.ts with MailNotifications class: register/unregister with mailship via NIP-98 auth, publish kind 30390 NIP-9a subscription events - Wired into alerts settings save handler - Email Digest Settings card in alerts UI --- src/app/env.ts | 2 + src/app/mail/index.ts | 162 ++++++++++++++++++++++++ src/app/push/adapters/common.ts | 9 ++ src/app/settings.ts | 3 + src/routes/settings/alerts/+page.svelte | 50 ++++++++ 5 files changed, 226 insertions(+) create mode 100644 src/app/mail/index.ts diff --git a/src/app/env.ts b/src/app/env.ts index 1d9a02de..40b5e3ec 100644 --- a/src/app/env.ts +++ b/src/app/env.ts @@ -16,6 +16,8 @@ export const PUSH_SERVER = env("VITE_PUSH_SERVER") export const PUSH_BRIDGE = normalizeRelayUrl(env("VITE_PUSH_BRIDGE")) +export const EMAIL_NOTIFICATION_SERVER = import.meta.env.VITE_EMAIL_NOTIFICATION_SERVER + export const ENABLE_ZAPS = Capacitor.getPlatform() != "ios" export const SIGNER_RELAYS = fromCsv(env("VITE_SIGNER_RELAYS")).map(normalizeRelayUrl) diff --git a/src/app/mail/index.ts b/src/app/mail/index.ts new file mode 100644 index 00000000..291c5023 --- /dev/null +++ b/src/app/mail/index.ts @@ -0,0 +1,162 @@ +import {hash, maybe} from "@welshman/lib" +import type {Filter} from "@welshman/util" +import {Address, DELETE, makeEvent, makeHttpAuth, makeHttpAuthHeader} from "@welshman/util" +import {Relays, User} from "@welshman/app" +import {buildUrl} from "@lib/util" +import {app, thunks} from "@app/core" +import {device} from "@app/device" +import {EMAIL_NOTIFICATION_SERVER, PUSH_BRIDGE} from "@app/env" +import {emailNotificationState, syncRelaySubscriptions} from "@app/push/adapters/common" + +export class MailNotifications { + _controller = maybe() + + async register(email: string, frequency: string) { + const $app = app.get() + const $pubkey = User.require($app).pubkey + const $signer = $app.signer + + if (!$signer) throw new Error("No signer available") + + const authEvent = await $signer.sign(await makeHttpAuth(EMAIL_NOTIFICATION_SERVER, "POST")) + const authHeader = makeHttpAuthHeader(authEvent) + + const res = await fetch(buildUrl(EMAIL_NOTIFICATION_SERVER, "subscription", "email"), { + method: "POST", + body: JSON.stringify({email, frequency, pubkey: $pubkey}), + headers: {Authorization: authHeader, "Content-Type": "application/json"}, + }) + + if (!res.ok) throw new Error(`Registration failed: ${await res.text()}`) + + const {key, callback} = await res.json() + emailNotificationState.set({key, callback, email, frequency}) + } + + async unregister() { + const {key} = emailNotificationState.get() + if (!key) return + + const $app = app.get() + const $signer = $app.signer + if (!$signer) return + + try { + const authEvent = await $signer.sign(await makeHttpAuth(EMAIL_NOTIFICATION_SERVER, "DELETE")) + const authHeader = makeHttpAuthHeader(authEvent) + + await fetch(buildUrl(EMAIL_NOTIFICATION_SERVER, "subscription", key), { + method: "DELETE", + headers: {Authorization: authHeader}, + }) + } catch { + // Best effort + } + + emailNotificationState.set({}) + } + + async enable() { + if (this._controller) return + + const {email, frequency} = emailNotificationState.get() + + if (!EMAIL_NOTIFICATION_SERVER) { + console.warn("Email notification server not configured") + return + } + + this._controller = new AbortController() + + // Register if we don't have a subscription yet + if (!emailNotificationState.get().callback && email && frequency) { + await this.register(email, frequency) + } + + // Sync kind 30390 subscriptions using the same filters as push + syncRelaySubscriptions( + this._controller.signal, + async (url: string, key: string, filters: Filter[], ignore: Filter[]) => { + const {callback} = emailNotificationState.get() + if (!callback) return + + if (filters.length > 0) { + await this._syncRelay(url, key, filters, ignore) + } else { + await this._unsyncRelay(url, key) + } + }, + ) + } + + async disable() { + this._controller?.abort() + this._controller = undefined + + await this.unregister() + } + + _getSubscriptionIdentifier = (relay: string, key: string) => + String(hash(relay + key + device.get() + "email")) + + _getPushUrl = async (url: string) => { + for (const candidate of [url, PUSH_BRIDGE]) { + const relay = await app.get().use(Relays).load(candidate) + + if (relay?.hasNip("9a")) { + return candidate + } + } + } + + _syncRelay = async (relay: string, key: string, filters: Filter[], ignore: Filter[] = []) => { + const {callback} = emailNotificationState.get() + if (!callback) return + + const url = await this._getPushUrl(relay) + if (!url) { + console.warn(`Failed to subscribe ${relay} to email notifications: unsupported`) + return + } + + const identifier = this._getSubscriptionIdentifier(relay, key) + + const thunk = thunks.get().publish({ + relays: [url], + event: makeEvent(30390, { + tags: [ + ["d", identifier], + ["relay", relay], + ["callback", callback], + ...ignore.map(f => ["ignore", JSON.stringify(f)]), + ...filters.map(f => ["filter", JSON.stringify(f)]), + ], + }), + }) + + const error = await thunk.waitForError() + if (error) { + console.warn(`Failed to subscribe ${relay} to email notifications:`, error) + } + } + + _unsyncRelay = async (relay: string, key: string) => { + const url = await this._getPushUrl(relay) + if (!url) return + + const $pubkey = User.require(app.get()).pubkey + const identifier = this._getSubscriptionIdentifier(relay, key) + const address = new Address(30390, $pubkey, identifier).toString() + const event = makeEvent(DELETE, {tags: [["a", address]]}) + const error = await thunks + .get() + .publish({relays: [url], event}) + .waitForError() + + if (error) { + console.warn(`Failed to unsubscribe ${relay} from email notifications:`, error) + } + } +} + +export const Mail = new MailNotifications() diff --git a/src/app/push/adapters/common.ts b/src/app/push/adapters/common.ts index 84c5fc22..db78785f 100644 --- a/src/app/push/adapters/common.ts +++ b/src/app/push/adapters/common.ts @@ -40,6 +40,15 @@ export type PushState = { export const pushState = withGetter(writable({})) +export type EmailNotificationState = { + key?: string + callback?: string + email?: string + frequency?: string +} + +export const emailNotificationState = withGetter(writable({})) + export interface IPushAdapter { request: (prompt?: boolean) => Promise disable: () => Promise diff --git a/src/app/settings.ts b/src/app/settings.ts index 5d97d1ca..fa342dca 100644 --- a/src/app/settings.ts +++ b/src/app/settings.ts @@ -97,6 +97,9 @@ export const notificationSettings = withGetter( spaces: true, mentions: true, messages: true, + email: false, + emailFrequency: "daily", + emailAddress: "", }), ) diff --git a/src/routes/settings/alerts/+page.svelte b/src/routes/settings/alerts/+page.svelte index 42a0097e..b163ece7 100644 --- a/src/routes/settings/alerts/+page.svelte +++ b/src/routes/settings/alerts/+page.svelte @@ -14,6 +14,7 @@ import {pushToast} from "@app/toast" import {clearBadges} from "@app/notifications" import {Push} from "@app/push" + import {Mail} from "@app/mail" import {notificationSettings} from "@app/settings" const reset = () => { @@ -45,6 +46,19 @@ } } + // Handle email notification registration + if (settings.email && settings.emailAddress) { + try { + await Mail.register(settings.emailAddress, settings.emailFrequency) + await Mail.enable() + } catch (e) { + console.warn("Failed to enable email notifications:", e) + pushToast({theme: "error", message: "Failed to enable email notifications."}) + } + } else if (!settings.email) { + await Mail.disable() + } + notificationSettings.set(settings) pushToast({message: "Your settings have been saved!"}) @@ -127,6 +141,42 @@ {/snippet} +
+ Email Digest Settings + + {#snippet label()} +

Receive email digest of missed activity

+ {/snippet} + {#snippet input()} + + {/snippet} +
+ {#if settings.email} + + {#snippet label()} +

Email address

+ {/snippet} + {#snippet input()} + + {/snippet} +
+ + {#snippet label()} +

Frequency

+ {/snippet} + {#snippet input()} + + {/snippet} +
+ {/if} +