@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
@@ -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
+ }
@@ -0,0 +1,41 @@
1
+ import path from 'path';
2
+ import { ApiError } from '@technomoron/api-server-base';
3
+ export const SEGMENT_PATTERN = /^[a-zA-Z0-9._-]+$/;
4
+ export function normalizeSubdir(value) {
5
+ if (!value) {
6
+ return '';
7
+ }
8
+ const cleaned = value.replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+$/, '');
9
+ if (!cleaned) {
10
+ return '';
11
+ }
12
+ const segments = cleaned.split('/').filter(Boolean);
13
+ for (const segment of segments) {
14
+ if (!SEGMENT_PATTERN.test(segment)) {
15
+ throw new ApiError({ code: 400, message: `Invalid path segment "${segment}"` });
16
+ }
17
+ }
18
+ return path.join(...segments);
19
+ }
20
+ export function assertSafeRelativePath(filename, label) {
21
+ const normalized = path.normalize(filename);
22
+ if (path.isAbsolute(normalized)) {
23
+ throw new Error(`${label} path must be relative`);
24
+ }
25
+ if (normalized.split(path.sep).includes('..')) {
26
+ throw new Error(`${label} path cannot include '..' segments`);
27
+ }
28
+ return normalized;
29
+ }
30
+ export function buildAssetUrl(baseUrl, route, domainName, assetPath) {
31
+ const trimmedBase = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
32
+ const normalizedRoute = route ? (route.startsWith('/') ? route : `/${route}`) : '';
33
+ const encodedDomain = encodeURIComponent(domainName);
34
+ const encodedPath = assetPath
35
+ .split('/')
36
+ .filter((segment) => segment.length > 0)
37
+ .map((segment) => encodeURIComponent(segment))
38
+ .join('/');
39
+ const trailing = encodedPath ? `/${encodedPath}` : '';
40
+ return `${trimmedBase}${normalizedRoute}/${encodedDomain}${trailing}`;
41
+ }
@@ -0,0 +1,48 @@
1
+ import { ApiError } from '@technomoron/api-server-base';
2
+ export class FixedWindowRateLimiter {
3
+ maxKeys;
4
+ buckets = new Map();
5
+ constructor(maxKeys = 10_000) {
6
+ this.maxKeys = maxKeys;
7
+ }
8
+ check(key, max, windowMs) {
9
+ if (!key || max <= 0 || windowMs <= 0) {
10
+ return { allowed: true, retryAfterSec: 0 };
11
+ }
12
+ const now = Date.now();
13
+ const bucket = this.buckets.get(key);
14
+ if (!bucket || now - bucket.windowStartMs >= windowMs) {
15
+ this.buckets.delete(key);
16
+ this.buckets.set(key, { windowStartMs: now, count: 1 });
17
+ this.prune();
18
+ return { allowed: true, retryAfterSec: 0 };
19
+ }
20
+ bucket.count += 1;
21
+ // Refresh insertion order to keep active entries at the end for pruning.
22
+ this.buckets.delete(key);
23
+ this.buckets.set(key, bucket);
24
+ if (bucket.count <= max) {
25
+ return { allowed: true, retryAfterSec: 0 };
26
+ }
27
+ const retryAfterSec = Math.max(1, Math.ceil((bucket.windowStartMs + windowMs - now) / 1000));
28
+ return { allowed: false, retryAfterSec };
29
+ }
30
+ prune() {
31
+ while (this.buckets.size > this.maxKeys) {
32
+ const oldest = this.buckets.keys().next().value;
33
+ if (!oldest) {
34
+ break;
35
+ }
36
+ this.buckets.delete(oldest);
37
+ }
38
+ }
39
+ }
40
+ export function enforceFormRateLimit(limiter, env, apireq) {
41
+ const clientIp = apireq.getClientIp() ?? '';
42
+ const windowMs = Math.max(0, env.FORM_RATE_LIMIT_WINDOW_SEC) * 1000;
43
+ const decision = limiter.check(`form-message:${clientIp || 'unknown'}`, env.FORM_RATE_LIMIT_MAX, windowMs);
44
+ if (!decision.allowed) {
45
+ apireq.res.set('Retry-After', String(decision.retryAfterSec));
46
+ throw new ApiError({ code: 429, message: 'Too many form submissions; try again later' });
47
+ }
48
+ }
@@ -0,0 +1,48 @@
1
+ import fs from 'node:fs';
2
+ import path from 'path';
3
+ import { ApiError } from '@technomoron/api-server-base';
4
+ import { SEGMENT_PATTERN } from './paths.js';
5
+ export function buildAttachments(rawFiles) {
6
+ const attachments = rawFiles.map((file) => ({
7
+ filename: file.originalname,
8
+ path: file.path
9
+ }));
10
+ const attachmentMap = {};
11
+ for (const file of rawFiles) {
12
+ attachmentMap[file.fieldname] = file.originalname;
13
+ }
14
+ return { attachments, attachmentMap };
15
+ }
16
+ export async function cleanupUploadedFiles(files) {
17
+ await Promise.all(files.map(async (file) => {
18
+ if (!file?.path) {
19
+ return;
20
+ }
21
+ try {
22
+ await fs.promises.unlink(file.path);
23
+ }
24
+ catch {
25
+ // best effort cleanup
26
+ }
27
+ }));
28
+ }
29
+ export async function moveUploadedFiles(files, targetDir) {
30
+ await fs.promises.mkdir(targetDir, { recursive: true });
31
+ for (const file of files) {
32
+ const filename = path.basename(file.originalname || '');
33
+ if (!filename || !SEGMENT_PATTERN.test(filename)) {
34
+ throw new ApiError({ code: 400, message: `Invalid filename "${file.originalname}"` });
35
+ }
36
+ const destination = path.join(targetDir, filename);
37
+ if (destination === file.path) {
38
+ continue;
39
+ }
40
+ try {
41
+ await fs.promises.rename(file.path, destination);
42
+ }
43
+ catch {
44
+ await fs.promises.copyFile(file.path, destination);
45
+ await fs.promises.unlink(file.path);
46
+ }
47
+ }
48
+ }