feat(settings): add email digest schedule controls
All checks were successful
CI / lint-check-build (pull_request) Successful in 3m26s

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
This commit is contained in:
Agent 2026-09-23 10:54:03 -04:00
parent e3e2e2f57c
commit 5c4e07bb8d
5 changed files with 291 additions and 3 deletions

1
.env
View file

@ -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

View file

@ -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")

111
src/app/mail.ts Normal file
View file

@ -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<MailState>({}))
let cachedAuth: {pubkey: string; header: Promise<string>} | undefined
const getAuthHeader = (): Promise<string> => {
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
}
}
}

View file

@ -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<AppDataReader> {
@ -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<SettingsValues, "emailHour" | "emailMinute" | "emailDayOfWeek" | "emailTimezone">
>,
) => publishSettings(params)

View file

@ -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: "",
})
</script>
<form {onsubmit}>
@ -97,6 +159,103 @@
{/snippet}
</FieldInline>
</div>
<div class="card flex flex-col gap-4 shadow-md">
<strong class="flex items-center gap-3 text-lg">
<Icon icon={MailIcon} />
Email Digest
</strong>
<FieldInline>
{#snippet label()}
<p>Enable email digest</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"
placeholder="you@example.com"
bind:value={settings.emailAddress} />
{/snippet}
</FieldInline>
<FieldInline>
{#snippet label()}
<p>Frequency</p>
{/snippet}
{#snippet input()}
<div class="flex gap-1 rounded-lg bg-surface-dim p-1">
<button
type="button"
class={cx("button button-sm rounded-md px-3 py-1", {
"button-primary": settings.emailFrequency === "daily",
"button-ghost": settings.emailFrequency !== "daily",
})}
onclick={() => {
settings.emailFrequency = "daily"
settings.emailDayOfWeek = undefined
}}>Daily</button>
<button
type="button"
class={cx("button button-sm rounded-md px-3 py-1", {
"button-primary": settings.emailFrequency === "weekly",
"button-ghost": settings.emailFrequency !== "weekly",
})}
onclick={() => {
settings.emailFrequency = "weekly"
settings.emailDayOfWeek = 1
}}>Weekly</button>
</div>
{/snippet}
</FieldInline>
<FieldInline>
{#snippet label()}
<p>Time</p>
{/snippet}
{#snippet input()}
<input
type="time"
class="input w-auto"
value={String(settings.emailHour).padStart(2, "0") +
":" +
String(settings.emailMinute).padStart(2, "0")}
oninput={e => {
const [h, m] = e.currentTarget.value.split(":").map(Number)
settings.emailHour = h
settings.emailMinute = m
}} />
{/snippet}
</FieldInline>
{#if settings.emailFrequency === "weekly"}
<FieldInline>
{#snippet label()}
<p>Day of week</p>
{/snippet}
{#snippet input()}
<div class="flex gap-1">
{#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)}
<button
type="button"
class={cx("button button-sm size-8 rounded-md", {
"button-primary": settings.emailDayOfWeek === day.value,
"button-ghost": settings.emailDayOfWeek !== day.value,
})}
onclick={() => {
settings.emailDayOfWeek = day.value
}}>{day.label}</button>
{/each}
</div>
{/snippet}
</FieldInline>
{/if}
{/if}
</div>
<div
class={cx("card bg-surface flex flex-col gap-4 shadow-md", {
"pointer-events-none opacity-50": !settings.badge && !settings.sound && !settings.push,