@technomoron/mail-magic 1.0.41 → 1.0.43
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 +36 -0
- package/README.md +1 -1
- package/dist/esm/api/assets.d.ts +4 -2
- package/dist/esm/api/assets.js +67 -22
- package/dist/esm/api/auth.js +2 -2
- package/dist/esm/api/forms.js +43 -3
- package/dist/esm/api/mailer.js +68 -9
- package/dist/esm/index.js +5 -4
- package/dist/esm/models/form.js +1 -2
- package/dist/esm/models/init.js +2 -2
- package/dist/esm/models/txmail.js +4 -4
- package/dist/esm/models/user.js +1 -1
- package/dist/esm/store/store.d.ts +4 -3
- package/dist/esm/store/store.js +32 -21
- package/dist/esm/swagger.js +2 -2
- package/dist/esm/types.d.ts +6 -2
- package/dist/esm/util/form-replyto.js +5 -14
- package/dist/esm/util/forms.js +25 -11
- package/dist/esm/util/paths.d.ts +0 -1
- package/dist/esm/util/paths.js +4 -1
- package/dist/esm/util/ratelimit.d.ts +3 -12
- package/dist/esm/util/ratelimit.js +4 -40
- package/dist/esm/util/uploads.d.ts +2 -1
- package/dist/esm/util/uploads.js +16 -11
- package/dist/esm/util/utils.d.ts +0 -2
- package/dist/esm/util/utils.js +0 -18
- package/docs/swagger/openapi.json +1 -1
- package/docs/tutorial.md +1 -1
- package/examples/README.md +2 -2
- package/package.json +2 -2
package/CHANGES
CHANGED
|
@@ -1,6 +1,42 @@
|
|
|
1
1
|
CHANGES
|
|
2
2
|
=======
|
|
3
3
|
|
|
4
|
+
Version 1.0.43 (2026-03-04)
|
|
5
|
+
|
|
6
|
+
- fix(security): add allowlist for custom email headers on `/v1/tx/message` to prevent header injection via arbitrary keys (e.g. `Bcc`, `From`, `Sender`).
|
|
7
|
+
- fix(security): sanitize Swagger error response to avoid leaking internal filesystem paths.
|
|
8
|
+
- fix(security): reject `..` and `.` path segments explicitly in `normalizeSubdir`.
|
|
9
|
+
- fix(forms): return 500 error instead of silent success when all `form_key` generation attempts are exhausted.
|
|
10
|
+
- fix(mailer): guard `transport` with null check instead of non-null assertion; return 503 when transport is unavailable.
|
|
11
|
+
- fix(mailer): stop leaking internal error details (SMTP errors, file paths) in `/v1/tx/message` error responses.
|
|
12
|
+
- fix(autoreload): debounce `fs.watch` callback (300ms) to prevent reload storms from rapid file change events.
|
|
13
|
+
- refactor(form-replyto): replace duplicated `getFirstStringField` with shared `getBodyValue` from utils.
|
|
14
|
+
- docs(readme): fix `rcpt` example from JSON array to comma-separated string to match actual API contract.
|
|
15
|
+
- feat(locale): implement 3-step locale fallback for template lookup: request locale → domain default locale → empty locale.
|
|
16
|
+
- fix(auth): resolve default locale from domain record instead of hardcoding `'en'` when request omits `locale`.
|
|
17
|
+
- refactor(locale): remove `user.locale` from all locale resolution chains; domain locale is the sole default.
|
|
18
|
+
- docs(tutorial): replace non-existent `now().iso8601()` Nunjucks call with `default('unknown')`.
|
|
19
|
+
- test(autoreload): update store-autoreload tests to use fake timers for debounced reload.
|
|
20
|
+
- perf(forms): batch recipient resolution for `_mm_recipients` into scoped + fallback `IN` queries while preserving precedence and requested order.
|
|
21
|
+
- test(forms): add coverage for multi-recipient resolution order with scoped-over-domain recipient precedence.
|
|
22
|
+
- (Changes generated/assisted by Codex (profile: openai-gpt-5-codex/medium).)
|
|
23
|
+
- (Changes generated/assisted by Claude Code (profile: anthropic-claude-opus-4-6/high).)
|
|
24
|
+
|
|
25
|
+
Version 1.0.42 (2026-02-27)
|
|
26
|
+
|
|
27
|
+
- chore(deps): upgrade `@technomoron/api-server-base` from `2.0.0-beta.20` to `2.0.0-beta.24` (Express → Fastify migration).
|
|
28
|
+
- feat(auth): enable API key authentication (`apiKeyEnabled: true`, `apiKeyPrefix: 'apikey-'`) in server config to restore API key auth broken by the beta.24 default change.
|
|
29
|
+
- feat(ratelimit): replace local `FixedWindowRateLimiter` implementation with the one exported by `@technomoron/api-server-base`; re-export for consumers.
|
|
30
|
+
- feat(validation): add Fastify JSON Schema validation (`schema`) to `/v1/tx/template`, `/v1/form/template`, and `/v1/form/recipient` routes; omit schema from multipart-capable routes where body is populated after Fastify's validation phase.
|
|
31
|
+
- fix(assets): use a MIME type map to set correct `Content-Type` headers (e.g. `image/png`) instead of passing raw file extensions to `fastifyReply.type()`, which Fastify 5 does not convert automatically.
|
|
32
|
+
- fix(assets): read asset files into a `Buffer` for `res.send()` since Fastify 5 stream piping through `fastify.routing()` does not flush to supertest-style clients.
|
|
33
|
+
- fix(ratelimit): restore `Retry-After` response header on 429 rate-limit rejections by setting it on the underlying Fastify reply before throwing `ApiError`.
|
|
34
|
+
- fix(uploads): update `UploadedFile` type to match `ApiUploadedFile` from beta.24 (`filepath?`/`buffer?` instead of `path`); update `store.ts`, `uploads.ts`, `mailer.ts`, `forms.ts` to handle both in-memory buffers and on-disk files.
|
|
35
|
+
- fix(swagger): replace Express-typed handler signature with `ExtendedReq`/`ApiRequest['res']` from `@technomoron/api-server-base`.
|
|
36
|
+
- fix(routes): change wildcard route from `/:domain/*path` to `/:domain/*` (find-my-way/Fastify does not support named wildcards).
|
|
37
|
+
- docs(swagger): bump packaged OpenAPI spec version to 1.0.42.
|
|
38
|
+
- (Changes generated/assisted by Claude Code (profile: anthropic-claude-sonnet-4-6/high).)
|
|
39
|
+
|
|
4
40
|
Version 1.0.41 (2026-02-22)
|
|
5
41
|
|
|
6
42
|
- chore(release): add package-level `release:check` script and wire `release` to shared publish script.
|
package/README.md
CHANGED
package/dist/esm/api/assets.d.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { ApiModule, ApiRoute } from '@technomoron/api-server-base';
|
|
2
2
|
import { mailApiServer } from '../server.js';
|
|
3
|
-
import type {
|
|
3
|
+
import type { ApiRequest, ExtendedReq } from '@technomoron/api-server-base';
|
|
4
|
+
type ApiRes = ApiRequest['res'];
|
|
4
5
|
export declare class AssetAPI extends ApiModule<mailApiServer> {
|
|
5
6
|
private resolveTemplateDir;
|
|
6
7
|
private postAssets;
|
|
7
8
|
defineRoutes(): ApiRoute[];
|
|
8
9
|
}
|
|
9
|
-
export declare function createAssetHandler(server: mailApiServer): (req:
|
|
10
|
+
export declare function createAssetHandler(server: mailApiServer): (req: ExtendedReq, res: ApiRes, next?: (error?: unknown) => void) => Promise<void>;
|
|
11
|
+
export {};
|
package/dist/esm/api/assets.js
CHANGED
|
@@ -6,12 +6,36 @@ import { api_txmail } from '../models/txmail.js';
|
|
|
6
6
|
import { SEGMENT_PATTERN, normalizeSubdir } from '../util/paths.js';
|
|
7
7
|
import { moveUploadedFiles } from '../util/uploads.js';
|
|
8
8
|
import { getBodyValue } from '../util/utils.js';
|
|
9
|
-
import { decodeComponent
|
|
9
|
+
import { decodeComponent } from '../util.js';
|
|
10
10
|
import { assert_domain_and_user } from './auth.js';
|
|
11
11
|
const DOMAIN_PATTERN = /^[a-z0-9][a-z0-9._-]*$/i;
|
|
12
|
+
const MIME_TYPES = {
|
|
13
|
+
'.png': 'image/png',
|
|
14
|
+
'.jpg': 'image/jpeg',
|
|
15
|
+
'.jpeg': 'image/jpeg',
|
|
16
|
+
'.gif': 'image/gif',
|
|
17
|
+
'.webp': 'image/webp',
|
|
18
|
+
'.svg': 'image/svg+xml',
|
|
19
|
+
'.ico': 'image/x-icon',
|
|
20
|
+
'.css': 'text/css',
|
|
21
|
+
'.js': 'application/javascript',
|
|
22
|
+
'.mjs': 'application/javascript',
|
|
23
|
+
'.json': 'application/json',
|
|
24
|
+
'.txt': 'text/plain',
|
|
25
|
+
'.html': 'text/html',
|
|
26
|
+
'.htm': 'text/html',
|
|
27
|
+
'.pdf': 'application/pdf',
|
|
28
|
+
'.woff': 'font/woff',
|
|
29
|
+
'.woff2': 'font/woff2',
|
|
30
|
+
'.ttf': 'font/ttf',
|
|
31
|
+
'.eot': 'application/vnd.ms-fontobject'
|
|
32
|
+
};
|
|
33
|
+
function getMimeType(ext) {
|
|
34
|
+
return MIME_TYPES[ext.toLowerCase()] ?? 'application/octet-stream';
|
|
35
|
+
}
|
|
12
36
|
export class AssetAPI extends ApiModule {
|
|
13
37
|
async resolveTemplateDir(apireq) {
|
|
14
|
-
const body = apireq.req.body ?? {};
|
|
38
|
+
const body = (apireq.req.body ?? {});
|
|
15
39
|
const templateTypeRaw = getBodyValue(body, 'templateType', 'template_type', 'type');
|
|
16
40
|
const templateName = getBodyValue(body, 'template', 'name', 'idname', 'formid');
|
|
17
41
|
const locale = getBodyValue(body, 'locale');
|
|
@@ -23,9 +47,17 @@ export class AssetAPI extends ApiModule {
|
|
|
23
47
|
}
|
|
24
48
|
const templateType = templateTypeRaw.toLowerCase();
|
|
25
49
|
const domainId = apireq.domain.domain_id;
|
|
50
|
+
const deflocale = apireq.domain.locale || '';
|
|
26
51
|
if (templateType === 'tx') {
|
|
27
|
-
|
|
28
|
-
|
|
52
|
+
let template = await api_txmail.findOne({ where: { name: templateName, domain_id: domainId, locale } });
|
|
53
|
+
if (!template && deflocale && deflocale !== locale) {
|
|
54
|
+
template = await api_txmail.findOne({
|
|
55
|
+
where: { name: templateName, domain_id: domainId, locale: deflocale }
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
if (!template && locale !== '') {
|
|
59
|
+
template = await api_txmail.findOne({ where: { name: templateName, domain_id: domainId, locale: '' } });
|
|
60
|
+
}
|
|
29
61
|
if (!template) {
|
|
30
62
|
throw new ApiError({
|
|
31
63
|
code: 404,
|
|
@@ -41,8 +73,15 @@ export class AssetAPI extends ApiModule {
|
|
|
41
73
|
return path.dirname(candidate);
|
|
42
74
|
}
|
|
43
75
|
if (templateType === 'form') {
|
|
44
|
-
|
|
45
|
-
|
|
76
|
+
let form = await api_form.findOne({ where: { idname: templateName, domain_id: domainId, locale } });
|
|
77
|
+
if (!form && deflocale && deflocale !== locale) {
|
|
78
|
+
form = await api_form.findOne({
|
|
79
|
+
where: { idname: templateName, domain_id: domainId, locale: deflocale }
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
if (!form && locale !== '') {
|
|
83
|
+
form = await api_form.findOne({ where: { idname: templateName, domain_id: domainId, locale: '' } });
|
|
84
|
+
}
|
|
46
85
|
if (!form) {
|
|
47
86
|
throw new ApiError({
|
|
48
87
|
code: 404,
|
|
@@ -65,7 +104,7 @@ export class AssetAPI extends ApiModule {
|
|
|
65
104
|
if (!rawFiles.length) {
|
|
66
105
|
throw new ApiError({ code: 400, message: 'No files uploaded' });
|
|
67
106
|
}
|
|
68
|
-
const body = apireq.req.body ?? {};
|
|
107
|
+
const body = (apireq.req.body ?? {});
|
|
69
108
|
const subdir = normalizeSubdir(getBodyValue(body, 'path', 'dir'));
|
|
70
109
|
const templateType = getBodyValue(body, 'templateType', 'template_type', 'type');
|
|
71
110
|
let targetRoot;
|
|
@@ -101,15 +140,15 @@ export function createAssetHandler(server) {
|
|
|
101
140
|
next();
|
|
102
141
|
return;
|
|
103
142
|
}
|
|
104
|
-
res.status(405).
|
|
143
|
+
res.status(405).send(null);
|
|
105
144
|
return;
|
|
106
145
|
}
|
|
107
|
-
const domain = decodeComponent(req?.params?.domain);
|
|
146
|
+
const domain = decodeComponent(req?.params?.['domain']);
|
|
108
147
|
if (!domain || !DOMAIN_PATTERN.test(domain)) {
|
|
109
|
-
res.status(404).
|
|
148
|
+
res.status(404).send(null);
|
|
110
149
|
return;
|
|
111
150
|
}
|
|
112
|
-
const rawPathParam = req
|
|
151
|
+
const rawPathParam = req.params?.['path'] ?? req.params?.['*'];
|
|
113
152
|
const rawSegments = Array.isArray(rawPathParam)
|
|
114
153
|
? rawPathParam
|
|
115
154
|
: typeof rawPathParam === 'string'
|
|
@@ -117,12 +156,12 @@ export function createAssetHandler(server) {
|
|
|
117
156
|
: [];
|
|
118
157
|
const segments = rawSegments.map((segment) => decodeComponent(typeof segment === 'string' ? segment : ''));
|
|
119
158
|
if (!segments.length || segments.some((segment) => !SEGMENT_PATTERN.test(segment))) {
|
|
120
|
-
res.status(404).
|
|
159
|
+
res.status(404).send(null);
|
|
121
160
|
return;
|
|
122
161
|
}
|
|
123
162
|
const assetsRoot = path.join(server.storage.configpath, domain, 'assets');
|
|
124
163
|
if (!fs.existsSync(assetsRoot)) {
|
|
125
|
-
res.status(404).
|
|
164
|
+
res.status(404).send(null);
|
|
126
165
|
return;
|
|
127
166
|
}
|
|
128
167
|
const resolvedRoot = fs.realpathSync(assetsRoot);
|
|
@@ -131,12 +170,12 @@ export function createAssetHandler(server) {
|
|
|
131
170
|
try {
|
|
132
171
|
const stats = await fs.promises.stat(candidate);
|
|
133
172
|
if (!stats.isFile()) {
|
|
134
|
-
res.status(404).
|
|
173
|
+
res.status(404).send(null);
|
|
135
174
|
return;
|
|
136
175
|
}
|
|
137
176
|
}
|
|
138
177
|
catch {
|
|
139
|
-
res.status(404).
|
|
178
|
+
res.status(404).send(null);
|
|
140
179
|
return;
|
|
141
180
|
}
|
|
142
181
|
let realCandidate;
|
|
@@ -144,23 +183,29 @@ export function createAssetHandler(server) {
|
|
|
144
183
|
realCandidate = await fs.promises.realpath(candidate);
|
|
145
184
|
}
|
|
146
185
|
catch {
|
|
147
|
-
res.status(404).
|
|
186
|
+
res.status(404).send(null);
|
|
148
187
|
return;
|
|
149
188
|
}
|
|
150
189
|
if (!realCandidate.startsWith(normalizedRoot)) {
|
|
151
|
-
res.status(404).
|
|
190
|
+
res.status(404).send(null);
|
|
152
191
|
return;
|
|
153
192
|
}
|
|
154
|
-
|
|
193
|
+
const ext = path.extname(realCandidate);
|
|
194
|
+
// Access the underlying Fastify reply to set content-type and cache-control headers.
|
|
195
|
+
// ApiResponse does not expose arbitrary header-setting methods.
|
|
196
|
+
const fastifyReply = res.reply;
|
|
197
|
+
if (fastifyReply) {
|
|
198
|
+
fastifyReply.type(getMimeType(ext));
|
|
199
|
+
fastifyReply.header('cache-control', 'public, max-age=300');
|
|
200
|
+
}
|
|
155
201
|
try {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
await sendFileAsync(res, realCandidate, { maxAge: 300_000 });
|
|
202
|
+
const content = await fs.promises.readFile(realCandidate);
|
|
203
|
+
res.send(content);
|
|
159
204
|
}
|
|
160
205
|
catch (err) {
|
|
161
206
|
server.storage.print_debug(`Failed to serve asset ${domain}/${segments.join('/')}: ${err instanceof Error ? err.message : String(err)}`);
|
|
162
207
|
if (!res.headersSent) {
|
|
163
|
-
res.status(500).
|
|
208
|
+
res.status(500).send(null);
|
|
164
209
|
}
|
|
165
210
|
}
|
|
166
211
|
};
|
package/dist/esm/api/auth.js
CHANGED
|
@@ -3,7 +3,7 @@ import { api_domain } from '../models/domain.js';
|
|
|
3
3
|
import { api_user } from '../models/user.js';
|
|
4
4
|
import { getBodyValue } from '../util/utils.js';
|
|
5
5
|
export async function assert_domain_and_user(apireq) {
|
|
6
|
-
const body = apireq.req.body ?? {};
|
|
6
|
+
const body = (apireq.req.body ?? {});
|
|
7
7
|
const domainRaw = getBodyValue(body, 'domain');
|
|
8
8
|
const locale = getBodyValue(body, 'locale');
|
|
9
9
|
const rawUid = apireq.getRealUid();
|
|
@@ -36,5 +36,5 @@ export async function assert_domain_and_user(apireq) {
|
|
|
36
36
|
}
|
|
37
37
|
apireq.domain = dbdomain;
|
|
38
38
|
apireq.user = user;
|
|
39
|
-
apireq.locale = locale || '
|
|
39
|
+
apireq.locale = locale || dbdomain.locale || '';
|
|
40
40
|
}
|
package/dist/esm/api/forms.js
CHANGED
|
@@ -62,7 +62,7 @@ export class FormAPI extends ApiModule {
|
|
|
62
62
|
}
|
|
63
63
|
async postFormTemplate(apireq) {
|
|
64
64
|
await assert_domain_and_user(apireq);
|
|
65
|
-
const payload = parseFormTemplatePayload(apireq.req.body ?? {});
|
|
65
|
+
const payload = parseFormTemplatePayload((apireq.req.body ?? {}));
|
|
66
66
|
validateFormTemplatePayload(payload);
|
|
67
67
|
const user = apireq.user;
|
|
68
68
|
const domain = apireq.domain;
|
|
@@ -89,6 +89,7 @@ export class FormAPI extends ApiModule {
|
|
|
89
89
|
payload
|
|
90
90
|
});
|
|
91
91
|
let created = false;
|
|
92
|
+
let upserted = false;
|
|
92
93
|
for (let attempt = 0; attempt < 10; attempt++) {
|
|
93
94
|
try {
|
|
94
95
|
const [form, wasCreated] = await api_form.upsert(record, {
|
|
@@ -96,6 +97,7 @@ export class FormAPI extends ApiModule {
|
|
|
96
97
|
conflictFields: ['user_id', 'domain_id', 'locale', 'idname']
|
|
97
98
|
});
|
|
98
99
|
created = wasCreated ?? false;
|
|
100
|
+
upserted = true;
|
|
99
101
|
form_key = form.form_key || form_key;
|
|
100
102
|
this.server.storage.print_debug(`Form template upserted: ${form.idname} (created=${wasCreated})`);
|
|
101
103
|
break;
|
|
@@ -115,6 +117,9 @@ export class FormAPI extends ApiModule {
|
|
|
115
117
|
});
|
|
116
118
|
}
|
|
117
119
|
}
|
|
120
|
+
if (!upserted) {
|
|
121
|
+
throw new ApiError({ code: 500, message: 'Unable to generate a unique form_key after multiple attempts' });
|
|
122
|
+
}
|
|
118
123
|
return [200, { Status: 'OK', created, form_key }];
|
|
119
124
|
}
|
|
120
125
|
async postSendForm(apireq) {
|
|
@@ -185,6 +190,9 @@ export class FormAPI extends ApiModule {
|
|
|
185
190
|
...(replyToValue ? { replyTo: replyToValue } : {})
|
|
186
191
|
};
|
|
187
192
|
try {
|
|
193
|
+
if (!this.server.storage.transport) {
|
|
194
|
+
throw new ApiError({ code: 503, message: 'Mail transport is not available' });
|
|
195
|
+
}
|
|
188
196
|
const info = await this.server.storage.transport.sendMail(mailOptions);
|
|
189
197
|
this.server.storage.print_debug('Email sent: ' + info.response);
|
|
190
198
|
}
|
|
@@ -207,13 +215,45 @@ export class FormAPI extends ApiModule {
|
|
|
207
215
|
method: 'post',
|
|
208
216
|
path: '/v1/form/recipient',
|
|
209
217
|
handler: (req) => this.postFormRecipient(req),
|
|
210
|
-
auth: { type: 'yes', req: 'any' }
|
|
218
|
+
auth: { type: 'yes', req: 'any' },
|
|
219
|
+
schema: {
|
|
220
|
+
body: {
|
|
221
|
+
type: 'object',
|
|
222
|
+
required: ['email', 'idname'],
|
|
223
|
+
properties: {
|
|
224
|
+
email: { type: 'string' },
|
|
225
|
+
idname: { type: 'string' },
|
|
226
|
+
name: { type: 'string' },
|
|
227
|
+
form_key: { type: 'string' },
|
|
228
|
+
formid: { type: 'string' },
|
|
229
|
+
locale: { type: 'string' },
|
|
230
|
+
domain: { type: 'string' }
|
|
231
|
+
},
|
|
232
|
+
additionalProperties: true
|
|
233
|
+
}
|
|
234
|
+
}
|
|
211
235
|
},
|
|
212
236
|
{
|
|
213
237
|
method: 'post',
|
|
214
238
|
path: '/v1/form/template',
|
|
215
239
|
handler: (req) => this.postFormTemplate(req),
|
|
216
|
-
auth: { type: 'yes', req: 'any' }
|
|
240
|
+
auth: { type: 'yes', req: 'any' },
|
|
241
|
+
schema: {
|
|
242
|
+
body: {
|
|
243
|
+
type: 'object',
|
|
244
|
+
required: ['idname', 'template', 'sender', 'recipient'],
|
|
245
|
+
properties: {
|
|
246
|
+
idname: { type: 'string' },
|
|
247
|
+
template: { type: 'string' },
|
|
248
|
+
sender: { type: 'string' },
|
|
249
|
+
recipient: { type: 'string' },
|
|
250
|
+
subject: { type: 'string' },
|
|
251
|
+
locale: { type: 'string' },
|
|
252
|
+
domain: { type: 'string' }
|
|
253
|
+
},
|
|
254
|
+
additionalProperties: true
|
|
255
|
+
}
|
|
256
|
+
}
|
|
217
257
|
},
|
|
218
258
|
{
|
|
219
259
|
method: 'post',
|
package/dist/esm/api/mailer.js
CHANGED
|
@@ -31,7 +31,12 @@ export class MailerAPI extends ApiModule {
|
|
|
31
31
|
// Store a template in the database
|
|
32
32
|
async post_template(apireq) {
|
|
33
33
|
await assert_domain_and_user(apireq);
|
|
34
|
-
const
|
|
34
|
+
const body = apireq.req.body;
|
|
35
|
+
const template = String(body.template ?? '');
|
|
36
|
+
const sender = String(body.sender ?? '');
|
|
37
|
+
const name = String(body.name ?? '');
|
|
38
|
+
const subject = String(body.subject ?? '');
|
|
39
|
+
const locale = String(body.locale ?? '');
|
|
35
40
|
if (!template) {
|
|
36
41
|
throw new ApiError({ code: 400, message: 'Missing template data' });
|
|
37
42
|
}
|
|
@@ -63,7 +68,14 @@ export class MailerAPI extends ApiModule {
|
|
|
63
68
|
}
|
|
64
69
|
// Send a template using posted arguments.
|
|
65
70
|
async post_send(apireq) {
|
|
66
|
-
const
|
|
71
|
+
const body = apireq.req.body;
|
|
72
|
+
const name = String(body.name ?? '');
|
|
73
|
+
const rcpt = String(body.rcpt ?? '');
|
|
74
|
+
const locale = String(body.locale ?? '');
|
|
75
|
+
const vars = body.vars ?? {};
|
|
76
|
+
const replyTo = body.replyTo;
|
|
77
|
+
const reply_to = body.reply_to;
|
|
78
|
+
const headers = body.headers;
|
|
67
79
|
await assert_domain_and_user(apireq);
|
|
68
80
|
if (!name || !rcpt) {
|
|
69
81
|
throw new ApiError({ code: 400, message: 'name/rcpt required' });
|
|
@@ -84,10 +96,18 @@ export class MailerAPI extends ApiModule {
|
|
|
84
96
|
}
|
|
85
97
|
let template = null;
|
|
86
98
|
const domain_id = apireq.domain.domain_id;
|
|
99
|
+
const deflocale = apireq.domain.locale || '';
|
|
87
100
|
try {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
101
|
+
// 1. Exact locale match
|
|
102
|
+
template = await api_txmail.findOne({ where: { name, domain_id, locale } });
|
|
103
|
+
// 2. Domain/user default locale (if different from request locale)
|
|
104
|
+
if (!template && deflocale && deflocale !== locale) {
|
|
105
|
+
template = await api_txmail.findOne({ where: { name, domain_id, locale: deflocale } });
|
|
106
|
+
}
|
|
107
|
+
// 3. Empty-locale fallback (if not already tried above)
|
|
108
|
+
if (!template && locale !== '') {
|
|
109
|
+
template = await api_txmail.findOne({ where: { name, domain_id, locale: '' } });
|
|
110
|
+
}
|
|
91
111
|
}
|
|
92
112
|
catch (error) {
|
|
93
113
|
throw new ApiError({
|
|
@@ -116,7 +136,7 @@ export class MailerAPI extends ApiModule {
|
|
|
116
136
|
})),
|
|
117
137
|
...rawFiles.map((file) => ({
|
|
118
138
|
filename: file.originalname,
|
|
119
|
-
path: file.
|
|
139
|
+
...(file.buffer ? { content: file.buffer } : { path: file.filepath })
|
|
120
140
|
}))
|
|
121
141
|
];
|
|
122
142
|
const attachmentMap = {};
|
|
@@ -133,6 +153,19 @@ export class MailerAPI extends ApiModule {
|
|
|
133
153
|
throw new ApiError({ code: 400, message: 'Invalid reply-to email address' });
|
|
134
154
|
}
|
|
135
155
|
}
|
|
156
|
+
const ALLOWED_CUSTOM_HEADERS = new Set([
|
|
157
|
+
'x-mailer',
|
|
158
|
+
'x-priority',
|
|
159
|
+
'x-entity-ref-id',
|
|
160
|
+
'list-unsubscribe',
|
|
161
|
+
'list-unsubscribe-post',
|
|
162
|
+
'list-id',
|
|
163
|
+
'precedence',
|
|
164
|
+
'references',
|
|
165
|
+
'in-reply-to',
|
|
166
|
+
'message-id',
|
|
167
|
+
'importance'
|
|
168
|
+
]);
|
|
136
169
|
let normalizedHeaders;
|
|
137
170
|
if (headers !== undefined) {
|
|
138
171
|
if (!headers || typeof headers !== 'object' || Array.isArray(headers)) {
|
|
@@ -143,6 +176,9 @@ export class MailerAPI extends ApiModule {
|
|
|
143
176
|
if (typeof value !== 'string') {
|
|
144
177
|
throw new ApiError({ code: 400, message: `headers.${key} must be a string` });
|
|
145
178
|
}
|
|
179
|
+
if (!ALLOWED_CUSTOM_HEADERS.has(key.toLowerCase())) {
|
|
180
|
+
throw new ApiError({ code: 400, message: `Header "${key}" is not allowed` });
|
|
181
|
+
}
|
|
146
182
|
normalizedHeaders[key] = value;
|
|
147
183
|
}
|
|
148
184
|
}
|
|
@@ -162,21 +198,27 @@ export class MailerAPI extends ApiModule {
|
|
|
162
198
|
const sendargs = {
|
|
163
199
|
from: sender,
|
|
164
200
|
to: recipient,
|
|
165
|
-
subject: template.subject ||
|
|
201
|
+
subject: template.subject || body.subject || '',
|
|
166
202
|
html,
|
|
167
203
|
text,
|
|
168
204
|
attachments,
|
|
169
205
|
...(normalizedReplyTo ? { replyTo: normalizedReplyTo } : {}),
|
|
170
206
|
...(normalizedHeaders ? { headers: normalizedHeaders } : {})
|
|
171
207
|
};
|
|
208
|
+
if (!this.server.storage.transport) {
|
|
209
|
+
throw new ApiError({ code: 503, message: 'Mail transport is not available' });
|
|
210
|
+
}
|
|
172
211
|
await this.server.storage.transport.sendMail(sendargs);
|
|
173
212
|
}
|
|
174
213
|
return [200, { Status: 'OK', Message: 'Emails sent successfully' }];
|
|
175
214
|
}
|
|
176
215
|
catch (error) {
|
|
216
|
+
if (error instanceof ApiError) {
|
|
217
|
+
throw error;
|
|
218
|
+
}
|
|
177
219
|
throw new ApiError({
|
|
178
220
|
code: 500,
|
|
179
|
-
message:
|
|
221
|
+
message: 'Failed to render or send email'
|
|
180
222
|
});
|
|
181
223
|
}
|
|
182
224
|
}
|
|
@@ -186,13 +228,30 @@ export class MailerAPI extends ApiModule {
|
|
|
186
228
|
method: 'post',
|
|
187
229
|
path: '/v1/tx/message',
|
|
188
230
|
handler: this.post_send.bind(this),
|
|
231
|
+
// No schema: this route accepts multipart/form-data; Fastify validates request.body
|
|
232
|
+
// before the multipart parsing hook populates it, so schema required-fields would
|
|
233
|
+
// reject valid multipart requests. Validation is handled in the route handler.
|
|
189
234
|
auth: { type: 'yes', req: 'any' }
|
|
190
235
|
},
|
|
191
236
|
{
|
|
192
237
|
method: 'post',
|
|
193
238
|
path: '/v1/tx/template',
|
|
194
239
|
handler: this.post_template.bind(this),
|
|
195
|
-
auth: { type: 'yes', req: 'any' }
|
|
240
|
+
auth: { type: 'yes', req: 'any' },
|
|
241
|
+
schema: {
|
|
242
|
+
body: {
|
|
243
|
+
type: 'object',
|
|
244
|
+
required: ['name', 'template'],
|
|
245
|
+
properties: {
|
|
246
|
+
name: { type: 'string' },
|
|
247
|
+
template: { type: 'string' },
|
|
248
|
+
sender: { type: 'string' },
|
|
249
|
+
subject: { type: 'string' },
|
|
250
|
+
locale: { type: 'string' }
|
|
251
|
+
},
|
|
252
|
+
additionalProperties: true
|
|
253
|
+
}
|
|
254
|
+
}
|
|
196
255
|
}
|
|
197
256
|
];
|
|
198
257
|
}
|
package/dist/esm/index.js
CHANGED
|
@@ -25,6 +25,8 @@ function buildServerConfig(store, overrides) {
|
|
|
25
25
|
apiBasePath: normalizeRoute(env.API_BASE_PATH, '/api'),
|
|
26
26
|
swaggerEnabled: env.SWAGGER_ENABLED,
|
|
27
27
|
swaggerPath: env.SWAGGER_PATH,
|
|
28
|
+
apiKeyEnabled: true,
|
|
29
|
+
apiKeyPrefix: 'apikey-',
|
|
28
30
|
...overrides
|
|
29
31
|
};
|
|
30
32
|
}
|
|
@@ -71,10 +73,9 @@ export async function createMailMagicServer(overrides = {}, envOverrides = {}) {
|
|
|
71
73
|
assetMounts.add(`${apiBasePrefix}${assetPrefix}`);
|
|
72
74
|
}
|
|
73
75
|
for (const prefix of assetMounts) {
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
|
|
77
|
-
server.useExpress(`${prefix}/:domain/*path`, assetHandler);
|
|
76
|
+
// Use ApiServer.useExpress() so mounts under `apiBasePath` are installed before the API
|
|
77
|
+
// 404 handler. Fastify (find-my-way) requires the wildcard to be an unnamed `*`.
|
|
78
|
+
server.useExpress(`${prefix}/:domain/*`, assetHandler);
|
|
78
79
|
}
|
|
79
80
|
if (store.vars.ADMIN_ENABLED) {
|
|
80
81
|
await enableAdminFeatures(server, store, adminUiPath);
|
package/dist/esm/models/form.js
CHANGED
|
@@ -231,12 +231,11 @@ export async function init_api_form(api_db) {
|
|
|
231
231
|
return api_form;
|
|
232
232
|
}
|
|
233
233
|
export async function upsert_form(record) {
|
|
234
|
-
const {
|
|
234
|
+
const { domain } = await user_and_domain(record.domain_id);
|
|
235
235
|
const dname = normalizeSlug(domain.name);
|
|
236
236
|
const { slug, filename: generatedFilename } = buildFormSlugAndFilename({
|
|
237
237
|
domainName: domain.name,
|
|
238
238
|
domainLocale: domain.locale,
|
|
239
|
-
userLocale: user.locale,
|
|
240
239
|
idname: record.idname,
|
|
241
240
|
locale: record.locale
|
|
242
241
|
});
|
package/dist/esm/models/init.js
CHANGED
|
@@ -67,12 +67,12 @@ async function _load_template(store, filename, pathname, user, domain, locale, t
|
|
|
67
67
|
}
|
|
68
68
|
export async function loadFormTemplate(store, form) {
|
|
69
69
|
const { user, domain } = await user_and_domain(form.domain_id);
|
|
70
|
-
const locale = form.locale || domain.locale ||
|
|
70
|
+
const locale = form.locale || domain.locale || null;
|
|
71
71
|
return _load_template(store, form.filename, '', user, domain, locale, 'form-template');
|
|
72
72
|
}
|
|
73
73
|
export async function loadTxTemplate(store, template) {
|
|
74
74
|
const { user, domain } = await user_and_domain(template.domain_id);
|
|
75
|
-
const locale = template.locale || domain.locale ||
|
|
75
|
+
const locale = template.locale || domain.locale || null;
|
|
76
76
|
return _load_template(store, template.filename, '', user, domain, locale, 'tx-template');
|
|
77
77
|
}
|
|
78
78
|
export async function importData(store) {
|
|
@@ -29,10 +29,10 @@ export const api_txmail_schema = z
|
|
|
29
29
|
export class api_txmail extends Model {
|
|
30
30
|
}
|
|
31
31
|
export async function upsert_txmail(record) {
|
|
32
|
-
const {
|
|
32
|
+
const { domain } = await user_and_domain(record.domain_id);
|
|
33
33
|
const dname = normalizeSlug(domain.name);
|
|
34
34
|
const name = normalizeSlug(record.name);
|
|
35
|
-
const locale = normalizeSlug(record.locale || domain.locale ||
|
|
35
|
+
const locale = normalizeSlug(record.locale || domain.locale || '');
|
|
36
36
|
if (!record.slug) {
|
|
37
37
|
record.slug = `${dname}${locale ? '-' + locale : ''}-${name}`;
|
|
38
38
|
}
|
|
@@ -157,10 +157,10 @@ export async function init_api_txmail(api_db) {
|
|
|
157
157
|
]
|
|
158
158
|
});
|
|
159
159
|
api_txmail.addHook('beforeValidate', async (template) => {
|
|
160
|
-
const {
|
|
160
|
+
const { domain } = await user_and_domain(template.domain_id);
|
|
161
161
|
const dname = normalizeSlug(domain.name);
|
|
162
162
|
const name = normalizeSlug(template.name);
|
|
163
|
-
const locale = normalizeSlug(template.locale || domain.locale ||
|
|
163
|
+
const locale = normalizeSlug(template.locale || domain.locale || '');
|
|
164
164
|
template.slug ||= `${dname}${locale ? '-' + locale : ''}-${name}`;
|
|
165
165
|
if (!template.filename) {
|
|
166
166
|
const parts = [dname, 'tx-template'];
|
package/dist/esm/models/user.js
CHANGED
|
@@ -10,7 +10,7 @@ export const api_user_schema = z
|
|
|
10
10
|
name: z.string().min(1).describe('Display name for the user.'),
|
|
11
11
|
email: z.string().email().describe('User email address.'),
|
|
12
12
|
domain: z.number().int().nonnegative().nullable().optional().describe('Default domain ID for the user.'),
|
|
13
|
-
locale: z.string().default('').describe('
|
|
13
|
+
locale: z.string().default('').describe('Reserved. Locale resolution uses the domain locale.')
|
|
14
14
|
})
|
|
15
15
|
.describe('User account record and API credentials.');
|
|
16
16
|
export class api_user extends Model {
|
|
@@ -4,9 +4,10 @@ import { Sequelize } from 'sequelize';
|
|
|
4
4
|
import { envOptions } from './envloader.js';
|
|
5
5
|
import type SMTPTransport from 'nodemailer/lib/smtp-transport';
|
|
6
6
|
type UploadedFile = {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
fieldname?: string;
|
|
8
|
+
originalname?: string;
|
|
9
|
+
filepath?: string;
|
|
10
|
+
buffer?: Buffer;
|
|
10
11
|
};
|
|
11
12
|
export type MailStoreVars = envConfig<typeof envOptions>;
|
|
12
13
|
type AutoReloadHandle = {
|
package/dist/esm/store/store.js
CHANGED
|
@@ -30,14 +30,21 @@ export function enableInitDataAutoReload(ctx, reload) {
|
|
|
30
30
|
}
|
|
31
31
|
const initDataPath = ctx.config_filename('init-data.json');
|
|
32
32
|
ctx.print_debug('Enabling auto reload of init-data.json');
|
|
33
|
+
let debounceTimer = null;
|
|
33
34
|
const onChange = () => {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
reload();
|
|
37
|
-
}
|
|
38
|
-
catch (err) {
|
|
39
|
-
ctx.print_debug(`Failed to reload config: ${err}`);
|
|
35
|
+
if (debounceTimer) {
|
|
36
|
+
clearTimeout(debounceTimer);
|
|
40
37
|
}
|
|
38
|
+
debounceTimer = setTimeout(() => {
|
|
39
|
+
debounceTimer = null;
|
|
40
|
+
ctx.print_debug('Config file changed, reloading...');
|
|
41
|
+
try {
|
|
42
|
+
reload();
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
ctx.print_debug(`Failed to reload config: ${err}`);
|
|
46
|
+
}
|
|
47
|
+
}, 300);
|
|
41
48
|
};
|
|
42
49
|
try {
|
|
43
50
|
const watcher = fs.watch(initDataPath, { persistent: false }, onChange);
|
|
@@ -102,24 +109,28 @@ export class mailStore {
|
|
|
102
109
|
}
|
|
103
110
|
await fs.promises.mkdir(targetDir, { recursive: true });
|
|
104
111
|
await Promise.all(files.map(async (file) => {
|
|
105
|
-
|
|
112
|
+
const name = (file.originalname ?? file.filepath) ? path.basename(file.filepath ?? file.originalname ?? '') : '';
|
|
113
|
+
if (!name) {
|
|
106
114
|
return;
|
|
107
115
|
}
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
116
|
+
const destination = path.join(targetDir, name);
|
|
117
|
+
if (file.buffer) {
|
|
118
|
+
await fs.promises.writeFile(destination, file.buffer);
|
|
119
|
+
file.filepath = destination;
|
|
120
|
+
file.buffer = undefined;
|
|
112
121
|
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
122
|
+
else if (file.filepath) {
|
|
123
|
+
if (destination === file.filepath) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
await fs.promises.rename(file.filepath, destination);
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
await fs.promises.copyFile(file.filepath, destination);
|
|
131
|
+
await fs.promises.unlink(file.filepath);
|
|
132
|
+
}
|
|
133
|
+
file.filepath = destination;
|
|
123
134
|
}
|
|
124
135
|
}));
|
|
125
136
|
}
|
package/dist/esm/swagger.js
CHANGED
|
@@ -54,8 +54,8 @@ function loadPackagedOpenApiSpec() {
|
|
|
54
54
|
cachedSpec = JSON.parse(raw);
|
|
55
55
|
return cachedSpec;
|
|
56
56
|
}
|
|
57
|
-
catch
|
|
58
|
-
cachedSpecError =
|
|
57
|
+
catch {
|
|
58
|
+
cachedSpecError = 'Failed to load OpenAPI spec';
|
|
59
59
|
return null;
|
|
60
60
|
}
|
|
61
61
|
}
|
package/dist/esm/types.d.ts
CHANGED
|
@@ -26,7 +26,11 @@ export interface RequestMeta {
|
|
|
26
26
|
ip_chain: string[];
|
|
27
27
|
}
|
|
28
28
|
export interface UploadedFile {
|
|
29
|
-
originalname: string;
|
|
30
|
-
path: string;
|
|
31
29
|
fieldname: string;
|
|
30
|
+
originalname: string;
|
|
31
|
+
encoding?: string;
|
|
32
|
+
mimetype?: string;
|
|
33
|
+
size?: number;
|
|
34
|
+
buffer?: Buffer;
|
|
35
|
+
filepath?: string;
|
|
32
36
|
}
|
|
@@ -1,14 +1,5 @@
|
|
|
1
1
|
import emailAddresses from 'email-addresses';
|
|
2
|
-
|
|
3
|
-
const value = body[key];
|
|
4
|
-
if (Array.isArray(value) && value.length > 0) {
|
|
5
|
-
return String(value[0] ?? '');
|
|
6
|
-
}
|
|
7
|
-
if (value !== undefined && value !== null) {
|
|
8
|
-
return String(value);
|
|
9
|
-
}
|
|
10
|
-
return '';
|
|
11
|
-
}
|
|
2
|
+
import { getBodyValue } from './utils.js';
|
|
12
3
|
function sanitizeHeaderValue(value, maxLen) {
|
|
13
4
|
const trimmed = String(value ?? '').trim();
|
|
14
5
|
if (!trimmed) {
|
|
@@ -21,7 +12,7 @@ function sanitizeHeaderValue(value, maxLen) {
|
|
|
21
12
|
return trimmed.slice(0, maxLen);
|
|
22
13
|
}
|
|
23
14
|
export function extractReplyToFromSubmission(body) {
|
|
24
|
-
const emailRaw = sanitizeHeaderValue(
|
|
15
|
+
const emailRaw = sanitizeHeaderValue(getBodyValue(body, 'email'), 320);
|
|
25
16
|
if (!emailRaw) {
|
|
26
17
|
return undefined;
|
|
27
18
|
}
|
|
@@ -34,10 +25,10 @@ export function extractReplyToFromSubmission(body) {
|
|
|
34
25
|
return undefined;
|
|
35
26
|
}
|
|
36
27
|
// Prefer a single "name" field, otherwise compose from first_name/last_name.
|
|
37
|
-
let name = sanitizeHeaderValue(
|
|
28
|
+
let name = sanitizeHeaderValue(getBodyValue(body, 'name'), 200);
|
|
38
29
|
if (!name) {
|
|
39
|
-
const first = sanitizeHeaderValue(
|
|
40
|
-
const last = sanitizeHeaderValue(
|
|
30
|
+
const first = sanitizeHeaderValue(getBodyValue(body, 'first_name'), 100);
|
|
31
|
+
const last = sanitizeHeaderValue(getBodyValue(body, 'last_name'), 100);
|
|
41
32
|
name = sanitizeHeaderValue(`${first}${first && last ? ' ' : ''}${last}`, 200);
|
|
42
33
|
}
|
|
43
34
|
return name ? { name, address } : address;
|
package/dist/esm/util/forms.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ApiError } from '@technomoron/api-server-base';
|
|
2
|
+
import { Op } from 'sequelize';
|
|
2
3
|
import { api_form } from '../models/form.js';
|
|
3
4
|
import { api_recipient } from '../models/recipient.js';
|
|
4
5
|
import { verifyCaptcha } from './captcha.js';
|
|
@@ -300,7 +301,6 @@ export function buildFormTemplatePaths(params) {
|
|
|
300
301
|
return buildFormSlugAndFilename({
|
|
301
302
|
domainName: params.domain.name,
|
|
302
303
|
domainLocale: params.domain.locale,
|
|
303
|
-
userLocale: params.user.locale,
|
|
304
304
|
idname: params.idname,
|
|
305
305
|
locale: params.locale
|
|
306
306
|
});
|
|
@@ -342,22 +342,36 @@ export async function resolveRecipients(form, recipientsRaw) {
|
|
|
342
342
|
if (!scopeFormKey) {
|
|
343
343
|
throw new ApiError({ code: 500, message: 'Form is missing a form_key' });
|
|
344
344
|
}
|
|
345
|
-
const resolveRecipient = async (idname) => {
|
|
346
|
-
const scoped = await api_recipient.findOne({
|
|
347
|
-
where: { domain_id: form.domain_id, form_key: scopeFormKey, idname }
|
|
348
|
-
});
|
|
349
|
-
if (scoped) {
|
|
350
|
-
return scoped;
|
|
351
|
-
}
|
|
352
|
-
return api_recipient.findOne({ where: { domain_id: form.domain_id, form_key: '', idname } });
|
|
353
|
-
};
|
|
354
345
|
const recipients = parseIdnameList(recipientsRaw, 'recipients');
|
|
355
346
|
if (recipients.length > 25) {
|
|
356
347
|
throw new ApiError({ code: 400, message: 'Too many recipients requested' });
|
|
357
348
|
}
|
|
349
|
+
if (recipients.length === 0) {
|
|
350
|
+
return [];
|
|
351
|
+
}
|
|
352
|
+
const scopedMatches = await api_recipient.findAll({
|
|
353
|
+
where: {
|
|
354
|
+
domain_id: form.domain_id,
|
|
355
|
+
form_key: scopeFormKey,
|
|
356
|
+
idname: { [Op.in]: recipients }
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
const scopedByIdname = new Map(scopedMatches.map((entry) => [entry.idname, entry]));
|
|
360
|
+
const unresolved = recipients.filter((idname) => !scopedByIdname.has(idname));
|
|
361
|
+
let fallbackByIdname = new Map();
|
|
362
|
+
if (unresolved.length > 0) {
|
|
363
|
+
const fallbackMatches = await api_recipient.findAll({
|
|
364
|
+
where: {
|
|
365
|
+
domain_id: form.domain_id,
|
|
366
|
+
form_key: '',
|
|
367
|
+
idname: { [Op.in]: unresolved }
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
fallbackByIdname = new Map(fallbackMatches.map((entry) => [entry.idname, entry]));
|
|
371
|
+
}
|
|
358
372
|
const resolvedRecipients = [];
|
|
359
373
|
for (const idname of recipients) {
|
|
360
|
-
const record =
|
|
374
|
+
const record = scopedByIdname.get(idname) ?? fallbackByIdname.get(idname) ?? null;
|
|
361
375
|
if (!record) {
|
|
362
376
|
throw new ApiError({ code: 404, message: `Unknown recipient identifier "${idname}"` });
|
|
363
377
|
}
|
package/dist/esm/util/paths.d.ts
CHANGED
package/dist/esm/util/paths.js
CHANGED
|
@@ -12,6 +12,9 @@ export function normalizeSubdir(value) {
|
|
|
12
12
|
}
|
|
13
13
|
const segments = cleaned.split('/').filter(Boolean);
|
|
14
14
|
for (const segment of segments) {
|
|
15
|
+
if (segment === '..' || segment === '.') {
|
|
16
|
+
throw new ApiError({ code: 400, message: `Invalid path segment "${segment}"` });
|
|
17
|
+
}
|
|
15
18
|
if (!SEGMENT_PATTERN.test(segment)) {
|
|
16
19
|
throw new ApiError({ code: 400, message: `Invalid path segment "${segment}"` });
|
|
17
20
|
}
|
|
@@ -31,7 +34,7 @@ export function assertSafeRelativePath(filename, label) {
|
|
|
31
34
|
export function buildFormSlugAndFilename(params) {
|
|
32
35
|
const domainSlug = normalizeSlug(params.domainName);
|
|
33
36
|
const formSlug = normalizeSlug(params.idname);
|
|
34
|
-
const localeSlug = normalizeSlug(params.locale || params.domainLocale ||
|
|
37
|
+
const localeSlug = normalizeSlug(params.locale || params.domainLocale || '');
|
|
35
38
|
const slug = `${domainSlug}${localeSlug ? '-' + localeSlug : ''}-${formSlug}`;
|
|
36
39
|
const filenameParts = [domainSlug, 'form-template'];
|
|
37
40
|
if (localeSlug) {
|
|
@@ -1,15 +1,6 @@
|
|
|
1
|
-
import { ApiRequest } from '@technomoron/api-server-base';
|
|
2
|
-
export
|
|
3
|
-
|
|
4
|
-
retryAfterSec: number;
|
|
5
|
-
};
|
|
6
|
-
export declare class FixedWindowRateLimiter {
|
|
7
|
-
private readonly maxKeys;
|
|
8
|
-
private readonly buckets;
|
|
9
|
-
constructor(maxKeys?: number);
|
|
10
|
-
check(key: string, max: number, windowMs: number): RateLimitDecision;
|
|
11
|
-
private prune;
|
|
12
|
-
}
|
|
1
|
+
import { ApiRequest, FixedWindowRateLimiter } from '@technomoron/api-server-base';
|
|
2
|
+
export { FixedWindowRateLimiter };
|
|
3
|
+
export type { RateLimitDecision } from '@technomoron/api-server-base';
|
|
13
4
|
export declare function enforceFormRateLimit(limiter: FixedWindowRateLimiter, env: {
|
|
14
5
|
FORM_RATE_LIMIT_WINDOW_SEC: number;
|
|
15
6
|
FORM_RATE_LIMIT_MAX: number;
|
|
@@ -1,42 +1,5 @@
|
|
|
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() ?? '';
|
|
42
5
|
if (!clientIp) {
|
|
@@ -47,7 +10,8 @@ export function enforceFormRateLimit(limiter, env, apireq) {
|
|
|
47
10
|
const windowMs = Math.max(0, env.FORM_RATE_LIMIT_WINDOW_SEC) * 1000;
|
|
48
11
|
const decision = limiter.check(`form-message:${clientIp}`, env.FORM_RATE_LIMIT_MAX, windowMs);
|
|
49
12
|
if (!decision.allowed) {
|
|
50
|
-
apireq.res.
|
|
13
|
+
const fastifyReply = apireq.res.reply;
|
|
14
|
+
fastifyReply?.header('retry-after', String(decision.retryAfterSec));
|
|
51
15
|
throw new ApiError({ code: 429, message: 'Too many form submissions; try again later' });
|
|
52
16
|
}
|
|
53
17
|
}
|
|
@@ -2,7 +2,8 @@ import type { UploadedFile } from '../types.js';
|
|
|
2
2
|
export declare function buildAttachments(rawFiles: UploadedFile[]): {
|
|
3
3
|
attachments: Array<{
|
|
4
4
|
filename: string;
|
|
5
|
-
path
|
|
5
|
+
path?: string;
|
|
6
|
+
content?: Buffer;
|
|
6
7
|
}>;
|
|
7
8
|
attachmentMap: Record<string, string>;
|
|
8
9
|
};
|
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
|
}
|
package/dist/esm/util/utils.d.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { api_domain } from '../models/domain.js';
|
|
2
2
|
import { api_user } from '../models/user.js';
|
|
3
3
|
import type { RequestMeta } from '../types.js';
|
|
4
|
-
import type { Response } from 'express';
|
|
5
4
|
/**
|
|
6
5
|
* Normalize a string into a safe identifier for slugs, filenames, etc.
|
|
7
6
|
*
|
|
@@ -24,4 +23,3 @@ export declare function buildRequestMeta(rawReq: unknown): RequestMeta;
|
|
|
24
23
|
export declare function decodeComponent(value: string | string[] | undefined): string;
|
|
25
24
|
export declare function getBodyValue(body: Record<string, unknown>, ...keys: string[]): string;
|
|
26
25
|
export declare function normalizeBoolean(value: unknown): boolean;
|
|
27
|
-
export declare function sendFileAsync(res: Pick<Response, 'sendFile'>, file: string, options?: Parameters<Response['sendFile']>[1]): Promise<void>;
|
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": [
|
package/docs/tutorial.md
CHANGED
|
@@ -264,7 +264,7 @@ Create `${CONFIG_ROOT}/init-data.json` so the service can bootstrap the MyOrg us
|
|
|
264
264
|
<tr>
|
|
265
265
|
<td style="padding:16px 24px;background:#f9fafb;color:#4b5563;font-size:12px;">
|
|
266
266
|
<strong>Sender IP:</strong> {{ _meta_.client_ip | default('unknown') }} ·
|
|
267
|
-
<strong>Received at:</strong> {{ _meta_.received_at | default(
|
|
267
|
+
<strong>Received at:</strong> {{ _meta_.received_at | default('unknown') }}
|
|
268
268
|
</td>
|
|
269
269
|
</tr>
|
|
270
270
|
{% endif %}
|
package/examples/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Mail Magic Examples
|
|
2
2
|
|
|
3
|
-
The canonical example setup for Mail Magic. Shipped inside the `@technomoron/mail-magic` package so it is
|
|
4
|
-
|
|
3
|
+
The canonical example setup for Mail Magic. Shipped inside the `@technomoron/mail-magic` package so it is accessible
|
|
4
|
+
without a private repo clone:
|
|
5
5
|
|
|
6
6
|
```bash
|
|
7
7
|
ls node_modules/@technomoron/mail-magic/examples/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@technomoron/mail-magic",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.43",
|
|
4
4
|
"main": "dist/cjs/index.js",
|
|
5
5
|
"module": "dist/esm/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"url": "https://github.com/technomoron/mail-magic/issues"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@technomoron/api-server-base": "2.0.0-beta.
|
|
37
|
+
"@technomoron/api-server-base": "2.0.0-beta.24",
|
|
38
38
|
"@technomoron/env-loader": "^1.0.8",
|
|
39
39
|
"@technomoron/unyuck": "^1.0.4",
|
|
40
40
|
"bcryptjs": "^3.0.2",
|