@technomoron/mail-magic 1.0.40 → 1.0.42

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 (87) hide show
  1. package/CHANGES +45 -2
  2. package/README.md +5 -0
  3. package/dist/cjs/index.d.ts +1 -0
  4. package/dist/cjs/index.js +1 -1
  5. package/dist/esm/api/assets.d.ts +11 -0
  6. package/dist/esm/api/assets.js +48 -18
  7. package/dist/esm/api/auth.d.ts +2 -0
  8. package/dist/esm/api/auth.js +18 -9
  9. package/dist/esm/api/forms.d.ts +9 -0
  10. package/dist/esm/api/forms.js +42 -7
  11. package/dist/esm/api/mailer.d.ts +11 -0
  12. package/dist/esm/api/mailer.js +37 -8
  13. package/dist/esm/bin/mail-magic.d.ts +2 -0
  14. package/dist/esm/index.d.ts +12 -0
  15. package/dist/esm/index.js +5 -4
  16. package/dist/esm/models/db.d.ts +5 -0
  17. package/dist/esm/models/domain.d.ts +24 -0
  18. package/dist/esm/models/form.d.ts +50 -0
  19. package/dist/esm/models/form.js +16 -13
  20. package/dist/esm/models/init.d.ts +12 -0
  21. package/dist/esm/models/recipient.d.ts +24 -0
  22. package/dist/esm/models/txmail.d.ts +42 -0
  23. package/dist/esm/models/user.d.ts +33 -0
  24. package/dist/esm/server.d.ts +8 -0
  25. package/dist/esm/store/envloader.d.ts +188 -0
  26. package/dist/esm/store/envloader.js +9 -4
  27. package/dist/esm/store/store.d.ts +38 -0
  28. package/dist/esm/store/store.js +20 -16
  29. package/dist/esm/swagger.d.ts +10 -0
  30. package/dist/esm/types.d.ts +36 -0
  31. package/dist/esm/util/captcha.d.ts +7 -0
  32. package/dist/esm/util/captcha.js +4 -1
  33. package/dist/esm/util/email.d.ts +3 -0
  34. package/dist/esm/util/form-replyto.d.ts +6 -0
  35. package/dist/esm/util/form-submission.d.ts +24 -0
  36. package/dist/esm/util/forms.d.ts +140 -0
  37. package/dist/esm/util/forms.js +42 -39
  38. package/dist/esm/util/paths.d.ts +15 -0
  39. package/dist/esm/util/paths.js +17 -0
  40. package/dist/esm/util/ratelimit.d.ts +7 -0
  41. package/dist/esm/util/ratelimit.js +10 -41
  42. package/dist/esm/util/route.d.ts +1 -0
  43. package/dist/esm/util/shared-template-flatten.d.ts +17 -0
  44. package/dist/esm/util/uploads.d.ts +11 -0
  45. package/dist/esm/util/uploads.js +16 -11
  46. package/dist/esm/util/utils.d.ts +25 -0
  47. package/dist/esm/util/utils.js +0 -18
  48. package/dist/esm/util.d.ts +7 -0
  49. package/docs/swagger/openapi.json +16 -12
  50. package/examples/.env-dist +21 -0
  51. package/examples/README.md +74 -0
  52. package/examples/data/example.test/form-template/base.njk +4 -0
  53. package/examples/data/example.test/form-template/en/base.njk +1 -0
  54. package/examples/data/example.test/form-template/en/change-password.njk +5 -0
  55. package/examples/data/example.test/form-template/en/confirm-account.njk +5 -0
  56. package/examples/data/example.test/form-template/en/contact.njk +5 -0
  57. package/examples/data/example.test/form-template/en/partials/fields.njk +5 -0
  58. package/examples/data/example.test/form-template/en/welcome-signup.njk +5 -0
  59. package/examples/data/example.test/form-template/nb/base.njk +1 -0
  60. package/examples/data/example.test/form-template/nb/change-password.njk +5 -0
  61. package/examples/data/example.test/form-template/nb/confirm-account.njk +5 -0
  62. package/examples/data/example.test/form-template/nb/contact.njk +5 -0
  63. package/examples/data/example.test/form-template/nb/partials/fields.njk +5 -0
  64. package/examples/data/example.test/form-template/nb/welcome-signup.njk +5 -0
  65. package/examples/data/example.test/form-template/partials/header.njk +1 -0
  66. package/examples/data/example.test/tx-template/base.njk +16 -0
  67. package/examples/data/example.test/tx-template/en/base.njk +1 -0
  68. package/examples/data/example.test/tx-template/en/change-password.njk +7 -0
  69. package/examples/data/example.test/tx-template/en/confirm.njk +6 -0
  70. package/examples/data/example.test/tx-template/en/invoice.njk +8 -0
  71. package/examples/data/example.test/tx-template/en/partials/header.njk +1 -0
  72. package/examples/data/example.test/tx-template/en/partials/line-items.njk +14 -0
  73. package/examples/data/example.test/tx-template/en/receipt.njk +7 -0
  74. package/examples/data/example.test/tx-template/en/welcome.njk +5 -0
  75. package/examples/data/example.test/tx-template/nb/base.njk +1 -0
  76. package/examples/data/example.test/tx-template/nb/change-password.njk +6 -0
  77. package/examples/data/example.test/tx-template/nb/confirm.njk +6 -0
  78. package/examples/data/example.test/tx-template/nb/invoice.njk +7 -0
  79. package/examples/data/example.test/tx-template/nb/partials/header.njk +1 -0
  80. package/examples/data/example.test/tx-template/nb/receipt.njk +6 -0
  81. package/examples/data/example.test/tx-template/nb/welcome.njk +5 -0
  82. package/examples/data/example.test/tx-template/partials/header.njk +7 -0
  83. package/examples/data/init-data.json +213 -0
  84. package/examples/scripts/mm-api.ts +206 -0
  85. package/examples/scripts/public-form.ts +100 -0
  86. package/examples/scripts/send-messages.ts +114 -0
  87. package/package.json +7 -5
