@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 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
 
@@ -1,44 +1 @@
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
- }
1
+ export * from '../util/form-replyto.js';
@@ -1,95 +1 @@
1
- import { z } from 'zod';
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 { verifyCaptcha } from '../util/captcha.js';
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, normalizeBoolean, normalizeSlug } from '../util.js';
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 idnameRaw = getBodyValue(body, 'idname');
145
- const emailRaw = getBodyValue(body, 'email');
146
- const nameRaw = getBodyValue(body, 'name');
147
- const formKeyRaw = getBodyValue(body, 'form_key');
148
- const formid = getBodyValue(body, 'formid');
149
- const localeRaw = getBodyValue(body, 'locale');
150
- if (!idnameRaw) {
151
- throw new ApiError({ code: 400, message: 'Missing recipient identifier (idname)' });
152
- }
153
- const idname = normalizeSlug(idnameRaw);
154
- if (!idname) {
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
- let form_key = '';
177
- if (formKeyRaw) {
178
- const form = await api_form.findOne({ where: { form_key: formKeyRaw } });
179
- if (!form || form.domain_id !== domain.domain_id || form.user_id !== user.user_id) {
180
- throw new ApiError({ code: 404, message: 'No such form_key for this domain' });
181
- }
182
- form_key = form.form_key ?? '';
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 { template, sender = '', recipient = '', idname, subject = '', locale = '', secret = '', replyto_email: replytoEmailRaw = '', replyto_from_fields: replytoFromFieldsRaw = false, allowed_fields: allowedFieldsRaw, captcha_required: captchaRequiredRaw } = apireq.req.body;
238
- const captcha_required = normalizeBoolean(captchaRequiredRaw);
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 userSlug = normalizeSlug(user.idname);
305
- const domainSlug = normalizeSlug(domain.name);
306
- const formSlug = normalizeSlug(idname);
307
- const localeSlug = normalizeSlug(resolvedLocale || domain.locale || user.locale || '');
308
- const slug = `${userSlug}-${domainSlug}${localeSlug ? '-' + localeSlug : ''}-${formSlug}`;
309
- const filenameParts = [domainSlug, 'form-template'];
310
- if (localeSlug) {
311
- filenameParts.push(localeSlug);
312
- }
313
- filenameParts.push(formSlug);
314
- let filename = path.join(...filenameParts);
315
- if (!filename.endsWith('.njk')) {
316
- filename += '.njk';
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
- secret,
346
- replyto_email,
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 scopeFormKey = String(form.form_key ?? '').trim();
404
- if (!scopeFormKey) {
405
- throw new ApiError({ code: 500, message: 'Form is missing a form_key' });
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
- _mm_form_key: form_key,
465
- _mm_recipients: recipients,
466
- _mm_locale: localeRaw,
467
- _rcpt_email_: rcptEmailForTemplate,
468
- _rcpt_name_: resolvedRecipients[0]?.name ? String(resolvedRecipients[0].name).trim().slice(0, 200) : '',
469
- _rcpt_idname_: resolvedRecipients[0]?.idname ?? '',
470
- _rcpt_idnames_: resolvedRecipients.map((entry) => entry.idname),
471
- _attachments_: attachmentMap,
472
- _vars_: thevars,
473
- _fields_: fields,
474
- _files_: rawFiles,
475
- _meta_: meta
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 {
@@ -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 used to build stable filenames/paths for this form.'),
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 = `${idname}-${dname}${locale ? '-' + locale : ''}-${name}`;
221
+ record.slug = `${dname}${locale ? '-' + locale : ''}-${name}`;
223
222
  }
224
223
  if (!record.filename) {
225
224
  const parts = [dname, 'form-template'];
@@ -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
- if (asset.cid !== urlPath) {
70
- html = html.replaceAll(`cid:${asset.cid}`, `cid:${urlPath}`);
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 };
@@ -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 used to build stable filenames/paths.'),
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 = `${idname}-${dname}${locale ? '-' + locale : ''}-${name}`;
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 ||= `${normalizeSlug(user.idname)}-${dname}${locale ? '-' + locale : ''}-${name}`;
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
@@ -2,3 +2,6 @@ export * from './util/utils.js';
2
2
  export * from './util/email.js';
3
3
  export * from './util/paths.js';
4
4
  export * from './util/uploads.js';
5
+ export * from './util/form-replyto.js';
6
+ export * from './util/form-submission.js';
7
+ export * from './util/forms.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@technomoron/mail-magic",
3
- "version": "1.0.33",
3
+ "version": "1.0.34",
4
4
  "main": "dist/index.js",
5
5
  "type": "module",
6
6
  "bin": {