2026-09-03 17:43:10 +00:00
|
|
|
import {call, hash, maybe, spec, throttle} from "@welshman/lib"
|
2026-08-20 14:55:45 +00:00
|
|
|
import type {Filter} from "@welshman/util"
|
2026-09-03 17:04:29 +00:00
|
|
|
import {Address, DELETE, MESSAGE, makeEvent, makeHttpAuth, makeHttpAuthHeader} from "@welshman/util"
|
|
|
|
|
import {merged} from "@welshman/store"
|
2026-08-20 14:55:45 +00:00
|
|
|
import {Relays, User} from "@welshman/app"
|
|
|
|
|
import {buildUrl} from "@lib/util"
|
2026-09-03 17:04:29 +00:00
|
|
|
import {app, messagingRelayLists, roomLists, thunks} from "@app/core"
|
2026-08-20 14:55:45 +00:00
|
|
|
import {EMAIL_NOTIFICATION_SERVER, PUSH_BRIDGE} from "@app/env"
|
2026-09-03 16:47:36 +00:00
|
|
|
import {ensureEmailSalt, userSettingsValues} from "@app/settings"
|
2026-09-03 17:04:29 +00:00
|
|
|
import {emailNotificationState} from "@app/push/adapters/common"
|
|
|
|
|
import {DM_KINDS, CONTENT_KINDS, makeCommentFilter} from "@app/content"
|
|
|
|
|
|
|
|
|
|
// Email digests sync their own kind 30390 subscriptions. Unlike push, the email
|
|
|
|
|
// subscription is driven by the email digest setting (SettingsValues.email) plus
|
|
|
|
|
// per-space alert preferences, and must NOT be gated on the push notificationSettings
|
|
|
|
|
// toggles - otherwise turning off push "new activity" would delete the 30390 and stop
|
|
|
|
|
// email digests entirely.
|
|
|
|
|
const syncRelaySubscriptionsForEmail = (
|
|
|
|
|
signal: AbortSignal,
|
|
|
|
|
sync: (url: string, key: string, filters: Filter[], ignore: Filter[]) => void,
|
|
|
|
|
) => {
|
|
|
|
|
const $pubkey = User.require(app.get()).pubkey
|
|
|
|
|
|
|
|
|
|
const unsubscribeSpaces = merged([roomLists.get().urls($pubkey).$, userSettingsValues]).subscribe(
|
|
|
|
|
throttle(3000, ([$spaceUrls, {email, alerts}]) => {
|
|
|
|
|
const baseFilters = [{kinds: [MESSAGE, ...CONTENT_KINDS]}, makeCommentFilter(CONTENT_KINDS)]
|
|
|
|
|
|
|
|
|
|
for (const url of $spaceUrls) {
|
|
|
|
|
const filters: Filter[] = []
|
|
|
|
|
const ignore: Filter[] = []
|
|
|
|
|
|
|
|
|
|
if (!email) {
|
|
|
|
|
sync(url, "spaces", filters, [])
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const {notify = true, exceptions = []} = alerts.find(spec({url})) || {}
|
|
|
|
|
|
|
|
|
|
if (notify) {
|
|
|
|
|
if (exceptions.length > 0) {
|
|
|
|
|
ignore.push({"#h": exceptions})
|
|
|
|
|
}
|
|
|
|
|
filters.push(...baseFilters)
|
|
|
|
|
} else if (exceptions.length > 0) {
|
|
|
|
|
filters.push(...baseFilters.map(f => ({...f, "#h": exceptions})))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Mentions are part of the digest regardless of push settings, and
|
|
|
|
|
// guarantee there is always at least one filter so the subscription
|
|
|
|
|
// stays live while email digests are enabled.
|
|
|
|
|
filters.push(...baseFilters.map(f => ({...f, "#p": [$pubkey]})))
|
|
|
|
|
|
|
|
|
|
sync(url, "spaces", filters, ignore)
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const unsubscribeMessages = merged([
|
|
|
|
|
messagingRelayLists.get().urls($pubkey).$,
|
|
|
|
|
userSettingsValues,
|
|
|
|
|
]).subscribe(
|
|
|
|
|
throttle(3000, ([$messagingUrls, {email}]) => {
|
|
|
|
|
for (const url of $messagingUrls) {
|
|
|
|
|
sync(url, "messages", email ? [{kinds: DM_KINDS, "#p": [$pubkey]}] : [], [])
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
signal.addEventListener("abort", () => {
|
|
|
|
|
unsubscribeSpaces()
|
|
|
|
|
unsubscribeMessages()
|
|
|
|
|
})
|
|
|
|
|
}
|
2026-08-20 14:55:45 +00:00
|
|
|
|
|
|
|
|
export class MailNotifications {
|
|
|
|
|
_controller = maybe<AbortController>()
|
|
|
|
|
|
2026-09-03 17:43:10 +00:00
|
|
|
// In-flight register promise, so overlapping calls (settings page submit plus
|
|
|
|
|
// Mail.sync) coalesce into a single PUT instead of racing two subscriptions.
|
|
|
|
|
_register = maybe<Promise<void>>()
|
|
|
|
|
|
feat(settings): merge email digest schedule controls onto email-notifications
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 18:07:05 +00:00
|
|
|
async register(
|
|
|
|
|
email: string,
|
|
|
|
|
frequency: string,
|
|
|
|
|
hour = 17,
|
|
|
|
|
minute = 0,
|
|
|
|
|
dayOfWeek?: number,
|
|
|
|
|
timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
|
|
|
|
|
) {
|
2026-09-23 21:11:53 +00:00
|
|
|
if (this._register) {
|
|
|
|
|
return this._register
|
|
|
|
|
}
|
2026-09-03 17:43:10 +00:00
|
|
|
|
|
|
|
|
this._register = call(async () => {
|
|
|
|
|
try {
|
|
|
|
|
const $user = User.require(app.get())
|
|
|
|
|
const $signer = $user.signer
|
|
|
|
|
|
|
|
|
|
if (!$signer) {
|
|
|
|
|
emailNotificationState.set({
|
|
|
|
|
error: "No signer available. Check your browser extension.",
|
|
|
|
|
})
|
|
|
|
|
throw new Error("No signer available")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!EMAIL_NOTIFICATION_SERVER) {
|
|
|
|
|
emailNotificationState.set({error: "Email notification server is not configured."})
|
|
|
|
|
throw new Error("Email notification server not configured")
|
|
|
|
|
}
|
|
|
|
|
|
feat(settings): merge email digest schedule controls onto email-notifications
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 18:07:05 +00:00
|
|
|
const body = {email, frequency, hour, minute, dayOfWeek, timezone}
|
2026-09-16 17:50:47 +00:00
|
|
|
const bodyJson = JSON.stringify(body)
|
|
|
|
|
|
2026-09-03 17:43:10 +00:00
|
|
|
let authEvent
|
|
|
|
|
try {
|
2026-09-16 17:50:47 +00:00
|
|
|
authEvent = await $signer.sign(
|
|
|
|
|
await makeHttpAuth(
|
|
|
|
|
buildUrl(EMAIL_NOTIFICATION_SERVER, "subscription", "email"),
|
|
|
|
|
"PUT",
|
|
|
|
|
bodyJson,
|
|
|
|
|
),
|
|
|
|
|
)
|
2026-09-03 17:43:10 +00:00
|
|
|
} catch (e) {
|
|
|
|
|
emailNotificationState.set({
|
|
|
|
|
error: "Failed to create authentication. Check your signer.",
|
|
|
|
|
})
|
|
|
|
|
throw new Error("Failed to sign NIP-98 auth", {cause: e})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const authHeader = makeHttpAuthHeader(authEvent)
|
|
|
|
|
|
|
|
|
|
let res
|
|
|
|
|
try {
|
|
|
|
|
res = await fetch(buildUrl(EMAIL_NOTIFICATION_SERVER, "subscription", "email"), {
|
|
|
|
|
method: "PUT",
|
2026-09-16 17:50:47 +00:00
|
|
|
body: bodyJson,
|
2026-09-03 17:43:10 +00:00
|
|
|
headers: {Authorization: authHeader, "Content-Type": "application/json"},
|
|
|
|
|
})
|
|
|
|
|
} catch (e) {
|
|
|
|
|
emailNotificationState.set({
|
|
|
|
|
error: `Could not reach email server. Check your connection.`,
|
|
|
|
|
})
|
|
|
|
|
throw new Error("Network error", {cause: e})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
const text = await res.text().catch(() => "unknown error")
|
|
|
|
|
emailNotificationState.set({error: `Registration failed: ${text}`})
|
|
|
|
|
throw new Error(`Registration failed: ${text}`)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const {key, callback} = await res.json()
|
|
|
|
|
emailNotificationState.set({key, callback, email, frequency, error: undefined})
|
|
|
|
|
} finally {
|
|
|
|
|
this._register = undefined
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return this._register
|
2026-08-20 14:55:45 +00:00
|
|
|
}
|
|
|
|
|
|
2026-09-22 16:31:56 +00:00
|
|
|
// Fetch the existing subscription for this pubkey (if any) without creating
|
|
|
|
|
// or changing anything. Used at boot to restore state instead of re-PUTting,
|
|
|
|
|
// which would otherwise re-send a confirmation email for an unconfirmed
|
|
|
|
|
// address on every reload.
|
|
|
|
|
async lookup(): Promise<
|
|
|
|
|
| {
|
|
|
|
|
key: string
|
|
|
|
|
callback: string
|
|
|
|
|
email: string
|
|
|
|
|
frequency: string
|
|
|
|
|
confirmed: boolean
|
|
|
|
|
}
|
|
|
|
|
| undefined
|
|
|
|
|
> {
|
|
|
|
|
const $signer = app.get().user?.signer
|
2026-09-23 21:11:53 +00:00
|
|
|
if (!$signer || !EMAIL_NOTIFICATION_SERVER) {
|
|
|
|
|
return undefined
|
|
|
|
|
}
|
2026-09-22 16:31:56 +00:00
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const authEvent = await $signer.sign(
|
|
|
|
|
await makeHttpAuth(buildUrl(EMAIL_NOTIFICATION_SERVER, "subscription", "email"), "GET"),
|
|
|
|
|
)
|
|
|
|
|
const authHeader = makeHttpAuthHeader(authEvent)
|
|
|
|
|
|
|
|
|
|
const res = await fetch(buildUrl(EMAIL_NOTIFICATION_SERVER, "subscription", "email"), {
|
|
|
|
|
method: "GET",
|
|
|
|
|
headers: {Authorization: authHeader},
|
|
|
|
|
})
|
|
|
|
|
|
2026-09-23 21:11:53 +00:00
|
|
|
if (!res.ok) {
|
|
|
|
|
return undefined
|
|
|
|
|
}
|
2026-09-22 16:31:56 +00:00
|
|
|
|
|
|
|
|
return await res.json()
|
|
|
|
|
} catch {
|
|
|
|
|
return undefined
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-20 14:55:45 +00:00
|
|
|
async unregister() {
|
|
|
|
|
const {key} = emailNotificationState.get()
|
2026-09-23 21:11:53 +00:00
|
|
|
if (!key) {
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-08-20 14:55:45 +00:00
|
|
|
|
2026-08-20 15:51:26 +00:00
|
|
|
const $signer = app.get().user?.signer
|
2026-09-23 21:11:53 +00:00
|
|
|
if (!$signer) {
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-08-20 14:55:45 +00:00
|
|
|
|
|
|
|
|
try {
|
2026-09-16 17:50:47 +00:00
|
|
|
const authEvent = await $signer.sign(
|
|
|
|
|
await makeHttpAuth(buildUrl(EMAIL_NOTIFICATION_SERVER, "subscription", key), "DELETE"),
|
|
|
|
|
)
|
2026-08-20 14:55:45 +00:00
|
|
|
const authHeader = makeHttpAuthHeader(authEvent)
|
|
|
|
|
|
|
|
|
|
await fetch(buildUrl(EMAIL_NOTIFICATION_SERVER, "subscription", key), {
|
|
|
|
|
method: "DELETE",
|
|
|
|
|
headers: {Authorization: authHeader},
|
|
|
|
|
})
|
|
|
|
|
} catch {
|
|
|
|
|
// Best effort
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
emailNotificationState.set({})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async enable() {
|
2026-09-23 21:11:53 +00:00
|
|
|
if (this._controller) {
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-08-20 14:55:45 +00:00
|
|
|
|
|
|
|
|
const {email, frequency} = emailNotificationState.get()
|
|
|
|
|
|
|
|
|
|
if (!EMAIL_NOTIFICATION_SERVER) {
|
|
|
|
|
console.warn("Email notification server not configured")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this._controller = new AbortController()
|
|
|
|
|
|
|
|
|
|
// Register if we don't have a subscription yet
|
|
|
|
|
if (!emailNotificationState.get().callback && email && frequency) {
|
feat(settings): merge email digest schedule controls onto email-notifications
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 18:07:05 +00:00
|
|
|
const {emailHour, emailMinute, emailDayOfWeek, emailTimezone} = userSettingsValues.get()
|
|
|
|
|
await this.register(email, frequency, emailHour, emailMinute, emailDayOfWeek, emailTimezone)
|
2026-08-20 14:55:45 +00:00
|
|
|
}
|
|
|
|
|
|
2026-09-03 17:04:29 +00:00
|
|
|
// Sync kind 30390 subscriptions for the email digest. Filters are derived
|
|
|
|
|
// from the email setting + per-space alerts, independent of push toggles.
|
|
|
|
|
syncRelaySubscriptionsForEmail(
|
2026-08-20 14:55:45 +00:00
|
|
|
this._controller.signal,
|
|
|
|
|
async (url: string, key: string, filters: Filter[], ignore: Filter[]) => {
|
|
|
|
|
const {callback} = emailNotificationState.get()
|
2026-09-23 21:11:53 +00:00
|
|
|
if (!callback) {
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-08-20 14:55:45 +00:00
|
|
|
|
|
|
|
|
if (filters.length > 0) {
|
|
|
|
|
await this._syncRelay(url, key, filters, ignore)
|
|
|
|
|
} else {
|
|
|
|
|
await this._unsyncRelay(url, key)
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async disable() {
|
|
|
|
|
this._controller?.abort()
|
|
|
|
|
this._controller = undefined
|
|
|
|
|
|
|
|
|
|
await this.unregister()
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-27 19:08:37 +00:00
|
|
|
sync() {
|
2026-09-21 15:54:19 +00:00
|
|
|
let reconciledPubkey: string | undefined
|
2026-08-27 19:08:37 +00:00
|
|
|
|
feat(settings): merge email digest schedule controls onto email-notifications
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 18:07:05 +00:00
|
|
|
return userSettingsValues.subscribe(
|
|
|
|
|
async ({
|
|
|
|
|
email,
|
|
|
|
|
emailAddress,
|
|
|
|
|
emailFrequency,
|
|
|
|
|
emailHour,
|
|
|
|
|
emailMinute,
|
|
|
|
|
emailDayOfWeek,
|
|
|
|
|
emailTimezone,
|
|
|
|
|
}) => {
|
|
|
|
|
const $pubkey = app.get().user?.pubkey
|
2026-09-23 21:34:31 +00:00
|
|
|
if (!$pubkey || reconciledPubkey === $pubkey) {
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-09-21 15:54:19 +00:00
|
|
|
|
feat(settings): merge email digest schedule controls onto email-notifications
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 18:07:05 +00:00
|
|
|
// 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).
|
2026-09-23 21:34:31 +00:00
|
|
|
if (!email || !emailAddress) {
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-09-21 15:54:19 +00:00
|
|
|
|
feat(settings): merge email digest schedule controls onto email-notifications
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 18:07:05 +00:00
|
|
|
reconciledPubkey = $pubkey
|
2026-09-21 15:54:19 +00:00
|
|
|
|
feat(settings): merge email digest schedule controls onto email-notifications
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 18:07:05 +00:00
|
|
|
// 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()
|
2026-09-22 16:31:56 +00:00
|
|
|
|
feat(settings): merge email digest schedule controls onto email-notifications
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 18:07:05 +00:00
|
|
|
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
|
2026-09-22 16:31:56 +00:00
|
|
|
}
|
2026-09-21 15:54:19 +00:00
|
|
|
|
feat(settings): merge email digest schedule controls onto email-notifications
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 18:07:05 +00:00
|
|
|
await this.enable()
|
|
|
|
|
},
|
|
|
|
|
)
|
2026-08-27 19:08:37 +00:00
|
|
|
}
|
|
|
|
|
|
2026-09-03 16:47:36 +00:00
|
|
|
_getSubscriptionIdentifier = async (relay: string, key: string) => {
|
|
|
|
|
const salt = await ensureEmailSalt()
|
|
|
|
|
|
|
|
|
|
return String(hash(relay + key + salt + "email"))
|
|
|
|
|
}
|
2026-08-20 14:55:45 +00:00
|
|
|
|
|
|
|
|
_getPushUrl = async (url: string) => {
|
|
|
|
|
for (const candidate of [url, PUSH_BRIDGE]) {
|
|
|
|
|
const relay = await app.get().use(Relays).load(candidate)
|
|
|
|
|
|
|
|
|
|
if (relay?.hasNip("9a")) {
|
|
|
|
|
return candidate
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_syncRelay = async (relay: string, key: string, filters: Filter[], ignore: Filter[] = []) => {
|
|
|
|
|
const {callback} = emailNotificationState.get()
|
2026-09-23 21:11:53 +00:00
|
|
|
if (!callback) {
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-08-20 14:55:45 +00:00
|
|
|
|
|
|
|
|
const url = await this._getPushUrl(relay)
|
|
|
|
|
if (!url) {
|
|
|
|
|
console.warn(`Failed to subscribe ${relay} to email notifications: unsupported`)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-03 16:47:36 +00:00
|
|
|
const identifier = await this._getSubscriptionIdentifier(relay, key)
|
2026-08-20 14:55:45 +00:00
|
|
|
|
|
|
|
|
const thunk = thunks.get().publish({
|
|
|
|
|
relays: [url],
|
|
|
|
|
event: makeEvent(30390, {
|
|
|
|
|
tags: [
|
|
|
|
|
["d", identifier],
|
|
|
|
|
["relay", relay],
|
|
|
|
|
["callback", callback],
|
2026-08-27 19:44:37 +00:00
|
|
|
["include_event", "true"],
|
2026-08-20 14:55:45 +00:00
|
|
|
...ignore.map(f => ["ignore", JSON.stringify(f)]),
|
|
|
|
|
...filters.map(f => ["filter", JSON.stringify(f)]),
|
|
|
|
|
],
|
|
|
|
|
}),
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const error = await thunk.waitForError()
|
|
|
|
|
if (error) {
|
|
|
|
|
console.warn(`Failed to subscribe ${relay} to email notifications:`, error)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_unsyncRelay = async (relay: string, key: string) => {
|
|
|
|
|
const url = await this._getPushUrl(relay)
|
2026-09-23 21:11:53 +00:00
|
|
|
if (!url) {
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-08-20 14:55:45 +00:00
|
|
|
|
|
|
|
|
const $pubkey = User.require(app.get()).pubkey
|
2026-09-03 16:47:36 +00:00
|
|
|
const identifier = await this._getSubscriptionIdentifier(relay, key)
|
2026-08-20 14:55:45 +00:00
|
|
|
const address = new Address(30390, $pubkey, identifier).toString()
|
|
|
|
|
const event = makeEvent(DELETE, {tags: [["a", address]]})
|
|
|
|
|
const error = await thunks
|
|
|
|
|
.get()
|
|
|
|
|
.publish({relays: [url], event})
|
|
|
|
|
.waitForError()
|
|
|
|
|
|
|
|
|
|
if (error) {
|
|
|
|
|
console.warn(`Failed to unsubscribe ${relay} from email notifications:`, error)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const Mail = new MailNotifications()
|