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