From 5c4e07bb8ddb6019900c0bd1259a442717ef8a5b Mon Sep 17 00:00:00 2001 From: Agent Date: Wed, 23 Sep 2026 10:54:03 -0400 Subject: [PATCH] feat(settings): add email digest schedule controls Adds digest schedule controls to the Email Digest card in Alert Settings: - emailHour, emailMinute, emailDayOfWeek, emailTimezone in SettingsValues persisted via AppData (publishSettings/publishEmailSettings) - Mail adapter (src/app/mail.ts) with Mail.register/unregister that sends PUT/DELETE /subscription/email to mailship with NIP-98 auth - UI: frequency toggle (daily/weekly), HH:MM time input, day-of-week picker (shown only for weekly), surface mailship errors via toast - VITE_MAILSHIP_URL env var for the mailship backend --- .env | 1 + src/app/env.ts | 2 + src/app/mail.ts | 111 ++++++++++++++++ src/app/settings.ts | 15 +++ src/routes/settings/alerts/+page.svelte | 165 +++++++++++++++++++++++- 5 files changed, 291 insertions(+), 3 deletions(-) create mode 100644 src/app/mail.ts diff --git a/.env b/.env index 4e87c8eb..b805a2e2 100644 --- a/.env +++ b/.env @@ -24,5 +24,6 @@ VITE_SIGNER_RELAYS=relay.nsec.app,ephemeral.snowflare.cc,bucket.coracle.social VITE_THUMBNAIL_URL=https://vthumbs.coracle.social VITE_GLITCHTIP_API_KEY= GLITCHTIP_AUTH_TOKEN= +VITE_MAILSHIP_URL=https://mailship.flotilla.social VITE_HOSTING_BACKEND_URL=https://api.hosting.coracle.social VITE_HOSTING_RELAY_DOMAIN=spaces.flotilla.social diff --git a/src/app/env.ts b/src/app/env.ts index 1d9a02de..68890559 100644 --- a/src/app/env.ts +++ b/src/app/env.ts @@ -73,6 +73,8 @@ export const HOSTING_BACKEND_URL = env("VITE_HOSTING_BACKEND_URL") export const HOSTING_RELAY_DOMAIN = env("VITE_HOSTING_RELAY_DOMAIN") +export const MAILSHIP_URL = env("VITE_MAILSHIP_URL") + export const DUFFLEPUD_URL = "https://dufflepud.coracle.social" export const THUMBNAIL_URL = env("VITE_THUMBNAIL_URL") diff --git a/src/app/mail.ts b/src/app/mail.ts new file mode 100644 index 00000000..69e31efb --- /dev/null +++ b/src/app/mail.ts @@ -0,0 +1,111 @@ +import {writable} from "svelte/store" +import {withGetter} from "@welshman/store" +import {makeHttpAuth, makeHttpAuthHeader} from "@welshman/util" +import {user} from "@app/core" +import {MAILSHIP_URL} from "@app/env" + +export type MailSubscription = { + email: string + frequency: "daily" | "weekly" + hour: number + minute: number + dayOfWeek?: number + timezone: string +} + +export type MailState = { + email?: string + subscription?: MailSubscription + error?: string +} + +export const mailState = withGetter(writable({})) + +let cachedAuth: {pubkey: string; header: Promise} | undefined + +const getAuthHeader = (): Promise => { + const {pubkey, signer} = user.get() + + if (cachedAuth?.pubkey !== pubkey) { + const header = makeHttpAuth(MAILSHIP_URL, "GET") + .then(template => signer.sign(template)) + .then(makeHttpAuthHeader) + + header.catch(() => { + if (cachedAuth?.header === header) cachedAuth = undefined + }) + + cachedAuth = {pubkey, header} + } + + return cachedAuth.header +} + +export class Mail { + static async register( + email: string, + frequency: "daily" | "weekly", + hour: number, + minute: number, + dayOfWeek?: number, + timezone?: string, + ) { + const authorization = await getAuthHeader() + + const body: MailSubscription = { + email, + frequency, + hour, + minute, + dayOfWeek, + timezone: timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC", + } + + try { + const res = await fetch(`${MAILSHIP_URL}/subscription/email`, { + method: "PUT", + headers: {"Content-Type": "application/json", authorization}, + body: JSON.stringify(body), + }) + + if (!res.ok) { + const {error} = await res.json().catch(() => ({error: `Request failed (${res.status})`})) + + throw new Error(error || `Request failed (${res.status})`) + } + + const subscription: MailSubscription = await res.json() + + mailState.set({email, subscription}) + + return subscription + } catch (e) { + mailState.set({email, error: (e as Error).message}) + + throw e + } + } + + static async unregister() { + const authorization = await getAuthHeader() + + try { + const res = await fetch(`${MAILSHIP_URL}/subscription/email`, { + method: "DELETE", + headers: {authorization}, + }) + + if (!res.ok) { + const {error} = await res.json().catch(() => ({error: `Request failed (${res.status})`})) + + throw new Error(error || `Request failed (${res.status})`) + } + + mailState.set({}) + } catch (e) { + mailState.set({error: (e as Error).message}) + + throw e + } + } +} diff --git a/src/app/settings.ts b/src/app/settings.ts index 5d97d1ca..31c4fc43 100644 --- a/src/app/settings.ts +++ b/src/app/settings.ts @@ -31,6 +31,10 @@ export type SettingsValues = { font_size: number alerts: SpaceNotificationSettings[] zap_amounts: number[] + emailHour: number + emailMinute: number + emailDayOfWeek: number | undefined + emailTimezone: string } export const defaultSettings: SettingsValues = { @@ -43,6 +47,10 @@ export const defaultSettings: SettingsValues = { font_size: 1.1, alerts: [], zap_amounts: [21, 210, 2100, 21000], + emailHour: 17, + emailMinute: 0, + emailDayOfWeek: undefined, + emailTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC", } export class Settings extends DerivedPlugin { @@ -97,6 +105,7 @@ export const notificationSettings = withGetter( spaces: true, mentions: true, messages: true, + email: false, }), ) @@ -148,3 +157,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 42a0097e..31533f82 100644 --- a/src/routes/settings/alerts/+page.svelte +++ b/src/routes/settings/alerts/+page.svelte @@ -4,6 +4,7 @@ import {Capacitor} from "@capacitor/core" import {Badge} from "@capawesome/capacitor-badge" import Bell from "@assets/icons/bell.svg?dataurl" + import MailIcon from "@assets/icons/letter.svg?dataurl" import {preventDefault} from "@lib/html" import FieldInline from "@lib/components/FieldInline.svelte" import ToggleInput from "@lib/components/ToggleInput.svelte" @@ -14,10 +15,21 @@ import {pushToast} from "@app/toast" import {clearBadges} from "@app/notifications" import {Push} from "@app/push" - import {notificationSettings} from "@app/settings" + import {notificationSettings, userSettingsValues, publishEmailSettings} from "@app/settings" + import {Mail} from "@app/mail" const reset = () => { - settings = {...notificationSettings.get()} + const userSettings = userSettingsValues.get() + + settings = { + ...notificationSettings.get(), + emailFrequency: userSettings.emailDayOfWeek ? "weekly" : "daily", + emailHour: userSettings.emailHour, + emailMinute: userSettings.emailMinute, + emailDayOfWeek: userSettings.emailDayOfWeek, + emailTimezone: userSettings.emailTimezone, + emailAddress: "", + } } const onsubmit = preventDefault(async () => { @@ -45,6 +57,45 @@ } } + if (settings.email) { + const timezone = + settings.emailTimezone || Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC" + + if (!settings.emailAddress) { + pushToast({theme: "error", message: "Please enter an email address."}) + + return + } + + try { + await Mail.register( + settings.emailAddress, + settings.emailFrequency, + settings.emailHour, + settings.emailMinute, + settings.emailDayOfWeek, + timezone, + ) + + await publishEmailSettings({ + emailHour: settings.emailHour, + emailMinute: settings.emailMinute, + emailDayOfWeek: settings.emailDayOfWeek, + emailTimezone: timezone, + }) + } catch (e) { + pushToast({theme: "error", message: (e as Error).message}) + + return + } + } else { + try { + await Mail.unregister() + } catch (e) { + console.error(e) + } + } + notificationSettings.set(settings) pushToast({message: "Your settings have been saved!"}) @@ -54,7 +105,18 @@ }) let loading = $state(false) - let settings = $state({...notificationSettings.get()}) + + let settings = $state({ + ...notificationSettings.get(), + emailFrequency: (userSettingsValues.get().emailDayOfWeek ? "weekly" : "daily") as + | "daily" + | "weekly", + emailHour: userSettingsValues.get().emailHour, + emailMinute: userSettingsValues.get().emailMinute, + emailDayOfWeek: userSettingsValues.get().emailDayOfWeek, + emailTimezone: userSettingsValues.get().emailTimezone, + emailAddress: "", + })
@@ -97,6 +159,103 @@ {/snippet} +
+ + + Email Digest + + + {#snippet label()} +

Enable email digest

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

Email address

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

Frequency

+ {/snippet} + {#snippet input()} +
+ + +
+ {/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} +