Compare commits

...

4 commits

Author SHA1 Message Date
mplorentz
cbba1035e7 Fix bug where email 30390s would be deleted if push notifications were toggled off.
Some checks failed
CI / lint-check-build (pull_request) Has been cancelled
2026-09-03 13:04:29 -04:00
mplorentz
42561c8c02 Use salt instead of device id for email settings 2026-09-03 12:47:36 -04:00
mplorentz
2b2515b1be check for nip-9a support 2026-09-03 12:40:28 -04:00
mplorentz
a7b948740c Omit email digest UI if env variable is not set. 2026-09-03 12:03:34 -04:00
4 changed files with 228 additions and 52 deletions

View file

@ -0,0 +1,26 @@
<script lang="ts">
import Button from "@lib/components/Button.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import {popModal} from "@app/modal"
const gotIt = () => popModal()
</script>
<Modal
><ModalBody>
<ModalHeader>
<ModalTitle>Email notifications</ModalTitle>
</ModalHeader>
<p>
For a community's new messages to appear in your email digest, its relay must support NIP-9a
(email notifications).
</p>
<p>
If a community is missing from your digest, ask the community admin to enable NIP-9a on their
relay.
</p>
<Button class="button button-primary" onclick={gotIt}>Got it</Button>
</ModalBody></Modal>

View file

