@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 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=./config/
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 `config/example.com/form-template/…`). Review `config-example/` for the recommended layout, in particular the `form-template/` and `tx-template/` folders used for compiled Nunjucks templates.
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 `config/<domain>/assets`. Mail Magic rewrites `asset('logo.png')` to the public route `/asset/<domain>/logo.png` (or whatever you set via `ASSET_ROUTE`).
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@technomoron/mail-magic",
3
- "version": "1.0.8",
3
+ "version": "1.0.9",
4
4
  "main": "dist/index.js",
5
5
  "type": "module",
6
6
  "repository": {
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: env.UPLOAD_PATH,
24
+ uploadPath: store.getUploadStagingPath(),
25
25
  debug: env.DEBUG,
26
26
  apiBasePath: '',
27
27
  swaggerEnabled: env.SWAGGER_ENABLED,
@@ -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: './config/'
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
  });
@@ -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.join(process.cwd(), p);
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 uploadPath = path.join(tempDir, 'uploads');
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 = uploadPath;
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,
@@ -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 () => {