@technomoron/mail-magic 1.0.32 → 1.0.34
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/CHANGES +18 -0
- package/README.md +213 -122
- package/dist/api/assets.js +9 -56
- package/dist/api/auth.js +1 -12
- package/dist/api/form-replyto.js +1 -0
- package/dist/api/form-submission.js +1 -0
- package/dist/api/forms.js +114 -474
- package/dist/api/mailer.js +1 -1
- package/dist/bin/mail-magic.js +2 -2
- package/dist/index.js +30 -18
- package/dist/models/db.js +5 -5
- package/dist/models/domain.js +16 -8
- package/dist/models/form.js +111 -40
- package/dist/models/init.js +44 -74
- package/dist/models/recipient.js +12 -8
- package/dist/models/txmail.js +24 -28
- package/dist/models/user.js +14 -10
- package/dist/server.js +1 -1
- package/dist/store/store.js +53 -22
- package/dist/swagger.js +107 -0
- package/dist/util/captcha.js +24 -0
- package/dist/util/email.js +19 -0
- package/dist/util/form-replyto.js +44 -0
- package/dist/util/form-submission.js +95 -0
- package/dist/util/forms.js +431 -0
- package/dist/util/paths.js +41 -0
- package/dist/util/ratelimit.js +48 -0
- package/dist/util/uploads.js +48 -0
- package/dist/util/utils.js +151 -0
- package/dist/util.js +7 -127
- package/docs/config-example/example.test/assets/files/banner.png +1 -0
- package/docs/config-example/example.test/assets/images/logo.png +1 -0
- package/docs/config-example/example.test/form-template/base.njk +6 -0
- package/docs/config-example/example.test/form-template/contact.njk +9 -0
- package/docs/config-example/example.test/form-template/partials/fields.njk +3 -0
- package/docs/config-example/example.test/tx-template/base.njk +10 -0
- package/docs/config-example/example.test/tx-template/partials/header.njk +1 -0
- package/docs/config-example/example.test/tx-template/welcome.njk +10 -0
- package/docs/config-example/init-data.json +57 -0
- package/docs/form-security.md +194 -0
- package/docs/swagger/openapi.json +1321 -0
- package/{TUTORIAL.MD → docs/tutorial.md} +24 -15
- package/package.json +3 -3
package/dist/models/user.js
CHANGED
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
import { createHmac } from 'node:crypto';
|
|
2
2
|
import { Model, DataTypes, Op } from 'sequelize';
|
|
3
3
|
import { z } from 'zod';
|
|
4
|
-
export const api_user_schema = z
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
4
|
+
export const api_user_schema = z
|
|
5
|
+
.object({
|
|
6
|
+
user_id: z.number().int().nonnegative().describe('Database primary key for the user record.'),
|
|
7
|
+
idname: z.string().min(1).describe('User identifier (slug-like).'),
|
|
8
|
+
token: z.string().min(1).optional().describe('Legacy API token (may be blank after migration).'),
|
|
9
|
+
token_hmac: z.string().min(1).optional().describe('API token digest (HMAC).'),
|
|
10
|
+
name: z.string().min(1).describe('Display name for the user.'),
|
|
11
|
+
email: z.string().email().describe('User email address.'),
|
|
12
|
+
domain: z.number().int().nonnegative().nullable().optional().describe('Default domain ID for the user.'),
|
|
13
|
+
locale: z.string().default('').describe('Default locale for the user.')
|
|
14
|
+
})
|
|
15
|
+
.describe('User account record and API credentials.');
|
|
16
|
+
// Sequelize typing pattern: merge the Zod-inferred attribute type onto the model instance type.
|
|
17
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
|
|
14
18
|
export class api_user extends Model {
|
|
15
19
|
}
|
|
16
20
|
export function apiTokenToHmac(token, pepper) {
|
package/dist/server.js
CHANGED
|
@@ -10,7 +10,7 @@ export class mailApiServer extends ApiServer {
|
|
|
10
10
|
}
|
|
11
11
|
async getApiKey(token) {
|
|
12
12
|
this.storage.print_debug('Looking up api key');
|
|
13
|
-
const pepper = this.storage.
|
|
13
|
+
const pepper = this.storage.vars.API_TOKEN_PEPPER;
|
|
14
14
|
const token_hmac = apiTokenToHmac(token, pepper);
|
|
15
15
|
const user = await api_user.findOne({ where: { token_hmac } });
|
|
16
16
|
if (user) {
|
package/dist/store/store.js
CHANGED
|
@@ -5,20 +5,20 @@ import { createTransport } from 'nodemailer';
|
|
|
5
5
|
import { connect_api_db } from '../models/db.js';
|
|
6
6
|
import { importData } from '../models/init.js';
|
|
7
7
|
import { envOptions } from './envloader.js';
|
|
8
|
-
function create_mail_transport(
|
|
8
|
+
function create_mail_transport(vars) {
|
|
9
9
|
const args = {
|
|
10
|
-
host:
|
|
11
|
-
port:
|
|
12
|
-
secure:
|
|
10
|
+
host: vars.SMTP_HOST,
|
|
11
|
+
port: vars.SMTP_PORT,
|
|
12
|
+
secure: vars.SMTP_SECURE,
|
|
13
13
|
tls: {
|
|
14
|
-
rejectUnauthorized:
|
|
14
|
+
rejectUnauthorized: vars.SMTP_TLS_REJECT
|
|
15
15
|
},
|
|
16
16
|
requireTLS: true,
|
|
17
|
-
logger:
|
|
18
|
-
debug:
|
|
17
|
+
logger: vars.DEBUG,
|
|
18
|
+
debug: vars.DEBUG
|
|
19
19
|
};
|
|
20
|
-
const user =
|
|
21
|
-
const pass =
|
|
20
|
+
const user = vars.SMTP_USER;
|
|
21
|
+
const pass = vars.SMTP_PASSWORD;
|
|
22
22
|
if (user && pass) {
|
|
23
23
|
args.auth = { user, pass };
|
|
24
24
|
}
|
|
@@ -33,6 +33,7 @@ function create_mail_transport(env) {
|
|
|
33
33
|
}
|
|
34
34
|
export class mailStore {
|
|
35
35
|
env;
|
|
36
|
+
vars;
|
|
36
37
|
transport;
|
|
37
38
|
api_db = null;
|
|
38
39
|
keys = {};
|
|
@@ -41,7 +42,7 @@ export class mailStore {
|
|
|
41
42
|
uploadTemplate;
|
|
42
43
|
uploadStagingPath;
|
|
43
44
|
print_debug(msg) {
|
|
44
|
-
if (this.
|
|
45
|
+
if (this.vars.DEBUG) {
|
|
45
46
|
console.log(msg);
|
|
46
47
|
}
|
|
47
48
|
}
|
|
@@ -49,7 +50,7 @@ export class mailStore {
|
|
|
49
50
|
return path.resolve(path.join(this.configpath, name));
|
|
50
51
|
}
|
|
51
52
|
resolveUploadPath(domainName) {
|
|
52
|
-
const raw = this.
|
|
53
|
+
const raw = this.vars.UPLOAD_PATH ?? '';
|
|
53
54
|
const hasDomainToken = raw.includes('{domain}');
|
|
54
55
|
const expanded = hasDomainToken && domainName ? raw.replaceAll('{domain}', domainName) : raw;
|
|
55
56
|
if (!expanded) {
|
|
@@ -62,7 +63,7 @@ export class mailStore {
|
|
|
62
63
|
return path.resolve(base, expanded);
|
|
63
64
|
}
|
|
64
65
|
getUploadStagingPath() {
|
|
65
|
-
if (!this.
|
|
66
|
+
if (!this.vars.UPLOAD_PATH) {
|
|
66
67
|
return '';
|
|
67
68
|
}
|
|
68
69
|
if (this.uploadTemplate) {
|
|
@@ -112,23 +113,53 @@ export class mailStore {
|
|
|
112
113
|
this.print_debug(`No api-keys.json file found: tried ${keyfile}`);
|
|
113
114
|
return {};
|
|
114
115
|
}
|
|
115
|
-
async init() {
|
|
116
|
+
async init(overrides = {}) {
|
|
116
117
|
// Load env config only via EnvLoader + envOptions (avoid ad-hoc `process.env` parsing here).
|
|
117
118
|
// If DEBUG is enabled, re-load with EnvLoader debug output enabled.
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
119
|
+
const overrideEntries = Object.entries(overrides);
|
|
120
|
+
const envSnapshot = new Map();
|
|
121
|
+
if (overrideEntries.length > 0) {
|
|
122
|
+
for (const [key, value] of overrideEntries) {
|
|
123
|
+
envSnapshot.set(key, process.env[key]);
|
|
124
|
+
if (value === undefined || value === null) {
|
|
125
|
+
delete process.env[key];
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
process.env[key] = String(value);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
let env;
|
|
133
|
+
try {
|
|
134
|
+
env = await EnvLoader.createConfigProxy(envOptions, { debug: false });
|
|
135
|
+
const debugEnabled = overrides.DEBUG ?? env.DEBUG;
|
|
136
|
+
if (debugEnabled) {
|
|
137
|
+
env = await EnvLoader.createConfigProxy(envOptions, { debug: true });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
finally {
|
|
141
|
+
if (envSnapshot.size > 0) {
|
|
142
|
+
for (const [key, value] of envSnapshot.entries()) {
|
|
143
|
+
if (value === undefined) {
|
|
144
|
+
delete process.env[key];
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
process.env[key] = value;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
121
151
|
}
|
|
122
152
|
this.env = env;
|
|
123
|
-
|
|
153
|
+
this.vars = { ...env, ...overrides };
|
|
154
|
+
if (this.vars.FORM_CAPTCHA_REQUIRED && !String(this.vars.FORM_CAPTCHA_SECRET ?? '').trim()) {
|
|
124
155
|
throw new Error('FORM_CAPTCHA_SECRET must be set when FORM_CAPTCHA_REQUIRED=true');
|
|
125
156
|
}
|
|
126
157
|
EnvLoader.genTemplate(envOptions, '.env-dist');
|
|
127
|
-
const p =
|
|
158
|
+
const p = this.vars.CONFIG_PATH;
|
|
128
159
|
this.configpath = path.isAbsolute(p) ? p : path.resolve(process.cwd(), p);
|
|
129
160
|
console.log(`Config path is ${this.configpath}`);
|
|
130
|
-
if (
|
|
131
|
-
this.uploadTemplate =
|
|
161
|
+
if (this.vars.UPLOAD_PATH && this.vars.UPLOAD_PATH.includes('{domain}')) {
|
|
162
|
+
this.uploadTemplate = this.vars.UPLOAD_PATH;
|
|
132
163
|
this.uploadStagingPath = path.resolve(this.configpath, '_uploads');
|
|
133
164
|
try {
|
|
134
165
|
fs.mkdirSync(this.uploadStagingPath, { recursive: true });
|
|
@@ -138,9 +169,9 @@ export class mailStore {
|
|
|
138
169
|
}
|
|
139
170
|
}
|
|
140
171
|
// this.keys = await this.load_api_keys(this.configpath);
|
|
141
|
-
this.transport = await create_mail_transport(
|
|
172
|
+
this.transport = await create_mail_transport(this.vars);
|
|
142
173
|
this.api_db = await connect_api_db(this);
|
|
143
|
-
if (this.
|
|
174
|
+
if (this.vars.DB_AUTO_RELOAD) {
|
|
144
175
|
this.print_debug('Enabling auto reload of init-data.json');
|
|
145
176
|
fs.watchFile(this.config_filename('init-data.json'), { interval: 2000 }, () => {
|
|
146
177
|
this.print_debug('Config file changed, reloading...');
|
package/dist/swagger.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
function normalizeRoute(value, fallback = '') {
|
|
5
|
+
if (!value) {
|
|
6
|
+
return fallback;
|
|
7
|
+
}
|
|
8
|
+
const trimmed = value.trim();
|
|
9
|
+
if (!trimmed) {
|
|
10
|
+
return fallback;
|
|
11
|
+
}
|
|
12
|
+
const withLeading = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
|
|
13
|
+
if (withLeading === '/') {
|
|
14
|
+
return withLeading;
|
|
15
|
+
}
|
|
16
|
+
return withLeading.replace(/\/+$/, '');
|
|
17
|
+
}
|
|
18
|
+
function replacePrefix(input, from, to) {
|
|
19
|
+
if (input === from) {
|
|
20
|
+
return to;
|
|
21
|
+
}
|
|
22
|
+
if (input.startsWith(`${from}/`)) {
|
|
23
|
+
const suffix = input.slice(from.length);
|
|
24
|
+
if (to === '/') {
|
|
25
|
+
return suffix.replace(/^\/+/, '/') || '/';
|
|
26
|
+
}
|
|
27
|
+
return `${to}${suffix}`;
|
|
28
|
+
}
|
|
29
|
+
return input;
|
|
30
|
+
}
|
|
31
|
+
function rewriteSpecForRuntime(spec, opts) {
|
|
32
|
+
if (!spec || typeof spec !== 'object') {
|
|
33
|
+
return spec;
|
|
34
|
+
}
|
|
35
|
+
const base = normalizeRoute(opts.apiBasePath, '/api');
|
|
36
|
+
const asset = normalizeRoute(opts.assetRoute, '/asset');
|
|
37
|
+
const root = spec;
|
|
38
|
+
const out = { ...root };
|
|
39
|
+
// Keep the spec stable while still reflecting the configured public URL and base paths.
|
|
40
|
+
out.servers = [{ url: String(opts.apiUrl || ''), description: 'Configured API_URL' }];
|
|
41
|
+
const rawPaths = root.paths;
|
|
42
|
+
if (!rawPaths || typeof rawPaths !== 'object') {
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
const rewritten = {};
|
|
46
|
+
for (const [p, v] of Object.entries(rawPaths)) {
|
|
47
|
+
let next = String(p);
|
|
48
|
+
next = replacePrefix(next, '/api', base);
|
|
49
|
+
next = replacePrefix(next, '/asset', asset);
|
|
50
|
+
// Normalize double slashes after prefix replacement (path only, not URLs).
|
|
51
|
+
next = next.replace(/\/{2,}/g, '/');
|
|
52
|
+
rewritten[next] = v;
|
|
53
|
+
}
|
|
54
|
+
out.paths = rewritten;
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
let cachedSpec = null;
|
|
58
|
+
let cachedSpecError = null;
|
|
59
|
+
function loadPackagedOpenApiSpec() {
|
|
60
|
+
if (cachedSpec || cachedSpecError) {
|
|
61
|
+
return cachedSpec;
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
65
|
+
const candidate = path.resolve(here, '../docs/swagger/openapi.json');
|
|
66
|
+
const raw = fs.readFileSync(candidate, 'utf8');
|
|
67
|
+
cachedSpec = JSON.parse(raw);
|
|
68
|
+
return cachedSpec;
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
cachedSpecError = err instanceof Error ? err.message : String(err);
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
export function installMailMagicSwagger(server, opts) {
|
|
76
|
+
const rawPath = typeof opts.swaggerPath === 'string' ? opts.swaggerPath.trim() : '';
|
|
77
|
+
const enabled = Boolean(opts.swaggerEnabled) || rawPath.length > 0;
|
|
78
|
+
if (!enabled) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const base = normalizeRoute(opts.apiBasePath, '/api');
|
|
82
|
+
const resolved = rawPath.length > 0 ? rawPath : `${base}/swagger`;
|
|
83
|
+
const mount = normalizeRoute(resolved, `${base}/swagger`);
|
|
84
|
+
// Mount under the API router so it runs before the API 404 handler.
|
|
85
|
+
server.useExpress(mount, (req, res, next) => {
|
|
86
|
+
if (req.method && req.method !== 'GET' && req.method !== 'HEAD') {
|
|
87
|
+
next();
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const spec = loadPackagedOpenApiSpec();
|
|
91
|
+
if (!spec) {
|
|
92
|
+
res.status(500).json({
|
|
93
|
+
success: false,
|
|
94
|
+
code: 500,
|
|
95
|
+
message: `Swagger spec is unavailable${cachedSpecError ? `: ${cachedSpecError}` : ''}`,
|
|
96
|
+
data: null,
|
|
97
|
+
errors: {}
|
|
98
|
+
});
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
res.status(200).json(rewriteSpecForRuntime(spec, {
|
|
102
|
+
apiBasePath: base,
|
|
103
|
+
assetRoute: opts.assetRoute,
|
|
104
|
+
apiUrl: opts.apiUrl
|
|
105
|
+
}));
|
|
106
|
+
});
|
|
107
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export async function verifyCaptcha(params) {
|
|
2
|
+
const endpoints = {
|
|
3
|
+
turnstile: 'https://challenges.cloudflare.com/turnstile/v0/siteverify',
|
|
4
|
+
hcaptcha: 'https://hcaptcha.com/siteverify',
|
|
5
|
+
recaptcha: 'https://www.google.com/recaptcha/api/siteverify'
|
|
6
|
+
};
|
|
7
|
+
const endpoint = endpoints[params.provider] ?? endpoints.turnstile;
|
|
8
|
+
const body = new URLSearchParams();
|
|
9
|
+
body.set('secret', params.secret);
|
|
10
|
+
body.set('response', params.token);
|
|
11
|
+
if (params.remoteip) {
|
|
12
|
+
body.set('remoteip', params.remoteip);
|
|
13
|
+
}
|
|
14
|
+
const res = await fetch(endpoint, {
|
|
15
|
+
method: 'POST',
|
|
16
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
17
|
+
body
|
|
18
|
+
});
|
|
19
|
+
if (!res.ok) {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
const data = (await res.json().catch(() => null));
|
|
23
|
+
return Boolean(data?.success);
|
|
24
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import emailAddresses from 'email-addresses';
|
|
2
|
+
export function validateEmail(email) {
|
|
3
|
+
const parsed = emailAddresses.parseOneAddress(email);
|
|
4
|
+
if (parsed) {
|
|
5
|
+
return parsed.address;
|
|
6
|
+
}
|
|
7
|
+
return undefined;
|
|
8
|
+
}
|
|
9
|
+
export function parseMailbox(value) {
|
|
10
|
+
const parsed = emailAddresses.parseOneAddress(value);
|
|
11
|
+
if (!parsed) {
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
const mailbox = parsed;
|
|
15
|
+
if (!mailbox?.address) {
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
return mailbox;
|
|
19
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import emailAddresses from 'email-addresses';
|
|
2
|
+
function getFirstStringField(body, key) {
|
|
3
|
+
const value = body[key];
|
|
4
|
+
if (Array.isArray(value) && value.length > 0) {
|
|
5
|
+
return String(value[0] ?? '');
|
|
6
|
+
}
|
|
7
|
+
if (value !== undefined && value !== null) {
|
|
8
|
+
return String(value);
|
|
9
|
+
}
|
|
10
|
+
return '';
|
|
11
|
+
}
|
|
12
|
+
function sanitizeHeaderValue(value, maxLen) {
|
|
13
|
+
const trimmed = String(value ?? '').trim();
|
|
14
|
+
if (!trimmed) {
|
|
15
|
+
return '';
|
|
16
|
+
}
|
|
17
|
+
// Prevent header injection.
|
|
18
|
+
if (/[\r\n]/.test(trimmed)) {
|
|
19
|
+
return '';
|
|
20
|
+
}
|
|
21
|
+
return trimmed.slice(0, maxLen);
|
|
22
|
+
}
|
|
23
|
+
export function extractReplyToFromSubmission(body) {
|
|
24
|
+
const emailRaw = sanitizeHeaderValue(getFirstStringField(body, 'email'), 320);
|
|
25
|
+
if (!emailRaw) {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
const parsed = emailAddresses.parseOneAddress(emailRaw);
|
|
29
|
+
if (!parsed) {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
const address = sanitizeHeaderValue(parsed?.address, 320);
|
|
33
|
+
if (!address) {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
// Prefer a single "name" field, otherwise compose from first_name/last_name.
|
|
37
|
+
let name = sanitizeHeaderValue(getFirstStringField(body, 'name'), 200);
|
|
38
|
+
if (!name) {
|
|
39
|
+
const first = sanitizeHeaderValue(getFirstStringField(body, 'first_name'), 100);
|
|
40
|
+
const last = sanitizeHeaderValue(getFirstStringField(body, 'last_name'), 100);
|
|
41
|
+
name = sanitizeHeaderValue(`${first}${first && last ? ' ' : ''}${last}`, 200);
|
|
42
|
+
}
|
|
43
|
+
return name ? { name, address } : address;
|
|
44
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { getBodyValue } from './utils.js';
|
|
3
|
+
const ALLOWED_MM_KEYS = new Set(['_mm_form_key', '_mm_locale', '_mm_recipients']);
|
|
4
|
+
function asRecord(input) {
|
|
5
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
|
6
|
+
return {};
|
|
7
|
+
}
|
|
8
|
+
return input;
|
|
9
|
+
}
|
|
10
|
+
function getCaptchaTokenFromBody(body) {
|
|
11
|
+
return getBodyValue(body, 'cf-turnstile-response', 'h-captcha-response', 'g-recaptcha-response', 'captcha');
|
|
12
|
+
}
|
|
13
|
+
const optionalStringish = z.union([z.string(), z.array(z.string())]).optional();
|
|
14
|
+
// Public form submission payload schema.
|
|
15
|
+
// - Validates/normalizes system fields under the `_mm_*` namespace.
|
|
16
|
+
// - Allows arbitrary non-system fields through (exposed to templates as `_fields_`).
|
|
17
|
+
// - Rejects unknown `_mm_*` keys (except `_mm_file*` attachment field names).
|
|
18
|
+
export const form_submission_schema = z
|
|
19
|
+
.object({
|
|
20
|
+
_mm_form_key: z
|
|
21
|
+
.string()
|
|
22
|
+
.min(1)
|
|
23
|
+
.describe('Required. Public form key identifying which form configuration to use.'),
|
|
24
|
+
_mm_locale: z
|
|
25
|
+
.string()
|
|
26
|
+
.optional()
|
|
27
|
+
.default('')
|
|
28
|
+
.describe('Optional locale hint used when rendering and for recipient resolution.'),
|
|
29
|
+
_mm_recipients: z
|
|
30
|
+
.union([z.string(), z.array(z.string())])
|
|
31
|
+
.optional()
|
|
32
|
+
.describe('Optional list of recipient idnames (array) or comma-separated string. Recipients are resolved server-side.'),
|
|
33
|
+
// Common fields used to derive Reply-To (optional; no defaults).
|
|
34
|
+
email: optionalStringish.describe('Optional submitter email used to derive Reply-To.'),
|
|
35
|
+
name: optionalStringish.describe('Optional submitter name used to derive Reply-To.'),
|
|
36
|
+
first_name: optionalStringish.describe('Optional submitter first name used to derive Reply-To.'),
|
|
37
|
+
last_name: optionalStringish.describe('Optional submitter last name used to derive Reply-To.'),
|
|
38
|
+
// Provider-native CAPTCHA token field names (accepted as-is; not part of the `_mm_*` namespace).
|
|
39
|
+
'cf-turnstile-response': optionalStringish.describe('Cloudflare Turnstile token (accepted as-is).'),
|
|
40
|
+
'h-captcha-response': optionalStringish.describe('hCaptcha token (accepted as-is).'),
|
|
41
|
+
'g-recaptcha-response': optionalStringish.describe('Google reCAPTCHA token (accepted as-is).'),
|
|
42
|
+
captcha: optionalStringish.describe('Generic/legacy captcha token field (accepted as-is).')
|
|
43
|
+
})
|
|
44
|
+
.passthrough()
|
|
45
|
+
.superRefine((obj, ctx) => {
|
|
46
|
+
for (const key of Object.keys(obj)) {
|
|
47
|
+
if (!key.startsWith('_mm_')) {
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (key.startsWith('_mm_file')) {
|
|
51
|
+
// Files arrive in req.files, but allow clients to pass harmless metadata fields.
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (!ALLOWED_MM_KEYS.has(key)) {
|
|
55
|
+
ctx.addIssue({
|
|
56
|
+
code: z.ZodIssueCode.custom,
|
|
57
|
+
message: `Unknown system field "${key}"`
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
.describe('Public form submission payload. System fields must be `_mm_*`. All other fields are treated as user fields and exposed to templates as `_fields_`.');
|
|
63
|
+
export function parseFormSubmissionInput(raw) {
|
|
64
|
+
const body = asRecord(raw);
|
|
65
|
+
// Enforce that system params are _mm_* only (except provider captcha fields).
|
|
66
|
+
// We intentionally do not accept non-_mm aliases for system params.
|
|
67
|
+
const mm_form_key = getBodyValue(body, '_mm_form_key');
|
|
68
|
+
const mm_locale = getBodyValue(body, '_mm_locale');
|
|
69
|
+
const mm_recipients = body._mm_recipients;
|
|
70
|
+
const mm_captcha_token = getCaptchaTokenFromBody(body);
|
|
71
|
+
const parsed = form_submission_schema.parse({
|
|
72
|
+
...body,
|
|
73
|
+
_mm_form_key: mm_form_key,
|
|
74
|
+
_mm_locale: mm_locale,
|
|
75
|
+
_mm_recipients: mm_recipients
|
|
76
|
+
});
|
|
77
|
+
const { _mm_form_key, _mm_locale, _mm_recipients, ...rest } = parsed;
|
|
78
|
+
// Expose non-system fields to templates. Keep all non-`_mm_*` keys verbatim.
|
|
79
|
+
const fields = {};
|
|
80
|
+
for (const [key, value] of Object.entries(rest)) {
|
|
81
|
+
if (key.startsWith('_mm_')) {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
fields[key] = value;
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
mm: {
|
|
88
|
+
form_key: _mm_form_key,
|
|
89
|
+
locale: _mm_locale,
|
|
90
|
+
captcha_token: mm_captcha_token,
|
|
91
|
+
recipients_raw: _mm_recipients
|
|
92
|
+
},
|
|
93
|
+
fields
|
|
94
|
+
};
|
|
95
|
+
}
|