2026-08-20 14:55:45 +00:00
|
|
|
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<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
|
2026-08-20 14:55:45 +00:00
|
|
|
|
|
|
|
|
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",
|
2026-08-20 15:51:26 +00:00
|
|
|
body: JSON.stringify({email, frequency, pubkey: $user.pubkey}),
|
2026-08-20 14:55:45 +00:00
|
|
|
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
|
|
|
|
|
|
2026-08-20 15:51:26 +00:00
|
|
|
const $signer = app.get().user?.signer
|
2026-08-20 14:55:45 +00:00
|
|
|
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()
|