@technomoron/mail-magic 1.0.35 → 1.0.37

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 (34) hide show
  1. package/CHANGES +34 -0
  2. package/dist/cjs/index.js +9 -0
  3. package/dist/cjs/package.json +3 -0
  4. package/dist/{api → esm/api}/assets.js +2 -1
  5. package/dist/{api → esm/api}/auth.js +1 -1
  6. package/dist/{api → esm/api}/forms.js +3 -2
  7. package/dist/{api → esm/api}/mailer.js +3 -22
  8. package/dist/{index.js → esm/index.js} +3 -15
  9. package/dist/{models → esm/models}/db.js +10 -2
  10. package/dist/{models → esm/models}/form.js +20 -2
  11. package/dist/{models → esm/models}/txmail.js +10 -1
  12. package/dist/esm/server.js +22 -0
  13. package/dist/{store → esm/store}/envloader.js +1 -1
  14. package/dist/{store → esm/store}/store.js +0 -21
  15. package/dist/{swagger.js → esm/swagger.js} +1 -14
  16. package/dist/esm/util/route.js +14 -0
  17. package/package.json +14 -5
  18. package/dist/server.js +0 -34
  19. /package/dist/{bin → esm/bin}/mail-magic.js +0 -0
  20. /package/dist/{models → esm/models}/domain.js +0 -0
  21. /package/dist/{models → esm/models}/init.js +0 -0
  22. /package/dist/{models → esm/models}/recipient.js +0 -0
  23. /package/dist/{models → esm/models}/user.js +0 -0
  24. /package/dist/{types.js → esm/types.js} +0 -0
  25. /package/dist/{util → esm/util}/captcha.js +0 -0
  26. /package/dist/{util → esm/util}/email.js +0 -0
  27. /package/dist/{util → esm/util}/form-replyto.js +0 -0
  28. /package/dist/{util → esm/util}/form-submission.js +0 -0
  29. /package/dist/{util → esm/util}/forms.js +0 -0
  30. /package/dist/{util → esm/util}/paths.js +0 -0
  31. /package/dist/{util → esm/util}/ratelimit.js +0 -0
  32. /package/dist/{util → esm/util}/uploads.js +0 -0
  33. /package/dist/{util → esm/util}/utils.js +0 -0
  34. /package/dist/{util.js → esm/util.js} +0 -0
package/CHANGES CHANGED
@@ -1,3 +1,23 @@
1
+ Version 1.0.37 (2026-02-17)
2
+
3
+ - Remove stale commented-out debug code from server mailer/store paths.
4
+ - Remove unused legacy API-key store scaffolding from `mailStore` (`api_key`,
5
+ `ImailStore`, `keys`, `load_api_keys`, `get_api_key`).
6
+ - Consolidate mailer address validation by reusing shared `util/email` helpers.
7
+
8
+ Version 1.0.36 (2026-02-17)
9
+
10
+ - Remove legacy plaintext-token fallback from request authentication; API key auth
11
+ now requires `token_hmac`.
12
+ - Update token migration regression coverage to verify auth rejection before
13
+ migration and success after migration.
14
+ - Extract shared `normalizeRoute` helper and reuse it across bootstrap and swagger
15
+ modules.
16
+ - Standardize `getBodyValue` usage to `util/utils` imports in API modules for
17
+ clearer utility ownership.
18
+ - Remove unreachable null check after `createTransport()`.
19
+ - Add utility test coverage for route normalization.
20
+
1
21
  Version 1.0.35 (2026-02-17)
2
22
 
3
23
  - Enforce deterministic locale resolution for transactional template sends:
@@ -8,6 +28,20 @@ Version 1.0.35 (2026-02-17)
8
28
  locales are missing.
9
29
  - Add regression tests covering deterministic locale fallback and missing-locale
10
30
  behavior for both transactional sends and template asset upload resolution.
