caracms 0.6.0 → 0.8.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.
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Mux on CaraCMS Cloud (caracms-cloud M4.5 Decision 16), behind mux.ts's seam, so the clip
3
+ * routes, where-used (`withUses`) and the editor don't change. The site's server holds no
4
+ * Mux token: it asks the API (live/cloud.ts, the site key), which asks the cloud's own
5
+ * Mux environment and answers from the site's own clips only. One route per method:
6
+ *
7
+ * - `createUpload`: `POST /v1/video/upload` `{ origin, title }` → `{ id, url }`.
8
+ * - `upload`: `GET /v1/video/upload?id=` → `VideoUploadStatus`.
9
+ * - `clip`: `GET /v1/video/clip?id=` → `Clip`; `clipOfPlayback`: `GET /v1/video/playback?id=`.
10
+ * - `clips`: `GET /v1/video/clips` → `{ clips }`, newest first (the cloud keeps the order).
11
+ *
12
+ * **The cloud's refusals become `MuxRouteError`s**, so the media handler's own catch answers
13
+ * them: `not_found` is the routes' documented 404 ('Not found.'); `video_limit` is a 403
14
+ * with the cloud's sentence as sent; any other refusal (`video_unavailable`, the key's own
15
+ * refusals…) is a 502 with the cloud's sentence as sent. A cloud that can't be reached, or
16
+ * an answer that isn't the shape, is a 502 with the package's own sentence. Each 502 is
17
+ * logged with the refusal's code. The key is never logged nor put in an error.
18
+ */
19
+ import type { VideoUploadStatus } from '../../types.ts';
20
+ import { cloudCall, isCloudRefusal, type CloudConnection } from '../../../live/cloud.ts';
21
+ import { MuxRouteError, MUX_ID, type Clip, type MuxClient } from './mux.ts';
22
+
23
+ export const CLOUD_VIDEO_UNREACHABLE = "CaraCMS Cloud couldn't be reached just now. Try again in a minute.";
24
+ export const CLOUD_VIDEO_UNREADABLE = 'CaraCMS Cloud sent an unreadable reply.';
25
+
26
+ const str = (v: unknown) => (typeof v === 'string' && v ? v : null);
27
+ const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
28
+ const px = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : null);
29
+
30
+ export function cloudMux(
31
+ connection: Pick<CloudConnection, 'key' | 'url'>,
32
+ options: { fetch?: typeof fetch; log?: (line: string) => void } = {},
33
+ ): MuxClient {
34
+ const conn: CloudConnection = { ...connection, fetch: options.fetch ?? ((...args) => globalThis.fetch(...args)) };
35
+ const log = options.log ?? ((line: string) => console.error(line));
36
+
37
+ async function api(method: 'GET' | 'POST', path: string, body?: unknown): Promise<Record<string, unknown>> {
38
+ const where = `${method} ${path.split('?')[0]}`;
39
+ try {
40
+ return await cloudCall(conn, method, path, body);
41
+ } catch (error) {
42
+ if (!isCloudRefusal(error)) throw error;
43
+ if (error.error === 'not_found') throw new MuxRouteError(404, 'Not found.');
44
+ if (error.error === 'video_limit') throw new MuxRouteError(403, error.message);
45
+ log(`[caracms] video ${where}: CaraCMS Cloud (${error.error}): ${error.message}`);
46
+ throw new MuxRouteError(502, error.error === 'unreachable' ? CLOUD_VIDEO_UNREACHABLE : error.message);
47
+ }
48
+ }
49
+
50
+ const unreadable = (where: string): never => {
51
+ log(`[caracms] video ${where}: CaraCMS Cloud sent an unreadable reply`);
52
+ throw new MuxRouteError(502, CLOUD_VIDEO_UNREADABLE);
53
+ };
54
+
55
+ /** The cloud's `Clip`, checked field by field (it passes `toClip`'s shape through). */
56
+ const clipOf = (raw: unknown, where: string): Clip => {
57
+ const c = raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record<string, unknown>) : null;
58
+ const id = str(c?.id);
59
+ if (!c || !id) return unreadable(where);
60
+ const playbackId = str(c.playbackId);
61
+ const errored = c.errored === true;
62
+ return {
63
+ id,
64
+ playbackId: playbackId && MUX_ID.test(playbackId) ? playbackId : null,
65
+ status: str(c.status) ?? 'unknown',
66
+ ready: c.ready === true,
67
+ errored,
68
+ error: errored ? (str(c.error) ?? 'Mux could not process this file.') : null,
69
+ title: str(c.title),
70
+ duration: num(c.duration),
71
+ aspectRatio: str(c.aspectRatio),
72
+ w: px(c.w),
73
+ h: px(c.h),
74
+ createdAt: str(c.createdAt),
75
+ };
76
+ };
77
+
78
+ const query = (id: string) => new URLSearchParams({ id }).toString();
79
+
80
+ return {
81
+ async createUpload(origin, title) {
82
+ const answer = await api('POST', '/v1/video/upload', { origin, title });
83
+ const id = str(answer.id);
84
+ const url = str(answer.url);
85
+ if (!id || !url || !/^https:\/\//.test(url)) return unreadable('POST /v1/video/upload');
86
+ return { id, url };
87
+ },
88
+ async upload(id): Promise<VideoUploadStatus> {
89
+ const answer = await api('GET', `/v1/video/upload?${query(id)}`);
90
+ const assetId = str(answer.assetId);
91
+ return {
92
+ id: str(answer.id) ?? id,
93
+ status: str(answer.status) ?? 'unknown',
94
+ assetId: assetId && MUX_ID.test(assetId) ? assetId : null,
95
+ error: str(answer.error),
96
+ };
97
+ },
98
+ async clip(assetId) {
99
+ return clipOf(await api('GET', `/v1/video/clip?${query(assetId)}`), 'GET /v1/video/clip');
100
+ },
101
+ async clipOfPlayback(playbackId) {
102
+ return clipOf(await api('GET', `/v1/video/playback?${query(playbackId)}`), 'GET /v1/video/playback');
103
+ },
104
+ async clips() {
105
+ const answer = await api('GET', '/v1/video/clips');
106
+ if (!Array.isArray(answer.clips)) return unreadable('GET /v1/video/clips');
107
+ return answer.clips.map((c) => clipOf(c, 'GET /v1/video/clips'));
108
+ },
109
+ };
110
+ }
@@ -12,9 +12,16 @@
12
12
  * `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`). Clips need `MUX_TOKEN_ID` and `MUX_TOKEN_SECRET`.
