@acms-subir/core 0.1.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 (237) hide show
  1. package/CHANGELOG.md +106 -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 -315
  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 +193 -2
  17. package/ops-check.mjs +27 -0
  18. package/package.json +35 -12
  19. package/src/actions/index.ts +744 -38
  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 +455 -47
  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 +216 -97
  30. package/src/components/NavManager.tsx +83 -38
  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 +826 -271
  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/cards.tsx +17 -1
  45. package/src/components/editor/extensions/codeBlock.tsx +1 -1
  46. package/src/components/editor/extensions/events.tsx +153 -0
  47. package/src/components/editor/extensions/infoCards.tsx +16 -1
  48. package/src/components/editor/extensions/mediaCards.tsx +36 -2
  49. package/src/components/editor/extensions/slash.tsx +77 -9
  50. package/src/components/editor/extensions/toc.tsx +89 -0
  51. package/src/components/editor/extensions/toggle.tsx +70 -0
  52. package/src/components/site/Pager.astro +24 -0
  53. package/src/components/site/PostCard.astro +64 -0
  54. package/src/components/site/TagLabel.astro +11 -0
  55. package/src/layouts/Admin.astro +289 -49
  56. package/src/layouts/Site.astro +331 -57
  57. package/src/lib/agent-api.ts +13 -0
  58. package/src/lib/agent-discovery.ts +40 -0
  59. package/src/lib/agent.ts +114 -0
  60. package/src/lib/ai-config.ts +58 -0
  61. package/src/lib/ai-schema.ts +22 -0
  62. package/src/lib/ai.ts +90 -0
  63. package/src/lib/analytics.ts +121 -0
  64. package/src/lib/api-keys.ts +65 -0
  65. package/src/lib/api.ts +52 -0
  66. package/src/lib/auth.ts +25 -5
  67. package/src/lib/backup/content.ts +36 -0
  68. package/src/lib/backup/unzip.ts +12 -1
  69. package/src/lib/backup/zip.ts +1 -1
  70. package/src/lib/booking/agent.ts +108 -0
  71. package/src/lib/booking/availability.ts +221 -0
  72. package/src/lib/booking/emails.ts +115 -0
  73. package/src/lib/booking/forms.ts +77 -0
  74. package/src/lib/booking/service.ts +277 -0
  75. package/src/lib/booking/view.ts +54 -0
  76. package/src/lib/calendar/agent-schema.ts +48 -0
  77. package/src/lib/calendar/agent.ts +136 -0
  78. package/src/lib/calendar/api.ts +81 -0
  79. package/src/lib/calendar/cards.ts +102 -0
  80. package/src/lib/calendar/config.ts +52 -0
  81. package/src/lib/calendar/data.ts +130 -0
  82. package/src/lib/calendar/events.ts +261 -0
  83. package/src/lib/calendar/feeds.ts +43 -0
  84. package/src/lib/calendar/hooks.ts +38 -0
  85. package/src/lib/calendar/http.ts +30 -0
  86. package/src/lib/calendar/ics-write.ts +120 -0
  87. package/src/lib/calendar/ics.ts +15 -5
  88. package/src/lib/calendar/import.ts +203 -0
  89. package/src/lib/calendar/render.ts +120 -37
  90. package/src/lib/calendar/rrule.ts +149 -0
  91. package/src/lib/calendar/seo.ts +48 -0
  92. package/src/lib/design-service.ts +58 -0
  93. package/src/lib/designDiff.ts +145 -0
  94. package/src/lib/designPreview.ts +112 -0
  95. package/src/lib/documents/cleanup.ts +31 -0
  96. package/src/lib/documents/extract.ts +154 -0
  97. package/src/lib/documents/vendor.d.ts +4 -0
  98. package/src/lib/drafts.ts +131 -0
  99. package/src/lib/dynamicBlocks.ts +79 -7
  100. package/src/lib/email/accounts.ts +55 -0
  101. package/src/lib/email/config.ts +86 -0
  102. package/src/lib/email/migration.ts +24 -0
  103. package/src/lib/email/providers.ts +89 -0
  104. package/src/lib/email/service.ts +78 -0
  105. package/src/lib/email/store.ts +167 -0
  106. package/src/lib/email/templates.ts +18 -0
  107. package/src/lib/email/types.ts +32 -0
  108. package/src/lib/email/webhooks.ts +87 -0
  109. package/src/lib/email.ts +15 -0
  110. package/src/lib/env.ts +1 -1
  111. package/src/lib/legal/index.ts +25 -0
  112. package/src/lib/mobile/bookings.ts +73 -0
  113. package/src/lib/mobile/calendar.ts +85 -0
  114. package/src/lib/mobile/content.ts +85 -0
  115. package/src/lib/mobile/http.ts +63 -0
  116. package/src/lib/mobile/route.ts +20 -0
  117. package/src/lib/ops.ts +8 -0
  118. package/src/lib/preview.ts +21 -1
  119. package/src/lib/repo/migrations.ts +254 -0
  120. package/src/lib/repo/sql.ts +956 -46
  121. package/src/lib/repo/types.ts +140 -3
  122. package/src/lib/scheduler.ts +25 -0
  123. package/src/lib/schema.ts +362 -0
  124. package/src/lib/search-i18n.ts +3 -0
  125. package/src/lib/socialIcons.ts +32 -0
  126. package/src/lib/tagColor.ts +11 -0
  127. package/src/lib/theme.ts +59 -40
  128. package/src/lib/themeTokens.ts +174 -0
  129. package/src/lib/umami.ts +108 -0
  130. package/src/lib/webhooks.ts +139 -0
  131. package/src/middleware.ts +36 -3
  132. package/src/pages/404.astro +23 -0
  133. package/src/pages/[...slug].astro +10 -5
  134. package/src/pages/account/[action].astro +53 -0
  135. package/src/pages/admin/ai.astro +12 -0
  136. package/src/pages/admin/c/[collection]/[id].astro +14 -4
  137. package/src/pages/admin/c/[collection]/index.astro +127 -91
  138. package/src/pages/admin/calendar/bookings.astro +400 -0
  139. package/src/pages/admin/calendar/calendars.astro +265 -0
  140. package/src/pages/admin/calendar/events/[id].astro +56 -0
  141. package/src/pages/admin/calendar/events/index.astro +408 -0
  142. package/src/pages/admin/calendar/index.ts +4 -0
  143. package/src/pages/admin/calendar/integrations.astro +221 -0
  144. package/src/pages/admin/calendar/resources/[id].astro +180 -0
  145. package/src/pages/admin/calendar/resources/index.astro +106 -0
  146. package/src/pages/admin/calendar/resources/link.astro +196 -0
  147. package/src/pages/admin/design.astro +16 -4
  148. package/src/pages/admin/email.astro +13 -0
  149. package/src/pages/admin/index.astro +237 -87
  150. package/src/pages/admin/links.astro +3 -3
  151. package/src/pages/admin/login.astro +14 -10
  152. package/src/pages/admin/navigation.astro +8 -2
  153. package/src/pages/admin/pages/[id].astro +14 -3
  154. package/src/pages/admin/pages/index.astro +41 -47
  155. package/src/pages/admin/settings.astro +475 -276
  156. package/src/pages/admin/tags.astro +4 -4
  157. package/src/pages/admin/users.astro +38 -28
  158. package/src/pages/api/agent/docs.ts +33 -0
  159. package/src/pages/api/agent/index.ts +21 -0
  160. package/src/pages/api/ai/clean.ts +13 -0
  161. package/src/pages/api/ai/draft.ts +14 -0
  162. package/src/pages/api/ai/settings.ts +53 -0
  163. package/src/pages/api/ai/test.ts +12 -0
  164. package/src/pages/api/backup.ts +8 -21
  165. package/src/pages/api/calendar/availability.ts +35 -0
  166. package/src/pages/api/calendar/bookings/[id].ts +49 -0
  167. package/src/pages/api/calendar/bookings/index.ts +73 -0
  168. package/src/pages/api/calendar/calendars.ts +35 -0
  169. package/src/pages/api/calendar/docs.ts +64 -0
  170. package/src/pages/api/calendar/events/[id].ts +63 -0
  171. package/src/pages/api/calendar/events/index.ts +112 -0
  172. package/src/pages/api/calendar/resources.ts +34 -0
  173. package/src/pages/api/design/capabilities.ts +10 -0
  174. package/src/pages/api/design/index.ts +58 -0
  175. package/src/pages/api/design/keys.ts +24 -0
  176. package/src/pages/api/design/media.ts +32 -0
  177. package/src/pages/api/email/run.ts +11 -0
  178. package/src/pages/api/email/settings.ts +61 -0
  179. package/src/pages/api/email/webhooks/[provider].ts +4 -0
  180. package/src/pages/api/mcp.ts +59 -0
  181. package/src/pages/api/media/upload.ts +2 -2
  182. package/src/pages/api/ops/status.ts +39 -0
  183. package/src/pages/api/restore.ts +27 -37
  184. package/src/pages/api/search.ts +18 -4
  185. package/src/pages/api/v1/availability.ts +7 -0
  186. package/src/pages/api/v1/bookings.ts +17 -0
  187. package/src/pages/api/v1/calendars.ts +7 -0
  188. package/src/pages/api/v1/content/[id].ts +7 -0
  189. package/src/pages/api/v1/content/index.ts +7 -0
  190. package/src/pages/api/v1/events.ts +7 -0
  191. package/src/pages/api/v1/resources.ts +7 -0
  192. package/src/pages/author/[slug].astro +43 -35
  193. package/src/pages/blog/[slug].astro +140 -66
  194. package/src/pages/blog/index.astro +95 -30
  195. package/src/pages/blog/tag/[slug].astro +45 -40
  196. package/src/pages/book/[resource]/[link].astro +70 -0
  197. package/src/pages/book/[resource]/index.astro +63 -0
  198. package/src/pages/book/[resource].ics.ts +38 -0
  199. package/src/pages/book/index.astro +47 -0
  200. package/src/pages/book/manage/[token].astro +108 -0
  201. package/src/pages/calendar/[slug].ics.ts +27 -0
  202. package/src/pages/calendar/feed.ics.ts +15 -0
  203. package/src/pages/calendar/index.astro +191 -0
  204. package/src/pages/embed/book/[resource]/[link].astro +33 -0
  205. package/src/pages/embed/calendar/[slug].astro +43 -0
  206. package/src/pages/events/[slug].astro +74 -0
  207. package/src/pages/events/[slug].ics.ts +25 -0
  208. package/src/pages/index.astro +9 -3
  209. package/src/pages/legal/privacy.astro +4 -0
  210. package/src/pages/legal/terms.astro +4 -0
  211. package/src/pages/links.astro +33 -7
  212. package/src/pages/llms.txt.ts +4 -0
  213. package/src/pages/preview/[type]/[id].astro +34 -8
  214. package/src/pages/search.astro +40 -20
  215. package/src/pages/sitemap.xml.ts +16 -1
  216. package/src/styles/admin.css +928 -105
  217. package/src/styles/cards.css +101 -60
  218. package/src/styles/editor.css +532 -102
  219. package/src/styles/email-settings.css +117 -0
  220. package/src/styles/sections.css +12 -0
  221. package/src/styles/site.css +938 -278
  222. package/src/styles/theme-editor.css +135 -0
  223. package/src/theme-sdk/contract.d.mts +619 -0
  224. package/src/theme-sdk/contract.mjs +80 -0
  225. package/src/theme-sdk/index.ts +15 -0
  226. package/src/theme-sdk/tokens.d.mts +148 -0
  227. package/src/theme-sdk/tokens.mjs +52 -0
  228. package/src/themes/editorial/templates/home.astro +4 -4
  229. package/src/themes/editorial/templates/post-minimal.astro +2 -2
  230. package/src/themes/editorial/theme.css +22 -37
  231. package/src/themes/editorial/theme.json +23 -3
  232. package/src/themes/field/theme.json +26 -9
  233. package/src/themes/ink/theme.json +30 -12
  234. package/src/themes/night/theme.css +4 -7
  235. package/src/themes/night/theme.json +27 -10
  236. package/src/virtual.d.ts +14 -0
  237. package/src/worker.ts +5 -1
