Merge pull request 'Add email digest schedule controls to Alert Settings' (#5) from flotilla-fc1-email-digests-let-users-choose-delivery--f63 into email-notifications
All checks were successful
CI / lint-check-build (pull_request) Successful in 4m17s

Reviewed-on: #5
Reviewed-by: matt <matt@lorentz.is>
This commit is contained in:
matt 2026-09-23 20:37:15 +00:00
commit 3df670c9f8
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.
_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
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) => {

View file

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

View file

@ -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 @@
<select
class="select select-bordered"
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="weekly">Weekly</option>
</select>
{/snippet}
</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 $emailNotificationState.error}
<div class="text-sm text-error p-3">