13
13
  * - **On CaraCMS Cloud** (caracms-cloud M3 Decision 19) `CARACMS_CLOUD_KEY` is the images'
14
14
  * one credential: the bucket is the cloud's (cloud-bucket.ts), the signing from the
15
- * settings the process holds (../../../media.ts). Mux is unchanged.
15
+ * settings the process holds (../../../media.ts).
16
+ * - **And the clips' (caracms-cloud M4.5 Decision 15)**: with the key, clips go through the
17
+ * cloud's Mux (cloud-mux.ts), and the site's own `MUX_TOKEN_ID` / `MUX_TOKEN_SECRET`
18
+ * beside it is a problem sentence naming them (one credential, not two).
19
+ * - **With the config's `services`** (caracms-cloud M5 Decision 2) the other mode's variables
20
+ * are never read (live/services.ts), and a cloud site without its key has neither.
16
21
  */
17
22
  import type { CloudConnection } from '../../../live/cloud.ts';
23
+ import { CLOUD_AND_MUX_PROBLEM, OWN_MUX_VARIABLES, readCloudUrl } from '../../../live/mode.ts';
24
+ import { bakedServices, CLOUD_KEY_MISSING_PROBLEM, servicesEnv, type Services } from '../../../live/services.ts';
18
25
  import { cloudMedia, isProductionBuild, readMediaConfig, type MediaSettings } from '../../../media.ts';
19
26
 
20
27
  type Env = Record<string, string | undefined>;
@@ -33,7 +40,11 @@ export type ImagesConfig =
33
40
  | { kind: 'cloud'; signing: MediaSettings; connection: Pick<CloudConnection, 'key' | 'url'> }
34
41
  | { kind: 'unset'; message: string; missing: string[] };
35
42
 
36
- export type MuxConfig = { kind: 'memory' } | { kind: 'mux'; tokenId: string; tokenSecret: string } | { kind: 'unset'; message: string; missing: string[] };
43
+ export type MuxConfig =
44
+ | { kind: 'memory' }
45
+ | { kind: 'mux'; tokenId: string; tokenSecret: string }
46
+ | { kind: 'cloud'; connection: Pick<CloudConnection, 'key' | 'url'> }
47
+ | { kind: 'unset'; message: string; missing: string[] };
37
48
 
38
49
  export const MEMORY_IN_PRODUCTION = 'CARACMS_MEDIA=memory is for tests and local runs; a production server never uses the in-memory media.';
39
50
 
