flotilla/src/app/mail/index.ts

208 lines
6.2 KiB
TypeScript
Raw Normal View History

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 {EMAIL_NOTIFICATION_SERVER, PUSH_BRIDGE} from "@app/env"
import {ensureEmailSalt, userSettingsValues} from "@app/settings"
import {emailNotificationState, syncRelaySubscriptions} from "@app/push/adapters/common"
export class MailNotifications {
_controller = maybe<AbortController>()
async register(email: string, frequency: string) {
2026-08-20 15:51:26 +00:00
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, "PUT"))
} 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: 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
2026-08-20 15:51:26 +00:00
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) {
try {
await this.register(emailAddress, emailFrequency)
} catch {
return
}
await this.enable()
} else {
await this.disable()
}
}),
)
}
_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],
2026-08-27 19:44:37 +00:00
["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()