@@ -0,0 +1,140 @@
1
+ import { ApiRequest } from '@technomoron/api-server-base';
2
+ import { api_form } from '../models/form.js';
3
+ import { api_recipient } from '../models/recipient.js';
4
+ import { ParsedFormSubmission } from './form-submission.js';
5
+ import type { api_domain } from '../models/domain.js';
6
+ import type { api_user } from '../models/user.js';
7
+ import type { RequestMeta, UploadedFile } from '../types.js';
8
+ export declare function parsePublicSubmissionOrThrow(apireq: ApiRequest): ParsedFormSubmission;
9
+ export declare function enforceAttachmentPolicy(env: {
10
+ FORM_MAX_ATTACHMENTS: number;
11
+ }, rawFiles: UploadedFile[]): void;
12
+ export declare function filterSubmissionFields(rawFields: Record<string, unknown>, allowedFields: unknown): Record<string, unknown>;
13
+ export declare function enforceCaptchaPolicy(params: {
14
+ vars: {
15
+ FORM_CAPTCHA_REQUIRED: boolean;
16
+ FORM_CAPTCHA_SECRET: string;
17
+ FORM_CAPTCHA_PROVIDER: string;
18
+ };
19
+ form: {
20
+ captcha_required: boolean;
21
+ };
22
+ captchaToken: string;
23
+ clientIp: string;
24
+ }): Promise<void>;
25
+ export declare function buildReplyToValue(form: {
26
+ replyto_email: string;
27
+ replyto_from_fields: boolean;
28
+ }, fields: Record<string, unknown>): (string | {
29
+ name: string;
30
+ address: string;
31
+ }) | undefined;
32
+ export declare function parseIdnameList(value: unknown, field: string): string[];
33
+ export type FormRecipientPayload = {
34
+ idnameRaw: string;
35
+ emailRaw: string;
36
+ nameRaw: string;
37
+ formKeyRaw: string;
38
+ formid: string;
39
+ localeRaw: string;
40
+ };
41
+ export declare function parseRecipientPayload(body: Record<string, unknown>): FormRecipientPayload;
42
+ export declare function normalizeRecipientIdname(raw: string): string;
43
+ export declare function normalizeRecipientEmail(raw: string): {
44
+ email: string;
45
+ mailbox: {
46
+ address: string;
47
+ name?: string | null;
48
+ };
49
+ };
50
+ export declare function normalizeRecipientName(raw: string, mailboxName?: string | null): string;
51
+ export declare function resolveFormKeyForRecipient(params: {
52
+ formKeyRaw: string;
53
+ formid: string;
54
+ localeRaw: string;
55
+ user: api_user;
56
+ domain: api_domain;
57
+ }): Promise<string>;
58
+ export declare function parseAllowedFields(raw: unknown): string[];
59
+ export type FormTemplateInput = {
60
+ template: string;
61
+ sender: string;
62
+ recipient: string;
63
+ idname: string;
64
+ subject: string;
65
+ locale: string;
66
+ secret: string;
67
+ replyto_email: string;
68
+ replyto_from_fields: boolean;
69
+ allowed_fields: string[];
70
+ captcha_required: boolean;
71
+ };
72
+ export declare function parseFormTemplatePayload(body: Record<string, unknown>): FormTemplateInput;
73
+ export declare function validateFormTemplatePayload(payload: FormTemplateInput): void;
74
+ export declare function buildFormTemplatePaths(params: {
75
+ user: api_user;
76
+ domain: api_domain;
77
+ idname: string;
78
+ locale: string;
79
+ }): {
80
+ localeSlug: string;
81
+ slug: string;
82
+ filename: string;
83
+ };
84
+ export declare function resolveFormKeyForTemplate(params: {
85
+ user_id: number;
86
+ domain_id: number;
87
+ locale: string;
88
+ idname: string;
89
+ }): Promise<string>;
90
+ export declare function buildFormTemplateRecord(params: {
91
+ form_key: string;
92
+ user_id: number;
93
+ domain_id: number;
94
+ locale: string;
95
+ slug: string;
96
+ filename: string;
97
+ payload: FormTemplateInput;
98
+ }): {
99
+ form_key: string;
100
+ user_id: number;
101
+ domain_id: number;
102
+ locale: string;
103
+ idname: string;
104
+ sender: string;
105
+ recipient: string;
106
+ subject: string;
107
+ template: string;
108
+ slug: string;
109
+ filename: string;
110
+ secret: string;
111
+ replyto_email: string;
112
+ replyto_from_fields: boolean;
113
+ allowed_fields: string[];
114
+ captcha_required: boolean;
115
+ files: never[];
116
+ };
117
+ export declare function resolveRecipients(form: api_form, recipientsRaw: unknown): Promise<api_recipient[]>;
118
+ export declare function buildRecipientTo(form: api_form, recipients: api_recipient[]): string | (string | {
119
+ name: string;
120
+ address: string;
121
+ })[];
122
+ export declare function getPrimaryRecipientInfo(form: api_form, recipients: api_recipient[]): {
123
+ rcptEmail: string;
124
+ rcptName: string;
125
+ rcptIdname: string;
126
+ rcptIdnames: string[];
127
+ };
128
+ export declare function buildSubmissionContext(params: {
129
+ form_key: string;
130
+ localeRaw: string;
131
+ recipients: string[];
132
+ rcptEmail: string;
133
+ rcptName: string;
134
+ rcptIdname: string;
135
+ rcptIdnames: string[];
136
+ attachmentMap: Record<string, string>;
137
+ fields: Record<string, unknown>;
138
+ files: UploadedFile[];
139
+ meta: RequestMeta;
140
+ }): Record<string, unknown>;
@@ -1,11 +1,12 @@
1
- import path from 'path';
2
1
  import { ApiError } from '@technomoron/api-server-base';
