@technomoron/mail-magic 1.0.32 → 1.0.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGES +18 -0
  2. package/README.md +213 -122
  3. package/dist/api/assets.js +9 -56
  4. package/dist/api/auth.js +1 -12
  5. package/dist/api/form-replyto.js +1 -0
  6. package/dist/api/form-submission.js +1 -0
  7. package/dist/api/forms.js +114 -474
  8. package/dist/api/mailer.js +1 -1
  9. package/dist/bin/mail-magic.js +2 -2
  10. package/dist/index.js +30 -18
  11. package/dist/models/db.js +5 -5
  12. package/dist/models/domain.js +16 -8
  13. package/dist/models/form.js +111 -40
  14. package/dist/models/init.js +44 -74
  15. package/dist/models/recipient.js +12 -8
  16. package/dist/models/txmail.js +24 -28
  17. package/dist/models/user.js +14 -10
  18. package/dist/server.js +1 -1
  19. package/dist/store/store.js +53 -22
  20. package/dist/swagger.js +107 -0
  21. package/dist/util/captcha.js +24 -0
  22. package/dist/util/email.js +19 -0
  23. package/dist/util/form-replyto.js +44 -0
  24. package/dist/util/form-submission.js +95 -0
  25. package/dist/util/forms.js +431 -0
  26. package/dist/util/paths.js +41 -0
  27. package/dist/util/ratelimit.js +48 -0
  28. package/dist/util/uploads.js +48 -0
  29. package/dist/util/utils.js +151 -0
  30. package/dist/util.js +7 -127
  31. package/docs/config-example/example.test/assets/files/banner.png +1 -0
  32. package/docs/config-example/example.test/assets/images/logo.png +1 -0
  33. package/docs/config-example/example.test/form-template/base.njk +6 -0
  34. package/docs/config-example/example.test/form-template/contact.njk +9 -0
  35. package/docs/config-example/example.test/form-template/partials/fields.njk +3 -0
  36. package/docs/config-example/example.test/tx-template/base.njk +10 -0
  37. package/docs/config-example/example.test/tx-template/partials/header.njk +1 -0
  38. package/docs/config-example/example.test/tx-template/welcome.njk +10 -0
  39. package/docs/config-example/init-data.json +57 -0
  40. package/docs/form-security.md +194 -0
  41. package/docs/swagger/openapi.json +1321 -0
  42. package/{TUTORIAL.MD → docs/tutorial.md} +24 -15
  43. package/package.json +3 -3
package/dist/api/forms.js CHANGED
@@ -1,193 +1,40 @@
1
- import fs from 'node:fs';
2
- import path from 'path';
3
1
  import { ApiModule, ApiError } from '@technomoron/api-server-base';
4
- import emailAddresses from 'email-addresses';
5
2
  import { nanoid } from 'nanoid';
6
3
  import nunjucks from 'nunjucks';
7
- import { Op } from 'sequelize';
4
+ import { UniqueConstraintError } from 'sequelize';
8
5
  import { api_domain } from '../models/domain.js';
9
6
  import { api_form } from '../models/form.js';
10
7
  import { api_recipient } from '../models/recipient.js';
11
- import { buildRequestMeta, normalizeSlug } from '../util.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';
9
+ import { FixedWindowRateLimiter, enforceFormRateLimit } from '../util/ratelimit.js';
10
+ import { buildAttachments, cleanupUploadedFiles } from '../util/uploads.js';
11
+ import { buildRequestMeta, getBodyValue } from '../util.js';
12
12
  import { assert_domain_and_user } from './auth.js';
