@technomoron/mail-magic 1.0.6 → 1.0.8

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.
@@ -37,6 +37,7 @@ export class mailStore {
37
37
  api_db = null;
38
38
  keys = {};
39
39
  configpath = '';
40
+ deflocale;
40
41
  print_debug(msg) {
41
42
  if (this.env.DEBUG) {
42
43
  console.log(msg);
package/eslint.config.mjs CHANGED
@@ -1,9 +1,45 @@
1
+ import tsPlugin from '@typescript-eslint/eslint-plugin';
1
2
  import tsParser from '@typescript-eslint/parser';
2
- import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript';
3
+ import eslintConfigPrettier from 'eslint-config-prettier/flat';
3
4
  import pluginImport from 'eslint-plugin-import';
4
- import pluginPrettier from 'eslint-plugin-prettier';
5
- import pluginVue from 'eslint-plugin-vue';
6
5
  import jsoncParser from 'jsonc-eslint-parser';
6
+ const TS_FILE_GLOBS = ['**/*.{ts,tsx,mts,cts,vue}'];
7
+ const TS_PLUGIN_FILE_GLOBS = ['**/*.{ts,tsx,mts,cts,js,mjs,cjs,vue}'];
8
+ const VUE_FILE_GLOBS = ['**/*.vue'];
9
+
10
+ const { hasVueSupport, pluginVue, vueTypeScriptConfigs } = await loadVueSupport();
11
+ const scopedVueTypeScriptConfigs = hasVueSupport
12
+ ? scopeVueConfigs(vueTypeScriptConfigs).map(stripTypeScriptPlugin)
13
+ : [];
14
+ const vueSpecificBlocks = hasVueSupport
15
+ ? [
16
+ ...scopedVueTypeScriptConfigs,
17
+ {
18
+ files: VUE_FILE_GLOBS,
19
+ plugins: {
20
+ vue: pluginVue
21
+ },
22
+ rules: {
23
+ 'vue/html-indent': 'off', // Let Prettier handle indentation
24
+ 'vue/max-attributes-per-line': 'off', // Let Prettier handle line breaks
25
+ 'vue/first-attribute-linebreak': 'off', // Let Prettier handle attribute positioning
26
+ 'vue/singleline-html-element-content-newline': 'off',
27
+ 'vue/html-self-closing': [
28
+ 'error',
29
+ {
30
+ html: {
31
+ void: 'always',
32
+ normal: 'always',
33
+ component: 'always'
34
+ }
35
+ }
36
+ ],
37
+ 'vue/multi-word-component-names': 'off', // Disable multi-word name restriction
38
+ 'vue/attribute-hyphenation': ['error', 'always']
39
+ }
40
+ }
41
+ ]
42
+ : [];
7
43
 
8
44
  export default [
9
45
  {
@@ -12,54 +48,31 @@ export default [
12
48
  'dist',
13
49
  '.output',
14
50
  '.nuxt',
51
+ '.netlify',
52
+ 'node_modules/.netlify',
53
+ '4000/.nuxt',
15
54
  'coverage',
16
55
  '**/*.d.ts',
56
+ 'configure-eslint.cjs',
17
57
  'configure-eslint.js',
18
58
  '*.config.js',
19
- '*.config.ts',
20
59
  'public'
21
60
  ]
22
61
  },
23
- ...defineConfigWithVueTs(vueTsConfigs.recommended),
24
62
  {
25
- files: ['**/*.vue'],
63
+ files: TS_PLUGIN_FILE_GLOBS,
26
64
  plugins: {
27
- vue: pluginVue,
28
- prettier: pluginPrettier
29
- },
30
- rules: {
31
- 'prettier/prettier': 'error', // Enforce Prettier rules
32
- 'vue/html-indent': 'off', // Let Prettier handle indentation
33
- 'vue/max-attributes-per-line': 'off', // Let Prettier handle line breaks
34
- 'vue/first-attribute-linebreak': 'off', // Let Prettier handle attribute positioning
35
- 'vue/singleline-html-element-content-newline': 'off',
36
- 'vue/html-self-closing': [
37
- 'error',
38
- {
39
- html: {
40
- void: 'always',
41
- normal: 'always',
42
- component: 'always'
43
- }
44
- }
45
- ],
46
- 'vue/multi-word-component-names': 'off', // Disable multi-word name restriction
47
- 'vue/attribute-hyphenation': ['error', 'always']
65
+ '@typescript-eslint': tsPlugin
48
66
  }
49
67
  },
68
+ ...vueSpecificBlocks,
50
69
  {
51
- files: ['*.json'],
70
+ files: ['**/*.json'],
52
71
  languageOptions: {
53
72
  parser: jsoncParser
54
73
  },
55
- plugins: {
56
- prettier: pluginPrettier
57
- },
58
74
  rules: {
59
- quotes: ['error', 'double'], // Enforce double quotes in JSON
60
- 'prettier/prettier': 'error',
61
- '@typescript-eslint/no-unused-expressions': 'off',
62
- '@typescript-eslint/no-unused-vars': 'off'
75
+ quotes: ['error', 'double'] // Enforce double quotes in JSON
63
76
  }
64
77
  },
65
78
  {
@@ -79,15 +92,9 @@ export default [
79
92
  }
80
93
  },
81
94
  plugins: {
82
- prettier: pluginPrettier,
83
95
  import: pluginImport
84
96
  },
85
97
  rules: {
86
- indent: ['error', 'tab', { SwitchCase: 1 }], // Use tabs for JS/TS
87
- quotes: ['warn', 'single', { avoidEscape: true }], // Prefer single quotes
88
- semi: ['error', 'always'], // Enforce semicolons
89
- 'comma-dangle': 'off', // Disable trailing commas
90
- 'prettier/prettier': 'error', // Enforce Prettier rules
91
98
  'import/order': [
92
99
  'error',
93
100
  {
@@ -100,5 +107,90 @@ export default [
100
107
  '@typescript-eslint/no-unused-vars': ['warn'],
101
108
  '@typescript-eslint/no-require-imports': 'off'
102
109
  }
110
+ },
111
+ {
112
+ ...eslintConfigPrettier
103
113
  }
104
114
  ];
115
+
116
+ async function loadVueSupport() {
117
+ try {
118
+ const [vuePluginModule, vueConfigModule] = await Promise.all([
119
+ import('eslint-plugin-vue'),
120
+ import('@vue/eslint-config-typescript')
121
+ ]);
122
+
123
+ const pluginVue = unwrapDefault(vuePluginModule);
124
+ const { defineConfigWithVueTs, vueTsConfigs } = vueConfigModule;
125
+ const configs = defineConfigWithVueTs(vueTsConfigs.recommended);
126
+
127
+ return {
128
+ hasVueSupport: Boolean(pluginVue && configs.length),
129
+ pluginVue,
130
+ vueTypeScriptConfigs: configs
131
+ };
132
+ } catch (error) {
133
+ if (isModuleNotFoundError(error)) {
134
+ return {
135
+ hasVueSupport: false,
136
+ pluginVue: null,
137
+ vueTypeScriptConfigs: []
138
+ };
139
+ }
140
+
141
+ throw error;
142
+ }
143
+ }
144
+
145
+ function scopeVueConfigs(configs) {
146
+ return configs.map((config) => {
147
+ const files = config.files ?? [];
148
+ const referencesOnlyVueFiles = files.length > 0 && files.every((pattern) => pattern.includes('.vue'));
149
+ const hasVuePlugin = Boolean(config.plugins?.vue);
150
+
151
+ if (hasVuePlugin || referencesOnlyVueFiles) {
152
+ return {
153
+ ...config,
154
+ files: VUE_FILE_GLOBS
155
+ };
156
+ }
157
+
158
+ return {
159
+ ...config,
160
+ files: TS_FILE_GLOBS
161
+ };
162
+ });
163
+ }
164
+
165
+ function stripTypeScriptPlugin(config) {
166
+ const { plugins = {}, ...rest } = config;
167
+
168
+ if (!plugins['@typescript-eslint']) {
169
+ return config;
170
+ }
171
+
172
+ const otherPlugins = { ...plugins };
173
+ delete otherPlugins['@typescript-eslint'];
174
+ const hasOtherPlugins = Object.keys(otherPlugins).length > 0;
175
+
176
+ return {
177
+ ...rest,
178
+ ...(hasOtherPlugins ? { plugins: otherPlugins } : {})
179
+ };
180
+ }
181
+
182
+ function unwrapDefault(module) {
183
+ return module?.default ?? module;
184
+ }
185
+
186
+ function isModuleNotFoundError(error) {
187
+ if (!error) {
188
+ return false;
189
+ }
190
+
191
+ if (error.code === 'ERR_MODULE_NOT_FOUND' || error.code === 'MODULE_NOT_FOUND') {
192
+ return true;
193
+ }
194
+
195
+ return typeof error.message === 'string' && error.message.includes('Cannot find module');
196
+ }
package/lintconfig.cjs ADDED
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env node
2
+ const { execSync, spawnSync } = require('child_process');
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+
7
+ const RELEASE_API_URL = 'https://api.github.com/repos/technomoron/vscode-eslint-defaults/releases/latest';
8
+ const INSTALLER_ASSET_NAME = 'installer.tgz';
9
+
10
+ async function fetch_json(url) {
11
+ const response = await fetch(url, {
12
+ headers: {
13
+ 'User-Agent': 'vscode-eslint-defaults-lintconfig',
14
+ Accept: 'application/vnd.github+json'
15
+ }
16
+ });
17
+
18
+ if (!response.ok) {
19
+ throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
20
+ }
21
+
22
+ return response.json();
23
+ }
24
+
25
+ async function download_asset(url, destination) {
26
+ const response = await fetch(url, {
27
+ headers: {
28
+ 'User-Agent': 'vscode-eslint-defaults-lintconfig'
29
+ }
30
+ });
31
+
32
+ if (!response.ok) {
33
+ throw new Error(`Failed to download ${url}: ${response.status} ${response.statusText}`);
34
+ }
35
+
36
+ const buffer = Buffer.from(await response.arrayBuffer());
37
+ fs.writeFileSync(destination, buffer);
38
+ }
39
+
40
+ async function run() {
41
+ const release = await fetch_json(RELEASE_API_URL);
42
+ const assets = Array.isArray(release.assets) ? release.assets : [];
43
+ const asset = assets.find((item) => item.name === INSTALLER_ASSET_NAME);
44
+
45
+ if (!asset?.browser_download_url) {
46
+ throw new Error('Latest release does not include installer.tgz.');
47
+ }
48
+
49
+ const temp_dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lintconfig-'));
50
+ const tgz_path = path.join(temp_dir, INSTALLER_ASSET_NAME);
51
+ const args = process.argv.slice(2);
52
+ let exit_code = 0;
53
+
54
+ try {
55
+ await download_asset(asset.browser_download_url, tgz_path);
56
+ execSync(`tar -xzf "${tgz_path}" -C "${process.cwd()}"`, { stdio: 'inherit' });
57
+
58
+ const configure_path = path.join(process.cwd(), 'configure-eslint.cjs');
59
+ if (!fs.existsSync(configure_path)) {
60
+ throw new Error('configure-eslint.cjs not found after extraction.');
61
+ }
62
+
63
+ const result = spawnSync(process.execPath, [configure_path, ...args], { stdio: 'inherit' });
64
+ if (result.status !== 0) {
65
+ exit_code = result.status ?? 1;
66
+ } else {
67
+ fs.unlinkSync(configure_path);
68
+ }
69
+ } finally {
70
+ fs.rmSync(temp_dir, { recursive: true, force: true });
71
+ }
72
+
73
+ if (exit_code !== 0) {
74
+ process.exit(exit_code);
75
+ }
76
+ }
77
+
78
+ run().catch((error) => {
79
+ console.error(error.message || error);
80
+ process.exit(1);
81
+ });
package/package.json CHANGED
@@ -1,23 +1,34 @@
1
1
  {
2
2
  "name": "@technomoron/mail-magic",
3
- "version": "1.0.6",
3
+ "version": "1.0.8",
4
4
  "main": "dist/index.js",
5
5
  "type": "module",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "git+https://github.com/technomoron/mail-magic.git"
9
9
  },
10
+ "pnpm": {
11
+ "onlyBuiltDependencies": [
12
+ "core-js",
13
+ "@scarf/scarf",
14
+ "esbuild",
15
+ "sqlite3"
16
+ ]
17
+ },
10
18
  "scripts": {
11
19
  "start": "node dist/index.js",
12
20
  "dev": "NODE_ENV=development nodemon --watch 'src/**/*.ts' --watch 'config/**/*.*' --watch '.env' --exec 'tsx' src/index.ts",
13
21
  "run": "NODE_ENV=production npm run start",
14
22
  "build": "tsc",
23
+ "test": "vitest run",
24
+ "test:watch": "vitest",
15
25
  "scrub": "rm -rf ./node_modules/ ./dist/ pnpm-lock.yaml",
16
- "lint": "eslint --ext .js,.cjs,.mjs,.ts,.mts,.tsx,.vue ./",
17
- "lintfix": "eslint --fix --ext .js,.cjs,.mjs,.ts,.mts,.tsx,.vue ./",
18
- "pretty": "prettier --write \"**/*.{js,jsx,cjs,mjs,ts,tsx,mts,vue,json,css,scss,md}\"",
26
+ "lint": "eslint --no-error-on-unmatched-pattern --ext .js,.cjs,.mjs,.ts,.mts,.tsx,.vue,.json ./",
27
+ "lintfix": "eslint --fix --no-error-on-unmatched-pattern --ext .js,.cjs,.mjs,.ts,.mts,.tsx,.vue,.json ./",
28
+ "pretty": "prettier --write \"**/*.{js,jsx,cjs,mjs,ts,tsx,mts,vue,json,md}\"",
19
29
  "format": "npm run lintfix && npm run pretty",
20
- "cleanbuild": "rm -rf ./dist/ && npm run lintfix && npm run format && npm run build"
30
+ "cleanbuild": "rm -rf ./dist/ && npm run format && npm run build",
31
+ "lintconfig": "node lintconfig.cjs"
21
32
  },
22
33
  "keywords": [],
23
34
  "author": "Bjørn Erik Jacobsen",
@@ -27,7 +38,7 @@
27
38
  "url": "https://github.com/technomoron/mail-magic/issues"
28
39
  },
29
40
  "dependencies": {
30
- "@technomoron/api-server-base": "^1.0.40",
41
+ "@technomoron/api-server-base": "2.0.0-beta.15",
31
42
  "@technomoron/env-loader": "^1.0.8",
32
43
  "@technomoron/unyuck": "^1.0.4",
33
44
  "bcryptjs": "^3.0.2",
@@ -45,23 +56,22 @@
45
56
  "@types/html-to-text": "^9.0.4",
46
57
  "@types/nodemailer": "^6.4.19",
47
58
  "@types/nunjucks": "^3.2.6",
48
- "@typescript-eslint/eslint-plugin": "8.44.1",
49
- "@typescript-eslint/parser": "8.44.1",
50
- "@vue/eslint-config-prettier": "10.2.0",
51
- "@vue/eslint-config-typescript": "^14.6.0",
52
- "eslint": "9.36.0",
53
- "eslint-config-prettier": "10.1.8",
54
- "eslint-import-resolver-alias": "1.1.2",
55
- "eslint-plugin-import": "2.32.0",
56
- "eslint-plugin-nuxt": "4.0.0",
57
- "eslint-plugin-prettier": "5.5.4",
58
- "eslint-plugin-vue": "^10.5.0",
59
+ "@types/supertest": "^6.0.3",
60
+ "@typescript-eslint/eslint-plugin": "^8.50.1",
61
+ "@typescript-eslint/parser": "^8.50.1",
62
+ "eslint": "^9.39.2",
63
+ "eslint-config-prettier": "^10.1.8",
64
+ "eslint-plugin-import": "^2.32.0",
59
65
  "jsonc-eslint-parser": "^2.4.1",
66
+ "mailparser": "^3.9.1",
60
67
  "nodemon": "^3.1.10",
61
- "prettier": "3.6.2",
68
+ "prettier": "^3.7.4",
69
+ "smtp-server": "^3.18.0",
70
+ "supertest": "^7.1.4",
62
71
  "tsx": "^4.20.5",
63
72
  "typescript": "^5.9.2",
64
- "vue-eslint-parser": "^10.2.0"
73
+ "vitest": "^4.0.16"
65
74
  },
66
- "homepage": "https://github.com/technomoron/mail-magic#readme"
75
+ "homepage": "https://github.com/technomoron/mail-magic#readme",
76
+ "packageManager": "pnpm@10.26.1+sha512.664074abc367d2c9324fdc18037097ce0a8f126034160f709928e9e9f95d98714347044e5c3164d65bd5da6c59c6be362b107546292a8eecb7999196e5ce58fa"
67
77
  }
package/src/api/assets.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
 
4
- import { ApiModule, ApiRoute, ApiError } from '@technomoron/api-server-base';
4
+ import { ApiError, ApiModule, ApiRoute } from '@technomoron/api-server-base';
5
5
 
6
6
  import { mailApiServer } from '../server.js';
7
7
  import { decodeComponent, sendFileAsync } from '../util.js';
@@ -28,18 +28,31 @@ export class AssetAPI extends ApiModule<mailApiServer> {
28
28
  }
29
29
 
30
30
  const assetsRoot = path.join(this.server.storage.configpath, domain, 'assets');
31
- const resolvedRoot = path.resolve(assetsRoot);
31
+ if (!fs.existsSync(assetsRoot)) {
32
+ throw new ApiError({ code: 404, message: 'Asset not found' });
33
+ }
34
+ const resolvedRoot = fs.realpathSync(assetsRoot);
32
35
  const normalizedRoot = resolvedRoot.endsWith(path.sep) ? resolvedRoot : resolvedRoot + path.sep;
33
36
  const candidate = path.resolve(assetsRoot, path.join(...segments));
34
- if (!candidate.startsWith(normalizedRoot)) {
37
+
38
+ try {
39
+ const stats = await fs.promises.stat(candidate);
40
+ if (!stats.isFile()) {
41
+ throw new ApiError({ code: 404, message: 'Asset not found' });
42
+ }
43
+ } catch {
35
44
  throw new ApiError({ code: 404, message: 'Asset not found' });
36
45
  }
37
46
 
47
+ let realCandidate: string;
38
48
  try {
39
- await fs.promises.access(candidate, fs.constants.R_OK);
49
+ realCandidate = await fs.promises.realpath(candidate);
40
50
  } catch {
41
51
  throw new ApiError({ code: 404, message: 'Asset not found' });
42
52
  }
53
+ if (!realCandidate.startsWith(normalizedRoot)) {
54
+ throw new ApiError({ code: 404, message: 'Asset not found' });
55
+ }
43
56
 
44
57
  const { res } = apiReq;
45
58
  const originalStatus = res.status.bind(res);
@@ -47,11 +60,11 @@ export class AssetAPI extends ApiModule<mailApiServer> {
47
60
  res.status = ((code: number) => (res.headersSent ? res : originalStatus(code))) as typeof res.status;
48
61
  res.json = ((body: unknown) => (res.headersSent ? res : originalJson(body))) as typeof res.json;
49
62
 
50
- res.type(path.extname(candidate));
63
+ res.type(path.extname(realCandidate));
51
64
  res.set('Cache-Control', 'public, max-age=300');
52
65
 
53
66
  try {
54
- await sendFileAsync(res, candidate);
67
+ await sendFileAsync(res, realCandidate);
55
68
  } catch (err) {
56
69
  this.server.storage.print_debug(
57
70
  `Failed to serve asset ${domain}/${segments.join('/')}: ${err instanceof Error ? err.message : String(err)}`
package/src/api/forms.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import path from 'path';
2
2
 
3
3
  import { ApiRoute, ApiRequest, ApiModule, ApiError } from '@technomoron/api-server-base';
4
+ import emailAddresses, { ParsedMailbox } from 'email-addresses';
4
5
  import nunjucks from 'nunjucks';
5
6
 
6
7
  import { api_domain } from '../models/domain.js';
@@ -12,6 +13,14 @@ import { buildRequestMeta, normalizeSlug } from '../util.js';
12
13
  import type { mailApiRequest, UploadedFile } from '../types.js';
13
14
 
14
15
  export class FormAPI extends ApiModule<mailApiServer> {
16
+ private validateEmail(email: string): string | undefined {
17
+ const parsed = emailAddresses.parseOneAddress(email);
18
+ if (parsed) {
19
+ return (parsed as ParsedMailbox).address;
20
+ }
21
+ return undefined;
22
+ }
23
+
15
24
  private async assertDomainAndUser(apireq: mailApiRequest): Promise<void> {
16
25
  const { domain, locale } = apireq.req.body;
17
26
 
@@ -26,6 +35,9 @@ export class FormAPI extends ApiModule<mailApiServer> {
26
35
  if (!dbdomain) {
27
36
  throw new ApiError({ code: 401, message: `Unable to look up the domain ${domain}` });
28
37
  }
38
+ if (dbdomain.user_id !== user.user_id) {
39
+ throw new ApiError({ code: 403, message: `Domain ${domain} is not owned by this user` });
40
+ }
29
41
  apireq.domain = dbdomain;
30
42
  apireq.locale = locale || 'en';
31
43
  apireq.user = user;
@@ -107,7 +119,7 @@ export class FormAPI extends ApiModule<mailApiServer> {
107
119
  }
108
120
 
109
121
  private async postSendForm(apireq: ApiRequest): Promise<[number, Record<string, unknown>]> {
110
- const { formid, secret, recipient, vars = {} } = apireq.req.body;
122
+ const { formid, secret, recipient, vars = {}, replyTo, reply_to } = apireq.req.body;
111
123
 
112
124
  if (!formid) {
113
125
  throw new ApiError({ code: 404, message: 'Missing formid field in form' });
@@ -127,12 +139,27 @@ export class FormAPI extends ApiModule<mailApiServer> {
127
139
  if (recipient && !form.secret) {
128
140
  throw new ApiError({ code: 401, message: "'recipient' parameterer requires form secret to be set" });
129
141
  }
142
+ let normalizedReplyTo: string | undefined;
143
+ let normalizedRecipient: string | undefined;
144
+ const replyToValue = (replyTo || reply_to) as string | undefined;
145
+ if (replyToValue) {
146
+ normalizedReplyTo = this.validateEmail(replyToValue);
147
+ if (!normalizedReplyTo) {
148
+ throw new ApiError({ code: 400, message: 'Invalid reply-to email address' });
149
+ }
150
+ }
151
+ if (recipient) {
152
+ normalizedRecipient = this.validateEmail(String(recipient));
153
+ if (!normalizedRecipient) {
154
+ throw new ApiError({ code: 400, message: 'Invalid recipient email address' });
155
+ }
156
+ }
130
157
 
131
158
  let parsedVars: unknown = vars ?? {};
132
159
  if (typeof vars === 'string') {
133
160
  try {
134
161
  parsedVars = JSON.parse(vars);
135
- } catch (error) {
162
+ } catch {
136
163
  throw new ApiError({ code: 400, message: 'Invalid JSON provided in "vars"' });
137
164
  }
138
165
  }
@@ -172,16 +199,17 @@ export class FormAPI extends ApiModule<mailApiServer> {
172
199
 
173
200
  const mailOptions = {
174
201
  from: form.sender,
175
- to: recipient || form.recipient,
202
+ to: normalizedRecipient || form.recipient,
176
203
  subject: form.subject,
177
204
  html,
178
- attachments
205
+ attachments,
206
+ ...(normalizedReplyTo ? { replyTo: normalizedReplyTo } : {})
179
207
  };
180
208
 
181
209
  try {
182
210
  const info = await this.server.storage.transport!.sendMail(mailOptions);
183
211
  this.server.storage.print_debug('Email sent: ' + info.response);
184
- } catch (error) {
212
+ } catch (error: unknown) {
185
213
  const errorMessage = error instanceof Error ? error.message : String(error);
186
214
  this.server.storage.print_debug('Error sending email: ' + errorMessage);
187
215
  return [500, { error: `Error sending email: ${errorMessage}` }];
package/src/api/mailer.ts CHANGED
@@ -15,12 +15,12 @@ export class MailerAPI extends ApiModule<mailApiServer> {
15
15
  //
16
16
  // Validate and return the parsed email address
17
17
  //
18
- validateEmail(email: string): string | null {
18
+ validateEmail(email: string): string | undefined {
19
19
  const parsed = emailAddresses.parseOneAddress(email);
20
20
  if (parsed) {
21
21
  return (parsed as ParsedMailbox).address;
22
22
  }
23
- return null;
23
+ return undefined;
24
24
  }
25
25
 
26
26
  //
@@ -61,6 +61,9 @@ export class MailerAPI extends ApiModule<mailApiServer> {
61
61
  if (!dbdomain) {
62
62
  throw new ApiError({ code: 401, message: `Unable to look up the domain ${domain}` });
63
63
  }
64
+ if (dbdomain.user_id !== user.user_id) {
65
+ throw new ApiError({ code: 403, message: `Domain ${domain} is not owned by this user` });
66
+ }
64
67
  apireq.domain = dbdomain;
65
68
  apireq.locale = locale || 'en';
66
69
  apireq.user = user;
@@ -102,7 +105,7 @@ export class MailerAPI extends ApiModule<mailApiServer> {
102
105
  const [templateRecord, created] = await api_txmail.upsert(data, {
103
106
  returning: true
104
107
  });
105
- console.log('Template upserted:', templateRecord.name, 'Created:', created);
108
+ this.server.storage.print_debug(`Template upserted: ${templateRecord.name} (created=${created})`);
106
109
  } catch (error: unknown) {
107
110
  throw new ApiError({
108
111
  code: 500,
@@ -117,7 +120,7 @@ export class MailerAPI extends ApiModule<mailApiServer> {
117
120
  private async post_send(apireq: mailApiRequest): Promise<[number, Record<string, unknown>]> {
118
121
  await this.assert_domain_and_user(apireq);
119
122
 
120
- const { name, rcpt, domain = '', locale = '', vars = {} } = apireq.req.body;
123
+ const { name, rcpt, domain = '', locale = '', vars = {}, replyTo, reply_to, headers } = apireq.req.body;
121
124
 
122
125
  if (!name || !rcpt || !domain) {
123
126
  throw new ApiError({ code: 400, message: 'name/rcpt/domain required' });
@@ -127,7 +130,7 @@ export class MailerAPI extends ApiModule<mailApiServer> {
127
130
  if (typeof vars === 'string') {
128
131
  try {
129
132
  parsedVars = JSON.parse(vars);
130
- } catch (error) {
133
+ } catch {
131
134
  throw new ApiError({ code: 400, message: 'Invalid JSON provided in "vars"' });
132
135
  }
133
136
  }
@@ -140,7 +143,7 @@ export class MailerAPI extends ApiModule<mailApiServer> {
140
143
  throw new ApiError({ code: 400, message: 'Invalid email address(es): ' + invalid.join(',') });
141
144
  }
142
145
  let template: api_txmail | null = null;
143
- const deflocale = apireq.server.store.deflocale || '';
146
+ const deflocale = this.server.storage.deflocale || '';
144
147
  const domain_id = apireq.domain!.domain_id;
145
148
 
146
149
  try {
@@ -184,9 +187,31 @@ export class MailerAPI extends ApiModule<mailApiServer> {
184
187
  for (const file of rawFiles) {
185
188
  attachmentMap[file.fieldname] = file.originalname;
186
189
  }
187
- console.log(JSON.stringify({ vars, thevars }, undefined, 2));
190
+ this.server.storage.print_debug(`Template vars: ${JSON.stringify({ vars, thevars }, undefined, 2)}`);
188
191
 
189
192
  const meta = buildRequestMeta(apireq.req);
193
+ const replyToValue = (replyTo || reply_to) as string | undefined;
194
+ let normalizedReplyTo: string | undefined;
195
+ if (replyToValue) {
196
+ normalizedReplyTo = this.validateEmail(replyToValue);
197
+ if (!normalizedReplyTo) {
198
+ throw new ApiError({ code: 400, message: 'Invalid reply-to email address' });
199
+ }
200
+ }
201
+
202
+ let normalizedHeaders: Record<string, string> | undefined;
203
+ if (headers !== undefined) {
204
+ if (!headers || typeof headers !== 'object' || Array.isArray(headers)) {
205
+ throw new ApiError({ code: 400, message: 'headers must be a key/value object' });
206
+ }
207
+ normalizedHeaders = {};
208
+ for (const [key, value] of Object.entries(headers as Record<string, unknown>)) {
209
+ if (typeof value !== 'string') {
210
+ throw new ApiError({ code: 400, message: `headers.${key} must be a string` });
211
+ }
212
+ normalizedHeaders[key] = value;
213
+ }
214
+ }
190
215
 
191
216
  try {
192
217
  const env = new nunjucks.Environment(null, { autoescape: false });
@@ -209,9 +234,11 @@ export class MailerAPI extends ApiModule<mailApiServer> {
209
234
  subject: template.subject || apireq.req.body.subject || '',
210
235
  html,
211
236
  text,
212
- attachments
237
+ attachments,
238
+ ...(normalizedReplyTo ? { replyTo: normalizedReplyTo } : {}),
239
+ ...(normalizedHeaders ? { headers: normalizedHeaders } : {})
213
240
  };
214
- await apireq.server.storage.transport.sendMail(sendargs);
241
+ await this.server.storage.transport!.sendMail(sendargs);
215
242
  }
216
243
  return [200, { Status: 'OK', Message: 'Emails sent successfully' }];
217
244
  } catch (error: unknown) {
package/src/index.ts CHANGED
@@ -24,6 +24,8 @@ function buildServerConfig(store: mailStore, overrides: MailMagicServerOptions):
24
24
  uploadPath: env.UPLOAD_PATH,
25
25
  debug: env.DEBUG,
26
26
  apiBasePath: '',
27
+ swaggerEnabled: env.SWAGGER_ENABLED,
28
+ swaggerPath: env.SWAGGER_PATH,
27
29
  ...overrides
28
30
  };
29
31
  }
@@ -156,6 +156,17 @@ export async function init_api_form(api_db: Sequelize): Promise<typeof api_form>
156
156
  return api_form;
157
157
  }
158
158
 
159
+ function assertSafeRelativePath(filename: string, label: string): string {
160
+ const normalized = path.normalize(filename);
161
+ if (path.isAbsolute(normalized)) {
162
+ throw new Error(`${label} path must be relative`);
163
+ }
164
+ if (normalized.split(path.sep).includes('..')) {
165
+ throw new Error(`${label} path cannot include '..' segments`);
166
+ }
167
+ return normalized;
168
+ }
169
+
159
170
  export async function upsert_form(record: api_form_type): Promise<api_form> {
160
171
  const { user, domain } = await user_and_domain(record.domain_id);
161
172
 
@@ -179,7 +190,7 @@ export async function upsert_form(record: api_form_type): Promise<api_form> {
179
190
  if (!record.filename.endsWith('.njk')) {
180
191
  record.filename += '.njk';
181
192
  }
182
- record.filename = path.normalize(record.filename);
193
+ record.filename = assertSafeRelativePath(record.filename, 'Form filename');
183
194
 
184
195
  let instance: api_form | null = null;
185
196
  instance = await api_form.findByPk(record.form_id);