@technomoron/mail-magic 1.0.33 → 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 +8 -0
- package/dist/api/form-replyto.js +1 -44
- package/dist/api/form-submission.js +1 -95
- package/dist/api/forms.js +67 -371
- package/dist/models/form.js +2 -3
- package/dist/models/init.js +13 -3
- package/dist/models/txmail.js +3 -4
- 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.js +3 -0
- package/package.json +1 -1
package/CHANGES
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
|
+
Version 1.0.34 (2026-02-10)
|
|
2
|
+
|
|
3
|
+
- Ensure inline form template assets (CID) are actually attached when delivering public form submissions.
|
|
4
|
+
- Use safe, stable inline Content-IDs (no path separators) and keep stored `files[].cid` consistent with rendered HTML.
|
|
5
|
+
- Expand tests to validate inline assets, linked assets, and uploaded attachments end-to-end.
|
|
6
|
+
- Ensure record slugs and generated paths are domain-rooted only (no user segment).
|
|
7
|
+
|
|
1
8
|
Version 1.0.33 (2026-02-09)
|
|
2
9
|
|
|
3
10
|
- Route all env-derived config through `mailStore.vars` with explicit overrides for tests/examples.
|
|
4
11
|
- Move shared helpers into `util/` modules (rate limiting, email parsing, path safety, uploads).
|
|
5
12
|
- Switch template preprocessing to Unyuck’s asset collection while preserving existing URL and CID behavior.
|
|
13
|
+
- Split form submission logic into focused util helpers and slim the API handler.
|
|
6
14
|
|
|
7
15
|
Version 1.0.32 (2026-02-08)
|
|
8
16
|
|
package/dist/api/form-replyto.js
CHANGED
|
@@ -1,44 +1 @@
|
|
|
1
|
-
|
|
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
|
-
}
|
|
1
|
+
export * from '../util/form-replyto.js';
|
|
@@ -1,95 +1 @@
|
|
|
1
|
-
|
|
2
|
-
import { getBodyValue } from '../util.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
|
-
}
|
|
1
|
+
export * from '../util/form-submission.js';
|
package/dist/api/forms.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import path from 'path';
|
|
2
1
|
import { ApiModule, ApiError } from '@technomoron/api-server-base';
|
|
3
2
|
import { nanoid } from 'nanoid';
|
|
4
3
|
import nunjucks from 'nunjucks';
|
|
@@ -6,209 +5,36 @@ import { UniqueConstraintError } from 'sequelize';
|
|
|
6
5
|
import { api_domain } from '../models/domain.js';
|
|
7
6
|
import { api_form } from '../models/form.js';
|
|
8
7
|
import { api_recipient } from '../models/recipient.js';
|
|
9
|
-
import {
|
|
10
|
-
import { parseMailbox } from '../util/email.js';
|
|
8
|
+
import { buildFormTemplateRecord, buildFormTemplatePaths, buildRecipientTo, buildReplyToValue, buildSubmissionContext, enforceAttachmentPolicy, enforceCaptchaPolicy, filterSubmissionFields, getPrimaryRecipientInfo, normalizeRecipientEmail, normalizeRecipientIdname, normalizeRecipientName, parseIdnameList, parseFormTemplatePayload, parseRecipientPayload, parsePublicSubmissionOrThrow, resolveFormKeyForTemplate, resolveFormKeyForRecipient, resolveRecipients, validateFormTemplatePayload } from '../util/forms.js';
|
|
11
9
|
import { FixedWindowRateLimiter, enforceFormRateLimit } from '../util/ratelimit.js';
|
|
12
10
|
import { buildAttachments, cleanupUploadedFiles } from '../util/uploads.js';
|
|
13
|
-
import { buildRequestMeta, getBodyValue
|
|
11
|
+
import { buildRequestMeta, getBodyValue } from '../util.js';
|
|
14
12
|
import { assert_domain_and_user } from './auth.js';
|
|
15
|
-
import { extractReplyToFromSubmission } from './form-replyto.js';
|
|
16
|
-
import { parseFormSubmissionInput } from './form-submission.js';
|
|
17
|
-
function parsePublicSubmissionOrThrow(apireq) {
|
|
18
|
-
try {
|
|
19
|
-
return parseFormSubmissionInput(apireq.req.body);
|
|
20
|
-
}
|
|
21
|
-
catch {
|
|
22
|
-
// Treat malformed input as a bad request (Zod schema failures, non-object bodies, etc).
|
|
23
|
-
throw new ApiError({ code: 400, message: 'Invalid form submission payload' });
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
function enforceAttachmentPolicy(env, rawFiles) {
|
|
27
|
-
if (env.FORM_MAX_ATTACHMENTS === 0 && rawFiles.length > 0) {
|
|
28
|
-
throw new ApiError({ code: 413, message: 'This endpoint does not accept file attachments' });
|
|
29
|
-
}
|
|
30
|
-
for (const file of rawFiles) {
|
|
31
|
-
if (!file?.fieldname) {
|
|
32
|
-
continue;
|
|
33
|
-
}
|
|
34
|
-
if (!file.fieldname.startsWith('_mm_file')) {
|
|
35
|
-
throw new ApiError({
|
|
36
|
-
code: 400,
|
|
37
|
-
message: 'Invalid upload field name. Use _mm_file* for attachments.'
|
|
38
|
-
});
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
if (env.FORM_MAX_ATTACHMENTS > 0 && rawFiles.length > env.FORM_MAX_ATTACHMENTS) {
|
|
42
|
-
throw new ApiError({
|
|
43
|
-
code: 413,
|
|
44
|
-
message: `Too many attachments: ${rawFiles.length} > ${env.FORM_MAX_ATTACHMENTS}`
|
|
45
|
-
});
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
function filterSubmissionFields(rawFields, allowedFields) {
|
|
49
|
-
const allowed = Array.isArray(allowedFields) ? allowedFields : [];
|
|
50
|
-
if (!allowed.length) {
|
|
51
|
-
return rawFields;
|
|
52
|
-
}
|
|
53
|
-
const filtered = {};
|
|
54
|
-
// Always allow Reply-To derivation fields even when allowed_fields is configured.
|
|
55
|
-
const alwaysAllow = ['email', 'name', 'first_name', 'last_name'];
|
|
56
|
-
for (const key of alwaysAllow) {
|
|
57
|
-
if (Object.prototype.hasOwnProperty.call(rawFields, key)) {
|
|
58
|
-
filtered[key] = rawFields[key];
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
for (const key of allowed) {
|
|
62
|
-
const k = typeof key === 'string' ? key : String(key ?? '').trim();
|
|
63
|
-
if (!k) {
|
|
64
|
-
continue;
|
|
65
|
-
}
|
|
66
|
-
if (Object.prototype.hasOwnProperty.call(rawFields, k)) {
|
|
67
|
-
filtered[k] = rawFields[k];
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
return filtered;
|
|
71
|
-
}
|
|
72
|
-
async function enforceCaptchaPolicy(params) {
|
|
73
|
-
const captchaRequired = Boolean(params.vars.FORM_CAPTCHA_REQUIRED || params.form.captcha_required);
|
|
74
|
-
const captchaSecret = String(params.vars.FORM_CAPTCHA_SECRET ?? '').trim();
|
|
75
|
-
if (!captchaSecret) {
|
|
76
|
-
if (captchaRequired) {
|
|
77
|
-
throw new ApiError({ code: 500, message: 'Captcha is required but not configured on the server' });
|
|
78
|
-
}
|
|
79
|
-
return;
|
|
80
|
-
}
|
|
81
|
-
if (!params.captchaToken) {
|
|
82
|
-
if (captchaRequired) {
|
|
83
|
-
throw new ApiError({ code: 403, message: 'Captcha token required' });
|
|
84
|
-
}
|
|
85
|
-
return;
|
|
86
|
-
}
|
|
87
|
-
const provider = params.vars.FORM_CAPTCHA_PROVIDER;
|
|
88
|
-
const ok = await verifyCaptcha({
|
|
89
|
-
provider,
|
|
90
|
-
secret: captchaSecret,
|
|
91
|
-
token: params.captchaToken,
|
|
92
|
-
remoteip: params.clientIp || null
|
|
93
|
-
});
|
|
94
|
-
if (!ok) {
|
|
95
|
-
throw new ApiError({ code: 403, message: 'Captcha verification failed' });
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
function buildReplyToValue(form, fields) {
|
|
99
|
-
const forced = typeof form.replyto_email === 'string' ? form.replyto_email.trim() : '';
|
|
100
|
-
const forcedValue = forced ? forced : '';
|
|
101
|
-
if (form.replyto_from_fields) {
|
|
102
|
-
const extracted = extractReplyToFromSubmission(fields);
|
|
103
|
-
if (extracted) {
|
|
104
|
-
return extracted;
|
|
105
|
-
}
|
|
106
|
-
return forcedValue || undefined;
|
|
107
|
-
}
|
|
108
|
-
return forcedValue || undefined;
|
|
109
|
-
}
|
|
110
13
|
export class FormAPI extends ApiModule {
|
|
111
14
|
rateLimiter = new FixedWindowRateLimiter();
|
|
112
|
-
parseIdnameList(value, field) {
|
|
113
|
-
if (value === undefined || value === null || value === '') {
|
|
114
|
-
return [];
|
|
115
|
-
}
|
|
116
|
-
const raw = Array.isArray(value) ? value : [value];
|
|
117
|
-
const out = [];
|
|
118
|
-
for (const entry of raw) {
|
|
119
|
-
const str = String(entry ?? '').trim();
|
|
120
|
-
if (!str) {
|
|
121
|
-
continue;
|
|
122
|
-
}
|
|
123
|
-
// Allow comma-separated convenience in form-encoded inputs while keeping the field name canonical.
|
|
124
|
-
const parts = str.split(',').map((p) => p.trim());
|
|
125
|
-
for (const part of parts) {
|
|
126
|
-
if (!part) {
|
|
127
|
-
continue;
|
|
128
|
-
}
|
|
129
|
-
const normalized = normalizeSlug(part);
|
|
130
|
-
if (!normalized) {
|
|
131
|
-
throw new ApiError({ code: 400, message: `Invalid ${field} identifier "${part}"` });
|
|
132
|
-
}
|
|
133
|
-
out.push(normalized);
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
return Array.from(new Set(out));
|
|
137
|
-
}
|
|
138
|
-
parseMailbox(value) {
|
|
139
|
-
return parseMailbox(value);
|
|
140
|
-
}
|
|
141
15
|
async postFormRecipient(apireq) {
|
|
142
16
|
await assert_domain_and_user(apireq);
|
|
143
17
|
const body = (apireq.req.body ?? {});
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
throw new ApiError({ code: 400, message: 'Invalid recipient identifier (idname)' });
|
|
156
|
-
}
|
|
157
|
-
if (!emailRaw) {
|
|
158
|
-
throw new ApiError({ code: 400, message: 'Missing recipient email address' });
|
|
159
|
-
}
|
|
160
|
-
const mailbox = this.parseMailbox(emailRaw);
|
|
161
|
-
if (!mailbox) {
|
|
162
|
-
throw new ApiError({ code: 400, message: 'Invalid recipient email address' });
|
|
163
|
-
}
|
|
164
|
-
const email = mailbox.address;
|
|
165
|
-
if (/[\r\n]/.test(email)) {
|
|
166
|
-
throw new ApiError({ code: 400, message: 'Invalid recipient email address' });
|
|
167
|
-
}
|
|
168
|
-
const name = String(nameRaw || mailbox.name || '')
|
|
169
|
-
.trim()
|
|
170
|
-
.slice(0, 200);
|
|
171
|
-
if (/[\r\n]/.test(name)) {
|
|
172
|
-
throw new ApiError({ code: 400, message: 'Invalid recipient name' });
|
|
173
|
-
}
|
|
18
|
+
const payload = parseRecipientPayload({
|
|
19
|
+
idname: getBodyValue(body, 'idname'),
|
|
20
|
+
email: getBodyValue(body, 'email'),
|
|
21
|
+
name: getBodyValue(body, 'name'),
|
|
22
|
+
form_key: getBodyValue(body, 'form_key'),
|
|
23
|
+
formid: getBodyValue(body, 'formid'),
|
|
24
|
+
locale: getBodyValue(body, 'locale')
|
|
25
|
+
});
|
|
26
|
+
const idname = normalizeRecipientIdname(payload.idnameRaw);
|
|
27
|
+
const { email, mailbox } = normalizeRecipientEmail(payload.emailRaw);
|
|
28
|
+
const name = normalizeRecipientName(payload.nameRaw, mailbox.name);
|
|
174
29
|
const user = apireq.user;
|
|
175
30
|
const domain = apireq.domain;
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
}
|
|
184
|
-
else if (formid) {
|
|
185
|
-
const locale = localeRaw ? normalizeSlug(localeRaw) : '';
|
|
186
|
-
if (locale) {
|
|
187
|
-
const form = await api_form.findOne({
|
|
188
|
-
where: { user_id: user.user_id, domain_id: domain.domain_id, locale, idname: formid }
|
|
189
|
-
});
|
|
190
|
-
if (!form) {
|
|
191
|
-
throw new ApiError({ code: 404, message: 'No such form for this domain/locale' });
|
|
192
|
-
}
|
|
193
|
-
form_key = form.form_key ?? '';
|
|
194
|
-
}
|
|
195
|
-
else {
|
|
196
|
-
const matches = await api_form.findAll({
|
|
197
|
-
where: { user_id: user.user_id, domain_id: domain.domain_id, idname: formid },
|
|
198
|
-
limit: 2
|
|
199
|
-
});
|
|
200
|
-
if (matches.length === 0) {
|
|
201
|
-
throw new ApiError({ code: 404, message: 'No such form for this domain' });
|
|
202
|
-
}
|
|
203
|
-
if (matches.length > 1) {
|
|
204
|
-
throw new ApiError({
|
|
205
|
-
code: 409,
|
|
206
|
-
message: 'Form identifier is ambiguous; provide locale or form_key'
|
|
207
|
-
});
|
|
208
|
-
}
|
|
209
|
-
form_key = matches[0].form_key ?? '';
|
|
210
|
-
}
|
|
211
|
-
}
|
|
31
|
+
const form_key = await resolveFormKeyForRecipient({
|
|
32
|
+
formKeyRaw: payload.formKeyRaw,
|
|
33
|
+
formid: payload.formid,
|
|
34
|
+
localeRaw: payload.localeRaw,
|
|
35
|
+
user,
|
|
36
|
+
domain
|
|
37
|
+
});
|
|
212
38
|
const record = {
|
|
213
39
|
domain_id: domain.domain_id,
|
|
214
40
|
form_key,
|
|
@@ -234,121 +60,32 @@ export class FormAPI extends ApiModule {
|
|
|
234
60
|
}
|
|
235
61
|
async postFormTemplate(apireq) {
|
|
236
62
|
await assert_domain_and_user(apireq);
|
|
237
|
-
const
|
|
238
|
-
|
|
239
|
-
const replyto_from_fields = normalizeBoolean(replytoFromFieldsRaw);
|
|
240
|
-
const replyto_email = String(replytoEmailRaw ?? '').trim();
|
|
241
|
-
const allowed_fields = (() => {
|
|
242
|
-
if (allowedFieldsRaw === undefined || allowedFieldsRaw === null || allowedFieldsRaw === '') {
|
|
243
|
-
return [];
|
|
244
|
-
}
|
|
245
|
-
const raw = Array.isArray(allowedFieldsRaw) ? allowedFieldsRaw : [allowedFieldsRaw];
|
|
246
|
-
const out = [];
|
|
247
|
-
for (const entry of raw) {
|
|
248
|
-
if (typeof entry === 'string') {
|
|
249
|
-
// Accept JSON arrays and comma-separated convenience.
|
|
250
|
-
const trimmed = entry.trim();
|
|
251
|
-
if (trimmed.startsWith('[')) {
|
|
252
|
-
try {
|
|
253
|
-
const parsed = JSON.parse(trimmed);
|
|
254
|
-
if (Array.isArray(parsed)) {
|
|
255
|
-
for (const item of parsed) {
|
|
256
|
-
const key = String(item ?? '').trim();
|
|
257
|
-
if (key) {
|
|
258
|
-
out.push(key);
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
continue;
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
catch {
|
|
265
|
-
// fall back to comma-splitting below
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
for (const part of trimmed.split(',').map((p) => p.trim())) {
|
|
269
|
-
if (part) {
|
|
270
|
-
out.push(part);
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
else {
|
|
275
|
-
const key = String(entry ?? '').trim();
|
|
276
|
-
if (key) {
|
|
277
|
-
out.push(key);
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
return Array.from(new Set(out));
|
|
282
|
-
})();
|
|
283
|
-
if (!template) {
|
|
284
|
-
throw new ApiError({ code: 400, message: 'Missing template data' });
|
|
285
|
-
}
|
|
286
|
-
if (!idname) {
|
|
287
|
-
throw new ApiError({ code: 400, message: 'Missing form identifier' });
|
|
288
|
-
}
|
|
289
|
-
if (!sender) {
|
|
290
|
-
throw new ApiError({ code: 400, message: 'Missing sender address' });
|
|
291
|
-
}
|
|
292
|
-
if (!recipient) {
|
|
293
|
-
throw new ApiError({ code: 400, message: 'Missing recipient address' });
|
|
294
|
-
}
|
|
295
|
-
if (replyto_email) {
|
|
296
|
-
const mailbox = this.parseMailbox(replyto_email);
|
|
297
|
-
if (!mailbox) {
|
|
298
|
-
throw new ApiError({ code: 400, message: 'Invalid replyto_email address' });
|
|
299
|
-
}
|
|
300
|
-
}
|
|
63
|
+
const payload = parseFormTemplatePayload(apireq.req.body ?? {});
|
|
64
|
+
validateFormTemplatePayload(payload);
|
|
301
65
|
const user = apireq.user;
|
|
302
66
|
const domain = apireq.domain;
|
|
303
|
-
const resolvedLocale = locale || apireq.locale || '';
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
}
|
|
318
|
-
let form_key = '';
|
|
319
|
-
try {
|
|
320
|
-
const existing = await api_form.findOne({
|
|
321
|
-
where: {
|
|
322
|
-
user_id: user.user_id,
|
|
323
|
-
domain_id: domain.domain_id,
|
|
324
|
-
locale: localeSlug,
|
|
325
|
-
idname
|
|
326
|
-
}
|
|
327
|
-
});
|
|
328
|
-
form_key = existing?.form_key || nanoid();
|
|
329
|
-
}
|
|
330
|
-
catch {
|
|
331
|
-
form_key = nanoid();
|
|
332
|
-
}
|
|
333
|
-
const record = {
|
|
67
|
+
const resolvedLocale = payload.locale || apireq.locale || '';
|
|
68
|
+
const { localeSlug, slug, filename } = buildFormTemplatePaths({
|
|
69
|
+
user,
|
|
70
|
+
domain,
|
|
71
|
+
idname: payload.idname,
|
|
72
|
+
locale: resolvedLocale
|
|
73
|
+
});
|
|
74
|
+
let form_key = (await resolveFormKeyForTemplate({
|
|
75
|
+
user_id: user.user_id,
|
|
76
|
+
domain_id: domain.domain_id,
|
|
77
|
+
locale: localeSlug,
|
|
78
|
+
idname: payload.idname
|
|
79
|
+
})) || nanoid();
|
|
80
|
+
const record = buildFormTemplateRecord({
|
|
334
81
|
form_key,
|
|
335
82
|
user_id: user.user_id,
|
|
336
83
|
domain_id: domain.domain_id,
|
|
337
84
|
locale: localeSlug,
|
|
338
|
-
idname,
|
|
339
|
-
sender,
|
|
340
|
-
recipient,
|
|
341
|
-
subject,
|
|
342
|
-
template,
|
|
343
85
|
slug,
|
|
344
86
|
filename,
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
replyto_from_fields,
|
|
348
|
-
allowed_fields,
|
|
349
|
-
captcha_required,
|
|
350
|
-
files: []
|
|
351
|
-
};
|
|
87
|
+
payload
|
|
88
|
+
});
|
|
352
89
|
let created = false;
|
|
353
90
|
for (let attempt = 0; attempt < 10; attempt++) {
|
|
354
91
|
try {
|
|
@@ -400,80 +137,39 @@ export class FormAPI extends ApiModule {
|
|
|
400
137
|
const fields = filterSubmissionFields(parsedInput.fields, form.allowed_fields);
|
|
401
138
|
const clientIp = apireq.getClientIp() ?? '';
|
|
402
139
|
await enforceCaptchaPolicy({ vars: env, form, captchaToken, clientIp });
|
|
403
|
-
const
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
}
|
|
407
|
-
const resolveRecipient = async (idname) => {
|
|
408
|
-
const scoped = await api_recipient.findOne({
|
|
409
|
-
where: { domain_id: form.domain_id, form_key: scopeFormKey, idname }
|
|
410
|
-
});
|
|
411
|
-
if (scoped) {
|
|
412
|
-
return scoped;
|
|
413
|
-
}
|
|
414
|
-
return api_recipient.findOne({ where: { domain_id: form.domain_id, form_key: '', idname } });
|
|
415
|
-
};
|
|
416
|
-
const recipients = this.parseIdnameList(recipientsRaw, 'recipients');
|
|
417
|
-
if (recipients.length > 25) {
|
|
418
|
-
throw new ApiError({ code: 400, message: 'Too many recipients requested' });
|
|
419
|
-
}
|
|
420
|
-
const resolvedRecipients = [];
|
|
421
|
-
for (const idname of recipients) {
|
|
422
|
-
const record = await resolveRecipient(idname);
|
|
423
|
-
if (!record) {
|
|
424
|
-
throw new ApiError({ code: 404, message: `Unknown recipient identifier "${idname}"` });
|
|
425
|
-
}
|
|
426
|
-
resolvedRecipients.push(record);
|
|
427
|
-
}
|
|
428
|
-
const thevars = {};
|
|
429
|
-
/*
|
|
430
|
-
console.log('Headers:', apireq.req.headers);
|
|
431
|
-
console.log('Body:', JSON.stringify(apireq.req.body, null, 2));
|
|
432
|
-
console.log('Files:', JSON.stringify(apireq.req.files, null, 2));
|
|
433
|
-
*/
|
|
140
|
+
const resolvedRecipients = await resolveRecipients(form, recipientsRaw);
|
|
141
|
+
const recipients = parseIdnameList(recipientsRaw, 'recipients');
|
|
142
|
+
const { rcptEmail, rcptName, rcptIdname, rcptIdnames } = getPrimaryRecipientInfo(form, resolvedRecipients);
|
|
434
143
|
const domainRecord = await api_domain.findOne({ where: { domain_id: form.domain_id } });
|
|
435
144
|
await this.server.storage.relocateUploads(domainRecord?.name ?? null, rawFiles);
|
|
436
145
|
const { attachments, attachmentMap } = buildAttachments(rawFiles);
|
|
146
|
+
// Attach inline template assets (cid:...) so clients can render embedded images reliably.
|
|
147
|
+
// Linked assets (asset('...') without inline flag) are kept as URLs and are not attached here.
|
|
148
|
+
const templateFiles = Array.isArray(form.files) ? form.files : [];
|
|
149
|
+
const inlineTemplateAttachments = templateFiles
|
|
150
|
+
.filter((file) => Boolean(file && file.cid))
|
|
151
|
+
.map((file) => ({
|
|
152
|
+
filename: file.filename,
|
|
153
|
+
path: file.path,
|
|
154
|
+
cid: file.cid
|
|
155
|
+
}));
|
|
156
|
+
const allAttachments = [...inlineTemplateAttachments, ...attachments];
|
|
437
157
|
const meta = buildRequestMeta(apireq.req);
|
|
438
|
-
const to = (
|
|
439
|
-
if (resolvedRecipients.length === 0) {
|
|
440
|
-
return form.recipient;
|
|
441
|
-
}
|
|
442
|
-
return resolvedRecipients.map((entry) => {
|
|
443
|
-
const mailbox = this.parseMailbox(entry.email);
|
|
444
|
-
if (!mailbox) {
|
|
445
|
-
throw new ApiError({ code: 500, message: 'Recipient mapping has an invalid email address' });
|
|
446
|
-
}
|
|
447
|
-
const mappedName = entry.name ? String(entry.name).trim().slice(0, 200) : '';
|
|
448
|
-
if (mappedName && /[\r\n]/.test(mappedName)) {
|
|
449
|
-
throw new ApiError({ code: 500, message: 'Recipient mapping has an invalid name' });
|
|
450
|
-
}
|
|
451
|
-
return mappedName ? { name: mappedName, address: mailbox.address } : mailbox.address;
|
|
452
|
-
});
|
|
453
|
-
})();
|
|
454
|
-
const rcptEmailForTemplate = (() => {
|
|
455
|
-
if (resolvedRecipients.length > 0) {
|
|
456
|
-
const mailbox = this.parseMailbox(resolvedRecipients[0].email);
|
|
457
|
-
return mailbox?.address ?? resolvedRecipients[0].email;
|
|
458
|
-
}
|
|
459
|
-
const mailbox = this.parseMailbox(String(form.recipient ?? ''));
|
|
460
|
-
return mailbox?.address ?? String(form.recipient ?? '');
|
|
461
|
-
})();
|
|
158
|
+
const to = buildRecipientTo(form, resolvedRecipients);
|
|
462
159
|
const replyToValue = buildReplyToValue(form, fields);
|
|
463
|
-
const context = {
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
};
|
|
160
|
+
const context = buildSubmissionContext({
|
|
161
|
+
form_key,
|
|
162
|
+
localeRaw,
|
|
163
|
+
recipients,
|
|
164
|
+
rcptEmail,
|
|
165
|
+
rcptName,
|
|
166
|
+
rcptIdname,
|
|
167
|
+
rcptIdnames,
|
|
168
|
+
attachmentMap,
|
|
169
|
+
fields,
|
|
170
|
+
files: rawFiles,
|
|
171
|
+
meta
|
|
172
|
+
});
|
|
477
173
|
nunjucks.configure({ autoescape: this.server.storage.vars.AUTOESCAPE_HTML });
|
|
478
174
|
const html = nunjucks.renderString(form.template, context);
|
|
479
175
|
const mailOptions = {
|
|
@@ -481,7 +177,7 @@ export class FormAPI extends ApiModule {
|
|
|
481
177
|
to,
|
|
482
178
|
subject: form.subject,
|
|
483
179
|
html,
|
|
484
|
-
attachments,
|
|
180
|
+
attachments: allAttachments,
|
|
485
181
|
...(replyToValue ? { replyTo: replyToValue } : {})
|
|
486
182
|
};
|
|
487
183
|
try {
|
package/dist/models/form.js
CHANGED
|
@@ -41,7 +41,7 @@ export const api_form_schema = z
|
|
|
41
41
|
.string()
|
|
42
42
|
.default('')
|
|
43
43
|
.describe('Relative path (within the config tree) of the source .njk template file for this form.'),
|
|
44
|
-
slug: z.string().default('').describe('Generated slug
|
|
44
|
+
slug: z.string().default('').describe('Generated slug for this form record (domain + locale + idname).'),
|
|
45
45
|
secret: z
|
|
46
46
|
.string()
|
|
47
47
|
.default('')
|
|
@@ -214,12 +214,11 @@ export async function init_api_form(api_db) {
|
|
|
214
214
|
}
|
|
215
215
|
export async function upsert_form(record) {
|
|
216
216
|
const { user, domain } = await user_and_domain(record.domain_id);
|
|
217
|
-
const idname = normalizeSlug(user.idname);
|
|
218
217
|
const dname = normalizeSlug(domain.name);
|
|
219
218
|
const name = normalizeSlug(record.idname);
|
|
220
219
|
const locale = normalizeSlug(record.locale || domain.locale || user.locale || '');
|
|
221
220
|
if (!record.slug) {
|
|
222
|
-
record.slug = `${
|
|
221
|
+
record.slug = `${dname}${locale ? '-' + locale : ''}-${name}`;
|
|
223
222
|
}
|
|
224
223
|
if (!record.filename) {
|
|
225
224
|
const parts = [dname, 'form-template'];
|
package/dist/models/init.js
CHANGED
|
@@ -8,6 +8,15 @@ import { api_domain, api_domain_schema } from './domain.js';
|
|
|
8
8
|
import { api_form_schema, upsert_form } from './form.js';
|
|
9
9
|
import { api_txmail_schema, upsert_txmail } from './txmail.js';
|
|
10
10
|
import { apiTokenToHmac, api_user, api_user_schema } from './user.js';
|
|
11
|
+
function buildInlineAssetCid(urlPath) {
|
|
12
|
+
// Many mail clients are picky about Content-ID values. Keep it stable and avoid path separators.
|
|
13
|
+
// Use a sanitized urlPath so nested assets remain unique without embedding `/` in the CID.
|
|
14
|
+
const normalized = String(urlPath || '')
|
|
15
|
+
.trim()
|
|
16
|
+
.replace(/\\/g, '/');
|
|
17
|
+
const safe = normalized.replace(/[^A-Za-z0-9._-]/g, '_').replace(/_+/g, '_');
|
|
18
|
+
return (safe || 'asset').slice(0, 200);
|
|
19
|
+
}
|
|
11
20
|
const init_data_schema = z.object({
|
|
12
21
|
user: z.array(api_user_schema).default([]),
|
|
13
22
|
domain: z.array(api_domain_schema).default([]),
|
|
@@ -57,7 +66,7 @@ async function _load_template(store, filename, pathname, user, domain, locale, t
|
|
|
57
66
|
return {
|
|
58
67
|
filename: urlPath,
|
|
59
68
|
path: asset.path,
|
|
60
|
-
cid: asset.cid ? urlPath : undefined
|
|
69
|
+
cid: asset.cid ? buildInlineAssetCid(urlPath) : undefined
|
|
61
70
|
};
|
|
62
71
|
});
|
|
63
72
|
for (const asset of assets) {
|
|
@@ -66,8 +75,9 @@ async function _load_template(store, filename, pathname, user, domain, locale, t
|
|
|
66
75
|
}
|
|
67
76
|
const rel = asset.filename.replace(/\\/g, '/');
|
|
68
77
|
const urlPath = rel.startsWith('assets/') ? rel.slice('assets/'.length) : rel;
|
|
69
|
-
|
|
70
|
-
|
|
78
|
+
const desiredCid = buildInlineAssetCid(urlPath);
|
|
79
|
+
if (asset.cid !== desiredCid) {
|
|
80
|
+
html = html.replaceAll(`cid:${asset.cid}`, `cid:${desiredCid}`);
|
|
71
81
|
}
|
|
72
82
|
}
|
|
73
83
|
return { html, assets: mappedAssets };
|
package/dist/models/txmail.js
CHANGED
|
@@ -14,7 +14,7 @@ export const api_txmail_schema = z
|
|
|
14
14
|
filename: z.string().default('').describe('Relative path of the source .njk template file.'),
|
|
15
15
|
sender: z.string().min(1).describe('Email From header used when delivering this template.'),
|
|
16
16
|
subject: z.string().describe('Email subject used when delivering this template.'),
|
|
17
|
-
slug: z.string().default('').describe('Generated slug
|
|
17
|
+
slug: z.string().default('').describe('Generated slug for this template record (domain + locale + name).'),
|
|
18
18
|
part: z.boolean().default(false).describe('If true, template is a partial (not a standalone send).'),
|
|
19
19
|
files: z
|
|
20
20
|
.array(z.object({
|
|
@@ -32,12 +32,11 @@ export class api_txmail extends Model {
|
|
|
32
32
|
}
|
|
33
33
|
export async function upsert_txmail(record) {
|
|
34
34
|
const { user, domain } = await user_and_domain(record.domain_id);
|
|
35
|
-
const idname = normalizeSlug(user.idname);
|
|
36
35
|
const dname = normalizeSlug(domain.name);
|
|
37
36
|
const name = normalizeSlug(record.name);
|
|
38
37
|
const locale = normalizeSlug(record.locale || domain.locale || user.locale || '');
|
|
39
38
|
if (!record.slug) {
|
|
40
|
-
record.slug = `${
|
|
39
|
+
record.slug = `${dname}${locale ? '-' + locale : ''}-${name}`;
|
|
41
40
|
}
|
|
42
41
|
if (!record.filename) {
|
|
43
42
|
const parts = [dname, 'tx-template'];
|
|
@@ -155,7 +154,7 @@ export async function init_api_txmail(api_db) {
|
|
|
155
154
|
const dname = normalizeSlug(domain.name);
|
|
156
155
|
const name = normalizeSlug(template.name);
|
|
157
156
|
const locale = normalizeSlug(template.locale || domain.locale || user.locale || '');
|
|
158
|
-
template.slug ||= `${
|
|
157
|
+
template.slug ||= `${dname}${locale ? '-' + locale : ''}-${name}`;
|
|
159
158
|
if (!template.filename) {
|
|
160
159
|
const parts = [dname, 'tx-template'];
|
|
161
160
|
if (locale)
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import { ApiError } from '@technomoron/api-server-base';
|
|
3
|
+
import { api_form } from '../models/form.js';
|
|
4
|
+
import { api_recipient } from '../models/recipient.js';
|
|
5
|
+
import { verifyCaptcha } from './captcha.js';
|
|
6
|
+
import { parseMailbox } from './email.js';
|
|
7
|
+
import { extractReplyToFromSubmission } from './form-replyto.js';
|
|
8
|
+
import { parseFormSubmissionInput } from './form-submission.js';
|
|
9
|
+
import { normalizeBoolean, normalizeSlug } from './utils.js';
|
|
10
|
+
export function parsePublicSubmissionOrThrow(apireq) {
|
|
11
|
+
try {
|
|
12
|
+
return parseFormSubmissionInput(apireq.req.body);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
// Treat malformed input as a bad request (Zod schema failures, non-object bodies, etc).
|
|
16
|
+
throw new ApiError({ code: 400, message: 'Invalid form submission payload' });
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export function enforceAttachmentPolicy(env, rawFiles) {
|
|
20
|
+
if (env.FORM_MAX_ATTACHMENTS === 0 && rawFiles.length > 0) {
|
|
21
|
+
throw new ApiError({ code: 413, message: 'This endpoint does not accept file attachments' });
|
|
22
|
+
}
|
|
23
|
+
for (const file of rawFiles) {
|
|
24
|
+
if (!file?.fieldname) {
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (!file.fieldname.startsWith('_mm_file')) {
|
|
28
|
+
throw new ApiError({
|
|
29
|
+
code: 400,
|
|
30
|
+
message: 'Invalid upload field name. Use _mm_file* for attachments.'
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (env.FORM_MAX_ATTACHMENTS > 0 && rawFiles.length > env.FORM_MAX_ATTACHMENTS) {
|
|
35
|
+
throw new ApiError({
|
|
36
|
+
code: 413,
|
|
37
|
+
message: `Too many attachments: ${rawFiles.length} > ${env.FORM_MAX_ATTACHMENTS}`
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export function filterSubmissionFields(rawFields, allowedFields) {
|
|
42
|
+
const allowed = Array.isArray(allowedFields) ? allowedFields : [];
|
|
43
|
+
if (!allowed.length) {
|
|
44
|
+
return rawFields;
|
|
45
|
+
}
|
|
46
|
+
const filtered = {};
|
|
47
|
+
// Always allow Reply-To derivation fields even when allowed_fields is configured.
|
|
48
|
+
const alwaysAllow = ['email', 'name', 'first_name', 'last_name'];
|
|
49
|
+
for (const key of alwaysAllow) {
|
|
50
|
+
if (Object.prototype.hasOwnProperty.call(rawFields, key)) {
|
|
51
|
+
filtered[key] = rawFields[key];
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
for (const key of allowed) {
|
|
55
|
+
const k = typeof key === 'string' ? key : String(key ?? '').trim();
|
|
56
|
+
if (!k) {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (Object.prototype.hasOwnProperty.call(rawFields, k)) {
|
|
60
|
+
filtered[k] = rawFields[k];
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return filtered;
|
|
64
|
+
}
|
|
65
|
+
export async function enforceCaptchaPolicy(params) {
|
|
66
|
+
const captchaRequired = Boolean(params.vars.FORM_CAPTCHA_REQUIRED || params.form.captcha_required);
|
|
67
|
+
const captchaSecret = String(params.vars.FORM_CAPTCHA_SECRET ?? '').trim();
|
|
68
|
+
if (!captchaSecret) {
|
|
69
|
+
if (captchaRequired) {
|
|
70
|
+
throw new ApiError({ code: 500, message: 'Captcha is required but not configured on the server' });
|
|
71
|
+
}
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (!params.captchaToken) {
|
|
75
|
+
if (captchaRequired) {
|
|
76
|
+
throw new ApiError({ code: 403, message: 'Captcha token required' });
|
|
77
|
+
}
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const provider = params.vars.FORM_CAPTCHA_PROVIDER;
|
|
81
|
+
const ok = await verifyCaptcha({
|
|
82
|
+
provider,
|
|
83
|
+
secret: captchaSecret,
|
|
84
|
+
token: params.captchaToken,
|
|
85
|
+
remoteip: params.clientIp || null
|
|
86
|
+
});
|
|
87
|
+
if (!ok) {
|
|
88
|
+
throw new ApiError({ code: 403, message: 'Captcha verification failed' });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
export function buildReplyToValue(form, fields) {
|
|
92
|
+
const forced = typeof form.replyto_email === 'string' ? form.replyto_email.trim() : '';
|
|
93
|
+
const forcedValue = forced ? forced : '';
|
|
94
|
+
if (form.replyto_from_fields) {
|
|
95
|
+
const extracted = extractReplyToFromSubmission(fields);
|
|
96
|
+
if (extracted) {
|
|
97
|
+
return extracted;
|
|
98
|
+
}
|
|
99
|
+
return forcedValue || undefined;
|
|
100
|
+
}
|
|
101
|
+
return forcedValue || undefined;
|
|
102
|
+
}
|
|
103
|
+
export function parseIdnameList(value, field) {
|
|
104
|
+
if (value === undefined || value === null || value === '') {
|
|
105
|
+
return [];
|
|
106
|
+
}
|
|
107
|
+
const raw = Array.isArray(value) ? value : [value];
|
|
108
|
+
const out = [];
|
|
109
|
+
for (const entry of raw) {
|
|
110
|
+
const str = String(entry ?? '').trim();
|
|
111
|
+
if (!str) {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
// Allow comma-separated convenience in form-encoded inputs while keeping the field name canonical.
|
|
115
|
+
const parts = str.split(',').map((p) => p.trim());
|
|
116
|
+
for (const part of parts) {
|
|
117
|
+
if (!part) {
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const normalized = normalizeSlug(part);
|
|
121
|
+
if (!normalized) {
|
|
122
|
+
throw new ApiError({ code: 400, message: `Invalid ${field} identifier "${part}"` });
|
|
123
|
+
}
|
|
124
|
+
out.push(normalized);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return Array.from(new Set(out));
|
|
128
|
+
}
|
|
129
|
+
export function parseRecipientPayload(body) {
|
|
130
|
+
return {
|
|
131
|
+
idnameRaw: String(body.idname ?? ''),
|
|
132
|
+
emailRaw: String(body.email ?? ''),
|
|
133
|
+
nameRaw: String(body.name ?? ''),
|
|
134
|
+
formKeyRaw: String(body.form_key ?? ''),
|
|
135
|
+
formid: String(body.formid ?? ''),
|
|
136
|
+
localeRaw: String(body.locale ?? '')
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
export function normalizeRecipientIdname(raw) {
|
|
140
|
+
if (!raw) {
|
|
141
|
+
throw new ApiError({ code: 400, message: 'Missing recipient identifier (idname)' });
|
|
142
|
+
}
|
|
143
|
+
const idname = normalizeSlug(raw);
|
|
144
|
+
if (!idname) {
|
|
145
|
+
throw new ApiError({ code: 400, message: 'Invalid recipient identifier (idname)' });
|
|
146
|
+
}
|
|
147
|
+
return idname;
|
|
148
|
+
}
|
|
149
|
+
export function normalizeRecipientEmail(raw) {
|
|
150
|
+
if (!raw) {
|
|
151
|
+
throw new ApiError({ code: 400, message: 'Missing recipient email address' });
|
|
152
|
+
}
|
|
153
|
+
const mailbox = parseMailbox(raw);
|
|
154
|
+
if (!mailbox) {
|
|
155
|
+
throw new ApiError({ code: 400, message: 'Invalid recipient email address' });
|
|
156
|
+
}
|
|
157
|
+
const email = mailbox.address;
|
|
158
|
+
if (/[\r\n]/.test(email)) {
|
|
159
|
+
throw new ApiError({ code: 400, message: 'Invalid recipient email address' });
|
|
160
|
+
}
|
|
161
|
+
return { email, mailbox };
|
|
162
|
+
}
|
|
163
|
+
export function normalizeRecipientName(raw, mailboxName) {
|
|
164
|
+
const name = String(raw || mailboxName || '')
|
|
165
|
+
.trim()
|
|
166
|
+
.slice(0, 200);
|
|
167
|
+
if (/[\r\n]/.test(name)) {
|
|
168
|
+
throw new ApiError({ code: 400, message: 'Invalid recipient name' });
|
|
169
|
+
}
|
|
170
|
+
return name;
|
|
171
|
+
}
|
|
172
|
+
export async function resolveFormKeyForRecipient(params) {
|
|
173
|
+
if (params.formKeyRaw) {
|
|
174
|
+
const form = await api_form.findOne({ where: { form_key: params.formKeyRaw } });
|
|
175
|
+
if (!form || form.domain_id !== params.domain.domain_id || form.user_id !== params.user.user_id) {
|
|
176
|
+
throw new ApiError({ code: 404, message: 'No such form_key for this domain' });
|
|
177
|
+
}
|
|
178
|
+
return form.form_key ?? '';
|
|
179
|
+
}
|
|
180
|
+
if (params.formid) {
|
|
181
|
+
const locale = params.localeRaw ? normalizeSlug(params.localeRaw) : '';
|
|
182
|
+
if (locale) {
|
|
183
|
+
const form = await api_form.findOne({
|
|
184
|
+
where: {
|
|
185
|
+
user_id: params.user.user_id,
|
|
186
|
+
domain_id: params.domain.domain_id,
|
|
187
|
+
locale,
|
|
188
|
+
idname: params.formid
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
if (!form) {
|
|
192
|
+
throw new ApiError({ code: 404, message: 'No such form for this domain/locale' });
|
|
193
|
+
}
|
|
194
|
+
return form.form_key ?? '';
|
|
195
|
+
}
|
|
196
|
+
const matches = await api_form.findAll({
|
|
197
|
+
where: { user_id: params.user.user_id, domain_id: params.domain.domain_id, idname: params.formid },
|
|
198
|
+
limit: 2
|
|
199
|
+
});
|
|
200
|
+
if (matches.length === 0) {
|
|
201
|
+
throw new ApiError({ code: 404, message: 'No such form for this domain' });
|
|
202
|
+
}
|
|
203
|
+
if (matches.length > 1) {
|
|
204
|
+
throw new ApiError({
|
|
205
|
+
code: 409,
|
|
206
|
+
message: 'Form identifier is ambiguous; provide locale or form_key'
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
return matches[0].form_key ?? '';
|
|
210
|
+
}
|
|
211
|
+
return '';
|
|
212
|
+
}
|
|
213
|
+
export function parseAllowedFields(raw) {
|
|
214
|
+
if (raw === undefined || raw === null || raw === '') {
|
|
215
|
+
return [];
|
|
216
|
+
}
|
|
217
|
+
const items = Array.isArray(raw) ? raw : [raw];
|
|
218
|
+
const out = [];
|
|
219
|
+
for (const entry of items) {
|
|
220
|
+
if (typeof entry === 'string') {
|
|
221
|
+
// Accept JSON arrays and comma-separated convenience.
|
|
222
|
+
const trimmed = entry.trim();
|
|
223
|
+
if (trimmed.startsWith('[')) {
|
|
224
|
+
try {
|
|
225
|
+
const parsed = JSON.parse(trimmed);
|
|
226
|
+
if (Array.isArray(parsed)) {
|
|
227
|
+
for (const item of parsed) {
|
|
228
|
+
const key = String(item ?? '').trim();
|
|
229
|
+
if (key) {
|
|
230
|
+
out.push(key);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
// fall back to comma-splitting below
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
for (const part of trimmed.split(',').map((p) => p.trim())) {
|
|
241
|
+
if (part) {
|
|
242
|
+
out.push(part);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
const key = String(entry ?? '').trim();
|
|
248
|
+
if (key) {
|
|
249
|
+
out.push(key);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return Array.from(new Set(out));
|
|
254
|
+
}
|
|
255
|
+
export function parseFormTemplatePayload(body) {
|
|
256
|
+
const template = body.template ? String(body.template) : '';
|
|
257
|
+
const sender = body.sender ? String(body.sender) : '';
|
|
258
|
+
const recipient = body.recipient ? String(body.recipient) : '';
|
|
259
|
+
const idname = body.idname ? String(body.idname) : '';
|
|
260
|
+
const subject = body.subject ? String(body.subject) : '';
|
|
261
|
+
const locale = body.locale ? String(body.locale) : '';
|
|
262
|
+
const secret = body.secret ? String(body.secret) : '';
|
|
263
|
+
const replyto_email = String(body.replyto_email ?? '').trim();
|
|
264
|
+
const replyto_from_fields = normalizeBoolean(body.replyto_from_fields);
|
|
265
|
+
const captcha_required = normalizeBoolean(body.captcha_required);
|
|
266
|
+
const allowed_fields = parseAllowedFields(body.allowed_fields);
|
|
267
|
+
return {
|
|
268
|
+
template,
|
|
269
|
+
sender,
|
|
270
|
+
recipient,
|
|
271
|
+
idname,
|
|
272
|
+
subject,
|
|
273
|
+
locale,
|
|
274
|
+
secret,
|
|
275
|
+
replyto_email,
|
|
276
|
+
replyto_from_fields,
|
|
277
|
+
allowed_fields,
|
|
278
|
+
captcha_required
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
export function validateFormTemplatePayload(payload) {
|
|
282
|
+
if (!payload.template) {
|
|
283
|
+
throw new ApiError({ code: 400, message: 'Missing template data' });
|
|
284
|
+
}
|
|
285
|
+
if (!payload.idname) {
|
|
286
|
+
throw new ApiError({ code: 400, message: 'Missing form identifier' });
|
|
287
|
+
}
|
|
288
|
+
if (!payload.sender) {
|
|
289
|
+
throw new ApiError({ code: 400, message: 'Missing sender address' });
|
|
290
|
+
}
|
|
291
|
+
if (!payload.recipient) {
|
|
292
|
+
throw new ApiError({ code: 400, message: 'Missing recipient address' });
|
|
293
|
+
}
|
|
294
|
+
if (payload.replyto_email) {
|
|
295
|
+
const mailbox = parseMailbox(payload.replyto_email);
|
|
296
|
+
if (!mailbox) {
|
|
297
|
+
throw new ApiError({ code: 400, message: 'Invalid replyto_email address' });
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
export function buildFormTemplatePaths(params) {
|
|
302
|
+
const domainSlug = normalizeSlug(params.domain.name);
|
|
303
|
+
const formSlug = normalizeSlug(params.idname);
|
|
304
|
+
const localeSlug = normalizeSlug(params.locale || params.domain.locale || params.user.locale || '');
|
|
305
|
+
const slug = `${domainSlug}${localeSlug ? '-' + localeSlug : ''}-${formSlug}`;
|
|
306
|
+
const filenameParts = [domainSlug, 'form-template'];
|
|
307
|
+
if (localeSlug) {
|
|
308
|
+
filenameParts.push(localeSlug);
|
|
309
|
+
}
|
|
310
|
+
filenameParts.push(formSlug);
|
|
311
|
+
let filename = path.join(...filenameParts);
|
|
312
|
+
if (!filename.endsWith('.njk')) {
|
|
313
|
+
filename += '.njk';
|
|
314
|
+
}
|
|
315
|
+
return { localeSlug, slug, filename };
|
|
316
|
+
}
|
|
317
|
+
export async function resolveFormKeyForTemplate(params) {
|
|
318
|
+
try {
|
|
319
|
+
const existing = await api_form.findOne({
|
|
320
|
+
where: {
|
|
321
|
+
user_id: params.user_id,
|
|
322
|
+
domain_id: params.domain_id,
|
|
323
|
+
locale: params.locale,
|
|
324
|
+
idname: params.idname
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
return existing?.form_key || '';
|
|
328
|
+
}
|
|
329
|
+
catch {
|
|
330
|
+
return '';
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
export function buildFormTemplateRecord(params) {
|
|
334
|
+
return {
|
|
335
|
+
form_key: params.form_key,
|
|
336
|
+
user_id: params.user_id,
|
|
337
|
+
domain_id: params.domain_id,
|
|
338
|
+
locale: params.locale,
|
|
339
|
+
idname: params.payload.idname,
|
|
340
|
+
sender: params.payload.sender,
|
|
341
|
+
recipient: params.payload.recipient,
|
|
342
|
+
subject: params.payload.subject,
|
|
343
|
+
template: params.payload.template,
|
|
344
|
+
slug: params.slug,
|
|
345
|
+
filename: params.filename,
|
|
346
|
+
secret: params.payload.secret,
|
|
347
|
+
replyto_email: params.payload.replyto_email,
|
|
348
|
+
replyto_from_fields: params.payload.replyto_from_fields,
|
|
349
|
+
allowed_fields: params.payload.allowed_fields,
|
|
350
|
+
captcha_required: params.payload.captcha_required,
|
|
351
|
+
files: []
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
export async function resolveRecipients(form, recipientsRaw) {
|
|
355
|
+
const scopeFormKey = String(form.form_key ?? '').trim();
|
|
356
|
+
if (!scopeFormKey) {
|
|
357
|
+
throw new ApiError({ code: 500, message: 'Form is missing a form_key' });
|
|
358
|
+
}
|
|
359
|
+
const resolveRecipient = async (idname) => {
|
|
360
|
+
const scoped = await api_recipient.findOne({
|
|
361
|
+
where: { domain_id: form.domain_id, form_key: scopeFormKey, idname }
|
|
362
|
+
});
|
|
363
|
+
if (scoped) {
|
|
364
|
+
return scoped;
|
|
365
|
+
}
|
|
366
|
+
return api_recipient.findOne({ where: { domain_id: form.domain_id, form_key: '', idname } });
|
|
367
|
+
};
|
|
368
|
+
const recipients = parseIdnameList(recipientsRaw, 'recipients');
|
|
369
|
+
if (recipients.length > 25) {
|
|
370
|
+
throw new ApiError({ code: 400, message: 'Too many recipients requested' });
|
|
371
|
+
}
|
|
372
|
+
const resolvedRecipients = [];
|
|
373
|
+
for (const idname of recipients) {
|
|
374
|
+
const record = await resolveRecipient(idname);
|
|
375
|
+
if (!record) {
|
|
376
|
+
throw new ApiError({ code: 404, message: `Unknown recipient identifier "${idname}"` });
|
|
377
|
+
}
|
|
378
|
+
resolvedRecipients.push(record);
|
|
379
|
+
}
|
|
380
|
+
return resolvedRecipients;
|
|
381
|
+
}
|
|
382
|
+
export function buildRecipientTo(form, recipients) {
|
|
383
|
+
if (recipients.length === 0) {
|
|
384
|
+
return form.recipient;
|
|
385
|
+
}
|
|
386
|
+
return recipients.map((entry) => {
|
|
387
|
+
const mailbox = parseMailbox(entry.email);
|
|
388
|
+
if (!mailbox) {
|
|
389
|
+
throw new ApiError({ code: 500, message: 'Recipient mapping has an invalid email address' });
|
|
390
|
+
}
|
|
391
|
+
const mappedName = entry.name ? String(entry.name).trim().slice(0, 200) : '';
|
|
392
|
+
if (mappedName && /[\r\n]/.test(mappedName)) {
|
|
393
|
+
throw new ApiError({ code: 500, message: 'Recipient mapping has an invalid name' });
|
|
394
|
+
}
|
|
395
|
+
return mappedName ? { name: mappedName, address: mailbox.address } : mailbox.address;
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
export function getPrimaryRecipientInfo(form, recipients) {
|
|
399
|
+
if (recipients.length > 0) {
|
|
400
|
+
const mailbox = parseMailbox(recipients[0].email);
|
|
401
|
+
return {
|
|
402
|
+
rcptEmail: mailbox?.address ?? recipients[0].email,
|
|
403
|
+
rcptName: recipients[0].name ? String(recipients[0].name).trim().slice(0, 200) : '',
|
|
404
|
+
rcptIdname: recipients[0].idname ?? '',
|
|
405
|
+
rcptIdnames: recipients.map((entry) => entry.idname)
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
const mailbox = parseMailbox(String(form.recipient ?? ''));
|
|
409
|
+
return {
|
|
410
|
+
rcptEmail: mailbox?.address ?? String(form.recipient ?? ''),
|
|
411
|
+
rcptName: '',
|
|
412
|
+
rcptIdname: '',
|
|
413
|
+
rcptIdnames: []
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
export function buildSubmissionContext(params) {
|
|
417
|
+
return {
|
|
418
|
+
_mm_form_key: params.form_key,
|
|
419
|
+
_mm_recipients: params.recipients,
|
|
420
|
+
_mm_locale: params.localeRaw,
|
|
421
|
+
_rcpt_email_: params.rcptEmail,
|
|
422
|
+
_rcpt_name_: params.rcptName,
|
|
423
|
+
_rcpt_idname_: params.rcptIdname,
|
|
424
|
+
_rcpt_idnames_: params.rcptIdnames,
|
|
425
|
+
_attachments_: params.attachmentMap,
|
|
426
|
+
_vars_: {},
|
|
427
|
+
_fields_: params.fields,
|
|
428
|
+
_files_: params.files,
|
|
429
|
+
_meta_: params.meta
|
|
430
|
+
};
|
|
431
|
+
}
|
package/dist/util.js
CHANGED