31
+ - Harden JSON-backed getters for template/form asset columns so malformed DB JSON
32
+ safely falls back to empty arrays instead of throwing runtime errors.
33
+ - Standardize form send SMTP failure handling to return ApiError responses
34
+ (consistent `message` contract instead of ad-hoc `error` payloads).
35
+ - Guard SQLite-only PRAGMA usage by database dialect during DB sync.
36
+ - Fix startup error text to use the current project name (`mail-magic`).
37
+ - Fix `DB_TYPE` environment description text ("API database" wording).
38
+ - Add regression tests for malformed JSON getter handling, standardized form-send
39
+ failure responses, sqlite PRAGMA guard helper behavior, startup error message,
40
+ and env option description text.
41
+ - Disallow legacy plaintext-token fallback during API authentication; requests now
42
+ authenticate strictly via `token_hmac`.
43
+ - Update token migration regression coverage to verify plaintext auth rejection
44
+ prior to migration and successful auth after migration.
11
45
 
12
46
  Version 1.0.34 (2026-02-10)
13
47
 
@@ -0,0 +1,9 @@
1
+ 'use strict';
2
+
3
+ const load = () => import('../esm/index.js');
4
+
5
+ module.exports = {
6
+ STARTUP_ERROR_MESSAGE: 'Failed to start mail-magic:',
7
+ createMailMagicServer: async (...args) => (await load()).createMailMagicServer(...args),
8
+ startMailMagicServer: async (...args) => (await load()).startMailMagicServer(...args)
9
+ };
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "commonjs"
3
+ }
@@ -5,7 +5,8 @@ import { api_form } from '../models/form.js';
5
5
  import { api_txmail } from '../models/txmail.js';
6
6
  import { SEGMENT_PATTERN, normalizeSubdir } from '../util/paths.js';
7
7
  import { moveUploadedFiles } from '../util/uploads.js';
8
- import { decodeComponent, getBodyValue, sendFileAsync } from '../util.js';
8
+ import { getBodyValue } from '../util/utils.js';
9
+ import { decodeComponent, sendFileAsync } from '../util.js';
9
10
  import { assert_domain_and_user } from './auth.js';
10
11
  const DOMAIN_PATTERN = /^[a-z0-9][a-z0-9._-]*$/i;