@@ -0,0 +1,89 @@
1
+ import { createHash, createHmac } from 'node:crypto';
2
+ import { emailBinding, parseFrom, validAddress } from './config';
3
+ import type { Mail, ResolvedConfig, SendResult } from './types';
4
+ const hash = (s: string) => createHash('sha256').update(s).digest('hex');
5
+ const hmac = (key: string | Buffer, data: string) => createHmac('sha256', key).update(data).digest();
6
+ const b64 = (s: string) => Buffer.from(s, 'utf8').toString('base64');
7
+
8
+ // SES v2 uses standard SigV4 over the exact body sent. No Node HTTP client:
9
+ // fetch + crypto work in Workers (nodejs_compat) and Node alike.
10
+ export function signSes(url: URL, body: string, c: ResolvedConfig, now = new Date()): Headers {
11
+ const timestamp = now.toISOString().replace(/[:-]|\.\d{3}/g, ''), day = timestamp.slice(0, 8);
12
+ const values: Record<string, string> = { 'content-type': 'application/json', host: url.host, 'x-amz-date': timestamp };
13
+ if (c.credentials.sessionToken) values['x-amz-security-token'] = c.credentials.sessionToken;
14
+ const names = Object.keys(values).sort(), signed = names.join(';');
15
+ const canonical = ['POST', url.pathname, '', names.map((n) => `${n}:${values[n].trim()}\n`).join(''), signed, hash(body)].join('\n');
16
+ const scope = `${day}/${c.config.region}/ses/aws4_request`;
17
+ const key = hmac(hmac(hmac(hmac(`AWS4${c.credentials.secretAccessKey}`, day), c.config.region), 'ses'), 'aws4_request');
18
+ const signature = hmac(key, `AWS4-HMAC-SHA256\n${timestamp}\n${scope}\n${hash(canonical)}`).toString('hex');
19
+ const headers = new Headers(values);
20
+ headers.set('authorization', `AWS4-HMAC-SHA256 Credential=${c.credentials.accessKeyId}/${scope}, SignedHeaders=${signed}, Signature=${signature}`);
21
+ return headers;
22
+ }
23
+ async function responseObject(response: Response): Promise<Record<string, unknown>> {
24
+ const reader = response.body?.getReader(); if (!reader) return {};
25
+ let text = '', size = 0; const decoder = new TextDecoder();
26
+ try { while (true) { const { value, done } = await reader.read(); if (done) break; size += value.byteLength; if (size > 65536) { await reader.cancel(); return {}; } text += decoder.decode(value, { stream: true }); } }
27
+ finally { reader.releaseLock(); }
28
+ try { return JSON.parse(text + decoder.decode()); } catch { return {}; }
29
+ }
30
+ export async function sendWithProvider(mail: Mail, c: ResolvedConfig, id: string, locals?: unknown): Promise<SendResult> {
31
+ if (!c.config.enabled || c.provider === 'none' || c.error) return { state: 'failed', error: c.error || 'Email sending is disabled.' };
32
+ const recipients = (Array.isArray(mail.to) ? mail.to : [mail.to]).map((x) => x.trim());
33
+ if (!recipients.length || recipients.some((x) => !validAddress(x)) || /[\r\n]/.test(mail.subject)) return { state: 'failed', error: 'Invalid recipient or subject.' };
34
+ const replyTo = mail.replyTo || c.config.replyTo || undefined;
35
+ if (replyTo && !parseFrom(replyTo)) return { state: 'failed', error: 'Invalid reply-to address.' };
36
+ const from = c.config.from, attachments = mail.attachments || [];
37
+ let response: Response;
38
+ try {
39
+ if (c.provider === 'cloudflare') {
40
+ const address = (raw: string) => { const parsed = parseFrom(raw)!; return parsed.name ? { email: parsed.email, name: parsed.name } : parsed.email; };
41
+ const result = await emailBinding(locals)!.send({ from: address(from), to: recipients, subject: mail.subject, html: mail.html, text: mail.text,
42
+ ...(replyTo ? { replyTo: address(replyTo) } : {}), attachments: attachments.map((a) => ({ ...a, disposition: 'attachment' as const })) });
43
+ return result?.messageId ? { state: 'accepted', providerId: result.messageId } : { state: 'unknown', error: 'Cloudflare returned no message ID; review its delivery log.' };
44
+ }
45
+ const signal = AbortSignal.timeout(10_000);
46
+ if (c.provider === 'resend') {
47
+ response = await fetch('https://api.resend.com/emails', { method: 'POST', signal,
48
+ headers: { authorization: `Bearer ${c.credentials.apiKey}`, 'content-type': 'application/json', 'Idempotency-Key': id },
49
+ body: JSON.stringify({ from, to: recipients, subject: mail.subject, html: mail.html, text: mail.text, reply_to: replyTo,
50
+ tags: [{ name: 'acms_id', value: id }], attachments: attachments.map((a) => ({ filename: a.filename, content: b64(a.content), content_type: a.type })) }) });
51
+ } else if (c.provider === 'mailgun') {
52
+ const form = new FormData();
53
+ for (const [key, value] of Object.entries({ from, subject: mail.subject, html: mail.html, text: mail.text, 'v:acms_id': id, 'o:tracking': 'no' })) form.set(key, value);
54
+ for (const recipient of recipients) form.append('to', recipient);
55
+ if (replyTo) form.set('h:Reply-To', replyTo);
56
+ for (const a of attachments) form.append('attachment', new Blob([a.content], { type: a.type }), a.filename);
57
+ response = await fetch(`https://${c.config.region === 'eu' ? 'api.eu.mailgun.net' : 'api.mailgun.net'}/v3/${c.config.domain}/messages`, {
58
+ method: 'POST', signal, headers: { authorization: `Basic ${b64(`api:${c.credentials.apiKey}`)}` }, body: form });
59
+ } else {
60
+ const url = new URL(`https://email.${c.config.region}.amazonaws.com/v2/email/outbound-emails`);
61
+ const body = JSON.stringify({ FromEmailAddress: from, Destination: { ToAddresses: recipients }, ReplyToAddresses: replyTo ? [replyTo] : undefined,
62
+ ConfigurationSetName: c.config.configurationSet || undefined, EmailTags: [{ Name: 'acms_id', Value: id }],
63
+ ConfigurationOverrides: { Tracking: { ClickTrackingEnabled: 'DISABLED', OpenTrackingEnabled: 'DISABLED' } },
64
+ Content: { Simple: { Subject: { Data: mail.subject, Charset: 'UTF-8' }, Body: { Html: { Data: mail.html, Charset: 'UTF-8' }, Text: { Data: mail.text, Charset: 'UTF-8' } },
65
+ Attachments: attachments.map((a) => ({ FileName: a.filename, ContentType: a.type, RawContent: b64(a.content), ContentDisposition: 'ATTACHMENT', ContentTransferEncoding: 'BASE64' })) } } });
66
+ response = await fetch(url, { method: 'POST', signal, headers: signSes(url, body, c), body });
67
+ }
68
+ const data = await responseObject(response);
69
+ if (response.ok) {
70
+ const providerId = data.id || data.MessageId;
71
+ return typeof providerId === 'string' ? { state: 'accepted', providerId: c.provider === 'mailgun' ? providerId.replace(/^<|>$/g,'') : providerId } : { state: 'unknown', error: 'Provider accepted the request without a message ID; review its log.' };
72
+ }
73
+ // Never persist a provider response: it can echo secrets or account links.
74
+ const error = `${c.provider} rejected the request (HTTP ${response.status}).`;
75
+ if (response.status === 429) {
76
+ const raw = response.headers.get('retry-after') || '';
77
+ const delay = /^\d+$/.test(raw) ? Number(raw) * 1000 : Date.parse(raw) - Date.now();
78
+ return { state: 'retrying', error, retryAfter: Number.isFinite(delay) ? Math.max(1000, delay) : undefined };
79
+ }
80
+ if (response.status >= 500 || response.status === 408) return { state: c.provider === 'resend' ? 'retrying' : 'unknown', error };
81
+ return { state: 'failed', error };
82
+ } catch (e) {
83
+ const code = String((e as { code?: string })?.code || '');
84
+ if (code.startsWith('E_') && !['E_INTERNAL_SERVER_ERROR', 'E_DELIVERY_FAILED'].includes(code)) {
85
+ return { state: /LIMIT_EXCEEDED/.test(code) ? 'retrying' : 'failed', error: `Cloudflare: ${code}` };
86
+ }
87
+ return { state: c.provider === 'resend' ? 'retrying' : 'unknown', error: 'No conclusive provider response. Delivery may have been accepted.' };
88
+ }
89
+ }
@@ -0,0 +1,78 @@
1
+ import type { ContentRepository } from '../repo/types';
2
+ import { resolveConfig, decrypt, encrypt, runtimeValue } from './config';
3
+ import { sendWithProvider } from './providers';
4
+ import { accountMail, canonicalOrigin } from './templates';
5
+ import { bookingMail } from '../booking/service';
6
+ import type { EmailJob, Mail } from './types';
7
+
8
+ async function render(repo: ContentRepository, job: EmailJob, locals?: unknown, from = ''): Promise<Mail | null> {
9
+ const settings = await repo.getSettings();
10
+ if (job.kind === 'test') return accountMail('test', job.recipient, settings.siteTitle);
11
+ const origin = canonicalOrigin(settings.siteUrl);
12
+ if (job.kind === 'booking') {
13
+ const payload = JSON.parse(job.payload);
14
+ const b = await repo.getBooking(payload.bookingId);
15
+ if (!b || (b.notificationVersion ?? 0) !== payload.version) return null;
16
+ if (payload.kind === 'reminder' && (b.status !== 'confirmed' || b.start <= Date.now())) return null;
17
+ const mail = await bookingMail(repo, b, payload.kind, origin, from, payload);
18
+ return { ...mail, to: job.recipient };
19
+ }
20
+ if (job.kind === 'auth') {
21
+ const payload = JSON.parse(job.payload);
22
+ const token = await repo.email.token(payload.tokenId);
23
+ if (!token || token.revoked || token.used_by || Number(token.expires_at) <= Date.now() || !token.encrypted) return null;
24
+ const raw = decrypt<string>(String(token.encrypted), `token:${token.id}`, locals);
25
+ return accountMail(payload.purpose, job.recipient, settings.siteTitle, `${origin}/account/${payload.purpose}?token=${encodeURIComponent(raw)}`);
26
+ }
27
+ if (job.kind === 'welcome' || job.kind === 'password-changed') return accountMail(job.kind, job.recipient, settings.siteTitle, `${origin}/admin/login`);
28
+ return null;
29
+ }
30
+ export async function dispatchEmail(repo: ContentRepository, locals?: unknown, limit = 10): Promise<number> {
31
+ if (runtimeValue('EMAIL_DISPATCH_PAUSED', locals) === 'true') return 0;
32
+ let processed = 0;
33
+ for (let i=0;i<limit;i++) {
34
+ const j = await repo.email.claim(); if (!j) break;
35
+ let handedToProvider = false;
36
+ try {
37
+ let config = await repo.email.config(j.configVersion || undefined);
38
+ const resolved = resolveConfig(config, locals);
39
+ const current = resolveConfig(await repo.email.config(), locals);
40
+ if (!current.config.enabled || current.config.provider === 'none') {
41
+ await repo.email.finish(j,{state:'cancelled',error:'Email is disabled.'}); continue;
42
+ }
43
+ if (j.fingerprint && j.fingerprint !== resolved.fingerprint) {
44
+ await repo.email.finish(j,{state:'failed',error:'Provider configuration changed. Restore the original environment credentials before retrying this job.'}); continue;
45
+ }
46
+ await repo.email.pin(j,resolved.provider,resolved.fingerprint,config?.version || 0);
47
+ if (await repo.email.suppressed(j.recipient)) { await repo.email.finish(j,{state:'suppressed',error:'Recipient is suppressed after a bounce or complaint.'}); continue; }
48
+ if (j.kind === 'booking') {
49
+ const payload = JSON.parse(j.payload);
50
+ if ((payload.kind === 'reminder' && !current.config.reminders) || (payload.kind === 'staff-new' && !current.config.staffNotifications)) {
51
+ await repo.email.finish(j,{state:'cancelled',error:'Optional notification disabled.'}); continue;
52
+ }
53
+ }
54
+ const mail = await render(repo,j,locals,resolved.config.from);
55
+ if (!mail) { await repo.email.finish(j,{state:'cancelled',error:'Notification no longer applies.'}); continue; }
56
+ // Retries must reuse exactly the same request body, including calendar
57
+ // timestamps, for Resend's idempotency key to remain valid. Encrypt the
58
+ // snapshot because account and booking links are bearer credentials.
59
+ const frozen = j.rendered ? decrypt<Mail>(j.rendered,`job:${j.id}`,locals) : mail;
60
+ if (!await repo.email.prepare(j,j.rendered || encrypt(frozen,`job:${j.id}`,locals))) continue;
61
+ handedToProvider = true;
62
+ const result = await sendWithProvider(frozen,resolved,j.id,locals);
63
+ await repo.email.finish(j,result);
64
+ // Purge the recoverable raw account token once the provider accepted it.
65
+ // The verifier hash remains so the recipient can redeem the link.
66
+ if (result.state === 'accepted') processed++;
67
+ } catch {
68
+ await repo.email.finish(j,{state:handedToProvider?'unknown':'failed',error:handedToProvider?'Provider handoff could not be recorded. Review delivery before retrying.':'Unable to prepare email. Check canonical site URL and encryption configuration.'});
69
+ }
70
+ }
71
+ await repo.email.reconcile();
72
+ await repo.email.maintenance();
73
+ return processed;
74
+ }
75
+ export async function kickEmail(repo: ContentRepository, locals?: unknown, waitUntil?: (p: Promise<unknown>) => void) {
76
+ const work = dispatchEmail(repo,locals,3).catch(() => 0);
77
+ if (waitUntil) waitUntil(work); else await work;
78
+ }
@@ -0,0 +1,167 @@
1
+ import type { SqlDriver, SqlStatement } from '../repo/sql';
2
+ import type { ConfigRecord, EmailConfig, EmailJob, Invitation, NewEmailJob, SendResult } from './types';
3
+ const record = (r: Record<string, unknown>): ConfigRecord => ({ version: Number(r.version), config: JSON.parse(String(r.config)), encrypted: String(r.encrypted), createdAt: Number(r.created_at) });
4
+ function job(r: Record<string, unknown>): EmailJob {
5
+ return { id: String(r.id), dedupe: String(r.dedupe), kind: String(r.kind), recipient: String(r.recipient), payload: String(r.payload), configVersion: Number(r.config_version), state: r.state as EmailJob['state'], attempts: Number(r.attempts), nextAt: Number(r.next_at), expiresAt: r.expires_at == null ? null : Number(r.expires_at), createdAt: Number(r.created_at), lease: String(r.lease), leaseUntil: r.lease_until == null ? null : Number(r.lease_until), provider: String(r.provider), providerId: String(r.provider_id), error: String(r.error), acceptedAt: r.accepted_at == null ? null : Number(r.accepted_at), updatedAt: Number(r.updated_at), fingerprint: String(r.fingerprint), rendered: String(r.rendered || '') };
6
+ }
7
+ export function jobStatement(j: NewEmailJob, condition = '1', args: unknown[] = []): SqlStatement {
8
+ const now = Date.now();
9
+ return { sql: `INSERT OR IGNORE INTO email_jobs (id,dedupe,kind,recipient,payload,config_version,next_at,expires_at,created_at,updated_at) SELECT ?,?,?,?,?,?,?,?,?,? WHERE ${condition}`, args: [j.id || crypto.randomUUID(), j.dedupe, j.kind, j.recipient.toLowerCase(), j.payload, j.configVersion, j.nextAt ?? now, j.expiresAt ?? null, now, now, ...args] };
10
+ }
11
+ // INSERT...SELECT is evaluated inside the same batch as the guarded booking
12
+ // mutation. Losing a capacity/slot race cannot enqueue a confirmation.
13
+ export function bookingJobs(id: string, kind: string, version?: number, staff = false, mutation = '', message = ''): SqlStatement[] {
14
+ const now = Date.now();
15
+ const select = (recipient: string, from: string, event: string): SqlStatement => ({
16
+ sql: `INSERT OR IGNORE INTO email_jobs (id,dedupe,kind,recipient,payload,config_version,next_at,expires_at,created_at,updated_at)
17
+ SELECT lower(hex(randomblob(16))), b.id || ':' || b.notification_version || ':' || ? || ':' || lower(${recipient}), 'booking', lower(${recipient}),
18
+ json_object('bookingId',b.id,'version',b.notification_version,'kind',?,'message',?,
19
+ 'eventTitle',(SELECT title FROM events WHERE id=b.event_id),
20
+ 'eventLocation',(SELECT location FROM events WHERE id=b.event_id),
21
+ 'eventAllDay',(SELECT all_day FROM events WHERE id=b.event_id)),
22
+ COALESCE((SELECT MAX(version) FROM email_configs),0), ?, CASE WHEN ? = 'reminder' THEN b.start_at ELSE NULL END, ?, ?
23
+ FROM ${from} WHERE b.id = ? AND b.kind != 'block' AND ${recipient} != '' ${version === undefined ? '' : 'AND b.notification_version = ?'} ${mutation ? 'AND b.email_mutation = ?' : ''}`,
24
+ args: [event, event, message.slice(0,2000), now, event, now, now, id, ...(version === undefined ? [] : [version]), ...(mutation ? [mutation] : [])],
25
+ });
26
+ return [select('b.email', 'bookings b', kind), ...(staff ? [select('j.value', 'bookings b JOIN resources r ON r.id = b.resource_id, json_each(r.notify_emails) j', 'staff-new')] : [])];
27
+ }
28
+ export class EmailStore {
29
+ constructor(private getDriver: () => Promise<SqlDriver>) {}
30
+ async config(version?: number): Promise<ConfigRecord | null> {
31
+ const d = await this.getDriver(); const rows = await d.all(version ? 'SELECT * FROM email_configs WHERE version = ?' : 'SELECT * FROM email_configs ORDER BY version DESC LIMIT 1', version ? [version] : []);
32
+ return rows[0] ? record(rows[0]) : null;
33
+ }
34
+ async configs(): Promise<ConfigRecord[]> { return (await (await this.getDriver()).all('SELECT * FROM email_configs ORDER BY version DESC LIMIT 100')).map(record); }
35
+ async saveConfig(config: EmailConfig, encrypted: string): Promise<ConfigRecord> {
36
+ const d = await this.getDriver();
37
+ const rows = await d.all('INSERT INTO email_configs (config,encrypted,created_at) VALUES (?,?,?) RETURNING *', [JSON.stringify(config), encrypted, Date.now()]);
38
+ return record(rows[0]);
39
+ }
40
+ async enqueue(j: NewEmailJob): Promise<void> { await (await this.getDriver()).batch([jobStatement(j)]); }
41
+ async get(id: string): Promise<EmailJob | null> { const rows = await (await this.getDriver()).all('SELECT * FROM email_jobs WHERE id = ?', [id]); return rows[0] ? job(rows[0]) : null; }
42
+ async list(): Promise<EmailJob[]> { return (await (await this.getDriver()).all('SELECT * FROM email_jobs ORDER BY created_at DESC LIMIT 100')).map(job); }
43
+ async claim(now = Date.now()): Promise<EmailJob | null> {
44
+ const d = await this.getDriver();
45
+ // A dead worker may have sent already. Only Resend can retry with the
46
+ // same key within its 24h window. Other uncertain outcomes need review.
47
+ await d.run(`UPDATE email_jobs SET state = CASE WHEN provider='resend' AND created_at > ? THEN 'retrying' ELSE 'unknown' END,
48
+ error='Dispatcher interrupted; provider acceptance may have occurred.', lease='',lease_until=NULL WHERE state='sending' AND lease_until < ?`, [now - 23 * 3600000, now]);
49
+ await d.run("UPDATE email_jobs SET state='cancelled',payload='',rendered='',error='Expired before delivery.',updated_at=? WHERE state IN ('queued','retrying','failed','unknown') AND expires_at <= ?", [now, now]);
50
+ await d.run("UPDATE email_jobs SET state='unknown',error='Retry window exhausted. Review provider delivery logs.',updated_at=? WHERE state='retrying' AND (attempts >= 8 OR created_at <= ?)",[now,now-23*3600000]);
51
+ const lease = crypto.randomUUID();
52
+ const rows = await d.all(`UPDATE email_jobs SET state='sending',lease=?,lease_until=?,attempts=attempts+1,updated_at=? WHERE id =
53
+ (SELECT id FROM email_jobs WHERE state IN ('queued','retrying') AND next_at <= ? ORDER BY CASE WHEN kind='auth' THEN 0 ELSE 1 END,next_at LIMIT 1) AND state IN ('queued','retrying') RETURNING *`, [lease, now + 120000, now, now]);
54
+ return rows[0] ? job(rows[0]) : null;
55
+ }
56
+ async pin(j: EmailJob, provider: string, fingerprint: string, version: number) {
57
+ await (await this.getDriver()).run('UPDATE email_jobs SET provider=?,fingerprint=?,config_version=? WHERE id=? AND lease=? AND state=\'sending\'', [provider, fingerprint, version, j.id, j.lease]);
58
+ }
59
+ async prepare(j: EmailJob, encrypted: string): Promise<boolean> {
60
+ const rows = await (await this.getDriver()).all("UPDATE email_jobs SET rendered=? WHERE id=? AND lease=? AND state='sending' RETURNING id",[encrypted,j.id,j.lease]);
61
+ return rows.length > 0;
62
+ }
63
+ async finish(j: EmailJob, result: SendResult | { state: 'cancelled' | 'suppressed'; error: string }, now = Date.now()) {
64
+ const accepted = result.state === 'accepted';
65
+ let state: EmailJob['state'] = result.state;
66
+ if (state === 'retrying' && (j.attempts >= 8 || now - j.createdAt > 23 * 3600000)) state = 'unknown';
67
+ const error = accepted ? '' : result.error;
68
+ const delay = result.state === 'retrying' ? Math.max(result.retryAfter || 0, Math.min(3600000, 30000 * 2 ** Math.min(j.attempts, 7)) + Math.floor(Math.random() * 5000)) : 0;
69
+ const d = await this.getDriver();
70
+ await d.batch([
71
+ { sql: `INSERT INTO email_attempts (job_id,attempt,state,error,created_at) SELECT ?,?,?,?,? WHERE EXISTS(SELECT 1 FROM email_jobs WHERE id=? AND lease=?)`, args: [j.id,j.attempts,state,error,now,j.id,j.lease] },
72
+ { sql: `UPDATE email_jobs SET state=?,provider_id=?,error=?,next_at=?,accepted_at=?,updated_at=?,lease='',lease_until=NULL,
73
+ rendered=CASE WHEN ? IN ('accepted','cancelled','suppressed') THEN '' ELSE rendered END,
74
+ payload=CASE WHEN ? IN ('accepted','cancelled','suppressed') AND kind != 'booking' THEN '' ELSE payload END
75
+ WHERE id=? AND lease=? AND state='sending'`, args: [state, accepted ? result.providerId : '', error, now+delay, accepted ? now : null, now,state,state,j.id,j.lease] },
76
+ ]);
77
+ if (accepted && j.kind === 'auth') {
78
+ const p = JSON.parse(j.payload);
79
+ await d.run("UPDATE email_tokens SET encrypted='' WHERE id=?", [p.tokenId]);
80
+ }
81
+ if (accepted && j.kind === 'booking') {
82
+ const p = JSON.parse(j.payload);
83
+ if (p.kind === 'reminder') await d.run('UPDATE bookings SET reminder_sent_at=? WHERE id=? AND notification_version=?', [now,p.bookingId,p.version]);
84
+ }
85
+ }
86
+ async retry(id: string): Promise<boolean> {
87
+ const d = await this.getDriver();
88
+ const rows = await d.all(`UPDATE email_jobs SET state='queued',error='',next_at=?,updated_at=? WHERE id=? AND state='failed' AND attempts < 8 AND created_at > ? AND (expires_at IS NULL OR expires_at > ?) RETURNING id`, [Date.now(),Date.now(),id,Date.now()-23*3600000,Date.now()]);
89
+ return rows.length > 0;
90
+ }
91
+ async suppressed(address: string): Promise<boolean> { return (await (await this.getDriver()).all('SELECT address FROM email_suppressions WHERE address=?', [address.toLowerCase()])).length > 0; }
92
+ async event(e: { id: string; provider: string; providerId: string; jobId?: string; recipient: string; state: 'delivered' | 'bounced' | 'complained'; at: number; configVersion: number }) {
93
+ const d = await this.getDriver();
94
+ await d.batch([
95
+ { sql: 'INSERT OR IGNORE INTO email_delivery_events (id,provider,provider_id,job_id,recipient,state,created_at,config_version) VALUES (?,?,?,?,?,?,?,?)', args: [e.id,e.provider,e.providerId,e.jobId || '',e.recipient.toLowerCase(),e.state,e.at,e.configVersion] },
96
+ ...(e.state !== 'delivered' && e.recipient ? [{ sql: 'INSERT OR IGNORE INTO email_suppressions (address,reason,created_at) VALUES (?,?,?)', args: [e.recipient.toLowerCase(),e.state,e.at] }] : []),
97
+ ]);
98
+ await this.reconcile();
99
+ }
100
+ async reconcile() {
101
+ const d = await this.getDriver();
102
+ // Event precedence is monotonic: complaint > bounce > delivery > accepted.
103
+ for (const state of ['delivered','bounced','complained']) {
104
+ await d.run(`UPDATE email_jobs SET state=?,payload=CASE WHEN kind='auth' THEN '' ELSE payload END WHERE state NOT IN (${state === 'delivered' ? "'bounced','complained','suppressed','cancelled'" : state === 'bounced' ? "'complained','suppressed','cancelled'" : "'suppressed','cancelled'"}) AND EXISTS (
105
+ SELECT 1 FROM email_delivery_events e WHERE e.state=? AND e.provider=email_jobs.provider AND e.config_version=email_jobs.config_version AND
106
+ ((e.provider_id=email_jobs.provider_id AND email_jobs.provider_id!='') OR e.job_id=email_jobs.id) AND (e.recipient='' OR e.recipient=email_jobs.recipient))`, [state,state]);
107
+ }
108
+ }
109
+ async rateLimit(key: string, max: number, windowMs: number): Promise<boolean> {
110
+ const now = Date.now(), d = await this.getDriver();
111
+ const rows = await d.all(`INSERT INTO email_rate_limits (key,count,expires_at) VALUES (?,1,?) ON CONFLICT(key) DO UPDATE SET
112
+ count=CASE WHEN expires_at <= ? THEN 1 ELSE count+1 END, expires_at=CASE WHEN expires_at <= ? THEN ? ELSE expires_at END RETURNING count`, [key,now+windowMs,now,now,now+windowMs]);
113
+ return Number(rows[0].count) <= max;
114
+ }
115
+ async recordScheduler(report: { startedAt: number; finishedAt: number; ok: boolean; jobs: Record<string, { ok: boolean; count?: number }> }) {
116
+ await (await this.getDriver()).run("INSERT INTO email_meta(key,value) VALUES ('scheduler-heartbeat',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value", [JSON.stringify(report)]);
117
+ }
118
+ async schedulerStatus(): Promise<{ startedAt: number; finishedAt: number; ok: boolean; jobs: Record<string, { ok: boolean; count?: number }> } | null> {
119
+ const rows = await (await this.getDriver()).all("SELECT value FROM email_meta WHERE key='scheduler-heartbeat'");
120
+ return rows[0] ? JSON.parse(String(rows[0].value)) : null;
121
+ }
122
+ async heartbeat(): Promise<number | null> { const r = await (await this.getDriver()).all("SELECT value FROM email_meta WHERE key='heartbeat'"); return r[0] ? Number(r[0].value) : null; }
123
+ async maintenance(now = Date.now()) {
124
+ const d = await this.getDriver();
125
+ await d.batch([
126
+ { sql: "INSERT INTO email_meta(key,value) VALUES ('heartbeat',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value", args:[String(now)] },
127
+ { sql: 'DELETE FROM email_rate_limits WHERE expires_at < ?', args:[now] },
128
+ { sql: "DELETE FROM email_jobs WHERE updated_at < ? AND state NOT IN ('queued','sending','retrying')",args:[now-30*86400000] },
129
+ { sql: 'DELETE FROM email_attempts WHERE created_at < ?',args:[now-30*86400000] },
130
+ { sql: 'DELETE FROM email_delivery_events WHERE created_at < ?',args:[now-30*86400000] },
131
+ { sql: "UPDATE email_tokens SET encrypted='' WHERE expires_at <= ? OR used_by != '' OR revoked=1", args:[now] },
132
+ ]);
133
+ }
134
+ async sessionVersion(id: string): Promise<number | null> { const r = await (await this.getDriver()).all('SELECT session_version FROM users WHERE id=?', [id]); return r[0] ? Number(r[0].session_version) : null; }
135
+ async invitations(): Promise<Invitation[]> {
136
+ return (await (await this.getDriver()).all("SELECT id,email,name,role,expires_at,created_at,CASE WHEN revoked=1 THEN 'revoked' WHEN used_by!='' THEN 'accepted' WHEN expires_at < ? THEN 'expired' ELSE 'pending' END state FROM email_tokens WHERE purpose='invite' ORDER BY created_at DESC LIMIT 100", [Date.now()])).map((r) => ({ id: String(r.id), email:String(r.email),name:String(r.name),role:r.role as Invitation['role'],expiresAt:Number(r.expires_at),createdAt:Number(r.created_at),state:String(r.state) }));
137
+ }
138
+ async token(id: string) { return (await (await this.getDriver()).all('SELECT * FROM email_tokens WHERE id=?', [id]))[0] || null; }
139
+ async tokenByHash(hash: string) { return (await (await this.getDriver()).all('SELECT * FROM email_tokens WHERE token_hash=? AND revoked=0 AND used_by=\'\' AND expires_at > ?', [hash,Date.now()]))[0] || null; }
140
+ async saveToken(t: { id:string; hash:string; encrypted:string; purpose:'invite'|'reset'; email:string; name:string; role:string; userId:string; expiresAt:number }, j: NewEmailJob) {
141
+ const d=await this.getDriver();
142
+ await d.batch([
143
+ {sql:"UPDATE email_tokens SET revoked=1,encrypted='' WHERE email=? AND purpose=? AND used_by=''",args:[t.email,t.purpose]},
144
+ {sql:"UPDATE email_jobs SET state='cancelled',payload='',rendered='' WHERE kind='auth' AND recipient=? AND json_extract(NULLIF(payload,''),'$.purpose')=? AND state IN ('queued','retrying','failed','unknown')",args:[t.email,t.purpose]},
145
+ {sql:'INSERT INTO email_tokens (id,token_hash,encrypted,purpose,email,name,role,user_id,expires_at,created_at) VALUES (?,?,?,?,?,?,?,?,?,?)',args:[t.id,t.hash,t.encrypted,t.purpose,t.email,t.name,t.role,t.userId,t.expiresAt,Date.now()]},
146
+ jobStatement(j),
147
+ ]);
148
+ }
149
+ async revoke(id:string) {
150
+ await (await this.getDriver()).batch([
151
+ {sql:"UPDATE email_tokens SET revoked=1,encrypted='' WHERE id=? AND used_by=''",args:[id]},
152
+ {sql:"UPDATE email_jobs SET state='cancelled',payload='',rendered='' WHERE kind='auth' AND json_extract(NULLIF(payload,''),'$.tokenId')=? AND state IN ('queued','retrying','failed','unknown')",args:[id]},
153
+ ]);
154
+ }
155
+ async consume(hash:string, passHash:string, userId:string, notice:NewEmailJob):Promise<boolean> {
156
+ const d=await this.getDriver(), nonce=crypto.randomUUID(),now=Date.now();
157
+ await d.batch([
158
+ {sql:`UPDATE email_tokens SET used_by=?,encrypted='' WHERE token_hash=? AND used_by='' AND revoked=0 AND expires_at>? AND
159
+ ((purpose='reset' AND EXISTS(SELECT 1 FROM users WHERE id=email_tokens.user_id AND email=email_tokens.email)) OR
160
+ (purpose='invite' AND NOT EXISTS(SELECT 1 FROM users WHERE email=email_tokens.email)))`,args:[nonce,hash,now]},
161
+ {sql:`INSERT INTO users (id,name,email,role,pass_hash,created_at) SELECT ?,name,email,role,?,? FROM email_tokens WHERE used_by=? AND purpose='invite'`,args:[userId,passHash,now,nonce]},
162
+ {sql:`UPDATE users SET pass_hash=?,session_version=session_version+1 WHERE id IN (SELECT user_id FROM email_tokens WHERE used_by=? AND purpose='reset')`,args:[passHash,nonce]},
163
+ jobStatement(notice,'EXISTS(SELECT 1 FROM email_tokens WHERE used_by=?)',[nonce]),
164
+ ]);
165
+ return (await d.all('SELECT id FROM email_tokens WHERE used_by=?',[nonce])).length>0;
166
+ }
167
+ }
@@ -0,0 +1,18 @@
1
+ import type { Mail } from './types';
2
+ export const escape = (s: string) => s.replace(/[&<>"']/g, (c) => ({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;' }[c]!));
3
+ export function accountMail(kind: 'invite'|'reset'|'welcome'|'password-changed'|'test', to: string, site: string, url = ''): Mail {
4
+ const copy = {
5
+ invite: ['You’re invited', `You’ve been invited to join ${site}. Choose your own password to accept.`, 'Accept invitation'],
6
+ reset: ['Reset your password', 'Use this single-use link within 30 minutes to choose a new password. If you did not request this, ignore this message.', 'Choose a new password'],
7
+ welcome: ['Welcome', `Your account at ${site} is ready.`, 'Sign in'],
8
+ 'password-changed': ['Your password changed', 'Your password was changed and previous sessions were signed out. If this was not you, contact the site owner.', 'Sign in'],
9
+ test: ['Email connection test', `This is a transactional email test from ${site}.`, ''],
10
+ }[kind];
11
+ return { to, subject: `${copy[0]} · ${site}`, text: [copy[0], '', copy[1], ...(url ? ['',`${copy[2]}: ${url}`] : [])].join('\n'),
12
+ html: `<!doctype html><html><body style="margin:0;background:#f6f5f2;color:#24211c;font:16px/1.6 system-ui,sans-serif"><div style="max-width:520px;margin:auto;padding:32px 24px"><p style="color:#7d776e">${escape(site)}</p><h1 style="font-size:24px">${escape(copy[0])}</h1><p>${escape(copy[1])}</p>${url ? `<p><a style="display:inline-block;background:#ee5d20;color:#fff;padding:10px 18px;border-radius:8px;text-decoration:none" href="${escape(url)}">${escape(copy[2])}</a></p>` : ''}</div></body></html>` };
13
+ }
14
+ export function canonicalOrigin(value: string): string {
15
+ let url: URL; try { url = new URL(value); } catch { throw new Error('Set the canonical site URL in Settings before sending email links.'); }
16
+ if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash || url.pathname !== '/') throw new Error('The canonical site URL must be an HTTPS origin, without a path, credentials or query.');
17
+ return url.origin;
18
+ }
@@ -0,0 +1,32 @@
1
+ export type Provider = 'resend' | 'mailgun' | 'ses' | 'cloudflare';
2
+ export type ProviderChoice = Provider | 'auto' | 'none';
3
+ export interface Attachment { filename: string; type: string; content: string }
4
+ export interface Mail { to: string | string[]; subject: string; html: string; text: string; attachments?: Attachment[]; replyTo?: string }
5
+ export interface EmailConfig {
6
+ provider: ProviderChoice; source: 'env' | 'encrypted'; from: string; replyTo: string;
7
+ domain: string; region: string; configurationSet: string; snsTopicArn: string;
8
+ enabled: boolean; reminders: boolean; staffNotifications: boolean;
9
+ }
10
+ export interface Credentials { apiKey?: string; webhookSecret?: string; accessKeyId?: string; secretAccessKey?: string; sessionToken?: string }
11
+ export interface ConfigRecord { version: number; config: EmailConfig; encrypted: string; createdAt: number }
12
+ export interface ResolvedConfig { version: number; config: EmailConfig; provider: Provider | 'none'; credentials: Credentials; error?: string; fingerprint: string }
13
+ export type DeliveryState = 'queued' | 'sending' | 'retrying' | 'accepted' | 'delivered' | 'failed' | 'unknown' | 'bounced' | 'complained' | 'suppressed' | 'cancelled';
14
+ export interface EmailJob {
15
+ id: string; dedupe: string; kind: string; recipient: string; payload: string; configVersion: number;
16
+ state: DeliveryState; attempts: number; nextAt: number; expiresAt: number | null; createdAt: number;
17
+ lease: string; leaseUntil: number | null; provider: string; providerId: string; error: string;
18
+ acceptedAt: number | null; updatedAt: number; fingerprint: string;
19
+ rendered: string;
20
+ }
21
+ export interface NewEmailJob { id?: string; dedupe: string; kind: string; recipient: string; payload: string; configVersion: number; nextAt?: number; expiresAt?: number }
22
+ export type SendResult = { state: 'accepted'; providerId: string } | { state: 'retrying' | 'failed' | 'unknown'; error: string; retryAfter?: number };
23
+ export interface Invitation { id: string; email: string; name: string; role: 'owner' | 'editor'; state: string; expiresAt: number; createdAt: number }
24
+ export interface PublicEmailSettings {
25
+ version: number; config: EmailConfig; provider: Provider | 'none'; configured: boolean; error: string;
26
+ encryptionAvailable: boolean; environmentLocked: boolean; credentialFields: string[];
27
+ activity: Omit<EmailJob, 'payload' | 'lease' | 'fingerprint' | 'rendered'>[]; heartbeat: number | null;
28
+ }
29
+ export const DEFAULT_EMAIL_CONFIG: EmailConfig = {
30
+ provider: 'auto', source: 'env', from: '', replyTo: '', domain: '', region: 'us-east-1', configurationSet: '', snsTopicArn: '',
31
+ enabled: true, reminders: true, staffNotifications: true,
32
+ };
@@ -0,0 +1,87 @@
1
+ import { createHmac, timingSafeEqual, verify, X509Certificate } from 'node:crypto';
2
+ import type { ContentRepository } from '../repo/types';
3
+ import { resolveConfig } from './config';
4
+ import type { ResolvedConfig } from './types';
5
+ const equal=(a:Buffer,b:Buffer)=>a.length===b.length&&timingSafeEqual(a,b);
6
+ const object=(v:unknown):Record<string,unknown>=>v&&typeof v==='object'&&!Array.isArray(v)?v as Record<string,unknown>:{};
7
+ const string=(v:unknown)=>typeof v==='string'?v:'';
8
+ const normalizeId=(s:string)=>s.replace(/^<|>$/g,'');
9
+ async function rawBody(request:Request):Promise<string>{
10
+ const reader=request.body?.getReader();if(!reader)return '';
11
+ const parts:Uint8Array[]=[];let size=0;
12
+ try{while(true){const {value,done}=await reader.read();if(done)break;size+=value.length;if(size>262144){await reader.cancel();throw new Error('Too large');}parts.push(value);}}finally{reader.releaseLock();}
13
+ return Buffer.concat(parts).toString('utf8');
14
+ }
15
+ function fresh(timestamp:number,window=10*60000){return Number.isFinite(timestamp)&&Math.abs(Date.now()-timestamp)<window;}
16
+ function resendValid(request:Request,raw:string,secret:string){
17
+ const id=request.headers.get('svix-id')||'',time=request.headers.get('svix-timestamp')||'',sig=request.headers.get('svix-signature')||'';
18
+ if(!id||!secret||!fresh(Number(time)*1000))return false;
19
+ const expected=createHmac('sha256',Buffer.from(secret.replace(/^whsec_/,''),'base64')).update(`${id}.${time}.${raw}`).digest();
20
+ return sig.split(' ').some(part=>part.startsWith('v1,')&&equal(expected,Buffer.from(part.slice(3),'base64')));
21
+ }
22
+ function mailgunValid(data:Record<string,unknown>,secret:string){
23
+ const sig=object(data.signature),time=string(sig.timestamp)||String(sig.timestamp||'');
24
+ if(!secret||!fresh(Number(time)*1000)||!string(sig.token)||!/^[a-f0-9]{64}$/i.test(string(sig.signature)))return false;
25
+ return equal(createHmac('sha256',secret).update(time+string(sig.token)).digest(),Buffer.from(string(sig.signature),'hex'));
26
+ }
27
+ function snsUrl(value:string,region:string,certificate=false):URL|null{
28
+ try{const u=new URL(value);return u.protocol==='https:'&&!u.username&&!u.password&&!u.port&&u.hostname===`sns.${region}.amazonaws.com`&&(!certificate||(/^\/SimpleNotificationService-[a-zA-Z0-9_-]+\.pem$/.test(u.pathname)&&!u.search))?u:null;}catch{return null;}
29
+ }
30
+ async function snsValid(data:Record<string,unknown>,c:ResolvedConfig){
31
+ if(!c.config.snsTopicArn||data.TopicArn!==c.config.snsTopicArn||!fresh(Date.parse(string(data.Timestamp)),86400000))return false;
32
+ const url=snsUrl(string(data.SigningCertURL),c.config.region,true);
33
+ if(!url||!['1','2'].includes(string(data.SignatureVersion)))return false;
34
+ const type=string(data.Type);
35
+ const fields=type==='Notification'?['Message','MessageId',...(data.Subject!==undefined?['Subject']:[]),'Timestamp','TopicArn','Type']:
36
+ type==='SubscriptionConfirmation'?['Message','MessageId','SubscribeURL','Timestamp','Token','TopicArn','Type']:[];
37
+ if(!fields.length||fields.some(k=>typeof data[k]!=='string'))return false;
38
+ const response=await fetch(url,{redirect:'error',signal:AbortSignal.timeout(5000)});
39
+ if(!response.ok)return false;
40
+ const certRaw=await rawBody(new Request('https://localhost',{method:'POST',body:response.body,duplex:'half'} as RequestInit));
41
+ const cert=new X509Certificate(certRaw);
42
+ if(Date.parse(cert.validFrom)>Date.now()||Date.parse(cert.validTo)<Date.now())return false;
43
+ const canonical=fields.map(k=>`${k}\n${data[k]}\n`).join('');
44
+ return verify(data.SignatureVersion==='2'?'RSA-SHA256':'RSA-SHA1',Buffer.from(canonical),cert.publicKey,Buffer.from(string(data.Signature),'base64'));
45
+ }
46
+ export async function receiveEmailWebhook(request:Request,provider:string,repo:ContentRepository,locals?:unknown):Promise<Response>{
47
+ try{
48
+ if(!['resend','mailgun','ses'].includes(provider))return new Response('Not found',{status:404});
49
+ const raw=await rawBody(request);const data=object(JSON.parse(raw));
50
+ const records=await repo.email.configs();
51
+ const candidates=[...records.map(r=>resolveConfig(r,locals)),resolveConfig(null,locals)].filter(c=>c.provider===provider);
52
+ let matched:ResolvedConfig|undefined;
53
+ for(const c of candidates){
54
+ if(provider==='resend'?resendValid(request,raw,c.credentials.webhookSecret||''):provider==='mailgun'?mailgunValid(data,c.credentials.webhookSecret||''):await snsValid(data,c)){matched=c;break;}
55
+ }
56
+ if(!matched)return new Response('Invalid signature',{status:401});
57
+ if(provider==='ses'&&data.Type==='SubscriptionConfirmation'){
58
+ const url=snsUrl(string(data.SubscribeURL),matched.config.region);
59
+ if(!url||url.searchParams.get('Action')!=='ConfirmSubscription'||url.searchParams.get('TopicArn')!==matched.config.snsTopicArn||url.searchParams.get('Token')!==data.Token)return new Response('Invalid subscription',{status:400});
60
+ const response=await fetch(url,{redirect:'error',signal:AbortSignal.timeout(5000)});
61
+ await response.body?.cancel();return new Response(response.ok?'Confirmed':'Confirmation failed',{status:response.ok?200:502});
62
+ }
63
+ let state:'delivered'|'bounced'|'complained'|undefined,recipients:string[]=[],providerId='',jobId='',eventId='';
64
+ if(provider==='resend'){
65
+ const d=object(data.data);state=({'email.delivered':'delivered','email.bounced':'bounced','email.complained':'complained'} as const)[string(data.type)];
66
+ recipients=Array.isArray(d.to)?d.to.filter(x=>typeof x==='string'):[];providerId=string(d.email_id);eventId=request.headers.get('svix-id')||'';
67
+ const tags=object(d.tags);jobId=string(tags.acms_id);
68
+ }else if(provider==='mailgun'){
69
+ const d=object(data['event-data']);state=string(d.event)==='delivered'?'delivered':string(d.event)==='complained'?'complained':string(d.event)==='failed'&&d.severity==='permanent'?'bounced':undefined;
70
+ recipients=[string(d.recipient)];providerId=normalizeId(string(object(object(d.message).headers)['message-id']));eventId=string(d.id);jobId=string(object(d['user-variables']).acms_id);
71
+ }else{
72
+ const d=object(JSON.parse(string(data.Message))),mail=object(d.mail);const kind=string(d.eventType)||string(d.notificationType);
73
+ state=kind==='Delivery'?'delivered':kind==='Bounce'?'bounced':kind==='Complaint'?'complained':undefined;
74
+ // Transient SES bounces do not permanently suppress an address.
75
+ if(kind==='Bounce'&&object(d.bounce).bounceType!=='Permanent')state=undefined;
76
+ recipients=kind==='Delivery'?(object(d.delivery).recipients as string[]||[]):((kind==='Bounce'?object(d.bounce).bouncedRecipients:object(d.complaint).complainedRecipients) as unknown[]||[]).map(r=>string(object(r).emailAddress));
77
+ providerId=string(mail.messageId);eventId=string(data.MessageId);const tag=object(mail.tags).acms_id;jobId=Array.isArray(tag)?string(tag[0]):'';
78
+ }
79
+ if(!state)return new Response('Ignored',{status:200});
80
+ if(!eventId||!providerId)return new Response('Invalid event',{status:400});
81
+ // A shared signing key can span configuration versions. Correlate only
82
+ // with jobs sent through one of the authenticated provider configs.
83
+ const authenticatedVersions=candidates.filter(c=>c.credentials.webhookSecret===matched!.credentials.webhookSecret&&c.config.snsTopicArn===matched!.config.snsTopicArn).map(c=>c.version);
84
+ for(const version of authenticatedVersions)for(const recipient of recipients.filter(Boolean))await repo.email.event({id:`${provider}:${version}:${eventId}:${recipient}`,provider,providerId,jobId,recipient,state,at:Date.now(),configVersion:version});
85
+ return new Response('OK');
86
+ }catch{return new Response('Invalid event',{status:400});}
87
+ }
@@ -0,0 +1,15 @@
1
+ // Compatibility facade. Durable application flows use email/service instead.
2
+ import { resolveConfig } from './email/config';
3
+ import { sendWithProvider } from './email/providers';
4
+ export { parseFrom } from './email/config';
5
+ export type { Mail, Attachment } from './email/types';
6
+ import type { Mail, Provider } from './email/types';
7
+ export type EmailProvider = Provider | 'none';
8
+ export function emailProvider(locals?: unknown): EmailProvider {
9
+ const resolved = resolveConfig(null, locals);
10
+ return resolved.error ? 'none' : resolved.provider;
11
+ }
12
+ export async function sendMail(mail: Mail, locals?: unknown) {
13
+ const c = resolveConfig(null, locals), result = await sendWithProvider(mail, c, crypto.randomUUID(), locals);
14
+ return { sent: result.state === 'accepted', provider: c.provider, ...(result.state === 'accepted' ? { providerId: result.providerId } : { error: result.error }) };
15
+ }
package/src/lib/env.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /// <reference types="astro/client" />
2
2
 
3
3
  export function env(key: string, fallback = ''): string {
4
- const v = (import.meta.env as Record<string, string | undefined>)[key] ?? process.env[key];
4
+ const v = process.env[key] ?? (import.meta.env as Record<string, string | undefined>)[key];
5
5
  return v ?? fallback;
6
6
  }
@@ -0,0 +1,25 @@
1
+ import { userConfig } from 'virtual:acms-config';
2
+ import type { Settings } from '../schema';
3
+ import { bookingsEnabled } from '../calendar/config';
4
+ export const LEGAL = { enabled: true, operatorName: '', contactEmail: '', updatedAt: '', ...userConfig.legal };
5
+ export function defaultPolicy(kind: 'privacy' | 'terms', settings: Settings) {
6
+ const operator = LEGAL.operatorName || settings.siteTitle;
7
+ const contact = LEGAL.contactEmail ? `Contact ${operator} at ${LEGAL.contactEmail}.` : 'Contact the site operator using the contact details published on this site.';
8
+ const sections = kind === 'privacy' ? [
9
+ { title: 'Who operates this site', text: `${operator} operates this site and decides how information collected here is used. ACMS supplies the website software; it does not automatically receive a copy of site content or visitor submissions. ${contact}` },
10
+ { title: 'Information used to run the site', text: 'The hosting and network providers may process IP addresses, request details and technical logs to deliver pages, protect the service and diagnose errors. The public API uses a hashed client address and short-lived counters to limit repeated requests. Administrators have account details and password hashes; sign-in uses a session cookie.' },
11
+ ...(bookingsEnabled() ? [{ title: 'Bookings and RSVPs', text: 'When you submit a booking or RSVP, the site stores your name, email address, any phone number or answers you provide, the requested time and the booking status. This information is used to manage the request, prevent duplicate submissions, and send confirmations or reminders when email is enabled. Authorized site staff can access it. Keep any booking-management link or token private.' }] : []),
12
+ { title: 'Cookies and browser storage', text: 'The admin session cookie keeps staff signed in and normally expires after seven days. Where enabled, the announcement banner stores its dismissed state in your browser. Optional integrations or embedded content may use additional cookies or storage; the operator is responsible for providing the notices and choices those features require.' },
13
+ { title: 'Service providers and optional features', text: 'The chosen host stores site data and uploaded files. If email is enabled, the selected email provider processes recipient details and message content. Themes may load fonts from Google Fonts, and embedded media may contact its provider. Optional analytics, session replay, custom scripts or AI tools are configured by the operator and may involve further providers. Additional processing must be described by the operator before those features are used.' },
14
+ { title: 'Retention and backups', text: 'Booking and content records are retained until the operator deletes them according to their retention policy. Hashed booking retry records are retained to prevent duplicate requests, including after a booking is deleted. Backups may retain copies of records and private booking-management tokens. The operator determines and must disclose the applicable retention periods, including hosting logs and backups.' },
15
+ { title: 'Questions and requests', text: `${contact} You may ask about your information or request access, correction or deletion. The operator may need to verify your identity and will handle requests in accordance with applicable law. This standard notice does not specify an operator’s jurisdiction, legal bases, international-transfer arrangements or additional data uses.` },
16
+ ] : [
17
+ { title: 'About this service', text: `${operator} provides this website and its enabled content, calendar and booking features. ${contact} These terms cover the standard site features; additional services may have separate terms presented by the operator.` },
18
+ { title: 'Using the site', text: 'Use the service lawfully. Do not attempt unauthorized access, disrupt availability, bypass request limits or submit malicious or misleading material. Keep account credentials and private booking-management links secure.' },
19
+ ...(bookingsEnabled() ? [{ title: 'Bookings and event information', text: 'Provide accurate contact and request details. Submitting a request does not guarantee acceptance: check whether its status is pending or confirmed. Availability can change before a booking is saved. Any event-specific fees, cancellation rules, eligibility or other conditions must be supplied by the operator; these standard terms do not create those conditions.' }] : []),
20
+ { title: 'Content and submissions', text: 'Respect the rights of content owners. Submit only information you are authorized to provide. Information submitted for a booking or support request is used to process that request as described in the privacy notice. The ACMS software license is separate from rights in the content published on this site.' },
21
+ { title: 'Availability and external services', text: 'The service may be interrupted for maintenance or technical reasons. Published information can change or contain errors; contact the operator to confirm information you rely on. External links and embedded services are operated by their respective providers and may have their own terms and privacy notices.' },
22
+ { title: 'Your rights and changes', text: 'Nothing in these terms excludes rights or liabilities that cannot be excluded under applicable law. The operator should explain material changes to these terms and provide any additional conditions needed for its services. No particular governing law, dispute forum, payment policy or contractual waiver is specified by this standard page.' },
23
+ ];
24
+ return { title: kind === 'privacy' ? 'Privacy notice' : 'Terms of service', sections, complete: Boolean(LEGAL.operatorName && LEGAL.contactEmail) };
25
+ }