caracms 0.0.0-stage → 0.2.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.
- package/LICENSE +21 -0
- package/README.md +57 -2
- package/bin/caracms.mjs +174 -0
- package/bin/gate.mjs +275 -0
- package/bin/init.mjs +712 -0
- package/bin/media.mjs +662 -0
- package/bin/site-runner.mjs +61 -0
- package/bin/templates/Page.astro.txt +31 -0
- package/bin/templates/TextBlock.astro.txt +13 -0
- package/bin/templates/caracms.config.ts.txt +22 -0
- package/bin/templates/catch-all.astro.txt +25 -0
- package/bin/templates/health.ts.txt +6 -0
- package/bin/templates/railway.ts.txt +147 -0
- package/package.json +66 -4
- package/skills/caracms-adopt/SKILL.md +508 -0
- package/skills/caracms-adopt/look-same.mjs +957 -0
- package/skills/caracms-blocks/SKILL.md +376 -0
- package/skills/caracms-content/SKILL.md +201 -0
- package/skills/caracms-tina/SKILL.md +427 -0
- package/skills/caracms-tina/field-kinds.md +88 -0
- package/skills/caracms-tina/tina-to-caracms.mjs +1011 -0
- package/src/block-runs.ts +84 -0
- package/src/cards.ts +158 -0
- package/src/cli/schemas.ts +122 -0
- package/src/cli/site.ts +66 -0
- package/src/cli/validate.ts +471 -0
- package/src/code-routes-disk.ts +67 -0
- package/src/code-routes.ts +134 -0
- package/src/columns.ts +39 -0
- package/src/components/Block.astro +46 -0
- package/src/components/BlockRunTree.astro +45 -0
- package/src/components/Blocks.astro +139 -0
- package/src/components/Buttons.astro +116 -0
- package/src/components/Fonts.astro +14 -0
- package/src/components/Form.astro +221 -0
- package/src/components/Head.astro +62 -0
- package/src/components/Link.astro +68 -0
- package/src/components/Lockup.astro +116 -0
- package/src/components/Media.astro +124 -0
- package/src/components/MediaPlayer.astro +102 -0
- package/src/components/RenderDocument.astro +64 -0
- package/src/components/RichText.astro +59 -0
- package/src/components/Text.astro +81 -0
- package/src/components/attributes.ts +33 -0
- package/src/components/descriptor.ts +45 -0
- package/src/components/form-client.ts +182 -0
- package/src/components/media-player.ts +46 -0
- package/src/components/media-props.ts +35 -0
- package/src/components/sitemap.xml.ts +36 -0
- package/src/config.ts +825 -0
- package/src/content-schema.ts +166 -0
- package/src/content.ts +422 -0
- package/src/document.ts +579 -0
- package/src/editor/README.md +405 -0
- package/src/editor/addresses.ts +61 -0
- package/src/editor/brand.ts +118 -0
- package/src/editor/client/block-list/BlockList.tsx +342 -0
- package/src/editor/client/block-list/Picker.tsx +105 -0
- package/src/editor/client/block-list/UnknownBlock.tsx +119 -0
- package/src/editor/client/block-list/insert-points.ts +56 -0
- package/src/editor/client/block-list/link.ts +71 -0
- package/src/editor/client/block-list/reorder.ts +105 -0
- package/src/editor/client/block-list/summary.ts +118 -0
- package/src/editor/client/collections/CollectionGrid.tsx +143 -0
- package/src/editor/client/collections/CollectionTable.tsx +406 -0
- package/src/editor/client/collections/table.ts +270 -0
- package/src/editor/client/context.tsx +27 -0
- package/src/editor/client/documents/DocMenu.tsx +154 -0
- package/src/editor/client/documents/actions.ts +192 -0
- package/src/editor/client/documents/dialogs.tsx +687 -0
- package/src/editor/client/editor.ts +995 -0
- package/src/editor/client/fields/ButtonsField.tsx +87 -0
- package/src/editor/client/fields/DateField.tsx +200 -0
- package/src/editor/client/fields/FieldControl.tsx +61 -0
- package/src/editor/client/fields/FieldFrame.tsx +109 -0
- package/src/editor/client/fields/GroupField.tsx +113 -0
- package/src/editor/client/fields/HiddenToggle.tsx +51 -0
- package/src/editor/client/fields/LinkField.tsx +307 -0
- package/src/editor/client/fields/ListField.tsx +642 -0
- package/src/editor/client/fields/LockupField.tsx +365 -0
- package/src/editor/client/fields/MediaField.tsx +823 -0
- package/src/editor/client/fields/PageFields.tsx +82 -0
- package/src/editor/client/fields/ReferenceField.tsx +209 -0
- package/src/editor/client/fields/RichTextField.tsx +306 -0
- package/src/editor/client/fields/SelectField.tsx +63 -0
- package/src/editor/client/fields/SeoGroup.tsx +282 -0
- package/src/editor/client/fields/SimpleFields.tsx +89 -0
- package/src/editor/client/fields/TextField.tsx +51 -0
- package/src/editor/client/fields/date.ts +73 -0
- package/src/editor/client/fields/host.tsx +172 -0
- package/src/editor/client/fields/list-summary.ts +85 -0
- package/src/editor/client/fields/lockup-rules.ts +181 -0
- package/src/editor/client/fields/parts.tsx +458 -0
- package/src/editor/client/fields/rich-text-commands.ts +183 -0
- package/src/editor/client/fields/rich-text-doc.ts +156 -0
- package/src/editor/client/fields/rich-text-schema.ts +224 -0
- package/src/editor/client/fields/stale.ts +58 -0
- package/src/editor/client/inbox/InboxStage.tsx +272 -0
- package/src/editor/client/inbox/store.ts +311 -0
- package/src/editor/client/live/format.ts +77 -0
- package/src/editor/client/live/marks.tsx +90 -0
- package/src/editor/client/live/notices.tsx +213 -0
- package/src/editor/client/live/publish.tsx +617 -0
- package/src/editor/client/live/session.tsx +162 -0
- package/src/editor/client/main.tsx +12 -0
- package/src/editor/client/media/LibraryStage.tsx +75 -0
- package/src/editor/client/media/ReplaceDialog.tsx +355 -0
- package/src/editor/client/media/store.ts +618 -0
- package/src/editor/client/nav/DocRow.tsx +122 -0
- package/src/editor/client/nav/search.ts +71 -0
- package/src/editor/client/nav/tree.ts +93 -0
- package/src/editor/client/overlays/OverlayMount.tsx +26 -0
- package/src/editor/client/overlays/editor-marks.ts +159 -0
- package/src/editor/client/overlays/index.ts +50 -0
- package/src/editor/client/overlays/layer.ts +437 -0
- package/src/editor/client/overlays/overlay-css.ts +177 -0
- package/src/editor/client/overlays/rich-text-bar.ts +717 -0
- package/src/editor/client/pane/Pane.tsx +91 -0
- package/src/editor/client/pane/levels/BlockLevel.tsx +123 -0
- package/src/editor/client/pane/levels/CollectionLevel.tsx +115 -0
- package/src/editor/client/pane/levels/DocumentLevel.tsx +93 -0
- package/src/editor/client/pane/levels/DocumentsLevel.tsx +390 -0
- package/src/editor/client/pane/levels/FileLevel.tsx +132 -0
- package/src/editor/client/pane/levels/FormLevel.tsx +550 -0
- package/src/editor/client/pane/levels/MediaLevel.tsx +657 -0
- package/src/editor/client/pane/levels/SettingsM6.tsx +299 -0
- package/src/editor/client/pane/levels/SubmissionsLevel.tsx +185 -0
- package/src/editor/client/pane/parts.tsx +179 -0
- package/src/editor/client/pane/switcher.tsx +165 -0
- package/src/editor/client/preview/driver.ts +640 -0
- package/src/editor/client/preview/field-text.ts +71 -0
- package/src/editor/client/preview/follow-links.ts +234 -0
- package/src/editor/client/preview/layout.ts +37 -0
- package/src/editor/client/preview/link-target.ts +50 -0
- package/src/editor/client/preview/markers.ts +86 -0
- package/src/editor/client/preview/page-edit.ts +632 -0
- package/src/editor/client/preview/page-editor-registry.ts +63 -0
- package/src/editor/client/preview/page-editor.ts +235 -0
- package/src/editor/client/preview/render-sequencer.ts +113 -0
- package/src/editor/client/preview/selection.ts +116 -0
- package/src/editor/client/preview/warnings.ts +61 -0
- package/src/editor/client/shell/App.tsx +101 -0
- package/src/editor/client/shell/Bar.tsx +197 -0
- package/src/editor/client/shell/Brand.tsx +33 -0
- package/src/editor/client/shell/Icons.tsx +87 -0
- package/src/editor/client/shell/NarrowNote.tsx +47 -0
- package/src/editor/client/shell/Popovers.tsx +47 -0
- package/src/editor/client/shell/Stage.tsx +132 -0
- package/src/editor/client/shell/Toast.tsx +61 -0
- package/src/editor/client/state/document-store.ts +465 -0
- package/src/editor/client/state/keeper.ts +713 -0
- package/src/editor/client/state/live.ts +257 -0
- package/src/editor/client/state/schema.ts +42 -0
- package/src/editor/client/state/store.ts +39 -0
- package/src/editor/fonts/OFL-GeistMono.txt +93 -0
- package/src/editor/fonts/OFL-InstrumentSans.txt +93 -0
- package/src/editor/fonts/geist-mono-latin-ext-wght-normal.woff2 +0 -0
- package/src/editor/fonts/geist-mono-latin-wght-normal.woff2 +0 -0
- package/src/editor/fonts/instrument-sans-latin-ext-wght-normal.woff2 +0 -0
- package/src/editor/fonts/instrument-sans-latin-wght-normal.woff2 +0 -0
- package/src/editor/middleware.ts +62 -0
- package/src/editor/routes/_live.ts +26 -0
- package/src/editor/routes/admin.astro +80 -0
- package/src/editor/routes/create.ts +19 -0
- package/src/editor/routes/delete.ts +19 -0
- package/src/editor/routes/document.ts +39 -0
- package/src/editor/routes/documents.ts +30 -0
- package/src/editor/routes/github.ts +23 -0
- package/src/editor/routes/history.ts +9 -0
- package/src/editor/routes/lock.ts +9 -0
- package/src/editor/routes/login/assets.d.ts +10 -0
- package/src/editor/routes/login/login.ts +43 -0
- package/src/editor/routes/media.ts +16 -0
- package/src/editor/routes/move.ts +17 -0
- package/src/editor/routes/notice.ts +9 -0
- package/src/editor/routes/preview.astro +77 -0
- package/src/editor/routes/publish.ts +9 -0
- package/src/editor/routes/status.ts +9 -0
- package/src/editor/routes/submissions.ts +16 -0
- package/src/editor/routes/try-again.ts +9 -0
- package/src/editor/routes/write.ts +18 -0
- package/src/editor/server/admin-props.ts +151 -0
- package/src/editor/server/cards.ts +61 -0
- package/src/editor/server/create.ts +414 -0
- package/src/editor/server/drafts-store.ts +127 -0
- package/src/editor/server/media/alt.ts +107 -0
- package/src/editor/server/media/bucket.ts +295 -0
- package/src/editor/server/media/config.ts +78 -0
- package/src/editor/server/media/handler.ts +221 -0
- package/src/editor/server/media/library.ts +254 -0
- package/src/editor/server/media/memory.ts +184 -0
- package/src/editor/server/media/mux.ts +241 -0
- package/src/editor/server/media/sniff.ts +599 -0
- package/src/editor/server/media/upload.ts +175 -0
- package/src/editor/server/mode.ts +63 -0
- package/src/editor/server/move.ts +295 -0
- package/src/editor/server/render.tsx +9 -0
- package/src/editor/server/replace.ts +72 -0
- package/src/editor/server/settings.ts +35 -0
- package/src/editor/server/write.ts +366 -0
- package/src/editor/styles/editor.css +502 -0
- package/src/editor/styles/fonts.css +40 -0
- package/src/editor/styles/levels/README.md +4 -0
- package/src/editor/styles/levels/collections.css +99 -0
- package/src/editor/styles/levels/document-level.css +10 -0
- package/src/editor/styles/levels/documents.css +9 -0
- package/src/editor/styles/levels/fields.css +171 -0
- package/src/editor/styles/levels/files.css +95 -0
- package/src/editor/styles/levels/live.css +10 -0
- package/src/editor/styles/levels/m5a-documents.css +51 -0
- package/src/editor/styles/levels/m6.css +82 -0
- package/src/editor/styles/levels/media.css +175 -0
- package/src/editor/styles/levels/switcher.css +3 -0
- package/src/editor/styles/tokens.css +114 -0
- package/src/editor/tsconfig.json +16 -0
- package/src/editor/types.ts +881 -0
- package/src/editor/ui-state.ts +113 -0
- package/src/email.ts +67 -0
- package/src/fields.ts +1129 -0
- package/src/files-route.ts +201 -0
- package/src/files.ts +116 -0
- package/src/forms/email.ts +149 -0
- package/src/forms/endpoint.ts +347 -0
- package/src/forms/inbox.ts +273 -0
- package/src/forms/rate-limit.ts +130 -0
- package/src/forms/route.ts +24 -0
- package/src/forms/rules.ts +278 -0
- package/src/forms/store.ts +486 -0
- package/src/head.ts +125 -0
- package/src/index.ts +434 -0
- package/src/json-source.ts +116 -0
- package/src/links.ts +121 -0
- package/src/live/README.md +43 -0
- package/src/live/auth/allow-rules.ts +70 -0
- package/src/live/auth/allow.ts +128 -0
- package/src/live/auth/auth.ts +155 -0
- package/src/live/auth/email.ts +84 -0
- package/src/live/auth/guard.ts +111 -0
- package/src/live/auth/index.ts +68 -0
- package/src/live/auth/links.ts +101 -0
- package/src/live/auth/login.ts +245 -0
- package/src/live/auth/middleware.ts +53 -0
- package/src/live/auth/request.ts +56 -0
- package/src/live/auth/routes.ts +9 -0
- package/src/live/auth/session.ts +181 -0
- package/src/live/auth/views.ts +355 -0
- package/src/live/autosaves.ts +306 -0
- package/src/live/backend.ts +652 -0
- package/src/live/branches.ts +19 -0
- package/src/live/drafts-store.ts +591 -0
- package/src/live/editor.ts +465 -0
- package/src/live/git.ts +1021 -0
- package/src/live/github.ts +271 -0
- package/src/live/locals.ts +24 -0
- package/src/live/locks.ts +273 -0
- package/src/live/merge.ts +197 -0
- package/src/live/messages.ts +151 -0
- package/src/live/mode.ts +210 -0
- package/src/live/redis.ts +533 -0
- package/src/live/runtime.ts +164 -0
- package/src/live/status.ts +109 -0
- package/src/live/sweep.ts +228 -0
- package/src/live/timings.ts +47 -0
- package/src/live/webhook.ts +39 -0
- package/src/lockup-css.ts +58 -0
- package/src/measure.ts +145 -0
- package/src/media-value.ts +239 -0
- package/src/media.ts +255 -0
- package/src/read.ts +256 -0
- package/src/redirect-middleware.ts +49 -0
- package/src/redirects.ts +76 -0
- package/src/relink.ts +293 -0
- package/src/reserved.ts +24 -0
- package/src/rich-text.ts +690 -0
- package/src/schema-key.ts +63 -0
- package/src/seo.ts +124 -0
- package/src/serialize.ts +697 -0
- package/src/skipped.ts +35 -0
- package/src/slug.ts +121 -0
- package/src/text-styles.ts +62 -0
- package/src/virtual.d.ts +28 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `GET`/`HEAD /files/<hash>/<name>.<ext>` (M6c Decision 6): the library's files, streamed
|
|
3
|
+
* from the bucket (never through imgproxy), on every site, in every mode. Injected by the
|
|
4
|
+
* integration as `/files/[...path]`, so everything under `/files` reaches this module and
|
|
5
|
+
* ends at its own answers, whatever Astro's router makes of the path.
|
|
6
|
+
*
|
|
7
|
+
* **It must never become a way to read the bucket** (the spec's Watch-outs). In order:
|
|
8
|
+
*
|
|
9
|
+
* 1. Not `GET`/`HEAD`: `405`.
|
|
10
|
+
* 2. **The path, an allow-list.** The request's own pathname (as the adapter built the
|
|
11
|
+
* URL, still percent-encoded) must be exactly a file link's: `fileKeyOf` (files.ts)
|
|
12
|
+
* splits it into two segments, checks the hash raw (`[0-9a-f]{12}`), decodes the name
|
|
13
|
+
* once, and `isFileKey` takes only `uploads/<hash>/<name>.<ext>` with `<ext>` in the
|
|
14
|
+
* file-type table and no slash, backslash or control character in the name. So `..`,
|
|
15
|
+
* `.`, `//`, a trailing `/`, `%2f`, `%5c`, a malformed escape, an image's or a
|
|
16
|
+
* submission's key and an extension not in the table all fail here: the short `404`
|
|
17
|
+
* (`no-store`), before anything else is asked.
|
|
18
|
+
* 3. **The query**: none, or exactly `download`. Anything else (`?x=1`, a bare `?`,
|
|
19
|
+
* `?download&x=1`) is a `301` to the clean address, keeping `?download` when it was
|
|
20
|
+
* there, cached a day (`public, max-age=86400`) so the CDN keeps the redirect.
|
|
21
|
+
* 4. No media variables: `503`, a neutral sentence; the missing names go to the log only.
|
|
22
|
+
* 5. **The rate limit**, its own instance (`caracms:files:rl:`, 30 per 10 minutes per
|
|
23
|
+
* visitor, Redis at `REDIS_URL`, failing open): `429` with `Retry-After` the window.
|
|
24
|
+
* 6. The bucket's `get` for that one key: missing (or a memory object with no bytes) is
|
|
25
|
+
* the same `404`. Otherwise the bytes, with `Content-Type` from the table, `nosniff`,
|
|
26
|
+
* `inline` for a PDF without `?download` and `attachment` with the key's name for the
|
|
27
|
+
* rest, and a year's immutable cache (an address never changes content), or
|
|
28
|
+
* `private, max-age=86400` while the pre-launch password is on (the gate reads
|
|
29
|
+
* `CARACMS_PRELAUNCH` in this same process), so a shared cache never keeps it.
|
|
30
|
+
*/
|
|
31
|
+
import type { APIRoute } from 'astro';
|
|
32
|
+
import { clientIp } from './live/auth/request.ts';
|
|
33
|
+
import { fileAddress, fileKeyOf, fileTypeOf } from './files.ts';
|
|
34
|
+
import { s3Bucket, type Bucket } from './editor/server/media/bucket.ts';
|
|
35
|
+
import { readImagesConfig } from './editor/server/media/config.ts';
|
|
36
|
+
import { memoryMedia } from './editor/server/media/memory.ts';
|
|
37
|
+
import { isProductionBuild } from './media.ts';
|
|
38
|
+
import { processLimiter, resetProcessLimiter, type LimitSettings, type RateLimiter } from './forms/rate-limit.ts';
|
|
39
|
+
|
|
40
|
+
export const prerender = false;
|
|
41
|
+
|
|
42
|
+
/** The route's own limiter: 30 downloads per visitor per 10 minutes (Decision 6). */
|
|
43
|
+
export const FILES_LIMIT: LimitSettings = { prefix: 'caracms:files:rl:', max: 30, windowMs: 10 * 60 * 1000, area: 'files', hitName: 'download' };
|
|
44
|
+
const FILES_LIMITER = 'caracms.filesLimiter';
|
|
45
|
+
|
|
46
|
+
/** The process's download limiter (Redis at `REDIS_URL`, else memory). */
|
|
47
|
+
export function filesLimiter(env: Record<string, string | undefined> = process.env): RateLimiter {
|
|
48
|
+
return processLimiter(FILES_LIMITER, FILES_LIMIT, env);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Forget it (tests). */
|
|
52
|
+
export function resetFilesLimiter(): void {
|
|
53
|
+
resetProcessLimiter(FILES_LIMITER);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const NOT_FOUND_TEXT = 'Not found.\n';
|
|
57
|
+
export const UNAVAILABLE_TEXT = "Downloads aren't available right now.\n";
|
|
58
|
+
export const RATE_LIMITED_TEXT = 'Too many downloads from here. Try again in a few minutes.\n';
|
|
59
|
+
|
|
60
|
+
export const CACHE_YEAR = 'public, max-age=31536000, immutable';
|
|
61
|
+
export const CACHE_PRELAUNCH = 'private, max-age=86400';
|
|
62
|
+
export const CACHE_REDIRECT = 'public, max-age=86400';
|
|
63
|
+
|
|
64
|
+
type Env = Record<string, string | undefined>;
|
|
65
|
+
|
|
66
|
+
export interface FileRouteContext {
|
|
67
|
+
/** Default `process.env`, read on each request. */
|
|
68
|
+
env?: Env;
|
|
69
|
+
production?: boolean;
|
|
70
|
+
/** The socket's address (Astro's `clientAddress`), read after `X-Real-IP`. */
|
|
71
|
+
clientAddress?: () => string | undefined;
|
|
72
|
+
/** Default the process's own (`filesLimiter`). */
|
|
73
|
+
limiter?: RateLimiter;
|
|
74
|
+
/** The S3 bucket's `fetch` (tests). */
|
|
75
|
+
fetch?: typeof fetch;
|
|
76
|
+
/** Server log lines (the 503's missing variables, a bucket failure). */
|
|
77
|
+
log?: (line: string) => void;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const text = (status: number, body: string, headers: Record<string, string> = {}, head = false) =>
|
|
81
|
+
new Response(head ? null : body, {
|
|
82
|
+
status,
|
|
83
|
+
headers: { 'Content-Type': 'text/plain; charset=utf-8', 'X-Content-Type-Options': 'nosniff', 'Cache-Control': 'no-store', ...headers },
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
/** The query as sent: null for none, `''` for a bare `?`. A fragment never reaches a server, but is dropped if it does. */
|
|
87
|
+
function rawQuery(url: string): string | null {
|
|
88
|
+
const q = url.indexOf('?');
|
|
89
|
+
if (q === -1) return null;
|
|
90
|
+
const hash = url.indexOf('#', q);
|
|
91
|
+
return url.slice(q + 1, hash === -1 ? undefined : hash);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The request's pathname, still percent-encoded (not Astro's decoded view). */
|
|
95
|
+
function rawPathname(url: string): string | null {
|
|
96
|
+
try {
|
|
97
|
+
return new URL(url).pathname;
|
|
98
|
+
} catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const PRINTABLE_ASCII = /^[\x20-\x7e]+$/;
|
|
104
|
+
/** RFC 5987's `attr-char` escapes: encodeURIComponent leaves `'()*` and `!` alone, which `filename*` can't carry raw (`'`, and the rest by caution). */
|
|
105
|
+
const rfc5987 = (s: string) => encodeURIComponent(s).replace(/['()*!]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* `attachment; filename="<name>"`. The key's name is a slug, so header-safe as it is;
|
|
109
|
+
* anything a quoted string can't carry (non-ASCII, `"`, `\`) is `_` there, and the
|
|
110
|
+
* real name goes in `filename*` too.
|
|
111
|
+
*/
|
|
112
|
+
export function attachmentDisposition(name: string): string {
|
|
113
|
+
if (PRINTABLE_ASCII.test(name) && !/["\\]/.test(name)) return `attachment; filename="${name}"`;
|
|
114
|
+
const fallback = name.replace(/[^\x20-\x7e]|["\\]/g, '_');
|
|
115
|
+
return `attachment; filename="${fallback}"; filename*=UTF-8''${rfc5987(name)}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const prelaunchOn = (env: Env) => typeof env['CARACMS_PRELAUNCH'] === 'string' && env['CARACMS_PRELAUNCH'].trim() !== '';
|
|
119
|
+
|
|
120
|
+
const logged = new Set<string>();
|
|
121
|
+
const logOnce = (log: (line: string) => void, line: string) => {
|
|
122
|
+
if (logged.has(line)) return;
|
|
123
|
+
logged.add(line);
|
|
124
|
+
log(line);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/** The handler, Astro-free so tests call it with a plain `Request`. */
|
|
128
|
+
export async function handleFileRequest(request: Request, ctx: FileRouteContext = {}): Promise<Response> {
|
|
129
|
+
const method = request.method.toUpperCase();
|
|
130
|
+
const head = method === 'HEAD';
|
|
131
|
+
if (method !== 'GET' && !head) return text(405, 'Method not allowed.\n', { Allow: 'GET, HEAD' });
|
|
132
|
+
|
|
133
|
+
// 2. The path: exactly a file key's shape, or the 404, before anything else is asked.
|
|
134
|
+
const path = rawPathname(request.url);
|
|
135
|
+
const file = path === null ? null : fileKeyOf(path);
|
|
136
|
+
const type = file ? fileTypeOf(file.key) : null;
|
|
137
|
+
if (!file || !type) return text(404, NOT_FOUND_TEXT, {}, head);
|
|
138
|
+
|
|
139
|
+
// 3. The query: none, or exactly `download`.
|
|
140
|
+
const query = rawQuery(request.url);
|
|
141
|
+
let download = false;
|
|
142
|
+
if (query !== null) {
|
|
143
|
+
const parts = query.split('&');
|
|
144
|
+
download = parts.some((p) => p.split('=')[0] === 'download');
|
|
145
|
+
if (query !== 'download') {
|
|
146
|
+
return new Response(null, { status: 301, headers: { Location: fileAddress(file.key, { download }), 'Cache-Control': CACHE_REDIRECT } });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// 4. The media variables.
|
|
151
|
+
const env = ctx.env ?? process.env;
|
|
152
|
+
const log = ctx.log ?? ((line: string) => console.error(line));
|
|
153
|
+
const config = readImagesConfig(env, ctx.production ?? isProductionBuild());
|
|
154
|
+
if (config.kind === 'unset') {
|
|
155
|
+
logOnce(log, `[caracms] files: /files/ answers 503: ${config.message}${config.missing.length ? ` (missing: ${config.missing.join(', ')})` : ''}`);
|
|
156
|
+
return text(503, UNAVAILABLE_TEXT, {}, head);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// 5. The visitor's allowance.
|
|
160
|
+
const limiter = ctx.limiter ?? filesLimiter(env);
|
|
161
|
+
if (await limiter.hit(clientIp(request, ctx.clientAddress ?? (() => undefined)))) {
|
|
162
|
+
return text(429, RATE_LIMITED_TEXT, { 'Retry-After': String(Math.ceil(FILES_LIMIT.windowMs / 1000)) }, head);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// 6. The one key the allow-list passed.
|
|
166
|
+
const bucket: Bucket = config.kind === 'memory' ? memoryMedia().bucket : s3Bucket(config.bucket, ctx.fetch);
|
|
167
|
+
let object;
|
|
168
|
+
try {
|
|
169
|
+
object = await bucket.get(file.key);
|
|
170
|
+
} catch (error) {
|
|
171
|
+
log(`[caracms] files: couldn't read ${file.key} from the bucket: ${error instanceof Error ? error.message : String(error)}`);
|
|
172
|
+
return text(503, UNAVAILABLE_TEXT, {}, head);
|
|
173
|
+
}
|
|
174
|
+
if (!object) return text(404, NOT_FOUND_TEXT, {}, head);
|
|
175
|
+
|
|
176
|
+
const name = file.key.slice(file.key.lastIndexOf('/') + 1);
|
|
177
|
+
const headers: Record<string, string> = {
|
|
178
|
+
'Content-Type': type.contentType,
|
|
179
|
+
'X-Content-Type-Options': 'nosniff',
|
|
180
|
+
'Content-Disposition': type.opensInTab && !download ? 'inline' : attachmentDisposition(name),
|
|
181
|
+
'Cache-Control': prelaunchOn(env) ? CACHE_PRELAUNCH : CACHE_YEAR,
|
|
182
|
+
};
|
|
183
|
+
if (object.size !== null) headers['Content-Length'] = String(object.size);
|
|
184
|
+
if (head) {
|
|
185
|
+
await object.body.cancel().catch(() => {});
|
|
186
|
+
return new Response(null, { status: 200, headers });
|
|
187
|
+
}
|
|
188
|
+
return new Response(object.body, { status: 200, headers });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export const ALL: APIRoute = (context) =>
|
|
192
|
+
handleFileRequest(context.request, {
|
|
193
|
+
// Astro's getter throws where the adapter gives no address (tests' containers).
|
|
194
|
+
clientAddress: () => {
|
|
195
|
+
try {
|
|
196
|
+
return context.clientAddress;
|
|
197
|
+
} catch {
|
|
198
|
+
return undefined;
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
});
|
package/src/files.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Files in the library (M6c G4; notes/2026-10-17-m6c-downloads-grid.md → Contracts): the
|
|
3
|
+
* one table of file types, and a file's public address as a pure function of its key.
|
|
4
|
+
* **Browser-safe** (no imports): the sniffer, the public route, the link picker, the
|
|
5
|
+
* library, Replace, the missing-file check, `mediaUses` and `<Link>` all read these, and
|
|
6
|
+
* none of them parses a `/files/` address its own way.
|
|
7
|
+
*
|
|
8
|
+
* - A file is stored as `uploads/<12 hex>/<name>.<ext>` (Decisions 1–2), `<ext>` one of
|
|
9
|
+
* the table's, from the sniffed bytes.
|
|
10
|
+
* - Its address is the key without `uploads/`: `/files/<hash>/<name>.<ext>`, each segment
|
|
11
|
+
* percent-encoded (slugs keep non-ASCII letters), and `?download` when a PDF link should
|
|
12
|
+
* download rather than open in a tab (Decisions 3 and 5).
|
|
13
|
+
* - **A file link** is an address under `/files/` of a key's exact shape, with or without
|
|
14
|
+
* `?download` or a fragment (Decision 4). `/brochure.pdf` from `public/` is not one.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export interface FileType {
|
|
18
|
+
/** The key's extension, lower case, no dot. */
|
|
19
|
+
ext: string;
|
|
20
|
+
/** The `Content-Type` the public route answers with. */
|
|
21
|
+
contentType: string;
|
|
22
|
+
/** What the editor calls it: "PDF", "Word", … */
|
|
23
|
+
label: string;
|
|
24
|
+
/** Opens in a browser tab (PDF only), so its links get the New tab / Download toggle (Decision 5). */
|
|
25
|
+
opensInTab: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Decision 1's types, in the order the editor lists them. */
|
|
29
|
+
export const FILE_TYPES: readonly FileType[] = [
|
|
30
|
+
{ ext: 'pdf', contentType: 'application/pdf', label: 'PDF', opensInTab: true },
|
|
31
|
+
{ ext: 'docx', contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', label: 'Word', opensInTab: false },
|
|
32
|
+
{ ext: 'doc', contentType: 'application/msword', label: 'Word', opensInTab: false },
|
|
33
|
+
{ ext: 'xlsx', contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', label: 'Excel', opensInTab: false },
|
|
34
|
+
{ ext: 'xls', contentType: 'application/vnd.ms-excel', label: 'Excel', opensInTab: false },
|
|
35
|
+
{ ext: 'pptx', contentType: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', label: 'PowerPoint', opensInTab: false },
|
|
36
|
+
{ ext: 'ppt', contentType: 'application/vnd.ms-powerpoint', label: 'PowerPoint', opensInTab: false },
|
|
37
|
+
{ ext: 'csv', contentType: 'text/csv; charset=utf-8', label: 'CSV', opensInTab: false },
|
|
38
|
+
{ ext: 'zip', contentType: 'application/zip', label: 'Zip', opensInTab: false },
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
const BY_EXT = new Map(FILE_TYPES.map((t) => [t.ext, t]));
|
|
42
|
+
|
|
43
|
+
/** The type a key or name ends in, or null when it isn't one of the table's. */
|
|
44
|
+
export function fileTypeOf(keyOrName: string): FileType | null {
|
|
45
|
+
const m = /\.([a-z0-9]+)$/i.exec(keyOrName);
|
|
46
|
+
return m ? (BY_EXT.get(m[1]!.toLowerCase()) ?? null) : null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The address prefix, and the folder every key lives in. */
|
|
50
|
+
export const FILES_BASE = '/files/';
|
|
51
|
+
export const UPLOADS_PREFIX = 'uploads/';
|
|
52
|
+
|
|
53
|
+
/** A file's name part: no slash, backslash or control character, something before a table extension. */
|
|
54
|
+
const NAME = /^[^/\\\u0000-\u001f\u007f]+\.([a-z0-9]+)$/;
|
|
55
|
+
const HASH = /^[0-9a-f]{12}$/;
|
|
56
|
+
|
|
57
|
+
/** Is this a file's key (`uploads/<hash>/<name>.<ext>`, `<ext>` in the table)? Images and submissions aren't. */
|
|
58
|
+
export function isFileKey(key: unknown): key is string {
|
|
59
|
+
if (typeof key !== 'string' || !key.startsWith(UPLOADS_PREFIX)) return false;
|
|
60
|
+
const parts = key.slice(UPLOADS_PREFIX.length).split('/');
|
|
61
|
+
if (parts.length !== 2 || !HASH.test(parts[0]!)) return false;
|
|
62
|
+
const m = NAME.exec(parts[1]!);
|
|
63
|
+
return !!m && BY_EXT.has(m[1]!) && parts[1] !== `.${m[1]}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A file key's public address: `/files/<hash>/<name>.<ext>`, each segment encoded, `?download` when asked. */
|
|
67
|
+
export function fileAddress(key: string, options: { download?: boolean } = {}): string {
|
|
68
|
+
if (!isFileKey(key)) throw new Error(`Not a file key: ${key}`);
|
|
69
|
+
const [hash, name] = key.slice(UPLOADS_PREFIX.length).split('/') as [string, string];
|
|
70
|
+
return `${FILES_BASE}${hash}/${encodeURIComponent(name)}${options.download ? '?download' : ''}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The inverse: the key and download flag an address names, or null when it isn't a file
|
|
75
|
+
* link. A fragment is ignored; the query must be empty or exactly `download`. The name is
|
|
76
|
+
* decoded once; an encoded slash, a malformed escape or anything not of a key's shape is
|
|
77
|
+
* null. (The public route answers other queries with a 301 first; this is the link side.)
|
|
78
|
+
*/
|
|
79
|
+
export function fileKeyOf(address: unknown): { key: string; download: boolean } | null {
|
|
80
|
+
if (typeof address !== 'string' || !address.startsWith(FILES_BASE)) return null;
|
|
81
|
+
let rest = address.slice(FILES_BASE.length);
|
|
82
|
+
const hashAt = rest.indexOf('#');
|
|
83
|
+
if (hashAt !== -1) rest = rest.slice(0, hashAt);
|
|
84
|
+
let download = false;
|
|
85
|
+
const q = rest.indexOf('?');
|
|
86
|
+
if (q !== -1) {
|
|
87
|
+
const query = rest.slice(q + 1);
|
|
88
|
+
if (query !== 'download') return null;
|
|
89
|
+
download = true;
|
|
90
|
+
rest = rest.slice(0, q);
|
|
91
|
+
}
|
|
92
|
+
const parts = rest.split('/');
|
|
93
|
+
if (parts.length !== 2) return null;
|
|
94
|
+
let name: string;
|
|
95
|
+
try {
|
|
96
|
+
name = decodeURIComponent(parts[1]!);
|
|
97
|
+
} catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
const key = `${UPLOADS_PREFIX}${parts[0]}/${name}`;
|
|
101
|
+
return isFileKey(key) ? { key, download } : null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Is this address a file link (Decision 4)? */
|
|
105
|
+
export const isFileLink = (address: unknown): boolean => fileKeyOf(address) !== null;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* How a file link behaves on the site (Decision 11), or null for any other address: a PDF
|
|
109
|
+
* without `?download` opens in a new tab; every other file link downloads. `<Link>` and
|
|
110
|
+
* rich text's render use this; a site's own markup can too.
|
|
111
|
+
*/
|
|
112
|
+
export function fileLinkBehaviour(address: unknown): 'new-tab' | 'download' | null {
|
|
113
|
+
const file = fileKeyOf(address);
|
|
114
|
+
if (!file) return null;
|
|
115
|
+
return fileTypeOf(file.key)?.opensInTab && !file.download ? 'new-tab' : 'download';
|
|
116
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The submission's email (M6 Decision 8; `forms.md` → Recipients and email): one
|
|
3
|
+
* plain-text email per stored submission, through the package's sender (../email.ts).
|
|
4
|
+
*
|
|
5
|
+
* - **Recipients and the brand name are read from the deployed Site Settings file**
|
|
6
|
+
* (`content/site-settings.json` on disk: `formRecipients.<form id>`, `brand.name`), so
|
|
7
|
+
* a change applies once it's published and deployed. Unreadable: the email is recorded
|
|
8
|
+
* `failed` with "Could not read Site Settings".
|
|
9
|
+
* - Every field in the form's order by its label, a select by its option's label; the
|
|
10
|
+
* files by name and size, **never attached, never linked to the bucket**; the receive
|
|
11
|
+
* time in the site's zone; one link to the submission in the editor.
|
|
12
|
+
* - Subject `<the form's email subject> — <brand name> website`, from `FORMS_FROM`,
|
|
13
|
+
* Reply-To the visitor's address when it looks like one.
|
|
14
|
+
* - Addresses are checked again here (Decision 14's third check): trimmed, a duplicate
|
|
15
|
+
* dropped, one that doesn't look like an address skipped. An empty list is `skipped`.
|
|
16
|
+
* - **Only a production server sends.** Elsewhere the email is written to the log and
|
|
17
|
+
* recorded `skipped`, saying so.
|
|
18
|
+
*/
|
|
19
|
+
import { readFile } from 'node:fs/promises';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import { SITE_SETTINGS_FILE } from '../reserved.ts';
|
|
22
|
+
import { sendEmail, sendsEmail } from '../email.ts';
|
|
23
|
+
import { formatBytes, looksLikeEmail, shownFileName, submissionAddress, type ResolvedForm } from './rules.ts';
|
|
24
|
+
import type { EmailOutcome, StoredSubmission } from './store.ts';
|
|
25
|
+
|
|
26
|
+
type Env = Record<string, string | undefined>;
|
|
27
|
+
|
|
28
|
+
export interface FormSettings {
|
|
29
|
+
/** Site Settings' `brand.name`, trimmed; '' when it has none. */
|
|
30
|
+
brandName: string;
|
|
31
|
+
/** `formRecipients` as written, each list's entries trimmed (strings only). */
|
|
32
|
+
recipients: Record<string, string[]>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The settings out of a parsed Site Settings document. */
|
|
36
|
+
export function formSettingsFrom(doc: unknown): FormSettings {
|
|
37
|
+
const o = (doc && typeof doc === 'object' ? doc : {}) as Record<string, unknown>;
|
|
38
|
+
const brand = (o.brand && typeof o.brand === 'object' ? o.brand : {}) as Record<string, unknown>;
|
|
39
|
+
const lists = (o.formRecipients && typeof o.formRecipients === 'object' && !Array.isArray(o.formRecipients) ? o.formRecipients : {}) as Record<string, unknown>;
|
|
40
|
+
const recipients: Record<string, string[]> = {};
|
|
41
|
+
for (const [id, list] of Object.entries(lists)) {
|
|
42
|
+
recipients[id] = Array.isArray(list) ? list.filter((v): v is string => typeof v === 'string').map((v) => v.trim()) : [];
|
|
43
|
+
}
|
|
44
|
+
return { brandName: typeof brand.name === 'string' ? brand.name.trim() : '', recipients };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Read the deployed file, on every submission (a few KB, no cache to go stale). Throws when it's missing or not JSON. */
|
|
48
|
+
export async function readFormSettings(root: string): Promise<FormSettings> {
|
|
49
|
+
return formSettingsFrom(JSON.parse(await readFile(join(root, SITE_SETTINGS_FILE), 'utf8')));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Trimmed, de-duplicated (without case), only what looks like an address: who the email goes to. */
|
|
53
|
+
export function sendableAddresses(values: readonly unknown[]): string[] {
|
|
54
|
+
const out: string[] = [];
|
|
55
|
+
const seen = new Set<string>();
|
|
56
|
+
for (const value of values) {
|
|
57
|
+
if (typeof value !== 'string') continue;
|
|
58
|
+
const address = value.trim();
|
|
59
|
+
if (!looksLikeEmail(address) || seen.has(address.toLowerCase())) continue;
|
|
60
|
+
seen.add(address.toLowerCase());
|
|
61
|
+
out.push(address);
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** "25 September 2026, 9:14 pm" in the site's zone. */
|
|
67
|
+
export function receivedText(iso: string, timeZone: string): string {
|
|
68
|
+
const date = new Date(iso);
|
|
69
|
+
if (Number.isNaN(date.getTime())) return iso;
|
|
70
|
+
const parts = new Intl.DateTimeFormat('en-GB', { timeZone, day: 'numeric', month: 'long', year: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true }).formatToParts(date);
|
|
71
|
+
const part = (type: string) => parts.find((p) => p.type === type)?.value ?? '';
|
|
72
|
+
return `${part('day')} ${part('month')} ${part('year')}, ${part('hour')}:${part('minute')} ${part('dayPeriod').toLowerCase()}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The subject, the plain-text body and the Reply-To. */
|
|
76
|
+
export function buildNotification(input: { submission: StoredSubmission; form: ResolvedForm | null; brandName: string; siteUrl: string | null; timeZone: string }): {
|
|
77
|
+
subject: string;
|
|
78
|
+
text: string;
|
|
79
|
+
replyTo?: string;
|
|
80
|
+
} {
|
|
81
|
+
const { submission, form, siteUrl, timeZone } = input;
|
|
82
|
+
const site = input.brandName.trim();
|
|
83
|
+
const formLabel = form?.label ?? submission.form;
|
|
84
|
+
const lines = [`Someone sent the ${formLabel} form on the ${site || 'your'} website.`, ''];
|
|
85
|
+
const push = (label: string, value: string) => {
|
|
86
|
+
if (value.includes('\n')) lines.push(`${label}:`, value, '');
|
|
87
|
+
else lines.push(`${label}: ${value}`);
|
|
88
|
+
};
|
|
89
|
+
const stored = submission.fields ?? {};
|
|
90
|
+
const shown = new Set<string>();
|
|
91
|
+
for (const field of form?.fields ?? []) {
|
|
92
|
+
shown.add(field.name);
|
|
93
|
+
if (field.type === 'files') {
|
|
94
|
+
const files = submission.files ?? [];
|
|
95
|
+
if (files.length) lines.push(`${field.label}:`, ...files.map((f) => `${shownFileName(f.name) || shownFileName(f.key)}, ${formatBytes(f.size)}`), '');
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const value = typeof stored[field.name] === 'string' ? stored[field.name]!.trim() : '';
|
|
99
|
+
push(field.label, field.type === 'select' ? (field.options?.find((o) => o.value === value)?.label ?? value) : value);
|
|
100
|
+
}
|
|
101
|
+
// Anything stored that the form no longer names (a form changed since), by name.
|
|
102
|
+
for (const [name, value] of Object.entries(stored)) if (!shown.has(name)) push(name, String(value ?? '').trim());
|
|
103
|
+
if (!form && submission.files?.length) lines.push('Files:', ...submission.files.map((f) => `${shownFileName(f.name) || shownFileName(f.key)}, ${formatBytes(f.size)}`), '');
|
|
104
|
+
if (lines[lines.length - 1] !== '') lines.push('');
|
|
105
|
+
lines.push(`Received: ${receivedText(submission.receivedAt, timeZone)}`);
|
|
106
|
+
const visitor = typeof stored.email === 'string' ? stored.email.trim() : '';
|
|
107
|
+
const replyTo = looksLikeEmail(visitor) ? visitor : undefined;
|
|
108
|
+
if (replyTo) lines.push('Reply to this email to answer them.');
|
|
109
|
+
// With no public origin there is no link: a Host header could be anyone's, so the email says where to look instead.
|
|
110
|
+
if (siteUrl) lines.push('', submission.files?.length ? 'Open it in the editor (the files download there):' : 'Open it in the editor:', new URL(submissionAddress(submission.id), siteUrl).href);
|
|
111
|
+
else lines.push('', submission.files?.length ? 'Open Site-wide → Submissions in the editor (the files download there).' : 'Open Site-wide → Submissions in the editor.');
|
|
112
|
+
return {
|
|
113
|
+
subject: `${form?.email.subject ?? `New ${submission.form} enquiry`} — ${site ? `${site} website` : 'your website'}`,
|
|
114
|
+
text: `${lines.join('\n')}\n`,
|
|
115
|
+
...(replyTo ? { replyTo } : {}),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Send a submission's email and say what happened (the outcome recorded on it). Never throws. */
|
|
120
|
+
export async function sendNotification(input: {
|
|
121
|
+
submission: StoredSubmission;
|
|
122
|
+
form: ResolvedForm | null;
|
|
123
|
+
settings: FormSettings;
|
|
124
|
+
siteUrl: string | null;
|
|
125
|
+
timeZone: string;
|
|
126
|
+
env?: Env;
|
|
127
|
+
fetch?: typeof fetch;
|
|
128
|
+
now?: () => Date;
|
|
129
|
+
log?: (line: string) => void;
|
|
130
|
+
}): Promise<EmailOutcome> {
|
|
131
|
+
const env = input.env ?? process.env;
|
|
132
|
+
const at = (input.now ?? (() => new Date()))().toISOString();
|
|
133
|
+
const log = input.log ?? ((line: string) => console.log(line));
|
|
134
|
+
const to = sendableAddresses(input.settings.recipients[input.submission.form] ?? []);
|
|
135
|
+
const label = input.form?.label ?? input.submission.form;
|
|
136
|
+
if (to.length === 0) return { status: 'skipped', at, to, error: `No one is set to get the ${label} form's emails (Site Settings → Form emails).` };
|
|
137
|
+
let built: ReturnType<typeof buildNotification>;
|
|
138
|
+
try {
|
|
139
|
+
built = buildNotification({ submission: input.submission, form: input.form, brandName: input.settings.brandName, siteUrl: input.siteUrl, timeZone: input.timeZone });
|
|
140
|
+
} catch (error) {
|
|
141
|
+
return { status: 'failed', at, to, error: `Could not write the email: ${error instanceof Error ? error.message : String(error)}` };
|
|
142
|
+
}
|
|
143
|
+
if (!sendsEmail(env)) {
|
|
144
|
+
log(`[caracms] form email for ${input.submission.id} (not emailed outside production), to ${to.join(', ')}:\nSubject: ${built.subject}\n${built.text}`);
|
|
145
|
+
return { status: 'skipped', at, to, error: 'Not emailed: only the production server sends email. It was written to the server log.' };
|
|
146
|
+
}
|
|
147
|
+
const result = await sendEmail({ to, subject: built.subject, text: built.text, replyTo: built.replyTo }, { env, fetch: input.fetch });
|
|
148
|
+
return result.status === 'sent' ? { status: 'sent', at, to } : { status: result.status, at, to, error: result.error };
|
|
149
|
+
}
|