Compare commits

...

2 commits

Author SHA1 Message Date
3df670c9f8 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>
2026-09-23 20:37:15 +00:00
Agent
f9878fdff1 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}.
2026-09-23 14:09:39 -04:00
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,7 +262,16 @@ export class MailNotifications {
sync() {
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
if (!$pubkey || reconciledPubkey === $pubkey) return
@ -276,9 +293,23 @@ export class MailNotifications {
try {
if (!existing) {
await this.register(emailAddress, emailFrequency)
await this.register(
emailAddress,
emailFrequency,
emailHour,
emailMinute,
emailDayOfWeek,
emailTimezone,
)
} else if (existing.email !== emailAddress || existing.frequency !== emailFrequency) {
await this.register(emailAddress, emailFrequency)
await this.register(
emailAddress,
emailFrequency,
emailHour,
emailMinute,
emailDayOfWeek,
emailTimezone,
)
} else {
emailNotificationState.set({
key: existing.key,
@ -293,7 +324,8 @@ export class MailNotifications {
}
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">