@technomoron/mail-magic 1.0.32 → 1.0.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGES +18 -0
  2. package/README.md +213 -122
  3. package/dist/api/assets.js +9 -56
  4. package/dist/api/auth.js +1 -12
  5. package/dist/api/form-replyto.js +1 -0
  6. package/dist/api/form-submission.js +1 -0
  7. package/dist/api/forms.js +114 -474
  8. package/dist/api/mailer.js +1 -1
  9. package/dist/bin/mail-magic.js +2 -2
  10. package/dist/index.js +30 -18
  11. package/dist/models/db.js +5 -5
  12. package/dist/models/domain.js +16 -8
  13. package/dist/models/form.js +111 -40
  14. package/dist/models/init.js +44 -74
  15. package/dist/models/recipient.js +12 -8
  16. package/dist/models/txmail.js +24 -28
  17. package/dist/models/user.js +14 -10
  18. package/dist/server.js +1 -1
  19. package/dist/store/store.js +53 -22
  20. package/dist/swagger.js +107 -0
  21. package/dist/util/captcha.js +24 -0
  22. package/dist/util/email.js +19 -0
  23. package/dist/util/form-replyto.js +44 -0
  24. package/dist/util/form-submission.js +95 -0
  25. package/dist/util/forms.js +431 -0
  26. package/dist/util/paths.js +41 -0
  27. package/dist/util/ratelimit.js +48 -0
  28. package/dist/util/uploads.js +48 -0
  29. package/dist/util/utils.js +151 -0
  30. package/dist/util.js +7 -127
  31. package/docs/config-example/example.test/assets/files/banner.png +1 -0
  32. package/docs/config-example/example.test/assets/images/logo.png +1 -0
  33. package/docs/config-example/example.test/form-template/base.njk +6 -0
  34. package/docs/config-example/example.test/form-template/contact.njk +9 -0
  35. package/docs/config-example/example.test/form-template/partials/fields.njk +3 -0
  36. package/docs/config-example/example.test/tx-template/base.njk +10 -0
  37. package/docs/config-example/example.test/tx-template/partials/header.njk +1 -0
  38. package/docs/config-example/example.test/tx-template/welcome.njk +10 -0
  39. package/docs/config-example/init-data.json +57 -0
  40. package/docs/form-security.md +194 -0
  41. package/docs/swagger/openapi.json +1321 -0
  42. package/{TUTORIAL.MD → docs/tutorial.md} +24 -15
  43. package/package.json +3 -3
@@ -1,16 +1,20 @@
1
1
  import { createHmac } from 'node:crypto';
2
2
  import { Model, DataTypes, Op } from 'sequelize';
3
3
  import { z } from 'zod';
4
- export const api_user_schema = z.object({
5
- user_id: z.number().int().nonnegative(),
6
- idname: z.string().min(1),
7
- token: z.string().min(1).optional(),
8
- token_hmac: z.string().min(1).optional(),
9
- name: z.string().min(1),
10
- email: z.string().email(),
11
- domain: z.number().int().nonnegative().nullable().optional(),
12
- locale: z.string().default('')
13
- });
4
+ export const api_user_schema = z
5
+ .object({
6
+ user_id: z.number().int().nonnegative().describe('Database primary key for the user record.'),
7
+ idname: z.string().min(1).describe('User identifier (slug-like).'),
8
+ token: z.string().min(1).optional().describe('Legacy API token (may be blank after migration).'),
9
+ token_hmac: z.string().min(1).optional().describe('API token digest (HMAC).'),
10
+ name: z.string().min(1).describe('Display name for the user.'),
11
+ email: z.string().email().describe('User email address.'),
12
+ domain: z.number().int().nonnegative().nullable().optional().describe('Default domain ID for the user.'),
13
+ locale: z.string().default('').describe('Default locale for the user.')
14
+ })
15
+ .describe('User account record and API credentials.');
16
+ // Sequelize typing pattern: merge the Zod-inferred attribute type onto the model instance type.
17
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
14
18
  export class api_user extends Model {
15
19
  }
