diff --git a/Dockerfile b/Dockerfile index 79880306..17391bb8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,6 +5,11 @@ # # Pass --build-arg VITE_BUILD_HASH=$(git rev-parse --short HEAD) to stamp the build. # A .env in the build context is picked up by scripts/build.sh for branding config. +# +# Self-hosted deployments can override branding/feature settings by passing +# VITE_* build args below (e.g. VITE_EMAIL_NOTIFICATION_SERVER). Unset args keep +# the value from the tracked .env (scripts/build.sh sources it and restores the +# exported environment), so local overrides never clobber upstream defaults. # https://pnpm.io/docker#example-3-build-on-cicd FROM node:24-slim AS builder @@ -17,9 +22,20 @@ ENV NODE_OPTIONS=--max_old_space_size=16384 COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ RUN pnpm i --frozen-lockfile COPY . . + ARG VITE_BUILD_HASH -RUN pnpm run build -RUN pnpm run build:server +ARG VITE_PLATFORM_URL +ARG VITE_PLATFORM_NAME +ARG VITE_PLATFORM_DESCRIPTION +ARG VITE_PLATFORM_ACCENT +ARG VITE_PLATFORM_SECONDARY +ARG VITE_DEFAULT_PUBKEYS +ARG VITE_PLATFORM_LOGO +ARG VITE_PLATFORM_RELAYS +ARG VITE_EMAIL_NOTIFICATION_SERVER + +RUN bash -c 'for v in VITE_BUILD_HASH VITE_PLATFORM_URL VITE_PLATFORM_NAME VITE_PLATFORM_DESCRIPTION VITE_PLATFORM_ACCENT VITE_PLATFORM_SECONDARY VITE_DEFAULT_PUBKEYS VITE_PLATFORM_LOGO VITE_PLATFORM_RELAYS VITE_EMAIL_NOTIFICATION_SERVER; do if [ -n "${!v}" ]; then export "$v=${!v}"; fi; done; pnpm run build' +RUN bash -c 'for v in VITE_BUILD_HASH VITE_PLATFORM_URL VITE_PLATFORM_NAME VITE_PLATFORM_DESCRIPTION VITE_PLATFORM_ACCENT VITE_PLATFORM_SECONDARY VITE_DEFAULT_PUBKEYS VITE_PLATFORM_LOGO VITE_PLATFORM_RELAYS VITE_EMAIL_NOTIFICATION_SERVER; do if [ -n "${!v}" ]; then export "$v=${!v}"; fi; done; pnpm run build:server' FROM node:24-slim AS production ENV NODE_ENV=production diff --git a/src/app/components/EmailNotificationsInfo.svelte b/src/app/components/EmailNotificationsInfo.svelte new file mode 100644 index 00000000..27b2f04e --- /dev/null +++ b/src/app/components/EmailNotificationsInfo.svelte @@ -0,0 +1,26 @@ + + + + + Email notifications + +

+ For a community's new messages to appear in your email digest, its relay must support NIP-9a + (email notifications). +

+

+ If a community is missing from your digest, ask the community admin to enable NIP-9a on their + relay. +