11
12
  export class AssetAPI extends ApiModule {
@@ -1,7 +1,7 @@
1
1
  import { ApiError } from '@technomoron/api-server-base';
2
2
  import { api_domain } from '../models/domain.js';
3
3
  import { api_user } from '../models/user.js';
4
- import { getBodyValue } from '../util.js';
4
+ import { getBodyValue } from '../util/utils.js';
5
5
  export async function assert_domain_and_user(apireq) {
6
6
  const body = apireq.req.body ?? {};
7
7
  const domain = getBodyValue(body, 'domain');
@@ -8,7 +8,8 @@ import { api_recipient } from '../models/recipient.js';
8
8
  import { buildFormTemplateRecord, buildFormTemplatePaths, buildRecipientTo, buildReplyToValue, buildSubmissionContext, enforceAttachmentPolicy, enforceCaptchaPolicy, filterSubmissionFields, getPrimaryRecipientInfo, normalizeRecipientEmail, normalizeRecipientIdname, normalizeRecipientName, parseIdnameList, parseFormTemplatePayload, parseRecipientPayload, parsePublicSubmissionOrThrow, resolveFormKeyForTemplate, resolveFormKeyForRecipient, resolveRecipients, validateFormTemplatePayload } from '../util/forms.js';
9
9
  import { FixedWindowRateLimiter, enforceFormRateLimit } from '../util/ratelimit.js';
10
10
  import { buildAttachments, cleanupUploadedFiles } from '../util/uploads.js';
11
- import { buildRequestMeta, getBodyValue } from '../util.js';
11
+ import { getBodyValue } from '../util/utils.js';
12
+ import { buildRequestMeta } from '../util.js';
12
13
  import { assert_domain_and_user } from './auth.js';
13
14
  export class FormAPI extends ApiModule {
14
15
  rateLimiter = new FixedWindowRateLimiter();
@@ -187,7 +188,7 @@ export class FormAPI extends ApiModule {
187
188
  catch (error) {
188
189
  const errorMessage = error instanceof Error ? error.message : String(error);
189
190
  this.server.storage.print_debug('Error sending email: ' + errorMessage);
190
- return [500, { error: `Error sending email: ${errorMessage}` }];
191
+ throw new ApiError({ code: 500, message: `Error sending email: ${errorMessage}` });
191
192
  }
192
193
  return [200, {}];
193
194
  }
@@ -1,21 +1,11 @@
1
1
  import { ApiModule, ApiError } from '@technomoron/api-server-base';
2
- import emailAddresses from 'email-addresses';
3
2
  import { convert } from 'html-to-text';
4
3
  import nunjucks from 'nunjucks';
5
4
  import { api_txmail } from '../models/txmail.js';
5
+ import { validateEmail } from '../util/email.js';
6
6
  import { buildRequestMeta } from '../util.js';
7
7
  import { assert_domain_and_user } from './auth.js';
8
8
  export class MailerAPI extends ApiModule {
9
- //
10
- // Validate and return the parsed email address
11
- //
12
- validateEmail(email) {
13
- const parsed = emailAddresses.parseOneAddress(email);
14
- if (parsed) {
15
- return parsed.address;
16
- }
17
- return undefined;
18
- }
19
9
  //
20
10
  // Validate a set of email addresses. Return arrays of invalid
21
11
  // and valid email addresses.
@@ -27,7 +17,7 @@ export class MailerAPI extends ApiModule {
27
17
  .map((email) => email.trim())
28
18
  .filter((email) => email !== '');
29
19
  emails.forEach((email) => {
30
- const addr = this.validateEmail(email);
20
+ const addr = validateEmail(email);
31
21
  if (addr) {
32
22
  valid.push(addr);
33
23
  }
@@ -56,13 +46,6 @@ export class MailerAPI extends ApiModule {
56
46
  sender,
57
47
  template
58
48
  };
59
- /*
60
- console.log(JSON.stringify({
61
- user: apireq.user,
62
- domain: apireq.domain,
63
- domain_id: apireq.domain.domain_id,
64
- data
65
- }, undefined, 2)); */
66
49
  try {
67
50
  const [templateRecord, created] = await api_txmail.upsert(data, {
68
51
  returning: true
@@ -94,7 +77,6 @@ export class MailerAPI extends ApiModule {
94
77
  }
95
78
  }
96
79
  const thevars = parsedVars;
97
- // const dbdomain = await api_domain.findOne({ where: { domain } });
98
80
  const { valid, invalid } = this.validateEmails(rcpt);
99
81
  if (invalid.length > 0) {
100
82
  throw new ApiError({ code: 400, message: 'Invalid email address(es): ' + invalid.join(',') });
@@ -145,7 +127,7 @@ export class MailerAPI extends ApiModule {
145
127
  const replyToValue = (replyTo || reply_to);
146
128
  let normalizedReplyTo;
147
129
  if (replyToValue) {
148
- normalizedReplyTo = this.validateEmail(replyToValue);
130
+ normalizedReplyTo = validateEmail(replyToValue);
149
131
  if (!normalizedReplyTo) {
150
132
  throw new ApiError({ code: 400, message: 'Invalid reply-to email address' });
151
133
  }
@@ -191,7 +173,6 @@ export class MailerAPI extends ApiModule {
191
173
  return [200, { Status: 'OK', Message: 'Emails sent successfully' }];
192
174
  }
193
175
  catch (error) {
194
- // console.log(JSON.stringify(e, null, 2));
195
176
  throw new ApiError({
196
177
  code: 500,
197
178
  message: error instanceof Error ? error.message : String(error)
@@ -5,20 +5,8 @@ import { MailerAPI } from './api/mailer.js';
5
5
  import { mailApiServer } from './server.js';
6
6
  import { mailStore } from './store/store.js';
7
7
  import { installMailMagicSwagger } from './swagger.js';
8
- function normalizeRoute(value, fallback = '') {
9
- if (!value) {
10
- return fallback;
11
- }
12
- const trimmed = value.trim();
13
- if (!trimmed) {
14
- return fallback;
15
- }
16
- const withLeading = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
17
- if (withLeading === '/') {
18
- return withLeading;
19
- }
20
- return withLeading.replace(/\/+$/, '');
21
- }
8
+ import { normalizeRoute } from './util/route.js';
9
+ export const STARTUP_ERROR_MESSAGE = 'Failed to start mail-magic:';
22
10
  function mergeStaticDirs(base, override) {
23
11
  const merged = { ...base, ...(override ?? {}) };
24
12
  if (Object.keys(merged).length === 0) {
@@ -107,7 +95,7 @@ async function bootMailMagic() {
107
95
  console.log(`mail-magic server listening on ${vars.API_HOST}:${vars.API_PORT}`);
108
96
  }
109
97
  catch (err) {
110
- console.error('Failed to start FormMailer:', err);
98
+ console.error(STARTUP_ERROR_MESSAGE, err);
111
99
  process.exit(1);
112
100
  }
113
101
  }
@@ -5,6 +5,9 @@ import { importData } from './init.js';
5
5
  import { init_api_recipient, api_recipient } from './recipient.js';
6
6
  import { init_api_txmail, api_txmail } from './txmail.js';
7
7
  import { init_api_user, api_user, migrateLegacyApiTokens } from './user.js';
8
+ export function usesSqlitePragmas(db) {
9
+ return db.getDialect() === 'sqlite';
10
+ }
8
11
  export async function init_api_db(db, store) {
9
12
  await init_api_user(db);
10
13
  await init_api_domain(db);
@@ -71,11 +74,16 @@ export async function init_api_db(db, store) {
71
74
  foreignKey: 'domain_id',
72
75
  as: 'domain'
73
76
  });
74
- await db.query('PRAGMA foreign_keys = OFF');
77
+ const useSqlitePragmas = usesSqlitePragmas(db);
78
+ if (useSqlitePragmas) {
79
+ await db.query('PRAGMA foreign_keys = OFF');
80
+ }
75
81
  const alter = Boolean(store.vars.DB_SYNC_ALTER);
76
82
  store.print_debug(`DB sync: alter=${alter} force=${store.vars.DB_FORCE_SYNC}`);
77
83
  await db.sync({ alter, force: store.vars.DB_FORCE_SYNC });
78
- await db.query('PRAGMA foreign_keys = ON');
84
+ if (useSqlitePragmas) {
85
+ await db.query('PRAGMA foreign_keys = ON');
86
+ }
79
87
  await importData(store);
80
88
  try {
81
89
  const { migrated, cleared } = await migrateLegacyApiTokens(store.vars.API_TOKEN_PEPPER);
@@ -170,7 +170,16 @@ export async function init_api_form(api_db) {
170
170
  get() {
171
171
  // This column is stored as JSON text but exposed as `string[]` via getter/setter.
172
172
  const raw = this.getDataValue('allowed_fields');
173
- return raw ? JSON.parse(raw) : [];
173
+ if (!raw) {
174
+ return [];
175
+ }
176
+ try {
177
+ const parsed = JSON.parse(raw);
178
+ return Array.isArray(parsed) ? parsed : [];
179
+ }
180
+ catch {
181
+ return [];
182
+ }
174
183
  },
175
184
  set(value) {
176
185
  this.setDataValue('allowed_fields', JSON.stringify(value ?? []));
@@ -188,7 +197,16 @@ export async function init_api_form(api_db) {
188
197
  get() {
189
198
  // This column is stored as JSON text but exposed as `StoredFile[]` via getter/setter.
190
199
  const raw = this.getDataValue('files');
191
- return raw ? JSON.parse(raw) : [];
200
+ if (!raw) {
201
+ return [];
202
+ }
203
+ try {
204
+ const parsed = JSON.parse(raw);
205
+ return Array.isArray(parsed) ? parsed : [];
206
+ }
207
+ catch {
208
+ return [];
209
+ }
192
210
  },
193
211
  set(value) {
194
212
  this.setDataValue('files', JSON.stringify(value ?? []));
@@ -131,7 +131,16 @@ export async function init_api_txmail(api_db) {
131
131
  defaultValue: '[]',
132
132
  get() {
133
133
  const raw = this.getDataValue('files');
134
- return raw ? JSON.parse(raw) : [];
134
+ if (!raw) {
135
+ return [];
136
+ }
137
+ try {
138
+ const parsed = JSON.parse(raw);
139
+ return Array.isArray(parsed) ? parsed : [];
140
+ }
141
+ catch {
142
+ return [];
143
+ }
135
144
  },
136
145
  set(value) {
137
146
  this.setDataValue('files', JSON.stringify(value ?? []));
@@ -0,0 +1,22 @@
1
+ import { ApiServer } from '@technomoron/api-server-base';
2
+ import { apiTokenToHmac, api_user } from './models/user.js';
3
+ export class mailApiServer extends ApiServer {
4
+ store;
5
+ storage;
6
+ constructor(config, store) {
7
+ super(config);
8
+ this.store = store;
9
+ this.storage = store;
10
+ }
11
+ async getApiKey(token) {
12
+ this.storage.print_debug('Looking up api key');
13
+ const pepper = this.storage.vars.API_TOKEN_PEPPER;
14
+ const token_hmac = apiTokenToHmac(token, pepper);
15
+ const user = await api_user.findOne({ where: { token_hmac } });
16
+ if (user) {
17
+ return { uid: user.user_id };
18
+ }
19
+ this.storage.print_debug('Unable to find user for api key');
20
+ return null;
21
+ }
22
+ }
@@ -82,7 +82,7 @@ export const envOptions = defineEnvOptions({
82
82
  default: 'localhost'
83
83
  },
84
84
  DB_TYPE: {
85
- description: 'Database type of WP database',
85
+ description: 'Database type for the API database',
86
86
  options: ['sqlite'],
87
87
  default: 'sqlite'
88
88
  },
@@ -22,13 +22,9 @@ function create_mail_transport(vars) {
22
22
  if (user && pass) {
23
23
  args.auth = { user, pass };
24
24
  }
25
- // console.log(JSON.stringify(args, undefined, 2));
26
25
  const mailer = createTransport({
27
26
  ...args
28
27
  });
29
- if (!mailer) {
30
- throw new Error('Unable to create mailer');
31
- }
32
28
  return mailer;
33
29
  }
34
30
  export class mailStore {
@@ -36,9 +32,7 @@ export class mailStore {
36
32
  vars;
37
33
  transport;
38
34
  api_db = null;
39
- keys = {};
40
35
  configpath = '';
41
- deflocale;
42
36
  uploadTemplate;
43
37
  uploadStagingPath;
44
38
  print_debug(msg) {
@@ -102,17 +96,6 @@ export class mailStore {
102
96
  }
103
97
  }));
104
98
  }
105
- async load_api_keys(cfgpath) {
106
- const keyfile = path.resolve(cfgpath, 'api-keys.json');
107
- if (fs.existsSync(keyfile)) {
108
- const raw = fs.readFileSync(keyfile, 'utf-8');
109
- const jsonData = JSON.parse(raw);
110
- this.print_debug(`API Key Database loaded from ${keyfile}`);
111
- return jsonData;
112
- }
113
- this.print_debug(`No api-keys.json file found: tried ${keyfile}`);
114
- return {};
115
- }
116
99
  async init(overrides = {}) {
117
100
  // Load env config only via EnvLoader + envOptions (avoid ad-hoc `process.env` parsing here).
118
101
  // If DEBUG is enabled, re-load with EnvLoader debug output enabled.
@@ -168,7 +151,6 @@ export class mailStore {
168
151
  this.print_debug(`Unable to create upload staging path: ${err}`);
169
152
  }
170
153
  }
171
- // this.keys = await this.load_api_keys(this.configpath);
172
154
  this.transport = await create_mail_transport(this.vars);
173
155
  this.api_db = await connect_api_db(this);
174
156
  if (this.vars.DB_AUTO_RELOAD) {
@@ -185,7 +167,4 @@ export class mailStore {
185
167
  }
186
168
  return this;
187
169
  }
188
- get_api_key(key) {
189
- return this.keys[key] || null;
190
- }
191
170
  }
@@ -1,20 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
- function normalizeRoute(value, fallback = '') {
5
- if (!value) {
6
- return fallback;
7
- }
8
- const trimmed = value.trim();
9
- if (!trimmed) {
10
- return fallback;
11
- }
12
- const withLeading = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
13
- if (withLeading === '/') {
14
- return withLeading;
15
- }
16
- return withLeading.replace(/\/+$/, '');
17
- }
4
+ import { normalizeRoute } from './util/route.js';
18
5
  function replacePrefix(input, from, to) {
19
6
  if (input === from) {
20
7
  return to;
@@ -0,0 +1,14 @@
1
+ export function normalizeRoute(value, fallback = '') {
2
+ if (!value) {
3
+ return fallback;
4
+ }
5
+ const trimmed = value.trim();
6
+ if (!trimmed) {
7
+ return fallback;
8
+ }
9
+ const withLeading = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
10
+ if (withLeading === '/') {
11
+ return withLeading;
12
+ }
13
+ return withLeading.replace(/\/+$/, '');
14
+ }
package/package.json CHANGED
@@ -1,10 +1,17 @@
1
1
  {
2
2
  "name": "@technomoron/mail-magic",
3
- "version": "1.0.35",
4
- "main": "dist/index.js",
3
+ "version": "1.0.37",
4
+ "main": "dist/cjs/index.js",
5
+ "module": "dist/esm/index.js",
5
6
  "type": "module",
6
7
  "bin": {
7
- "mail-magic": "dist/bin/mail-magic.js"
8
+ "mail-magic": "dist/esm/bin/mail-magic.js"
9
+ },
10
+ "exports": {
11
+ ".": {
12
+ "import": "./dist/esm/index.js",
13
+ "require": "./dist/cjs/index.js"
14
+ }
8
15
  },
9
16
  "files": [
10
17
  "dist",
@@ -18,10 +25,12 @@
18
25
  "directory": "packages/mail-magic"
19
26
  },
20
27
  "scripts": {
21
- "start": "node dist/index.js",
28
+ "start": "node dist/esm/index.js",
22
29
  "dev": "NODE_ENV=development nodemon --watch 'src/**/*.ts' --watch 'config/**/*.*' --watch '.env' --exec 'tsx' src/index.ts",
23
30
  "run": "NODE_ENV=production npm run start",
24
- "build": "tsc",
31
+ "build:esm": "tsc --project tsconfig/tsconfig.esm.json",
32
+ "build:cjs": "node scripts/add-shebang.cjs --cjs-only",
33
+ "build": "npm run build:esm && npm run build:cjs",
25
34
  "postbuild": "node scripts/add-shebang.cjs",
26
35
  "prepack": "npm run build",
27
36
  "test": "vitest run",
package/dist/server.js DELETED
@@ -1,34 +0,0 @@
1
- import { ApiServer } from '@technomoron/api-server-base';
2
- import { apiTokenToHmac, api_user } from './models/user.js';
3
- export class mailApiServer extends ApiServer {
4
- store;
5
- storage;
6
- constructor(config, store) {
7
- super(config);
8
- this.store = store;
9
- this.storage = store;
10
- }
11
- async getApiKey(token) {
12
- this.storage.print_debug('Looking up api key');
13
- const pepper = this.storage.vars.API_TOKEN_PEPPER;
14
- const token_hmac = apiTokenToHmac(token, pepper);
15
- const user = await api_user.findOne({ where: { token_hmac } });
16
- if (user) {
17
- return { uid: user.user_id };
18
- }
19
- // Backwards-compatible fallback for legacy databases that still store plaintext tokens.
20
- const legacy = await api_user.findOne({ where: { token } });
21
- if (!legacy) {
22
- this.storage.print_debug('Unable to find user for api key');
23
- return null;
24
- }
25
- try {
26
- await legacy.update({ token_hmac, token: '' });
27
- }
28
- catch (err) {
29
- // Don't leak token data; just surface the update failure for debugging.
30
- this.storage.print_debug(`Unable to migrate legacy api token: ${err instanceof Error ? err.message : String(err)}`);
31
- }
32
- return { uid: legacy.user_id };
33
- }
34
- }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes