@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,154 @@
1
+ /// <reference types="vite/client" />
2
+
3
+ export const MAX_FILE_BYTES = 10 * 1024 * 1024;
4
+ export const MAX_SOURCE_CHARS = 50_000;
5
+ const MAX_PDF_PAGES = 100;
6
+ export interface ExtractedDocument { source: string; title: string; filename: string; warnings: string[] }
7
+
8
+ // Extraction is browser-local. Neither file bytes nor embedded media are
9
+ // uploaded. Conversion libraries are loaded only for the selected format.
10
+ export async function extractDocument(file: File): Promise<ExtractedDocument> {
11
+ if (!file.size) throw new Error('This document is empty. Choose a file with readable text.');
12
+ if (file.size > MAX_FILE_BYTES) throw new Error('Choose a document smaller than 10 MB.');
13
+ const extension = file.name.split('.').pop()?.toLowerCase();
14
+ if (!['docx', 'pdf', 'txt', 'md'].includes(extension ?? '')) throw new Error('Use a DOCX, text-based PDF, TXT, or Markdown file.');
15
+ const bytes = await file.arrayBuffer();
16
+ let source: string;
17
+ const warnings: string[] = [];
18
+ if (extension === 'docx') {
19
+ validateDocxArchive(bytes);
20
+ const [{ default: mammoth }, { default: Turndown }] = await Promise.all([
21
+ import('mammoth/mammoth.browser.js'), import('turndown'),
22
+ ]);
23
+ let result;
24
+ try {
25
+ result = await mammoth.convertToHtml({ arrayBuffer: bytes }, {
26
+ externalFileAccess: false,
27
+ includeEmbeddedStyleMap: false,
28
+ convertImage: mammoth.images.imgElement(async () => {
29
+ warnings.push('An embedded image was omitted. Add it in the post or page editor after import.');
30
+ return { src: '' };
31
+ }),
32
+ });
33
+ } catch { throw new Error('This DOCX could not be read. Export it again as DOCX or UTF-8 text.'); }
34
+ if (result.messages.some((message) => message.type === 'error')) throw new Error('The DOCX contains content that could not be extracted. Export it as text and review it before importing.');
35
+ warnings.push(...result.messages.map((message) => message.message));
36
+ const converter = new Turndown({ headingStyle: 'atx', bulletListMarker: '-', codeBlockStyle: 'fenced' });
37
+ converter.remove(['img', 'script', 'style']);
38
+ converter.addRule('documentTables', {
39
+ filter: 'table',
40
+ replacement: (_content, node) => {
41
+ warnings.push('Tables were converted to Markdown. Review the column headings and cell layout.');
42
+ const rows = Array.from(node.querySelectorAll('tr')).map((row) => Array.from(row.querySelectorAll('th,td')).map((cell) =>
43
+ converter.turndown(cell.innerHTML).replace(/\|/g, '\\|').replace(/\n/g, ' ')));
44
+ if (!rows.length) return '';
45
+ const width = Math.max(...rows.map((row) => row.length));
46
+ const line = (row: string[]) => `| ${Array.from({ length: width }, (_, i) => row[i] ?? '').join(' | ')} |`;
47
+ return `\n\n${[line(rows[0]), line(Array(width).fill('---')), ...rows.slice(1).map(line)].join('\n')}\n\n`;
48
+ },
49
+ });
50
+ source = converter.turndown(result.value);
51
+ warnings.push('Text, headings, and lists were imported. Word page layout, headers, footers, and text boxes may differ or be omitted; check the original document.');
52
+ } else if (extension === 'pdf') {
53
+ if (!new TextDecoder().decode(bytes.slice(0, 1024)).includes('%PDF-')) throw new Error('This file does not appear to be a PDF.');
54
+ source = await extractPdf(bytes);
55
+ warnings.push('PDF import extracts text only. Images and page layout are not imported; check reading order, tables, and paragraph breaks against the original.');
56
+ } else {
57
+ source = decodeText(bytes);
58
+ }
59
+ source = source.replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n').trim();
60
+ if (!source) throw new Error('No readable text was found. Scanned documents need OCR before import.');
61
+ if (source.length > MAX_SOURCE_CHARS) throw new Error('This document exceeds 50,000 characters. Split it into smaller documents before importing. Nothing was truncated.');
62
+ return { source, title: documentTitle(source, file.name), filename: file.name, warnings: [...new Set(warnings)].slice(0, 30) };
63
+ }
64
+
65
+ export function documentTitle(source: string, filename: string): string {
66
+ const heading = source.match(/^#{1,6}\s+(.+)$/m)?.[1]?.replace(/\s+#+$/, '').trim();
67
+ return (heading || filename.replace(/\.[^.]+$/, '').replace(/[_-]+/g, ' ').trim()).slice(0, 300);
68
+ }
69
+ export function decodeText(bytes: ArrayBuffer): string {
70
+ const prefix = new Uint8Array(bytes, 0, Math.min(3, bytes.byteLength));
71
+ const encoding = prefix[0] === 0xff && prefix[1] === 0xfe ? 'utf-16le' : prefix[0] === 0xfe && prefix[1] === 0xff ? 'utf-16be' : 'utf-8';
72
+ let text: string;
73
+ try { text = new TextDecoder(encoding, { fatal: true }).decode(bytes); }
74
+ catch { throw new Error('The text encoding could not be read. Save the document as UTF-8 text and try again.'); }
75
+ if (/[\x00-\x08\x0B\x0C\x0E-\x1F]/.test(text)) throw new Error('This appears to be a binary file, not a text document.');
76
+ return text;
77
+ }
78
+
79
+ // Check the ZIP directory before asking Mammoth to inflate a DOCX. Reject
80
+ // oversized archives, encrypted entries, ZIP64, and malformed directories.
81
+ export function validateDocxArchive(buffer: ArrayBuffer): void {
82
+ const view = new DataView(buffer);
83
+ const invalid = () => new Error('This DOCX is invalid, encrypted, or too complex to import. Export a smaller DOCX or text file.');
84
+ if (view.byteLength < 22) throw invalid();
85
+ let end = -1;
86
+ for (let i = view.byteLength - 22; i >= Math.max(0, view.byteLength - 65557); i--) {
87
+ if (view.getUint32(i, true) === 0x06054b50 && i + 22 + view.getUint16(i + 20, true) === view.byteLength) { end = i; break; }
88
+ }
89
+ if (end < 0 || view.getUint16(end + 4, true) || view.getUint16(end + 6, true)) throw invalid();
90
+ const count = view.getUint16(end + 10, true);
91
+ const size = view.getUint32(end + 12, true);
92
+ let position = view.getUint32(end + 16, true);
93
+ const directoryEnd = position + size;
94
+ if (!count || count > 4096 || count !== view.getUint16(end + 8, true) || directoryEnd > end) throw invalid();
95
+ let unpacked = 0;
96
+ let hasDocument = false;
97
+ for (let entry = 0; entry < count; entry++) {
98
+ if (position + 46 > directoryEnd || view.getUint32(position, true) !== 0x02014b50 || (view.getUint16(position + 8, true) & 1)) throw invalid();
99
+ unpacked += view.getUint32(position + 24, true);
100
+ if (unpacked > 40 * 1024 * 1024) throw invalid();
101
+ const nameLength = view.getUint16(position + 28, true);
102
+ const next = position + 46 + nameLength + view.getUint16(position + 30, true) + view.getUint16(position + 32, true);
103
+ if (next > directoryEnd) throw invalid();
104
+ const name = new TextDecoder().decode(new Uint8Array(buffer, position + 46, nameLength));
105
+ if (name === 'word/document.xml') hasDocument = true;
106
+ position = next;
107
+ }
108
+ if (!hasDocument || position !== directoryEnd) throw invalid();
109
+ }
110
+
111
+ export interface PdfTextItem { str: string; hasEOL?: boolean; transform?: number[]; width?: number }
112
+ export function pdfItemsToText(items: PdfTextItem[]): string {
113
+ let text = '';
114
+ let previous: PdfTextItem | undefined;
115
+ for (const item of items) {
116
+ if (previous && item.str && !/\s$/.test(text)) {
117
+ const sameLine = item.transform && previous.transform && Math.abs(item.transform[5] - previous.transform[5]) < 2;
118
+ const gap = item.transform && previous.transform ? item.transform[4] - (previous.transform[4] + (previous.width ?? 0)) : 1;
119
+ text += sameLine === false ? '\n' : gap > 0.5 ? ' ' : '';
120
+ }
121
+ text += item.str;
122
+ if (item.hasEOL) text += '\n';
123
+ previous = item;
124
+ }
125
+ return text.trim();
126
+ }
127
+ async function extractPdf(buffer: ArrayBuffer): Promise<string> {
128
+ const pdfjs = await import('pdfjs-dist/legacy/build/pdf.mjs');
129
+ const { default: workerUrl } = await import('pdfjs-dist/legacy/build/pdf.worker.min.mjs?url');
130
+ pdfjs.GlobalWorkerOptions.workerSrc = workerUrl;
131
+ const task = pdfjs.getDocument({ data: new Uint8Array(buffer), useSystemFonts: true, disableFontFace: true, useWasm: false });
132
+ // With no password callback PDF.js rejects encrypted files immediately.
133
+ try {
134
+ const pdf = await task.promise;
135
+ if (pdf.numPages > MAX_PDF_PAGES) throw new Error('PDFs are limited to 100 pages. Split this document before importing.');
136
+ const pages: string[] = [];
137
+ let length = 0;
138
+ for (let i = 1; i <= pdf.numPages; i++) {
139
+ const page = await pdf.getPage(i);
140
+ const content = await page.getTextContent();
141
+ const text = pdfItemsToText(content.items.filter((item): item is import('pdfjs-dist/types/src/display/api').TextItem => 'str' in item));
142
+ page.cleanup();
143
+ if (!text) throw new Error(`Page ${i} has no readable text. Remove blank pages or use OCR for scanned pages before importing. No pages were silently skipped.`);
144
+ length += text.length + 2;
145
+ if (length > MAX_SOURCE_CHARS) throw new Error('This document exceeds 50,000 characters. Split it into smaller documents before importing. Nothing was truncated.');
146
+ pages.push(text);
147
+ }
148
+ return pages.join('\n\n');
149
+ } catch (error) {
150
+ if (error instanceof Error && error.name === 'PasswordException') throw new Error('Password-protected PDFs are not supported. Upload an unlocked copy.');
151
+ if (error instanceof Error && /^(Page \d|PDFs are limited|This document exceeds)/.test(error.message)) throw error;
152
+ throw new Error('This PDF could not be read. Try exporting it as DOCX or text.');
153
+ } finally { await task.destroy(); }
154
+ }
@@ -0,0 +1,4 @@
1
+ declare module 'mammoth/mammoth.browser.js' {
2
+ import mammoth = require('mammoth');
3
+ export default mammoth;
4
+ }
@@ -0,0 +1,131 @@
1
+ import type { CalEvent, NavItem, Page, Post, Settings } from './schema';
2
+
3
+ // Pending-edit ("draft") snapshots for live documents. A published or
4
+ // scheduled post/page never receives content edits directly: they accumulate
5
+ // in the row's `draft` JSON column and only reach the live columns when the
6
+ // user hits Update (repo.applyPostDraft / applyPageDraft).
7
+ //
8
+ // The snapshot stores the same camelCase field names the save actions accept,
9
+ // plus `savedAt`. Status transitions, schedule times and the publish sweep
10
+ // never live in a snapshot — those stay deliberate, immediate actions.
11
+
12
+ export const POST_DRAFT_KEYS = [
13
+ 'title', 'slug', 'collection', 'body', 'bodyJson', 'bodyHtml', 'excerpt',
14
+ 'tags', 'cover', 'coverAlt', 'seoTitle', 'seoDesc', 'featured',
15
+ 'authorIds', 'template', 'publishedAt',
16
+ ] as const;
17
+
18
+ export const PAGE_DRAFT_KEYS = [
19
+ 'title', 'slug', 'locale', 'isHome', 'seoTitle', 'seoDesc', 'template',
20
+ 'bodyJson', 'bodyHtml', 'blocks',
21
+ ] as const;
22
+
23
+ // events stage everything the editor can change, times and recurrence
24
+ // included — a published event's date only moves when the user hits Update
25
+ export const EVENT_DRAFT_KEYS = [
26
+ 'title', 'slug', 'bodyJson', 'bodyHtml', 'excerpt', 'tags', 'cover', 'coverAlt',
27
+ 'seoTitle', 'seoDesc', 'featured', 'template', 'start', 'end', 'allDay',
28
+ 'timezone', 'rrule', 'exdates', 'cancelled', 'location', 'cta', 'calendarIds', 'fields', 'rsvp',
29
+ ] as const;
30
+
31
+ type PostDraftKey = (typeof POST_DRAFT_KEYS)[number];
32
+ type EventDraftKey = (typeof EVENT_DRAFT_KEYS)[number];
33
+ type PageDraftKey = (typeof PAGE_DRAFT_KEYS)[number];
34
+
35
+ function pick(draft: Record<string, unknown>, keys: readonly string[]) {
36
+ const out: Record<string, unknown> = {};
37
+ for (const k of keys) if (draft[k] !== undefined) out[k] = draft[k];
38
+ return out;
39
+ }
40
+
41
+ // Only known editable fields survive the pick — a hand-edited or legacy
42
+ // snapshot can never smuggle status/id/timestamps into an UPDATE.
43
+ export function pickPostDraft(draft: Record<string, unknown>): Partial<Pick<Post, PostDraftKey>> {
44
+ return pick(draft, POST_DRAFT_KEYS) as Partial<Pick<Post, PostDraftKey>>;
45
+ }
46
+ export function pickEventDraft(draft: Record<string, unknown>): Partial<Pick<CalEvent, EventDraftKey>> {
47
+ return pick(draft, EVENT_DRAFT_KEYS) as Partial<Pick<CalEvent, EventDraftKey>>;
48
+ }
49
+ export function pickPageDraft(draft: Record<string, unknown>): Partial<Pick<Page, PageDraftKey>> {
50
+ return pick(draft, PAGE_DRAFT_KEYS) as Partial<Pick<Page, PageDraftKey>>;
51
+ }
52
+
53
+ // The document as it will look after Update — what the editor hydrates from
54
+ // and what /preview renders. Live rows pass through untouched.
55
+ export function mergePostDraft(post: Post): Post {
56
+ if (!post.draft) return post;
57
+ const staged = pickPostDraft(post.draft);
58
+ const merged = { ...post, ...staged };
59
+ if (staged.authorIds) merged.authorId = staged.authorIds[0] ?? null;
60
+ return merged;
61
+ }
62
+ export function mergePageDraft(page: Page): Page {
63
+ if (!page.draft) return page;
64
+ return { ...page, ...pickPageDraft(page.draft) };
65
+ }
66
+
67
+ export function mergeEventDraft(event: CalEvent): CalEvent {
68
+ if (!event.draft) return event;
69
+ return { ...event, ...pickEventDraft(event.draft) };
70
+ }
71
+
72
+ export function draftSavedAt(doc: Pick<Post | Page | CalEvent, 'draft'>): number | null {
73
+ const ts = doc.draft?.savedAt;
74
+ return typeof ts === 'number' ? ts : null;
75
+ }
76
+
77
+ // Design-screen staging: the same shape, stored in settings.draftDesign and
78
+ // applied by the "Publish design" button. The draft may also carry the full
79
+ // ordered navigation list (navItems) — menus stage with the design so one
80
+ // Publish changes the whole look at once — and the share-link epoch.
81
+ export const DESIGN_DRAFT_KEYS = [
82
+ 'activeTheme', 'designOverrides', 'siteLogo', 'siteFavicon', 'siteCover', 'sectionPages',
83
+ ] as const;
84
+
85
+ type DesignDraftKey = (typeof DESIGN_DRAFT_KEYS)[number];
86
+
87
+ export function pickDesignDraft(draft: Record<string, unknown>): Partial<Pick<Settings, DesignDraftKey>> {
88
+ return pick(draft, DESIGN_DRAFT_KEYS) as Partial<Pick<Settings, DesignDraftKey>>;
89
+ }
90
+
91
+ // Settings as they will look after Publish design — what preview mode
92
+ // renders for admins (and share-link reviewers) while visitors keep the live one.
93
+ export function mergeDesignDraft(settings: Settings): Settings {
94
+ if (!settings.draftDesign) return settings;
95
+ return { ...settings, ...pickDesignDraft(settings.draftDesign) };
96
+ }
97
+
98
+ export interface StagedNavItem { id: string; label: string; url: string; area: NavItem['area'] }
99
+
100
+ // the staged menu list, or null when navigation isn't part of the draft
101
+ export function draftNavItems(draft: Record<string, unknown> | null | undefined): StagedNavItem[] | null {
102
+ const list = draft?.navItems;
103
+ if (!Array.isArray(list)) return null;
104
+ return list
105
+ .filter((n): n is StagedNavItem =>
106
+ Boolean(n) && typeof n.id === 'string' && typeof n.label === 'string' && typeof n.url === 'string' &&
107
+ (n.area === 'header' || n.area === 'footer' || n.area === 'social'))
108
+ .map(({ id, label, url, area }) => ({ id, label, url, area }));
109
+ }
110
+
111
+ // staged list → NavItem[] in display order (what Site.astro renders)
112
+ export function toNavItems(list: StagedNavItem[]): NavItem[] {
113
+ return list.map((n, i) => ({ ...n, order: i }));
114
+ }
115
+
116
+ export const shareEpoch = (draft: Record<string, unknown> | null | undefined): string =>
117
+ typeof draft?.shareEpoch === 'string' ? draft.shareEpoch : 'legacy-disabled';
118
+
119
+ // A published-design snapshot (design_versions rows) — the draft shape
120
+ // minus bookkeeping, so restoring one is just "load it as the draft".
121
+ export function designSnapshot(settings: Settings, navItems: NavItem[]): Record<string, unknown> {
122
+ return {
123
+ activeTheme: settings.activeTheme,
124
+ designOverrides: settings.designOverrides,
125
+ sectionPages: settings.sectionPages,
126
+ siteLogo: settings.siteLogo,
127
+ siteFavicon: settings.siteFavicon,
128
+ siteCover: settings.siteCover,
129
+ navItems: navItems.map(({ id, label, url, area }) => ({ id, label, url, area })),
130
+ };
131
+ }
@@ -3,7 +3,8 @@ import type { Post, Settings } from './schema';
3
3
  import { slugify } from './schema';
4
4
  import { formatDate } from './dates';
5
5
  import { CONTENT, collectionUrl } from './content';
6
- import { renderCalendarBlock } from './calendar/render';
6
+ import { renderCalendarBlock, isNativeSource } from './calendar/render';
7
+ import { renderEventCards, type EventCardAttrs } from './calendar/cards';
7
8
 
8
9
  // Dynamic blocks: bodyHtml is a save-time cache, so cards whose content must
9
10
  // stay fresh (post lists, calendars) serialize as placeholder divs and get
@@ -14,8 +15,11 @@ import { renderCalendarBlock } from './calendar/render';
14
15
  // data-layout="grid" data-limit="3" data-show-image="1" data-show-excerpt="1"></div>
15
16
  // <div data-dyn="calendar" data-sources="[…ics urls]" data-view="month"
16
17
  // data-title="…" data-subtitle="…" data-limit="6" data-open-link="1"></div>
18
+ // <div data-dyn="toc" data-title="In this article"></div>
19
+ // <div data-dyn="events" data-mode="upcoming" data-calendar="youth"
20
+ // data-event="" data-limit="3" data-layout="grid" data-show-image="1"></div>
17
21
 
18
- const PLACEHOLDER = /<div[^>]*data-dyn="(?:post-list|calendar)"[^>]*>\s*<\/div>/g;
22
+ const PLACEHOLDER = /<div[^>]*data-dyn="(?:post-list|calendar|toc|events)"[^>]*>\s*<\/div>/g;
19
23
 
20
24
  const esc = (s: string) =>
21
25
  s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
@@ -51,11 +55,57 @@ function renderList(posts: Post[], layout: string, showImage: boolean, showExcer
51
55
  return `<section class="dyn-postlist" data-layout="${esc(layout)}">${items}</section>`;
52
56
  }
53
57
 
58
+ // Heading anchors: body h1–h3 get stable slugified ids so sections are
59
+ // deep-linkable and the TOC block has targets. Headings that already carry an
60
+ // id (e.g. hand-written in an HTML card) are left alone.
61
+ export function stampHeadingAnchors(html: string): string {
62
+ const seen = new Map<string, number>();
63
+ return html.replace(/<h([1-3])(\s[^>]*)?>([\s\S]*?)<\/h\1>/g, (m, lvl: string, attrs: string | undefined, inner: string) => {
64
+ if (attrs && /\bid=/.test(attrs)) return m;
65
+ const text = inner.replace(/<[^>]+>/g, '').replace(/&amp;/g, '&').replace(/&#0?39;|&apos;/g, "'");
66
+ const base = slugify(text);
67
+ const n = (seen.get(base) ?? 0) + 1;
68
+ seen.set(base, n);
69
+ const id = n > 1 ? `${base}-${n}` : base;
70
+ return `<h${lvl}${attrs ?? ''} id="${id}">${inner}</h${lvl}>`;
71
+ });
72
+ }
73
+
74
+ // Table of contents: rebuilt from the body's (already anchored) headings on
75
+ // every request, so it can never go stale. Depth is relative to the shallowest
76
+ // heading present.
77
+ export interface TocItem { level: number; id: string; text: string }
78
+
79
+ // the body's anchored h1–h3 headings, in order — feeds the TOC card and the
80
+ // post page's "On this page" rail
81
+ export function extractToc(html: string): TocItem[] {
82
+ const items: TocItem[] = [];
83
+ const re = /<h([1-3])[^>]*\bid="([^"]+)"[^>]*>([\s\S]*?)<\/h\1>/g;
84
+ let m: RegExpExecArray | null;
85
+ while ((m = re.exec(html))) {
86
+ const text = m[3].replace(/<[^>]+>/g, '').trim();
87
+ if (text) items.push({ level: Number(m[1]), id: m[2], text });
88
+ }
89
+ return items;
90
+ }
91
+
92
+ function renderToc(html: string, title: string): string {
93
+ const items = extractToc(html);
94
+ if (!items.length) return '';
95
+ const min = Math.min(...items.map((i) => i.level));
96
+ const links = items
97
+ .map((i) => `<a class="pc-toc-link" data-depth="${i.level - min}" href="#${esc(i.id)}">${i.text}</a>`)
98
+ .join('');
99
+ return `<nav class="pc-toc">${title ? `<span class="pc-toc-title">${esc(title)}</span>` : ''}${links}</nav>`;
100
+ }
101
+
54
102
  function calendarAttrs(tag: string) {
55
103
  let sources: string[] = [];
56
104
  try {
57
105
  const parsed = JSON.parse(attr(tag, 'sources') || '[]');
58
- if (Array.isArray(parsed)) sources = parsed.filter((s): s is string => typeof s === 'string' && s.startsWith('http'));
106
+ if (Array.isArray(parsed)) {
107
+ sources = parsed.filter((s): s is string => typeof s === 'string' && (s.startsWith('http') || isNativeSource(s)));
108
+ }
59
109
  } catch {
60
110
  /* malformed attr: render the empty state rather than break the page */
61
111
  }
@@ -70,10 +120,24 @@ function calendarAttrs(tag: string) {
70
120
  };
71
121
  }