13
- function getBodyValue(body, ...keys) {
14
- for (const key of keys) {
15
- const value = body[key];
16
- if (Array.isArray(value) && value.length > 0) {
17
- return String(value[0]);
18
- }
19
- if (value !== undefined && value !== null) {
20
- return String(value);
21
- }
22
- }
23
- return '';
24
- }
25
- async function verifyCaptcha(params) {
26
- const endpoints = {
27
- turnstile: 'https://challenges.cloudflare.com/turnstile/v0/siteverify',
28
- hcaptcha: 'https://hcaptcha.com/siteverify',
29
- recaptcha: 'https://www.google.com/recaptcha/api/siteverify'
30
- };
31
- const endpoint = endpoints[params.provider] ?? endpoints.turnstile;
32
- const body = new URLSearchParams();
33
- body.set('secret', params.secret);
34
- body.set('response', params.token);
35
- if (params.remoteip) {
36
- body.set('remoteip', params.remoteip);
37
- }
38
- const res = await fetch(endpoint, {
39
- method: 'POST',
40
- headers: { 'content-type': 'application/x-www-form-urlencoded' },
41
- body
42
- });
43
- if (!res.ok) {
44
- return false;
45
- }
46
- const data = (await res.json().catch(() => null));
47
- return Boolean(data?.success);
48
- }
49
- async function cleanupUploadedFiles(files) {
50
- await Promise.all(files.map(async (file) => {
51
- if (!file?.path) {
52
- return;
53
- }
54
- try {
55
- await fs.promises.unlink(file.path);
56
- }
57
- catch {
58
- // best effort cleanup
59
- }
60
- }));
61
- }
62
- class FixedWindowRateLimiter {
63
- maxKeys;
64
- buckets = new Map();
65
- constructor(maxKeys = 10_000) {
66
- this.maxKeys = maxKeys;
67
- }
68
- check(key, max, windowMs) {
69
- if (!key || max <= 0 || windowMs <= 0) {
70
- return { allowed: true, retryAfterSec: 0 };
71
- }
72
- const now = Date.now();
73
- const bucket = this.buckets.get(key);
74
- if (!bucket || now - bucket.windowStartMs >= windowMs) {
75
- this.buckets.delete(key);
76
- this.buckets.set(key, { windowStartMs: now, count: 1 });
77
- this.prune();
78
- return { allowed: true, retryAfterSec: 0 };
79
- }
80
- bucket.count += 1;
81
- // Refresh insertion order to keep active entries at the end for pruning.
82
- this.buckets.delete(key);
83
- this.buckets.set(key, bucket);
84
- if (bucket.count <= max) {
85
- return { allowed: true, retryAfterSec: 0 };
86
- }
87
- const retryAfterSec = Math.max(1, Math.ceil((bucket.windowStartMs + windowMs - now) / 1000));
88
- return { allowed: false, retryAfterSec };
89
- }
90
- prune() {
91
- while (this.buckets.size > this.maxKeys) {
92
- const oldest = this.buckets.keys().next().value;
93
- if (!oldest) {
94
- break;
95
- }
96
- this.buckets.delete(oldest);
97
- }
98
- }
99
- }
100
13
  export class FormAPI extends ApiModule {
101
14
  rateLimiter = new FixedWindowRateLimiter();
102
- validateEmail(email) {
103
- const parsed = emailAddresses.parseOneAddress(email);
104
- if (parsed) {
105
- return parsed.address;
106
- }
107
- return undefined;
108
- }
109
- parseMailbox(value) {
110
- const parsed = emailAddresses.parseOneAddress(value);
111
- if (!parsed) {
112
- return undefined;
113
- }
114
- const mailbox = parsed;
115
- if (!mailbox?.address) {
116
- return undefined;
117
- }
118
- return mailbox;
119
- }
120
15
  async postFormRecipient(apireq) {
121
16
  await assert_domain_and_user(apireq);
122
17
  const body = (apireq.req.body ?? {});
123
- const idnameRaw = getBodyValue(body, 'idname', 'recipient_idname', 'recipientIdname');
124
- const emailRaw = getBodyValue(body, 'email');
125
- const nameRaw = getBodyValue(body, 'name');
126
- const formKeyRaw = getBodyValue(body, 'form_key', 'formKey', 'formkey');
127
- const formid = getBodyValue(body, 'formid');
128
- const localeRaw = getBodyValue(body, 'locale');
129
- if (!idnameRaw) {
130
- throw new ApiError({ code: 400, message: 'Missing recipient identifier (idname)' });
131
- }
132
- const idname = normalizeSlug(idnameRaw);
133
- if (!idname) {
134
- throw new ApiError({ code: 400, message: 'Invalid recipient identifier (idname)' });
135
- }
136
- if (!emailRaw) {
137
- throw new ApiError({ code: 400, message: 'Missing recipient email address' });
138
- }
139
- const mailbox = this.parseMailbox(emailRaw);
140
- if (!mailbox) {
141
- throw new ApiError({ code: 400, message: 'Invalid recipient email address' });
142
- }
143
- const email = mailbox.address;
144
- if (/[\r\n]/.test(email)) {
145
- throw new ApiError({ code: 400, message: 'Invalid recipient email address' });
146
- }
147
- const name = String(nameRaw || mailbox.name || '')
148
- .trim()
149
- .slice(0, 200);
150
- if (/[\r\n]/.test(name)) {
151
- throw new ApiError({ code: 400, message: 'Invalid recipient name' });
152
- }
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);
153
29
  const user = apireq.user;
154
30
  const domain = apireq.domain;
155
- let form_key = '';
156
- if (formKeyRaw) {
157
- const form = await api_form.findOne({ where: { form_key: formKeyRaw } });
158
- if (!form || form.domain_id !== domain.domain_id || form.user_id !== user.user_id) {
159
- throw new ApiError({ code: 404, message: 'No such form_key for this domain' });
160
- }
161
- form_key = form.form_key ?? '';
162
- }
163
- else if (formid) {
164
- const locale = localeRaw ? normalizeSlug(localeRaw) : '';
165
- if (locale) {
166
- const form = await api_form.findOne({
167
- where: { user_id: user.user_id, domain_id: domain.domain_id, locale, idname: formid }
168
- });
169
- if (!form) {
170
- throw new ApiError({ code: 404, message: 'No such form for this domain/locale' });
171
- }
172
- form_key = form.form_key ?? '';
173
- }
174
- else {
175
- const matches = await api_form.findAll({
176
- where: { user_id: user.user_id, domain_id: domain.domain_id, idname: formid },
177
- limit: 2
178
- });
179
- if (matches.length === 0) {
180
- throw new ApiError({ code: 404, message: 'No such form for this domain' });
181
- }
182
- if (matches.length > 1) {
183
- throw new ApiError({
184
- code: 409,
185
- message: 'Form identifier is ambiguous; provide locale or form_key'
186
- });
187
- }
188
- form_key = matches[0].form_key ?? '';
189
- }
190
- }
31
+ const form_key = await resolveFormKeyForRecipient({
32
+ formKeyRaw: payload.formKeyRaw,
33
+ formid: payload.formid,
34
+ localeRaw: payload.localeRaw,
35
+ user,
36
+ domain
37
+ });
191
38
  const record = {
192
39
  domain_id: domain.domain_id,
193
40
  form_key,
@@ -213,332 +60,125 @@ export class FormAPI extends ApiModule {
213
60
  }
214
61
  async postFormTemplate(apireq) {
215
62
  await assert_domain_and_user(apireq);
216
- const { template, sender = '', recipient = '', idname, subject = '', locale = '', secret = '', captcha_required: captchaRequiredRaw, captchaRequired: captchaRequiredAlt } = apireq.req.body;
217
- const captcha_required = (() => {
218
- const value = captchaRequiredRaw ?? captchaRequiredAlt;
219
- if (typeof value === 'boolean') {
220
- return value;
221
- }
222
- if (typeof value === 'number') {
223
- return value !== 0;
224
- }
225
- const normalized = String(value ?? '')
226
- .trim()
227
- .toLowerCase();
228
- return ['true', '1', 'yes', 'on'].includes(normalized);
229
- })();
230
- if (!template) {
231
- throw new ApiError({ code: 400, message: 'Missing template data' });
232
- }
233
- if (!idname) {
234
- throw new ApiError({ code: 400, message: 'Missing form identifier' });
235
- }
236
- if (!sender) {
237
- throw new ApiError({ code: 400, message: 'Missing sender address' });
238
- }
239
- if (!recipient) {
240
- throw new ApiError({ code: 400, message: 'Missing recipient address' });
241
- }
63
+ const payload = parseFormTemplatePayload(apireq.req.body ?? {});
64
+ validateFormTemplatePayload(payload);
242
65
  const user = apireq.user;
243
66
  const domain = apireq.domain;
244
- const resolvedLocale = locale || apireq.locale || '';
245
- const userSlug = normalizeSlug(user.idname);
246
- const domainSlug = normalizeSlug(domain.name);
247
- const formSlug = normalizeSlug(idname);
248
- const localeSlug = normalizeSlug(resolvedLocale || domain.locale || user.locale || '');
249
- const slug = `${userSlug}-${domainSlug}${localeSlug ? '-' + localeSlug : ''}-${formSlug}`;
250
- const filenameParts = [domainSlug, 'form-template'];
251
- if (localeSlug) {
252
- filenameParts.push(localeSlug);
253
- }
254
- filenameParts.push(formSlug);
255
- let filename = path.join(...filenameParts);
256
- if (!filename.endsWith('.njk')) {
257
- filename += '.njk';
258
- }
259
- let form_key = '';
260
- try {
261
- const existing = await api_form.findOne({
262
- where: {
263
- user_id: user.user_id,
264
- domain_id: domain.domain_id,
265
- locale: localeSlug,
266
- idname
267
- }
268
- });
269
- form_key = existing?.form_key || nanoid();
270
- }
271
- catch {
272
- form_key = nanoid();
273
- }
274
- 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({
275
81
  form_key,
276
82
  user_id: user.user_id,
277
83
  domain_id: domain.domain_id,
278
84
  locale: localeSlug,
279
- idname,
280
- sender,
281
- recipient,
282
- subject,
283
- template,
284
85
  slug,
285
86
  filename,
286
- secret,
287
- captcha_required,
288
- files: []
289
- };
87
+ payload
88
+ });
290
89
  let created = false;
291
- try {
292
- const [form, wasCreated] = await api_form.upsert(record, {
293
- returning: true,
294
- conflictFields: ['user_id', 'domain_id', 'locale', 'idname']
295
- });
296
- created = wasCreated ?? false;
297
- form_key = form.form_key || form_key;
298
- this.server.storage.print_debug(`Form template upserted: ${form.idname} (created=${wasCreated})`);
299
- }
300
- catch (error) {
301
- throw new ApiError({
302
- code: 500,
303
- message: this.server.guessExceptionText(error, 'Unknown Sequelize Error on upsert form template')
304
- });
90
+ for (let attempt = 0; attempt < 10; attempt++) {
91
+ try {
92
+ const [form, wasCreated] = await api_form.upsert(record, {
93
+ returning: true,
94
+ conflictFields: ['user_id', 'domain_id', 'locale', 'idname']
95
+ });
96
+ created = wasCreated ?? false;
97
+ form_key = form.form_key || form_key;
98
+ this.server.storage.print_debug(`Form template upserted: ${form.idname} (created=${wasCreated})`);
99
+ break;
100
+ }
101
+ catch (error) {
102
+ if (error instanceof UniqueConstraintError) {
103
+ const conflicted = error.errors?.some((e) => e.path === 'form_key');
104
+ if (conflicted) {
105
+ record.form_key = nanoid();
106
+ form_key = record.form_key;
107
+ continue;
108
+ }
109
+ }
110
+ throw new ApiError({
111
+ code: 500,
112
+ message: this.server.guessExceptionText(error, 'Unknown Sequelize Error on upsert form template')
113
+ });
114
+ }
305
115
  }
306
116
  return [200, { Status: 'OK', created, form_key }];
307
117
  }
308
118
  async postSendForm(apireq) {
309
- const env = this.server.storage.env;
119
+ const env = this.server.storage.vars;
310
120
  const rawFiles = Array.isArray(apireq.req.files) ? apireq.req.files : [];
311
121
  const keepUploads = env.FORM_KEEP_UPLOADS;
312
122
  try {
313
- const body = (apireq.req.body ?? {});
314
- const formid = getBodyValue(body, 'formid');
315
- const form_key = getBodyValue(body, 'form_key', 'formkey', 'formKey');
316
- const domainName = getBodyValue(body, 'domain');
317
- const localeRaw = getBodyValue(body, 'locale');
318
- const secret = getBodyValue(body, 'secret');
319
- const recipient = getBodyValue(body, 'recipient');
320
- const recipientIdnameRaw = getBodyValue(body, 'recipient_idname', 'recipientIdname', 'recipient_slug', 'recipientSlug', 'recipient_key', 'recipientKey');
321
- const replyTo = getBodyValue(body, 'replyTo', 'reply_to');
322
- const vars = body.vars ?? {};
323
- const clientIp = apireq.getClientIp() ?? '';
324
- const windowMs = Math.max(0, env.FORM_RATE_LIMIT_WINDOW_SEC) * 1000;
325
- const decision = this.rateLimiter.check(`form-message:${clientIp || 'unknown'}`, env.FORM_RATE_LIMIT_MAX, windowMs);
326
- if (!decision.allowed) {
327
- apireq.res.set('Retry-After', String(decision.retryAfterSec));
328
- throw new ApiError({ code: 429, message: 'Too many form submissions; try again later' });
329
- }
330
- if (env.FORM_MAX_ATTACHMENTS === 0 && rawFiles.length > 0) {
331
- throw new ApiError({ code: 413, message: 'This endpoint does not accept file attachments' });
332
- }
333
- if (env.FORM_MAX_ATTACHMENTS > 0 && rawFiles.length > env.FORM_MAX_ATTACHMENTS) {
334
- throw new ApiError({
335
- code: 413,
336
- message: `Too many attachments: ${rawFiles.length} > ${env.FORM_MAX_ATTACHMENTS}`
337
- });
338
- }
339
- if (!form_key && !formid) {
340
- throw new ApiError({ code: 404, message: 'Missing formid field in form' });
341
- }
342
- let form = null;
343
- if (form_key) {
344
- form = await api_form.findOne({ where: { form_key } });
345
- if (!form) {
346
- throw new ApiError({ code: 404, message: 'No such form_key' });
347
- }
348
- }
349
- else {
350
- if (!domainName) {
351
- throw new ApiError({ code: 400, message: 'Missing domain (or form_key)' });
352
- }
353
- const domains = await api_domain.findAll({ where: { name: domainName } });
354
- if (domains.length === 0) {
355
- throw new ApiError({ code: 404, message: `No such domain: ${domainName}` });
356
- }
357
- const domainIds = domains.map((domain) => domain.domain_id);
358
- const domainWhere = { [Op.in]: domainIds };
359
- if (localeRaw) {
360
- const locale = normalizeSlug(localeRaw);
361
- form = await api_form.findOne({
362
- where: {
363
- idname: formid,
364
- domain_id: domainWhere,
365
- locale
366
- }
367
- });
368
- }
369
- else if (domains.length === 1) {
370
- const locale = normalizeSlug(domains[0].locale || '');
371
- if (locale) {
372
- form = await api_form.findOne({
373
- where: {
374
- idname: formid,
375
- domain_id: domainWhere,
376
- locale
377
- }
378
- });
379
- }
380
- }
381
- if (!form) {
382
- const matches = await api_form.findAll({
383
- where: {
384
- idname: formid,
385
- domain_id: domainWhere
386
- },
387
- limit: 2
388
- });
389
- if (matches.length === 1) {
390
- form = matches[0];
391
- }
392
- else if (matches.length > 1) {
393
- throw new ApiError({
394
- code: 409,
395
- message: 'Form identifier is ambiguous; provide locale or form_key'
396
- });
397
- }
398
- }
399
- }
123
+ const parsedInput = parsePublicSubmissionOrThrow(apireq);
124
+ const form_key = parsedInput.mm.form_key;
125
+ const localeRaw = parsedInput.mm.locale;
126
+ const captchaToken = parsedInput.mm.captcha_token;
127
+ const recipientsRaw = parsedInput.mm.recipients_raw;
128
+ enforceFormRateLimit(this.rateLimiter, env, apireq);
129
+ enforceAttachmentPolicy(env, rawFiles);
130
+ if (!form_key) {
131
+ throw new ApiError({ code: 400, message: 'Missing form_key' });
132
+ }
133
+ const form = await api_form.findOne({ where: { form_key } });
400
134
  if (!form) {
401
- throw new ApiError({ code: 404, message: `No such form: ${formid}` });
402
- }
403
- if (form.secret && !secret) {
404
- throw new ApiError({ code: 401, message: 'This form requires a secret key' });
405
- }
406
- if (form.secret && form.secret !== secret) {
407
- throw new ApiError({ code: 401, message: 'Bad form secret' });
408
- }
409
- const captchaRequired = Boolean(env.FORM_CAPTCHA_REQUIRED || form.captcha_required);
410
- const captchaSecret = String(env.FORM_CAPTCHA_SECRET ?? '').trim();
411
- const captchaToken = getBodyValue(body, 'cf-turnstile-response', 'h-captcha-response', 'g-recaptcha-response', 'captcha', 'captchaToken', 'captcha_token');
412
- if (!captchaSecret) {
413
- if (captchaRequired) {
414
- throw new ApiError({ code: 500, message: 'Captcha is required but not configured on the server' });
415
- }
416
- }
417
- else if (!captchaToken) {
418
- if (captchaRequired) {
419
- throw new ApiError({ code: 403, message: 'Captcha token required' });
420
- }
421
- }
422
- else {
423
- const provider = env.FORM_CAPTCHA_PROVIDER;
424
- const ok = await verifyCaptcha({
425
- provider,
426
- secret: captchaSecret,
427
- token: captchaToken,
428
- remoteip: clientIp
429
- });
430
- if (!ok) {
431
- throw new ApiError({ code: 403, message: 'Captcha verification failed' });
432
- }
135
+ throw new ApiError({ code: 404, message: 'No such form_key' });
433
136
  }
434
- if (recipient && !form.secret) {
435
- throw new ApiError({ code: 401, message: "'recipient' parameterer requires form secret to be set" });
436
- }
437
- let normalizedReplyTo;
438
- let normalizedRecipient;
439
- if (replyTo) {
440
- normalizedReplyTo = this.validateEmail(replyTo);
441
- if (!normalizedReplyTo) {
442
- throw new ApiError({ code: 400, message: 'Invalid reply-to email address' });
443
- }
444
- }
445
- if (recipient) {
446
- normalizedRecipient = this.validateEmail(recipient);
447
- if (!normalizedRecipient) {
448
- throw new ApiError({ code: 400, message: 'Invalid recipient email address' });
449
- }
450
- }
451
- if (recipientIdnameRaw && !normalizeSlug(recipientIdnameRaw)) {
452
- throw new ApiError({ code: 400, message: 'Invalid recipient identifier (recipient_idname)' });
453
- }
454
- const recipientIdname = normalizeSlug(recipientIdnameRaw);
455
- let resolvedRecipient = null;
456
- if (!normalizedRecipient && recipientIdname) {
457
- const scopeFormKey = form.form_key ?? '';
458
- if (scopeFormKey) {
459
- resolvedRecipient = await api_recipient.findOne({
460
- where: {
461
- domain_id: form.domain_id,
462
- form_key: scopeFormKey,
463
- idname: recipientIdname
464
- }
465
- });
466
- }
467
- if (!resolvedRecipient) {
468
- resolvedRecipient = await api_recipient.findOne({
469
- where: {
470
- domain_id: form.domain_id,
471
- form_key: '',
472
- idname: recipientIdname
473
- }
474
- });
475
- }
476
- if (!resolvedRecipient) {
477
- throw new ApiError({ code: 404, message: 'Unknown recipient identifier' });
478
- }
479
- }
480
- let parsedVars = vars ?? {};
481
- if (typeof vars === 'string') {
482
- try {
483
- parsedVars = JSON.parse(vars);
484
- }
485
- catch {
486
- throw new ApiError({ code: 400, message: 'Invalid JSON provided in "vars"' });
487
- }
488
- }
489
- const thevars = parsedVars;
490
- /*
491
- console.log('Headers:', apireq.req.headers);
492
- console.log('Body:', JSON.stringify(apireq.req.body, null, 2));
493
- console.log('Files:', JSON.stringify(apireq.req.files, null, 2));
494
- */
137
+ const fields = filterSubmissionFields(parsedInput.fields, form.allowed_fields);
138
+ const clientIp = apireq.getClientIp() ?? '';
139
+ await enforceCaptchaPolicy({ vars: env, form, captchaToken, clientIp });
140
+ const resolvedRecipients = await resolveRecipients(form, recipientsRaw);
141
+ const recipients = parseIdnameList(recipientsRaw, 'recipients');
142
+ const { rcptEmail, rcptName, rcptIdname, rcptIdnames } = getPrimaryRecipientInfo(form, resolvedRecipients);
495
143
  const domainRecord = await api_domain.findOne({ where: { domain_id: form.domain_id } });
496
144
  await this.server.storage.relocateUploads(domainRecord?.name ?? null, rawFiles);
497
- const attachments = rawFiles.map((file) => ({
498
- filename: file.originalname,
499
- path: file.path
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
500
155
  }));
501
- const attachmentMap = {};
502
- for (const file of rawFiles) {
503
- attachmentMap[file.fieldname] = file.originalname;
504
- }
156
+ const allAttachments = [...inlineTemplateAttachments, ...attachments];
505
157
  const meta = buildRequestMeta(apireq.req);
506
- const mappedEmailAddress = resolvedRecipient ? this.validateEmail(resolvedRecipient.email) : undefined;
507
- if (resolvedRecipient && !mappedEmailAddress) {
508
- throw new ApiError({ code: 500, message: 'Recipient mapping has an invalid email address' });
509
- }
510
- const mappedName = resolvedRecipient?.name ? String(resolvedRecipient.name).trim().slice(0, 200) : '';
511
- if (mappedName && /[\r\n]/.test(mappedName)) {
512
- throw new ApiError({ code: 500, message: 'Recipient mapping has an invalid name' });
513
- }
514
- const to = resolvedRecipient
515
- ? mappedName
516
- ? { name: mappedName, address: mappedEmailAddress }
517
- : mappedEmailAddress
518
- : normalizedRecipient || form.recipient;
519
- const rcptEmailForTemplate = resolvedRecipient
520
- ? mappedEmailAddress
521
- : normalizedRecipient || form.recipient;
522
- const context = {
523
- ...thevars,
524
- _rcpt_email_: rcptEmailForTemplate,
525
- _rcpt_name_: mappedName,
526
- _rcpt_idname_: recipientIdname,
527
- _attachments_: attachmentMap,
528
- _vars_: thevars,
529
- _fields_: apireq.req.body,
530
- _files_: rawFiles,
531
- _meta_: meta
532
- };
533
- nunjucks.configure({ autoescape: this.server.storage.env.AUTOESCAPE_HTML });
158
+ const to = buildRecipientTo(form, resolvedRecipients);
159
+ const replyToValue = buildReplyToValue(form, fields);
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
+ });
173
+ nunjucks.configure({ autoescape: this.server.storage.vars.AUTOESCAPE_HTML });
534
174
  const html = nunjucks.renderString(form.template, context);
535
175
  const mailOptions = {
536
176
  from: form.sender,
537
177
  to,
538
178
  subject: form.subject,
539
179
  html,
540
- attachments,
541
- ...(normalizedReplyTo ? { replyTo: normalizedReplyTo } : {})
180
+ attachments: allAttachments,
181
+ ...(replyToValue ? { replyTo: replyToValue } : {})
542
182
  };
543
183
  try {
544
184
  const info = await this.server.storage.transport.sendMail(mailOptions);
@@ -166,7 +166,7 @@ export class MailerAPI extends ApiModule {
166
166
  }
167
167
  }
168
168
  try {
169
- const env = new nunjucks.Environment(null, { autoescape: this.server.storage.env.AUTOESCAPE_HTML });
169
+ const env = new nunjucks.Environment(null, { autoescape: this.server.storage.vars.AUTOESCAPE_HTML });
170
170
  const compiled = nunjucks.compile(template.template, env);
171
171
  for (const recipient of valid) {
172
172
  const fullargs = {
@@ -50,9 +50,9 @@ if (result.error) {
50
50
  }
51
51
  async function main() {
52
52
  try {
53
- const { store, env } = await startMailMagicServer();
53
+ const { store, vars } = await startMailMagicServer();
54
54
  console.log(`Using config path: ${store.configpath}`);
55
- console.log(`mail-magic server listening on ${env.API_HOST}:${env.API_PORT}`);
55
+ console.log(`mail-magic server listening on ${vars.API_HOST}:${vars.API_PORT}`);
56
56
  }
57
57
  catch (error) {
58
58
  console.error('Failed to start mail-magic server');