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()