From fee6f565ffd8618b207450536b5c330281dc6489 Mon Sep 17 00:00:00 2001 From: Agent Date: Thu, 20 Aug 2026 10:55:45 -0400 Subject: [PATCH 01/21] 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 --- src/app/env.ts | 2 + src/app/mail/index.ts | 162 ++++++++++++++++++++++++ src/app/push/adapters/common.ts | 9 ++ src/app/settings.ts | 3 + src/routes/settings/alerts/+page.svelte | 50 ++++++++ 5 files changed, 226 insertions(+) create mode 100644 src/app/mail/index.ts diff --git a/src/app/env.ts b/src/app/env.ts index 1d9a02de..40b5e3ec 100644 --- a/src/app/env.ts +++ b/src/app/env.ts @@ -16,6 +16,8 @@ export const PUSH_SERVER = env("VITE_PUSH_SERVER") export const PUSH_BRIDGE = normalizeRelayUrl(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(env("VITE_SIGNER_RELAYS")).map(normalizeRelayUrl) diff --git a/src/app/mail/index.ts b/src/app/mail/index.ts new file mode 100644 index 00000000..291c5023 --- /dev/null +++ b/src/app/mail/index.ts @@ -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() + + 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() diff --git a/src/app/push/adapters/common.ts b/src/app/push/adapters/common.ts index 84c5fc22..db78785f 100644 --- a/src/app/push/adapters/common.ts +++ b/src/app/push/adapters/common.ts @@ -40,6 +40,15 @@ export type PushState = { export const pushState = withGetter(writable({})) +export type EmailNotificationState = { + key?: string + callback?: string + email?: string + frequency?: string +} + +export const emailNotificationState = withGetter(writable({})) + export interface IPushAdapter { request: (prompt?: boolean) => Promise disable: () => Promise diff --git a/src/app/settings.ts b/src/app/settings.ts index 5d97d1ca..fa342dca 100644 --- a/src/app/settings.ts +++ b/src/app/settings.ts @@ -97,6 +97,9 @@ export const notificationSettings = withGetter( spaces: true, mentions: true, messages: true, + email: false, + emailFrequency: "daily", + emailAddress: "", }), ) diff --git a/src/routes/settings/alerts/+page.svelte b/src/routes/settings/alerts/+page.svelte index 42a0097e..b163ece7 100644 --- a/src/routes/settings/alerts/+page.svelte +++ b/src/routes/settings/alerts/+page.svelte @@ -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} +
+ Email Digest Settings + + {#snippet label()} +

Receive email digest of missed activity

+ {/snippet} + {#snippet input()} + + {/snippet} +
+ {#if settings.email} + + {#snippet label()} +

Email address

+ {/snippet} + {#snippet input()} + + {/snippet} +
+ + {#snippet label()} +

Frequency

+ {/snippet} + {#snippet input()} + + {/snippet} +
+ {/if} +
-- 2.45.2 From 725ef45d9dd513dd2a5cd0daf1baebdf90d360cf Mon Sep 17 00:00:00 2001 From: mplorentz Date: Thu, 20 Aug 2026 11:51:26 -0400 Subject: [PATCH 02/21] Fix signer error --- src/app/mail/index.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/app/mail/index.ts b/src/app/mail/index.ts index 291c5023..849c1a83 100644 --- a/src/app/mail/index.ts +++ b/src/app/mail/index.ts @@ -12,9 +12,8 @@ export class MailNotifications { _controller = maybe() async register(email: string, frequency: string) { - const $app = app.get() - const $pubkey = User.require($app).pubkey - const $signer = $app.signer + const $user = User.require(app.get()) + const $signer = $user.signer if (!$signer) throw new Error("No signer available") @@ -23,7 +22,7 @@ export class MailNotifications { const res = await fetch(buildUrl(EMAIL_NOTIFICATION_SERVER, "subscription", "email"), { method: "POST", - body: JSON.stringify({email, frequency, pubkey: $pubkey}), + body: JSON.stringify({email, frequency, pubkey: $user.pubkey}), headers: {Authorization: authHeader, "Content-Type": "application/json"}, }) @@ -37,8 +36,7 @@ export class MailNotifications { const {key} = emailNotificationState.get() if (!key) return - const $app = app.get() - const $signer = $app.signer + const $signer = app.get().user?.signer if (!$signer) return try { -- 2.45.2 From 5cbc1e459ce76ecdfb349e4bb2e691aa22ffe1ba Mon Sep 17 00:00:00 2001 From: Agent Date: Thu, 20 Aug 2026 12:22:53 -0400 Subject: [PATCH 03/21] Show email registration errors in UI - Add error field to EmailNotificationState type - Set user-friendly error messages in MailNotifications.register() for: missing signer, missing server config, NIP-98 auth failure, network errors, and server errors - Clear error on successful registration - Show inline error banner in the email settings card - Return early on registration failure (like push does) - Clear error when user edits email/frequency or clicks reset --- src/app/mail/index.ts | 43 +++++++++++++++++++------ src/app/push/adapters/common.ts | 1 + src/routes/settings/alerts/+page.svelte | 25 ++++++++++++-- 3 files changed, 57 insertions(+), 12 deletions(-) diff --git a/src/app/mail/index.ts b/src/app/mail/index.ts index 291c5023..62f45c20 100644 --- a/src/app/mail/index.ts +++ b/src/app/mail/index.ts @@ -16,21 +16,46 @@ export class MailNotifications { const $pubkey = User.require($app).pubkey const $signer = $app.signer - if (!$signer) throw new Error("No signer available") + 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") + } - 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"}, - }) + let res + try { + 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"}, + }) + } catch (e) { + emailNotificationState.set({error: `Could not reach email server. Check your connection.`}) + throw new Error("Network error") + } - if (!res.ok) throw new Error(`Registration failed: ${await res.text()}`) + 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}) + emailNotificationState.set({key, callback, email, frequency, error: undefined}) } async unregister() { diff --git a/src/app/push/adapters/common.ts b/src/app/push/adapters/common.ts index db78785f..eb171f3c 100644 --- a/src/app/push/adapters/common.ts +++ b/src/app/push/adapters/common.ts @@ -45,6 +45,7 @@ export type EmailNotificationState = { callback?: string email?: string frequency?: string + error?: string } export const emailNotificationState = withGetter(writable({})) diff --git a/src/routes/settings/alerts/+page.svelte b/src/routes/settings/alerts/+page.svelte index b163ece7..3237a334 100644 --- a/src/routes/settings/alerts/+page.svelte +++ b/src/routes/settings/alerts/+page.svelte @@ -16,9 +16,17 @@ import {Push} from "@app/push" import {Mail} from "@app/mail" import {notificationSettings} from "@app/settings" + import {emailNotificationState} from "@app/push/adapters/common" + + const clearError = () => { + if ($emailNotificationState.error) { + emailNotificationState.set({...emailNotificationState.get(), error: undefined}) + } + } const reset = () => { settings = {...notificationSettings.get()} + clearError() } const onsubmit = preventDefault(async () => { @@ -52,8 +60,13 @@ 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."}) + settings.email = false + await sleep(300) + return pushToast({ + theme: "error", + message: `Failed to enable email notifications.`, + action: {message: "Details", onclick: () => emailNotificationState.set({...emailNotificationState.get(), error: undefined})}, + }) } } else if (!settings.email) { await Mail.disable() @@ -161,6 +174,7 @@ type="email" class="input input-bordered w-full" bind:value={settings.emailAddress} + oninput={clearError} placeholder="you@example.com" /> {/snippet} @@ -169,13 +183,18 @@

Frequency

{/snippet} {#snippet input()} - {/snippet} {/if} + {#if $emailNotificationState.error} +
+ {$emailNotificationState.error} +
+ {/if}
-- 2.45.2 From 2b2515b1be7bcf0ff241f0911daa9c3d80baf8de Mon Sep 17 00:00:00 2001 From: mplorentz Date: Thu, 3 Sep 2026 12:40:28 -0400 Subject: [PATCH 11/21] check for nip-9a support --- .../components/EmailNotificationsInfo.svelte | 26 ++++++++++ src/routes/settings/alerts/+page.svelte | 51 +++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 src/app/components/EmailNotificationsInfo.svelte diff --git a/src/app/components/EmailNotificationsInfo.svelte b/src/app/components/EmailNotificationsInfo.svelte new file mode 100644 index 00000000..27b2f04e --- /dev/null +++ b/src/app/components/EmailNotificationsInfo.svelte @@ -0,0 +1,26 @@ + + + + + Email notifications + +

+ For a community's new messages to appear in your email digest, its relay must support NIP-9a + (email notifications). +

+

+ If a community is missing from your digest, ask the community admin to enable NIP-9a on their + relay. +

+ +
diff --git a/src/routes/settings/alerts/+page.svelte b/src/routes/settings/alerts/+page.svelte index 34f23921..58776c57 100644 --- a/src/routes/settings/alerts/+page.svelte +++ b/src/routes/settings/alerts/+page.svelte @@ -1,6 +1,8 @@ @@ -198,6 +232,23 @@ {/snippet} {#if settings.email} + {#if $communitiesWithoutEmail.length > 0} +
+

+ {#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} + +

+
    + {#each $communitiesWithoutEmail as name (name)} +
  • {name}
  • + {/each} +
+
+ {/if} {#snippet label()}

Email address

-- 2.45.2 From 42561c8c022b51c2befea3825ca1677e92111517 Mon Sep 17 00:00:00 2001 From: mplorentz Date: Thu, 3 Sep 2026 12:47:36 -0400 Subject: [PATCH 12/21] Use salt instead of device id for email settings --- src/app/mail/index.ts | 14 ++++++++------ src/app/settings.ts | 31 ++++++++++++++++++++++++++++++- 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/src/app/mail/index.ts b/src/app/mail/index.ts index f11550f4..9d4644f8 100644 --- a/src/app/mail/index.ts +++ b/src/app/mail/index.ts @@ -4,9 +4,8 @@ import {Address, DELETE, makeEvent, makeHttpAuth, makeHttpAuthHeader} from "@wel 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 {ensureEmailSalt, userSettingsValues} from "@app/settings" import {emailNotificationState, syncRelaySubscriptions} from "@app/push/adapters/common" export class MailNotifications { @@ -138,8 +137,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 +163,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 +190,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 diff --git a/src/app/settings.ts b/src/app/settings.ts index 0d7c8b91..5aa96b88 100644 --- a/src/app/settings.ts +++ b/src/app/settings.ts @@ -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>() + 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 { @@ -121,6 +125,31 @@ export const publishSettings = async (params: Partial) => { 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"))}) -- 2.45.2 From cbba1035e7a362ad289f0bbde24f3b0ba625c2f7 Mon Sep 17 00:00:00 2001 From: mplorentz Date: Thu, 3 Sep 2026 13:04:29 -0400 Subject: [PATCH 13/21] Fix bug where email 30390s would be deleted if push notifications were toggled off. --- src/app/mail/index.ts | 77 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 71 insertions(+), 6 deletions(-) diff --git a/src/app/mail/index.ts b/src/app/mail/index.ts index 9d4644f8..64127ff3 100644 --- a/src/app/mail/index.ts +++ b/src/app/mail/index.ts @@ -1,12 +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 {app, messagingRelayLists, roomLists, 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" +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() @@ -96,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() -- 2.45.2 From d6f0e47edfc9d9b79758ea71c10f2654181975df Mon Sep 17 00:00:00 2001 From: mplorentz Date: Thu, 3 Sep 2026 13:43:10 -0400 Subject: [PATCH 14/21] debounce subscription syncing --- src/app/mail/index.ts | 94 ++++++++++++++++++++++++++----------------- 1 file changed, 57 insertions(+), 37 deletions(-) diff --git a/src/app/mail/index.ts b/src/app/mail/index.ts index 64127ff3..1e504659 100644 --- a/src/app/mail/index.ts +++ b/src/app/mail/index.ts @@ -1,4 +1,4 @@ -import {hash, maybe, spec, throttle} from "@welshman/lib" +import {call, hash, maybe, spec, throttle} from "@welshman/lib" import type {Filter} from "@welshman/util" import {Address, DELETE, MESSAGE, makeEvent, makeHttpAuth, makeHttpAuthHeader} from "@welshman/util" import {merged} from "@welshman/store" @@ -75,50 +75,70 @@ const syncRelaySubscriptionsForEmail = ( export class MailNotifications { _controller = maybe() + // In-flight register promise, so overlapping calls (settings page submit plus + // Mail.sync) coalesce into a single PUT instead of racing two subscriptions. + _register = maybe>() + async register(email: string, frequency: string) { - const $user = User.require(app.get()) - const $signer = $user.signer + if (this._register) return this._register - if (!$signer) { - emailNotificationState.set({error: "No signer available. Check your browser extension."}) - throw new Error("No signer available") - } + this._register = call(async () => { + try { + const $user = User.require(app.get()) + const $signer = $user.signer - if (!EMAIL_NOTIFICATION_SERVER) { - emailNotificationState.set({error: "Email notification server is not configured."}) - throw new Error("Email notification server not configured") - } + if (!$signer) { + emailNotificationState.set({ + error: "No signer available. Check your browser extension.", + }) + throw new Error("No signer available") + } - 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}) - } + if (!EMAIL_NOTIFICATION_SERVER) { + emailNotificationState.set({error: "Email notification server is not configured."}) + throw new Error("Email notification server not configured") + } - const authHeader = makeHttpAuthHeader(authEvent) + 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}) + } - 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}) - } + const authHeader = makeHttpAuthHeader(authEvent) - if (!res.ok) { - const text = await res.text().catch(() => "unknown error") - emailNotificationState.set({error: `Registration failed: ${text}`}) - throw new Error(`Registration failed: ${text}`) - } + 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}) + } - const {key, callback} = await res.json() - emailNotificationState.set({key, callback, email, frequency, error: undefined}) + 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}) + } finally { + this._register = undefined + } + }) + + return this._register } async unregister() { -- 2.45.2 From 3981ed9608a3b66d47e155b203c0f5b0876f68a8 Mon Sep 17 00:00:00 2001 From: Agent Date: Wed, 16 Sep 2026 13:50:47 -0400 Subject: [PATCH 15/21] fix email notifications: match mailship PUT API with full-URL NIP-98 and payload tag - register(): sign makeHttpAuth with full URL (/subscription/email), method PUT, and serialized body so both u-tag and payload tag match mailship's verifyNip98Auth. Drop pubkey from request body (server reads it from the auth event). - unregister(): sign makeHttpAuth with full URL (/subscription/:key) and method DELETE, matching mailship's u-tag expectation. Closes PR #4 --- src/app/mail/index.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/app/mail/index.ts b/src/app/mail/index.ts index 1e504659..8649efa9 100644 --- a/src/app/mail/index.ts +++ b/src/app/mail/index.ts @@ -99,9 +99,18 @@ export class MailNotifications { throw new Error("Email notification server not configured") } + const body = {email, frequency} + const bodyJson = JSON.stringify(body) + let authEvent try { - authEvent = await $signer.sign(await makeHttpAuth(EMAIL_NOTIFICATION_SERVER, "PUT")) + authEvent = await $signer.sign( + await makeHttpAuth( + buildUrl(EMAIL_NOTIFICATION_SERVER, "subscription", "email"), + "PUT", + bodyJson, + ), + ) } catch (e) { emailNotificationState.set({ error: "Failed to create authentication. Check your signer.", @@ -115,7 +124,7 @@ export class MailNotifications { try { res = await fetch(buildUrl(EMAIL_NOTIFICATION_SERVER, "subscription", "email"), { method: "PUT", - body: JSON.stringify({email, frequency, pubkey: $user.pubkey}), + body: bodyJson, headers: {Authorization: authHeader, "Content-Type": "application/json"}, }) } catch (e) { @@ -149,7 +158,9 @@ export class MailNotifications { if (!$signer) return try { - const authEvent = await $signer.sign(await makeHttpAuth(EMAIL_NOTIFICATION_SERVER, "DELETE")) + const authEvent = await $signer.sign( + await makeHttpAuth(buildUrl(EMAIL_NOTIFICATION_SERVER, "subscription", key), "DELETE"), + ) const authHeader = makeHttpAuthHeader(authEvent) await fetch(buildUrl(EMAIL_NOTIFICATION_SERVER, "subscription", key), { -- 2.45.2 From 1cff79dba24ab32ccd1199f42f1ed6a2cdd95e84 Mon Sep 17 00:00:00 2001 From: hudson Date: Wed, 16 Sep 2026 15:13:47 -0400 Subject: [PATCH 16/21] Remove signer-not-responding toast with dead Details link --- src/routes/+layout.svelte | 29 +---------------------------- 1 file changed, 1 insertion(+), 28 deletions(-) diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 6055ab41..d8e91016 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -12,7 +12,7 @@ import {goto} from "$app/navigation" import {page} from "$app/stores" import {context as pomadeContext} from "@pomade/core" - import {sync, throttled} from "@welshman/store" + import {sync} from "@welshman/store" import {setNip55Plugin} from "@welshman/signer" import * as util from "@welshman/util" import * as lib from "@welshman/lib" @@ -26,7 +26,6 @@ import {setupLogging} from "@app/logger" import "@app/policies" import {restoreSession} from "@app/session" - import {signerRequests} from "@app/signer" import {wallet} from "@app/lightning" import {kv, ss, storage} from "@app/storage" import {device} from "@app/device" @@ -236,32 +235,6 @@ }), ) - // Listen for signer errors, report to user via toast - unsubscribers.push( - throttled(10_000, signerRequests).subscribe($requests => { - if ($toast) return - - const longCutoff = Date.now() - 30_000 - const shortCutoff = Date.now() - 10_000 - const pending = $requests.filter(r => !r.finishedAt && r.startedAt < longCutoff) - const completed = $requests.filter(r => r.finishedAt && r.finishedAt > shortCutoff) - const showPendingError = pending.length > 10 - const showCompletedError = completed.length > 5 && completed.filter(r => r.ok).length === 0 - - if (showPendingError || showCompletedError) { - pushToast({ - theme: "error", - timeout: 60_000, - message: "Your signer isn't responding.", - action: { - message: "Details", - onclick: () => goto("/settings/profile"), - }, - }) - } - }), - ) - // Sync theme and font size unsubscribers.push( activeTheme.subscribe($activeTheme => { -- 2.45.2 From ce8920123fcb475a6140ac95a3f0f7c6645e8345 Mon Sep 17 00:00:00 2001 From: hudson Date: Thu, 17 Sep 2026 16:40:09 -0400 Subject: [PATCH 17/21] Keep signer-not-responding toast; drop only the dead Details action button --- src/routes/+layout.svelte | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index d8e91016..b7524572 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -12,7 +12,7 @@ import {goto} from "$app/navigation" import {page} from "$app/stores" import {context as pomadeContext} from "@pomade/core" - import {sync} from "@welshman/store" + import {sync, throttled} from "@welshman/store" import {setNip55Plugin} from "@welshman/signer" import * as util from "@welshman/util" import * as lib from "@welshman/lib" @@ -26,6 +26,7 @@ import {setupLogging} from "@app/logger" import "@app/policies" import {restoreSession} from "@app/session" + import {signerRequests} from "@app/signer" import {wallet} from "@app/lightning" import {kv, ss, storage} from "@app/storage" import {device} from "@app/device" @@ -235,6 +236,28 @@ }), ) + // Listen for signer errors, report to user via toast + unsubscribers.push( + throttled(10_000, signerRequests).subscribe($requests => { + if ($toast) return + + const longCutoff = Date.now() - 30_000 + const shortCutoff = Date.now() - 10_000 + const pending = $requests.filter(r => !r.finishedAt && r.startedAt < longCutoff) + const completed = $requests.filter(r => r.finishedAt && r.finishedAt > shortCutoff) + const showPendingError = pending.length > 10 + const showCompletedError = completed.length > 5 && completed.filter(r => r.ok).length === 0 + + if (showPendingError || showCompletedError) { + pushToast({ + theme: "error", + timeout: 60_000, + message: "Your signer isn't responding.", + }) + } + }), + ) + // Sync theme and font size unsubscribers.push( activeTheme.subscribe($activeTheme => { -- 2.45.2 From b90a20424e61976f6c25516825f98f34cdab1fb2 Mon Sep 17 00:00:00 2001 From: mplorentz Date: Mon, 21 Sep 2026 11:54:19 -0400 Subject: [PATCH 18/21] Fix Mail.sync to only fire when the user saves, or when contents are loaded from disk --- src/app/mail/index.ts | 36 ++++++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/src/app/mail/index.ts b/src/app/mail/index.ts index 8649efa9..69b356b6 100644 --- a/src/app/mail/index.ts +++ b/src/app/mail/index.ts @@ -216,21 +216,29 @@ export class MailNotifications { } sync() { - return userSettingsValues.subscribe( - throttle(500, async ({email, emailAddress, emailFrequency}) => { - if (email && emailAddress) { - try { - await this.register(emailAddress, emailFrequency) - } catch { - return - } + let reconciledPubkey: string | undefined - await this.enable() - } else { - await this.disable() - } - }), - ) + return userSettingsValues.subscribe(async ({email, emailAddress, emailFrequency}) => { + const $pubkey = app.get().user?.pubkey + if (!$pubkey || reconciledPubkey === $pubkey) return + + // Wait for the real stored settings. userSettingsValues is projected from + // AppData and reads as the default (email: false, emailAddress: "") until + // that loads — acting on that would race the disk with a no-op. Only a + // truthy email comes from stored settings, so keep waiting until we see + // it (a user who has email off simply never reconciles, which is correct). + if (!email || !emailAddress) return + + reconciledPubkey = $pubkey + + try { + await this.register(emailAddress, emailFrequency) + } catch { + return + } + + await this.enable() + }) } _getSubscriptionIdentifier = async (relay: string, key: string) => { -- 2.45.2 From 63cf801ef15c2d111e5a26de6acd35060ebfa3d0 Mon Sep 17 00:00:00 2001 From: mplorentz Date: Mon, 21 Sep 2026 16:14:02 -0400 Subject: [PATCH 19/21] Fix settings hydration --- src/routes/settings/alerts/+page.svelte | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/routes/settings/alerts/+page.svelte b/src/routes/settings/alerts/+page.svelte index 58776c57..35c7e52b 100644 --- a/src/routes/settings/alerts/+page.svelte +++ b/src/routes/settings/alerts/+page.svelte @@ -148,6 +148,17 @@ let loading = $state(false) let settings = $state(buildSettings()) + let settingsHydrated = $state(false) + + $effect(() => { + if (settingsHydrated) return + + const {email, emailAddress} = $userSettingsValues + if (!email && !emailAddress) return + + settingsHydrated = true + settings = buildSettings() + }) -- 2.45.2 From 609a2f65ea932c38034210e284d8f44a44621012 Mon Sep 17 00:00:00 2001 From: mplorentz Date: Tue, 22 Sep 2026 12:31:56 -0400 Subject: [PATCH 20/21] Email digest: GET-first restore on boot so reloads stop re-sending confirmations Mail.sync's boot reconcile now GETs the existing subscription (NIP-98 auth) before deciding what to do. When a subscription already exists for the pubkey with the same email and frequency, its key/callback are restored in memory and the PUT is skipped entirely. Only a matching subscription missing the requested params triggers a PUT, so a page refresh of an unconfirmed address no longer makes mailship re-send a confirmation email (previously each reload sent one). --- src/app/mail/index.ts | 57 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/src/app/mail/index.ts b/src/app/mail/index.ts index 69b356b6..5460681f 100644 --- a/src/app/mail/index.ts +++ b/src/app/mail/index.ts @@ -150,6 +150,42 @@ export class MailNotifications { return this._register } + // Fetch the existing subscription for this pubkey (if any) without creating + // or changing anything. Used at boot to restore state instead of re-PUTting, + // which would otherwise re-send a confirmation email for an unconfirmed + // address on every reload. + async lookup(): Promise< + | { + key: string + callback: string + email: string + frequency: string + confirmed: boolean + } + | undefined + > { + const $signer = app.get().user?.signer + if (!$signer || !EMAIL_NOTIFICATION_SERVER) return undefined + + try { + const authEvent = await $signer.sign( + await makeHttpAuth(buildUrl(EMAIL_NOTIFICATION_SERVER, "subscription", "email"), "GET"), + ) + const authHeader = makeHttpAuthHeader(authEvent) + + const res = await fetch(buildUrl(EMAIL_NOTIFICATION_SERVER, "subscription", "email"), { + method: "GET", + headers: {Authorization: authHeader}, + }) + + if (!res.ok) return undefined + + return await res.json() + } catch { + return undefined + } + } + async unregister() { const {key} = emailNotificationState.get() if (!key) return @@ -231,8 +267,27 @@ export class MailNotifications { reconciledPubkey = $pubkey + // GET-first: if a subscription already exists for this pubkey with the + // same email and cadence, restore its state (key/callback) instead of + // PUTting. A PUT here would make mailship re-send a confirmation email + // for an unconfirmed address on every app reload. Only register when + // there's nothing to restore or the settings changed. + const existing = await this.lookup() + try { - await this.register(emailAddress, emailFrequency) + if (!existing) { + await this.register(emailAddress, emailFrequency) + } else if (existing.email !== emailAddress || existing.frequency !== emailFrequency) { + await this.register(emailAddress, emailFrequency) + } else { + emailNotificationState.set({ + key: existing.key, + callback: existing.callback, + email: existing.email, + frequency: existing.frequency, + error: undefined, + }) + } } catch { return } -- 2.45.2 From f9878fdff1b7c0ffa60313a6fefdac642e4d1dbb Mon Sep 17 00:00:00 2001 From: Agent Date: Wed, 23 Sep 2026 14:07:05 -0400 Subject: [PATCH 21/21] feat(settings): merge email digest schedule controls onto email-notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves PR #5 conflict resolution: rebased the digest-schedule work (from flotilla-fc1, built against dev) onto the email-notifications branch. - settings.ts: add emailHour/emailMinute/emailDayOfWeek/emailTimezone to SettingsValues + defaults (17:00, device timezone, dayOfWeek undefined) - alerts/+page.svelte: keep the email-notifications card (gated on EMAIL_NOTIFICATION_SERVER, communities warning, error state, NIP-98 register/enable flow); add HH:MM time input + weekly day-of-week picker; weekly select defaults dayOfWeek to Monday; publish schedule fields via AppData publishSettings - mail/index.ts: register() accepts hour/minute/dayOfWeek/timezone and PUTs them to mailship; enable()/sync() pass stored schedule through Dropped dev-based duplicates: src/app/mail.ts, VITE_MAILSHIP_URL (.env + env.ts) — the email-notifications mail adapter and VITE_EMAIL_NOTIFICATION_SERVER are canonical. PUT contract matches mailship PR #28: {email, frequency, hour, minute, dayOfWeek, timezone}. --- src/app/mail/index.ts | 106 +++++++++++++++--------- src/app/settings.ts | 15 ++++ src/routes/settings/alerts/+page.svelte | 65 ++++++++++++++- 3 files changed, 147 insertions(+), 39 deletions(-) diff --git a/src/app/mail/index.ts b/src/app/mail/index.ts index 5460681f..8c6527b2 100644 --- a/src/app/mail/index.ts +++ b/src/app/mail/index.ts @@ -79,7 +79,14 @@ export class MailNotifications { // Mail.sync) coalesce into a single PUT instead of racing two subscriptions. _register = maybe>() - async register(email: string, frequency: string) { + async register( + email: string, + frequency: string, + hour = 17, + minute = 0, + dayOfWeek?: number, + timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC", + ) { if (this._register) return this._register this._register = call(async () => { @@ -99,7 +106,7 @@ export class MailNotifications { throw new Error("Email notification server not configured") } - const body = {email, frequency} + const body = {email, frequency, hour, minute, dayOfWeek, timezone} const bodyJson = JSON.stringify(body) let authEvent @@ -224,7 +231,8 @@ export class MailNotifications { // Register if we don't have a subscription yet if (!emailNotificationState.get().callback && email && frequency) { - await this.register(email, frequency) + const {emailHour, emailMinute, emailDayOfWeek, emailTimezone} = userSettingsValues.get() + await this.register(email, frequency, emailHour, emailMinute, emailDayOfWeek, emailTimezone) } // Sync kind 30390 subscriptions for the email digest. Filters are derived @@ -254,46 +262,70 @@ export class MailNotifications { sync() { let reconciledPubkey: string | undefined - return userSettingsValues.subscribe(async ({email, emailAddress, emailFrequency}) => { - const $pubkey = app.get().user?.pubkey - if (!$pubkey || reconciledPubkey === $pubkey) return + return userSettingsValues.subscribe( + async ({ + email, + emailAddress, + emailFrequency, + emailHour, + emailMinute, + emailDayOfWeek, + emailTimezone, + }) => { + const $pubkey = app.get().user?.pubkey + if (!$pubkey || reconciledPubkey === $pubkey) return - // Wait for the real stored settings. userSettingsValues is projected from - // AppData and reads as the default (email: false, emailAddress: "") until - // that loads — acting on that would race the disk with a no-op. Only a - // truthy email comes from stored settings, so keep waiting until we see - // it (a user who has email off simply never reconciles, which is correct). - if (!email || !emailAddress) return + // Wait for the real stored settings. userSettingsValues is projected from + // AppData and reads as the default (email: false, emailAddress: "") until + // that loads — acting on that would race the disk with a no-op. Only a + // truthy email comes from stored settings, so keep waiting until we see + // it (a user who has email off simply never reconciles, which is correct). + if (!email || !emailAddress) return - reconciledPubkey = $pubkey + reconciledPubkey = $pubkey - // GET-first: if a subscription already exists for this pubkey with the - // same email and cadence, restore its state (key/callback) instead of - // PUTting. A PUT here would make mailship re-send a confirmation email - // for an unconfirmed address on every app reload. Only register when - // there's nothing to restore or the settings changed. - const existing = await this.lookup() + // GET-first: if a subscription already exists for this pubkey with the + // same email and cadence, restore its state (key/callback) instead of + // PUTting. A PUT here would make mailship re-send a confirmation email + // for an unconfirmed address on every app reload. Only register when + // there's nothing to restore or the settings changed. + const existing = await this.lookup() - try { - if (!existing) { - await this.register(emailAddress, emailFrequency) - } else if (existing.email !== emailAddress || existing.frequency !== emailFrequency) { - await this.register(emailAddress, emailFrequency) - } else { - emailNotificationState.set({ - key: existing.key, - callback: existing.callback, - email: existing.email, - frequency: existing.frequency, - error: undefined, - }) + try { + if (!existing) { + await this.register( + emailAddress, + emailFrequency, + emailHour, + emailMinute, + emailDayOfWeek, + emailTimezone, + ) + } else if (existing.email !== emailAddress || existing.frequency !== emailFrequency) { + await this.register( + emailAddress, + emailFrequency, + emailHour, + emailMinute, + emailDayOfWeek, + emailTimezone, + ) + } else { + emailNotificationState.set({ + key: existing.key, + callback: existing.callback, + email: existing.email, + frequency: existing.frequency, + error: undefined, + }) + } + } catch { + return } - } catch { - return - } - await this.enable() - }) + await this.enable() + }, + ) } _getSubscriptionIdentifier = async (relay: string, key: string) => { diff --git a/src/app/settings.ts b/src/app/settings.ts index 5aa96b88..436bfec4 100644 --- a/src/app/settings.ts +++ b/src/app/settings.ts @@ -37,6 +37,10 @@ export type SettingsValues = { emailAddress: string emailFrequency: string emailSalt: string + emailHour: number + emailMinute: number + emailDayOfWeek: number | undefined + emailTimezone: string } export const defaultSettings: SettingsValues = { @@ -53,6 +57,10 @@ export const defaultSettings: SettingsValues = { emailAddress: "", emailFrequency: "daily", emailSalt: "", + emailHour: 17, + emailMinute: 0, + emailDayOfWeek: undefined, + emailTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC", } export class Settings extends DerivedPlugin { @@ -107,6 +115,7 @@ export const notificationSettings = withGetter( spaces: true, mentions: true, messages: true, + email: false, }), ) @@ -183,3 +192,9 @@ export const toggleRoomNotifications = (url: string, h: string) => { return publishSettings({alerts: [...alerts, {url, notify: true, exceptions: [h]}]}) } + +export const publishEmailSettings = ( + params: Partial< + Pick + >, +) => publishSettings(params) diff --git a/src/routes/settings/alerts/+page.svelte b/src/routes/settings/alerts/+page.svelte index 35c7e52b..44f02c85 100644 --- a/src/routes/settings/alerts/+page.svelte +++ b/src/routes/settings/alerts/+page.svelte @@ -44,6 +44,10 @@ email: $userSettingsValues.email, emailAddress: $userSettingsValues.emailAddress, emailFrequency: $userSettingsValues.emailFrequency, + emailHour: $userSettingsValues.emailHour, + emailMinute: $userSettingsValues.emailMinute, + emailDayOfWeek: $userSettingsValues.emailDayOfWeek, + emailTimezone: $userSettingsValues.emailTimezone, } } @@ -80,7 +84,14 @@ // Handle email notification registration if (settings.email && settings.emailAddress) { try { - await Mail.register(settings.emailAddress, settings.emailFrequency) + await Mail.register( + settings.emailAddress, + settings.emailFrequency, + settings.emailHour, + settings.emailMinute, + settings.emailDayOfWeek, + settings.emailTimezone, + ) await Mail.enable() } catch (e) { settings.email = false @@ -101,6 +112,10 @@ email: settings.email, emailAddress: settings.emailAddress, emailFrequency: settings.emailFrequency, + emailHour: settings.emailHour, + emailMinute: settings.emailMinute, + emailDayOfWeek: settings.emailDayOfWeek, + emailTimezone: settings.emailTimezone, }) notificationSettings.set({ @@ -110,6 +125,7 @@ spaces: settings.spaces, mentions: settings.mentions, messages: settings.messages, + email: settings.email, }) pushToast({message: "Your settings have been saved!"}) @@ -281,12 +297,57 @@ {/snippet}
+ + {#snippet label()} +

Time

+ {/snippet} + {#snippet input()} + { + const [h, m] = e.currentTarget.value.split(":").map(Number) + settings.emailHour = h + settings.emailMinute = m + }} /> + {/snippet} +
+ {#if settings.emailFrequency === "weekly"} + + {#snippet label()} +

Day of week

+ {/snippet} + {#snippet input()} +
+ {#each [{label: "M", value: 1}, {label: "T", value: 2}, {label: "W", value: 3}, {label: "T", value: 4}, {label: "F", value: 5}, {label: "S", value: 6}, {label: "S", value: 7}] as day (day.value)} + + {/each} +
+ {/snippet} +
+ {/if} {/if} {#if $emailNotificationState.error}
-- 2.45.2