@@ -45,15 +56,17 @@ const get = (env: Env, name: string) => {
45
56
  const memory = (env: Env) => get(env, 'CARACMS_MEDIA') === 'memory';
46
57
 
47
58
  /** The image routes' settings: the bucket, and imgproxy's for the thumbnails. */
48
- export function readImagesConfig(env: Env = process.env, production = isProductionBuild()): ImagesConfig {
59
+ export function readImagesConfig(env: Env = process.env, production = isProductionBuild(), services: Services | null = bakedServices()): ImagesConfig {
60
+ env = servicesEnv(env, services);
49
61
  if (memory(env)) {
50
62
  if (production) return { kind: 'unset', message: MEMORY_IN_PRODUCTION, missing: [] };
51
- const signing = readMediaConfig(env, production);
63
+ const signing = readMediaConfig(env, production, services);
52
64
  if (signing.kind !== 'ready') return { kind: 'unset', message: MEMORY_IN_PRODUCTION, missing: [] };
53
65
  return { kind: 'memory', signing: signing.settings };
54
66
  }
55
- const signing = readMediaConfig(env, production);
56
- const cloud = cloudMedia(env);
67
+ if (services === 'cloud' && !get(env, 'CARACMS_CLOUD_KEY')) return { kind: 'unset', message: CLOUD_KEY_MISSING_PROBLEM, missing: [] };
68
+ const signing = readMediaConfig(env, production, services);
69
+ const cloud = cloudMedia(env, services);
57
70
  if (cloud) {
58
71
  if (signing.kind === 'ready') return { kind: 'cloud', signing: signing.settings, connection: cloud.connection };
59
72
  return { kind: 'unset', message: signing.problems.join(' '), missing: [] };
@@ -77,9 +90,20 @@ export function readImagesConfig(env: Env = process.env, production = isProducti
77
90
  return { kind: 's3', signing: signing.settings, bucket: { endpoint, region, bucket: signing.settings.bucket, accessKeyId, secretAccessKey } };
78
91
  }
79
92
 
80
- /** The clip routes' settings: the site's Mux environment's token. */
81
- export function readMuxConfig(env: Env = process.env, production = isProductionBuild()): MuxConfig {
93
+ /** The clip routes' settings: the cloud's Mux (the site key), or the site's Mux environment's token. */
94
+ export function readMuxConfig(env: Env = process.env, production = isProductionBuild(), services: Services | null = bakedServices()): MuxConfig {
95
+ env = servicesEnv(env, services);
82
96
  if (memory(env)) return production ? { kind: 'unset', message: MEMORY_IN_PRODUCTION, missing: [] } : { kind: 'memory' };
97
+ const key = get(env, 'CARACMS_CLOUD_KEY');
98
+ if (services === 'cloud' && !key) return { kind: 'unset', message: CLOUD_KEY_MISSING_PROBLEM, missing: [] };
99
+ if (key) {
100
+ const problems: string[] = [];
101
+ const both = OWN_MUX_VARIABLES.filter((name) => get(env, name));
102
+ if (both.length) problems.push(CLOUD_AND_MUX_PROBLEM(both));
103
+ const url = readCloudUrl(get(env, 'CARACMS_CLOUD_URL'), problems);
104
+ if (problems.length) return { kind: 'unset', message: problems.join(' '), missing: [] };
105
+ return { kind: 'cloud', connection: { key, url } };
106
+ }
83
107
  const tokenId = get(env, 'MUX_TOKEN_ID');
84
108
  const tokenSecret = get(env, 'MUX_TOKEN_SECRET');
85
109
  const missing = [...(tokenId ? [] : ['MUX_TOKEN_ID']), ...(tokenSecret ? [] : ['MUX_TOKEN_SECRET'])];
@@ -10,7 +10,8 @@
10
10
  * - **No lock** (Decision 7): an upload, or reading the library, changes no document.
11
11
  * - **Every reply is `Cache-Control: private, no-store`.**
12
12
  * - **Unconfigured: 503 with a sentence naming what's missing**, images (the bucket and
13
- * imgproxy) and clips (Mux) apart: one missing never disables the other.
13
+ * imgproxy) and clips (Mux, or CaraCMS Cloud's: cloud-mux.ts) apart: one missing never
14
+ * disables the other.
14
15
  * - **The Mux token never reaches the browser, nor do Mux's error bodies** (server log
15
16
  * only). Ids are checked against `^[A-Za-z0-9]{1,128}$` before they reach a URL.
16
17
  * - **The upload has its own body cap** (40 MB, upload.ts), not the write route's 2 MB or
@@ -42,6 +43,7 @@ import { parseReplaceBody } from '../replace.ts';
42
43
  import { checkOrigin, seqOf, tabOf, WriteRefused } from '../write.ts';
43
44
  import { BucketError, s3Bucket, type Bucket } from './bucket.ts';
44
45
  import { cloudBucket } from './cloud-bucket.ts';
46
+ import { cloudMux } from './cloud-mux.ts';
45
47
  import { isCloudRefusal } from '../../../live/cloud.ts';
46
48
  import { readImagesConfig, readMuxConfig } from './config.ts';
47
49
  import { isFileKey } from '../../../files.ts';
@@ -88,6 +90,8 @@ function mux(ctx: MediaContext): MuxClient | Response {
88
90
  if (config.kind === 'unset') return json(503, { error: config.message, code: 'mux-unset', missing: config.missing } satisfies MediaUnset);
89
91
  const log = ctx.log ?? ((line: string) => console.error(line));
90
92
  if (config.kind === 'memory') return muxClient({ tokenId: 'memory', tokenSecret: 'memory', fetch: memoryMedia().mux.fetch, log });
93
+ // caracms-cloud M4.5: the cloud's Mux, through the site key; its refusals arrive as MuxRouteErrors.
94
+ if (config.kind === 'cloud') return cloudMux(config.connection, { fetch: ctx.fetch, log });
91
95
  return muxClient({ tokenId: config.tokenId, tokenSecret: config.tokenSecret, fetch: ctx.fetch, log });
92
96
  }
93
97
 
@@ -15,6 +15,7 @@
15
15
  import type { ResolvedSite } from '../../config.ts';
16
16
  import { liveEditor, type LiveEditor } from '../../live/editor.ts';
17
17
  import { readMode, unconfiguredMessage, type Baked, type Mode } from '../../live/mode.ts';
18
+ import { bakedServices } from '../../live/services.ts';
18
19
  import { localDraftsStore, type DraftsStore } from './drafts-store.ts';
19
20
 
20
21
  declare const __CARACMS_PRODUCTION__: boolean | undefined;
@@ -26,11 +27,12 @@ export function isProductionServer(): boolean {
26
27
  return typeof __CARACMS_PRODUCTION__ === 'boolean' ? __CARACMS_PRODUCTION__ : false;
27
28
  }
28
29
 
29
- /** The site's address and the commit the build baked in (runs-anywhere Decisions 7 and 9); empty in plain Node (tests). */
30
+ /** The site's address, the commit and the config's `services` the build baked in (runs-anywhere Decisions 7 and 9, caracms-cloud M5 Decision 1); empty in plain Node (tests). */
30
31
  export function bakedAtBuild(): Baked {
31
32
  return {
32
33
  site: typeof __CARACMS_SITE__ === 'string' ? __CARACMS_SITE__ : null,
33
34
  commit: typeof __CARACMS_BUILD_COMMIT__ === 'string' ? __CARACMS_BUILD_COMMIT__ : null,
35
+ services: bakedServices(),
34
36
  };
35
37
  }
36
38
 
package/src/email.ts CHANGED
@@ -10,6 +10,8 @@
10
10
  * reaches anyone's inbox. It never throws: an unset key or sender, Resend's refusal
11
11
  * or a timeout all come back as a result with a sentence.
12
12
  */
13
+ import { bakedServices, servicesEnv } from './live/services.ts';
14
+
13
15
  export const RESEND_URL = 'https://api.resend.com/emails';
14
16
  export const SEND_TIMEOUT_MS = 10_000;
15
17
 
@@ -30,7 +32,8 @@ export async function sendEmail(
30
32
  email: Email,
31
33
  options: { env?: Record<string, string | undefined>; fetch?: typeof fetch; timeoutMs?: number } = {},
32
34
  ): Promise<SendResult> {
33
- const env = options.env ?? process.env;
35
+ // caracms-cloud M5 Decision 2: on a cloud site RESEND_API_KEY and FORMS_FROM are never read (the cloud sends).
36
+ const env = servicesEnv(options.env ?? process.env, bakedServices());
34
37
  const timeoutMs = options.timeoutMs ?? SEND_TIMEOUT_MS;
35
38
  const apiKey = (env['RESEND_API_KEY'] ?? '').trim();
36
39
  const from = (env['FORMS_FROM'] ?? '').trim();
@@ -0,0 +1,212 @@
1
+ /**
2
+ * The submissions store on CaraCMS Cloud (caracms-cloud M4 Decisions 5–9 and 23), behind
3
+ * store.ts's `SubmissionStore`, so the endpoint's checks and the inbox don't change. The
4
+ * site's server never holds R2 or Resend credentials: it asks the API (live/cloud.ts, the
5
+ * site key), which answers for the site's own folder.
6
+ *
7
+ * - `create`: mints the id (`newSubmissionId`, the package's own shape), builds the
8
+ * `StoredSubmission`, asks the `email` callback to write the email for it, and sends both
9
+ * (`POST /v1/submissions`). On `409 exists` it mints a new id, writes the email again (its
10
+ * link names the id) and tries once more. With files, the answer is **pending** with one
11
+ * presigned PUT per file: each is uploaded with exactly the headers named, then
12
+ * `POST /v1/submissions/confirm`, tried once more on a timeout or an unreachable cloud.
13
+ * An upload that fails, or a confirm answered `409 incomplete`, removes the row
14
+ * (`POST /v1/submissions/delete`); a confirm that never got through is left to the
15
+ * cloud's hourly clean-up. Either way `create` throws: the visitor gets the endpoint's 502.
16
+ * - `get`, `list`, `update` (`readAt` only), `delete` and `getFile` (a 5-minute download
17
+ * URL, then the bytes): one route each.
18
+ * - **The unread count comes back with every answer**; `unreadCount()` gives the last one
19
+ * this store saw, or asks (`GET /v1/submissions/unread`). store.ts makes a store per
20
+ * request, so the count is never older than the request.
21
+ *
22
+ * Refusals are `CloudRefusal`s with the cloud's own sentence (the endpoint logs it, the
23
+ * inbox shows it). Presigned URLs go nowhere but the fetch here: never in a log line or an error.
24
+ */
25
+ import { createHash } from 'node:crypto';
26
+ import { cloudCall, CloudRefusal, isCloudRefusal, type CloudConnection } from '../live/cloud.ts';
27
+ import { isSubmissionId } from './rules.ts';
28
+ import { fileKey, isFileKey, newSubmissionId, PAGE_SIZE, type StoredFile, type StoredSubmission, type SubmissionStore } from './store.ts';
29
+
30
+ const STORAGE_TIMEOUT_MS = 20_000;
31
+
32
+ const malformed = (what: string) => new CloudRefusal('unreachable', `CaraCMS Cloud's answer to ${what} isn't one the package knows.`);
33
+
34
+ const count = (v: unknown): number | null => (typeof v === 'number' && Number.isInteger(v) && v >= 0 ? v : null);
35
+
36
+ /** A submission as the cloud answered it: the package's own shape, checked enough to be read by the inbox. */
37
+ function submissionOf(value: unknown, what: string): StoredSubmission {
38
+ const s = value as StoredSubmission | null;
39
+ if (!s || typeof s !== 'object' || !isSubmissionId(s.id) || typeof s.form !== 'string' || typeof s.receivedAt !== 'string' || !s.fields || typeof s.fields !== 'object') {
40
+ throw malformed(what);
41
+ }
42
+ return { ...s, v: 1, readAt: typeof s.readAt === 'string' ? s.readAt : null };
43
+ }
44
+
45
+ export function cloudStore(connection: CloudConnection): SubmissionStore {
46
+ const fetchImpl = connection.fetch ?? ((...args: Parameters<typeof fetch>) => globalThis.fetch(...args));
47
+ /** The unread count from the last answer that carried one. */
48
+ let unread: number | null = null;
49
+ const seen = (answer: Record<string, unknown>) => {
50
+ const n = count(answer.unread);
51
+ if (n !== null) unread = n;
52
+ };
53
+ const api = (method: 'GET' | 'POST', path: string, body?: unknown) => cloudCall(connection, method, path, body);
54
+ /** Null for the cloud's `404 not_found`; any other refusal goes up as sent. */
55
+ async function orNull(method: 'GET' | 'POST', path: string, body?: unknown): Promise<Record<string, unknown> | null> {
56
+ try {
57
+ return await api(method, path, body);
58
+ } catch (error) {
59
+ if (isCloudRefusal(error) && error.error === 'not_found') return null;
60
+ throw error;
61
+ }
62
+ }
63
+
64
+ /** A presigned URL's request, with its own timeout. */
65
+ async function storage(what: string, url: string, init: RequestInit): Promise<Response> {
66
+ try {
67
+ return await fetchImpl(url, { ...init, signal: AbortSignal.timeout(STORAGE_TIMEOUT_MS) });
68
+ } catch (error) {
69
+ throw new Error(`submissions: ${what}: storage unreachable (${error instanceof Error ? error.message : String(error)})`);
70
+ }
71
+ }
72
+
73
+ /** The confirm, tried once more when the cloud didn't answer (Decision 6). */
74
+ async function confirm(id: string): Promise<void> {
75
+ for (let attempt = 1; ; attempt++) {
76
+ try {
77
+ await api('POST', '/v1/submissions/confirm', { id });
78
+ return;
79
+ } catch (error) {
80
+ if (attempt < 2 && isCloudRefusal(error) && error.error === 'unreachable') continue;
81
+ throw error;
82
+ }
83
+ }
84
+ }
85
+
86
+ /** The row goes, best effort: the cloud's hourly clean-up takes it otherwise. */
87
+ const discard = (id: string) => api('POST', '/v1/submissions/delete', { id }).catch(() => {});
88
+
89
+ async function get(id: string): Promise<StoredSubmission | null> {
90
+ if (!isSubmissionId(id)) return null;
91
+ const answer = await orNull('GET', `/v1/submissions/get?${new URLSearchParams({ id })}`);
92
+ if (!answer) return null;
93
+ seen(answer);
94
+ const submission = submissionOf(answer.submission, 'get');
95
+ return submission.id === id ? submission : null;
96
+ }
97
+
98
+ return {
99
+ sendsEmail: true,
100
+ async create({ form, receivedAt, fields, files, email }) {
101
+ const hashes = (files ?? []).map((f) => createHash('sha256').update(f.data).digest('hex'));
102
+ for (let attempt = 1; ; attempt++) {
103
+ const id = newSubmissionId(receivedAt);
104
+ const stored: StoredFile[] = (files ?? []).map((f, i) => ({ name: f.name, size: f.data.byteLength, type: f.type, key: fileKey(id, i + 1, f.name) }));
105
+ const record: StoredSubmission = { v: 1, id, form, receivedAt, fields, ...(stored.length ? { files: stored } : {}), readAt: null };
106
+ const written = email?.(record) ?? { emailSkipped: 'No email was written for this submission.' };
107
+ let answer: Record<string, unknown>;
108
+ try {
109
+ answer = await api('POST', '/v1/submissions', {
110
+ id,
111
+ form,
112
+ receivedAt,
113
+ fields,
114
+ ...(stored.length ? { files: stored.map((f, i) => ({ name: f.name, type: f.type, size: f.size, key: f.key, sha256: hashes[i] })) } : {}),
115
+ ...written,
116
+ });
117
+ } catch (error) {
118
+ // The id was already the site's (Decision 5): a new one, and the email written again for it.
119
+ if (attempt < 2 && isCloudRefusal(error) && error.error === 'exists') continue;
120
+ throw error;
121
+ }
122
+ if (answer.state === 'complete') return record;
123
+ if (answer.state !== 'pending' || !Array.isArray(answer.uploads)) throw malformed('a new submission');
124
+ try {
125
+ for (const file of stored) {
126
+ const upload = (answer.uploads as Record<string, unknown>[]).find((u) => u && typeof u === 'object' && u.key === file.key);
127
+ const { url, method, headers } = upload ?? {};
128
+ if (typeof url !== 'string' || !/^https?:\/\//.test(url) || (method !== undefined && method !== 'PUT') || !headers || typeof headers !== 'object') {
129
+ throw malformed('a new submission');
130
+ }
131
+ // The headers as the cloud named them (the URL signs each); Content-Length and Host are fetch's own.
132
+ const sent = Object.fromEntries(
133
+ Object.entries(headers as Record<string, unknown>).filter(([name, v]) => typeof v === 'string' && !/^(content-length|host)$/i.test(name)),
134
+ ) as Record<string, string>;
135
+ const n = stored.indexOf(file);
136
+ const res = await storage(`upload ${n + 1}`, url, { method: 'PUT', headers: sent, body: files![n]!.data as BodyInit });
137
+ await res.body?.cancel().catch(() => {});
138
+ // 412: the signed If-None-Match: * found the key already there, so an earlier try put it.
139
+ if (!res.ok && res.status !== 412) throw new Error(`submissions: upload ${n + 1} of ${stored.length}: the storage answered ${res.status}`);
140
+ }
141
+ } catch (error) {
142
+ await discard(id);
143
+ throw error;
144
+ }
145
+ try {
146
+ await confirm(id);
147
+ } catch (error) {
148
+ // `409 incomplete`: a file isn't there at its size, so the row goes. Unreachable: the cloud's clean-up takes it.
149
+ if (isCloudRefusal(error) && error.error !== 'unreachable') await discard(id);
150
+ throw error;
151
+ }
152
+ return record;
153
+ }
154
+ },
155
+ get,
156
+ async getFile(id, n) {
157
+ if (!isSubmissionId(id) || !Number.isInteger(n) || n < 1) return null;
158
+ const answer = await orNull('POST', '/v1/submissions/file-url', { id, n });
159
+ if (!answer) return null;
160
+ const file = answer.file as StoredFile | undefined;
161
+ // Checked against the id asked for: a key that isn't this submission's is never read.
162
+ if (typeof answer.url !== 'string' || !file || typeof file !== 'object' || !isFileKey(id, file.key)) throw malformed('a file');
163
+ const res = await storage(`download ${n}`, answer.url, { method: 'GET' });
164
+ if (res.status === 404) {
165
+ await res.body?.cancel().catch(() => {});
166
+ return null;
167
+ }
168
+ if (!res.ok) {
169
+ await res.body?.cancel().catch(() => {});
170
+ throw new Error(`submissions: download ${n}: the storage answered ${res.status}`);
171
+ }
172
+ return { file: { name: String(file.name ?? ''), size: Number(file.size) || 0, type: String(file.type ?? ''), key: file.key }, body: new Uint8Array(await res.arrayBuffer()) };
173
+ },
174
+ async list(opts = {}) {
175
+ const limit = Math.max(1, Math.min(500, opts.limit ?? PAGE_SIZE));
176
+ const query = new URLSearchParams({ limit: String(limit) });
177
+ if (opts.before) {
178
+ // The cloud refuses a cursor not of the id's shape; v3's stores treat it as "after everything".
179
+ if (!isSubmissionId(opts.before)) return { items: [], older: null };
180
+ query.set('before', opts.before);
181
+ }
182
+ const answer = await api('GET', `/v1/submissions?${query}`);
183
+ if (!Array.isArray(answer.items)) throw malformed('the list');
184
+ seen(answer);
185
+ const older = answer.older;
186
+ return { items: answer.items.map((s) => submissionOf(s, 'the list')), older: typeof older === 'string' && isSubmissionId(older) ? older : null };
187
+ },
188
+ async update(id, patch) {
189
+ // The cloud records the email's outcome itself; only `readAt` is the inbox's to change.
190
+ if (!('readAt' in patch)) return get(id);
191
+ if (!isSubmissionId(id)) return null;
192
+ const answer = await orNull('POST', '/v1/submissions/update', { id, readAt: patch.readAt ?? null });
193
+ if (!answer) return null;
194
+ seen(answer);
195
+ return submissionOf(answer.submission, 'an update');
196
+ },
197
+ async delete(id) {
198
+ if (!isSubmissionId(id)) return false;
199
+ const answer = await api('POST', '/v1/submissions/delete', { id });
200
+ seen(answer);
201
+ return answer.deleted === true;
202
+ },
203
+ async unreadCount() {
204
+ if (unread !== null) return unread;
205
+ const answer = await api('GET', '/v1/submissions/unread');
206
+ const n = count(answer.unread);
207
+ if (n === null) throw malformed('the unread count');
208
+ unread = n;
209
+ return n;
210
+ },
211
+ };
212
+ }
@@ -15,13 +15,16 @@
15
15
  * dropped, one that doesn't look like an address skipped. An empty list is `skipped`.
16
16
  * - **Only a production server sends.** Elsewhere the email is written to the log and
17
17
  * recorded `skipped`, saying so.
18
+ * - **On CaraCMS Cloud** (caracms-cloud M4 Decision 24) the site writes the email the same
19
+ * way (`writeNotification`), from "<brand> – <form label>", to at most ten addresses,
20
+ * and the cloud store hands it over with the submission; the cloud sends it.
18
21
  */
19
22
  import { readFile } from 'node:fs/promises';
20
23
  import { join } from 'node:path';
21
24
  import { SITE_SETTINGS_FILE } from '../reserved.ts';
22
25
  import { sendEmail, sendsEmail } from '../email.ts';
23
26
  import { formatBytes, looksLikeEmail, shownFileName, submissionAddress, type ResolvedForm } from './rules.ts';
24
- import type { EmailOutcome, StoredSubmission } from './store.ts';
27
+ import type { EmailOutcome, StoredSubmission, WrittenEmail } from './store.ts';
25
28
 
26
29
  type Env = Record<string, string | undefined>;
27
30
 
@@ -116,6 +119,53 @@ export function buildNotification(input: { submission: StoredSubmission; form: R
116
119
  };
117
120
  }
118
121
 
122
+ /** The most addresses one form email goes to on CaraCMS Cloud (caracms-cloud M4 Decision 4). */
123
+ export const MAX_RECIPIENTS = 10;
124
+
125
+ /** The sender's display name on CaraCMS Cloud (M4 Decision 11): `<brand name> – <form label>`, "Urban Return – Contact". The cloud cleans it further. */
126
+ export function fromName(brandName: string, formLabel: string): string {
127
+ return [brandName.trim(), formLabel.trim()].filter(Boolean).join(' – ');
128
+ }
129
+
130
+ /** The sentence for a form with no one set to get its emails. */
131
+ const noRecipients = (label: string) => `No one is set to get the ${label} form's emails (Site Settings → Form emails).`;
132
+
133
+ /**
134
+ * The email a cloud site's server hands the cloud with a submission (M4 Decisions 11 and 24):
135
+ * written as `sendNotification` writes it, the recipients capped at the first ten (the rest
136
+ * logged), or the reason there is none. `settings` is the Site Settings read, or the error
137
+ * reading them threw. Never throws.
138
+ */
139
+ export function writeNotification(input: {
140
+ submission: StoredSubmission;
141
+ form: ResolvedForm | null;
142
+ settings: FormSettings | { error: unknown };
143
+ siteUrl: string | null;
144
+ timeZone: string;
145
+ log?: (line: string) => void;
146
+ }): WrittenEmail {
147
+ const log = input.log ?? ((line: string) => console.log(line));
148
+ if ('error' in input.settings) {
149
+ const { error } = input.settings;
150
+ return { emailFailed: `Could not read Site Settings: ${error instanceof Error ? error.message : String(error)}` };
151
+ }
152
+ const settings = input.settings;
153
+ const label = input.form?.label ?? input.submission.form;
154
+ const all = sendableAddresses(settings.recipients[input.submission.form] ?? []);
155
+ if (all.length === 0) return { emailSkipped: noRecipients(label) };
156
+ const to = all.slice(0, MAX_RECIPIENTS);
157
+ if (all.length > to.length) {
158
+ const left = all.length - to.length;
159
+ log(`[caracms] forms: the ${label} form has ${all.length} recipients; its email goes to the first ${MAX_RECIPIENTS}, and ${left} ${left === 1 ? 'was' : 'were'} left off (Site Settings → Form emails).`);
160
+ }
161
+ try {
162
+ const built = buildNotification({ submission: input.submission, form: input.form, brandName: settings.brandName, siteUrl: input.siteUrl, timeZone: input.timeZone });
163
+ return { email: { to, subject: built.subject, text: built.text, ...(built.replyTo ? { replyTo: built.replyTo } : {}), fromName: fromName(settings.brandName, label) } };
164
+ } catch (error) {
165
+ return { emailFailed: `Could not write the email: ${error instanceof Error ? error.message : String(error)}` };
166
+ }
167
+ }
168
+
119
169
  /** Send a submission's email and say what happened (the outcome recorded on it). Never throws. */
120
170
  export async function sendNotification(input: {
121
171
  submission: StoredSubmission;
@@ -133,7 +183,7 @@ export async function sendNotification(input: {
133
183
  const log = input.log ?? ((line: string) => console.log(line));
134
184
  const to = sendableAddresses(input.settings.recipients[input.submission.form] ?? []);
135
185
  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).` };
186
+ if (to.length === 0) return { status: 'skipped', at, to, error: noRecipients(label) };
137
187
  let built: ReturnType<typeof buildNotification>;
138
188
  try {
139
189
  built = buildNotification({ submission: input.submission, form: input.form, brandName: input.settings.brandName, siteUrl: input.siteUrl, timeZone: input.timeZone });
@@ -29,7 +29,9 @@
29
29
  * 403; Cloudflare unreachable fails open, logged. Unset: accepted, with a warning.
30
30
  * 8. **Store** (store.ts), then **email** (email.ts), then **record** the email's outcome
31
31
  * on the submission, then reply. A storage failure is a 502; an email failure never
32
- * fails the visitor.
32
+ * fails the visitor. **On CaraCMS Cloud** (caracms-cloud M4 Decision 24) the store
33
+ * takes the written email with the submission and the cloud sends and records it, so
34
+ * nothing is sent or recorded here; a cloud refusal is the same 502, its sentence logged.
33
35
  *
34
36
  * Replies: `Accept: application/json` gets `{ ok: true }` or `{ error }`; a no-JS post
35
37
  * gets a 303 to `<return>?sent=1`, or the error as plain text. Nothing about the visitor
@@ -40,7 +42,7 @@ import { publicOrigin } from '../live/auth/index.ts';
40
42
  import { clientIp, NO_GATE_MESSAGE } from '../live/auth/request.ts';
41
43
  import { readCapped } from '../editor/server/media/upload.ts';
42
44
  import { isProductionBuild } from '../media.ts';
43
- import { readFormSettings, sendNotification, type FormSettings } from './email.ts';
45
+ import { readFormSettings, sendNotification, writeNotification, type FormSettings } from './email.ts';
44
46
  import { formLimiter, type FormRateLimiter } from './rate-limit.ts';
45
47
  import { cleanReturn, FILE_KINDS, FILE_TYPES, formsAcceptFiles, HONEYPOT, looksLikeEmail, MAX_FILE_BYTES, MAX_FILES, type ResolvedForm } from './rules.ts';
46
48
  import { cleanFileName, fileExtension, readSubmissionsConfig, submissionStore, type NewFile, type SubmissionStore } from './store.ts';
@@ -313,21 +315,31 @@ export async function handleForm(request: Request, ctx: FormContext): Promise<Re
313
315
  }
314
316
  store = chosen;
315
317
  }
318
+ const siteUrl = publicOrigin(ctx.astroSite, env);
319
+ const readSettings = ctx.readSettings ?? (() => readFormSettings(ctx.site.root));
320
+ // On CaraCMS Cloud (caracms-cloud M4 Decision 24) the email goes with the submission: written here, sent by the cloud.
321
+ const settingsRead = store.sendsEmail ? await readSettings().catch((error: unknown) => ({ error })) : null;
316
322
  let submission;
317
323
  try {
318
324
  const files: NewFile[] = await Promise.all(vetted.files.map(async (f) => ({ name: f.name, type: f.type, data: new Uint8Array(await f.blob.arrayBuffer()) })));
319
- submission = await store.create({ form: form.id, receivedAt: now().toISOString(), fields: vetted.fields, ...(files.length ? { files } : {}) });
325
+ submission = await store.create({
326
+ form: form.id,
327
+ receivedAt: now().toISOString(),
328
+ fields: vetted.fields,
329
+ ...(files.length ? { files } : {}),
330
+ ...(settingsRead ? { email: (stored) => writeNotification({ submission: stored, form, settings: settingsRead, siteUrl, timeZone: ctx.site.timeZone, log }) } : {}),
331
+ });
320
332
  } catch (error) {
321
333
  log(`[caracms] forms: couldn't store a ${form.id} submission: ${error instanceof Error ? error.message : String(error)}`);
322
334
  return reply(502, { error: STORE_FAILED_MESSAGE });
323
335
  }
324
336
  await limiter.hit(ip);
337
+ if (store.sendsEmail) return reply(200, { ok: true });
325
338
 
326
339
  // EMAIL, then RECORD its outcome. Neither changes the visitor's reply.
327
340
  let email;
328
341
  try {
329
- const settings = await (ctx.readSettings ?? (() => readFormSettings(ctx.site.root)))();
330
- const siteUrl = publicOrigin(ctx.astroSite, env);
342
+ const settings = await readSettings();
331
343
  email = await sendNotification({ submission, form, settings, siteUrl, timeZone: ctx.site.timeZone, env, fetch: ctx.fetch, now, log });
332
344
  } catch (error) {
333
345
  email = { status: 'failed' as const, at: now().toISOString(), to: [], error: `Could not read Site Settings: ${error instanceof Error ? error.message : String(error)}` };