@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.
- package/.do-realease.sh +40 -1
- package/.env-dist +9 -0
- package/.vscode/extensions.json +1 -13
- package/.vscode/settings.json +13 -114
- package/CHANGES +14 -0
- package/dist/api/assets.js +19 -6
- package/dist/api/forms.js +31 -4
- package/dist/api/mailer.js +34 -8
- package/dist/index.js +2 -0
- package/dist/models/form.js +11 -1
- package/dist/models/init.js +10 -7
- package/dist/models/txmail.js +13 -4
- package/dist/store/envloader.js +9 -0
- package/dist/store/store.js +1 -0
- package/eslint.config.mjs +133 -41
- package/lintconfig.cjs +81 -0
- package/package.json +30 -20
- package/src/api/assets.ts +19 -6
- package/src/api/forms.ts +33 -5
- package/src/api/mailer.ts +36 -9
- package/src/index.ts +2 -0
- package/src/models/form.ts +12 -1
- package/src/models/init.ts +10 -7
- package/src/models/txmail.ts +14 -6
- package/src/store/envloader.ts +9 -0
- package/src/store/store.ts +2 -0
- package/tests/fixtures/certs/test.crt +19 -0
- package/tests/fixtures/certs/test.key +28 -0
- package/tests/helpers/test-setup.ts +316 -0
- package/tests/mail-magic.test.ts +154 -0
- package/vitest.config.ts +11 -0
package/dist/store/store.js
CHANGED
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
|
|
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:
|
|
63
|
+
files: TS_PLUGIN_FILE_GLOBS,
|
|
26
64
|
plugins: {
|
|
27
|
-
|
|
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: ['
|
|
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']
|
|
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.
|
|
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,
|
|
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
|
|
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": "
|
|
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
|
-
"@
|
|
49
|
-
"@typescript-eslint/
|
|
50
|
-
"@
|
|
51
|
-
"
|
|
52
|
-
"eslint": "
|
|
53
|
-
"eslint-
|
|
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.
|
|
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
|
-
"
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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(
|
|
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,
|
|
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
|
|
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:
|
|
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 |
|
|
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
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
package/src/models/form.ts
CHANGED
|
@@ -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 =
|
|
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);
|