@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.
- package/CHANGES +45 -2
- package/README.md +5 -0
- package/dist/cjs/index.d.ts +1 -0
- package/dist/cjs/index.js +1 -1
- package/dist/esm/api/assets.d.ts +11 -0
- package/dist/esm/api/assets.js +48 -18
- package/dist/esm/api/auth.d.ts +2 -0
- package/dist/esm/api/auth.js +18 -9
- package/dist/esm/api/forms.d.ts +9 -0
- package/dist/esm/api/forms.js +42 -7
- package/dist/esm/api/mailer.d.ts +11 -0
- package/dist/esm/api/mailer.js +37 -8
- package/dist/esm/bin/mail-magic.d.ts +2 -0
- package/dist/esm/index.d.ts +12 -0
- package/dist/esm/index.js +5 -4
- package/dist/esm/models/db.d.ts +5 -0
- package/dist/esm/models/domain.d.ts +24 -0
- package/dist/esm/models/form.d.ts +50 -0
- package/dist/esm/models/form.js +16 -13
- package/dist/esm/models/init.d.ts +12 -0
- package/dist/esm/models/recipient.d.ts +24 -0
- package/dist/esm/models/txmail.d.ts +42 -0
- package/dist/esm/models/user.d.ts +33 -0
- package/dist/esm/server.d.ts +8 -0
- package/dist/esm/store/envloader.d.ts +188 -0
- package/dist/esm/store/envloader.js +9 -4
- package/dist/esm/store/store.d.ts +38 -0
- package/dist/esm/store/store.js +20 -16
- package/dist/esm/swagger.d.ts +10 -0
- package/dist/esm/types.d.ts +36 -0
- package/dist/esm/util/captcha.d.ts +7 -0
- package/dist/esm/util/captcha.js +4 -1
- package/dist/esm/util/email.d.ts +3 -0
- package/dist/esm/util/form-replyto.d.ts +6 -0
- package/dist/esm/util/form-submission.d.ts +24 -0
- package/dist/esm/util/forms.d.ts +140 -0
- package/dist/esm/util/forms.js +42 -39
- package/dist/esm/util/paths.d.ts +15 -0
- package/dist/esm/util/paths.js +17 -0
- package/dist/esm/util/ratelimit.d.ts +7 -0
- package/dist/esm/util/ratelimit.js +10 -41
- package/dist/esm/util/route.d.ts +1 -0
- package/dist/esm/util/shared-template-flatten.d.ts +17 -0
- package/dist/esm/util/uploads.d.ts +11 -0
- package/dist/esm/util/uploads.js +16 -11
- package/dist/esm/util/utils.d.ts +25 -0
- package/dist/esm/util/utils.js +0 -18
- package/dist/esm/util.d.ts +7 -0
- package/docs/swagger/openapi.json +16 -12
- package/examples/.env-dist +21 -0
- package/examples/README.md +74 -0
- package/examples/data/example.test/form-template/base.njk +4 -0
- package/examples/data/example.test/form-template/en/base.njk +1 -0
- package/examples/data/example.test/form-template/en/change-password.njk +5 -0
- package/examples/data/example.test/form-template/en/confirm-account.njk +5 -0
- package/examples/data/example.test/form-template/en/contact.njk +5 -0
- package/examples/data/example.test/form-template/en/partials/fields.njk +5 -0
- package/examples/data/example.test/form-template/en/welcome-signup.njk +5 -0
- package/examples/data/example.test/form-template/nb/base.njk +1 -0
- package/examples/data/example.test/form-template/nb/change-password.njk +5 -0
- package/examples/data/example.test/form-template/nb/confirm-account.njk +5 -0
- package/examples/data/example.test/form-template/nb/contact.njk +5 -0
- package/examples/data/example.test/form-template/nb/partials/fields.njk +5 -0
- package/examples/data/example.test/form-template/nb/welcome-signup.njk +5 -0
- package/examples/data/example.test/form-template/partials/header.njk +1 -0
- package/examples/data/example.test/tx-template/base.njk +16 -0
- package/examples/data/example.test/tx-template/en/base.njk +1 -0
- package/examples/data/example.test/tx-template/en/change-password.njk +7 -0
- package/examples/data/example.test/tx-template/en/confirm.njk +6 -0
- package/examples/data/example.test/tx-template/en/invoice.njk +8 -0
- package/examples/data/example.test/tx-template/en/partials/header.njk +1 -0
- package/examples/data/example.test/tx-template/en/partials/line-items.njk +14 -0
- package/examples/data/example.test/tx-template/en/receipt.njk +7 -0
- package/examples/data/example.test/tx-template/en/welcome.njk +5 -0
- package/examples/data/example.test/tx-template/nb/base.njk +1 -0
- package/examples/data/example.test/tx-template/nb/change-password.njk +6 -0
- package/examples/data/example.test/tx-template/nb/confirm.njk +6 -0
- package/examples/data/example.test/tx-template/nb/invoice.njk +7 -0
- package/examples/data/example.test/tx-template/nb/partials/header.njk +1 -0
- package/examples/data/example.test/tx-template/nb/receipt.njk +6 -0
- package/examples/data/example.test/tx-template/nb/welcome.njk +5 -0
- package/examples/data/example.test/tx-template/partials/header.njk +7 -0
- package/examples/data/init-data.json +213 -0
- package/examples/scripts/mm-api.ts +206 -0
- package/examples/scripts/public-form.ts +100 -0
- package/examples/scripts/send-messages.ts +114 -0
- 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>;
|
package/dist/esm/util/forms.js
CHANGED
|
@@ -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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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 =
|
|
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;
|
package/dist/esm/util/paths.js
CHANGED
|
@@ -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
|
|
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
|
|
11
|
+
const decision = limiter.check(`form-message:${clientIp}`, env.FORM_RATE_LIMIT_MAX, windowMs);
|
|
44
12
|
if (!decision.allowed) {
|
|
45
|
-
apireq.res.
|
|
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>;
|
package/dist/esm/util/uploads.js
CHANGED
|
@@ -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.
|
|
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?.
|
|
18
|
+
if (!file?.filepath) {
|
|
19
19
|
return;
|
|
20
20
|
}
|
|
21
21
|
try {
|
|
22
|
-
await fs.promises.unlink(file.
|
|
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 (
|
|
38
|
-
|
|
37
|
+
if (file.buffer) {
|
|
38
|
+
await fs.promises.writeFile(destination, file.buffer);
|
|
39
39
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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;
|
package/dist/esm/util/utils.js
CHANGED
|
@@ -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
|
-
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"openapi": "3.1.0",
|
|
3
3
|
"info": {
|
|
4
4
|
"title": "Mail Magic API",
|
|
5
|
-
"version": "1.0.
|
|
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
|
|
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": ["
|
|
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": ["
|
|
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": ["
|
|
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": ["
|
|
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": ["
|
|
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
|
|
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
|