import {hash, maybe, throttle} 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 {userSettingsValues} from "@app/settings" import {emailNotificationState, syncRelaySubscriptions} from "@app/push/adapters/common" export class MailNotifications { _controller = maybe() async register(email: string, frequency: string) { 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") } let authEvent try { authEvent = await $signer.sign(await makeHttpAuth(EMAIL_NOTIFICATION_SERVER, "POST")) } 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: "POST", body: JSON.stringify({email, frequency, pubkey: $user.pubkey}), 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}) } 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(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() } sync() { return userSettingsValues.subscribe( throttle(500, async ({email, emailAddress, emailFrequency}) => { if (email && emailAddress) { const existing = await this._lookup() if ( existing?.confirmed && existing.email === emailAddress && existing.frequency === emailFrequency ) { emailNotificationState.set({ key: existing.key, callback: existing.callback, email: existing.email, frequency: existing.frequency, error: undefined, }) } else { try { await this.register(emailAddress, emailFrequency) } catch { return } } await this.enable() } else { await this.disable() } }), ) } async _lookup() { const {pubkey} = User.require(app.get()) const res = await fetch( `${buildUrl(EMAIL_NOTIFICATION_SERVER, "subscription", "email")}?pubkey=${pubkey}`, ) if (!res.ok) return undefined return res.json() } _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], ["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 = 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()