Merge branch 'email-notifications' of ssh://forgejo.lorentz.is:4201/matt/flotilla into email-notifications
All checks were successful
CI / lint-check-build (pull_request) Successful in 4m16s

# Conflicts:
#	src/app/mail/index.ts
#	src/app/settings.ts
This commit is contained in:
mplorentz 2026-09-23 17:34:31 -04:00
commit fbec3e1659
3 changed files with 155 additions and 47 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) { if (this._register) {
return this._register return this._register
} }
@ -101,7 +108,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
@ -236,7 +243,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
@ -268,50 +276,74 @@ export class MailNotifications {
sync() { sync() {
let reconciledPubkey: string | undefined let reconciledPubkey: string | undefined
return userSettingsValues.subscribe(async ({email, emailAddress, emailFrequency}) => { return userSettingsValues.subscribe(
const $pubkey = app.get().user?.pubkey async ({
if (!$pubkey || reconciledPubkey === $pubkey) { email,
return emailAddress,
} emailFrequency,
emailHour,
// Wait for the real stored settings. userSettingsValues is projected from emailMinute,
// AppData and reads as the default (email: false, emailAddress: "") until emailDayOfWeek,
// that loads — acting on that would race the disk with a no-op. Only a emailTimezone,
// 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). const $pubkey = app.get().user?.pubkey
if (!email || !emailAddress) { if (!$pubkey || reconciledPubkey === $pubkey) {
return return
}
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()
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,
})
} }
} catch {
return
}
await this.enable() // 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
// 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,
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
}
await this.enable()
},
)
} }
_getSubscriptionIdentifier = async (relay: string, key: string) => { _getSubscriptionIdentifier = async (relay: string, key: string) => {

View file

@ -43,6 +43,10 @@ export type SettingsValues = {
openrouter_key: string openrouter_key: string
min_dm_pow: number min_dm_pow: number
min_dm_wot: number min_dm_wot: number
emailHour: number
emailMinute: number
emailDayOfWeek: number | undefined
emailTimezone: string
} }
export const defaultSettings: SettingsValues = { export const defaultSettings: SettingsValues = {
@ -62,6 +66,10 @@ export const defaultSettings: SettingsValues = {
openrouter_key: "", openrouter_key: "",
min_dm_pow: 16, min_dm_pow: 16,
min_dm_wot: 3, min_dm_wot: 3,
emailHour: 17,
emailMinute: 0,
emailDayOfWeek: undefined,
emailTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
} }
export class Settings extends DerivedPlugin<AppDataReader> { export class Settings extends DerivedPlugin<AppDataReader> {
@ -174,6 +182,7 @@ export const notificationSettings = withGetter(
spaces: true, spaces: true,
mentions: true, mentions: true,
messages: true, messages: true,
email: false,
}), }),
) )
@ -268,3 +277,9 @@ export const toggleRoomMuted = (url: string, h: string) => {
return publishSettings({alerts: [...alerts, {url, notify: true, exceptions: [], muted: [h]}]}) return publishSettings({alerts: [...alerts, {url, notify: true, exceptions: [], muted: [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!"})
@ -285,12 +301,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">