@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
package/CHANGES
CHANGED
|
@@ -1,13 +1,56 @@
|
|
|
1
1
|
CHANGES
|
|
2
2
|
=======
|
|
3
3
|
|
|
4
|
-
Unreleased (2026-02-
|
|
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
|
+
|
|
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.
|
|
7
29
|
- chore(scripts): replace `rm -rf` cleanup scripts with `rimraf`.
|
|
30
|
+
- chore(examples): move example data, scripts, and `.env-dist` from standalone `packages/examples/` workspace into `packages/server/examples/`; add `examples` to the npm `files` list so they are shipped with the package.
|
|
31
|
+
- chore(repo): rename admin package directory from `mail-magic-admin` to `admin` (npm package name unchanged).
|
|
8
32
|
- test(logging): suppress noisy SQL/startup stdout in automated tests by default (`DB_LOG=false` in test setup; startup logs debug-only).
|
|
9
33
|
- test(logging): quiet package test output by default (silent Vitest + silent sync step) and remove Node `DEP0170` warning in schema-drift test DB setup.
|
|
10
|
-
- (
|
|
34
|
+
- fix(forms): replace global `nunjucks.configure()` call in `postSendForm` with a scoped `nunjucks.Environment` instance, matching the pattern already used in `post_send`.
|
|
35
|
+
- fix(forms): add plain-text email part to form submissions via `html-to-text` (was HTML-only, harming deliverability).
|
|
36
|
+
- fix(forms): derive `recipients` template variable from already-resolved DB records instead of re-parsing the raw input with a second `parseIdnameList` call.
|
|
37
|
+
- fix(forms): remove silent `catch` in `resolveFormKeyForTemplate` so DB errors propagate instead of being silently treated as "no existing form".
|
|
38
|
+
- fix(forms): extract `buildFormSlugAndFilename` into `util/paths.ts` to eliminate duplicated slug/filename generation between the API upload path and the DB upsert path.
|
|
39
|
+
- fix(models): filter `allowed_fields` JSON getter to string elements only; previously returned non-string values (numbers, nulls) typed as `string[]`.
|
|
40
|
+
- fix(smtp): make `requireTLS` configurable via `SMTP_REQUIRE_TLS` env var (default `true`, preserving existing behaviour); allows disabling STARTTLS for servers such as MailHog.
|
|
41
|
+
- fix(smtp): change `SMTP_TLS_REJECT` default from `false` to `true` so TLS certificates are validated in production by default.
|
|
42
|
+
- fix(smtp): correct `SMTP_TLS_REJECT` description (previously described the inverse of what the option does).
|
|
43
|
+
- fix(config): change `DB_SYNC_ALTER` default from `true` to `false` to prevent automatic DDL on production startups.
|
|
44
|
+
- fix(config): add `mysql` and `postgres` to the `DB_TYPE` allowed-values list to match what the connection code already supports.
|
|
45
|
+
- fix(ratelimit): skip rate limiting when the client IP cannot be resolved, rather than bucketing all unresolvable IPs under a shared `unknown` key.
|
|
46
|
+
- fix(captcha): throw an explicit error for unknown CAPTCHA providers instead of silently falling back to Turnstile.
|
|
47
|
+
- fix(auth): make `domain` optional in `assert_domain_and_user`; when absent from the request body, fall back to the authenticated user's default domain (`api_user.domain`).
|
|
48
|
+
- fix(build): remove `noImplicitAny: false` override from `tsconfig/tsconfig.esm.json`; the root tsconfig already enables `strict`, which includes `noImplicitAny`.
|
|
49
|
+
- fix(build): add `declaration: true` to `tsconfig/tsconfig.esm.json` to emit `.d.ts` files; generate `dist/cjs/index.d.ts` re-exporting ESM types; add `types` condition to package exports; extract `STARTUP_ERROR_MESSAGE` from compiled ESM output instead of hardcoding it in the CJS shim script.
|
|
50
|
+
- fix(scripts): remove `--ext` flags from all `lint`/`lintfix` npm scripts — ESLint v9 flat config silently ignores these flags; file coverage is already provided by the glob patterns in `eslint.config.mjs`.
|
|
51
|
+
- docs(swagger): remove `domain` from the `required` array in all authenticated request schemas (`TxTemplateUpsertRequest`, `TxSendRequest`, `TxSendMultipartRequest`, `FormRecipientUpsertRequest`, `FormTemplateUpsertRequest`); mark property descriptions as optional; bump spec version to 1.0.41.
|
|
52
|
+
- docs(readme): add `SMTP_REQUIRE_TLS` to quick-start `.env` example and configuration reference.
|
|
53
|
+
- (Changes generated/assisted by Claude Code (profile: anthropic-claude-sonnet-4-6/high).)
|
|
11
54
|
|
|
12
55
|
Version 1.0.40 (2026-02-22)
|
|
13
56
|
|
package/README.md
CHANGED
|
@@ -60,6 +60,7 @@ API_URL=http://127.0.0.1:3776
|
|
|
60
60
|
SMTP_HOST=127.0.0.1
|
|
61
61
|
SMTP_PORT=1025
|
|
62
62
|
SMTP_SECURE=false
|
|
63
|
+
SMTP_REQUIRE_TLS=false
|
|
63
64
|
SMTP_TLS_REJECT=false
|
|
64
65
|
```
|
|
65
66
|
|
|
@@ -144,6 +145,10 @@ Commonly used variables:
|
|
|
144
145
|
- `FORM_RATE_LIMIT_WINDOW_SEC`, `FORM_RATE_LIMIT_MAX`
|
|
145
146
|
- `FORM_MAX_ATTACHMENTS`, `FORM_KEEP_UPLOADS`
|
|
146
147
|
- `FORM_CAPTCHA_PROVIDER`, `FORM_CAPTCHA_SECRET`, `FORM_CAPTCHA_REQUIRED`
|
|
148
|
+
- SMTP:
|
|
149
|
+
- `SMTP_HOST`, `SMTP_PORT`, `SMTP_SECURE`
|
|
150
|
+
- `SMTP_REQUIRE_TLS` (default `true`; set to `false` for local dev servers like MailHog that don't support STARTTLS)
|
|
151
|
+
- `SMTP_TLS_REJECT` (default `true`; set to `false` to accept self-signed certificates)
|
|
147
152
|
- Swagger/OpenAPI:
|
|
148
153
|
- `SWAGGER_ENABLED`, `SWAGGER_PATH`
|
|
149
154
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from '../esm/index.js';
|
package/dist/cjs/index.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
const load = () => import('../esm/index.js');
|
|
4
4
|
|
|
5
5
|
module.exports = {
|
|
6
|
-
STARTUP_ERROR_MESSAGE:
|
|
6
|
+
STARTUP_ERROR_MESSAGE: "Failed to start mail-magic:",
|
|
7
7
|
createMailMagicServer: async (...args) => (await load()).createMailMagicServer(...args),
|
|
8
8
|
startMailMagicServer: async (...args) => (await load()).startMailMagicServer(...args)
|
|
9
9
|
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { ApiModule, ApiRoute } from '@technomoron/api-server-base';
|
|
2
|
+
import { mailApiServer } from '../server.js';
|
|
3
|
+
import type { ApiRequest, ExtendedReq } from '@technomoron/api-server-base';
|
|
4
|
+
type ApiRes = ApiRequest['res'];
|
|
5
|
+
export declare class AssetAPI extends ApiModule<mailApiServer> {
|
|
6
|
+
private resolveTemplateDir;
|
|
7
|
+
private postAssets;
|
|
8
|
+
defineRoutes(): ApiRoute[];
|
|
9
|
+
}
|
|
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,12 +3,9 @@ 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 ?? {};
|
|
7
|
-
const
|
|
6
|
+
const body = (apireq.req.body ?? {});
|
|
7
|
+
const domainRaw = getBodyValue(body, 'domain');
|
|
8
8
|
const locale = getBodyValue(body, 'locale');
|
|
9
|
-
if (!domain) {
|
|
10
|
-
throw new ApiError({ code: 401, message: 'Missing domain' });
|
|
11
|
-
}
|
|
12
9
|
const rawUid = apireq.getRealUid();
|
|
13
10
|
const uid = rawUid === null ? null : Number(rawUid);
|
|
14
11
|
if (!uid || Number.isNaN(uid)) {
|
|
@@ -18,12 +15,24 @@ export async function assert_domain_and_user(apireq) {
|
|
|
18
15
|
if (!user) {
|
|
19
16
|
throw new ApiError({ code: 401, message: 'Invalid/Unknown API Key/Token' });
|
|
20
17
|
}
|
|
21
|
-
|
|
22
|
-
if (
|
|
23
|
-
|
|
18
|
+
let dbdomain;
|
|
19
|
+
if (domainRaw) {
|
|
20
|
+
dbdomain = await api_domain.findOne({ where: { name: domainRaw } });
|
|
21
|
+
if (!dbdomain) {
|
|
22
|
+
throw new ApiError({ code: 401, message: `Unable to look up the domain ${domainRaw}` });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
else if (user.domain != null) {
|
|
26
|
+
dbdomain = await api_domain.findByPk(user.domain);
|
|
27
|
+
if (!dbdomain) {
|
|
28
|
+
throw new ApiError({ code: 401, message: 'Unable to resolve default domain for this user' });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
throw new ApiError({ code: 401, message: 'Missing domain' });
|
|
24
33
|
}
|
|
25
34
|
if (dbdomain.user_id !== user.user_id) {
|
|
26
|
-
throw new ApiError({ code: 403, message: `Domain ${
|
|
35
|
+
throw new ApiError({ code: 403, message: `Domain ${dbdomain.name} is not owned by this user` });
|
|
27
36
|
}
|
|
28
37
|
apireq.domain = dbdomain;
|
|
29
38
|
apireq.user = user;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { ApiRoute, ApiModule } from '@technomoron/api-server-base';
|
|
2
|
+
import { mailApiServer } from '../server.js';
|
|
3
|
+
export declare class FormAPI extends ApiModule<mailApiServer> {
|
|
4
|
+
private readonly rateLimiter;
|
|
5
|
+
private postFormRecipient;
|
|
6
|
+
private postFormTemplate;
|
|
7
|
+
private postSendForm;
|
|
8
|
+
defineRoutes(): ApiRoute[];
|
|
9
|
+
}
|
package/dist/esm/api/forms.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { ApiModule, ApiError } from '@technomoron/api-server-base';
|
|
2
|
+
import { convert } from 'html-to-text';
|
|
2
3
|
import { nanoid } from 'nanoid';
|
|
3
4
|
import nunjucks from 'nunjucks';
|
|
4
5
|
import { UniqueConstraintError } from 'sequelize';
|
|
5
6
|
import { api_domain } from '../models/domain.js';
|
|
6
7
|
import { api_form } from '../models/form.js';
|
|
7
8
|
import { api_recipient } from '../models/recipient.js';
|
|
8
|
-
import { buildFormTemplateRecord, buildFormTemplatePaths, buildRecipientTo, buildReplyToValue, buildSubmissionContext, enforceAttachmentPolicy, enforceCaptchaPolicy, filterSubmissionFields, getPrimaryRecipientInfo, normalizeRecipientEmail, normalizeRecipientIdname, normalizeRecipientName,
|
|
9
|
+
import { buildFormTemplateRecord, buildFormTemplatePaths, buildRecipientTo, buildReplyToValue, buildSubmissionContext, enforceAttachmentPolicy, enforceCaptchaPolicy, filterSubmissionFields, getPrimaryRecipientInfo, normalizeRecipientEmail, normalizeRecipientIdname, normalizeRecipientName, parseFormTemplatePayload, parseRecipientPayload, parsePublicSubmissionOrThrow, resolveFormKeyForTemplate, resolveFormKeyForRecipient, resolveRecipients, validateFormTemplatePayload } from '../util/forms.js';
|
|
9
10
|
import { FixedWindowRateLimiter, enforceFormRateLimit } from '../util/ratelimit.js';
|
|
10
11
|
import { buildAttachments, cleanupUploadedFiles } from '../util/uploads.js';
|
|
11
12
|
import { getBodyValue } from '../util/utils.js';
|
|
@@ -61,7 +62,7 @@ export class FormAPI extends ApiModule {
|
|
|
61
62
|
}
|
|
62
63
|
async postFormTemplate(apireq) {
|
|
63
64
|
await assert_domain_and_user(apireq);
|
|
64
|
-
const payload = parseFormTemplatePayload(apireq.req.body ?? {});
|
|
65
|
+
const payload = parseFormTemplatePayload((apireq.req.body ?? {}));
|
|
65
66
|
validateFormTemplatePayload(payload);
|
|
66
67
|
const user = apireq.user;
|
|
67
68
|
const domain = apireq.domain;
|
|
@@ -139,7 +140,7 @@ export class FormAPI extends ApiModule {
|
|
|
139
140
|
const clientIp = apireq.getClientIp() ?? '';
|
|
140
141
|
await enforceCaptchaPolicy({ vars: env, form, captchaToken, clientIp });
|
|
141
142
|
const resolvedRecipients = await resolveRecipients(form, recipientsRaw);
|
|
142
|
-
const recipients =
|
|
143
|
+
const recipients = resolvedRecipients.map((r) => r.idname ?? '').filter(Boolean);
|
|
143
144
|
const { rcptEmail, rcptName, rcptIdname, rcptIdnames } = getPrimaryRecipientInfo(form, resolvedRecipients);
|
|
144
145
|
const domainRecord = await api_domain.findOne({ where: { domain_id: form.domain_id } });
|
|
145
146
|
await this.server.storage.relocateUploads(domainRecord?.name ?? null, rawFiles);
|
|
@@ -171,13 +172,15 @@ export class FormAPI extends ApiModule {
|
|
|
171
172
|
files: rawFiles,
|
|
172
173
|
meta
|
|
173
174
|
});
|
|
174
|
-
nunjucks.
|
|
175
|
-
const html =
|
|
175
|
+
const njkEnv = new nunjucks.Environment(null, { autoescape: this.server.storage.vars.AUTOESCAPE_HTML });
|
|
176
|
+
const html = njkEnv.renderString(form.template, context);
|
|
177
|
+
const text = convert(html);
|
|
176
178
|
const mailOptions = {
|
|
177
179
|
from: form.sender,
|
|
178
180
|
to,
|
|
179
181
|
subject: form.subject,
|
|
180
182
|
html,
|
|
183
|
+
text,
|
|
181
184
|
attachments: allAttachments,
|
|
182
185
|
...(replyToValue ? { replyTo: replyToValue } : {})
|
|
183
186
|
};
|
|
@@ -204,13 +207,45 @@ export class FormAPI extends ApiModule {
|
|
|
204
207
|
method: 'post',
|
|
205
208
|
path: '/v1/form/recipient',
|
|
206
209
|
handler: (req) => this.postFormRecipient(req),
|
|
207
|
-
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
|
+
}
|
|
208
227
|
},
|
|
209
228
|
{
|
|
210
229
|
method: 'post',
|
|
211
230
|
path: '/v1/form/template',
|
|
212
231
|
handler: (req) => this.postFormTemplate(req),
|
|
213
|
-
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
|
+
}
|
|
214
249
|
},
|
|
215
250
|
{
|
|
216
251
|
method: 'post',
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { ApiModule, ApiRoute } from '@technomoron/api-server-base';
|
|
2
|
+
import { mailApiServer } from '../server.js';
|
|
3
|
+
export declare class MailerAPI extends ApiModule<mailApiServer> {
|
|
4
|
+
validateEmails(list: string): {
|
|
5
|
+
valid: string[];
|
|
6
|
+
invalid: string[];
|
|
7
|
+
};
|
|
8
|
+
private post_template;
|
|
9
|
+
private post_send;
|
|
10
|
+
defineRoutes(): ApiRoute[];
|
|
11
|
+
}
|
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,10 +68,17 @@ 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
|
-
if (!name || !rcpt
|
|
69
|
-
throw new ApiError({ code: 400, message: 'name/rcpt
|
|
80
|
+
if (!name || !rcpt) {
|
|
81
|
+
throw new ApiError({ code: 400, message: 'name/rcpt required' });
|
|
70
82
|
}
|
|
71
83
|
let parsedVars = vars ?? {};
|
|
72
84
|
if (typeof vars === 'string') {
|
|
@@ -98,7 +110,7 @@ export class MailerAPI extends ApiModule {
|
|
|
98
110
|
if (!template) {
|
|
99
111
|
throw new ApiError({
|
|
100
112
|
code: 404,
|
|
101
|
-
message: `Template "${name}" not found for any locale in domain "${domain}"`
|
|
113
|
+
message: `Template "${name}" not found for any locale in domain "${apireq.domain.name}"`
|
|
102
114
|
});
|
|
103
115
|
}
|
|
104
116
|
const sender = template.sender || apireq.domain.sender || apireq.user.email;
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { mailApiServer } from './server.js';
|
|
2
|
+
import { MailStoreVars, mailStore } from './store/store.js';
|
|
3
|
+
import type { ApiServerConf } from '@technomoron/api-server-base';
|
|
4
|
+
export type MailMagicServerOptions = Partial<ApiServerConf>;
|
|
5
|
+
export type MailMagicServerBootstrap = {
|
|
6
|
+
server: mailApiServer;
|
|
7
|
+
store: mailStore;
|
|
8
|
+
vars: MailStoreVars;
|
|
9
|
+
};
|
|
10
|
+
export declare const STARTUP_ERROR_MESSAGE = "Failed to start mail-magic:";
|
|
11
|
+
export declare function createMailMagicServer(overrides?: MailMagicServerOptions, envOverrides?: Partial<MailStoreVars>): Promise<MailMagicServerBootstrap>;
|
|
12
|
+
export declare function startMailMagicServer(overrides?: MailMagicServerOptions, envOverrides?: Partial<MailStoreVars>): Promise<MailMagicServerBootstrap>;
|
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);
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { Sequelize } from 'sequelize';
|
|
2
|
+
import { mailStore } from '../store/store.js';
|
|
3
|
+
export declare function usesSqlitePragmas(db: Pick<Sequelize, 'getDialect'>): boolean;
|
|
4
|
+
export declare function init_api_db(db: Sequelize, store: mailStore): Promise<void>;
|
|
5
|
+
export declare function connect_api_db(store: mailStore): Promise<Sequelize>;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Sequelize, Model } from 'sequelize';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
export declare const api_domain_schema: z.ZodObject<{
|
|
4
|
+
domain_id: z.ZodNumber;
|
|
5
|
+
user_id: z.ZodNumber;
|
|
6
|
+
name: z.ZodString;
|
|
7
|
+
sender: z.ZodDefault<z.ZodString>;
|
|
8
|
+
locale: z.ZodDefault<z.ZodString>;
|
|
9
|
+
is_default: z.ZodDefault<z.ZodBoolean>;
|
|
10
|
+
}, z.core.$strip>;
|
|
11
|
+
export type api_domain_input = z.input<typeof api_domain_schema>;
|
|
12
|
+
export type api_domain_type = z.output<typeof api_domain_schema>;
|
|
13
|
+
export type api_domain_creation_type = Omit<api_domain_input, 'domain_id'> & {
|
|
14
|
+
domain_id?: number;
|
|
15
|
+
};
|
|
16
|
+
export declare class api_domain extends Model<api_domain_type, api_domain_creation_type> {
|
|
17
|
+
domain_id: number;
|
|
18
|
+
user_id: number;
|
|
19
|
+
name: string;
|
|
20
|
+
sender: string;
|
|
21
|
+
locale: string;
|
|
22
|
+
is_default: boolean;
|
|
23
|
+
}
|
|
24
|
+
export declare function init_api_domain(api_db: Sequelize): Promise<typeof api_domain>;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Sequelize, Model } from 'sequelize';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { StoredFile } from '../types.js';
|
|
4
|
+
export declare const api_form_schema: z.ZodObject<{
|
|
5
|
+
form_id: z.ZodNumber;
|
|
6
|
+
form_key: z.ZodDefault<z.ZodString>;
|
|
7
|
+
user_id: z.ZodNumber;
|
|
8
|
+
domain_id: z.ZodNumber;
|
|
9
|
+
locale: z.ZodDefault<z.ZodString>;
|
|
10
|
+
idname: z.ZodString;
|
|
11
|
+
sender: z.ZodString;
|
|
12
|
+
recipient: z.ZodString;
|
|
13
|
+
subject: z.ZodString;
|
|
14
|
+
template: z.ZodDefault<z.ZodString>;
|
|
15
|
+
filename: z.ZodDefault<z.ZodString>;
|
|
16
|
+
slug: z.ZodDefault<z.ZodString>;
|
|
17
|
+
secret: z.ZodDefault<z.ZodString>;
|
|
18
|
+
replyto_email: z.ZodDefault<z.ZodString>;
|
|
19
|
+
replyto_from_fields: z.ZodDefault<z.ZodBoolean>;
|
|
20
|
+
allowed_fields: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
21
|
+
captcha_required: z.ZodDefault<z.ZodBoolean>;
|
|
22
|
+
files: z.ZodDefault<z.ZodArray<z.ZodType<StoredFile, unknown, z.core.$ZodTypeInternals<StoredFile, unknown>>>>;
|
|
23
|
+
}, z.core.$strip>;
|
|
24
|
+
export type api_form_input = z.input<typeof api_form_schema>;
|
|
25
|
+
export type api_form_type = z.output<typeof api_form_schema>;
|
|
26
|
+
export type api_form_creation_type = Omit<api_form_input, 'form_id'> & {
|
|
27
|
+
form_id?: number;
|
|
28
|
+
};
|
|
29
|
+
export declare class api_form extends Model<api_form_type, api_form_creation_type> {
|
|
30
|
+
form_id: number;
|
|
31
|
+
form_key: string;
|
|
32
|
+
user_id: number;
|
|
33
|
+
domain_id: number;
|
|
34
|
+
locale: string;
|
|
35
|
+
idname: string;
|
|
36
|
+
sender: string;
|
|
37
|
+
recipient: string;
|
|
38
|
+
subject: string;
|
|
39
|
+
template: string;
|
|
40
|
+
filename: string;
|
|
41
|
+
slug: string;
|
|
42
|
+
secret: string;
|
|
43
|
+
replyto_email: string;
|
|
44
|
+
replyto_from_fields: boolean;
|
|
45
|
+
allowed_fields: string[];
|
|
46
|
+
captcha_required: boolean;
|
|
47
|
+
files: StoredFile[];
|
|
48
|
+
}
|
|
49
|
+
export declare function init_api_form(api_db: Sequelize): Promise<typeof api_form>;
|
|
50
|
+
export declare function upsert_form(record: api_form_type): Promise<api_form>;
|