@acms-subir/core 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (230) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/cli/common.mjs +35 -0
  3. package/cli/legacy.mjs +317 -0
  4. package/cli/main.mjs +66 -0
  5. package/cli/palette.mjs +4 -0
  6. package/cli/preview.mjs +50 -0
  7. package/cli/remote.mjs +55 -0
  8. package/cli/themes.mjs +127 -0
  9. package/cli.mjs +2 -369
  10. package/deploy/Caddyfile +8 -0
  11. package/deploy/Dockerfile +34 -0
  12. package/deploy/compose.https.yml +21 -0
  13. package/deploy/compose.yml +46 -0
  14. package/docs/theme-authoring.md +103 -0
  15. package/email-runner.mjs +32 -0
  16. package/integration.mjs +182 -2
  17. package/ops-check.mjs +27 -0
  18. package/package.json +27 -5
  19. package/src/actions/index.ts +575 -47
  20. package/src/components/AiCleaner.tsx +291 -0
  21. package/src/components/AiSettings.tsx +575 -0
  22. package/src/components/CommandPalette.tsx +200 -0
  23. package/src/components/ContentList.astro +246 -0
  24. package/src/components/DesignAccess.tsx +182 -0
  25. package/src/components/DesignManager.tsx +418 -46
  26. package/src/components/EmailSettings.tsx +521 -0
  27. package/src/components/EventPage.astro +178 -0
  28. package/src/components/LegalPage.astro +27 -0
  29. package/src/components/LinksManager.tsx +114 -57
  30. package/src/components/NavManager.tsx +42 -43
  31. package/src/components/ThemeScript.astro +50 -0
  32. package/src/components/ThemeSections.astro +57 -0
  33. package/src/components/ThemeSectionsEditor.tsx +513 -0
  34. package/src/components/booking/BookingFlow.astro +150 -0
  35. package/src/components/booking/HoursEditor.astro +63 -0
  36. package/src/components/builder/PageBuilder.tsx +10 -2
  37. package/src/components/editor/EventPanel.tsx +401 -0
  38. package/src/components/editor/FormatDock.tsx +81 -0
  39. package/src/components/editor/LivePane.tsx +122 -0
  40. package/src/components/editor/MobileToolbar.tsx +93 -0
  41. package/src/components/editor/PostEditor.tsx +605 -223
  42. package/src/components/editor/PreviewOverlay.tsx +242 -43
  43. package/src/components/editor/extensions/calendar.tsx +51 -7
  44. package/src/components/editor/extensions/events.tsx +153 -0
  45. package/src/components/editor/extensions/slash.tsx +7 -1
  46. package/src/components/site/Pager.astro +24 -0
  47. package/src/components/site/PostCard.astro +64 -0
  48. package/src/components/site/TagLabel.astro +11 -0
  49. package/src/layouts/Admin.astro +289 -49
  50. package/src/layouts/Site.astro +324 -59
  51. package/src/lib/agent-api.ts +13 -0
  52. package/src/lib/agent-discovery.ts +40 -0
  53. package/src/lib/agent.ts +114 -0
  54. package/src/lib/ai-config.ts +58 -0
  55. package/src/lib/ai-schema.ts +22 -0
  56. package/src/lib/ai.ts +90 -0
  57. package/src/lib/api-keys.ts +65 -0
  58. package/src/lib/api.ts +52 -0
  59. package/src/lib/auth.ts +25 -5
  60. package/src/lib/backup/content.ts +36 -0
  61. package/src/lib/backup/unzip.ts +12 -1
  62. package/src/lib/backup/zip.ts +1 -1
  63. package/src/lib/booking/agent.ts +108 -0
  64. package/src/lib/booking/availability.ts +221 -0
  65. package/src/lib/booking/emails.ts +115 -0
  66. package/src/lib/booking/forms.ts +77 -0
  67. package/src/lib/booking/service.ts +277 -0
  68. package/src/lib/booking/view.ts +54 -0
  69. package/src/lib/calendar/agent-schema.ts +48 -0
  70. package/src/lib/calendar/agent.ts +136 -0
  71. package/src/lib/calendar/api.ts +81 -0
  72. package/src/lib/calendar/cards.ts +102 -0
  73. package/src/lib/calendar/config.ts +52 -0
  74. package/src/lib/calendar/data.ts +130 -0
  75. package/src/lib/calendar/events.ts +261 -0
  76. package/src/lib/calendar/feeds.ts +43 -0
  77. package/src/lib/calendar/hooks.ts +38 -0
  78. package/src/lib/calendar/http.ts +30 -0
  79. package/src/lib/calendar/ics-write.ts +120 -0
  80. package/src/lib/calendar/ics.ts +15 -5
  81. package/src/lib/calendar/import.ts +203 -0
  82. package/src/lib/calendar/render.ts +120 -37
  83. package/src/lib/calendar/rrule.ts +149 -0
  84. package/src/lib/calendar/seo.ts +48 -0
  85. package/src/lib/design-service.ts +58 -0
  86. package/src/lib/designDiff.ts +145 -0
  87. package/src/lib/designPreview.ts +110 -12
  88. package/src/lib/documents/cleanup.ts +31 -0
  89. package/src/lib/documents/extract.ts +154 -0
  90. package/src/lib/documents/vendor.d.ts +4 -0
  91. package/src/lib/drafts.ts +60 -6
  92. package/src/lib/dynamicBlocks.ts +40 -8
  93. package/src/lib/email/accounts.ts +55 -0
  94. package/src/lib/email/config.ts +86 -0
  95. package/src/lib/email/migration.ts +24 -0
  96. package/src/lib/email/providers.ts +89 -0
  97. package/src/lib/email/service.ts +78 -0
  98. package/src/lib/email/store.ts +167 -0
  99. package/src/lib/email/templates.ts +18 -0
  100. package/src/lib/email/types.ts +32 -0
  101. package/src/lib/email/webhooks.ts +87 -0
  102. package/src/lib/email.ts +15 -0
  103. package/src/lib/env.ts +1 -1
  104. package/src/lib/legal/index.ts +25 -0
  105. package/src/lib/mobile/bookings.ts +73 -0
  106. package/src/lib/mobile/calendar.ts +85 -0
  107. package/src/lib/mobile/content.ts +85 -0
  108. package/src/lib/mobile/http.ts +63 -0
  109. package/src/lib/mobile/route.ts +20 -0
  110. package/src/lib/ops.ts +8 -0
  111. package/src/lib/preview.ts +21 -1
  112. package/src/lib/repo/migrations.ts +217 -0
  113. package/src/lib/repo/sql.ts +853 -60
  114. package/src/lib/repo/types.ts +122 -5
  115. package/src/lib/scheduler.ts +25 -0
  116. package/src/lib/schema.ts +309 -0
  117. package/src/lib/search-i18n.ts +3 -0
  118. package/src/lib/socialIcons.ts +32 -0
  119. package/src/lib/tagColor.ts +11 -0
  120. package/src/lib/theme.ts +59 -40
  121. package/src/lib/themeTokens.ts +174 -0
  122. package/src/lib/umami.ts +108 -0
  123. package/src/lib/webhooks.ts +139 -0
  124. package/src/middleware.ts +36 -3
  125. package/src/pages/404.astro +23 -0
  126. package/src/pages/[...slug].astro +8 -5
  127. package/src/pages/account/[action].astro +53 -0
  128. package/src/pages/admin/ai.astro +12 -0
  129. package/src/pages/admin/c/[collection]/[id].astro +4 -1
  130. package/src/pages/admin/c/[collection]/index.astro +127 -92
  131. package/src/pages/admin/calendar/bookings.astro +400 -0
  132. package/src/pages/admin/calendar/calendars.astro +265 -0
  133. package/src/pages/admin/calendar/events/[id].astro +56 -0
  134. package/src/pages/admin/calendar/events/index.astro +408 -0
  135. package/src/pages/admin/calendar/index.ts +4 -0
  136. package/src/pages/admin/calendar/integrations.astro +221 -0
  137. package/src/pages/admin/calendar/resources/[id].astro +180 -0
  138. package/src/pages/admin/calendar/resources/index.astro +106 -0
  139. package/src/pages/admin/calendar/resources/link.astro +196 -0
  140. package/src/pages/admin/design.astro +10 -3
  141. package/src/pages/admin/email.astro +13 -0
  142. package/src/pages/admin/index.astro +232 -85
  143. package/src/pages/admin/links.astro +3 -3
  144. package/src/pages/admin/login.astro +14 -10
  145. package/src/pages/admin/navigation.astro +8 -2
  146. package/src/pages/admin/pages/[id].astro +4 -1
  147. package/src/pages/admin/pages/index.astro +41 -48
  148. package/src/pages/admin/settings.astro +418 -367
  149. package/src/pages/admin/tags.astro +4 -4
  150. package/src/pages/admin/users.astro +38 -28
  151. package/src/pages/api/agent/docs.ts +33 -0
  152. package/src/pages/api/agent/index.ts +21 -0
  153. package/src/pages/api/ai/clean.ts +13 -0
  154. package/src/pages/api/ai/draft.ts +14 -0
  155. package/src/pages/api/ai/settings.ts +53 -0
  156. package/src/pages/api/ai/test.ts +12 -0
  157. package/src/pages/api/backup.ts +8 -21
  158. package/src/pages/api/calendar/availability.ts +35 -0
  159. package/src/pages/api/calendar/bookings/[id].ts +49 -0
  160. package/src/pages/api/calendar/bookings/index.ts +73 -0
  161. package/src/pages/api/calendar/calendars.ts +35 -0
  162. package/src/pages/api/calendar/docs.ts +64 -0
  163. package/src/pages/api/calendar/events/[id].ts +63 -0
  164. package/src/pages/api/calendar/events/index.ts +112 -0
  165. package/src/pages/api/calendar/resources.ts +34 -0
  166. package/src/pages/api/design/capabilities.ts +10 -0
  167. package/src/pages/api/design/index.ts +58 -0
  168. package/src/pages/api/design/keys.ts +24 -0
  169. package/src/pages/api/design/media.ts +32 -0
  170. package/src/pages/api/email/run.ts +11 -0
  171. package/src/pages/api/email/settings.ts +61 -0
  172. package/src/pages/api/email/webhooks/[provider].ts +4 -0
  173. package/src/pages/api/mcp.ts +59 -0
  174. package/src/pages/api/media/upload.ts +2 -2
  175. package/src/pages/api/ops/status.ts +39 -0
  176. package/src/pages/api/restore.ts +27 -37
  177. package/src/pages/api/search.ts +18 -4
  178. package/src/pages/api/v1/availability.ts +7 -0
  179. package/src/pages/api/v1/bookings.ts +17 -0
  180. package/src/pages/api/v1/calendars.ts +7 -0
  181. package/src/pages/api/v1/content/[id].ts +7 -0
  182. package/src/pages/api/v1/content/index.ts +7 -0
  183. package/src/pages/api/v1/events.ts +7 -0
  184. package/src/pages/api/v1/resources.ts +7 -0
  185. package/src/pages/author/[slug].astro +43 -35
  186. package/src/pages/blog/[slug].astro +137 -66
  187. package/src/pages/blog/index.astro +95 -30
  188. package/src/pages/blog/tag/[slug].astro +45 -40
  189. package/src/pages/book/[resource]/[link].astro +70 -0
  190. package/src/pages/book/[resource]/index.astro +63 -0
  191. package/src/pages/book/[resource].ics.ts +38 -0
  192. package/src/pages/book/index.astro +47 -0
  193. package/src/pages/book/manage/[token].astro +108 -0
  194. package/src/pages/calendar/[slug].ics.ts +27 -0
  195. package/src/pages/calendar/feed.ics.ts +15 -0
  196. package/src/pages/calendar/index.astro +191 -0
  197. package/src/pages/embed/book/[resource]/[link].astro +33 -0
  198. package/src/pages/embed/calendar/[slug].astro +43 -0
  199. package/src/pages/events/[slug].astro +74 -0
  200. package/src/pages/events/[slug].ics.ts +25 -0
  201. package/src/pages/index.astro +6 -3
  202. package/src/pages/legal/privacy.astro +4 -0
  203. package/src/pages/legal/terms.astro +4 -0
  204. package/src/pages/links.astro +23 -3
  205. package/src/pages/llms.txt.ts +4 -0
  206. package/src/pages/preview/[type]/[id].astro +27 -6
  207. package/src/pages/search.astro +40 -20
  208. package/src/pages/sitemap.xml.ts +16 -1
  209. package/src/styles/admin.css +924 -105
  210. package/src/styles/cards.css +92 -74
  211. package/src/styles/editor.css +492 -114
  212. package/src/styles/email-settings.css +117 -0
  213. package/src/styles/sections.css +12 -0
  214. package/src/styles/site.css +938 -281
  215. package/src/styles/theme-editor.css +135 -0
  216. package/src/theme-sdk/contract.d.mts +619 -0
  217. package/src/theme-sdk/contract.mjs +80 -0
  218. package/src/theme-sdk/index.ts +15 -0
  219. package/src/theme-sdk/tokens.d.mts +148 -0
  220. package/src/theme-sdk/tokens.mjs +52 -0
  221. package/src/themes/editorial/templates/home.astro +4 -4
  222. package/src/themes/editorial/templates/post-minimal.astro +2 -2
  223. package/src/themes/editorial/theme.css +22 -37
  224. package/src/themes/editorial/theme.json +23 -3
  225. package/src/themes/field/theme.json +26 -9
  226. package/src/themes/ink/theme.json +30 -12
  227. package/src/themes/night/theme.css +4 -7
  228. package/src/themes/night/theme.json +27 -10
  229. package/src/virtual.d.ts +14 -0
  230. package/src/worker.ts +5 -1