2
+ import { Op } from 'sequelize';
3
3
  import { api_form } from '../models/form.js';
4
4
  import { api_recipient } from '../models/recipient.js';
5
5
  import { verifyCaptcha } from './captcha.js';
6
6
  import { parseMailbox } from './email.js';
7
7
  import { extractReplyToFromSubmission } from './form-replyto.js';
8
8
  import { parseFormSubmissionInput } from './form-submission.js';
9
+ import { buildFormSlugAndFilename } from './paths.js';
9
10
  import { normalizeBoolean, normalizeSlug } from './utils.js';
10
11
  export function parsePublicSubmissionOrThrow(apireq) {
11
12
  try {
@@ -297,36 +298,24 @@ export function validateFormTemplatePayload(payload) {
297
298
  }
298
299
  }
299
300
  export function buildFormTemplatePaths(params) {
300
- const domainSlug = normalizeSlug(params.domain.name);
301
- const formSlug = normalizeSlug(params.idname);
302
- const localeSlug = normalizeSlug(params.locale || params.domain.locale || params.user.locale || '');
303
- const slug = `${domainSlug}${localeSlug ? '-' + localeSlug : ''}-${formSlug}`;
304
- const filenameParts = [domainSlug, 'form-template'];
305
- if (localeSlug) {
306
- filenameParts.push(localeSlug);
307
- }
308
- filenameParts.push(formSlug);
309
- let filename = path.join(...filenameParts);
310
- if (!filename.endsWith('.njk')) {
311
- filename += '.njk';
312
- }
313
- return { localeSlug, slug, filename };
301
+ return buildFormSlugAndFilename({
302
+ domainName: params.domain.name,
303
+ domainLocale: params.domain.locale,
304
+ userLocale: params.user.locale,
305
+ idname: params.idname,
306
+ locale: params.locale
307
+ });
314
308
  }
315
309
  export async function resolveFormKeyForTemplate(params) {
316
- try {
317
- const existing = await api_form.findOne({
318
- where: {
319
- user_id: params.user_id,
320
- domain_id: params.domain_id,
321
- locale: params.locale,
322
- idname: params.idname
323
- }
324
- });
325
- return existing?.form_key || '';
326
- }
327
- catch {
328
- return '';
329
- }
310
+ const existing = await api_form.findOne({
311
+ where: {
312
+ user_id: params.user_id,
313
+ domain_id: params.domain_id,
314
+ locale: params.locale,
315
+ idname: params.idname
316
+ }
317
+ });
318
+ return existing?.form_key || '';
330
319
  }
331
320
  export function buildFormTemplateRecord(params) {
332
321
  return {
@@ -354,22 +343,36 @@ export async function resolveRecipients(form, recipientsRaw) {
354
343
  if (!scopeFormKey) {
355
344
  throw new ApiError({ code: 500, message: 'Form is missing a form_key' });
356
345
  }
357
- const resolveRecipient = async (idname) => {
358
- const scoped = await api_recipient.findOne({
359
- where: { domain_id: form.domain_id, form_key: scopeFormKey, idname }
360
- });
361
- if (scoped) {
362
- return scoped;
363
- }
364
- return api_recipient.findOne({ where: { domain_id: form.domain_id, form_key: '', idname } });
365
- };
366
346
  const recipients = parseIdnameList(recipientsRaw, 'recipients');
367
347
  if (recipients.length > 25) {
368
348
  throw new ApiError({ code: 400, message: 'Too many recipients requested' });
369
349
  }
350
+ if (recipients.length === 0) {
351
+ return [];
352
+ }
353
+ const scopedMatches = await api_recipient.findAll({
354
+ where: {
355
+ domain_id: form.domain_id,
356
+ form_key: scopeFormKey,
357
+ idname: { [Op.in]: recipients }
358
+ }
359
+ });
360
+ const scopedByIdname = new Map(scopedMatches.map((entry) => [entry.idname, entry]));
361
+ const unresolved = recipients.filter((idname) => !scopedByIdname.has(idname));
362
+ let fallbackByIdname = new Map();
363
+ if (unresolved.length > 0) {
364
+ const fallbackMatches = await api_recipient.findAll({
365
+ where: {
366
+ domain_id: form.domain_id,
367
+ form_key: '',
368
+ idname: { [Op.in]: unresolved }
369
+ }
370
+ });
371
+ fallbackByIdname = new Map(fallbackMatches.map((entry) => [entry.idname, entry]));
372
+ }
370
373
  const resolvedRecipients = [];
371
374
  for (const idname of recipients) {
372
- const record = await resolveRecipient(idname);
375
+ const record = scopedByIdname.get(idname) ?? fallbackByIdname.get(idname) ?? null;
373
376
  if (!record) {
374
377
  throw new ApiError({ code: 404, message: `Unknown recipient identifier "${idname}"` });
375
378
  }
@@ -0,0 +1,15 @@
1
+ export declare const SEGMENT_PATTERN: RegExp;
2
+ export declare function normalizeSubdir(value: string): string;
3
+ export declare function assertSafeRelativePath(filename: string, label: string): string;
4
+ export declare function buildFormSlugAndFilename(params: {
5
+ domainName: string;
6
+ domainLocale: string;
7
+ userLocale: string;
8
+ idname: string;
9
+ locale: string;
10
+ }): {
11
+ localeSlug: string;
12
+ slug: string;
13
+ filename: string;
14
+ };
15
+ export declare function buildAssetUrl(baseUrl: string, route: string, domainName: string, assetPath: string): string;
@@ -1,5 +1,6 @@
1
1
  import path from 'path';
2
2
  import { ApiError } from '@technomoron/api-server-base';
3
+ import { normalizeSlug } from './utils.js';
3
4
  export const SEGMENT_PATTERN = /^[a-zA-Z0-9._-]+$/;
4
5
  export function normalizeSubdir(value) {
5
6
  if (!value) {
@@ -27,6 +28,22 @@ export function assertSafeRelativePath(filename, label) {
27
28
  }
28
29
  return normalized;
29
30
  }
31
+ export function buildFormSlugAndFilename(params) {
32
+ const domainSlug = normalizeSlug(params.domainName);
33
+ const formSlug = normalizeSlug(params.idname);
34
+ const localeSlug = normalizeSlug(params.locale || params.domainLocale || params.userLocale || '');
35
+ const slug = `${domainSlug}${localeSlug ? '-' + localeSlug : ''}-${formSlug}`;
36
+ const filenameParts = [domainSlug, 'form-template'];
37
+ if (localeSlug) {
38
+ filenameParts.push(localeSlug);
39
+ }
40
+ filenameParts.push(formSlug);
41
+ let filename = path.join(...filenameParts);
42
+ if (!filename.endsWith('.njk')) {
43
+ filename += '.njk';
44
+ }
45
+ return { localeSlug, slug, filename };
46
+ }
30
47
  export function buildAssetUrl(baseUrl, route, domainName, assetPath) {
31
48
  const trimmedBase = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
32
49
  const normalizedRoute = route ? (route.startsWith('/') ? route : `/${route}`) : '';
@@ -0,0 +1,7 @@
1
+ import { ApiRequest, FixedWindowRateLimiter } from '@technomoron/api-server-base';
2
+ export { FixedWindowRateLimiter };
3
+ export type { RateLimitDecision } from '@technomoron/api-server-base';
4
+ export declare function enforceFormRateLimit(limiter: FixedWindowRateLimiter, env: {
5
+ FORM_RATE_LIMIT_WINDOW_SEC: number;
6
+ FORM_RATE_LIMIT_MAX: number;
7
+ }, apireq: ApiRequest): void;
@@ -1,48 +1,17 @@
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
- }
1
+ import { ApiError, FixedWindowRateLimiter } from '@technomoron/api-server-base';
2
+ export { FixedWindowRateLimiter };
40
3
  export function enforceFormRateLimit(limiter, env, apireq) {
41
4
  const clientIp = apireq.getClientIp() ?? '';
5
+ if (!clientIp) {
6
+ // Cannot rate-limit without a resolvable client IP; skip to avoid collapsing
7
+ // all IP-unknown requests into a single shared bucket.
8
+ return;
9
+ }
42
10
  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);
11
+ const decision = limiter.check(`form-message:${clientIp}`, env.FORM_RATE_LIMIT_MAX, windowMs);
44
12
  if (!decision.allowed) {
45
- apireq.res.set('Retry-After', String(decision.retryAfterSec));
13
+ const fastifyReply = apireq.res.reply;
14
+ fastifyReply?.header('retry-after', String(decision.retryAfterSec));
46
15
  throw new ApiError({ code: 429, message: 'Too many form submissions; try again later' });
47
16
  }
48
17
  }
@@ -0,0 +1 @@
1
+ export declare function normalizeRoute(value: string, fallback?: string): string;
@@ -0,0 +1,17 @@
1
+ export type FlattenedAsset = {
2
+ filename: string;
3
+ path: string;
4
+ cid?: string;
5
+ };
6
+ export type FlattenWithAssetsOptions = {
7
+ domainRoot: string;
8
+ templateKey: string;
9
+ baseUrl: string;
10
+ assetFormatter: (urlPath: string) => string;
11
+ normalizeInlineCid?: (urlPath: string) => string;
12
+ };
13
+ export type FlattenWithAssetsResult = {
14
+ html: string;
15
+ assets: FlattenedAsset[];
16
+ };
17
+ export declare function flattenTemplateWithAssets(options: FlattenWithAssetsOptions): FlattenWithAssetsResult;
@@ -0,0 +1,11 @@
1
+ import type { UploadedFile } from '../types.js';
2
+ export declare function buildAttachments(rawFiles: UploadedFile[]): {
3
+ attachments: Array<{
4
+ filename: string;
5
+ path?: string;
6
+ content?: Buffer;
7
+ }>;
8
+ attachmentMap: Record<string, string>;
9
+ };
10
+ export declare function cleanupUploadedFiles(files: UploadedFile[]): Promise<void>;
11
+ export declare function moveUploadedFiles(files: UploadedFile[], targetDir: string): Promise<void>;
@@ -5,7 +5,7 @@ import { SEGMENT_PATTERN } from './paths.js';
5
5
  export function buildAttachments(rawFiles) {
6
6
  const attachments = rawFiles.map((file) => ({
7
7
  filename: file.originalname,
8
- path: file.path
8
+ ...(file.buffer ? { content: file.buffer } : { path: file.filepath })
9
9
  }));
10
10
  const attachmentMap = {};
11
11
  for (const file of rawFiles) {
@@ -15,11 +15,11 @@ export function buildAttachments(rawFiles) {
15
15
  }
16
16
  export async function cleanupUploadedFiles(files) {
17
17
  await Promise.all(files.map(async (file) => {
18
- if (!file?.path) {
18
+ if (!file?.filepath) {
19
19
  return;
20
20
  }
21
21
  try {
22
- await fs.promises.unlink(file.path);
22
+ await fs.promises.unlink(file.filepath);
23
23
  }
24
24
  catch {
25
25
  // best effort cleanup
@@ -34,15 +34,20 @@ export async function moveUploadedFiles(files, targetDir) {
34
34
  throw new ApiError({ code: 400, message: `Invalid filename "${file.originalname}"` });
35
35
  }
36
36
  const destination = path.join(targetDir, filename);
37
- if (destination === file.path) {
38
- continue;
37
+ if (file.buffer) {
38
+ await fs.promises.writeFile(destination, file.buffer);
39
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);
40
+ else if (file.filepath) {
41
+ if (destination === file.filepath) {
42
+ continue;
43
+ }
44
+ try {
45
+ await fs.promises.rename(file.filepath, destination);
46
+ }
47
+ catch {
48
+ await fs.promises.copyFile(file.filepath, destination);
49
+ await fs.promises.unlink(file.filepath);
50
+ }
46
51
  }
47
52
  }
48
53
  }
@@ -0,0 +1,25 @@
1
+ import { api_domain } from '../models/domain.js';
2
+ import { api_user } from '../models/user.js';
3
+ import type { RequestMeta } from '../types.js';
4
+ /**
5
+ * Normalize a string into a safe identifier for slugs, filenames, etc.
6
+ *
7
+ * - Lowercases all characters
8
+ * - Replaces any character that is not `a-z`, `0-9`, `-`, '.' or `_` with `-`
9
+ * - Collapses multiple consecutive dashes into one
10
+ * - Trims leading and trailing dashes
11
+ *
12
+ * Examples:
13
+ * normalizeSlug("Hello World!") -> "hello-world"
14
+ * normalizeSlug(" Áccêntš ") -> "cc-nt"
15
+ * normalizeSlug("My--Slug__Test") -> "my-slug__test"
16
+ */
17
+ export declare function normalizeSlug(input: string): string;
18
+ export declare function user_and_domain(domain_id: number): Promise<{
19
+ user: api_user;
20
+ domain: api_domain;
21
+ }>;
22
+ export declare function buildRequestMeta(rawReq: unknown): RequestMeta;
23
+ export declare function decodeComponent(value: string | string[] | undefined): string;
24
+ export declare function getBodyValue(body: Record<string, unknown>, ...keys: string[]): string;
25
+ export declare function normalizeBoolean(value: unknown): boolean;
@@ -131,21 +131,3 @@ export function normalizeBoolean(value) {
131
131
  .toLowerCase();
132
132
  return ['true', '1', 'yes', 'on'].includes(normalized);
133
133
  }
134
- export function sendFileAsync(res, file, options) {
135
- return new Promise((resolve, reject) => {
136
- const cb = (err) => {
137
- if (err) {
138
- reject(err instanceof Error ? err : new Error(String(err)));
139
- }
140
- else {
141
- resolve();
142
- }
143
- };
144
- if (options !== undefined) {
145
- // Express will set Cache-Control based on `maxAge` etc; callers can still override.
146
- res.sendFile(file, options, cb);
147
- return;
148
- }
149
- res.sendFile(file, cb);
150
- });
151
- }
@@ -0,0 +1,7 @@
1
+ export * from './util/utils.js';
2
+ export * from './util/email.js';
3
+ export * from './util/paths.js';
4
+ export * from './util/uploads.js';
5
+ export * from './util/form-replyto.js';
6
+ export * from './util/form-submission.js';
7
+ export * from './util/forms.js';
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Mail Magic API",
5
- "version": "1.0.33",
5
+ "version": "1.0.42",
6
6
  "description": "OpenAPI definition for the Mail Magic server. Authenticated endpoints require an API key provided as `Authorization: Bearer apikey-<token>`."
7
7
  },
8
8
  "servers": [
@@ -897,7 +897,7 @@
897
897
  "properties": {
898
898
  "domain": {
899
899
  "type": "string",
900
- "description": "Domain name owned by the API key user."
900
+ "description": "Optional. Domain name. If omitted, the API key's default domain is used."
901
901
  },
902
902
  "name": {
903
903
  "type": "string",
@@ -920,13 +920,14 @@
920
920
  "default": ""
921
921
  }
922
922
  },
923
- "required": ["domain", "name", "template"]
923
+ "required": ["name", "template"]
924
924
  },
925
925
  "TxSendRequest": {
926
926
  "type": "object",
927
927
  "properties": {
928
928
  "domain": {
929
- "type": "string"
929
+ "type": "string",
930
+ "description": "Optional. Domain name. If omitted, the API key's default domain is used."
930
931
  },
931
932
  "name": {
932
933
  "type": "string",
@@ -969,14 +970,15 @@
969
970
  "description": "Custom email headers."
970
971
  }
971
972
  },
972
- "required": ["domain", "name", "rcpt"]
973
+ "required": ["name", "rcpt"]
973
974
  },
974
975
  "TxSendMultipartRequest": {
975
976
  "type": "object",
976
977
  "description": "Multipart version of TxSendRequest. Attach files in any multipart field; `files` is a conventional field name.",
977
978
  "properties": {
978
979
  "domain": {
979
- "type": "string"
980
+ "type": "string",
981
+ "description": "Optional. Domain name. If omitted, the API key's default domain is used."
980
982
  },
981
983
  "name": {
982
984
  "type": "string"
@@ -1012,7 +1014,7 @@
1012
1014
  }
1013
1015
  }
1014
1016
  },
1015
- "required": ["domain", "name", "rcpt"]
1017
+ "required": ["name", "rcpt"]
1016
1018
  },
1017
1019
  "TxSendResponseData": {
1018
1020
  "type": "object",
@@ -1031,7 +1033,8 @@
1031
1033
  "type": "object",
1032
1034
  "properties": {
1033
1035
  "domain": {
1034
- "type": "string"
1036
+ "type": "string",
1037
+ "description": "Optional. Domain name. If omitted, the API key's default domain is used."
1035
1038
  },
1036
1039
  "idname": {
1037
1040
  "type": "string",
@@ -1053,7 +1056,7 @@
1053
1056
  "type": "string"
1054
1057
  }
1055
1058
  },
1056
- "required": ["domain", "idname", "email"]
1059
+ "required": ["idname", "email"]
1057
1060
  },
1058
1061
  "FormRecipientUpsertResponseData": {
1059
1062
  "type": "object",
@@ -1075,7 +1078,8 @@
1075
1078
  "type": "object",
1076
1079
  "properties": {
1077
1080
  "domain": {
1078
- "type": "string"
1081
+ "type": "string",
1082
+ "description": "Optional. Domain name. If omitted, the API key's default domain is used."
1079
1083
  },
1080
1084
  "idname": {
1081
1085
  "type": "string",
@@ -1131,7 +1135,7 @@
1131
1135
  "type": "boolean"
1132
1136
  }
1133
1137
  },
1134
- "required": ["domain", "idname", "template", "sender", "recipient"]
1138
+ "required": ["idname", "template", "sender", "recipient"]
1135
1139
  },
1136
1140
  "FormTemplateUpsertResponseData": {
1137
1141
  "type": "object",
@@ -1262,7 +1266,7 @@
1262
1266
  },
1263
1267
  "FormMessageResponseData": {
1264
1268
  "type": "object",
1265
- "description": "On success, data is usually an empty object. Some internal failures may return an object with an `error` string.",
1269
+ "description": "On success, data is an empty object.",
1266
1270
  "additionalProperties": true
1267
1271
  },
1268
1272
  "AssetsUploadMultipartRequest": {
@@ -0,0 +1,21 @@
1
+ NODE_ENV=development
2
+ API_PORT=3776
3
+ API_HOST=127.0.0.1
4
+ API_URL=http://127.0.0.1:3776/api
5
+ API_BASE_PATH=/api
6
+ ASSET_ROUTE=/asset
7
+ CONFIG_PATH=./data
8
+ DB_TYPE=sqlite
9
+ DB_NAME=./maildata.db
10
+ DB_FORCE_SYNC=true
11
+ DB_AUTO_RELOAD=false
12
+ DB_SYNC_ALTER=false
13
+ DB_LOG=false
14
+ DEBUG=false
15
+ AUTOESCAPE_HTML=true
16
+ UPLOAD_PATH=./{domain}/uploads
17
+ SMTP_HOST=127.0.0.1
18
+ SMTP_PORT=1025
19
+ SMTP_SECURE=false
20
+ SMTP_TLS_REJECT=false
21
+ API_TOKEN_PEPPER=example-token-pepper-value