@voidbase-cloud/voidbase 0.1.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/.env.example +9 -0
- package/CHANGELOG.md +19 -0
- package/COMPAT.md +43 -0
- package/LICENSE +21 -0
- package/NOTICE +8 -0
- package/README.md +124 -0
- package/bin/voidbase.ts +158 -0
- package/crons/every-minute.ts +13 -0
- package/db/migrations/20260905175935_large_swarm.sql +87 -0
- package/db/migrations/20260905185720_wild_sunspot.sql +16 -0
- package/db/migrations/20260905190723_solid_toro.sql +1 -0
- package/db/migrations/20260905213340_remarkable_union_jack.sql +11 -0
- package/db/migrations/meta/20260905175935_snapshot.json +599 -0
- package/db/migrations/meta/20260905185720_snapshot.json +703 -0
- package/db/migrations/meta/20260905190723_snapshot.json +710 -0
- package/db/migrations/meta/20260905213340_snapshot.json +781 -0
- package/db/migrations/meta/_journal.json +34 -0
- package/db/schema.ts +130 -0
- package/docs/deploy.md +153 -0
- package/docs/differences.md +88 -0
- package/docs/hooks.md +84 -0
- package/docs/migrating.md +29 -0
- package/docs/perf.md +53 -0
- package/docs/platform.md +208 -0
- package/docs/releasing.md +38 -0
- package/env.ts +23 -0
- package/hooks-plugin.ts +237 -0
- package/package.json +134 -0
- package/queues/jobs.ts +13 -0
- package/routes/api/[...path].ts +19 -0
- package/scripts/bench-realtime.ts +46 -0
- package/scripts/bench.ts +39 -0
- package/scripts/ci-suites.sh +27 -0
- package/scripts/dev.sh +29 -0
- package/scripts/export.ts +70 -0
- package/scripts/seed-app-user.sh +14 -0
- package/scripts/seed-d1.ts +17 -0
- package/scripts/seed-reference.sh +29 -0
- package/scripts/starter.sh +22 -0
- package/scripts/sync-app.ts +22 -0
- package/scripts/sync-panel.ts +66 -0
- package/src/cloud/rest.ts +297 -0
- package/src/node/assets.ts +22 -0
- package/src/node/bundle.ts +88 -0
- package/src/node/cloud-init.ts +51 -0
- package/src/node/d1.ts +44 -0
- package/src/node/deploy-cf.ts +179 -0
- package/src/node/index.ts +5 -0
- package/src/node/panel.ts +21 -0
- package/src/node/serve.ts +125 -0
- package/src/node/storage.ts +51 -0
- package/src/platform/node/env.ts +4 -0
- package/src/platform/node/hooks.ts +19 -0
- package/src/platform/node/log.ts +7 -0
- package/src/platform/node/migrations.ts +5 -0
- package/src/platform/node/photon.ts +1 -0
- package/src/platform/node/sockets.ts +22 -0
- package/src/platform/node/sse.ts +23 -0
- package/src/platform/workers/env.ts +3 -0
- package/src/platform/workers/hooks.ts +2 -0
- package/src/platform/workers/log.ts +1 -0
- package/src/platform/workers/migrations.ts +1 -0
- package/src/platform/workers/photon.ts +1 -0
- package/src/platform/workers/sockets.ts +3 -0
- package/src/platform/workers/sse.ts +1 -0
- package/src/server/api.ts +27 -0
- package/src/server/app.ts +582 -0
- package/src/server/auth-extra.ts +113 -0
- package/src/server/auth-flows.ts +186 -0
- package/src/server/auth-response.ts +111 -0
- package/src/server/auth.ts +187 -0
- package/src/server/backups.ts +234 -0
- package/src/server/batch.ts +123 -0
- package/src/server/bootstrap.ts +71 -0
- package/src/server/collections/auth-option-shape.json +71 -0
- package/src/server/collections/ddl.ts +127 -0
- package/src/server/collections/fields.ts +120 -0
- package/src/server/collections/model.ts +185 -0
- package/src/server/collections/oauth2-providers.json +1 -0
- package/src/server/collections/scaffolds.json +210 -0
- package/src/server/collections/service.ts +392 -0
- package/src/server/collections/system.json +605 -0
- package/src/server/collections/system.ts +19 -0
- package/src/server/collections/validate.ts +239 -0
- package/src/server/crc32.ts +13 -0
- package/src/server/crons.ts +100 -0
- package/src/server/crypto.ts +26 -0
- package/src/server/db.ts +37 -0
- package/src/server/errors.ts +53 -0
- package/src/server/files-api.ts +52 -0
- package/src/server/filter/compile.ts +420 -0
- package/src/server/filter/lexer.ts +107 -0
- package/src/server/filter/parser.ts +49 -0
- package/src/server/hardening.ts +136 -0
- package/src/server/hooks/index.ts +147 -0
- package/src/server/hooks/migrations.ts +58 -0
- package/src/server/hooks/node-async-hooks.d.ts +7 -0
- package/src/server/hooks/record.ts +152 -0
- package/src/server/hooks/runtime.ts +344 -0
- package/src/server/hooks/virtual-migrations.d.ts +4 -0
- package/src/server/hooks/virtual.d.ts +7 -0
- package/src/server/hub.ts +91 -0
- package/src/server/ids.ts +22 -0
- package/src/server/jobs.ts +84 -0
- package/src/server/jwt.ts +61 -0
- package/src/server/logs.ts +144 -0
- package/src/server/mail/index.ts +99 -0
- package/src/server/mail/message.ts +43 -0
- package/src/server/mail/smtp.ts +82 -0
- package/src/server/mail/templates.ts +168 -0
- package/src/server/oauth2/index.ts +198 -0
- package/src/server/oauth2/providers.ts +153 -0
- package/src/server/password.ts +17 -0
- package/src/server/realtime/hub-client.ts +50 -0
- package/src/server/realtime/index.ts +239 -0
- package/src/server/records/expand.ts +129 -0
- package/src/server/records/files.ts +69 -0
- package/src/server/records/json.ts +23 -0
- package/src/server/records/picker.ts +80 -0
- package/src/server/records/service.ts +598 -0
- package/src/server/records/thumbs.ts +148 -0
- package/src/server/records/values.ts +295 -0
- package/src/server/settings-api.ts +104 -0
- package/src/server/settings.ts +215 -0
- package/src/server/sql.ts +61 -0
- package/src/server/static.ts +17 -0
- package/src/server/storage/s3.ts +118 -0
- package/src/server/types.ts +25 -0
- package/src/server/webauthn.ts +168 -0
- package/tsconfig.json +36 -0
- package/tsconfig.node.json +27 -0
- package/types/pb_data.d.ts +24438 -0
- package/vite.config.ts +10 -0
- package/void.json +12 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// Logs (apis/logs.go + apis/middlewares.go logRequest): every API request writes a _logs row after the response,
|
|
2
|
+
// app-level messages go through appLog, and the superuser API lists, filters, views, aggregates and truncates them.
|
|
3
|
+
import type { Context, Hono, MiddlewareHandler } from "hono";
|
|
4
|
+
import { env as voidEnv, defaultLogMinLevel } from "#platform/env";
|
|
5
|
+
// the platform default for request logs, overridable per deployment (PocketBase levels: -4 debug, 0 info, 4 warn, 8 error)
|
|
6
|
+
const envLogMinLevel = () => { const v = (voidEnv as Record<string, unknown>).VOIDBASE_LOG_MIN_LEVEL; const n = v === undefined || v === "" ? NaN : Number(v); return Number.isFinite(n) ? n : defaultLogMinLevel; };
|
|
7
|
+
import type { Collection } from "./collections/model";
|
|
8
|
+
import { all, one, run, stmt } from "./db";
|
|
9
|
+
import { ApiError, badRequest, notFound } from "./errors";
|
|
10
|
+
import { compileFilter, compileSort, FilterError } from "./filter/compile";
|
|
11
|
+
import { FilterSyntaxError } from "./filter/lexer";
|
|
12
|
+
import { nowString, randomId } from "./ids";
|
|
13
|
+
import { loadSettings } from "./settings";
|
|
14
|
+
import type { AppEnv } from "./types";
|
|
15
|
+
import { requireSuperuser } from "./auth";
|
|
16
|
+
|
|
17
|
+
export const LEVEL = { debug: -4, info: 0, warn: 4, error: 8 } as const;
|
|
18
|
+
const cut = (s: string, max: number) => (s.length > max ? s.slice(0, max) + "..." : s);
|
|
19
|
+
const sorted = (o: Record<string, unknown>) => Object.fromEntries(Object.entries(o).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
|
|
20
|
+
|
|
21
|
+
export async function appLog(db: D1Database, level: number, message: string, data: Record<string, unknown> = {}): Promise<void> {
|
|
22
|
+
const settings = await loadSettings(db);
|
|
23
|
+
if (settings.logs.maxDays === 0 || level < settings.logs.minLevel) return;
|
|
24
|
+
await run(db, "INSERT INTO `_logs` (id, created, data, message, level) VALUES (?, ?, ?, ?, ?)", [randomId(), nowString(), JSON.stringify(sorted(data)), message, level]);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const SKIP_PREFIXES = ["/api/logs", "/api/realtime"];
|
|
28
|
+
|
|
29
|
+
// Records the request after the response is written (PocketBase logs in a goroutine; here waitUntil).
|
|
30
|
+
export function requestLogger(): MiddlewareHandler<AppEnv> {
|
|
31
|
+
return async (c, next) => {
|
|
32
|
+
const started = Date.now();
|
|
33
|
+
let thrown: unknown = null;
|
|
34
|
+
try { await next(); } catch (err) { thrown = err; throw err; }
|
|
35
|
+
finally {
|
|
36
|
+
const path = new URL(c.req.url).pathname;
|
|
37
|
+
const skip = SKIP_PREFIXES.some((p) => path.startsWith(p)) && !thrown;
|
|
38
|
+
if (!skip) c.executionCtx.waitUntil(logRequest(c, started, thrown).catch((err) => console.error("voidbase: request log failed", err)));
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function logRequest(c: Context<AppEnv>, started: number, err: unknown): Promise<void> {
|
|
44
|
+
const settings = await loadSettings(c.env.DB);
|
|
45
|
+
if (settings.logs.maxDays === 0) return;
|
|
46
|
+
const url = new URL(c.req.url);
|
|
47
|
+
const requestUri = cut(url.pathname + url.search, 3000);
|
|
48
|
+
const method = cut(c.req.method.toUpperCase(), 50);
|
|
49
|
+
const data: Record<string, unknown> = { type: "request", execTime: Date.now() - started };
|
|
50
|
+
let status = c.res?.status ?? 0;
|
|
51
|
+
let failed = !!err;
|
|
52
|
+
if (err) {
|
|
53
|
+
if (err instanceof ApiError) { if (!status || status === 500) status = err.status; data.error = err.message; data.details = err.data ?? {}; }
|
|
54
|
+
else data.error = err instanceof Error ? err.message : String(err);
|
|
55
|
+
} else if (status >= 400 && c.res) {
|
|
56
|
+
// handlers (hook routes among them) may answer an API error directly instead of throwing it
|
|
57
|
+
failed = true;
|
|
58
|
+
try { const body = (await c.res.clone().json()) as { message?: string; data?: unknown }; data.error = body.message ?? ""; data.details = body.data ?? {}; } catch { data.error = `${status}`; }
|
|
59
|
+
}
|
|
60
|
+
Object.assign(data, { url: requestUri, method, status, referer: cut(c.req.header("Referer") ?? "", 2000), userAgent: cut(c.req.header("User-Agent") ?? "", 2000) });
|
|
61
|
+
const auth = c.get("auth");
|
|
62
|
+
data.auth = auth ? auth.collection.name : "";
|
|
63
|
+
if (auth && settings.logs.logAuthId) data.authId = String(auth.row.id);
|
|
64
|
+
if (settings.logs.logIP) {
|
|
65
|
+
data.userIP = c.req.header("CF-Connecting-IP") ?? c.req.header("X-Forwarded-For")?.split(",")[0]?.trim() ?? "127.0.0.1";
|
|
66
|
+
data.remoteIP = c.req.header("CF-Connecting-IP") ?? "127.0.0.1";
|
|
67
|
+
}
|
|
68
|
+
const level = failed ? LEVEL.error : LEVEL.info;
|
|
69
|
+
// Analytics Engine (binding LOGS_ANALYTICS, declared by the deploy): one data point per request, whatever the level
|
|
70
|
+
c.env.LOGS_ANALYTICS?.writeDataPoint({ blobs: [method, cut(url.pathname, 256), String(status), String(data.auth ?? ""), cut(String(data.error ?? ""), 256), failed ? "error" : "info"], doubles: [Number(data.execTime), status], indexes: [cut(url.pathname, 96)] });
|
|
71
|
+
if (level < Math.max(settings.logs.minLevel, envLogMinLevel())) return;
|
|
72
|
+
let message = method + " ";
|
|
73
|
+
try { message += decodeURIComponent(requestUri); } catch { message += requestUri; }
|
|
74
|
+
await run(c.env.DB, "INSERT INTO `_logs` (id, created, data, message, level) VALUES (?, ?, ?, ?, ?)", [randomId(), nowString(), JSON.stringify(sorted(data)), message, level]);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function deleteOldLogs(db: D1Database, maxDays: number): Promise<void> {
|
|
78
|
+
if (maxDays <= 0) return;
|
|
79
|
+
const before = nowString(new Date(Date.now() - maxDays * 86400_000));
|
|
80
|
+
await run(db, "DELETE FROM `_logs` WHERE created <= ?", [before]);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// the filter/sort compiler works on a collection shape; logs get a pseudo collection with their columns
|
|
84
|
+
const LOGS_COLLECTION: Collection = {
|
|
85
|
+
id: "_logs", name: "_logs", type: "base", system: true, listRule: null, viewRule: null, createRule: null, updateRule: null, deleteRule: null, indexes: [], options: {}, created: "", updated: "",
|
|
86
|
+
fields: [
|
|
87
|
+
{ id: "text_id", name: "id", type: "text", system: true, required: true, hidden: false, presentable: false },
|
|
88
|
+
{ id: "date_created", name: "created", type: "date", system: true, required: false, hidden: false, presentable: false },
|
|
89
|
+
{ id: "text_message", name: "message", type: "text", system: true, required: false, hidden: false, presentable: false },
|
|
90
|
+
{ id: "number_level", name: "level", type: "number", system: true, required: false, hidden: false, presentable: false },
|
|
91
|
+
{ id: "json_data", name: "data", type: "json", system: true, required: false, hidden: false, presentable: false },
|
|
92
|
+
] as never,
|
|
93
|
+
} as Collection;
|
|
94
|
+
const toJSON = (r: Record<string, unknown>) => ({ id: r.id, created: r.created, data: typeof r.data === "string" ? JSON.parse(r.data) : r.data, message: r.message, level: r.level });
|
|
95
|
+
|
|
96
|
+
export function mountLogsApi(app: Hono<AppEnv>) {
|
|
97
|
+
const sub = (c: Context<AppEnv>) => requireSuperuser(c);
|
|
98
|
+
app.get("/api/logs", async (c) => {
|
|
99
|
+
sub(c);
|
|
100
|
+
const page = Math.max(1, Number(c.req.query("page") ?? 1) || 1);
|
|
101
|
+
const perPage = Math.min(1000, Math.max(1, Number(c.req.query("perPage") ?? 30) || 30));
|
|
102
|
+
let where = "1=1", params: unknown[] = [];
|
|
103
|
+
try {
|
|
104
|
+
const filter = c.req.query("filter") ?? "";
|
|
105
|
+
if (filter.trim()) { const f = compileFilter(filter, { base: LOGS_COLLECTION, baseTable: "_logs", collections: new Map(), request: { auth: null, method: "GET", query: {}, headers: {}, body: {}, context: "default" }, allowHiddenFields: true }); where = f.where; params = f.params; }
|
|
106
|
+
} catch (err) { if (err instanceof FilterError || err instanceof FilterSyntaxError) throw badRequest("Invalid filter format."); throw err; }
|
|
107
|
+
const sortParam = (c.req.query("sort") ?? "").trim();
|
|
108
|
+
let order = "`_logs`.rowid DESC";
|
|
109
|
+
if (sortParam) {
|
|
110
|
+
order = sortParam.split(",").map((s) => s.trim()).filter(Boolean).map((s) => {
|
|
111
|
+
const desc = s.startsWith("-"); const name = s.replace(/^[+-]/, "");
|
|
112
|
+
if (name === "rowid" || name === "@rowid") return `\`_logs\`.rowid ${desc ? "DESC" : "ASC"}`; // the panel sorts by -@rowid
|
|
113
|
+
if (name.startsWith("data.")) return `json_extract(\`_logs\`.data, '$.${name.slice(5).replace(/'/g, "")}') ${desc ? "DESC" : "ASC"}`;
|
|
114
|
+
if (!["id", "created", "message", "level"].includes(name)) throw badRequest("Invalid sort format.");
|
|
115
|
+
return `\`_logs\`.\`${name}\` ${desc ? "DESC" : "ASC"}`;
|
|
116
|
+
}).join(", ");
|
|
117
|
+
}
|
|
118
|
+
void compileSort;
|
|
119
|
+
const total = (await one<{ n: number }>(c.env.DB, `SELECT COUNT(*) AS n FROM \`_logs\` WHERE ${where}`, params))?.n ?? 0;
|
|
120
|
+
const rows = await all<Record<string, unknown>>(c.env.DB, `SELECT * FROM \`_logs\` WHERE ${where} ORDER BY ${order} LIMIT ? OFFSET ?`, [...params, perPage, (page - 1) * perPage]);
|
|
121
|
+
return c.json({ page, perPage, totalItems: total, totalPages: Math.ceil(total / perPage), items: rows.map(toJSON) });
|
|
122
|
+
});
|
|
123
|
+
app.get("/api/logs/stats", async (c) => {
|
|
124
|
+
sub(c);
|
|
125
|
+
let where = "1=1", params: unknown[] = [];
|
|
126
|
+
try {
|
|
127
|
+
const filter = c.req.query("filter") ?? "";
|
|
128
|
+
if (filter.trim()) { const f = compileFilter(filter, { base: LOGS_COLLECTION, baseTable: "_logs", collections: new Map(), request: { auth: null, method: "GET", query: {}, headers: {}, body: {}, context: "default" }, allowHiddenFields: true }); where = f.where; params = f.params; }
|
|
129
|
+
} catch (err) { if (err instanceof FilterError || err instanceof FilterSyntaxError) throw badRequest("Invalid filter format."); throw err; }
|
|
130
|
+
const rows = await all<{ total: number; date: string }>(c.env.DB, `SELECT COUNT(id) AS total, strftime('%Y-%m-%d %H:00:00', created) AS date FROM \`_logs\` WHERE ${where} GROUP BY date`, params);
|
|
131
|
+
return c.json(rows.map((r) => ({ date: r.date.replace(" ", " ") + ".000Z", total: r.total })));
|
|
132
|
+
});
|
|
133
|
+
app.get("/api/logs/:id", async (c) => {
|
|
134
|
+
sub(c);
|
|
135
|
+
const row = await one<Record<string, unknown>>(c.env.DB, "SELECT * FROM `_logs` WHERE id = ? LIMIT 1", [c.req.param("id") ?? ""]);
|
|
136
|
+
if (!row) throw notFound();
|
|
137
|
+
return c.json(toJSON(row));
|
|
138
|
+
});
|
|
139
|
+
app.delete("/api/logs", async (c) => {
|
|
140
|
+
sub(c);
|
|
141
|
+
await c.env.DB.batch([stmt(c.env.DB, "DELETE FROM `_logs`", [])]);
|
|
142
|
+
return c.body(null, 204);
|
|
143
|
+
});
|
|
144
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Outbound mail: PocketBase's record emails (verification, password reset, email change, OTP, auth alert) and the
|
|
2
|
+
// generic mailer used by $app.newMailClient(). Delivery goes through the SMTP settings when enabled; otherwise the
|
|
3
|
+
// message is logged (PocketBase would hand it to sendmail, which a Worker does not have). System emails leave through
|
|
4
|
+
// the jobs queue when the deploy has one (src/server/jobs.ts), so the request never waits on SMTP.
|
|
5
|
+
import type { Collection } from "../collections/model";
|
|
6
|
+
import { signJWT } from "../jwt";
|
|
7
|
+
import { env as voidEnv } from "#platform/env";
|
|
8
|
+
import { loadSettings } from "../settings";
|
|
9
|
+
import type { Row } from "../types";
|
|
10
|
+
import { trigger } from "../hooks/runtime";
|
|
11
|
+
import { HookRecord } from "../hooks/record";
|
|
12
|
+
import { buildMime, htmlToText, type MailMessage } from "./message";
|
|
13
|
+
import { sendSMTP } from "./smtp";
|
|
14
|
+
import { dispatch, registerJobHandler } from "../jobs";
|
|
15
|
+
import type { Bindings } from "../types";
|
|
16
|
+
import { collectionTemplate, PLACEHOLDER, resolveEmailTemplate, type EmailTemplate } from "./templates";
|
|
17
|
+
|
|
18
|
+
export type { MailMessage } from "./message";
|
|
19
|
+
|
|
20
|
+
// Alternative transport: an HTTP mail API (Resend-compatible request shape). Configured by environment, not by the
|
|
21
|
+
// PocketBase settings, so the settings JSON stays wire-identical.
|
|
22
|
+
const httpMail = { get url() { return String((voidEnv as Record<string, unknown>).VOIDBASE_MAIL_HTTP_URL ?? "").trim(); }, get key() { return String((voidEnv as Record<string, unknown>).VOIDBASE_MAIL_HTTP_KEY ?? ""); } };
|
|
23
|
+
const addr = (a: { name?: string; address: string }) => (a.name ? `${a.name} <${a.address}>` : a.address);
|
|
24
|
+
async function sendHTTP(m: MailMessage, text: string): Promise<void> {
|
|
25
|
+
const payload: Record<string, unknown> = { from: addr(m.from), to: m.to.map(addr), subject: m.subject, html: m.html, text };
|
|
26
|
+
if (m.cc?.length) payload.cc = m.cc.map(addr);
|
|
27
|
+
if (m.bcc?.length) payload.bcc = m.bcc.map(addr);
|
|
28
|
+
if (m.headers && Object.keys(m.headers).length) payload.headers = m.headers;
|
|
29
|
+
const res = await fetch(httpMail.url, { method: "POST", headers: { "content-type": "application/json", ...(httpMail.key ? { authorization: `Bearer ${httpMail.key}` } : {}) }, body: JSON.stringify(payload) });
|
|
30
|
+
if (!res.ok) throw new Error(`mail provider ${new URL(httpMail.url).host} answered ${res.status}: ${(await res.text()).slice(0, 200)}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface SendOptions { inline?: boolean } // inline: deliver now and surface transport errors to the caller
|
|
34
|
+
|
|
35
|
+
// The transport step: an HTTP mail API when configured, else the SMTP settings, else a log line. On Cloudflare it
|
|
36
|
+
// runs from the jobs queue (retries, no request time spent on SMTP); inline for the panel's test email and hooks.
|
|
37
|
+
export async function deliverMail(env: Bindings, m: MailMessage, text: string): Promise<void> {
|
|
38
|
+
if (httpMail.url) { await sendHTTP(m, text); return; }
|
|
39
|
+
const settings = await loadSettings(env.DB);
|
|
40
|
+
if (!settings.smtp.enabled) {
|
|
41
|
+
console.log(`voidbase: mail not delivered (SMTP disabled): "${m.subject}" -> ${m.to.map((t) => t.address).join(", ")}`);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const mime = buildMime(m, text);
|
|
45
|
+
await sendSMTP(settings.smtp, { from: mime.from, to: mime.rcpts, data: mime.data });
|
|
46
|
+
}
|
|
47
|
+
registerJobHandler("mail", (env, job) => deliverMail(env, job.message, job.text));
|
|
48
|
+
|
|
49
|
+
export async function sendMail(db: D1Database, message: MailMessage, opts: SendOptions = {}): Promise<void> {
|
|
50
|
+
const ev = { app: undefined as unknown, message, mailer: null as unknown, next: async () => undefined as unknown };
|
|
51
|
+
// hooks (onMailerSend) run here, in the request, on the message that is then queued or delivered
|
|
52
|
+
await trigger("onMailerSend", ev, null, async () => {
|
|
53
|
+
const m = ev.message;
|
|
54
|
+
const text = m.text || htmlToText(m.html);
|
|
55
|
+
await dispatch({ type: "mail", message: m, text }, { env: { DB: db } as Bindings, inline: opts.inline });
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const tokenOption = (c: Collection, key: string) => ((c.options as Record<string, unknown>)[key] ?? {}) as { secret?: string; duration?: number };
|
|
60
|
+
|
|
61
|
+
async function recordToken(c: Collection, row: Row, type: "verification" | "passwordReset" | "emailChange", extra: Record<string, unknown> = {}): Promise<string> {
|
|
62
|
+
const optKey = type === "verification" ? "verificationToken" : type === "passwordReset" ? "passwordResetToken" : "emailChangeToken";
|
|
63
|
+
const opt = tokenOption(c, optKey);
|
|
64
|
+
const key = String(row.tokenKey ?? "") + String(opt.secret ?? "");
|
|
65
|
+
if (!key) throw new Error("missing or invalid signing key");
|
|
66
|
+
return signJWT({ type, id: String(row.id), collectionId: c.id, email: String(row.email ?? ""), ...extra }, key, Number(opt.duration ?? 0) || 604800);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function recordMail(db: D1Database, c: Collection, row: Row, template: EmailTemplate, placeholders: Record<string, string>, to: string, hook: string, meta: Record<string, unknown>, opts: SendOptions = {}): Promise<MailMessage> {
|
|
70
|
+
const settings = await loadSettings(db);
|
|
71
|
+
const { subject, body } = resolveEmailTemplate(settings.meta, c, row, template, placeholders);
|
|
72
|
+
const message: MailMessage = { from: { name: settings.meta.senderName, address: settings.meta.senderAddress }, to: [{ address: to }], subject, html: body };
|
|
73
|
+
const ev = { app: undefined as unknown, message, record: HookRecord.fromRow(c, row), meta, mailer: null as unknown, next: async () => undefined as unknown };
|
|
74
|
+
await trigger(hook, ev, c.name, () => sendMail(db, ev.message, opts));
|
|
75
|
+
return ev.message;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function sendRecordVerification(db: D1Database, c: Collection, row: Row, opts: SendOptions = {}): Promise<{ token: string; message: MailMessage }> {
|
|
79
|
+
const token = await recordToken(c, row, "verification");
|
|
80
|
+
const message = await recordMail(db, c, row, collectionTemplate(c, "verificationTemplate"), { [PLACEHOLDER.token]: token }, String(row.email), "onMailerRecordVerificationSend", { token }, opts);
|
|
81
|
+
return { token, message };
|
|
82
|
+
}
|
|
83
|
+
export async function sendRecordPasswordReset(db: D1Database, c: Collection, row: Row, opts: SendOptions = {}): Promise<{ token: string; message: MailMessage }> {
|
|
84
|
+
const token = await recordToken(c, row, "passwordReset");
|
|
85
|
+
const message = await recordMail(db, c, row, collectionTemplate(c, "resetPasswordTemplate"), { [PLACEHOLDER.token]: token }, String(row.email), "onMailerRecordPasswordResetSend", { token }, opts);
|
|
86
|
+
return { token, message };
|
|
87
|
+
}
|
|
88
|
+
export async function sendRecordChangeEmail(db: D1Database, c: Collection, row: Row, newEmail: string, opts: SendOptions = {}): Promise<{ token: string; message: MailMessage }> {
|
|
89
|
+
const token = await recordToken(c, row, "emailChange", { newEmail });
|
|
90
|
+
const message = await recordMail(db, c, row, collectionTemplate(c, "confirmEmailChangeTemplate"), { [PLACEHOLDER.token]: token }, newEmail, "onMailerRecordEmailChangeSend", { token, newEmail }, opts);
|
|
91
|
+
return { token, message };
|
|
92
|
+
}
|
|
93
|
+
export async function sendRecordOTP(db: D1Database, c: Collection, row: Row, otpId: string, pass: string, opts: SendOptions = {}): Promise<MailMessage> {
|
|
94
|
+
return recordMail(db, c, row, collectionTemplate(c, "otp"), { [PLACEHOLDER.otpId]: otpId, [PLACEHOLDER.otp]: pass }, String(row.email), "onMailerRecordOTPSend", { otpId, password: pass }, opts);
|
|
95
|
+
}
|
|
96
|
+
export async function sendRecordAuthAlert(db: D1Database, c: Collection, row: Row, info: string, opts: SendOptions = {}): Promise<MailMessage> {
|
|
97
|
+
const escaped = info.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
98
|
+
return recordMail(db, c, row, collectionTemplate(c, "authAlert"), { [PLACEHOLDER.alertInfo]: escaped }, String(row.email), "onMailerRecordAuthAlertSend", { info: escaped }, opts);
|
|
99
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// RFC 5322 message construction (what mailyak does for PocketBase): multipart/alternative with a text and an
|
|
2
|
+
// HTML part, encoded words for non-ASCII headers, Message-ID from the sender's domain when none is given.
|
|
3
|
+
export interface Address { address: string; name?: string }
|
|
4
|
+
export interface MailMessage { from: Address; to: Address[]; cc?: Address[]; bcc?: Address[]; subject: string; html: string; text?: string; headers?: Record<string, string> }
|
|
5
|
+
|
|
6
|
+
const isAscii = (s: string) => /^[\x20-\x7e]*$/.test(s);
|
|
7
|
+
const encodeWord = (s: string) => (isAscii(s) ? s : `=?UTF-8?B?${btoa(String.fromCharCode(...new TextEncoder().encode(s)))}?=`);
|
|
8
|
+
// net/mail Address.String(): the display name is quoted only when it contains something other than atext and spaces
|
|
9
|
+
const needsQuote = (s: string) => !/^[A-Za-z0-9!#$%&'*+/=?^_`{|}~ .-]*$/.test(s);
|
|
10
|
+
const quote = (s: string) => `"${s.replace(/["\\]/g, "\\$&")}"`;
|
|
11
|
+
export const formatAddress = (a: Address) => (a.name ? `${!isAscii(a.name) ? encodeWord(a.name) : needsQuote(a.name) ? quote(a.name) : a.name} <${a.address}>` : a.address);
|
|
12
|
+
const base64Lines = (s: string) => btoa(String.fromCharCode(...new TextEncoder().encode(s))).replace(/(.{76})/g, "$1\r\n");
|
|
13
|
+
export const randomToken = (n: number) => { const chars = "abcdefghijklmnopqrstuvwxyz0123456789"; let out = ""; const bytes = crypto.getRandomValues(new Uint8Array(n)); for (const b of bytes) out += chars[b % chars.length]; return out; };
|
|
14
|
+
|
|
15
|
+
export function buildMime(m: MailMessage, text: string): { from: string; rcpts: string[]; data: string } {
|
|
16
|
+
const boundary = `--pb-${randomToken(24)}`;
|
|
17
|
+
const headers: [string, string][] = [
|
|
18
|
+
["From", formatAddress(m.from)],
|
|
19
|
+
["To", m.to.map(formatAddress).join(", ")],
|
|
20
|
+
];
|
|
21
|
+
if (m.cc?.length) headers.push(["Cc", m.cc.map(formatAddress).join(", ")]);
|
|
22
|
+
headers.push(["Subject", encodeWord(m.subject)], ["Date", new Date().toUTCString().replace("GMT", "+0000")], ["MIME-Version", "1.0"]);
|
|
23
|
+
const extra = Object.entries(m.headers ?? {});
|
|
24
|
+
if (!extra.some(([k]) => k.toLowerCase() === "message-id")) {
|
|
25
|
+
const domain = m.from.address.split("@")[1];
|
|
26
|
+
if (domain) headers.push(["Message-ID", `<${randomToken(15)}@${domain}>`]);
|
|
27
|
+
}
|
|
28
|
+
for (const [k, v] of extra) headers.push([k, v]);
|
|
29
|
+
headers.push(["Content-Type", `multipart/alternative; boundary="${boundary}"`]);
|
|
30
|
+
const part = (type: string, body: string) => `--${boundary}\r\nContent-Type: ${type}; charset=UTF-8\r\nContent-Transfer-Encoding: base64\r\n\r\n${base64Lines(body)}\r\n`;
|
|
31
|
+
const data = headers.map(([k, v]) => `${k}: ${v}`).join("\r\n") + "\r\n\r\n" + part("text/plain", text) + part("text/html", m.html) + `--${boundary}--\r\n`;
|
|
32
|
+
return { from: m.from.address, rcpts: [...m.to, ...(m.cc ?? []), ...(m.bcc ?? [])].map((a) => a.address), data };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// tools/mailer/html2text.go, simplified: block tags and <br> become CRLF, inline tags vanish, links keep their text
|
|
36
|
+
export function htmlToText(html: string): string {
|
|
37
|
+
let s = html.replace(/<head[\s\S]*?<\/head>/gi, "").replace(/<style[\s\S]*?<\/style>/gi, "").replace(/<script[\s\S]*?<\/script>/gi, "");
|
|
38
|
+
s = s.replace(/<br\s*\/?>/gi, "\r\n");
|
|
39
|
+
s = s.replace(/<\/(p|div|h[1-6]|li|tr|table|blockquote|pre|ul|ol|section|article|header|footer)>/gi, "\r\n");
|
|
40
|
+
s = s.replace(/<[^>]+>/g, "");
|
|
41
|
+
s = s.replace(/ /g, " ").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'");
|
|
42
|
+
return s.split(/\r?\n/).map((l) => l.replace(/\s+/g, " ").trim()).filter((l, i, arr) => l !== "" || (i > 0 && arr[i - 1] !== "")).join("\r\n").trim();
|
|
43
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// SMTP client on Cloudflare TCP sockets (tools/mailer/smtp.go semantics): implicit TLS when settings.smtp.tls,
|
|
2
|
+
// otherwise opportunistic STARTTLS when the server offers it; AUTH PLAIN (default) or LOGIN; EHLO with localName.
|
|
3
|
+
import { connect, type Socket } from "#platform/sockets";
|
|
4
|
+
|
|
5
|
+
export interface SMTPConfig { host: string; port: number; username: string; password: string; authMethod: string; tls: boolean; localName: string }
|
|
6
|
+
export interface Envelope { from: string; to: string[]; data: string }
|
|
7
|
+
|
|
8
|
+
const TIMEOUT_MS = 30_000;
|
|
9
|
+
|
|
10
|
+
class Conn {
|
|
11
|
+
private buf = "";
|
|
12
|
+
private reader: ReadableStreamDefaultReader<Uint8Array>;
|
|
13
|
+
private writer: WritableStreamDefaultWriter<Uint8Array>;
|
|
14
|
+
private dec = new TextDecoder(); private enc = new TextEncoder();
|
|
15
|
+
constructor(private socket: Socket) { this.reader = socket.readable.getReader(); this.writer = socket.writable.getWriter(); }
|
|
16
|
+
async startTls(): Promise<Conn> {
|
|
17
|
+
this.reader.releaseLock(); this.writer.releaseLock();
|
|
18
|
+
return new Conn(this.socket.startTls());
|
|
19
|
+
}
|
|
20
|
+
// one SMTP reply, possibly multi-line ("250-..." continuation lines)
|
|
21
|
+
async reply(): Promise<{ code: number; lines: string[] }> {
|
|
22
|
+
const lines: string[] = [];
|
|
23
|
+
for (;;) {
|
|
24
|
+
const line = await this.line();
|
|
25
|
+
lines.push(line.slice(4));
|
|
26
|
+
if (line.length >= 4 && line[3] === " ") return { code: Number(line.slice(0, 3)), lines };
|
|
27
|
+
if (line.length < 4) throw new Error(`smtp: malformed reply ${JSON.stringify(line)}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
private async line(): Promise<string> {
|
|
31
|
+
for (;;) {
|
|
32
|
+
const i = this.buf.indexOf("\r\n");
|
|
33
|
+
if (i >= 0) { const l = this.buf.slice(0, i); this.buf = this.buf.slice(i + 2); return l; }
|
|
34
|
+
const { value, done } = await this.reader.read();
|
|
35
|
+
if (done) throw new Error("smtp: connection closed");
|
|
36
|
+
this.buf += this.dec.decode(value, { stream: true });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
async cmd(text: string, expect: number[]): Promise<{ code: number; lines: string[] }> {
|
|
40
|
+
await this.writer.write(this.enc.encode(text + "\r\n"));
|
|
41
|
+
const r = await this.reply();
|
|
42
|
+
if (!expect.includes(r.code)) throw new Error(`smtp: ${text.split(" ")[0]} failed: ${r.code} ${r.lines.join(" ")}`);
|
|
43
|
+
return r;
|
|
44
|
+
}
|
|
45
|
+
async raw(text: string) { await this.writer.write(this.enc.encode(text)); }
|
|
46
|
+
async close() { try { await this.writer.close(); } catch { /* closed */ } try { this.socket.close(); } catch { /* closed */ } }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function sendSMTP(cfg: SMTPConfig, env: Envelope): Promise<void> {
|
|
50
|
+
const run = async () => {
|
|
51
|
+
const socket = connect({ hostname: cfg.host, port: cfg.port }, { secureTransport: cfg.tls ? "on" : "starttls", allowHalfOpen: false });
|
|
52
|
+
let conn = new Conn(socket);
|
|
53
|
+
const name = cfg.localName || "localhost";
|
|
54
|
+
try {
|
|
55
|
+
const greeting = await conn.reply();
|
|
56
|
+
if (greeting.code !== 220) throw new Error(`smtp: unexpected greeting ${greeting.code}`);
|
|
57
|
+
let ehlo = await conn.cmd(`EHLO ${name}`, [250]);
|
|
58
|
+
if (!cfg.tls && ehlo.lines.some((l) => l.toUpperCase().startsWith("STARTTLS"))) {
|
|
59
|
+
await conn.cmd("STARTTLS", [220]);
|
|
60
|
+
conn = await conn.startTls();
|
|
61
|
+
ehlo = await conn.cmd(`EHLO ${name}`, [250]);
|
|
62
|
+
}
|
|
63
|
+
if (cfg.username || cfg.password) {
|
|
64
|
+
if (cfg.authMethod === "LOGIN") {
|
|
65
|
+
await conn.cmd("AUTH LOGIN", [334]);
|
|
66
|
+
await conn.cmd(btoa(cfg.username), [334]);
|
|
67
|
+
await conn.cmd(btoa(cfg.password), [235]);
|
|
68
|
+
} else {
|
|
69
|
+
await conn.cmd(`AUTH PLAIN ${btoa(`\0${cfg.username}\0${cfg.password}`)}`, [235]);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
await conn.cmd(`MAIL FROM:<${env.from}>`, [250]);
|
|
73
|
+
for (const rcpt of env.to) await conn.cmd(`RCPT TO:<${rcpt}>`, [250, 251]);
|
|
74
|
+
await conn.cmd("DATA", [354]);
|
|
75
|
+
const stuffed = env.data.replace(/\r?\n/g, "\r\n").replace(/(^|\r\n)\./g, "$1..");
|
|
76
|
+
await conn.raw(stuffed.endsWith("\r\n") ? stuffed : stuffed + "\r\n");
|
|
77
|
+
await conn.cmd(".", [250]);
|
|
78
|
+
try { await conn.cmd("QUIT", [221]); } catch { /* some servers close right away */ }
|
|
79
|
+
} finally { await conn.close(); }
|
|
80
|
+
};
|
|
81
|
+
await Promise.race([run(), new Promise<never>((_, rej) => setTimeout(() => rej(new Error("smtp: timeout")), TIMEOUT_MS))]);
|
|
82
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// PocketBase's email templates (core/collection_model_auth_templates.go + mails/templates): the per-collection
|
|
2
|
+
// template resolves {APP_NAME}, {APP_URL}, {TOKEN}, {OTP}, {OTP_ID}, {ALERT_INFO} and {RECORD:field}, and the
|
|
3
|
+
// result is wrapped in the shared HTML layout.
|
|
4
|
+
import type { Collection } from "../collections/model";
|
|
5
|
+
import type { Row } from "../types";
|
|
6
|
+
import { fromColumn } from "../records/values";
|
|
7
|
+
import type { Field } from "../collections/fields";
|
|
8
|
+
|
|
9
|
+
export interface EmailTemplate { subject: string; body: string }
|
|
10
|
+
export const PLACEHOLDER = { appName: "{APP_NAME}", appURL: "{APP_URL}", token: "{TOKEN}", otp: "{OTP}", otpId: "{OTP_ID}", alertInfo: "{ALERT_INFO}" };
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_TEMPLATES: Record<"verification" | "resetPassword" | "confirmEmailChange" | "otp" | "authAlert", EmailTemplate> = {
|
|
13
|
+
verification: { subject: "Verify your {APP_NAME} email", body: `<p>Hello,</p>
|
|
14
|
+
<p>Thank you for joining us at {APP_NAME}.</p>
|
|
15
|
+
<p>Click on the button below to verify your email address.</p>
|
|
16
|
+
<p>
|
|
17
|
+
<a class="btn" href="{APP_URL}/_/#/auth/confirm-verification/{TOKEN}" target="_blank" rel="noopener">Verify</a>
|
|
18
|
+
</p>
|
|
19
|
+
<p><i>If you didn't recently register, please ignore this email.</i></p>
|
|
20
|
+
<p>
|
|
21
|
+
Thanks,<br/>
|
|
22
|
+
{APP_NAME} team
|
|
23
|
+
</p>` },
|
|
24
|
+
resetPassword: { subject: "Reset your {APP_NAME} password", body: `<p>Hello,</p>
|
|
25
|
+
<p>Click on the button below to reset your password.</p>
|
|
26
|
+
<p>
|
|
27
|
+
<a class="btn" href="{APP_URL}/_/#/auth/confirm-password-reset/{TOKEN}" target="_blank" rel="noopener">Reset password</a>
|
|
28
|
+
</p>
|
|
29
|
+
<p><i>If you didn't ask to reset your password, please ignore this email.</i></p>
|
|
30
|
+
<p>
|
|
31
|
+
Thanks,<br/>
|
|
32
|
+
{APP_NAME} team
|
|
33
|
+
</p>` },
|
|
34
|
+
confirmEmailChange: { subject: "Confirm your {APP_NAME} new email address", body: `<p>Hello,</p>
|
|
35
|
+
<p>Click on the button below to confirm your new email address.</p>
|
|
36
|
+
<p>
|
|
37
|
+
<a class="btn" href="{APP_URL}/_/#/auth/confirm-email-change/{TOKEN}" target="_blank" rel="noopener">Confirm new email</a>
|
|
38
|
+
</p>
|
|
39
|
+
<p><i>If you didn't ask to change your email address, please ignore this email.</i></p>
|
|
40
|
+
<p>
|
|
41
|
+
Thanks,<br/>
|
|
42
|
+
{APP_NAME} team
|
|
43
|
+
</p>` },
|
|
44
|
+
otp: { subject: "OTP for {APP_NAME}", body: `<p>Hello,</p>
|
|
45
|
+
<p>Your one-time password is: <strong>{OTP}</strong></p>
|
|
46
|
+
<p><i>If you didn't ask for the one-time password, you can ignore this email.</i></p>
|
|
47
|
+
<p>
|
|
48
|
+
Thanks,<br/>
|
|
49
|
+
{APP_NAME} team
|
|
50
|
+
</p>` },
|
|
51
|
+
authAlert: { subject: "Login from a new location", body: `<p>Hello,</p>
|
|
52
|
+
<p>We noticed a login to your {APP_NAME} account from a new location:</p>
|
|
53
|
+
<p><em>{ALERT_INFO}</em></p>
|
|
54
|
+
<p><strong>If this wasn't you, you should immediately change your {APP_NAME} account password to revoke access from all other locations.</strong></p>
|
|
55
|
+
<p>If this was you, you may disregard this email.</p>
|
|
56
|
+
<p>
|
|
57
|
+
Thanks,<br/>
|
|
58
|
+
{APP_NAME} team
|
|
59
|
+
</p>` },
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const LAYOUT = `
|
|
63
|
+
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
|
|
64
|
+
<html xmlns="http://www.w3.org/1999/xhtml">
|
|
65
|
+
<head>
|
|
66
|
+
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
|
67
|
+
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
68
|
+
<style>
|
|
69
|
+
body, html {
|
|
70
|
+
padding: 0;
|
|
71
|
+
margin: 0;
|
|
72
|
+
border: 0;
|
|
73
|
+
color: #16161a;
|
|
74
|
+
background: #fff;
|
|
75
|
+
font-size: 14px;
|
|
76
|
+
line-height: 20px;
|
|
77
|
+
font-weight: normal;
|
|
78
|
+
font-family: Source Sans Pro, sans-serif, emoji;
|
|
79
|
+
}
|
|
80
|
+
body {
|
|
81
|
+
padding: 20px 30px;
|
|
82
|
+
}
|
|
83
|
+
strong {
|
|
84
|
+
font-weight: bold;
|
|
85
|
+
}
|
|
86
|
+
em, i {
|
|
87
|
+
font-style: italic;
|
|
88
|
+
}
|
|
89
|
+
p {
|
|
90
|
+
display: block;
|
|
91
|
+
margin: 10px 0;
|
|
92
|
+
font-family: inherit;
|
|
93
|
+
}
|
|
94
|
+
small {
|
|
95
|
+
font-size: 12px;
|
|
96
|
+
line-height: 16px;
|
|
97
|
+
}
|
|
98
|
+
hr {
|
|
99
|
+
display: block;
|
|
100
|
+
height: 1px;
|
|
101
|
+
border: 0;
|
|
102
|
+
width: 100%;
|
|
103
|
+
background: #e1e6ea;
|
|
104
|
+
margin: 10px 0;
|
|
105
|
+
}
|
|
106
|
+
a {
|
|
107
|
+
color: inherit;
|
|
108
|
+
}
|
|
109
|
+
.hidden {
|
|
110
|
+
display: none !important;
|
|
111
|
+
}
|
|
112
|
+
.btn {
|
|
113
|
+
display: inline-block;
|
|
114
|
+
vertical-align: top;
|
|
115
|
+
border: 0;
|
|
116
|
+
cursor: pointer;
|
|
117
|
+
color: #fff !important;
|
|
118
|
+
background: #16161a !important;
|
|
119
|
+
text-decoration: none !important;
|
|
120
|
+
line-height: 40px;
|
|
121
|
+
width: auto;
|
|
122
|
+
min-width: 150px;
|
|
123
|
+
text-align: center;
|
|
124
|
+
padding: 0 20px;
|
|
125
|
+
margin: 5px 0;
|
|
126
|
+
font-family: Source Sans Pro, sans-serif, emoji;;
|
|
127
|
+
font-size: 14px;
|
|
128
|
+
font-weight: bold;
|
|
129
|
+
border-radius: 6px;
|
|
130
|
+
box-sizing: border-box;
|
|
131
|
+
}
|
|
132
|
+
</style>
|
|
133
|
+
</head>
|
|
134
|
+
<body>
|
|
135
|
+
{{CONTENT}}
|
|
136
|
+
</body>
|
|
137
|
+
</html>
|
|
138
|
+
`;
|
|
139
|
+
|
|
140
|
+
const escapeHtml = (s: string) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
141
|
+
const NONESCAPE_TYPES = ["editor"];
|
|
142
|
+
|
|
143
|
+
export function collectionTemplate(c: Collection, key: "verificationTemplate" | "resetPasswordTemplate" | "confirmEmailChangeTemplate" | "otp" | "authAlert"): EmailTemplate {
|
|
144
|
+
const o = c.options as Record<string, unknown>;
|
|
145
|
+
if (key === "otp") { const t = (o.otp as { emailTemplate?: EmailTemplate } | undefined)?.emailTemplate; return t?.subject ? t : DEFAULT_TEMPLATES.otp; }
|
|
146
|
+
if (key === "authAlert") { const t = (o.authAlert as { emailTemplate?: EmailTemplate } | undefined)?.emailTemplate; return t?.subject ? t : DEFAULT_TEMPLATES.authAlert; }
|
|
147
|
+
const t = o[key] as EmailTemplate | undefined;
|
|
148
|
+
if (t?.subject) return t;
|
|
149
|
+
return key === "verificationTemplate" ? DEFAULT_TEMPLATES.verification : key === "resetPasswordTemplate" ? DEFAULT_TEMPLATES.resetPassword : DEFAULT_TEMPLATES.confirmEmailChange;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// mails/record.go resolveEmailTemplate
|
|
153
|
+
export function resolveEmailTemplate(meta: { appName: string; appURL: string }, c: Collection, row: Row, template: EmailTemplate, placeholders: Record<string, string>): { subject: string; body: string } {
|
|
154
|
+
const all: Record<string, string> = { ...placeholders };
|
|
155
|
+
if (!(PLACEHOLDER.appName in all)) all[PLACEHOLDER.appName] = meta.appName;
|
|
156
|
+
if (!(PLACEHOLDER.appURL in all)) all[PLACEHOLDER.appURL] = meta.appURL;
|
|
157
|
+
for (const f of c.fields as Field[]) {
|
|
158
|
+
if (f.hidden) continue;
|
|
159
|
+
const key = `{RECORD:${f.name}}`;
|
|
160
|
+
if (key in all) continue;
|
|
161
|
+
const v = fromColumn(f, row[f.name]);
|
|
162
|
+
const str = v == null ? "" : Array.isArray(v) ? String(v[0] ?? "") : typeof v === "object" ? JSON.stringify(v) : String(v);
|
|
163
|
+
all[key] = NONESCAPE_TYPES.includes(f.type) ? str : escapeHtml(str);
|
|
164
|
+
}
|
|
165
|
+
let subject = template.subject, body = template.body;
|
|
166
|
+
for (const [k, v] of Object.entries(all)) { subject = subject.split(k).join(v); body = body.split(k).join(v); }
|
|
167
|
+
return { subject, body: LAYOUT.replace("{{CONTENT}}", body) };
|
|
168
|
+
}
|