72
122
 
123
+ function eventCardAttrs(tag: string): EventCardAttrs {
124
+ return {
125
+ mode: attr(tag, 'mode') === 'single' ? 'single' : 'upcoming',
126
+ eventId: attr(tag, 'event'),
127
+ calendar: attr(tag, 'calendar'),
128
+ limit: Math.min(12, Math.max(1, Number(attr(tag, 'limit')) || 3)),
129
+ layout: attr(tag, 'layout') === 'list' ? 'list' : 'grid',
130
+ showImage: attr(tag, 'show-image') !== '0',
131
+ };
132
+ }
133
+
73
134
  export async function expandDynamicBlocks(html: string, repo: ContentRepository): Promise<string> {
135
+ html = stampHeadingAnchors(html);
74
136
  const hasPostList = html.includes('data-dyn="post-list"');
75
137
  const hasCalendar = html.includes('data-dyn="calendar"');
76
- if (!hasPostList && !hasCalendar) return html;
138
+ const hasToc = html.includes('data-dyn="toc"');
139
+ const hasEvents = html.includes('data-dyn="events"');
140
+ if (!hasPostList && !hasCalendar && !hasToc && !hasEvents) return html;
77
141
  const [posts, settings] = await Promise.all([
78
142
  hasPostList ? repo.listPublishedPosts() : Promise.resolve([] as Post[]), // newest first; shared by all blocks on the page
79
143
  repo.getSettings(),
@@ -83,15 +147,23 @@ export async function expandDynamicBlocks(html: string, repo: ContentRepository)
83
147
  // rendered up front, then spliced in during the synchronous pass.
84
148
  const calendarHtml = new Map<string, string>();
85
149
  if (hasCalendar) {
86
- const tags = [...new Set((html.match(PLACEHOLDER) ?? []).filter((t) => t.includes('data-dyn="calendar"')))];
150
+ const tags = [...new Set((html.match(PLACEHOLDER) ?? ([] as string[])).filter((t) => t.includes('data-dyn="calendar"')))];
87
151
  const now = Date.now();
88
152
  await Promise.all(
89
- tags.map(async (tag) => calendarHtml.set(tag, await renderCalendarBlock(calendarAttrs(tag), settings, now)))
153
+ tags.map(async (tag) => calendarHtml.set(tag, await renderCalendarBlock(calendarAttrs(tag), settings, now, repo)))
90
154
  );
91
155
  }
156
+ // event cards read the DB, so they're rendered up front the same way
157
+ if (hasEvents) {
158
+ const tags = [...new Set((html.match(PLACEHOLDER) ?? ([] as string[])).filter((t) => t.includes('data-dyn="events"')))];
159
+ const now = Date.now();
160
+ await Promise.all(tags.map(async (tag) => calendarHtml.set(tag, await renderEventCards(eventCardAttrs(tag), repo, settings, now))));
161
+ }
92
162
 
163
+ const anchored = html;
93
164
  return html.replace(PLACEHOLDER, (tag) => {
94
- if (tag.includes('data-dyn="calendar"')) return calendarHtml.get(tag) ?? '';
165
+ if (tag.includes('data-dyn="calendar"') || tag.includes('data-dyn="events"')) return calendarHtml.get(tag) ?? '';
166
+ if (tag.includes('data-dyn="toc"')) return renderToc(anchored, attr(tag, 'title'));
95
167
  const source = attr(tag, 'source') || 'latest';
96
168
  const tagName = attr(tag, 'tag');
97
169
  // which collection the block draws from; 'posts' unless the card says otherwise
@@ -0,0 +1,55 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import type { ContentRepository } from '../repo/types';
3
+ import { hashPassword, verifyPassword, checkPassword, OWNER_ENV } from '../auth';
4
+ import { encrypt, encryptionAvailable, sha256, resolveConfig, validAddress } from './config';
5
+ import { canonicalOrigin } from './templates';
6
+ import type { NewEmailJob } from './types';
7
+
8
+ export async function accountForm(request: Request): Promise<URLSearchParams> {
9
+ if (!request.headers.get('content-type')?.startsWith('application/x-www-form-urlencoded')) throw new Error('Unsupported form format.');
10
+ const reader = request.body?.getReader();
11
+ if (!reader) throw new Error('Form is required.');
12
+ const chunks: Uint8Array[] = []; let size = 0;
13
+ try {
14
+ while (true) {
15
+ const {done,value} = await reader.read(); if (done) break;
16
+ size += value.byteLength;
17
+ if (size > 10000) { await reader.cancel(); throw new Error('Form too large.'); }
18
+ chunks.push(value);
19
+ }
20
+ } finally { reader.releaseLock(); }
21
+ return new URLSearchParams(Buffer.concat(chunks).toString('utf8'));
22
+ }
23
+
24
+ export async function reauthenticate(repo:ContentRepository, userId:string, password:string) {
25
+ if (!password || password.length>1024) return false;
26
+ if (userId===OWNER_ENV) return checkPassword(password);
27
+ const user=await repo.getUser(userId); if(!user)return false;
28
+ const auth=await repo.getUserAuth(user.email);return !!auth && verifyPassword(password,auth.passHash);
29
+ }
30
+ export async function issueAccountToken(repo:ContentRepository, data:{purpose:'invite'|'reset';email:string;name?:string;role?:'owner'|'editor';expiresDays?:number}, locals?:unknown) {
31
+ const email=data.email.trim().toLowerCase();
32
+ if(!validAddress(email))throw new Error('Enter a valid email address.');
33
+ const auth=await repo.getUserAuth(email);
34
+ if(data.purpose==='reset'&&!auth)return;
35
+ if(data.purpose==='invite'&&auth)throw new Error('This address already has an account. Manage its role in Users.');
36
+ const c=resolveConfig(await repo.email.config(),locals);
37
+ if(c.error||c.provider==='none'||!c.config.enabled)throw new Error(c.error||'Configure email before sending account links.');
38
+ if(!encryptionAvailable(locals))throw new Error('Configure EMAIL_ENCRYPTION_KEY before sending account links.');
39
+ canonicalOrigin((await repo.getSettings()).siteUrl);
40
+ const id=crypto.randomUUID(),raw=randomBytes(32).toString('base64url');
41
+ const expiresAt=Date.now()+(data.purpose==='reset'?30*60000:(data.expiresDays||7)*86400000);
42
+ const token={id,hash:sha256(raw),encrypted:encrypt(raw,`token:${id}`,locals),purpose:data.purpose,email,name:data.name?.trim().slice(0,120)||email.split('@')[0],role:data.role||'editor',userId:auth?.user.id||'',expiresAt};
43
+ const job:NewEmailJob={dedupe:`${data.purpose}:${id}`,kind:'auth',recipient:email,payload:JSON.stringify({tokenId:id,purpose:data.purpose}),configVersion:c.version,expiresAt};
44
+ await repo.email.saveToken(token,job);
45
+ }
46
+ export async function redeemAccountToken(repo:ContentRepository,raw:string,password:string,purpose:'invite'|'reset'):Promise<boolean> {
47
+ if(!/^[a-zA-Z0-9_-]{43}$/.test(raw))return false;
48
+ if(password.length<12||password.length>128)throw new Error('Use a password between 12 and 128 characters.');
49
+ const token=await repo.email.tokenByHash(sha256(raw));
50
+ if(!token||token.purpose!==purpose)return false;
51
+ const config=await repo.email.config();
52
+ return repo.email.consume(sha256(raw),await hashPassword(password),crypto.randomUUID(),{
53
+ dedupe:`notice:${token.id}`,kind:purpose==='invite'?'welcome':'password-changed',recipient:String(token.email),payload:'{}',configVersion:config?.version||0,
54
+ });
55
+ }
@@ -0,0 +1,86 @@
1
+ import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
2
+ import { env } from '../env';
3
+ import type { SendEmail } from '@cloudflare/workers-types';
4
+ import { DEFAULT_EMAIL_CONFIG, type ConfigRecord, type Credentials, type EmailConfig, type Provider, type ResolvedConfig } from './types';
5
+
6
+ export function runtimeValue(key: string, locals?: unknown): string {
7
+ const value = (locals as { runtime?: { env?: Record<string, unknown> } } | undefined)?.runtime?.env?.[key];
8
+ return typeof value === 'string' ? value : env(key);
9
+ }
10
+ export function emailBinding(locals?: unknown): SendEmail | null {
11
+ const value = (locals as { runtime?: { env?: { EMAIL?: SendEmail } } } | undefined)?.runtime?.env?.EMAIL;
12
+ return value && typeof value.send === 'function' ? value : null;
13
+ }
14
+ export const validAddress = (s: string) => s.length <= 254 && /^[^\s<>@,;\r\n]+@[^\s<>@,;\r\n]+\.[^\s<>@,;\r\n]+$/.test(s);
15
+ export function parseFrom(raw: string): { email: string; name?: string } | null {
16
+ if (/[\r\n]/.test(raw)) return null;
17
+ const match = /^\s*(?:"?([^"<>]*?)"?\s*)?<([^<>]+)>\s*$/.exec(raw);
18
+ const email = (match ? match[2] : raw).trim();
19
+ return validAddress(email) ? { email, ...(match?.[1]?.trim() ? { name: match[1].trim() } : {}) } : null;
20
+ }
21
+ export const sha256 = (s: string) => createHash('sha256').update(s).digest('hex');
22
+ function keys(locals?: unknown): Record<string, string> {
23
+ const raw = runtimeValue('EMAIL_ENCRYPTION_KEYS', locals);
24
+ if (raw) { try { return JSON.parse(raw); } catch { throw new Error('EMAIL_ENCRYPTION_KEYS must be a JSON key map.'); } }
25
+ const key = runtimeValue('EMAIL_ENCRYPTION_KEY', locals);
26
+ return key ? { v1: key } : {};
27
+ }
28
+ function encryptionKey(id: string, locals?: unknown): Buffer {
29
+ const key = Buffer.from(keys(locals)[id] || '', 'base64');
30
+ if (key.length !== 32) throw new Error('Configure a dedicated 32-byte base64 EMAIL_ENCRYPTION_KEY before storing credentials or sending account links.');
31
+ return key;
32
+ }
33
+ export function encryptionAvailable(locals?: unknown): boolean {
34
+ try { encryptionKey(runtimeValue('EMAIL_ENCRYPTION_KEY_ID', locals) || 'v1', locals); return true; } catch { return false; }
35
+ }
36
+ export function encrypt(value: unknown, purpose: string, locals?: unknown): string {
37
+ const id = runtimeValue('EMAIL_ENCRYPTION_KEY_ID', locals) || 'v1';
38
+ if (!/^[a-zA-Z0-9_-]{1,40}$/.test(id)) throw new Error('Invalid email encryption key ID.');
39
+ const iv = randomBytes(12), cipher = createCipheriv('aes-256-gcm', encryptionKey(id, locals), iv);
40
+ cipher.setAAD(Buffer.from(`acms-email:${purpose}:${id}`));
41
+ const data = Buffer.concat([cipher.update(JSON.stringify(value), 'utf8'), cipher.final()]);
42
+ return [id, iv.toString('base64url'), cipher.getAuthTag().toString('base64url'), data.toString('base64url')].join('.');
43
+ }
44
+ export function decrypt<T>(raw: string, purpose: string, locals?: unknown): T {
45
+ try {
46
+ const parts = raw.split('.');
47
+ if (parts.length !== 4) throw new Error();
48
+ const [id, iv, tag, data] = parts;
49
+ const decipher = createDecipheriv('aes-256-gcm', encryptionKey(id, locals), Buffer.from(iv, 'base64url'));
50
+ decipher.setAAD(Buffer.from(`acms-email:${purpose}:${id}`)); decipher.setAuthTag(Buffer.from(tag, 'base64url'));
51
+ return JSON.parse(Buffer.concat([decipher.update(Buffer.from(data, 'base64url')), decipher.final()]).toString('utf8')) as T;
52
+ } catch { throw new Error('Saved email data cannot be decrypted. Restore its encryption key or reconnect the provider.'); }
53
+ }
54
+ export function resolveConfig(record: ConfigRecord | null, locals?: unknown): ResolvedConfig {
55
+ const config = { ...DEFAULT_EMAIL_CONFIG, ...record?.config };
56
+ const read = (key: string) => runtimeValue(key, locals).trim();
57
+ const override = read('EMAIL_PROVIDER');
58
+ if (override) { config.provider = override as EmailConfig['provider']; config.source = 'env'; }
59
+ let credentials: Credentials = {};
60
+ if (config.source === 'env') {
61
+ config.from = read('EMAIL_FROM') || config.from; config.replyTo = read('EMAIL_REPLY_TO') || config.replyTo;
62
+ config.domain = read('MAILGUN_DOMAIN') || config.domain;
63
+ config.configurationSet = read('SES_CONFIGURATION_SET') || config.configurationSet;
64
+ config.snsTopicArn = read('SES_SNS_TOPIC_ARN') || config.snsTopicArn;
65
+ }
66
+ let provider: Provider | 'none' = 'none', error = '';
67
+ if (!['none', 'auto', 'resend', 'mailgun', 'ses', 'cloudflare'].includes(config.provider)) error = 'Invalid EMAIL_PROVIDER.';
68
+ else if (config.enabled && config.provider !== 'none') {
69
+ provider = config.provider === 'auto' ? emailBinding(locals) ? 'cloudflare' : read('RESEND_API_KEY') ? 'resend' : 'none' : config.provider;
70
+ if (config.source === 'encrypted' && record?.encrypted) {
71
+ try { credentials = decrypt<Credentials>(record.encrypted, 'credentials', locals); } catch (e) { error = (e as Error).message; }
72
+ } else if (config.source === 'env') {
73
+ credentials = { apiKey: read(provider === 'mailgun' ? 'MAILGUN_API_KEY' : 'RESEND_API_KEY'), webhookSecret: read(provider === 'mailgun' ? 'MAILGUN_WEBHOOK_SIGNING_KEY' : 'RESEND_WEBHOOK_SECRET'), accessKeyId: read('AWS_ACCESS_KEY_ID'), secretAccessKey: read('AWS_SECRET_ACCESS_KEY'), sessionToken: read('AWS_SESSION_TOKEN') };
74
+ config.region = read(provider === 'mailgun' ? 'MAILGUN_REGION' : 'AWS_REGION') || config.region;
75
+ }
76
+ if (!parseFrom(config.from)) error ||= 'Set a valid sending address (EMAIL_FROM).';
77
+ if (config.replyTo && !parseFrom(config.replyTo)) error ||= 'Set a valid reply-to address.';
78
+ if ((provider === 'resend' || provider === 'mailgun') && !credentials.apiKey) error ||= 'The selected provider needs an API key.';
79
+ if (provider === 'mailgun' && (!/^[a-z0-9.-]+$/i.test(config.domain) || !['us', 'eu'].includes(config.region))) error ||= 'Mailgun needs a sending domain and region (us or eu).';
80
+ if (provider === 'ses' && (!credentials.accessKeyId || !credentials.secretAccessKey || !/^[a-z]{2}(?:-gov)?-[a-z]+-\d$/.test(config.region))) error ||= 'SES needs AWS credentials and a valid AWS region.';
81
+ if (provider === 'cloudflare' && !emailBinding(locals)) error ||= 'The Cloudflare EMAIL binding is not available in this runtime.';
82
+ if (provider === 'none') error ||= 'No email provider is configured.';
83
+ }
84
+ const fingerprint = sha256(JSON.stringify({ config, credentials }));
85
+ return { version: record?.version || 0, config, provider, credentials, error: error || undefined, fingerprint };
86
+ }
@@ -0,0 +1,24 @@
1
+ // No secrets or outbox payloads are part of the content backup format.
2
+ export const EMAIL_MIGRATION = {
3
+ version: 29,
4
+ statements: [
5
+ `ALTER TABLE users ADD COLUMN session_version INTEGER NOT NULL DEFAULT 0`,
6
+ `ALTER TABLE bookings ADD COLUMN notification_version INTEGER NOT NULL DEFAULT 0`,
7
+ `ALTER TABLE bookings ADD COLUMN email_mutation TEXT NOT NULL DEFAULT ''`,
8
+ `CREATE TABLE email_configs (version INTEGER PRIMARY KEY AUTOINCREMENT,config TEXT NOT NULL,encrypted TEXT NOT NULL DEFAULT '',created_at INTEGER NOT NULL)`,
9
+ `CREATE TABLE email_jobs (id TEXT PRIMARY KEY,dedupe TEXT NOT NULL UNIQUE,kind TEXT NOT NULL,recipient TEXT NOT NULL,payload TEXT NOT NULL,
10
+ config_version INTEGER NOT NULL DEFAULT 0,state TEXT NOT NULL DEFAULT 'queued',attempts INTEGER NOT NULL DEFAULT 0,next_at INTEGER NOT NULL,
11
+ expires_at INTEGER,created_at INTEGER NOT NULL,updated_at INTEGER NOT NULL,lease TEXT NOT NULL DEFAULT '',lease_until INTEGER,
12
+ provider TEXT NOT NULL DEFAULT '',provider_id TEXT NOT NULL DEFAULT '',error TEXT NOT NULL DEFAULT '',accepted_at INTEGER,fingerprint TEXT NOT NULL DEFAULT '',rendered TEXT NOT NULL DEFAULT '')`,
13
+ `CREATE INDEX email_jobs_due ON email_jobs(state,next_at)`,
14
+ `CREATE INDEX email_jobs_provider ON email_jobs(provider,provider_id)`,
15
+ `CREATE TABLE email_attempts (id INTEGER PRIMARY KEY AUTOINCREMENT,job_id TEXT NOT NULL,attempt INTEGER NOT NULL,state TEXT NOT NULL,error TEXT NOT NULL,created_at INTEGER NOT NULL)`,
16
+ `CREATE TABLE email_delivery_events (id TEXT PRIMARY KEY,provider TEXT NOT NULL,provider_id TEXT NOT NULL,job_id TEXT NOT NULL DEFAULT '',recipient TEXT NOT NULL,state TEXT NOT NULL,created_at INTEGER NOT NULL,config_version INTEGER NOT NULL)`,
17
+ `CREATE INDEX email_events_message ON email_delivery_events(provider,provider_id)`,
18
+ `CREATE TABLE email_suppressions (address TEXT PRIMARY KEY,reason TEXT NOT NULL,created_at INTEGER NOT NULL)`,
19
+ `CREATE TABLE email_tokens (id TEXT PRIMARY KEY,token_hash TEXT NOT NULL UNIQUE,encrypted TEXT NOT NULL,purpose TEXT NOT NULL,email TEXT NOT NULL,name TEXT NOT NULL,role TEXT NOT NULL,user_id TEXT NOT NULL,expires_at INTEGER NOT NULL,created_at INTEGER NOT NULL,used_by TEXT NOT NULL DEFAULT '',revoked INTEGER NOT NULL DEFAULT 0)`,
20
+ `CREATE INDEX email_tokens_email ON email_tokens(email,purpose)`,
21
+ `CREATE TABLE email_rate_limits (key TEXT PRIMARY KEY,count INTEGER NOT NULL,expires_at INTEGER NOT NULL)`,
22
+ `CREATE TABLE email_meta (key TEXT PRIMARY KEY,value TEXT NOT NULL)`,
23
+ ],
24
+ };