feat(settings): merge email digest schedule controls onto email-notifications
All checks were successful
CI / lint-check-build (pull_request) Successful in 3m19s

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}.
This commit is contained in:
Agent 2026-09-23 14:07:05 -04:00
parent 609a2f65ea
commit f9878fdff1
3 changed files with 147 additions and 39 deletions

View file

@ -79,7 +79,14 @@ export class MailNotifications {
// Mail.sync) coalesce into a single PUT instead of racing two subscriptions. // Mail.sync) coalesce into a single PUT instead of racing two subscriptions.
_register = maybe<Promise<void>>() _register = maybe<Promise<void>>()
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 if (this._register) return this._register
this._register = call(async () => { this._register = call(async () => {
@ -99,7 +106,7 @@ export class MailNotifications {
throw new Error("Email notification server not configured") 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) const bodyJson = JSON.stringify(body)
let authEvent let authEvent
@ -224,7 +231,8 @@ export class MailNotifications {
// Register if we don't have a subscription yet // Register if we don't have a subscription yet
if (!emailNotificationState.get().callback && email && frequency) { 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 // Sync kind 30390 subscriptions for the email digest. Filters are derived
@ -254,7 +262,16 @@ export class MailNotifications {
sync() { sync() {
let reconciledPubkey: string | undefined let reconciledPubkey: string | undefined
return userSettingsValues.subscribe(async ({email, emailAddress, emailFrequency}) => { return userSettingsValues.subscribe(
async ({
email,
emailAddress,
emailFrequency,
emailHour,
emailMinute,
emailDayOfWeek,
emailTimezone,
}) => {
const $pubkey = app.get().user?.pubkey const $pubkey = app.get().user?.pubkey
if (!$pubkey || reconciledPubkey === $pubkey) return if (!$pubkey || reconciledPubkey === $pubkey) return
@ -276,9 +293,23 @@ export class MailNotifications {
try { try {
if (!existing) { if (!existing) {
await this.register(emailAddress, emailFrequency) await this.register(
emailAddress,
emailFrequency,
emailHour,
emailMinute,
emailDayOfWeek,
emailTimezone,
)
} else if (existing.email !== emailAddress || existing.frequency !== emailFrequency) { } else if (existing.email !== emailAddress || existing.frequency !== emailFrequency) {
await this.register(emailAddress, emailFrequency) await this.register(
emailAddress,
emailFrequency,
emailHour,
emailMinute,
emailDayOfWeek,
emailTimezone,
)
} else { } else {
emailNotificationState.set({ emailNotificationState.set({
key: existing.key, key: existing.key,
@ -293,7 +324,8 @@ export class MailNotifications {
} }
await this.enable() await this.enable()
}) },
)
} }
_getSubscriptionIdentifier = async (relay: string, key: string) => { _getSubscriptionIdentifier = async (relay: string, key: string) => {

View file

@ -37,6 +37,10 @@ export type SettingsValues = {
emailAddress: string emailAddress: string
emailFrequency: string emailFrequency: string
emailSalt: string emailSalt: string
emailHour: number
emailMinute: number
emailDayOfWeek: number | undefined
emailTimezone: string
} }
export const defaultSettings: SettingsValues = { export const defaultSettings: SettingsValues = {
@ -53,6 +57,10 @@ export const defaultSettings: SettingsValues = {
emailAddress: "", emailAddress: "",
emailFrequency: "daily", emailFrequency: "daily",
emailSalt: "", emailSalt: "",
emailHour: 17,
emailMinute: 0,
emailDayOfWeek: undefined,
emailTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
} }
export class Settings extends DerivedPlugin<AppDataReader> { export class Settings extends DerivedPlugin<AppDataReader> {
@ -107,6 +115,7 @@ export const notificationSettings = withGetter(
spaces: true, spaces: true,
mentions: true, mentions: true,
messages: 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]}]}) return publishSettings({alerts: [...alerts, {url, notify: true, exceptions: [h]}]})
} }
export const publishEmailSettings = (
params: Partial<
Pick<SettingsValues, "emailHour" | "emailMinute" | "emailDayOfWeek" | "emailTimezone">
>,
) => publishSettings(params)

View file

@ -44,6 +44,10 @@
email: $userSettingsValues.email, email: $userSettingsValues.email,
emailAddress: $userSettingsValues.emailAddress, emailAddress: $userSettingsValues.emailAddress,
emailFrequency: $userSettingsValues.emailFrequency, emailFrequency: $userSettingsValues.emailFrequency,
emailHour: $userSettingsValues.emailHour,
emailMinute: $userSettingsValues.emailMinute,
emailDayOfWeek: $userSettingsValues.emailDayOfWeek,
emailTimezone: $userSettingsValues.emailTimezone,
} }
} }
@ -80,7 +84,14 @@
// Handle email notification registration // Handle email notification registration
if (settings.email && settings.emailAddress) { if (settings.email && settings.emailAddress) {
try { 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() await Mail.enable()
} catch (e) { } catch (e) {
settings.email = false settings.email = false
@ -101,6 +112,10 @@
email: settings.email, email: settings.email,
emailAddress: settings.emailAddress, emailAddress: settings.emailAddress,
emailFrequency: settings.emailFrequency, emailFrequency: settings.emailFrequency,
emailHour: settings.emailHour,
emailMinute: settings.emailMinute,
emailDayOfWeek: settings.emailDayOfWeek,
emailTimezone: settings.emailTimezone,
}) })
notificationSettings.set({ notificationSettings.set({
@ -110,6 +125,7 @@
spaces: settings.spaces, spaces: settings.spaces,
mentions: settings.mentions, mentions: settings.mentions,
messages: settings.messages, messages: settings.messages,
email: settings.email,
}) })
pushToast({message: "Your settings have been saved!"}) pushToast({message: "Your settings have been saved!"})
@ -281,12 +297,57 @@
<select <select
class="select select-bordered" class="select select-bordered"
bind:value={settings.emailFrequency} bind:value={settings.emailFrequency}
onchange={clearError}> onchange={e => {
if (e.currentTarget.value === "weekly" && !settings.emailDayOfWeek) {
settings.emailDayOfWeek = 1
}
clearError()
}}>
<option value="daily">Daily</option> <option value="daily">Daily</option>
<option value="weekly">Weekly</option> <option value="weekly">Weekly</option>
</select> </select>
{/snippet} {/snippet}
</FieldInline> </FieldInline>
<FieldInline>
{#snippet label()}
<p>Time</p>
{/snippet}
{#snippet input()}
<input
type="time"
class="input input-bordered 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} {/if}
{#if $emailNotificationState.error} {#if $emailNotificationState.error}
<div class="text-sm text-error p-3"> <div class="text-sm text-error p-3">