@@ -0,0 +1,521 @@
1
+ import { useEffect, useId, useRef, useState, type KeyboardEvent } from 'react';
2
+ import type { Credentials, EmailConfig, PublicEmailSettings } from '../lib/email/types';
3
+ import '../styles/email-settings.css';
4
+
5
+ // Settings → Email: Setup, Notifications, Templates, Activity over /api/email/settings.
6
+ // Secrets are write-only: inputs start empty, blank keeps the saved value, and a
7
+ // successful save wipes password and secret fields. Nothing goes to browser
8
+ // storage, nothing polls, and no email is sent unless the owner types an address.
9
+
10
+ type Choice = EmailConfig['provider'];
11
+ type Job = PublicEmailSettings['activity'][number];
12
+ type Tab = 'setup' | 'notifications' | 'templates' | 'activity';
13
+ type Busy = 'save' | 'notify' | 'test' | 'refresh' | 'retry';
14
+ interface Msg { ok: string; err: string }
15
+ const NO_MSG: Msg = { ok: '', err: '' };
16
+ const ENDPOINT = '/api/email/settings';
17
+
18
+ const TABS: { id: Tab; label: string }[] = [
19
+ { id: 'setup', label: 'Setup' }, { id: 'notifications', label: 'Notifications' },
20
+ { id: 'templates', label: 'Templates' }, { id: 'activity', label: 'Activity' },
21
+ ];
22
+ const PROVIDERS: { id: Choice; name: string; note: string }[] = [
23
+ { id: 'resend', name: 'Resend', note: 'API key and a verified sending domain' },
24
+ { id: 'mailgun', name: 'Mailgun', note: 'Domain-scoped key, US or EU region' },
25
+ { id: 'ses', name: 'Amazon SES', note: 'AWS keys, region and a verified identity' },
26
+ { id: 'cloudflare', name: 'Cloudflare', note: 'The Workers EMAIL binding' },
27
+ { id: 'auto', name: 'Automatic', note: 'EMAIL binding if present, else RESEND_API_KEY' },
28
+ { id: 'none', name: 'Off', note: 'Send no email' },
29
+ ];
30
+ const NAME = Object.fromEntries(PROVIDERS.map((p) => [p.id, p.name])) as Record<Choice, string>;
31
+ const CREDS: Record<Choice, { key: keyof Credentials; label: string; optional?: boolean }[]> = {
32
+ resend: [{ key: 'apiKey', label: 'API key' }, { key: 'webhookSecret', label: 'Webhook signing secret', optional: true }],
33
+ mailgun: [{ key: 'apiKey', label: 'Sending API key' }, { key: 'webhookSecret', label: 'Webhook signing key', optional: true }],
34
+ ses: [
35
+ { key: 'accessKeyId', label: 'AWS access key ID' }, { key: 'secretAccessKey', label: 'AWS secret access key' },
36
+ { key: 'sessionToken', label: 'Session token', optional: true },
37
+ ],
38
+ cloudflare: [], auto: [], none: [],
39
+ };
40
+ const ENV: Record<Choice, string[]> = {
41
+ resend: ['RESEND_API_KEY', 'RESEND_WEBHOOK_SECRET (optional)'],
42
+ mailgun: ['MAILGUN_API_KEY', 'MAILGUN_DOMAIN', 'MAILGUN_REGION (us or eu)', 'MAILGUN_WEBHOOK_SIGNING_KEY (optional)'],
43
+ ses: ['AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'AWS_REGION', 'AWS_SESSION_TOKEN (optional)', 'SES_CONFIGURATION_SET (optional)', 'SES_SNS_TOPIC_ARN (optional)'],
44
+ cloudflare: ['EMAIL send_email binding in your Wrangler config'],
45
+ auto: ['EMAIL binding, or RESEND_API_KEY'],
46
+ none: [],
47
+ };
48
+ const VERIFY: Partial<Record<Choice, string>> = {
49
+ resend: 'Add and verify the sending domain under Resend → Domains, using the SPF and DKIM records Resend gives you.',
50
+ mailgun: 'Verify the sending domain in Mailgun → Sending → Domains, and pick the region that domain lives in.',
51
+ ses: 'Verify the sender identity in the SES console for this region, and request production access to leave the sandbox.',
52
+ cloudflare: 'Onboard the From domain for Cloudflare Email Sending (Workers Paid; outbound sending is in beta).',
53
+ auto: 'Verify the sender domain with whichever provider ends up sending.',
54
+ };
55
+ const STATE_NOTE: Record<string, string> = {
56
+ queued: 'Waiting for the runner', sending: 'Being handed to the provider',
57
+ retrying: 'Temporary failure; retries automatically', accepted: 'Accepted by the provider; delivery not confirmed',
58
+ delivered: 'Provider reported delivery', failed: 'Stopped retrying; safe to retry by hand',
59
+ unknown: 'Outcome unknown. Check the provider dashboard before sending again',
60
+ bounced: 'The recipient’s server rejected it', complained: 'The recipient marked it as spam',
61
+ suppressed: 'Address is suppressed; not sent', cancelled: 'Cancelled before sending',
62
+ };
63
+ const TONE: Record<string, string> = {
64
+ delivered: 'ok', accepted: 'info', sending: 'info', retrying: 'warn', unknown: 'warn',
65
+ failed: 'bad', bounced: 'bad', complained: 'bad',
66
+ };
67
+
68
+ async function api<T>(body?: unknown, signal?: AbortSignal): Promise<T> {
69
+ const res = await fetch(ENDPOINT, {
70
+ method: body === undefined ? 'GET' : 'POST', credentials: 'same-origin', signal, cache: 'no-store',
71
+ headers: body === undefined ? undefined : { 'content-type': 'application/json' },
72
+ body: body === undefined ? undefined : JSON.stringify(body),
73
+ });
74
+ const data = await res.json().catch(() => null);
75
+ if (!res.ok) throw new Error(data?.error || `${res.status} ${res.statusText || 'Request failed'}`);
76
+ if (!data) throw new Error('The server sent an empty response.');
77
+ return data as T;
78
+ }
79
+ const errText = (e: unknown) => (e instanceof Error ? e.message : String(e));
80
+ const validAddress = (s: string) => s.length <= 254 && /^[^\s<>@,;]+@[^\s<>@,;]+\.[^\s<>@,;]+$/.test(s);
81
+ // mirrors the server's parseFrom: `address` or `Name <address>`
82
+ function validSender(raw: string) {
83
+ if (/[\r\n]/.test(raw)) return false;
84
+ const m = /^\s*(?:"?([^"<>]*?)"?\s*)?<([^<>]+)>\s*$/.exec(raw);
85
+ return validAddress((m ? m[2] : raw).trim());
86
+ }
87
+ const fmt = new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' });
88
+ const when = (ms: number | null) => (ms ? fmt.format(new Date(ms)) : '—');
89
+ function ago(ms: number) {
90
+ const m = Math.round((Date.now() - ms) / 60000);
91
+ return m < 1 ? 'just now' : m < 60 ? `${m} min ago` : m < 1440 ? `${Math.round(m / 60)} h ago` : `${Math.round(m / 1440)} d ago`;
92
+ }
93
+ const kind = (k: string) => k.replace(/[._-]+/g, ' ');
94
+
95
+ export default function EmailSettings() {
96
+ const uid = useId();
97
+ const id = (s: string) => `${uid}-${s}`;
98
+ const [data, setData] = useState<PublicEmailSettings | null>(null);
99
+ const [loadErr, setLoadErr] = useState('');
100
+ const [tab, setTab] = useState<Tab>('setup');
101
+ const [form, setForm] = useState<EmailConfig | null>(null);
102
+ const [creds, setCreds] = useState<Credentials>({});
103
+ const [setupPw, setSetupPw] = useState('');
104
+ const [notify, setNotify] = useState({ reminders: true, staff: true, pw: '' });
105
+ const [testTo, setTestTo] = useState('');
106
+ const [tested, setTested] = useState(false);
107
+ const [tpl, setTpl] = useState<keyof typeof SAMPLES>('invite');
108
+ const [plain, setPlain] = useState(false);
109
+ const [busy, setBusy] = useState<Busy | null>(null);
110
+ const busyRef = useRef<Busy | null>(null);
111
+ // one controller per mount; aborted on unmount so late responses are dropped
112
+ const ctl = useRef<AbortController | null>(null);
113
+ const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
114
+ const [setupMsg, setSetupMsg] = useState(NO_MSG);
115
+ const [notifyMsg, setNotifyMsg] = useState(NO_MSG);
116
+ const [testMsg, setTestMsg] = useState(NO_MSG);
117
+ const [actMsg, setActMsg] = useState(NO_MSG);
118
+
119
+ const live = (c = ctl.current) => !!c && !c.signal.aborted;
120
+
121
+ function adopt(s: PublicEmailSettings, part: 'all' | 'notify' = 'all') {
122
+ setData(s);
123
+ setNotify({ reminders: s.config.reminders, staff: s.config.staffNotifications, pw: '' });
124
+ if (part === 'all') { setForm({ ...s.config }); setCreds({}); setSetupPw(''); }
125
+ else setForm((f) => f && { ...f, reminders: s.config.reminders, staffNotifications: s.config.staffNotifications });
126
+ }
127
+
128
+ async function load() {
129
+ const c = ctl.current;
130
+ setLoadErr('');
131
+ try { const s = await api<PublicEmailSettings>(undefined, c?.signal); if (live(c)) adopt(s); }
132
+ catch (e) { if (live(c)) setLoadErr(errText(e)); }
133
+ }
134
+
135
+ useEffect(() => {
136
+ const c = new AbortController();
137
+ ctl.current = c;
138
+ void load();
139
+ return () => c.abort();
140
+ }, []);
141
+
142
+ async function run(k: Busy, fn: () => Promise<void>) {
143
+ if (busyRef.current) return;
144
+ busyRef.current = k; setBusy(k);
145
+ try { await fn(); } finally { busyRef.current = null; if (live()) setBusy(null); }
146
+ }
147
+
148
+ // re-read status and activity without touching unsaved form edits
149
+ async function refresh(setMsg: (m: Msg) => void) {
150
+ const c = ctl.current;
151
+ try { const s = await api<PublicEmailSettings>(undefined, c?.signal); if (live(c)) setData(s); }
152
+ catch (e) { if (live(c)) setMsg({ ok: '', err: `Couldn’t refresh: ${errText(e)}` }); }
153
+ }
154
+
155
+ if (!data || !form) {
156
+ return (
157
+ <div className="ems">
158
+ {loadErr ? (
159
+ <div className="flash error" role="alert">
160
+ Couldn’t load email settings: {loadErr}{' '}
161
+ <button type="button" className="btn sm" onClick={() => void load()}>Retry</button>
162
+ </div>
163
+ ) : (
164
+ <div className="ems-skel" role="status"><span className="sr-only">Loading email settings…</span><i /><i /><i /></div>
165
+ )}
166
+ </div>
167
+ );
168
+ }
169
+
170
+ const locked = data.environmentLocked;
171
+ const p = form.provider;
172
+ const takesCreds = p === 'resend' || p === 'mailgun' || p === 'ses';
173
+ const usesEnv = !takesCreds || form.source === 'env';
174
+ const saved = (k: string) => p === data.config.provider && data.credentialFields.includes(k);
175
+ const hbStale = !data.heartbeat || Date.now() - data.heartbeat > 15 * 60000;
176
+ const set = <K extends keyof EmailConfig>(k: K, v: EmailConfig[K]) => setForm((f) => f && { ...f, [k]: v });
177
+ const savedOff = data.config.provider === 'none' || !data.config.enabled;
178
+
179
+ function pickProvider(next: Choice) {
180
+ setCreds({});
181
+ setForm((f) => {
182
+ if (!f) return f;
183
+ const region = next === 'mailgun' ? (['us', 'eu'].includes(f.region) ? f.region : 'us')
184
+ : next === 'ses' && ['us', 'eu', ''].includes(f.region) ? 'us-east-1' : f.region;
185
+ return { ...f, provider: next, region, source: ['auto','none','cloudflare'].includes(next) ? 'env' : f.source };
186
+ });
187
+ }
188
+
189
+ async function saveSetup() {
190
+ setSetupMsg(NO_MSG);
191
+ const f = { ...form!, from: form!.from.trim(), replyTo: form!.replyTo.trim(), domain: form!.domain.trim(), region: form!.region.trim() };
192
+ const fail = (err: string) => setSetupMsg({ ok: '', err });
193
+ if (f.provider !== 'none' && !usesEnv && !f.from) return fail('Enter the sender address.');
194
+ if (f.from && !validSender(f.from)) return fail('Sender must be an address or Name <address>.');
195
+ if (f.replyTo && !validSender(f.replyTo)) return fail('Reply-to must be an address or Name <address>.');
196
+ if (f.provider === 'mailgun' && !usesEnv && !/^[a-z0-9.-]+$/i.test(f.domain)) return fail('Enter the Mailgun sending domain, like mg.example.com.');
197
+ if (!setupPw) return fail('Enter your current password to save.');
198
+ const credentials: Credentials = {};
199
+ if (!usesEnv) for (const c of CREDS[f.provider]) { const v = creds[c.key]?.trim(); if (v) credentials[c.key] = v; }
200
+ await run('save', async () => {
201
+ try {
202
+ const next = await api<PublicEmailSettings>({ action: 'save', config: f, credentials, reauthPassword: setupPw });
203
+ if (!live()) return;
204
+ adopt(next); setTested(false);
205
+ setSetupMsg({
206
+ ok: next.configured ? 'Saved. The configuration is valid; send a test to check the connection.' : `Saved, but email isn’t ready yet: ${next.error || 'no provider is configured.'}`,
207
+ err: '',
208
+ });
209
+ } catch (e) { if (live()) { setSetupPw(''); fail(errText(e)); } }
210
+ });
211
+ }
212
+
213
+ async function saveNotify() {
214
+ setNotifyMsg(NO_MSG);
215
+ if (!notify.pw) return setNotifyMsg({ ok: '', err: 'Enter your current password to save.' });
216
+ const config = { ...data!.config, reminders: notify.reminders, staffNotifications: notify.staff };
217
+ await run('notify', async () => {
218
+ try {
219
+ const next = await api<PublicEmailSettings>({ action: 'save', config, credentials: {}, reauthPassword: notify.pw });
220
+ if (live()) { adopt(next, 'notify'); setNotifyMsg({ ok: 'Notification settings saved.', err: '' }); }
221
+ } catch (e) { if (live()) { setNotify((n) => ({ ...n, pw: '' })); setNotifyMsg({ ok: '', err: errText(e) }); } }
222
+ });
223
+ }
224
+
225
+ async function sendTest() {
226
+ setTestMsg(NO_MSG);
227
+ const to = testTo.trim();
228
+ if (!validAddress(to)) return setTestMsg({ ok: '', err: 'Enter the address to send the test to.' });
229
+ await run('test', async () => {
230
+ try {
231
+ const r = await api<{ message: string }>({ action: 'test', to });
232
+ if (!live()) return;
233
+ setTested(true); setTestMsg({ ok: r.message, err: '' });
234
+ await refresh(setTestMsg);
235
+ } catch (e) { if (live()) setTestMsg({ ok: '', err: errText(e) }); }
236
+ });
237
+ }
238
+
239
+ async function retry(job: Job) {
240
+ if (job.state !== 'failed' || !confirm(`Retry the ${kind(job.kind)} email to ${job.recipient}?`)) return;
241
+ setActMsg(NO_MSG);
242
+ await run('retry', async () => {
243
+ try {
244
+ await api({ action: 'retry', id: job.id });
245
+ if (!live()) return;
246
+ setActMsg({ ok: `Queued the ${kind(job.kind)} email to ${job.recipient} for another attempt.`, err: '' });
247
+ await refresh(setActMsg);
248
+ } catch (e) { if (live()) setActMsg({ ok: '', err: errText(e) }); }
249
+ });
250
+ }
251
+
252
+ function onTabKey(e: KeyboardEvent, i: number) {
253
+ const n = TABS.length;
254
+ const j = e.key === 'ArrowRight' ? (i + 1) % n : e.key === 'ArrowLeft' ? (i - 1 + n) % n : e.key === 'Home' ? 0 : e.key === 'End' ? n - 1 : -1;
255
+ if (j < 0) return;
256
+ e.preventDefault(); setTab(TABS[j].id); tabRefs.current[j]?.focus();
257
+ }
258
+
259
+ const status = (m: Msg) => (
260
+ <>
261
+ <div className="ems-status" aria-live="polite">{m.ok && <p className="flash">{m.ok}</p>}</div>
262
+ <div className="ems-status" role="alert">{m.err && <p className="flash error">{m.err}</p>}</div>
263
+ </>
264
+ );
265
+ const pwField = (value: string, onChange: (v: string) => void, key: string) => (
266
+ <label className="field ems-pw">
267
+ <span>Your current password</span>
268
+ <input type="password" autoComplete="current-password" value={value} onChange={(e) => onChange(e.target.value)} aria-describedby={id(key)} />
269
+ <small className="hint" id={id(key)}>Owners confirm their password for every email settings change.</small>
270
+ </label>
271
+ );
272
+ const heartbeat = (
273
+ <p className={`ems-hb ${hbStale ? 'stale' : ''}`}>
274
+ {data.heartbeat ? <>Delivery runner last ran {ago(data.heartbeat)} ({when(data.heartbeat)}).</> : <>No delivery runner has checked in yet.</>}
275
+ {hbStale && ' Queued email waits until the scheduled runner (cron or Node runner) runs.'}
276
+ </p>
277
+ );
278
+
279
+ return (
280
+ <div className="ems">
281
+ <div className="seg ems-tabs" role="tablist" aria-label="Email settings">
282
+ {TABS.map((t, i) => (
283
+ <button key={t.id} ref={(el) => { tabRefs.current[i] = el; }} type="button" role="tab" id={id(`tab-${t.id}`)}
284
+ aria-selected={tab === t.id} aria-controls={id(`panel-${t.id}`)} tabIndex={tab === t.id ? 0 : -1}
285
+ onClick={() => setTab(t.id)} onKeyDown={(e) => onTabKey(e, i)}>{t.label}</button>
286
+ ))}
287
+ </div>
288
+
289
+ <section role="tabpanel" id={id('panel-setup')} aria-labelledby={id('tab-setup')} hidden={tab !== 'setup'} className="ems-panel">
290
+ <div className="ems-card">
291
+ <header className="ems-head">
292
+ <div><h2>Connection</h2><p>Where site email is sent from. Each part below is checked separately.</p></div>
293
+ <span className={`pill ${data.configured ? 'published' : savedOff ? 'archived' : 'draft'}`}>
294
+ {data.configured ? 'Configured' : savedOff ? 'Off' : 'Needs setup'}
295
+ </span>
296
+ </header>
297
+ <ul className="ems-check" aria-label="Setup status">
298
+ <li data-s={data.provider === 'none' ? 'todo' : 'ok'}><b>Provider</b><span>{data.provider === 'none' ? 'None active' : NAME[data.provider]}{data.config.provider === 'auto' && data.provider !== 'none' && ' (chosen automatically)'}</span></li>
299
+ <li data-s={data.configured ? 'ok' : 'bad'}><b>Configuration</b><span>{data.configured ? 'Complete' : data.error || 'Incomplete'}</span></li>
300
+ <li data-s="manual"><b>Sender verification</b><span>Done in the provider dashboard. ACMS can’t see it, so a valid configuration doesn’t mean the domain is verified.</span></li>
301
+ <li data-s={tested ? 'ok' : 'todo'}><b>Test email</b><span>{tested ? 'Accepted by the provider. Check the inbox to confirm delivery.' : 'Not sent in this session'}</span></li>
302
+ </ul>
303
+ {heartbeat}
304
+ </div>
305
+
306
+ <form className="ems-card" onSubmit={(e) => { e.preventDefault(); void saveSetup(); }} noValidate>
307
+ {locked && (
308
+ <div className="notice warn" role="note">
309
+ <b>Locked by the deployment.</b> EMAIL_PROVIDER is set, so the provider and credentials come from environment variables. Change or remove it there to manage them here.
310
+ </div>
311
+ )}
312
+ <label className="ems-toggle">
313
+ <input type="checkbox" checked={form.enabled} onChange={(e) => set('enabled', e.target.checked)} />
314
+ <span>Send email from this site</span>
315
+ </label>
316
+ <fieldset className="ems-grid" disabled={locked}>
317
+ <legend>Provider</legend>
318
+ {PROVIDERS.map((o) => (
319
+ <label key={o.id} className={`ems-opt ${p === o.id ? 'on' : ''}`}>
320
+ <input type="radio" name={id('provider')} checked={p === o.id} onChange={() => pickProvider(o.id)} />
321
+ <span><b>{o.name}</b><small>{o.note}</small></span>
322
+ </label>
323
+ ))}
324
+ </fieldset>
325
+
326
+ {takesCreds && (
327
+ <fieldset className="ems-grid two" disabled={locked}>
328
+ <legend>Credentials live in</legend>
329
+ <label className={`ems-opt ${form.source === 'env' ? 'on' : ''}`}>
330
+ <input type="radio" name={id('source')} checked={form.source === 'env'} onChange={() => set('source', 'env')} />
331
+ <span><b>Deployment environment</b><small>Set secrets in your host; ACMS never asks for them.</small></span>
332
+ </label>
333
+ <label className={`ems-opt ${form.source === 'encrypted' ? 'on' : ''}`}>
334
+ <input type="radio" name={id('source')} checked={form.source === 'encrypted'} disabled={!data.encryptionAvailable} onChange={() => set('source', 'encrypted')} />
335
+ <span><b>Encrypted in ACMS</b><small>{data.encryptionAvailable ? 'Enter keys here; they’re encrypted and never shown again.' : 'Needs a deployment EMAIL_ENCRYPTION_KEY (32-byte base64) first.'}</small></span>
336
+ </label>
337
+ </fieldset>
338
+ )}
339
+
340
+ {p !== 'none' && (
341
+ <div className="field-row">
342
+ <label className="field"><span>Sender</span>
343
+ <input type="text" value={form.from} onChange={(e) => set('from', e.target.value)} placeholder="Example Studio <hello@example.com>" autoComplete="off" spellCheck={false} aria-describedby={id('from-h')} />
344
+ <small className="hint" id={id('from-h')}>An address or Name &lt;address&gt; on a verified domain.{usesEnv && ' EMAIL_FROM overrides this.'}</small>
345
+ </label>
346
+ <label className="field"><span>Reply-to <em className="ems-opt-l">optional</em></span>
347
+ <input type="text" value={form.replyTo} onChange={(e) => set('replyTo', e.target.value)} placeholder="Front desk <desk@example.com>" autoComplete="off" spellCheck={false} />
348
+ </label>
349
+ </div>
350
+ )}
351
+ {p === 'mailgun' && (
352
+ <div className="field-row">
353
+ <label className="field"><span>Sending domain</span>
354
+ <input type="text" value={form.domain} onChange={(e) => set('domain', e.target.value)} placeholder="mg.example.com" spellCheck={false} />
355
+ </label>
356
+ <label className="field"><span>Region</span>
357
+ <select value={form.region} onChange={(e) => set('region', e.target.value)} disabled={locked}>
358
+ <option value="us">US (api.mailgun.net)</option><option value="eu">EU (api.eu.mailgun.net)</option>
359
+ </select>
360
+ </label>
361
+ </div>
362
+ )}
363
+ {p === 'ses' && (
364
+ <div className="field-row three">
365
+ <label className="field"><span>AWS region</span>
366
+ <input type="text" value={form.region} onChange={(e) => set('region', e.target.value)} placeholder="us-east-1" spellCheck={false} />
367
+ </label>
368
+ <label className="field"><span>Configuration set <em className="ems-opt-l">optional</em></span>
369
+ <input type="text" value={form.configurationSet} onChange={(e) => set('configurationSet', e.target.value)} spellCheck={false} />
370
+ </label>
371
+ <label className="field"><span>SNS topic ARN <em className="ems-opt-l">optional</em></span>
372
+ <input type="text" value={form.snsTopicArn} onChange={(e) => set('snsTopicArn', e.target.value)} placeholder="arn:aws:sns:…" spellCheck={false} />
373
+ </label>
374
+ </div>
375
+ )}
376
+
377
+ {p !== 'none' && (usesEnv ? (
378
+ ENV[p].length > 0 && (
379
+ <div className="ems-env">
380
+ <p>Set these in your deployment, then redeploy:</p>
381
+ <ul>{['EMAIL_FROM', 'EMAIL_REPLY_TO (optional)', ...ENV[p]].map((v) => <li key={v}><code>{v}</code></li>)}</ul>
382
+ </div>
383
+ )
384
+ ) : (
385
+ <div className="field-row">
386
+ {CREDS[p].map((c) => (
387
+ <label key={c.key} className="field"><span>{c.label}{c.optional && <em className="ems-opt-l"> optional</em>}{saved(c.key) && <em className="ems-saved"> saved</em>}</span>
388
+ <input type="password" autoComplete="new-password" spellCheck={false} data-1p-ignore data-lpignore="true" value={creds[c.key] || ''}
389
+ placeholder={saved(c.key) ? 'Leave blank to keep the saved value' : c.optional ? 'Optional' : 'Required'}
390
+ onChange={(e) => setCreds((x) => ({ ...x, [c.key]: e.target.value }))} disabled={locked} />
391
+ </label>
392
+ ))}
393
+ </div>
394
+ ))}
395
+ {VERIFY[p] && <p className="ems-note"><b>Before sending:</b> {VERIFY[p]}</p>}
396
+ {p === 'resend' && <p className="hint">Turn off click and open tracking for this sending domain in Resend, so account links stay direct.</p>}
397
+ {['resend','mailgun','ses'].includes(p) && <p className="ems-note"><b>Delivery events:</b> Register your site’s <code>{`/api/email/webhooks/${p}`}</code> endpoint with the provider. Resend and Mailgun require the webhook signing secret above; SES requires the configuration set and SNS topic ARN. Subscribe to delivered, permanent bounce and complaint events.</p>}
398
+
399
+ {pwField(setupPw, setSetupPw, 'pw-setup')}
400
+ <div className="ems-actions">
401
+ <button type="submit" className="btn primary" disabled={busy !== null}>{busy === 'save' ? 'Saving…' : 'Save settings'}</button>
402
+ </div>
403
+ {status(setupMsg)}
404
+ </form>
405
+
406
+ <form className="ems-card" onSubmit={(e) => { e.preventDefault(); void sendTest(); }} noValidate>
407
+ <header className="ems-head"><div><h2>Send a test</h2><p>Uses the saved settings. Save first to test changes. Only sent to the address you type.</p></div></header>
408
+ <div className="ems-row">
409
+ <label className="field ems-grow"><span>Send to</span>
410
+ <input type="email" value={testTo} onChange={(e) => setTestTo(e.target.value)} placeholder="you@example.com" autoComplete="email" />
411
+ </label>
412
+ <button type="submit" className="btn" disabled={busy !== null || !data.configured}>{busy === 'test' ? 'Sending…' : 'Send test email'}</button>
413
+ </div>
414
+ {!data.configured && <p className="hint">Finish the configuration before sending a test.</p>}
415
+ {testMsg.ok && <p className="hint">Accepted means the provider took the message, not that it arrived. Check the inbox and the Activity tab.</p>}
416
+ {status(testMsg)}
417
+ </form>
418
+ </section>
419
+
420
+ <section role="tabpanel" id={id('panel-notifications')} aria-labelledby={id('tab-notifications')} hidden={tab !== 'notifications'} className="ems-panel">
421
+ <form className="ems-card" onSubmit={(e) => { e.preventDefault(); void saveNotify(); }} noValidate>
422
+ <header className="ems-head"><div><h2>Always sent</h2><p>Essential messages go out whenever email is on and a provider is configured, unless the address is suppressed after a bounce or complaint.</p></div></header>
423
+ <ul className="ems-list">
424
+ <li>Account invitations, password resets and password-changed notices</li>
425
+ <li>Booking received, confirmed, declined, cancelled and rescheduled</li>
426
+ </ul>
427
+ <h3 className="ems-sub">Optional</h3>
428
+ <label className="ems-toggle">
429
+ <input type="checkbox" checked={notify.reminders} onChange={(e) => setNotify((n) => ({ ...n, reminders: e.target.checked }))} />
430
+ <span>Reminders<small>24-hour reminders for bookings and event RSVPs</small></span>
431
+ </label>
432
+ <label className="ems-toggle">
433
+ <input type="checkbox" checked={notify.staff} onChange={(e) => setNotify((n) => ({ ...n, staff: e.target.checked }))} />
434
+ <span>Staff notifications<small>Tell site staff about new booking requests</small></span>
435
+ </label>
436
+ {pwField(notify.pw, (v) => setNotify((n) => ({ ...n, pw: v })), 'pw-notify')}
437
+ <div className="ems-actions">
438
+ <button type="submit" className="btn primary" disabled={busy !== null}>{busy === 'notify' ? 'Saving…' : 'Save notifications'}</button>
439
+ </div>
440
+ {status(notifyMsg)}
441
+ </form>
442
+ </section>
443
+
444
+ <section role="tabpanel" id={id('panel-templates')} aria-labelledby={id('tab-templates')} hidden={tab !== 'templates'} className="ems-panel">
445
+ <div className="ems-card">
446
+ <header className="ems-head"><div><h2>Built-in templates</h2><p>Every message has HTML and plain-text versions. The logo, name and colors come from your site settings. This sample uses made-up example.com data; real account links are never previewed.</p></div></header>
447
+ <div className="ems-row">
448
+ <div className="seg" role="group" aria-label="Sample message">
449
+ {(Object.keys(SAMPLES) as (keyof typeof SAMPLES)[]).map((k) => (
450
+ <button key={k} type="button" aria-pressed={tpl === k} onClick={() => setTpl(k)}>{SAMPLES[k].label}</button>
451
+ ))}
452
+ </div>
453
+ <div className="seg" role="group" aria-label="Format">
454
+ <button type="button" aria-pressed={!plain} onClick={() => setPlain(false)}>HTML</button>
455
+ <button type="button" aria-pressed={plain} onClick={() => setPlain(true)}>Plain text</button>
456
+ </div>
457
+ </div>
458
+ <Preview s={SAMPLES[tpl]} plain={plain} />
459
+ </div>
460
+ </section>
461
+
462
+ <section role="tabpanel" id={id('panel-activity')} aria-labelledby={id('tab-activity')} hidden={tab !== 'activity'} className="ems-panel">
463
+ <div className="ems-card">
464
+ <header className="ems-head">
465
+ <div><h2>Recent email</h2><p>The latest 100 messages. Accepted means the provider took it; delivery is confirmed only when the provider reports it.</p></div>
466
+ <button type="button" className="btn sm" onClick={() => { setActMsg(NO_MSG); void run('refresh', () => refresh(setActMsg)); }} disabled={busy !== null}>{busy === 'refresh' ? 'Refreshing…' : 'Refresh'}</button>
467
+ </header>
468
+ {heartbeat}
469
+ {status(actMsg)}
470
+ {data.activity.length === 0 ? <p className="ems-empty">No email yet.</p> : (
471
+ <div className="ems-scroll">
472
+ <table className="list ems-table">
473
+ <thead><tr><th scope="col">Recipient</th><th scope="col">Message</th><th scope="col">Status</th><th scope="col">Attempts</th><th scope="col">Created</th><th scope="col"><span className="sr-only">Actions</span></th></tr></thead>
474
+ <tbody>
475
+ {data.activity.slice(0, 100).map((j) => (
476
+ <tr key={j.id}>
477
+ <td data-l="Recipient" className="ems-rcpt">{j.recipient}</td>
478
+ <td data-l="Message">{kind(j.kind)}{j.provider && <div className="t-sub">{j.provider}{j.providerId && <> · <code>{j.providerId}</code></>}</div>}</td>
479
+ <td data-l="Status"><span className="ems-state" data-tone={TONE[j.state] || 'muted'}>{j.state}</span>
480
+ <div className="t-sub">{STATE_NOTE[j.state] || ''}</div>
481
+ {j.error && <div className="ems-err">{j.error}</div>}</td>
482
+ <td data-l="Attempts" className="num">{j.attempts}{j.acceptedAt ? <div className="t-sub">accepted {when(j.acceptedAt)}</div> : null}</td>
483
+ <td data-l="Created">{when(j.createdAt)}{j.state === 'retrying' && <div className="t-sub">next {when(j.nextAt)}</div>}</td>
484
+ <td className="ems-act">{j.state === 'failed' && <button type="button" className="btn sm" disabled={busy !== null} onClick={() => void retry(j)}>Retry</button>}</td>
485
+ </tr>
486
+ ))}
487
+ </tbody>
488
+ </table>
489
+ </div>
490
+ )}
491
+ </div>
492
+ </section>
493
+ </div>
494
+ );
495
+ }
496
+
497
+ interface Sample { label: string; subject: string; heading: string; body: string[]; action: string; ics?: boolean; foot: string }
498
+ const SAMPLES = {
499
+ invite: { label: 'Invitation', subject: 'You’re invited to Example Studio', heading: 'Join Example Studio', body: ['Riley Example invited you to help edit example.com as an editor.', 'This invitation expires in 7 days.'], action: 'Accept invitation', foot: 'Didn’t expect this? You can ignore it; nothing happens until you accept.' },
500
+ reset: { label: 'Password reset', subject: 'Reset your Example Studio password', heading: 'Reset your password', body: ['Someone asked to reset the password for sam@example.com.', 'The link works once and expires in 30 minutes.'], action: 'Choose a new password', foot: 'If this wasn’t you, ignore this email; your password stays the same.' },
501
+ booking: { label: 'Booking confirmed', subject: 'Confirmed: Studio tour, Tue 14 Oct', heading: 'Your booking is confirmed', body: ['Studio tour with Example Studio', 'Tuesday 14 October 2026, 10:00–10:45 (America/New_York)', '123 Example Street, Springfield'], action: 'Manage booking', ics: true, foot: 'Need to change it? Use the link above or reply to this email.' },
502
+ } satisfies Record<string, Sample>;
503
+
504
+ function Preview({ s, plain }: { s: Sample; plain: boolean }) {
505
+ const text = [s.heading, '', ...s.body, '', `${s.action}: [link omitted in preview]`, '', s.foot, '', '— Example Studio'].join('\n');
506
+ return (
507
+ <figure className="ems-mail" aria-label={`Sample ${s.label} email`}>
508
+ <figcaption><b>Subject:</b> {s.subject}<br /><b>From:</b> Example Studio &lt;hello@example.com&gt;{s.ics && <span className="ems-ics">event.ics attached</span>}</figcaption>
509
+ {plain ? <pre className="ems-text">{text}</pre> : (
510
+ <div className="ems-html">
511
+ <p className="ems-brand">Example Studio</p>
512
+ <h3>{s.heading}</h3>
513
+ {s.body.map((l) => <p key={l}>{l}</p>)}
514
+ <span className="ems-cta" aria-disabled="true">{s.action}</span>
515
+ <p className="ems-small">Links are placeholders in previews.</p>
516
+ <p className="ems-small">{s.foot}</p>
517
+ </div>
518
+ )}
519
+ </figure>
520
+ );
521
+ }