From f9878fdff1b7c0ffa60313a6fefdac642e4d1dbb Mon Sep 17 00:00:00 2001 From: Agent Date: Wed, 23 Sep 2026 14:07:05 -0400 Subject: [PATCH] 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