@technomoron/mail-magic 1.0.41 → 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 +22 -0
- package/dist/esm/api/assets.d.ts +4 -2
- package/dist/esm/api/assets.js +48 -18
- package/dist/esm/api/auth.js +1 -1
- package/dist/esm/api/forms.js +35 -3
- package/dist/esm/api/mailer.js +34 -5
- package/dist/esm/index.js +5 -4
- package/dist/esm/store/store.d.ts +4 -3
- package/dist/esm/store/store.js +19 -15
- package/dist/esm/types.d.ts +6 -2
- package/dist/esm/util/forms.js +25 -10
- 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/examples/README.md +2 -2
- package/package.json +2 -2
package/CHANGES
CHANGED
|
@@ -1,6 +1,28 @@
|
|
|
1
1
|
CHANGES
|
|
2
2
|
=======
|
|
3
3
|
|
|
4
|
+
Unreleased (2026-02-27)
|
|
5
|
+
|
|
6
|
+
- chore(release): prepare release metadata for server package.
|
|
7
|
+
- perf(forms): batch recipient resolution for `_mm_recipients` into scoped + fallback `IN` queries while preserving precedence and requested order.
|
|
8
|
+
- test(forms): add coverage for multi-recipient resolution order with scoped-over-domain recipient precedence.
|
|
9
|
+
- (Changes generated/assisted by Codex (profile: openai-gpt-5-codex/medium).)
|
|
10
|
+
|
|
11
|
+
Version 1.0.42 (2026-02-27)
|
|
12
|
+
|
|
13
|
+
- chore(deps): upgrade `@technomoron/api-server-base` from `2.0.0-beta.20` to `2.0.0-beta.24` (Express → Fastify migration).
|
|
14
|
+
- 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.
|
|
15
|
+
- feat(ratelimit): replace local `FixedWindowRateLimiter` implementation with the one exported by `@technomoron/api-server-base`; re-export for consumers.
|
|
16
|
+
- 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.
|
|
17
|
+
- 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.
|
|
18
|
+
- 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.
|
|
19
|
+
- fix(ratelimit): restore `Retry-After` response header on 429 rate-limit rejections by setting it on the underlying Fastify reply before throwing `ApiError`.
|
|
20
|
+
- 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.
|
|
21
|
+
- fix(swagger): replace Express-typed handler signature with `ExtendedReq`/`ApiRequest['res']` from `@technomoron/api-server-base`.
|
|
22
|
+
- fix(routes): change wildcard route from `/:domain/*path` to `/:domain/*` (find-my-way/Fastify does not support named wildcards).
|
|
23
|
+
- docs(swagger): bump packaged OpenAPI spec version to 1.0.42.
|
|
24
|
+
- (Changes generated/assisted by Claude Code (profile: anthropic-claude-sonnet-4-6/high).)
|
|
25
|
+
|
|
4
26
|
Version 1.0.41 (2026-02-22)
|
|
5
27
|
|
|
6
28
|
- chore(release): add package-level `release:check` script and wire `release` to shared publish script.
|
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');
|
|
@@ -65,7 +89,7 @@ export class AssetAPI extends ApiModule {
|
|
|
65
89
|
if (!rawFiles.length) {
|
|
66
90
|
throw new ApiError({ code: 400, message: 'No files uploaded' });
|
|
67
91
|
}
|
|
68
|
-
const body = apireq.req.body ?? {};
|
|
92
|
+
const body = (apireq.req.body ?? {});
|
|
69
93
|
const subdir = normalizeSubdir(getBodyValue(body, 'path', 'dir'));
|
|
70
94
|
const templateType = getBodyValue(body, 'templateType', 'template_type', 'type');
|
|
71
95
|
let targetRoot;
|
|
@@ -101,15 +125,15 @@ export function createAssetHandler(server) {
|
|
|
101
125
|
next();
|
|
102
126
|
return;
|
|
103
127
|
}
|
|
104
|
-
res.status(405).
|
|
128
|
+
res.status(405).send(null);
|
|
105
129
|
return;
|
|
106
130
|
}
|
|
107
|
-
const domain = decodeComponent(req?.params?.domain);
|
|
131
|
+
const domain = decodeComponent(req?.params?.['domain']);
|
|
108
132
|
if (!domain || !DOMAIN_PATTERN.test(domain)) {
|
|
109
|
-
res.status(404).
|
|
133
|
+
res.status(404).send(null);
|
|
110
134
|
return;
|
|
111
135
|
}
|
|
112
|
-
const rawPathParam = req
|
|
136
|
+
const rawPathParam = req.params?.['path'] ?? req.params?.['*'];
|
|
113
137
|
const rawSegments = Array.isArray(rawPathParam)
|
|
114
138
|
? rawPathParam
|
|
115
139
|
: typeof rawPathParam === 'string'
|
|
@@ -117,12 +141,12 @@ export function createAssetHandler(server) {
|
|
|
117
141
|
: [];
|
|
118
142
|
const segments = rawSegments.map((segment) => decodeComponent(typeof segment === 'string' ? segment : ''));
|
|
119
143
|
if (!segments.length || segments.some((segment) => !SEGMENT_PATTERN.test(segment))) {
|
|
120
|
-
res.status(404).
|
|
144
|
+
res.status(404).send(null);
|
|
121
145
|
return;
|
|
122
146
|
}
|
|
123
147
|
const assetsRoot = path.join(server.storage.configpath, domain, 'assets');
|
|
124
148
|
if (!fs.existsSync(assetsRoot)) {
|
|
125
|
-
res.status(404).
|
|
149
|
+
res.status(404).send(null);
|
|
126
150
|
return;
|
|
127
151
|
}
|
|
128
152
|
const resolvedRoot = fs.realpathSync(assetsRoot);
|
|
@@ -131,12 +155,12 @@ export function createAssetHandler(server) {
|
|
|
131
155
|
try {
|
|
132
156
|
const stats = await fs.promises.stat(candidate);
|
|
133
157
|
if (!stats.isFile()) {
|
|
134
|
-
res.status(404).
|
|
158
|
+
res.status(404).send(null);
|
|
135
159
|
return;
|
|
136
160
|
}
|
|
137
161
|
}
|
|
138
162
|
catch {
|
|
139
|
-
res.status(404).
|
|
163
|
+
res.status(404).send(null);
|
|
140
164
|
return;
|
|
141
165
|
}
|
|
142
166
|
let realCandidate;
|
|
@@ -144,23 +168,29 @@ export function createAssetHandler(server) {
|
|
|
144
168
|
realCandidate = await fs.promises.realpath(candidate);
|
|
145
169
|
}
|
|
146
170
|
catch {
|
|
147
|
-
res.status(404).
|
|
171
|
+
res.status(404).send(null);
|
|
148
172
|
return;
|
|
149
173
|
}
|
|
150
174
|
if (!realCandidate.startsWith(normalizedRoot)) {
|
|
151
|
-
res.status(404).
|
|
175
|
+
res.status(404).send(null);
|
|
152
176
|
return;
|
|
153
177
|
}
|
|
154
|
-
|
|
178
|
+
const ext = path.extname(realCandidate);
|
|
179
|
+
// Access the underlying Fastify reply to set content-type and cache-control headers.
|
|
180
|
+
// ApiResponse does not expose arbitrary header-setting methods.
|
|
181
|
+
const fastifyReply = res.reply;
|
|
182
|
+
if (fastifyReply) {
|
|
183
|
+
fastifyReply.type(getMimeType(ext));
|
|
184
|
+
fastifyReply.header('cache-control', 'public, max-age=300');
|
|
185
|
+
}
|
|
155
186
|
try {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
await sendFileAsync(res, realCandidate, { maxAge: 300_000 });
|
|
187
|
+
const content = await fs.promises.readFile(realCandidate);
|
|
188
|
+
res.send(content);
|
|
159
189
|
}
|
|
160
190
|
catch (err) {
|
|
161
191
|
server.storage.print_debug(`Failed to serve asset ${domain}/${segments.join('/')}: ${err instanceof Error ? err.message : String(err)}`);
|
|
162
192
|
if (!res.headersSent) {
|
|
163
|
-
res.status(500).
|
|
193
|
+
res.status(500).send(null);
|
|
164
194
|
}
|
|
165
195
|
}
|
|
166
196
|
};
|
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();
|
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;
|
|
@@ -207,13 +207,45 @@ export class FormAPI extends ApiModule {
|
|
|
207
207
|
method: 'post',
|
|
208
208
|
path: '/v1/form/recipient',
|
|
209
209
|
handler: (req) => this.postFormRecipient(req),
|
|
210
|
-
auth: { type: 'yes', req: 'any' }
|
|
210
|
+
auth: { type: 'yes', req: 'any' },
|
|
211
|
+
schema: {
|
|
212
|
+
body: {
|
|
213
|
+
type: 'object',
|
|
214
|
+
required: ['email', 'idname'],
|
|
215
|
+
properties: {
|
|
216
|
+
email: { type: 'string' },
|
|
217
|
+
idname: { type: 'string' },
|
|
218
|
+
name: { type: 'string' },
|
|
219
|
+
form_key: { type: 'string' },
|
|
220
|
+
formid: { type: 'string' },
|
|
221
|
+
locale: { type: 'string' },
|
|
222
|
+
domain: { type: 'string' }
|
|
223
|
+
},
|
|
224
|
+
additionalProperties: true
|
|
225
|
+
}
|
|
226
|
+
}
|
|
211
227
|
},
|
|
212
228
|
{
|
|
213
229
|
method: 'post',
|
|
214
230
|
path: '/v1/form/template',
|
|
215
231
|
handler: (req) => this.postFormTemplate(req),
|
|
216
|
-
auth: { type: 'yes', req: 'any' }
|
|
232
|
+
auth: { type: 'yes', req: 'any' },
|
|
233
|
+
schema: {
|
|
234
|
+
body: {
|
|
235
|
+
type: 'object',
|
|
236
|
+
required: ['idname', 'template', 'sender', 'recipient'],
|
|
237
|
+
properties: {
|
|
238
|
+
idname: { type: 'string' },
|
|
239
|
+
template: { type: 'string' },
|
|
240
|
+
sender: { type: 'string' },
|
|
241
|
+
recipient: { type: 'string' },
|
|
242
|
+
subject: { type: 'string' },
|
|
243
|
+
locale: { type: 'string' },
|
|
244
|
+
domain: { type: 'string' }
|
|
245
|
+
},
|
|
246
|
+
additionalProperties: true
|
|
247
|
+
}
|
|
248
|
+
}
|
|
217
249
|
},
|
|
218
250
|
{
|
|
219
251
|
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' });
|
|
@@ -116,7 +128,7 @@ export class MailerAPI extends ApiModule {
|
|
|
116
128
|
})),
|
|
117
129
|
...rawFiles.map((file) => ({
|
|
118
130
|
filename: file.originalname,
|
|
119
|
-
path: file.
|
|
131
|
+
...(file.buffer ? { content: file.buffer } : { path: file.filepath })
|
|
120
132
|
}))
|
|
121
133
|
];
|
|
122
134
|
const attachmentMap = {};
|
|
@@ -162,7 +174,7 @@ export class MailerAPI extends ApiModule {
|
|
|
162
174
|
const sendargs = {
|
|
163
175
|
from: sender,
|
|
164
176
|
to: recipient,
|
|
165
|
-
subject: template.subject ||
|
|
177
|
+
subject: template.subject || body.subject || '',
|
|
166
178
|
html,
|
|
167
179
|
text,
|
|
168
180
|
attachments,
|
|
@@ -186,13 +198,30 @@ export class MailerAPI extends ApiModule {
|
|
|
186
198
|
method: 'post',
|
|
187
199
|
path: '/v1/tx/message',
|
|
188
200
|
handler: this.post_send.bind(this),
|
|
201
|
+
// No schema: this route accepts multipart/form-data; Fastify validates request.body
|
|
202
|
+
// before the multipart parsing hook populates it, so schema required-fields would
|
|
203
|
+
// reject valid multipart requests. Validation is handled in the route handler.
|
|
189
204
|
auth: { type: 'yes', req: 'any' }
|
|
190
205
|
},
|
|
191
206
|
{
|
|
192
207
|
method: 'post',
|
|
193
208
|
path: '/v1/tx/template',
|
|
194
209
|
handler: this.post_template.bind(this),
|
|
195
|
-
auth: { type: 'yes', req: 'any' }
|
|
210
|
+
auth: { type: 'yes', req: 'any' },
|
|
211
|
+
schema: {
|
|
212
|
+
body: {
|
|
213
|
+
type: 'object',
|
|
214
|
+
required: ['name', 'template'],
|
|
215
|
+
properties: {
|
|
216
|
+
name: { type: 'string' },
|
|
217
|
+
template: { type: 'string' },
|
|
218
|
+
sender: { type: 'string' },
|
|
219
|
+
subject: { type: 'string' },
|
|
220
|
+
locale: { type: 'string' }
|
|
221
|
+
},
|
|
222
|
+
additionalProperties: true
|
|
223
|
+
}
|
|
224
|
+
}
|
|
196
225
|
}
|
|
197
226
|
];
|
|
198
227
|
}
|
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);
|
|
@@ -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
|
@@ -102,24 +102,28 @@ export class mailStore {
|
|
|
102
102
|
}
|
|
103
103
|
await fs.promises.mkdir(targetDir, { recursive: true });
|
|
104
104
|
await Promise.all(files.map(async (file) => {
|
|
105
|
-
|
|
105
|
+
const name = (file.originalname ?? file.filepath) ? path.basename(file.filepath ?? file.originalname ?? '') : '';
|
|
106
|
+
if (!name) {
|
|
106
107
|
return;
|
|
107
108
|
}
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
try {
|
|
114
|
-
await fs.promises.rename(file.path, destination);
|
|
115
|
-
}
|
|
116
|
-
catch {
|
|
117
|
-
await fs.promises.copyFile(file.path, destination);
|
|
118
|
-
await fs.promises.unlink(file.path);
|
|
109
|
+
const destination = path.join(targetDir, name);
|
|
110
|
+
if (file.buffer) {
|
|
111
|
+
await fs.promises.writeFile(destination, file.buffer);
|
|
112
|
+
file.filepath = destination;
|
|
113
|
+
file.buffer = undefined;
|
|
119
114
|
}
|
|
120
|
-
file.
|
|
121
|
-
|
|
122
|
-
|
|
115
|
+
else if (file.filepath) {
|
|
116
|
+
if (destination === file.filepath) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
await fs.promises.rename(file.filepath, destination);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
await fs.promises.copyFile(file.filepath, destination);
|
|
124
|
+
await fs.promises.unlink(file.filepath);
|
|
125
|
+
}
|
|
126
|
+
file.filepath = destination;
|
|
123
127
|
}
|
|
124
128
|
}));
|
|
125
129
|
}
|
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
|
}
|
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';
|
|
@@ -342,22 +343,36 @@ export async function resolveRecipients(form, recipientsRaw) {
|
|
|
342
343
|
if (!scopeFormKey) {
|
|
343
344
|
throw new ApiError({ code: 500, message: 'Form is missing a form_key' });
|
|
344
345
|
}
|
|
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
346
|
const recipients = parseIdnameList(recipientsRaw, 'recipients');
|
|
355
347
|
if (recipients.length > 25) {
|
|
356
348
|
throw new ApiError({ code: 400, message: 'Too many recipients requested' });
|
|
357
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
|
+
}
|
|
358
373
|
const resolvedRecipients = [];
|
|
359
374
|
for (const idname of recipients) {
|
|
360
|
-
const record =
|
|
375
|
+
const record = scopedByIdname.get(idname) ?? fallbackByIdname.get(idname) ?? null;
|
|
361
376
|
if (!record) {
|
|
362
377
|
throw new ApiError({ code: 404, message: `Unknown recipient identifier "${idname}"` });
|
|
363
378
|
}
|
|
@@ -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/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.42",
|
|
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",
|