16
20
  export function apiTokenToHmac(token, pepper) {
package/dist/server.js CHANGED
@@ -10,7 +10,7 @@ export class mailApiServer extends ApiServer {
10
10
  }
11
11
  async getApiKey(token) {
12
12
  this.storage.print_debug('Looking up api key');
13
- const pepper = this.storage.env.API_TOKEN_PEPPER;
13
+ const pepper = this.storage.vars.API_TOKEN_PEPPER;
14
14
  const token_hmac = apiTokenToHmac(token, pepper);
15
15
  const user = await api_user.findOne({ where: { token_hmac } });
16
16
  if (user) {
@@ -5,20 +5,20 @@ import { createTransport } from 'nodemailer';
5
5
  import { connect_api_db } from '../models/db.js';
6
6
  import { importData } from '../models/init.js';
7
7
  import { envOptions } from './envloader.js';
8
- function create_mail_transport(env) {
8
+ function create_mail_transport(vars) {
9
9
  const args = {
10
- host: env.SMTP_HOST,
11
- port: env.SMTP_PORT,
12
- secure: env.SMTP_SECURE,
10
+ host: vars.SMTP_HOST,
11
+ port: vars.SMTP_PORT,
12
+ secure: vars.SMTP_SECURE,
13
13
  tls: {
14
- rejectUnauthorized: env.SMTP_TLS_REJECT
14
+ rejectUnauthorized: vars.SMTP_TLS_REJECT
15
15
  },
16
16
  requireTLS: true,
17
- logger: env.DEBUG,
18
- debug: env.DEBUG
17
+ logger: vars.DEBUG,
18
+ debug: vars.DEBUG
19
19
  };
20
- const user = env.SMTP_USER;
21
- const pass = env.SMTP_PASSWORD;
20
+ const user = vars.SMTP_USER;
21
+ const pass = vars.SMTP_PASSWORD;
22
22
  if (user && pass) {
23
23
  args.auth = { user, pass };
24
24
  }
@@ -33,6 +33,7 @@ function create_mail_transport(env) {
33
33
  }
34
34
  export class mailStore {
35
35
  env;
36
+ vars;
36
37
  transport;
37
38
  api_db = null;
38
39
  keys = {};
@@ -41,7 +42,7 @@ export class mailStore {
41
42
  uploadTemplate;
42
43
  uploadStagingPath;
43
44
  print_debug(msg) {
44
- if (this.env.DEBUG) {
45
+ if (this.vars.DEBUG) {
45
46
  console.log(msg);
46
47
  }
47
48
  }
@@ -49,7 +50,7 @@ export class mailStore {
49
50
  return path.resolve(path.join(this.configpath, name));
50
51
  }
51
52
  resolveUploadPath(domainName) {
52
- const raw = this.env.UPLOAD_PATH ?? '';
53
+ const raw = this.vars.UPLOAD_PATH ?? '';
53
54
  const hasDomainToken = raw.includes('{domain}');
54
55
  const expanded = hasDomainToken && domainName ? raw.replaceAll('{domain}', domainName) : raw;
55
56
  if (!expanded) {
@@ -62,7 +63,7 @@ export class mailStore {
62
63
  return path.resolve(base, expanded);
63
64
  }
64
65
  getUploadStagingPath() {
65
- if (!this.env.UPLOAD_PATH) {
66
+ if (!this.vars.UPLOAD_PATH) {
66
67
  return '';
67
68
  }
68
69
  if (this.uploadTemplate) {
@@ -112,23 +113,53 @@ export class mailStore {
112
113
  this.print_debug(`No api-keys.json file found: tried ${keyfile}`);
113
114
  return {};
114
115
  }
115
- async init() {
116
+ async init(overrides = {}) {
116
117
  // Load env config only via EnvLoader + envOptions (avoid ad-hoc `process.env` parsing here).
117
118
  // If DEBUG is enabled, re-load with EnvLoader debug output enabled.
118
- let env = await EnvLoader.createConfigProxy(envOptions, { debug: false });
119
- if (env.DEBUG) {
120
- env = await EnvLoader.createConfigProxy(envOptions, { debug: true });
119
+ const overrideEntries = Object.entries(overrides);
120
+ const envSnapshot = new Map();
121
+ if (overrideEntries.length > 0) {
122
+ for (const [key, value] of overrideEntries) {
123
+ envSnapshot.set(key, process.env[key]);
124
+ if (value === undefined || value === null) {
125
+ delete process.env[key];
126
+ }
127
+ else {
128
+ process.env[key] = String(value);
129
+ }
130
+ }
131
+ }
132
+ let env;
133
+ try {
134
+ env = await EnvLoader.createConfigProxy(envOptions, { debug: false });
135
+ const debugEnabled = overrides.DEBUG ?? env.DEBUG;
136
+ if (debugEnabled) {
137
+ env = await EnvLoader.createConfigProxy(envOptions, { debug: true });
138
+ }
139
+ }
140
+ finally {
141
+ if (envSnapshot.size > 0) {
142
+ for (const [key, value] of envSnapshot.entries()) {
143
+ if (value === undefined) {
144
+ delete process.env[key];
145
+ }
146
+ else {
147
+ process.env[key] = value;
148
+ }
149
+ }
150
+ }
121
151
  }
122
152
  this.env = env;
123
- if (this.env.FORM_CAPTCHA_REQUIRED && !String(this.env.FORM_CAPTCHA_SECRET ?? '').trim()) {
153
+ this.vars = { ...env, ...overrides };
154
+ if (this.vars.FORM_CAPTCHA_REQUIRED && !String(this.vars.FORM_CAPTCHA_SECRET ?? '').trim()) {
124
155
  throw new Error('FORM_CAPTCHA_SECRET must be set when FORM_CAPTCHA_REQUIRED=true');
125
156
  }
126
157
  EnvLoader.genTemplate(envOptions, '.env-dist');
127
- const p = env.CONFIG_PATH;
158
+ const p = this.vars.CONFIG_PATH;
128
159
  this.configpath = path.isAbsolute(p) ? p : path.resolve(process.cwd(), p);
129
160
  console.log(`Config path is ${this.configpath}`);
130
- if (env.UPLOAD_PATH && env.UPLOAD_PATH.includes('{domain}')) {
131
- this.uploadTemplate = env.UPLOAD_PATH;
161
+ if (this.vars.UPLOAD_PATH && this.vars.UPLOAD_PATH.includes('{domain}')) {
162
+ this.uploadTemplate = this.vars.UPLOAD_PATH;
132
163
  this.uploadStagingPath = path.resolve(this.configpath, '_uploads');
133
164
  try {
134
165
  fs.mkdirSync(this.uploadStagingPath, { recursive: true });
@@ -138,9 +169,9 @@ export class mailStore {
138
169
  }
139
170
  }
140
171
  // this.keys = await this.load_api_keys(this.configpath);
141
- this.transport = await create_mail_transport(env);
172
+ this.transport = await create_mail_transport(this.vars);
142
173
  this.api_db = await connect_api_db(this);
143
- if (this.env.DB_AUTO_RELOAD) {
174
+ if (this.vars.DB_AUTO_RELOAD) {
144
175
  this.print_debug('Enabling auto reload of init-data.json');
145
176
  fs.watchFile(this.config_filename('init-data.json'), { interval: 2000 }, () => {
146
177
  this.print_debug('Config file changed, reloading...');
@@ -0,0 +1,107 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ function normalizeRoute(value, fallback = '') {
5
+ if (!value) {
6
+ return fallback;
7
+ }
8
+ const trimmed = value.trim();
9
+ if (!trimmed) {
10
+ return fallback;
11
+ }
12
+ const withLeading = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
13
+ if (withLeading === '/') {
14
+ return withLeading;
15
+ }
16
+ return withLeading.replace(/\/+$/, '');
17
+ }
18
+ function replacePrefix(input, from, to) {
19
+ if (input === from) {
20
+ return to;
21
+ }
22
+ if (input.startsWith(`${from}/`)) {
23
+ const suffix = input.slice(from.length);
24
+ if (to === '/') {
25
+ return suffix.replace(/^\/+/, '/') || '/';
26
+ }
27
+ return `${to}${suffix}`;
28
+ }
29
+ return input;
30
+ }
31
+ function rewriteSpecForRuntime(spec, opts) {
32
+ if (!spec || typeof spec !== 'object') {
33
+ return spec;
34
+ }
35
+ const base = normalizeRoute(opts.apiBasePath, '/api');
36
+ const asset = normalizeRoute(opts.assetRoute, '/asset');
37
+ const root = spec;
38
+ const out = { ...root };
39
+ // Keep the spec stable while still reflecting the configured public URL and base paths.
40
+ out.servers = [{ url: String(opts.apiUrl || ''), description: 'Configured API_URL' }];
41
+ const rawPaths = root.paths;
42
+ if (!rawPaths || typeof rawPaths !== 'object') {
43
+ return out;
44
+ }
45
+ const rewritten = {};
46
+ for (const [p, v] of Object.entries(rawPaths)) {
47
+ let next = String(p);
48
+ next = replacePrefix(next, '/api', base);
49
+ next = replacePrefix(next, '/asset', asset);
50
+ // Normalize double slashes after prefix replacement (path only, not URLs).
51
+ next = next.replace(/\/{2,}/g, '/');
52
+ rewritten[next] = v;
53
+ }
54
+ out.paths = rewritten;
55
+ return out;
56
+ }
57
+ let cachedSpec = null;
58
+ let cachedSpecError = null;
59
+ function loadPackagedOpenApiSpec() {
60
+ if (cachedSpec || cachedSpecError) {
61
+ return cachedSpec;
62
+ }
63
+ try {
64
+ const here = path.dirname(fileURLToPath(import.meta.url));
65
+ const candidate = path.resolve(here, '../docs/swagger/openapi.json');
66
+ const raw = fs.readFileSync(candidate, 'utf8');
67
+ cachedSpec = JSON.parse(raw);
68
+ return cachedSpec;
69
+ }
70
+ catch (err) {
71
+ cachedSpecError = err instanceof Error ? err.message : String(err);
72
+ return null;
73
+ }
74
+ }
75
+ export function installMailMagicSwagger(server, opts) {
76
+ const rawPath = typeof opts.swaggerPath === 'string' ? opts.swaggerPath.trim() : '';
77
+ const enabled = Boolean(opts.swaggerEnabled) || rawPath.length > 0;
78
+ if (!enabled) {
79
+ return;
80
+ }
81
+ const base = normalizeRoute(opts.apiBasePath, '/api');
82
+ const resolved = rawPath.length > 0 ? rawPath : `${base}/swagger`;
83
+ const mount = normalizeRoute(resolved, `${base}/swagger`);
84
+ // Mount under the API router so it runs before the API 404 handler.
85
+ server.useExpress(mount, (req, res, next) => {
86
+ if (req.method && req.method !== 'GET' && req.method !== 'HEAD') {
87
+ next();
88
+ return;
89
+ }
90
+ const spec = loadPackagedOpenApiSpec();
91
+ if (!spec) {
92
+ res.status(500).json({
93
+ success: false,
94
+ code: 500,
95
+ message: `Swagger spec is unavailable${cachedSpecError ? `: ${cachedSpecError}` : ''}`,
96
+ data: null,
97
+ errors: {}
98
+ });
99
+ return;
100
+ }
101
+ res.status(200).json(rewriteSpecForRuntime(spec, {
102
+ apiBasePath: base,
103
+ assetRoute: opts.assetRoute,
104
+ apiUrl: opts.apiUrl
105
+ }));
106
+ });
107
+ }
@@ -0,0 +1,24 @@
1
+ export async function verifyCaptcha(params) {
2
+ const endpoints = {
3
+ turnstile: 'https://challenges.cloudflare.com/turnstile/v0/siteverify',
4
+ hcaptcha: 'https://hcaptcha.com/siteverify',
5
+ recaptcha: 'https://www.google.com/recaptcha/api/siteverify'
6
+ };
7
+ const endpoint = endpoints[params.provider] ?? endpoints.turnstile;
8
+ const body = new URLSearchParams();
9
+ body.set('secret', params.secret);
10
+ body.set('response', params.token);
11
+ if (params.remoteip) {
12
+ body.set('remoteip', params.remoteip);
13
+ }
14
+ const res = await fetch(endpoint, {
15
+ method: 'POST',
16
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
17
+ body
18
+ });
19
+ if (!res.ok) {
20
+ return false;
21
+ }
22
+ const data = (await res.json().catch(() => null));
23
+ return Boolean(data?.success);
24
+ }
@@ -0,0 +1,19 @@
1
+ import emailAddresses from 'email-addresses';
2
+ export function validateEmail(email) {
3
+ const parsed = emailAddresses.parseOneAddress(email);
4
+ if (parsed) {
5
+ return parsed.address;
6
+ }
7
+ return undefined;
8
+ }
9
+ export function parseMailbox(value) {
10
+ const parsed = emailAddresses.parseOneAddress(value);
11
+ if (!parsed) {
12
+ return undefined;
13
+ }
14
+ const mailbox = parsed;
15
+ if (!mailbox?.address) {
16
+ return undefined;
17
+ }
18
+ return mailbox;
19
+ }
@@ -0,0 +1,44 @@
1
+ import emailAddresses from 'email-addresses';
2
+ function getFirstStringField(body, key) {
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
+ }
12
+ function sanitizeHeaderValue(value, maxLen) {
13
+ const trimmed = String(value ?? '').trim();
14
+ if (!trimmed) {
15
+ return '';
16
+ }
17
+ // Prevent header injection.
18
+ if (/[\r\n]/.test(trimmed)) {
19
+ return '';
20
+ }
21
+ return trimmed.slice(0, maxLen);
22
+ }
23
+ export function extractReplyToFromSubmission(body) {
24
+ const emailRaw = sanitizeHeaderValue(getFirstStringField(body, 'email'), 320);
25
+ if (!emailRaw) {
26
+ return undefined;
27
+ }
28
+ const parsed = emailAddresses.parseOneAddress(emailRaw);
29
+ if (!parsed) {
30
+ return undefined;
31
+ }
32
+ const address = sanitizeHeaderValue(parsed?.address, 320);
33
+ if (!address) {
34
+ return undefined;
35
+ }
36
+ // Prefer a single "name" field, otherwise compose from first_name/last_name.
37
+ let name = sanitizeHeaderValue(getFirstStringField(body, 'name'), 200);
38
+ if (!name) {
39
+ const first = sanitizeHeaderValue(getFirstStringField(body, 'first_name'), 100);
40
+ const last = sanitizeHeaderValue(getFirstStringField(body, 'last_name'), 100);
41
+ name = sanitizeHeaderValue(`${first}${first && last ? ' ' : ''}${last}`, 200);
42
+ }
43
+ return name ? { name, address } : address;
44
+ }
@@ -0,0 +1,95 @@
1
+ import { z } from 'zod';
2
+ import { getBodyValue } from './utils.js';
3
+ const ALLOWED_MM_KEYS = new Set(['_mm_form_key', '_mm_locale', '_mm_recipients']);
4
+ function asRecord(input) {
5
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
6
+ return {};
7
+ }
8
+ return input;
9
+ }
10
+ function getCaptchaTokenFromBody(body) {
11
+ return getBodyValue(body, 'cf-turnstile-response', 'h-captcha-response', 'g-recaptcha-response', 'captcha');
12
+ }
13
+ const optionalStringish = z.union([z.string(), z.array(z.string())]).optional();
14
+ // Public form submission payload schema.
15
+ // - Validates/normalizes system fields under the `_mm_*` namespace.
16
+ // - Allows arbitrary non-system fields through (exposed to templates as `_fields_`).
17
+ // - Rejects unknown `_mm_*` keys (except `_mm_file*` attachment field names).
18
+ export const form_submission_schema = z
19
+ .object({
20
+ _mm_form_key: z
21
+ .string()
22
+ .min(1)
23
+ .describe('Required. Public form key identifying which form configuration to use.'),
24
+ _mm_locale: z
25
+ .string()
26
+ .optional()
27
+ .default('')
28
+ .describe('Optional locale hint used when rendering and for recipient resolution.'),
29
+ _mm_recipients: z
30
+ .union([z.string(), z.array(z.string())])
31
+ .optional()
32
+ .describe('Optional list of recipient idnames (array) or comma-separated string. Recipients are resolved server-side.'),
33
+ // Common fields used to derive Reply-To (optional; no defaults).
34
+ email: optionalStringish.describe('Optional submitter email used to derive Reply-To.'),
35
+ name: optionalStringish.describe('Optional submitter name used to derive Reply-To.'),
36
+ first_name: optionalStringish.describe('Optional submitter first name used to derive Reply-To.'),
37
+ last_name: optionalStringish.describe('Optional submitter last name used to derive Reply-To.'),
38
+ // Provider-native CAPTCHA token field names (accepted as-is; not part of the `_mm_*` namespace).
39
+ 'cf-turnstile-response': optionalStringish.describe('Cloudflare Turnstile token (accepted as-is).'),
40
+ 'h-captcha-response': optionalStringish.describe('hCaptcha token (accepted as-is).'),
41
+ 'g-recaptcha-response': optionalStringish.describe('Google reCAPTCHA token (accepted as-is).'),
42
+ captcha: optionalStringish.describe('Generic/legacy captcha token field (accepted as-is).')
43
+ })
44
+ .passthrough()
45
+ .superRefine((obj, ctx) => {
46
+ for (const key of Object.keys(obj)) {
47
+ if (!key.startsWith('_mm_')) {
48
+ continue;
49
+ }
50
+ if (key.startsWith('_mm_file')) {
51
+ // Files arrive in req.files, but allow clients to pass harmless metadata fields.
52
+ continue;
53
+ }
54
+ if (!ALLOWED_MM_KEYS.has(key)) {
55
+ ctx.addIssue({
56
+ code: z.ZodIssueCode.custom,
57
+ message: `Unknown system field "${key}"`
58
+ });
59
+ }
60
+ }
61
+ })
62
+ .describe('Public form submission payload. System fields must be `_mm_*`. All other fields are treated as user fields and exposed to templates as `_fields_`.');
63
+ export function parseFormSubmissionInput(raw) {
64
+ const body = asRecord(raw);
65
+ // Enforce that system params are _mm_* only (except provider captcha fields).
66
+ // We intentionally do not accept non-_mm aliases for system params.
67
+ const mm_form_key = getBodyValue(body, '_mm_form_key');
68
+ const mm_locale = getBodyValue(body, '_mm_locale');
69
+ const mm_recipients = body._mm_recipients;
70
+ const mm_captcha_token = getCaptchaTokenFromBody(body);
71
+ const parsed = form_submission_schema.parse({
72
+ ...body,
73
+ _mm_form_key: mm_form_key,
74
+ _mm_locale: mm_locale,
75
+ _mm_recipients: mm_recipients
76
+ });
77
+ const { _mm_form_key, _mm_locale, _mm_recipients, ...rest } = parsed;
78
+ // Expose non-system fields to templates. Keep all non-`_mm_*` keys verbatim.
79
+ const fields = {};
80
+ for (const [key, value] of Object.entries(rest)) {
81
+ if (key.startsWith('_mm_')) {
82
+ continue;
83
+ }
84
+ fields[key] = value;
85
+ }
86
+ return {
87
+ mm: {
88
+ form_key: _mm_form_key,
89
+ locale: _mm_locale,
90
+ captcha_token: mm_captcha_token,
91
+ recipients_raw: _mm_recipients
92
+ },
93
+ fields
94
+ };
95
+ }