@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.
- package/CHANGES +18 -0
- package/README.md +213 -122
- package/dist/api/assets.js +9 -56
- package/dist/api/auth.js +1 -12
- package/dist/api/form-replyto.js +1 -0
- package/dist/api/form-submission.js +1 -0
- package/dist/api/forms.js +114 -474
- package/dist/api/mailer.js +1 -1
- package/dist/bin/mail-magic.js +2 -2
- package/dist/index.js +30 -18
- package/dist/models/db.js +5 -5
- package/dist/models/domain.js +16 -8
- package/dist/models/form.js +111 -40
- package/dist/models/init.js +44 -74
- package/dist/models/recipient.js +12 -8
- package/dist/models/txmail.js +24 -28
- package/dist/models/user.js +14 -10
- package/dist/server.js +1 -1
- package/dist/store/store.js +53 -22
- package/dist/swagger.js +107 -0
- package/dist/util/captcha.js +24 -0
- package/dist/util/email.js +19 -0
- package/dist/util/form-replyto.js +44 -0
- package/dist/util/form-submission.js +95 -0
- package/dist/util/forms.js +431 -0
- package/dist/util/paths.js +41 -0
- package/dist/util/ratelimit.js +48 -0
- package/dist/util/uploads.js +48 -0
- package/dist/util/utils.js +151 -0
- package/dist/util.js +7 -127
- package/docs/config-example/example.test/assets/files/banner.png +1 -0
- package/docs/config-example/example.test/assets/images/logo.png +1 -0
- package/docs/config-example/example.test/form-template/base.njk +6 -0
- package/docs/config-example/example.test/form-template/contact.njk +9 -0
- package/docs/config-example/example.test/form-template/partials/fields.njk +3 -0
- package/docs/config-example/example.test/tx-template/base.njk +10 -0
- package/docs/config-example/example.test/tx-template/partials/header.njk +1 -0
- package/docs/config-example/example.test/tx-template/welcome.njk +10 -0
- package/docs/config-example/init-data.json +57 -0
- package/docs/form-security.md +194 -0
- package/docs/swagger/openapi.json +1321 -0
- package/{TUTORIAL.MD → docs/tutorial.md} +24 -15
- 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 {
|
|
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 {
|
|
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
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const
|
|
133
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
|
217
|
-
|
|
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
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
files: []
|
|
289
|
-
};
|
|
87
|
+
payload
|
|
88
|
+
});
|
|
290
89
|
let created = false;
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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.
|
|
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
|
|
314
|
-
const
|
|
315
|
-
const
|
|
316
|
-
const
|
|
317
|
-
const
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
const
|
|
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:
|
|
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
|
-
|
|
435
|
-
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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
|
|
498
|
-
|
|
499
|
-
|
|
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
|
|
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
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
:
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
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
|
-
...(
|
|
180
|
+
attachments: allAttachments,
|
|
181
|
+
...(replyToValue ? { replyTo: replyToValue } : {})
|
|
542
182
|
};
|
|
543
183
|
try {
|
|
544
184
|
const info = await this.server.storage.transport.sendMail(mailOptions);
|
package/dist/api/mailer.js
CHANGED
|
@@ -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.
|
|
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 = {
|
package/dist/bin/mail-magic.js
CHANGED
|
@@ -50,9 +50,9 @@ if (result.error) {
|
|
|
50
50
|
}
|
|
51
51
|
async function main() {
|
|
52
52
|
try {
|
|
53
|
-
const { store,
|
|
53
|
+
const { store, vars } = await startMailMagicServer();
|
|
54
54
|
console.log(`Using config path: ${store.configpath}`);
|
|
55
|
-
console.log(`mail-magic server listening on ${
|
|
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');
|