@technomoron/mail-magic 1.0.33 → 1.0.35

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,27 @@
1
+ Version 1.0.35 (2026-02-17)
2
+
3
+ - Enforce deterministic locale resolution for transactional template sends:
4
+ requested locale first, then root fallback (`locale=''`) only.
5
+ - Enforce the same deterministic locale resolution for template-scoped asset uploads
6
+ (`POST /api/v1/assets`) for both `tx` and `form` template types.
7
+ - Remove non-deterministic fallback to arbitrary locale records when requested/root
8
+ locales are missing.
9
+ - Add regression tests covering deterministic locale fallback and missing-locale
10
+ behavior for both transactional sends and template asset upload resolution.
11
+
12
+ Version 1.0.34 (2026-02-10)
13
+
14
+ - Ensure inline form template assets (CID) are actually attached when delivering public form submissions.
15
+ - Use safe, stable inline Content-IDs (no path separators) and keep stored `files[].cid` consistent with rendered HTML.
16
+ - Expand tests to validate inline assets, linked assets, and uploaded attachments end-to-end.
17
+ - Ensure record slugs and generated paths are domain-rooted only (no user segment).
18
+
1
19
  Version 1.0.33 (2026-02-09)
2
20
 
3
21
  - Route all env-derived config through `mailStore.vars` with explicit overrides for tests/examples.
4
22
  - Move shared helpers into `util/` modules (rate limiting, email parsing, path safety, uploads).
5
23
  - Switch template preprocessing to Unyuck’s asset collection while preserving existing URL and CID behavior.
24
+ - Split form submission logic into focused util helpers and slim the API handler.
6
25
 
7
26
  Version 1.0.32 (2026-02-08)
8
27
 
@@ -22,11 +22,9 @@ export class AssetAPI extends ApiModule {
22
22
  }
23
23
  const templateType = templateTypeRaw.toLowerCase();
24
24
  const domainId = apireq.domain.domain_id;
