Email notifications: settings UI + mail adapter
- Add VITE_EMAIL_NOTIFICATION_SERVER env var - Extend notificationSettings with email, emailFrequency, emailAddress - Add emailNotificationState store in push/adapters/common.ts - New src/app/mail/index.ts with MailNotifications class: register/unregister with mailship via NIP-98 auth, publish kind 30390 NIP-9a subscription events - Wired into alerts settings save handler - Email Digest Settings card in alerts UI
This commit is contained in:
parent
91a8995a98
commit
3b8d321bd8
5 changed files with 226 additions and 0 deletions
|
|
@ -9,6 +9,8 @@ export const PUSH_SERVER = import.meta.env.VITE_PUSH_SERVER
|
|||
|
||||
export const PUSH_BRIDGE = normalizeRelayUrl(import.meta.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(import.meta.env.VITE_SIGNER_RELAYS).map(normalizeRelayUrl)
|
||||
|
|
|
|||
162
src/app/mail/index.ts
Normal file
162
src/app/mail/index.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
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) {
|
||||
const $app = app.get()
|
||||
const $pubkey = User.require($app).pubkey
|
||||
const $signer = $app.signer
|
||||
|
||||
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",
|
||||
body: JSON.stringify({email, frequency, pubkey: $pubkey}),
|
||||
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
|
||||
|
||||
const $app = app.get()
|
||||
const $signer = $app.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()
|
||||
}
|
||||
|
||||
_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()
|
||||
|
|
@ -40,6 +40,15 @@ export type PushState = {
|
|||
|
||||
export const pushState = withGetter(writable<PushState>({}))
|
||||
|
||||
export type EmailNotificationState = {
|
||||
key?: string
|
||||
callback?: string
|
||||
email?: string
|
||||
frequency?: string
|
||||
}
|
||||
|
||||
export const emailNotificationState = withGetter(writable<EmailNotificationState>({}))
|
||||
|
||||
export interface IPushAdapter {
|
||||
request: (prompt?: boolean) => Promise<string>
|
||||
disable: () => Promise<void>
|
||||
|
|
|
|||
|
|
@ -99,6 +99,9 @@ export const notificationSettings = withGetter(
|
|||
spaces: true,
|
||||
mentions: true,
|
||||
messages: true,
|
||||
email: false,
|
||||
emailFrequency: "daily",
|
||||
emailAddress: "",
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
import {pushToast} from "@app/toast"
|
||||
import {clearBadges} from "@app/notifications"
|
||||
import {Push} from "@app/push"
|
||||
import {Mail} from "@app/mail"
|
||||
import {notificationSettings} from "@app/settings"
|
||||
|
||||
const reset = () => {
|
||||
|
|
@ -45,6 +46,19 @@
|
|||
}
|
||||
}
|
||||
|
||||
// Handle email notification registration
|
||||
if (settings.email && settings.emailAddress) {
|
||||
try {
|
||||
await Mail.register(settings.emailAddress, settings.emailFrequency)
|
||||
await Mail.enable()
|
||||
} catch (e) {
|
||||
console.warn("Failed to enable email notifications:", e)
|
||||
pushToast({theme: "error", message: "Failed to enable email notifications."})
|
||||
}
|
||||
} else if (!settings.email) {
|
||||
await Mail.disable()
|
||||
}
|
||||
|
||||
notificationSettings.set(settings)
|
||||
|
||||
pushToast({message: "Your settings have been saved!"})
|
||||
|
|
@ -127,6 +141,42 @@
|
|||
{/snippet}
|
||||
</FieldInline>
|
||||
</div>
|
||||
<div class="card bg-surface flex flex-col gap-4 shadow-md">
|
||||
<strong class="text-lg">Email Digest Settings</strong>
|
||||
<FieldInline>
|
||||
{#snippet label()}
|
||||
<p>Receive email digest of missed activity</p>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<ToggleInput bind:checked={settings.email} />
|
||||
{/snippet}
|
||||
</FieldInline>
|
||||
{#if settings.email}
|
||||
<FieldInline>
|
||||
{#snippet label()}
|
||||
<p>Email address</p>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<input
|
||||
type="email"
|
||||
class="input input-bordered w-full"
|
||||
bind:value={settings.emailAddress}
|
||||
placeholder="you@example.com" />
|
||||
{/snippet}
|
||||
</FieldInline>
|
||||
<FieldInline>
|
||||
{#snippet label()}
|
||||
<p>Frequency</p>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<select class="select select-bordered" bind:value={settings.emailFrequency}>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
</select>
|
||||
{/snippet}
|
||||
</FieldInline>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="card sticky -bottom-3 shadow-md flex flex-row items-center justify-between gap-4">
|
||||
<Button class="button button-neutral" onclick={reset} disabled={loading}
|
||||
>Discard Changes</Button>
|
||||
|
|
|
|||
Loading…
Reference in a new issue