@technomoron/mail-magic 1.0.8 → 1.0.9
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 +5 -0
- package/.env-dist +3 -3
- package/CHANGES +8 -0
- package/README.md +4 -3
- package/TUTORIAL.MD +1 -0
- package/package.json +1 -1
- package/src/api/forms.ts +2 -0
- package/src/api/mailer.ts +1 -0
- package/src/index.ts +1 -1
- package/src/store/envloader.ts +3 -3
- package/src/store/store.ts +78 -1
- package/tests/helpers/test-setup.ts +4 -3
- package/tests/mail-magic.test.ts +17 -0
package/.do-realease.sh
CHANGED
|
@@ -30,6 +30,11 @@ if [ "$BEHIND_COUNT" -ne 0 ] || [ "$AHEAD_COUNT" -ne 0 ]; then
|
|
|
30
30
|
exit 1
|
|
31
31
|
fi
|
|
32
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
|
+
|
|
33
38
|
if git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then
|
|
34
39
|
echo "Tag v${VERSION} already exists. Aborting." >&2
|
|
35
40
|
exit 1
|
package/.env-dist
CHANGED
|
@@ -26,7 +26,7 @@ SWAGGER_PATH=
|
|
|
26
26
|
ASSET_ROUTE=/asset
|
|
27
27
|
|
|
28
28
|
# Path to directory where config files are located
|
|
29
|
-
CONFIG_PATH=./
|
|
29
|
+
CONFIG_PATH=./data/
|
|
30
30
|
|
|
31
31
|
# Database username for API database
|
|
32
32
|
DB_USER=
|
|
@@ -67,5 +67,5 @@ SMTP_USER=
|
|
|
67
67
|
# Password for SMTP host
|
|
68
68
|
SMTP_PASSWORD=
|
|
69
69
|
|
|
70
|
-
# Path for attached files
|
|
71
|
-
UPLOAD_PATH=./uploads
|
|
70
|
+
# Path for attached files. Use {domain} to scope per domain.
|
|
71
|
+
UPLOAD_PATH=./{domain}/uploads
|
package/CHANGES
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
Version 1.0.9 (2026-01-02)
|
|
2
|
+
|
|
3
|
+
- Default CONFIG_PATH to ./data and document the data-rooted layout, including
|
|
4
|
+
per-domain uploads.
|
|
5
|
+
- Allow UPLOAD_PATH to include {domain}, staging incoming uploads under
|
|
6
|
+
<CONFIG_PATH>/_uploads before relocating to <CONFIG_PATH>/<domain>/uploads
|
|
7
|
+
for transactional and form submissions.
|
|
8
|
+
|
|
1
9
|
Version 1.0.7 (2026-01-01)
|
|
2
10
|
|
|
3
11
|
- Harden asset serving and template resolution against traversal/symlink escapes by
|
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@ Mail Magic is a TypeScript service for managing, templating, and delivering tran
|
|
|
15
15
|
1. Clone the repository: `git clone git@github.com:technomoron/mail-magic.git`
|
|
16
16
|
2. Install dependencies: `npm install`
|
|
17
17
|
3. Create your environment file: copy `.env-dist` to `.env` and adjust values
|
|
18
|
-
4. Populate the config directory (see `config-example/` for a reference layout)
|
|
18
|
+
4. Populate the config directory (defaults to `./data/`; see `config-example/` for a reference layout). You can point `CONFIG_PATH` at `./config` to use the bundled sample data.
|
|
19
19
|
5. Build the project: `npm run build`
|
|
20
20
|
6. Start the API server: `npm run start`
|
|
21
21
|
|
|
@@ -24,14 +24,15 @@ During development you can run `npm run dev` for a watch mode that recompiles on
|
|
|
24
24
|
## Configuration
|
|
25
25
|
|
|
26
26
|
- **Environment variables** are defined in `src/store/envloader.ts`. Important settings include SMTP credentials, API host/port, the config directory path, and database options.
|
|
27
|
-
- **Config directory** (`CONFIG_PATH`) contains JSON seed data (`init-data.json`), optional API key files, and template assets. Each domain now lives directly under the config root (for example `
|
|
27
|
+
- **Config directory** (`CONFIG_PATH`) contains JSON seed data (`init-data.json`), optional API key files, and template assets. Each domain now lives directly under the config root (for example `data/example.com/form-template/…`). Use an absolute path or a relative one like `../data` when you want the config outside the repo. Review `config-example/` for the recommended layout, in particular the `form-template/` and `tx-template/` folders used for compiled Nunjucks templates.
|
|
28
28
|
- **Database** defaults to SQLite (`maildata.db`). You can switch dialects by updating the environment options if your deployment requires another database.
|
|
29
|
+
- **Uploads** default to `<CONFIG_PATH>/<domain>/uploads` via `UPLOAD_PATH=./{domain}/uploads`. Set a fixed path if you prefer a shared upload directory.
|
|
29
30
|
|
|
30
31
|
When `DB_AUTO_RELOAD` is enabled the service watches `init-data.json` and refreshes templates and forms without a restart.
|
|
31
32
|
|
|
32
33
|
### Template assets and inline resources
|
|
33
34
|
|
|
34
|
-
- Keep any non-inline files (images, attachments, etc.) under
|
|
35
|
+
- 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`).
|
|
35
36
|
- Pass `true` as the second argument when you want to embed a file as an inline CID attachment: `asset('logo.png', true)` stores the file in Nodemailer and rewrites the HTML to reference `cid:logo.png`.
|
|
36
37
|
- Avoid mixing template-type folders for assets; everything that should be linked externally belongs in the shared `<domain>/assets` tree so it can be served for both form and transactional templates.
|
|
37
38
|
|
package/TUTORIAL.MD
CHANGED
|
@@ -19,6 +19,7 @@ Update your `.env` (or runtime environment) to point at the new workspace:
|
|
|
19
19
|
```
|
|
20
20
|
CONFIG_PATH=${CONFIG_ROOT}
|
|
21
21
|
DB_AUTO_RELOAD=1 # optional: hot‑reload init-data and templates
|
|
22
|
+
UPLOAD_PATH=./{domain}/uploads
|
|
22
23
|
```
|
|
23
24
|
|
|
24
25
|
From now on the tutorial assumes `${CONFIG_ROOT}` is the root of the custom config tree.
|
package/package.json
CHANGED
package/src/api/forms.ts
CHANGED
|
@@ -172,6 +172,8 @@ export class FormAPI extends ApiModule<mailApiServer> {
|
|
|
172
172
|
*/
|
|
173
173
|
|
|
174
174
|
const rawFiles = Array.isArray(apireq.req.files) ? (apireq.req.files as UploadedFile[]) : [];
|
|
175
|
+
const domainRecord = await api_domain.findOne({ where: { domain_id: form.domain_id } });
|
|
176
|
+
await this.server.storage.relocateUploads(domainRecord?.name ?? null, rawFiles);
|
|
175
177
|
const attachments = rawFiles.map((file) => ({
|
|
176
178
|
filename: file.originalname,
|
|
177
179
|
path: file.path
|
package/src/api/mailer.ts
CHANGED
|
@@ -170,6 +170,7 @@ export class MailerAPI extends ApiModule<mailApiServer> {
|
|
|
170
170
|
}
|
|
171
171
|
|
|
172
172
|
const rawFiles = Array.isArray(apireq.req.files) ? (apireq.req.files as UploadedFile[]) : [];
|
|
173
|
+
await this.server.storage.relocateUploads(apireq.domain?.name ?? null, rawFiles);
|
|
173
174
|
const templateAssets = Array.isArray(template.files) ? template.files : [];
|
|
174
175
|
const attachments = [
|
|
175
176
|
...templateAssets.map((file) => ({
|
package/src/index.ts
CHANGED
|
@@ -21,7 +21,7 @@ function buildServerConfig(store: mailStore, overrides: MailMagicServerOptions):
|
|
|
21
21
|
return {
|
|
22
22
|
apiHost: env.API_HOST,
|
|
23
23
|
apiPort: env.API_PORT,
|
|
24
|
-
uploadPath:
|
|
24
|
+
uploadPath: store.getUploadStagingPath(),
|
|
25
25
|
debug: env.DEBUG,
|
|
26
26
|
apiBasePath: '',
|
|
27
27
|
swaggerEnabled: env.SWAGGER_ENABLED,
|
package/src/store/envloader.ts
CHANGED
|
@@ -44,7 +44,7 @@ export const envOptions = defineEnvOptions({
|
|
|
44
44
|
},
|
|
45
45
|
CONFIG_PATH: {
|
|
46
46
|
description: 'Path to directory where config files are located',
|
|
47
|
-
default: './
|
|
47
|
+
default: './data/'
|
|
48
48
|
},
|
|
49
49
|
DB_USER: {
|
|
50
50
|
description: 'Database username for API database'
|
|
@@ -103,7 +103,7 @@ export const envOptions = defineEnvOptions({
|
|
|
103
103
|
default: ''
|
|
104
104
|
},
|
|
105
105
|
UPLOAD_PATH: {
|
|
106
|
-
description: 'Path for attached files',
|
|
107
|
-
default: './uploads
|
|
106
|
+
description: 'Path for attached files. Use {domain} to scope per domain.',
|
|
107
|
+
default: './{domain}/uploads'
|
|
108
108
|
}
|
|
109
109
|
});
|
package/src/store/store.ts
CHANGED
|
@@ -18,6 +18,12 @@ interface api_key {
|
|
|
18
18
|
domain: number;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
type UploadedFile = {
|
|
22
|
+
path: string;
|
|
23
|
+
filename?: string;
|
|
24
|
+
destination?: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
21
27
|
function create_mail_transport(env: envConfig<typeof envOptions>): Transporter {
|
|
22
28
|
const args: SMTPTransport.Options = {
|
|
23
29
|
host: env.SMTP_HOST,
|
|
@@ -52,6 +58,8 @@ export interface ImailStore {
|
|
|
52
58
|
keys: Record<string, api_key>;
|
|
53
59
|
configpath: string;
|
|
54
60
|
deflocale?: string;
|
|
61
|
+
uploadTemplate?: string;
|
|
62
|
+
uploadStagingPath?: string;
|
|
55
63
|
}
|
|
56
64
|
|
|
57
65
|
export class mailStore implements ImailStore {
|
|
@@ -61,6 +69,8 @@ export class mailStore implements ImailStore {
|
|
|
61
69
|
keys: Record<string, api_key> = {};
|
|
62
70
|
configpath = '';
|
|
63
71
|
deflocale?: string;
|
|
72
|
+
uploadTemplate?: string;
|
|
73
|
+
uploadStagingPath?: string;
|
|
64
74
|
|
|
65
75
|
print_debug(msg: string) {
|
|
66
76
|
if (this.env.DEBUG) {
|
|
@@ -72,6 +82,63 @@ export class mailStore implements ImailStore {
|
|
|
72
82
|
return path.resolve(path.join(this.configpath, name));
|
|
73
83
|
}
|
|
74
84
|
|
|
85
|
+
resolveUploadPath(domainName?: string): string {
|
|
86
|
+
const raw = this.env.UPLOAD_PATH ?? '';
|
|
87
|
+
const hasDomainToken = raw.includes('{domain}');
|
|
88
|
+
const expanded = hasDomainToken && domainName ? raw.replaceAll('{domain}', domainName) : raw;
|
|
89
|
+
if (!expanded) {
|
|
90
|
+
return '';
|
|
91
|
+
}
|
|
92
|
+
if (path.isAbsolute(expanded)) {
|
|
93
|
+
return expanded;
|
|
94
|
+
}
|
|
95
|
+
const base = hasDomainToken ? this.configpath : process.cwd();
|
|
96
|
+
return path.resolve(base, expanded);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
getUploadStagingPath(): string {
|
|
100
|
+
if (!this.env.UPLOAD_PATH) {
|
|
101
|
+
return '';
|
|
102
|
+
}
|
|
103
|
+
if (this.uploadTemplate) {
|
|
104
|
+
return this.uploadStagingPath || path.resolve(this.configpath, '_uploads');
|
|
105
|
+
}
|
|
106
|
+
return this.resolveUploadPath();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async relocateUploads(domainName: string | null, files: UploadedFile[]): Promise<void> {
|
|
110
|
+
if (!this.uploadTemplate || !domainName || !files?.length) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const targetDir = this.resolveUploadPath(domainName);
|
|
114
|
+
if (!targetDir) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
await fs.promises.mkdir(targetDir, { recursive: true });
|
|
118
|
+
await Promise.all(
|
|
119
|
+
files.map(async (file) => {
|
|
120
|
+
if (!file?.path) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const basename = path.basename(file.path);
|
|
124
|
+
const destination = path.join(targetDir, basename);
|
|
125
|
+
if (destination === file.path) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
await fs.promises.rename(file.path, destination);
|
|
130
|
+
} catch {
|
|
131
|
+
await fs.promises.copyFile(file.path, destination);
|
|
132
|
+
await fs.promises.unlink(file.path);
|
|
133
|
+
}
|
|
134
|
+
file.path = destination;
|
|
135
|
+
if (file.destination !== undefined) {
|
|
136
|
+
file.destination = targetDir;
|
|
137
|
+
}
|
|
138
|
+
})
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
75
142
|
private async load_api_keys(cfgpath: string): Promise<Record<string, api_key>> {
|
|
76
143
|
const keyfile = path.resolve(cfgpath, 'api-keys.json');
|
|
77
144
|
if (fs.existsSync(keyfile)) {
|
|
@@ -88,9 +155,19 @@ export class mailStore implements ImailStore {
|
|
|
88
155
|
const env = (this.env = await EnvLoader.createConfigProxy(envOptions, { debug: true }));
|
|
89
156
|
EnvLoader.genTemplate(envOptions, '.env-dist');
|
|
90
157
|
const p = env.CONFIG_PATH;
|
|
91
|
-
this.configpath = path.isAbsolute(p) ? p : path.
|
|
158
|
+
this.configpath = path.isAbsolute(p) ? p : path.resolve(process.cwd(), p);
|
|
92
159
|
console.log(`Config path is ${this.configpath}`);
|
|
93
160
|
|
|
161
|
+
if (env.UPLOAD_PATH && env.UPLOAD_PATH.includes('{domain}')) {
|
|
162
|
+
this.uploadTemplate = env.UPLOAD_PATH;
|
|
163
|
+
this.uploadStagingPath = path.resolve(this.configpath, '_uploads');
|
|
164
|
+
try {
|
|
165
|
+
fs.mkdirSync(this.uploadStagingPath, { recursive: true });
|
|
166
|
+
} catch (err) {
|
|
167
|
+
this.print_debug(`Unable to create upload staging path: ${err}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
94
171
|
// this.keys = await this.load_api_keys(this.configpath);
|
|
95
172
|
|
|
96
173
|
this.transport = await create_mail_transport(env);
|
|
@@ -30,6 +30,7 @@ export type TestContext = {
|
|
|
30
30
|
tempDir: string;
|
|
31
31
|
configPath: string;
|
|
32
32
|
uploadFile: string;
|
|
33
|
+
uploadsPath: string;
|
|
33
34
|
domainName: string;
|
|
34
35
|
userToken: string;
|
|
35
36
|
apiUrl: string;
|
|
@@ -239,8 +240,7 @@ export async function createTestContext(): Promise<TestContext> {
|
|
|
239
240
|
|
|
240
241
|
const smtp = await startSmtpServer();
|
|
241
242
|
|
|
242
|
-
const
|
|
243
|
-
fs.mkdirSync(uploadPath, { recursive: true });
|
|
243
|
+
const uploadsPath = path.join(configPath, domainName, 'uploads');
|
|
244
244
|
|
|
245
245
|
const uploadFile = path.join(tempDir, 'upload.txt');
|
|
246
246
|
fs.writeFileSync(uploadFile, 'upload-bytes');
|
|
@@ -279,7 +279,7 @@ export async function createTestContext(): Promise<TestContext> {
|
|
|
279
279
|
process.env.ASSET_ROUTE = '/asset';
|
|
280
280
|
process.env.API_HOST = '127.0.0.1';
|
|
281
281
|
process.env.API_PORT = '0';
|
|
282
|
-
process.env.UPLOAD_PATH =
|
|
282
|
+
process.env.UPLOAD_PATH = './{domain}/uploads';
|
|
283
283
|
process.env.SMTP_HOST = '127.0.0.1';
|
|
284
284
|
process.env.SMTP_PORT = String(smtp.port);
|
|
285
285
|
process.env.SMTP_SECURE = 'true';
|
|
@@ -308,6 +308,7 @@ export async function createTestContext(): Promise<TestContext> {
|
|
|
308
308
|
tempDir,
|
|
309
309
|
configPath,
|
|
310
310
|
uploadFile,
|
|
311
|
+
uploadsPath,
|
|
311
312
|
domainName,
|
|
312
313
|
userToken: 'test-token',
|
|
313
314
|
apiUrl,
|
package/tests/mail-magic.test.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
1
4
|
import request from 'supertest';
|
|
2
5
|
|
|
3
6
|
import { api_form } from '../src/models/form.js';
|
|
@@ -93,6 +96,8 @@ describe('mail-magic API', () => {
|
|
|
93
96
|
});
|
|
94
97
|
|
|
95
98
|
test('sends transactional mail with inline assets and attachments', async () => {
|
|
99
|
+
const uploadsDir = ctx.uploadsPath;
|
|
100
|
+
const beforeUploads = fs.existsSync(uploadsDir) ? fs.readdirSync(uploadsDir) : [];
|
|
96
101
|
const res = await api
|
|
97
102
|
.post('/api/v1/tx/message')
|
|
98
103
|
.set('Authorization', `Bearer apikey-${ctx.userToken}`)
|
|
@@ -116,6 +121,18 @@ describe('mail-magic API', () => {
|
|
|
116
121
|
expect(filenames).toContain('upload.txt');
|
|
117
122
|
const inline = message.attachments.find((att) => att.contentId?.includes('images/logo.png'));
|
|
118
123
|
expect(inline).toBeTruthy();
|
|
124
|
+
|
|
125
|
+
expect(fs.existsSync(uploadsDir)).toBe(true);
|
|
126
|
+
const afterUploads = fs.readdirSync(uploadsDir);
|
|
127
|
+
const newUploads = afterUploads.filter((name) => !beforeUploads.includes(name));
|
|
128
|
+
expect(newUploads.length).toBeGreaterThan(0);
|
|
129
|
+
const uploadedPath = path.join(uploadsDir, newUploads[0]);
|
|
130
|
+
expect(fs.readFileSync(uploadedPath, 'utf8')).toBe('upload-bytes');
|
|
131
|
+
|
|
132
|
+
const stagingDir = path.join(ctx.configPath, '_uploads');
|
|
133
|
+
if (fs.existsSync(stagingDir)) {
|
|
134
|
+
expect(fs.existsSync(path.join(stagingDir, newUploads[0]))).toBe(false);
|
|
135
|
+
}
|
|
119
136
|
});
|
|
120
137
|
|
|
121
138
|
test('rejects form submissions without the secret', async () => {
|