@technomoron/mail-magic 1.0.9 → 1.0.12
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 +24 -0
- package/README.md +5 -0
- package/dist/api/assets.js +153 -0
- package/dist/api/forms.js +2 -0
- package/dist/api/mailer.js +1 -0
- package/dist/bin/mail-magic.js +63 -0
- package/dist/index.js +61 -2
- package/dist/store/envloader.js +3 -3
- package/dist/store/store.js +66 -1
- package/package.json +16 -3
- package/.do-realease.sh +0 -54
- package/.editorconfig +0 -9
- package/.env-dist +0 -71
- package/.prettierrc +0 -14
- package/.vscode/extensions.json +0 -3
- package/.vscode/settings.json +0 -22
- package/config-example/form-template/default.njk +0 -102
- package/config-example/forms.config.json +0 -8
- package/config-example/init-data.json +0 -33
- package/config-example/tx-template/default.njk +0 -107
- package/ecosystem.config.cjs +0 -42
- package/eslint.config.mjs +0 -196
- package/lintconfig.cjs +0 -81
- package/src/api/assets.ts +0 -92
- package/src/api/forms.ts +0 -239
- package/src/api/mailer.ts +0 -270
- package/src/index.ts +0 -71
- package/src/models/db.ts +0 -112
- package/src/models/domain.ts +0 -72
- package/src/models/form.ts +0 -209
- package/src/models/init.ts +0 -240
- package/src/models/txmail.ts +0 -206
- package/src/models/user.ts +0 -79
- package/src/server.ts +0 -27
- package/src/store/envloader.ts +0 -109
- package/src/store/store.ts +0 -195
- package/src/types.ts +0 -39
- package/src/util.ts +0 -137
- package/tests/fixtures/certs/test.crt +0 -19
- package/tests/fixtures/certs/test.key +0 -28
- package/tests/helpers/test-setup.ts +0 -317
- package/tests/mail-magic.test.ts +0 -171
- package/tsconfig.json +0 -14
- package/vitest.config.ts +0 -11
package/CHANGES
CHANGED
|
@@ -1,3 +1,27 @@
|
|
|
1
|
+
Version 1.0.12 (2026-01-31)
|
|
2
|
+
|
|
3
|
+
- Removed the hard dependency on `@technomoron/mail-magic-admin` so installs
|
|
4
|
+
are fully standalone unless the admin UI is explicitly added.
|
|
5
|
+
|
|
6
|
+
Version 1.0.11 (2026-01-31)
|
|
7
|
+
|
|
8
|
+
- Scoped the client package to `@technomoron/mail-magic-client` and refreshed
|
|
9
|
+
documentation to match.
|
|
10
|
+
- Added root build shortcuts for server/client/admin packages and aligned the
|
|
11
|
+
release tag pattern for the client workflow.
|
|
12
|
+
- Added a TypeScript-based CLI bin so global installs expose `mail-magic`.
|
|
13
|
+
|
|
14
|
+
Version 1.0.10 (2026-01-31)
|
|
15
|
+
|
|
16
|
+
- Added authenticated asset uploads via `POST /api/v1/assets`, supporting
|
|
17
|
+
domain and template-scoped targets with path validation and ownership checks.
|
|
18
|
+
- Mount optional admin UI at `/` when the admin package is installed, while
|
|
19
|
+
leaving API and asset routes unchanged.
|
|
20
|
+
- Expanded automated testing with new Vitest unit/integration coverage, an
|
|
21
|
+
SMTP harness, and fixture-backed template/asset scenarios.
|
|
22
|
+
- Updated the client library/CLI to support transactional/form uploads,
|
|
23
|
+
attachments, and config-driven bulk pushes.
|
|
24
|
+
|
|
1
25
|
Version 1.0.9 (2026-01-02)
|
|
2
26
|
|
|
3
27
|
- Default CONFIG_PATH to ./data and document the data-rooted layout, including
|
package/README.md
CHANGED
|
@@ -9,6 +9,7 @@ Mail Magic is a TypeScript service for managing, templating, and delivering tran
|
|
|
9
9
|
- Nodemailer transport configuration driven by environment variables
|
|
10
10
|
- SQLite-backed data models for domains, users, forms, and templates
|
|
11
11
|
- Type-safe configuration loader powered by `@technomoron/env-loader`
|
|
12
|
+
- Bundled admin UI (placeholder) served at the root path `/`
|
|
12
13
|
|
|
13
14
|
## Getting Started
|
|
14
15
|
|
|
@@ -30,6 +31,10 @@ During development you can run `npm run dev` for a watch mode that recompiles on
|
|
|
30
31
|
|
|
31
32
|
When `DB_AUTO_RELOAD` is enabled the service watches `init-data.json` and refreshes templates and forms without a restart.
|
|
32
33
|
|
|
34
|
+
### Admin UI
|
|
35
|
+
|
|
36
|
+
The server mounts the admin UI at `/` when the `@technomoron/mail-magic-admin` package is installed. This is a placeholder Vue app today, but it is already wired so future admin features can live there without changing the server routing.
|
|
37
|
+
|
|
33
38
|
### Template assets and inline resources
|
|
34
39
|
|
|
35
40
|
- Keep any non-inline files (images, attachments, etc.) under `<CONFIG_PATH>/<domain>/assets`. Mail Magic rewrites `asset('logo.png')` to the public route `/asset/<domain>/logo.png` (or whatever you set via `ASSET_ROUTE`).
|
package/dist/api/assets.js
CHANGED
|
@@ -1,10 +1,157 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import { ApiError, ApiModule } from '@technomoron/api-server-base';
|
|
4
|
+
import { api_domain } from '../models/domain.js';
|
|
5
|
+
import { api_form } from '../models/form.js';
|
|
6
|
+
import { api_txmail } from '../models/txmail.js';
|
|
7
|
+
import { api_user } from '../models/user.js';
|
|
4
8
|
import { decodeComponent, sendFileAsync } from '../util.js';
|
|
5
9
|
const DOMAIN_PATTERN = /^[a-z0-9][a-z0-9._-]*$/i;
|
|
6
10
|
const SEGMENT_PATTERN = /^[a-zA-Z0-9._-]+$/;
|
|
7
11
|
export class AssetAPI extends ApiModule {
|
|
12
|
+
getBodyValue(body, ...keys) {
|
|
13
|
+
for (const key of keys) {
|
|
14
|
+
const value = body[key];
|
|
15
|
+
if (Array.isArray(value) && value.length > 0) {
|
|
16
|
+
return String(value[0]);
|
|
17
|
+
}
|
|
18
|
+
if (value !== undefined && value !== null) {
|
|
19
|
+
return String(value);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return '';
|
|
23
|
+
}
|
|
24
|
+
async assertDomainAndUser(apireq) {
|
|
25
|
+
const domainName = this.getBodyValue(apireq.req.body ?? {}, 'domain');
|
|
26
|
+
if (!domainName) {
|
|
27
|
+
throw new ApiError({ code: 401, message: 'Missing domain' });
|
|
28
|
+
}
|
|
29
|
+
const user = await api_user.findOne({ where: { token: apireq.token } });
|
|
30
|
+
if (!user) {
|
|
31
|
+
throw new ApiError({ code: 401, message: `Invalid/Unknown API Key/Token '${apireq.token}'` });
|
|
32
|
+
}
|
|
33
|
+
const dbdomain = await api_domain.findOne({ where: { name: domainName } });
|
|
34
|
+
if (!dbdomain) {
|
|
35
|
+
throw new ApiError({ code: 401, message: `Unable to look up the domain ${domainName}` });
|
|
36
|
+
}
|
|
37
|
+
if (dbdomain.user_id !== user.user_id) {
|
|
38
|
+
throw new ApiError({ code: 403, message: `Domain ${domainName} is not owned by this user` });
|
|
39
|
+
}
|
|
40
|
+
apireq.domain = dbdomain;
|
|
41
|
+
apireq.user = user;
|
|
42
|
+
}
|
|
43
|
+
normalizeSubdir(value) {
|
|
44
|
+
if (!value) {
|
|
45
|
+
return '';
|
|
46
|
+
}
|
|
47
|
+
const cleaned = value.replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+$/, '');
|
|
48
|
+
if (!cleaned) {
|
|
49
|
+
return '';
|
|
50
|
+
}
|
|
51
|
+
const segments = cleaned.split('/').filter(Boolean);
|
|
52
|
+
for (const segment of segments) {
|
|
53
|
+
if (!SEGMENT_PATTERN.test(segment)) {
|
|
54
|
+
throw new ApiError({ code: 400, message: `Invalid path segment "${segment}"` });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return path.join(...segments);
|
|
58
|
+
}
|
|
59
|
+
async moveUploadedFiles(files, targetDir) {
|
|
60
|
+
await fs.promises.mkdir(targetDir, { recursive: true });
|
|
61
|
+
for (const file of files) {
|
|
62
|
+
const filename = path.basename(file.originalname || '');
|
|
63
|
+
if (!filename || !SEGMENT_PATTERN.test(filename)) {
|
|
64
|
+
throw new ApiError({ code: 400, message: `Invalid filename "${file.originalname}"` });
|
|
65
|
+
}
|
|
66
|
+
const destination = path.join(targetDir, filename);
|
|
67
|
+
if (destination === file.path) {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
await fs.promises.rename(file.path, destination);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
await fs.promises.copyFile(file.path, destination);
|
|
75
|
+
await fs.promises.unlink(file.path);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
async resolveTemplateDir(apireq) {
|
|
80
|
+
const body = apireq.req.body ?? {};
|
|
81
|
+
const templateTypeRaw = this.getBodyValue(body, 'templateType', 'template_type', 'type');
|
|
82
|
+
const templateName = this.getBodyValue(body, 'template', 'name', 'idname', 'formid');
|
|
83
|
+
const locale = this.getBodyValue(body, 'locale');
|
|
84
|
+
if (!templateTypeRaw) {
|
|
85
|
+
throw new ApiError({ code: 400, message: 'Missing templateType for template asset upload' });
|
|
86
|
+
}
|
|
87
|
+
if (!templateName) {
|
|
88
|
+
throw new ApiError({ code: 400, message: 'Missing template name/id for template asset upload' });
|
|
89
|
+
}
|
|
90
|
+
const templateType = templateTypeRaw.toLowerCase();
|
|
91
|
+
const domainId = apireq.domain.domain_id;
|
|
92
|
+
const deflocale = this.server.storage.deflocale || '';
|
|
93
|
+
if (templateType === 'tx') {
|
|
94
|
+
const template = (await api_txmail.findOne({ where: { name: templateName, domain_id: domainId, locale } })) ||
|
|
95
|
+
(await api_txmail.findOne({ where: { name: templateName, domain_id: domainId, locale: deflocale } })) ||
|
|
96
|
+
(await api_txmail.findOne({ where: { name: templateName, domain_id: domainId } }));
|
|
97
|
+
if (!template) {
|
|
98
|
+
throw new ApiError({
|
|
99
|
+
code: 404,
|
|
100
|
+
message: `Template "${templateName}" not found for domain "${apireq.domain.name}"`
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
const candidate = path.resolve(this.server.storage.configpath, template.filename);
|
|
104
|
+
const configRoot = this.server.storage.configpath;
|
|
105
|
+
const normalizedRoot = configRoot.endsWith(path.sep) ? configRoot : configRoot + path.sep;
|
|
106
|
+
if (!candidate.startsWith(normalizedRoot)) {
|
|
107
|
+
throw new ApiError({ code: 400, message: 'Template path escapes config root' });
|
|
108
|
+
}
|
|
109
|
+
return path.dirname(candidate);
|
|
110
|
+
}
|
|
111
|
+
if (templateType === 'form') {
|
|
112
|
+
const form = (await api_form.findOne({ where: { idname: templateName, domain_id: domainId, locale } })) ||
|
|
113
|
+
(await api_form.findOne({ where: { idname: templateName, domain_id: domainId, locale: deflocale } })) ||
|
|
114
|
+
(await api_form.findOne({ where: { idname: templateName, domain_id: domainId } }));
|
|
115
|
+
if (!form) {
|
|
116
|
+
throw new ApiError({
|
|
117
|
+
code: 404,
|
|
118
|
+
message: `Form "${templateName}" not found for domain "${apireq.domain.name}"`
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
const candidate = path.resolve(this.server.storage.configpath, form.filename);
|
|
122
|
+
const configRoot = this.server.storage.configpath;
|
|
123
|
+
const normalizedRoot = configRoot.endsWith(path.sep) ? configRoot : configRoot + path.sep;
|
|
124
|
+
if (!candidate.startsWith(normalizedRoot)) {
|
|
125
|
+
throw new ApiError({ code: 400, message: 'Template path escapes config root' });
|
|
126
|
+
}
|
|
127
|
+
return path.dirname(candidate);
|
|
128
|
+
}
|
|
129
|
+
throw new ApiError({ code: 400, message: 'templateType must be "tx" or "form"' });
|
|
130
|
+
}
|
|
131
|
+
async postAssets(apireq) {
|
|
132
|
+
await this.assertDomainAndUser(apireq);
|
|
133
|
+
const rawFiles = Array.isArray(apireq.req.files) ? apireq.req.files : [];
|
|
134
|
+
if (!rawFiles.length) {
|
|
135
|
+
throw new ApiError({ code: 400, message: 'No files uploaded' });
|
|
136
|
+
}
|
|
137
|
+
const body = apireq.req.body ?? {};
|
|
138
|
+
const subdir = this.normalizeSubdir(this.getBodyValue(body, 'path', 'dir'));
|
|
139
|
+
const templateType = this.getBodyValue(body, 'templateType', 'template_type', 'type');
|
|
140
|
+
let targetRoot;
|
|
141
|
+
if (templateType) {
|
|
142
|
+
targetRoot = await this.resolveTemplateDir(apireq);
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
targetRoot = path.join(this.server.storage.configpath, apireq.domain.name, 'assets');
|
|
146
|
+
}
|
|
147
|
+
const candidate = path.resolve(targetRoot, subdir);
|
|
148
|
+
const normalizedRoot = targetRoot.endsWith(path.sep) ? targetRoot : targetRoot + path.sep;
|
|
149
|
+
if (candidate !== targetRoot && !candidate.startsWith(normalizedRoot)) {
|
|
150
|
+
throw new ApiError({ code: 400, message: 'Invalid asset target path' });
|
|
151
|
+
}
|
|
152
|
+
await this.moveUploadedFiles(rawFiles, candidate);
|
|
153
|
+
return [200, { Status: 'OK' }];
|
|
154
|
+
}
|
|
8
155
|
async getAsset(apiReq) {
|
|
9
156
|
const domain = decodeComponent(apiReq.req.params.domain);
|
|
10
157
|
if (!domain || !DOMAIN_PATTERN.test(domain)) {
|
|
@@ -66,6 +213,12 @@ export class AssetAPI extends ApiModule {
|
|
|
66
213
|
const route = this.server.storage.env.ASSET_ROUTE;
|
|
67
214
|
const normalizedRoute = route.startsWith('/') ? route : `/${route}`;
|
|
68
215
|
return [
|
|
216
|
+
{
|
|
217
|
+
method: 'post',
|
|
218
|
+
path: '/v1/assets',
|
|
219
|
+
handler: (apiReq) => this.postAssets(apiReq),
|
|
220
|
+
auth: { type: 'yes', req: 'any' }
|
|
221
|
+
},
|
|
69
222
|
{
|
|
70
223
|
method: 'get',
|
|
71
224
|
path: `${normalizedRoute}/:domain/*`,
|
package/dist/api/forms.js
CHANGED
|
@@ -144,6 +144,8 @@ export class FormAPI extends ApiModule {
|
|
|
144
144
|
console.log('Files:', JSON.stringify(apireq.req.files, null, 2));
|
|
145
145
|
*/
|
|
146
146
|
const rawFiles = Array.isArray(apireq.req.files) ? apireq.req.files : [];
|
|
147
|
+
const domainRecord = await api_domain.findOne({ where: { domain_id: form.domain_id } });
|
|
148
|
+
await this.server.storage.relocateUploads(domainRecord?.name ?? null, rawFiles);
|
|
147
149
|
const attachments = rawFiles.map((file) => ({
|
|
148
150
|
filename: file.originalname,
|
|
149
151
|
path: file.path
|
package/dist/api/mailer.js
CHANGED
|
@@ -146,6 +146,7 @@ export class MailerAPI extends ApiModule {
|
|
|
146
146
|
throw new ApiError({ code: 500, message: `Unable to locate sender for ${template.name}` });
|
|
147
147
|
}
|
|
148
148
|
const rawFiles = Array.isArray(apireq.req.files) ? apireq.req.files : [];
|
|
149
|
+
await this.server.storage.relocateUploads(apireq.domain?.name ?? null, rawFiles);
|
|
149
150
|
const templateAssets = Array.isArray(template.files) ? template.files : [];
|
|
150
151
|
const attachments = [
|
|
151
152
|
...templateAssets.map((file) => ({
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import process from 'node:process';
|
|
5
|
+
import dotenv from 'dotenv';
|
|
6
|
+
import { startMailMagicServer } from '../index.js';
|
|
7
|
+
const args = process.argv.slice(2);
|
|
8
|
+
function usage(exitCode = 0) {
|
|
9
|
+
const out = exitCode === 0 ? process.stdout : process.stderr;
|
|
10
|
+
out.write(`Usage: mail-magic [--env PATH]\n\n` +
|
|
11
|
+
`Options:\n` +
|
|
12
|
+
` -e, --env PATH Path to .env (defaults to ./.env)\n` +
|
|
13
|
+
` -h, --help Show this help\n`);
|
|
14
|
+
process.exit(exitCode);
|
|
15
|
+
}
|
|
16
|
+
let envPath;
|
|
17
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
18
|
+
const arg = args[i];
|
|
19
|
+
if (arg === '-h' || arg === '--help') {
|
|
20
|
+
usage(0);
|
|
21
|
+
}
|
|
22
|
+
if (arg === '-e' || arg === '--env') {
|
|
23
|
+
const next = args[i + 1];
|
|
24
|
+
if (!next) {
|
|
25
|
+
console.error('Error: --env requires a path');
|
|
26
|
+
usage(1);
|
|
27
|
+
}
|
|
28
|
+
envPath = next;
|
|
29
|
+
i += 1;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (arg.startsWith('--env=')) {
|
|
33
|
+
envPath = arg.slice('--env='.length);
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
console.error(`Error: unknown option ${arg}`);
|
|
37
|
+
usage(1);
|
|
38
|
+
}
|
|
39
|
+
const resolvedEnvPath = path.resolve(envPath || '.env');
|
|
40
|
+
if (!fs.existsSync(resolvedEnvPath)) {
|
|
41
|
+
console.error(`Error: env file not found at ${resolvedEnvPath}`);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
process.chdir(path.dirname(resolvedEnvPath));
|
|
45
|
+
const result = dotenv.config({ path: resolvedEnvPath });
|
|
46
|
+
if (result.error) {
|
|
47
|
+
console.error('Error: failed to load env file');
|
|
48
|
+
console.error(result.error);
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
async function main() {
|
|
52
|
+
try {
|
|
53
|
+
const { store, env } = await startMailMagicServer();
|
|
54
|
+
console.log(`Using config path: ${store.configpath}`);
|
|
55
|
+
console.log(`mail-magic server listening on ${env.API_HOST}:${env.API_PORT}`);
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
console.error('Failed to start mail-magic server');
|
|
59
|
+
console.error(error);
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
await main();
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
2
5
|
import { AssetAPI } from './api/assets.js';
|
|
3
6
|
import { FormAPI } from './api/forms.js';
|
|
4
7
|
import { MailerAPI } from './api/mailer.js';
|
|
@@ -9,7 +12,7 @@ function buildServerConfig(store, overrides) {
|
|
|
9
12
|
return {
|
|
10
13
|
apiHost: env.API_HOST,
|
|
11
14
|
apiPort: env.API_PORT,
|
|
12
|
-
uploadPath:
|
|
15
|
+
uploadPath: store.getUploadStagingPath(),
|
|
13
16
|
debug: env.DEBUG,
|
|
14
17
|
apiBasePath: '',
|
|
15
18
|
swaggerEnabled: env.SWAGGER_ENABLED,
|
|
@@ -21,6 +24,7 @@ export async function createMailMagicServer(overrides = {}) {
|
|
|
21
24
|
const store = await new mailStore().init();
|
|
22
25
|
const config = buildServerConfig(store, overrides);
|
|
23
26
|
const server = new mailApiServer(config, store).api(new MailerAPI()).api(new FormAPI()).api(new AssetAPI());
|
|
27
|
+
mountAdminUi(server, store);
|
|
24
28
|
return { server, store, env: store.env };
|
|
25
29
|
}
|
|
26
30
|
export async function startMailMagicServer(overrides = {}) {
|
|
@@ -52,3 +56,58 @@ const isDirectExecution = (() => {
|
|
|
52
56
|
if (isDirectExecution) {
|
|
53
57
|
void bootMailMagic();
|
|
54
58
|
}
|
|
59
|
+
function resolveAdminDist() {
|
|
60
|
+
const require = createRequire(import.meta.url);
|
|
61
|
+
try {
|
|
62
|
+
const pkgPath = require.resolve('@technomoron/mail-magic-admin/package.json');
|
|
63
|
+
const pkgDir = path.dirname(pkgPath);
|
|
64
|
+
const distPath = path.join(pkgDir, 'dist');
|
|
65
|
+
if (fs.existsSync(distPath)) {
|
|
66
|
+
return distPath;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// ignore
|
|
71
|
+
}
|
|
72
|
+
const fallbackBase = path.dirname(fileURLToPath(import.meta.url));
|
|
73
|
+
const fallback = path.resolve(fallbackBase, '..', '..', 'mail-magic-admin', 'dist');
|
|
74
|
+
if (fs.existsSync(fallback)) {
|
|
75
|
+
return fallback;
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
function mountAdminUi(server, store) {
|
|
80
|
+
const distPath = resolveAdminDist();
|
|
81
|
+
if (!distPath) {
|
|
82
|
+
store.print_debug('Admin UI not found, skipping static mount');
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const assetRoute = store.env.ASSET_ROUTE.startsWith('/') ? store.env.ASSET_ROUTE : `/${store.env.ASSET_ROUTE}`;
|
|
86
|
+
const indexPath = path.join(distPath, 'index.html');
|
|
87
|
+
const hasIndex = fs.existsSync(indexPath);
|
|
88
|
+
server.app.get('*', (req, res, next) => {
|
|
89
|
+
if (req.method !== 'GET') {
|
|
90
|
+
next();
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (req.path.startsWith('/api') || req.path.startsWith(assetRoute)) {
|
|
94
|
+
next();
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const requestPath = req.path === '/' ? 'index.html' : req.path.replace(/^\//, '');
|
|
98
|
+
const resolvedPath = path.resolve(distPath, requestPath);
|
|
99
|
+
if (!resolvedPath.startsWith(`${distPath}${path.sep}`) && resolvedPath !== distPath) {
|
|
100
|
+
next();
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (fs.existsSync(resolvedPath) && fs.statSync(resolvedPath).isFile()) {
|
|
104
|
+
res.sendFile(resolvedPath);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (!hasIndex) {
|
|
108
|
+
next();
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
res.sendFile(indexPath);
|
|
112
|
+
});
|
|
113
|
+
}
|
package/dist/store/envloader.js
CHANGED
|
@@ -43,7 +43,7 @@ export const envOptions = defineEnvOptions({
|
|
|
43
43
|
},
|
|
44
44
|
CONFIG_PATH: {
|
|
45
45
|
description: 'Path to directory where config files are located',
|
|
46
|
-
default: './
|
|
46
|
+
default: './data/'
|
|
47
47
|
},
|
|
48
48
|
DB_USER: {
|
|
49
49
|
description: 'Database username for API database'
|
|
@@ -102,7 +102,7 @@ export const envOptions = defineEnvOptions({
|
|
|
102
102
|
default: ''
|
|
103
103
|
},
|
|
104
104
|
UPLOAD_PATH: {
|
|
105
|
-
description: 'Path for attached files',
|
|
106
|
-
default: './uploads
|
|
105
|
+
description: 'Path for attached files. Use {domain} to scope per domain.',
|
|
106
|
+
default: './{domain}/uploads'
|
|
107
107
|
}
|
|
108
108
|
});
|
package/dist/store/store.js
CHANGED
|
@@ -38,6 +38,8 @@ export class mailStore {
|
|
|
38
38
|
keys = {};
|
|
39
39
|
configpath = '';
|
|
40
40
|
deflocale;
|
|
41
|
+
uploadTemplate;
|
|
42
|
+
uploadStagingPath;
|
|
41
43
|
print_debug(msg) {
|
|
42
44
|
if (this.env.DEBUG) {
|
|
43
45
|
console.log(msg);
|
|
@@ -46,6 +48,59 @@ export class mailStore {
|
|
|
46
48
|
config_filename(name) {
|
|
47
49
|
return path.resolve(path.join(this.configpath, name));
|
|
48
50
|
}
|
|
51
|
+
resolveUploadPath(domainName) {
|
|
52
|
+
const raw = this.env.UPLOAD_PATH ?? '';
|
|
53
|
+
const hasDomainToken = raw.includes('{domain}');
|
|
54
|
+
const expanded = hasDomainToken && domainName ? raw.replaceAll('{domain}', domainName) : raw;
|
|
55
|
+
if (!expanded) {
|
|
56
|
+
return '';
|
|
57
|
+
}
|
|
58
|
+
if (path.isAbsolute(expanded)) {
|
|
59
|
+
return expanded;
|
|
60
|
+
}
|
|
61
|
+
const base = hasDomainToken ? this.configpath : process.cwd();
|
|
62
|
+
return path.resolve(base, expanded);
|
|
63
|
+
}
|
|
64
|
+
getUploadStagingPath() {
|
|
65
|
+
if (!this.env.UPLOAD_PATH) {
|
|
66
|
+
return '';
|
|
67
|
+
}
|
|
68
|
+
if (this.uploadTemplate) {
|
|
69
|
+
return this.uploadStagingPath || path.resolve(this.configpath, '_uploads');
|
|
70
|
+
}
|
|
71
|
+
return this.resolveUploadPath();
|
|
72
|
+
}
|
|
73
|
+
async relocateUploads(domainName, files) {
|
|
74
|
+
if (!this.uploadTemplate || !domainName || !files?.length) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const targetDir = this.resolveUploadPath(domainName);
|
|
78
|
+
if (!targetDir) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
await fs.promises.mkdir(targetDir, { recursive: true });
|
|
82
|
+
await Promise.all(files.map(async (file) => {
|
|
83
|
+
if (!file?.path) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const basename = path.basename(file.path);
|
|
87
|
+
const destination = path.join(targetDir, basename);
|
|
88
|
+
if (destination === file.path) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
await fs.promises.rename(file.path, destination);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
await fs.promises.copyFile(file.path, destination);
|
|
96
|
+
await fs.promises.unlink(file.path);
|
|
97
|
+
}
|
|
98
|
+
file.path = destination;
|
|
99
|
+
if (file.destination !== undefined) {
|
|
100
|
+
file.destination = targetDir;
|
|
101
|
+
}
|
|
102
|
+
}));
|
|
103
|
+
}
|
|
49
104
|
async load_api_keys(cfgpath) {
|
|
50
105
|
const keyfile = path.resolve(cfgpath, 'api-keys.json');
|
|
51
106
|
if (fs.existsSync(keyfile)) {
|
|
@@ -61,8 +116,18 @@ export class mailStore {
|
|
|
61
116
|
const env = (this.env = await EnvLoader.createConfigProxy(envOptions, { debug: true }));
|
|
62
117
|
EnvLoader.genTemplate(envOptions, '.env-dist');
|
|
63
118
|
const p = env.CONFIG_PATH;
|
|
64
|
-
this.configpath = path.isAbsolute(p) ? p : path.
|
|
119
|
+
this.configpath = path.isAbsolute(p) ? p : path.resolve(process.cwd(), p);
|
|
65
120
|
console.log(`Config path is ${this.configpath}`);
|
|
121
|
+
if (env.UPLOAD_PATH && env.UPLOAD_PATH.includes('{domain}')) {
|
|
122
|
+
this.uploadTemplate = env.UPLOAD_PATH;
|
|
123
|
+
this.uploadStagingPath = path.resolve(this.configpath, '_uploads');
|
|
124
|
+
try {
|
|
125
|
+
fs.mkdirSync(this.uploadStagingPath, { recursive: true });
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
this.print_debug(`Unable to create upload staging path: ${err}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
66
131
|
// this.keys = await this.load_api_keys(this.configpath);
|
|
67
132
|
this.transport = await create_mail_transport(env);
|
|
68
133
|
this.api_db = await connect_api_db(this);
|
package/package.json
CHANGED
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@technomoron/mail-magic",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.12",
|
|
4
4
|
"main": "dist/index.js",
|
|
5
5
|
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"mail-magic": "dist/bin/mail-magic.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"README.md",
|
|
12
|
+
"CHANGES",
|
|
13
|
+
"TUTORIAL.MD"
|
|
14
|
+
],
|
|
6
15
|
"repository": {
|
|
7
16
|
"type": "git",
|
|
8
|
-
"url": "git+https://github.com/technomoron/mail-magic.git"
|
|
17
|
+
"url": "git+https://github.com/technomoron/mail-magic.git",
|
|
18
|
+
"directory": "packages/mail-magic"
|
|
9
19
|
},
|
|
10
20
|
"pnpm": {
|
|
11
21
|
"onlyBuiltDependencies": [
|
|
@@ -20,6 +30,8 @@
|
|
|
20
30
|
"dev": "NODE_ENV=development nodemon --watch 'src/**/*.ts' --watch 'config/**/*.*' --watch '.env' --exec 'tsx' src/index.ts",
|
|
21
31
|
"run": "NODE_ENV=production npm run start",
|
|
22
32
|
"build": "tsc",
|
|
33
|
+
"postbuild": "node scripts/add-shebang.cjs",
|
|
34
|
+
"prepack": "npm run build",
|
|
23
35
|
"test": "vitest run",
|
|
24
36
|
"test:watch": "vitest",
|
|
25
37
|
"scrub": "rm -rf ./node_modules/ ./dist/ pnpm-lock.yaml",
|
|
@@ -42,6 +54,7 @@
|
|
|
42
54
|
"@technomoron/env-loader": "^1.0.8",
|
|
43
55
|
"@technomoron/unyuck": "^1.0.4",
|
|
44
56
|
"bcryptjs": "^3.0.2",
|
|
57
|
+
"dotenv": "^16.4.5",
|
|
45
58
|
"email-addresses": "^5.0.0",
|
|
46
59
|
"html-to-text": "^9.0.5",
|
|
47
60
|
"nodemailer": "^6.10.1",
|
|
@@ -69,7 +82,7 @@
|
|
|
69
82
|
"smtp-server": "^3.18.0",
|
|
70
83
|
"supertest": "^7.1.4",
|
|
71
84
|
"tsx": "^4.20.5",
|
|
72
|
-
"typescript": "^5.9.
|
|
85
|
+
"typescript": "^5.9.3",
|
|
73
86
|
"vitest": "^4.0.16"
|
|
74
87
|
},
|
|
75
88
|
"homepage": "https://github.com/technomoron/mail-magic#readme",
|
package/.do-realease.sh
DELETED
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
#!/bin/sh
|
|
2
|
-
|
|
3
|
-
VERSION=$(node -p "require('./package.json').version")
|
|
4
|
-
|
|
5
|
-
echo "Creating release for ${VERSION}"
|
|
6
|
-
|
|
7
|
-
if [ -n "$(git status --porcelain)" ]; then
|
|
8
|
-
echo "Working tree is not clean. Commit or stash changes before release." >&2
|
|
9
|
-
exit 1
|
|
10
|
-
fi
|
|
11
|
-
|
|
12
|
-
UPSTREAM=$(git rev-parse --abbrev-ref --symbolic-full-name @{u} 2>/dev/null || true)
|
|
13
|
-
if [ -z "$UPSTREAM" ]; then
|
|
14
|
-
echo "No upstream configured for $(git rev-parse --abbrev-ref HEAD). Set upstream before release." >&2
|
|
15
|
-
exit 1
|
|
16
|
-
fi
|
|
17
|
-
|
|
18
|
-
if ! git fetch --quiet; then
|
|
19
|
-
echo "Failed to fetch remote updates. Check your network or remote access." >&2
|
|
20
|
-
exit 1
|
|
21
|
-
fi
|
|
22
|
-
|
|
23
|
-
set -- $(git rev-list --left-right --count "${UPSTREAM}...HEAD")
|
|
24
|
-
BEHIND_COUNT=$1
|
|
25
|
-
AHEAD_COUNT=$2
|
|
26
|
-
|
|
27
|
-
if [ "$BEHIND_COUNT" -ne 0 ] || [ "$AHEAD_COUNT" -ne 0 ]; then
|
|
28
|
-
echo "Branch is not in sync with ${UPSTREAM} (behind ${BEHIND_COUNT}, ahead ${AHEAD_COUNT})." >&2
|
|
29
|
-
echo "Pull/push until the branch matches upstream before release." >&2
|
|
30
|
-
exit 1
|
|
31
|
-
fi
|
|
32
|
-
|
|
33
|
-
if ! npm whoami >/dev/null 2>&1; then
|
|
34
|
-
echo "Not logged into npm. Run 'npm login' before release." >&2
|
|
35
|
-
exit 1
|
|
36
|
-
fi
|
|
37
|
-
|
|
38
|
-
if git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then
|
|
39
|
-
echo "Tag v${VERSION} already exists. Aborting." >&2
|
|
40
|
-
exit 1
|
|
41
|
-
fi
|
|
42
|
-
|
|
43
|
-
git tag -a "v${VERSION}" -m "Release version ${VERSION}"
|
|
44
|
-
git push origin "v${VERSION}"
|
|
45
|
-
|
|
46
|
-
# detect prerelease versions (contains a hyphen)
|
|
47
|
-
if echo "$VERSION" | grep -q "-"; then
|
|
48
|
-
TAG=$(echo "$VERSION" | sed 's/^[0-9.]*-\([a-zA-Z0-9]*\).*/\1/')
|
|
49
|
-
echo "Detected prerelease. Publishing with tag '$TAG'"
|
|
50
|
-
npm publish --tag "$TAG" --access=public
|
|
51
|
-
else
|
|
52
|
-
echo "Stable release. Publishing as latest"
|
|
53
|
-
npm publish --access=public
|
|
54
|
-
fi
|
package/.editorconfig
DELETED
package/.env-dist
DELETED
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
# Specifies the environment in which the app is running Possible values: development, production, staging
|
|
2
|
-
NODE_ENV=development
|
|
3
|
-
|
|
4
|
-
# Defines the port on which the app listens. Default 3780 [number]
|
|
5
|
-
API_PORT=3776
|
|
6
|
-
|
|
7
|
-
# Sets the local IP address for the API to listen at
|
|
8
|
-
API_HOST=0.0.0.0
|
|
9
|
-
|
|
10
|
-
# Reload init-data.db automatically on change [boolean]
|
|
11
|
-
DB_AUTO_RELOAD=true
|
|
12
|
-
|
|
13
|
-
# Whether to force sync on table definitions (ALTER TABLE) [boolean]
|
|
14
|
-
DB_FORCE_SYNC=false
|
|
15
|
-
|
|
16
|
-
# Sets the public URL for the API (i.e. https://ml.example.com:3790)
|
|
17
|
-
API_URL=http://localhost:3776
|
|
18
|
-
|
|
19
|
-
# Enable the Swagger/OpenAPI endpoint [boolean]
|
|
20
|
-
SWAGGER_ENABLED=false
|
|
21
|
-
|
|
22
|
-
# Path to expose the Swagger/OpenAPI spec (default: /api/swagger when enabled)
|
|
23
|
-
SWAGGER_PATH=
|
|
24
|
-
|
|
25
|
-
# Route prefix exposed for config assets
|
|
26
|
-
ASSET_ROUTE=/asset
|
|
27
|
-
|
|
28
|
-
# Path to directory where config files are located
|
|
29
|
-
CONFIG_PATH=./data/
|
|
30
|
-
|
|
31
|
-
# Database username for API database
|
|
32
|
-
DB_USER=
|
|
33
|
-
|
|
34
|
-
# Password for API database
|
|
35
|
-
DB_PASS=
|
|
36
|
-
|
|
37
|
-
# Name of API database. Filename for sqlite3, database name for others
|
|
38
|
-
DB_NAME=maildata
|
|
39
|
-
|
|
40
|
-
# Host of API database
|
|
41
|
-
DB_HOST=localhost
|
|
42
|
-
|
|
43
|
-
# Database type of WP database Possible values: sqlite
|
|
44
|
-
DB_TYPE=sqlite
|
|
45
|
-
|
|
46
|
-
# Log SQL statements [boolean]
|
|
47
|
-
DB_LOG=false
|
|
48
|
-
|
|
49
|
-
# Enable debug output, including nodemailer and API [boolean]
|
|
50
|
-
DEBUG=false
|
|
51
|
-
|
|
52
|
-
# Hostname of SMTP sending host
|
|
53
|
-
SMTP_HOST=localhost
|
|
54
|
-
|
|
55
|
-
# SMTP host server port [number]
|
|
56
|
-
SMTP_PORT=587
|
|
57
|
-
|
|
58
|
-
# Use secure connection to SMTP host (SSL/TSL) [boolean]
|
|
59
|
-
SMTP_SECURE=false
|
|
60
|
-
|
|
61
|
-
# Reject bad cert/TLS connection to SMTP host [boolean]
|
|
62
|
-
SMTP_TLS_REJECT=false
|
|
63
|
-
|
|
64
|
-
# Username for SMTP host
|
|
65
|
-
SMTP_USER=
|
|
66
|
-
|
|
67
|
-
# Password for SMTP host
|
|
68
|
-
SMTP_PASSWORD=
|
|
69
|
-
|
|
70
|
-
# Path for attached files. Use {domain} to scope per domain.
|
|
71
|
-
UPLOAD_PATH=./{domain}/uploads
|