From 9499b6a9a72778b5c2d68e99c5bfaec34ef6df2b Mon Sep 17 00:00:00 2001 From: Amr Mohammed El-Sheraey <141947355+AmrMsCLL@users.noreply.github.com> Date: Sat, 7 Nov 2026 01:26:16 +0200 Subject: [PATCH 1/2] feat(email): subscribers, sends, the weekly digest and editable email templates on /admin --- site/app/context.ts | 2 + site/app/routes/admin.tsx | 172 +++++- site/app/settings.ts | 89 +++- site/app/styles/pages/subscribe.css | 361 +++++++++++++ site/app/subscribe.ts | 87 ++++ site/server/app.ts | 19 + site/server/digest.test.ts | 42 ++ site/server/digest.ts | 26 + site/server/emails.ts | 256 +++++++++ site/server/jobs.test.ts | 26 + site/server/jobs.ts | 15 +- site/server/lifecycle.ts | 6 + site/server/mail.ts | 39 ++ site/server/main.ts | 22 +- site/server/migrations/007-subscribers.sql | 30 ++ site/server/posts.ts | 8 +- site/server/sends.test.ts | 139 +++++ site/server/sends.ts | 194 +++++++ site/server/subscribers.test.ts | 352 +++++++++++++ site/server/subscribers.ts | 574 +++++++++++++++++++++ site/server/testing.ts | 9 +- 21 files changed, 2456 insertions(+), 12 deletions(-) create mode 100644 site/app/styles/pages/subscribe.css create mode 100644 site/app/subscribe.ts create mode 100644 site/server/digest.test.ts create mode 100644 site/server/digest.ts create mode 100644 site/server/emails.ts create mode 100644 site/server/migrations/007-subscribers.sql create mode 100644 site/server/sends.test.ts create mode 100644 site/server/sends.ts create mode 100644 site/server/subscribers.test.ts create mode 100644 site/server/subscribers.ts diff --git a/site/app/context.ts b/site/app/context.ts index b20414e..7b60476 100644 --- a/site/app/context.ts +++ b/site/app/context.ts @@ -11,6 +11,7 @@ import type { Writeback } from '../server/writeback.ts'; import type { Uploads } from '../server/uploads.ts'; import type { Share } from '../server/share.ts'; import type { Reads } from '../server/reads.ts'; +import type { Subscribers } from '../server/subscribers.ts'; import type { Values } from './settings'; import type { Directory, Viewer } from './types'; @@ -26,6 +27,7 @@ export interface Hub { writeback: Writeback; uploads: Uploads; posts: Posts; + subscribers: Subscribers; collab: Collab; address: string; origin: string; diff --git a/site/app/routes/admin.tsx b/site/app/routes/admin.tsx index 0cb2b98..88fe472 100644 --- a/site/app/routes/admin.tsx +++ b/site/app/routes/admin.tsx @@ -12,6 +12,7 @@ import { isSettingKey, SETTINGS, type Area, + type ChoiceSetting, type ColorsSetting, type NumberSetting, type OrderItem, @@ -20,8 +21,9 @@ import { type SettingKey, type TextSetting, } from '../settings'; +import { describeScope, parseScope, validEmail } from '../subscribe'; -export const handle = { topics: ['settings', 'errors'] }; +export const handle = { topics: ['settings', 'errors', 'subscribers'] }; function owner(hub: Hub) { if (!hub.viewer?.owner) { @@ -38,6 +40,7 @@ export function loader({ context, request }: Route.LoaderArgs) { errors: hub.admin.errors(), waiting: hub.messages.queue().length, history: key && isSettingKey(key) ? { key, versions: hub.admin.history(key) } : null, + email: hub.subscribers.overview(), }; } @@ -67,6 +70,17 @@ export async function action({ context, request }: Route.ActionArgs) { break; case 'sign-out-everyone': return { ok: `${await hub.admin.signOutEveryone(viewer.login)} sessions ended.` }; + case 'test-email': { + const to = String(form.get('to') ?? '') + .trim() + .toLowerCase(); + const kind = String(form.get('kind')); + if (!validEmail(to) || !['confirm', 'post', 'digest'].includes(kind)) { + return data({ error: 'Type an address to send the test to.', key }, { status: 400 }); + } + await hub.subscribers.sendTest(kind as 'confirm' | 'post' | 'digest', to); + return { ok: `${kind} sent to ${to}`, key: 'test-email' }; + } } } catch (error) { return data( @@ -77,7 +91,13 @@ export async function action({ context, request }: Route.ActionArgs) { return { ok: 'saved', key }; } -const AREAS: Record = { site: 'site', home: 'home page', rail: 'rail', blog: 'blog' }; +const AREAS: Record = { + site: 'site', + home: 'home page', + rail: 'rail', + blog: 'blog', + email: 'email', +}; type Versions = NonNullable['versions']; @@ -221,8 +241,21 @@ function ColorsControl({ value: colours, commit }: ControlProps) { + return ( + [option, option] as const)} + value={value} + choose={commit} + label={setting.label} + /> + ); +} + function Control(props: ControlProps) { switch (props.setting.kind) { + case 'choice': + return )} />; case 'text': return )} />; case 'number': @@ -411,6 +444,133 @@ function Preview() { ); } +const EMAIL_KINDS = [ + ['post', 'new post'], + ['digest', 'digest'], + ['confirm', 'confirmation'], +] as const; + +function EmailStudio({ csrf, testTo }: { csrf: string; testTo: string }) { + const settings = useRoot()!.settings; + const [kind, setKind] = useState<(typeof EMAIL_KINDS)[number][0]>('post'); + const test = useFetcher(); + const version = JSON.stringify( + Object.entries(settings).filter(([key]) => key.startsWith('email.') || key === 'site.name'), + ); + const result = + test.data && 'key' in test.data && test.data.key === 'test-email' ? test.data : null; + return ( +
+
+
+ + + + +
+