+ +
diff --git a/src/app/env.ts b/src/app/env.ts index 7e25e180..d407d751 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..993ed91a --- /dev/null +++ b/src/app/mail/index.ts @@ -0,0 +1,420 @@ +import {call, hash, maybe, spec, throttle} from "@welshman/lib" +import type {Filter} from "@welshman/util" +import {Address, DELETE, MESSAGE, makeEvent, makeHttpAuth, makeHttpAuthHeader} from "@welshman/util" +import {merged} from "@welshman/store" +import {Relays, User} from "@welshman/app" +import {buildUrl} from "@lib/util" +import {app, messagingRelayLists, roomLists, thunks} from "@app/core" +import {EMAIL_NOTIFICATION_SERVER, PUSH_BRIDGE} from "@app/env" +import {ensureEmailSalt, userSettingsValues} from "@app/settings" +import {emailNotificationState} from "@app/push/adapters/common" +import {DM_KINDS, CONTENT_KINDS, makeCommentFilter} from "@app/content" + +// Email digests sync their own kind 30390 subscriptions. Unlike push, the email +// subscription is driven by the email digest setting (SettingsValues.email) plus +// per-space alert preferences, and must NOT be gated on the push notificationSettings +// toggles - otherwise turning off push "new activity" would delete the 30390 and stop +// email digests entirely. +const syncRelaySubscriptionsForEmail = ( + signal: AbortSignal, + sync: (url: string, key: string, filters: Filter[], ignore: Filter[]) => void, +) => { + const $pubkey = User.require(app.get()).pubkey + + const unsubscribeSpaces = merged([roomLists.get().urls($pubkey).$, userSettingsValues]).subscribe( + throttle(3000, ([$spaceUrls, {email, alerts}]) => { + const baseFilters = [{kinds: [MESSAGE, ...CONTENT_KINDS]}, makeCommentFilter(CONTENT_KINDS)] + + for (const url of $spaceUrls) { + const filters: Filter[] = [] + const ignore: Filter[] = [] + + if (!email) { + sync(url, "spaces", filters, []) + continue + } + + const {notify = true, exceptions = []} = alerts.find(spec({url})) || {} + + if (notify) { + if (exceptions.length > 0) { + ignore.push({"#h": exceptions}) + } + filters.push(...baseFilters) + } else if (exceptions.length > 0) { + filters.push(...baseFilters.map(f => ({...f, "#h": exceptions}))) + } + + // Mentions are part of the digest regardless of push settings, and + // guarantee there is always at least one filter so the subscription + // stays live while email digests are enabled. + filters.push(...baseFilters.map(f => ({...f, "#p": [$pubkey]}))) + + sync(url, "spaces", filters, ignore) + } + }), + ) + + const unsubscribeMessages = merged([ + messagingRelayLists.get().urls($pubkey).$, + userSettingsValues, + ]).subscribe( + throttle(3000, ([$messagingUrls, {email}]) => { + for (const url of $messagingUrls) { + sync(url, "messages", email ? [{kinds: DM_KINDS, "#p": [$pubkey]}] : [], []) + } + }), + ) + + signal.addEventListener("abort", () => { + unsubscribeSpaces() + unsubscribeMessages() + }) +} + +export class MailNotifications { + _controller = maybe() + + // In-flight register promise, so overlapping calls (settings page submit plus + // Mail.sync) coalesce into a single PUT instead of racing two subscriptions. + _register = maybe>() + + async register( + email: string, + frequency: string, + hour = 17, + minute = 0, + dayOfWeek?: number, + timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC", + ) { + if (this._register) { + return this._register + } + + this._register = call(async () => { + try { + const $user = User.require(app.get()) + const $signer = $user.signer + + if (!$signer) { + emailNotificationState.set({ + error: "No signer available. Check your browser extension.", + }) + throw new Error("No signer available") + } + + if (!EMAIL_NOTIFICATION_SERVER) { + emailNotificationState.set({error: "Email notification server is not configured."}) + throw new Error("Email notification server not configured") + } + + const body = {email, frequency, hour, minute, dayOfWeek, timezone} + const bodyJson = JSON.stringify(body) + + let authEvent + try { + authEvent = await $signer.sign( + await makeHttpAuth( + buildUrl(EMAIL_NOTIFICATION_SERVER, "subscription", "email"), + "PUT", + bodyJson, + ), + ) + } catch (e) { + emailNotificationState.set({ + error: "Failed to create authentication. Check your signer.", + }) + throw new Error("Failed to sign NIP-98 auth", {cause: e}) + } + + const authHeader = makeHttpAuthHeader(authEvent) + + let res + try { + res = await fetch(buildUrl(EMAIL_NOTIFICATION_SERVER, "subscription", "email"), { + method: "PUT", + body: bodyJson, + headers: {Authorization: authHeader, "Content-Type": "application/json"}, + }) + } catch (e) { + emailNotificationState.set({ + error: `Could not reach email server. Check your connection.`, + }) + throw new Error("Network error", {cause: e}) + } + + if (!res.ok) { + const text = await res.text().catch(() => "unknown error") + emailNotificationState.set({error: `Registration failed: ${text}`}) + throw new Error(`Registration failed: ${text}`) + } + + const {key, callback} = await res.json() + emailNotificationState.set({key, callback, email, frequency, error: undefined}) + } finally { + this._register = undefined + } + }) + + return this._register + } + + // Fetch the existing subscription for this pubkey (if any) without creating + // or changing anything. Used at boot to restore state instead of re-PUTting, + // which would otherwise re-send a confirmation email for an unconfirmed + // address on every reload. + async lookup(): Promise< + | { + key: string + callback: string + email: string + frequency: string + confirmed: boolean + } + | undefined + > { + const $signer = app.get().user?.signer + if (!$signer || !EMAIL_NOTIFICATION_SERVER) { + return undefined + } + + try { + const authEvent = await $signer.sign( + await makeHttpAuth(buildUrl(EMAIL_NOTIFICATION_SERVER, "subscription", "email"), "GET"), + ) + const authHeader = makeHttpAuthHeader(authEvent) + + const res = await fetch(buildUrl(EMAIL_NOTIFICATION_SERVER, "subscription", "email"), { + method: "GET", + headers: {Authorization: authHeader}, + }) + + if (!res.ok) { + return undefined + } + + return await res.json() + } catch { + return undefined + } + } + + async unregister() { + const {key} = emailNotificationState.get() + if (!key) { + return + } + + const $signer = app.get().user?.signer + if (!$signer) { + return + } + + try { + const authEvent = await $signer.sign( + await makeHttpAuth(buildUrl(EMAIL_NOTIFICATION_SERVER, "subscription", key), "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) { + const {emailHour, emailMinute, emailDayOfWeek, emailTimezone} = userSettingsValues.get() + await this.register(email, frequency, emailHour, emailMinute, emailDayOfWeek, emailTimezone) + } + + // Sync kind 30390 subscriptions for the email digest. Filters are derived + // from the email setting + per-space alerts, independent of push toggles. + syncRelaySubscriptionsForEmail( + 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() + } + + sync() { + let reconciledPubkey: string | undefined + + return userSettingsValues.subscribe( + async ({ + email, + emailAddress, + emailFrequency, + emailHour, + emailMinute, + emailDayOfWeek, + emailTimezone, + }) => { + const $pubkey = app.get().user?.pubkey + if (!$pubkey || reconciledPubkey === $pubkey) { + return + } + + // Wait for the real stored settings. userSettingsValues is projected from + // AppData and reads as the default (email: false, emailAddress: "") until + // that loads — acting on that would race the disk with a no-op. Only a + // truthy email comes from stored settings, so keep waiting until we see + // it (a user who has email off simply never reconciles, which is correct). + if (!email || !emailAddress) { + return + } + + reconciledPubkey = $pubkey + + // GET-first: if a subscription already exists for this pubkey with the + // same email and cadence, restore its state (key/callback) instead of + // PUTting. A PUT here would make mailship re-send a confirmation email + // for an unconfirmed address on every app reload. Only register when + // there's nothing to restore or the settings changed. + const existing = await this.lookup() + + try { + if (!existing) { + await this.register( + emailAddress, + emailFrequency, + emailHour, + emailMinute, + emailDayOfWeek, + emailTimezone, + ) + } else if (existing.email !== emailAddress || existing.frequency !== emailFrequency) { + await this.register( + emailAddress, + emailFrequency, + emailHour, + emailMinute, + emailDayOfWeek, + emailTimezone, + ) + } else { + emailNotificationState.set({ + key: existing.key, + callback: existing.callback, + email: existing.email, + frequency: existing.frequency, + error: undefined, + }) + } + } catch { + return + } + + await this.enable() + }, + ) + } + + _getSubscriptionIdentifier = async (relay: string, key: string) => { + const salt = await ensureEmailSalt() + + return String(hash(relay + key + salt + "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 = await this._getSubscriptionIdentifier(relay, key) + + const thunk = thunks.get().publish({ + relays: [url], + event: makeEvent(30390, { + tags: [ + ["d", identifier], + ["relay", relay], + ["callback", callback], + ["include_event", "true"], + ...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 = await 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 1d618941..b364e731 100644 --- a/src/app/push/adapters/common.ts +++ b/src/app/push/adapters/common.ts @@ -40,6 +40,16 @@ export const requiresFallback = export const pushState = withGetter(writable({useFallback: requiresFallback})) +export type EmailNotificationState = { + key?: string + callback?: string + email?: string + frequency?: string + error?: 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 165bb2c5..26f77583 100644 --- a/src/app/settings.ts +++ b/src/app/settings.ts @@ -1,6 +1,6 @@ import type {Writable} from "svelte/store" import {derived, writable} from "svelte/store" -import {append, equals, remove, spec} from "@welshman/lib" +import {append, call, equals, randomId, remove, spec} from "@welshman/lib" import {APP_DATA} from "@welshman/util" import {withGetter} from "@welshman/store" import {AppData} from "@welshman/domain" @@ -11,6 +11,8 @@ import {app, fromApp, usePlugin} from "@app/core" export const SETTINGS = "flotilla/settings" +const emailSaltsByPubkey = new Map>() + export enum RelayAuthMode { Aggressive = "aggressive", Conservative = "conservative", @@ -34,9 +36,17 @@ export type SettingsValues = { font_size: number alerts: SpaceNotificationSettings[] zap_amounts: number[] + email: boolean + emailAddress: string + emailFrequency: string + emailSalt: string openrouter_key: string min_dm_pow: number min_dm_wot: number + emailHour: number + emailMinute: number + emailDayOfWeek: number | undefined + emailTimezone: string } export const defaultSettings: SettingsValues = { @@ -49,9 +59,17 @@ export const defaultSettings: SettingsValues = { font_size: 1.1, alerts: [], zap_amounts: [21, 210, 2100, 21000], + email: false, + emailAddress: "", + emailFrequency: "daily", + emailSalt: "", openrouter_key: "", min_dm_pow: 16, min_dm_wot: 3, + emailHour: 17, + emailMinute: 0, + emailDayOfWeek: undefined, + emailTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC", } export class Settings extends DerivedPlugin { @@ -164,6 +182,7 @@ export const notificationSettings = withGetter( spaces: true, mentions: true, messages: true, + email: false, }), ) @@ -182,6 +201,35 @@ export const publishSettings = async (params: Partial) => { return command.publish() } +// A per-account salt for building deterministic identifiers (e.g. kind 30390 email +// subscription ids) that reproduce on any device of the user, stored with the settings. +export const ensureEmailSalt = () => { + const $pubkey = User.require(app.get()).pubkey + const existing = emailSaltsByPubkey.get($pubkey) + if (existing) { + return existing + } + + // Force-load from the network so a salt synced by another device wins over a fresh one, + // and memoize the in-flight promise so parallel syncs (one per space relay) share a salt. + const promise = call(async () => { + await settings.get().forceLoad($pubkey) + const salt = userSettingsValues.get().emailSalt + if (salt) { + return salt + } + + const emailSalt = randomId() + await publishSettings({emailSalt}) + + return emailSalt + }) + + emailSaltsByPubkey.set($pubkey, promise) + + return promise +} + export const addTrustedRelay = (url: string) => publishSettings({trusted_relays: append(url, getSetting("trusted_relays"))}) @@ -229,3 +277,9 @@ export const toggleRoomMuted = (url: string, h: string) => { return publishSettings({alerts: [...alerts, {url, notify: true, exceptions: [], muted: [h]}]}) } + +export const publishEmailSettings = ( + params: Partial< + Pick + >, +) => publishSettings(params) diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 2014a09b..9eb3f40b 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -42,6 +42,7 @@ import * as notifications from "@app/notifications" import {notificationCount, backgroundNotificationCount} from "@app/notifications" import {Push} from "@app/push" + import {Mail} from "@app/mail" import {onPushNotificationAction, pushState} from "@app/push/adapters/common" import {syncKeyboard} from "@app/keyboard" import {getPageTitle} from "@app/title" @@ -240,6 +241,9 @@ // Initialize background notifications unsubscribers.push(Push.sync()) + // Sync email notification prefs across devices and register this device + unsubscribers.push(Mail.sync()) + // Logging in swaps in a fresh app — its policies rebind themselves, we just have to sync // application data against the new identity's relays. Wait for the new app's storage to // load the way startup does, so sync reconciles against cached events. @@ -279,10 +283,6 @@ theme: "error", timeout: 60_000, message: "Your signer isn't responding.", - action: { - message: "Details", - onclick: () => goto("/settings/profile"), - }, }) } }), diff --git a/src/routes/settings/alerts/+page.svelte b/src/routes/settings/alerts/+page.svelte index 42a0097e..6214843d 100644 --- a/src/routes/settings/alerts/+page.svelte +++ b/src/routes/settings/alerts/+page.svelte @@ -1,6 +1,8 @@
@@ -127,6 +251,115 @@ {/snippet} + {#if EMAIL_NOTIFICATION_SERVER} +
+ Email Digest Settings + + {#snippet label()} +

Receive email digest of missed activity

+ {/snippet} + {#snippet input()} + + {/snippet} +
+ {#if settings.email} + {#if $communitiesWithoutEmail.length > 0} +
+

+ {#if $communitiesWithoutEmail.length === 1} + 1 of your communities does not support email notifications. + {:else} + {$communitiesWithoutEmail.length} of your communities do not support email notifications. + {/if} + +

+
    + {#each $communitiesWithoutEmail as name (name)} +
  • {name}
  • + {/each} +
+
+ {/if} + + {#snippet label()} +

Email address

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

Frequency

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

Time

+ {/snippet} + {#snippet input()} + { + const [h, m] = e.currentTarget.value.split(":").map(Number) + settings.emailHour = h + settings.emailMinute = m + }} /> + {/snippet} +
+ {#if settings.emailFrequency === "weekly"} + + {#snippet label()} +

Day of week

+ {/snippet} + {#snippet input()} +
+ {#each [{label: "M", value: 1}, {label: "T", value: 2}, {label: "W", value: 3}, {label: "T", value: 4}, {label: "F", value: 5}, {label: "S", value: 6}, {label: "S", value: 7}] as day (day.value)} + + {/each} +
+ {/snippet} +
+ {/if} + {/if} + {#if $emailNotificationState.error} +
+ {$emailNotificationState.error} +
+ {/if} +
+ {/if}