@ -1,13 +1,76 @@
import {hash, maybe, throttle} from "@welshman/lib"
import {hash, maybe, spec, throttle} from "@welshman/lib"
import type {Filter} from "@welshman/util"
import {Address, DELETE, makeEvent, makeHttpAuth, makeHttpAuthHeader} 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, thunks} from "@app/core"
import {device} from "@app/device"
import {app, messagingRelayLists, roomLists, thunks} from "@app/core"
import {EMAIL_NOTIFICATION_SERVER, PUSH_BRIDGE} from "@app/env"
import {userSettingsValues} from "@app/settings"
import {emailNotificationState, syncRelaySubscriptions} from "@app/push/adapters/common"
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<AbortController>()
@ -97,8 +160,9 @@ export class MailNotifications {
await this.register(email, frequency)
}
// Sync kind 30390 subscriptions using the same filters as push
syncRelaySubscriptions(
// 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()
@ -138,8 +202,11 @@ export class MailNotifications {
)
}
_getSubscriptionIdentifier = (relay: string, key: string) =>
String(hash(relay + key + device.get() + "email"))
_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]) {
@ -161,7 +228,7 @@ export class MailNotifications {
return
}
const identifier = this._getSubscriptionIdentifier(relay, key)
const identifier = await this._getSubscriptionIdentifier(relay, key)
const thunk = thunks.get().publish({
relays: [url],
@ -188,7 +255,7 @@ export class MailNotifications {
if (!url) return
const $pubkey = User.require(app.get()).pubkey
const identifier = this._getSubscriptionIdentifier(relay, key)
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

View file

@ -1,5 +1,5 @@
import {derived, writable} from "svelte/store"
import {append, remove, spec} from "@welshman/lib"
import {append, call, randomId, remove, spec} from "@welshman/lib"
import {APP_DATA} from "@welshman/util"
import {withGetter} from "@welshman/store"
import {AppData} from "@welshman/domain"
@ -10,6 +10,8 @@ import {app, fromApp, usePlugin} from "@app/core"
export const SETTINGS = "flotilla/settings"
const emailSaltsByPubkey = new Map<string, Promise<string>>()
export enum RelayAuthMode {
Aggressive = "aggressive",
Conservative = "conservative",
@ -34,6 +36,7 @@ export type SettingsValues = {
email: boolean
emailAddress: string
emailFrequency: string
emailSalt: string
}
export const defaultSettings: SettingsValues = {
@ -49,6 +52,7 @@ export const defaultSettings: SettingsValues = {
email: false,
emailAddress: "",
emailFrequency: "daily",
emailSalt: "",
}
export class Settings extends DerivedPlugin<AppDataReader> {
@ -121,6 +125,31 @@ export const publishSettings = async (params: Partial<SettingsValues>) => {
return command.publish()
}
// A per-account salt for building deterministic identifiers (e.g. kind 30390 email
// subscription ids) that reproduce on any device of the user, stored with the settings.
export const ensureEmailSalt = () => {
const $pubkey = User.require(app.get()).pubkey
const existing = emailSaltsByPubkey.get($pubkey)
if (existing) return existing
// Force-load from the network so a salt synced by another device wins over a fresh one,
// and memoize the in-flight promise so parallel syncs (one per space relay) share a salt.
const promise = call(async () => {
await settings.get().forceLoad($pubkey)
const salt = userSettingsValues.get().emailSalt
if (salt) return salt
const emailSalt = randomId()
await publishSettings({emailSalt})
return emailSalt
})
emailSaltsByPubkey.set($pubkey, promise)
return promise
}
export const addTrustedRelay = (url: string) =>
publishSettings({trusted_relays: append(url, getSetting("trusted_relays"))})

View file

@ -1,6 +1,8 @@
<script lang="ts">
import cx from "classnames"
import {derived} from "svelte/store"
import {sleep} from "@welshman/lib"
import type {Relay} from "@welshman/domain"
import {Capacitor} from "@capacitor/core"
import {Badge} from "@capawesome/capacitor-badge"
import Bell from "@assets/icons/bell.svg?dataurl"
@ -12,11 +14,16 @@
import Button from "@lib/components/Button.svelte"
import PageContent from "@lib/components/PageContent.svelte"
import {pushToast} from "@app/toast"
import {pushModal} from "@app/modal"
import {clearBadges} from "@app/notifications"
import {Push} from "@app/push"
import {Mail} from "@app/mail"
import {notificationSettings, publishSettings, userSettingsValues} from "@app/settings"
import {emailNotificationState} from "@app/push/adapters/common"
import {EMAIL_NOTIFICATION_SERVER} from "@app/env"
import {relays} from "@app/core"
import {userSpaceUrls} from "@app/rooms"
import EmailNotificationsInfo from "@app/components/EmailNotificationsInfo.svelte"
const clearError = () => {
if ($emailNotificationState.error) {
@ -111,6 +118,34 @@
}
})
// Communities whose relay doesn't advertise NIP-9a won't be included in the email digest.
// Subscribing to each space's NIP-11 info triggers a fetch for any that hasn't loaded yet.
const communitiesWithoutEmail = derived(
userSpaceUrls,
($urls, set: (value: string[]) => void) => {
const unsubscribers = $urls.map(url =>
relays
.get()
.one(url)
.subscribe(() =>
set(
$urls
.map(url => relays.get().get(url))
.filter((relay): relay is Relay => relay !== undefined && !relay.hasNip("9a"))
.map(relay => relay.display()),
),
),
)
return () =>
unsubscribers.forEach(unsubscribe => {
unsubscribe()
})
},
)
const showEmailInfo = () => pushModal(EmailNotificationsInfo)
let loading = $state(false)
let settings = $state(buildSettings())
</script>
@ -185,6 +220,7 @@
{/snippet}
</FieldInline>
</div>
{#if EMAIL_NOTIFICATION_SERVER}
<div class="card bg-surface flex flex-col gap-4 shadow-md">
<strong class="text-lg">Email Digest Settings</strong>
<FieldInline>
@ -196,6 +232,23 @@
{/snippet}
</FieldInline>
{#if settings.email}
{#if $communitiesWithoutEmail.length > 0}
<div class="flex flex-col gap-1 rounded-2xl border border-warning bg-warning/10 p-4">
<p class="flex items-center gap-2 text-sm text-warning">
{#if $communitiesWithoutEmail.length === 1}
1 of your communities does not support email notifications.
{:else}
{$communitiesWithoutEmail.length} of your communities do not support email notifications.
{/if}
<Button class="link" onclick={showEmailInfo}>Learn more</Button>
</p>
<ul class="list-inside list-disc text-sm text-warning">
{#each $communitiesWithoutEmail as name (name)}
<li>{name}</li>
{/each}
</ul>
</div>
{/if}
<FieldInline>
{#snippet label()}
<p>Email address</p>
@ -230,6 +283,7 @@
</div>
{/if}
</div>
{/if}
<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>