@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.
Files changed (44) hide show
  1. package/CHANGES +24 -0
  2. package/README.md +5 -0
  3. package/dist/api/assets.js +153 -0
  4. package/dist/api/forms.js +2 -0
  5. package/dist/api/mailer.js +1 -0
  6. package/dist/bin/mail-magic.js +63 -0
  7. package/dist/index.js +61 -2
  8. package/dist/store/envloader.js +3 -3
  9. package/dist/store/store.js +66 -1
  10. package/package.json +16 -3
  11. package/.do-realease.sh +0 -54
  12. package/.editorconfig +0 -9
  13. package/.env-dist +0 -71
  14. package/.prettierrc +0 -14
  15. package/.vscode/extensions.json +0 -3
  16. package/.vscode/settings.json +0 -22
  17. package/config-example/form-template/default.njk +0 -102
  18. package/config-example/forms.config.json +0 -8
  19. package/config-example/init-data.json +0 -33
  20. package/config-example/tx-template/default.njk +0 -107
  21. package/ecosystem.config.cjs +0 -42
  22. package/eslint.config.mjs +0 -196
  23. package/lintconfig.cjs +0 -81
  24. package/src/api/assets.ts +0 -92
  25. package/src/api/forms.ts +0 -239
  26. package/src/api/mailer.ts +0 -270
  27. package/src/index.ts +0 -71
  28. package/src/models/db.ts +0 -112
  29. package/src/models/domain.ts +0 -72
  30. package/src/models/form.ts +0 -209
  31. package/src/models/init.ts +0 -240
  32. package/src/models/txmail.ts +0 -206
  33. package/src/models/user.ts +0 -79
  34. package/src/server.ts +0 -27
  35. package/src/store/envloader.ts +0 -109
  36. package/src/store/store.ts +0 -195
  37. package/src/types.ts +0 -39
  38. package/src/util.ts +0 -137
  39. package/tests/fixtures/certs/test.crt +0 -19
  40. package/tests/fixtures/certs/test.key +0 -28
  41. package/tests/helpers/test-setup.ts +0 -317
  42. package/tests/mail-magic.test.ts +0 -171
  43. package/tsconfig.json +0 -14
  44. 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`).
@@ -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
@@ -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 { pathToFileURL } from 'node:url';
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: env.UPLOAD_PATH,
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
+ }
@@ -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: './config/'
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
  });
@@ -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.join(process.cwd(), p);
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.9",
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.2",
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
@@ -1,9 +0,0 @@
1
- root = true
2
-
3
- [*]
4
- charset = utf-8
5
- indent_style = tab
6
- indent_size = 4
7
- end_of_line = lf
8
- insert_final_newline = true
9
- trim_trailing_whitespace = true
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