25
- const deflocale = this.server.storage.deflocale || '';
26
25
  if (templateType === 'tx') {
27
26
  const template = (await api_txmail.findOne({ where: { name: templateName, domain_id: domainId, locale } })) ||
28
- (await api_txmail.findOne({ where: { name: templateName, domain_id: domainId, locale: deflocale } })) ||
29
- (await api_txmail.findOne({ where: { name: templateName, domain_id: domainId } }));
27
+ (await api_txmail.findOne({ where: { name: templateName, domain_id: domainId, locale: '' } }));
30
28
  if (!template) {
31
29
  throw new ApiError({
32
30
  code: 404,
@@ -43,8 +41,7 @@ export class AssetAPI extends ApiModule {
43
41
  }
44
42
  if (templateType === 'form') {
45
43
  const form = (await api_form.findOne({ where: { idname: templateName, domain_id: domainId, locale } })) ||
46
- (await api_form.findOne({ where: { idname: templateName, domain_id: domainId, locale: deflocale } })) ||
47
- (await api_form.findOne({ where: { idname: templateName, domain_id: domainId } }));
44
+ (await api_form.findOne({ where: { idname: templateName, domain_id: domainId, locale: '' } }));
48
45
  if (!form) {
49
46
  throw new ApiError({
50
47
  code: 404,
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 {
@@ -100,13 +100,11 @@ export class MailerAPI extends ApiModule {
100
100
  throw new ApiError({ code: 400, message: 'Invalid email address(es): ' + invalid.join(',') });
101
101
  }
102
102
  let template = null;
103
- const deflocale = this.server.storage.deflocale || '';
104
103
  const domain_id = apireq.domain.domain_id;
105
104
  try {
106
105
  template =
107
106
  (await api_txmail.findOne({ where: { name, domain_id, locale } })) ||
108
- (await api_txmail.findOne({ where: { name, domain_id, locale: deflocale } })) ||
109
- (await api_txmail.findOne({ where: { name, domain_id } }));
107
+ (await api_txmail.findOne({ where: { name, domain_id, locale: '' } }));
110
108
  }
111
109
  catch (error) {
112
110
  throw new ApiError({
@@ -7,13 +7,15 @@ import { startMailMagicServer } from '../index.js';
7
7
  const args = process.argv.slice(2);
8
8
  function usage(exitCode = 0) {
9
9
  const out = exitCode === 0 ? process.stdout : process.stderr;
10
- out.write(`Usage: mail-magic [--env PATH]\n\n` +
10
+ out.write(`Usage: mail-magic [--env PATH] [--config DIR]\n\n` +
11
11
  `Options:\n` +
12
12
  ` -e, --env PATH Path to .env (defaults to ./.env)\n` +
13
+ ` -c, --config DIR Config directory (overrides CONFIG_PATH)\n` +
13
14
  ` -h, --help Show this help\n`);
14
15
  process.exit(exitCode);
15
16
  }
16
17
  let envPath;
18
+ let configPath;
17
19
  for (let i = 0; i < args.length; i += 1) {
18
20
  const arg = args[i];
19
21
  if (arg === '-h' || arg === '--help') {
@@ -29,10 +31,24 @@ for (let i = 0; i < args.length; i += 1) {
29
31
  i += 1;
30
32
  continue;
31
33
  }
34
+ if (arg === '-c' || arg === '--config') {
35
+ const next = args[i + 1];
36
+ if (!next) {
37
+ console.error('Error: --config requires a directory');
38
+ usage(1);
39
+ }
40
+ configPath = next;
41
+ i += 1;
42
+ continue;
43
+ }
32
44
  if (arg.startsWith('--env=')) {
33
45
  envPath = arg.slice('--env='.length);
34
46
  continue;
35
47
  }
48
+ if (arg.startsWith('--config=')) {
49
+ configPath = arg.slice('--config='.length);
50
+ continue;
51
+ }
36
52
  console.error(`Error: unknown option ${arg}`);
37
53
  usage(1);
38
54
  }
@@ -41,6 +57,19 @@ if (!fs.existsSync(resolvedEnvPath)) {
41
57
  console.error(`Error: env file not found at ${resolvedEnvPath}`);
42
58
  process.exit(1);
43
59
  }
60
+ let resolvedConfigPath;
61
+ if (configPath) {
62
+ // Resolve the config dir relative to the .env directory (we chdir there next).
63
+ resolvedConfigPath = path.resolve(path.dirname(resolvedEnvPath), configPath);
64
+ if (!fs.existsSync(resolvedConfigPath)) {
65
+ console.error(`Error: config dir not found at ${resolvedConfigPath}`);
66
+ process.exit(1);
67
+ }
68
+ if (!fs.statSync(resolvedConfigPath).isDirectory()) {
69
+ console.error(`Error: config path is not a directory: ${resolvedConfigPath}`);
70
+ process.exit(1);
71
+ }
72
+ }
44
73
  process.chdir(path.dirname(resolvedEnvPath));
45
74
  const result = dotenv.config({ path: resolvedEnvPath });
46
75
  if (result.error) {
@@ -50,7 +79,8 @@ if (result.error) {
50
79
  }
51
80
  async function main() {
52
81
  try {
53
- const { store, vars } = await startMailMagicServer();
82
+ const envOverrides = resolvedConfigPath ? { CONFIG_PATH: resolvedConfigPath } : {};
83
+ const { store, vars } = await startMailMagicServer({}, envOverrides);
54
84
  console.log(`Using config path: ${store.configpath}`);
55
85
  console.log(`mail-magic server listening on ${vars.API_HOST}:${vars.API_PORT}`);
56
86
  }
@@ -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)
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { getBodyValue } from '../util.js';
2
+ import { getBodyValue } from './utils.js';
3
3
  const ALLOWED_MM_KEYS = new Set(['_mm_form_key', '_mm_locale', '_mm_recipients']);
4
4
  function asRecord(input) {
5
5
  if (!input || typeof input !== 'object' || Array.isArray(input)) {
@@ -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.35",
4
4
  "main": "dist/index.js",
5
5
  "type": "module",
6
6
  "bin": {
File without changes