@technomoron/mail-magic 1.0.4

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 (45) hide show
  1. package/.do-realease.sh +10 -0
  2. package/.editorconfig +9 -0
  3. package/.env-dist +62 -0
  4. package/.prettierrc +14 -0
  5. package/.vscode/extensions.json +15 -0
  6. package/.vscode/settings.json +123 -0
  7. package/CHANGES +25 -0
  8. package/README.md +63 -0
  9. package/config-example/form-template/default.njk +102 -0
  10. package/config-example/forms.config.json +8 -0
  11. package/config-example/init-data.json +33 -0
  12. package/config-example/tx-template/default.njk +107 -0
  13. package/dist/api/forms.js +175 -0
  14. package/dist/api/mailer.js +213 -0
  15. package/dist/index.js +50 -0
  16. package/dist/models/db.js +99 -0
  17. package/dist/models/domain.js +58 -0
  18. package/dist/models/form.js +168 -0
  19. package/dist/models/init.js +176 -0
  20. package/dist/models/txmail.js +167 -0
  21. package/dist/models/user.js +65 -0
  22. package/dist/server.js +22 -0
  23. package/dist/store/envloader.js +116 -0
  24. package/dist/store/store.js +85 -0
  25. package/dist/types.js +1 -0
  26. package/dist/util.js +94 -0
  27. package/ecosystem.config.cjs +42 -0
  28. package/eslint.config.mjs +104 -0
  29. package/package.json +67 -0
  30. package/src/api/forms.ts +209 -0
  31. package/src/api/mailer.ts +242 -0
  32. package/src/index.ts +67 -0
  33. package/src/models/db.ts +112 -0
  34. package/src/models/domain.ts +72 -0
  35. package/src/models/form.ts +198 -0
  36. package/src/models/init.ts +237 -0
  37. package/src/models/txmail.ts +199 -0
  38. package/src/models/user.ts +79 -0
  39. package/src/server.ts +27 -0
  40. package/src/store/envloader.ts +117 -0
  41. package/src/store/store.ts +116 -0
  42. package/src/types.ts +39 -0
  43. package/src/util.ts +111 -0
  44. package/test1.sh +13 -0
  45. package/tsconfig.json +14 -0
@@ -0,0 +1,116 @@
1
+ import { defineEnvOptions } from '@technomoron/env-loader';
2
+ export const envOptions = defineEnvOptions({
3
+ NODE_ENV: {
4
+ description: 'Specifies the environment in which the app is running',
5
+ options: ['development', 'production', 'staging'],
6
+ default: 'development'
7
+ },
8
+ API_PORT: {
9
+ description: 'Defines the port on which the app listens. Default 3780',
10
+ default: '3776',
11
+ type: 'number'
12
+ },
13
+ API_HOST: {
14
+ description: 'Sets the local IP address for the API to listen at',
15
+ default: '0.0.0.0'
16
+ },
17
+ DB_AUTO_RELOAD: {
18
+ description: 'Reload init-data.db automatically on change',
19
+ type: 'boolean',
20
+ default: true
21
+ },
22
+ DB_FORCE_SYNC: {
23
+ description: 'Whether to force sync on table definitions (ALTER TABLE)',
24
+ type: 'boolean',
25
+ default: false
26
+ },
27
+ API_URL: {
28
+ description: 'Sets the public URL for the API (i.e. https://ml.example.com:3790)',
29
+ default: 'http://localhost:3776'
30
+ },
31
+ CONFIG_PATH: {
32
+ description: 'Path to directory where config files are located',
33
+ default: './config/'
34
+ },
35
+ /*
36
+ SWAGGER_ENABLE: {
37
+ description: 'Enable Swagger API docs',
38
+ default: 'false',
39
+ type: 'boolean'
40
+ },
41
+ SWAGGER_PATH: {
42
+ description: 'Path for swagger api docs',
43
+ default: '/api-docs'
44
+ },
45
+ */
46
+ /*
47
+ JWT_SECRET: {
48
+ description: 'Secret key for generating JWT access tokens',
49
+ required: true
50
+ },
51
+ JWT_REFRESH: {
52
+ description: 'Secret key for generating JWT refresh tokens',
53
+ required: true
54
+ },
55
+ */
56
+ DB_USER: {
57
+ description: 'Database username for API database'
58
+ },
59
+ DB_PASS: {
60
+ description: 'Password for API database'
61
+ },
62
+ DB_NAME: {
63
+ description: 'Name of API database. Filename for sqlite3, database name for others',
64
+ default: 'maildata'
65
+ },
66
+ DB_HOST: {
67
+ description: 'Host of API database',
68
+ default: 'localhost'
69
+ },
70
+ DB_TYPE: {
71
+ description: 'Database type of WP database',
72
+ options: ['sqlite'],
73
+ default: 'sqlite'
74
+ },
75
+ DB_LOG: {
76
+ description: 'Log SQL statements',
77
+ default: 'false',
78
+ type: 'boolean'
79
+ },
80
+ DEBUG: {
81
+ description: 'Enable debug output, including nodemailer and API',
82
+ default: false,
83
+ type: 'boolean'
84
+ },
85
+ SMTP_HOST: {
86
+ description: 'Hostname of SMTP sending host',
87
+ default: 'localhost'
88
+ },
89
+ SMTP_PORT: {
90
+ description: 'SMTP host server port',
91
+ default: 587,
92
+ type: 'number'
93
+ },
94
+ SMTP_SECURE: {
95
+ description: 'Use secure connection to SMTP host (SSL/TSL)',
96
+ default: false,
97
+ type: 'boolean'
98
+ },
99
+ SMTP_TLS_REJECT: {
100
+ description: 'Reject bad cert/TLS connection to SMTP host',
101
+ default: false,
102
+ type: 'boolean'
103
+ },
104
+ SMTP_USER: {
105
+ description: 'Username for SMTP host',
106
+ default: ''
107
+ },
108
+ SMTP_PASSWORD: {
109
+ description: 'Password for SMTP host',
110
+ default: ''
111
+ },
112
+ UPLOAD_PATH: {
113
+ description: 'Path for attached files',
114
+ default: './uploads/'
115
+ }
116
+ });
@@ -0,0 +1,85 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { EnvLoader } from '@technomoron/env-loader';
4
+ import { createTransport } from 'nodemailer';
5
+ import { connect_api_db } from '../models/db.js';
6
+ import { importData } from '../models/init.js';
7
+ import { envOptions } from './envloader.js';
8
+ function create_mail_transport(env) {
9
+ const args = {
10
+ host: env.SMTP_HOST,
11
+ port: env.SMTP_PORT,
12
+ secure: env.SMTP_SECURE,
13
+ tls: {
14
+ rejectUnauthorized: env.SMTP_TLS_REJECT
15
+ },
16
+ requireTLS: true,
17
+ logger: env.DEBUG,
18
+ debug: env.DEBUG
19
+ };
20
+ const user = env.SMTP_USER;
21
+ const pass = env.SMTP_PASSWORD;
22
+ if (user && pass) {
23
+ args.auth = { user, pass };
24
+ }
25
+ // console.log(JSON.stringify(args, undefined, 2));
26
+ const mailer = createTransport({
27
+ ...args
28
+ });
29
+ if (!mailer) {
30
+ throw new Error('Unable to create mailer');
31
+ }
32
+ return mailer;
33
+ }
34
+ export class mailStore {
35
+ env;
36
+ transport;
37
+ api_db = null;
38
+ keys = {};
39
+ configpath = '';
40
+ print_debug(msg) {
41
+ if (this.env.DEBUG) {
42
+ console.log(msg);
43
+ }
44
+ }
45
+ config_filename(name) {
46
+ return path.resolve(path.join(this.configpath, name));
47
+ }
48
+ async load_api_keys(cfgpath) {
49
+ const keyfile = path.resolve(cfgpath, 'api-keys.json');
50
+ if (fs.existsSync(keyfile)) {
51
+ const raw = fs.readFileSync(keyfile, 'utf-8');
52
+ const jsonData = JSON.parse(raw);
53
+ this.print_debug(`API Key Database loaded from ${keyfile}`);
54
+ return jsonData;
55
+ }
56
+ this.print_debug(`No api-keys.json file found: tried ${keyfile}`);
57
+ return {};
58
+ }
59
+ async init() {
60
+ const env = (this.env = await EnvLoader.createConfigProxy(envOptions, { debug: true }));
61
+ EnvLoader.genTemplate(envOptions, '.env-dist');
62
+ const p = env.CONFIG_PATH;
63
+ this.configpath = path.isAbsolute(p) ? p : path.join(process.cwd(), p);
64
+ console.log(`Config path is ${this.configpath}`);
65
+ // this.keys = await this.load_api_keys(this.configpath);
66
+ this.transport = await create_mail_transport(env);
67
+ this.api_db = await connect_api_db(this);
68
+ if (this.env.DB_AUTO_RELOAD) {
69
+ this.print_debug('Enabling auto reload of init-data.json');
70
+ fs.watchFile(this.config_filename('init-data.json'), { interval: 2000 }, () => {
71
+ this.print_debug('Config file changed, reloading...');
72
+ try {
73
+ importData(this);
74
+ }
75
+ catch (err) {
76
+ this.print_debug(`Failed to reload config: ${err}`);
77
+ }
78
+ });
79
+ }
80
+ return this;
81
+ }
82
+ get_api_key(key) {
83
+ return this.keys[key] || null;
84
+ }
85
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/dist/util.js ADDED
@@ -0,0 +1,94 @@
1
+ import { api_domain } from './models/domain.js';
2
+ import { api_user } from './models/user.js';
3
+ /**
4
+ * Normalize a string into a safe identifier for slugs, filenames, etc.
5
+ *
6
+ * - Lowercases all characters
7
+ * - Replaces any character that is not `a-z`, `0-9`, `-`, '.' or `_` with `-`
8
+ * - Collapses multiple consecutive dashes into one
9
+ * - Trims leading and trailing dashes
10
+ *
11
+ * Examples:
12
+ * normalizeSlug("Hello World!") -> "hello-world"
13
+ * normalizeSlug(" Áccêntš ") -> "ccnt"
14
+ * normalizeSlug("My--Slug__Test") -> "my-slug__test"
15
+ */
16
+ export function normalizeSlug(input) {
17
+ if (!input) {
18
+ return '';
19
+ }
20
+ return input
21
+ .trim()
22
+ .toLowerCase()
23
+ .replace(/[^a-z0-9-_\.]/g, '-')
24
+ .replace(/--+/g, '-') // collapse multiple dashes
25
+ .replace(/^-+|-+$/g, ''); // trim leading/trailing dashes
26
+ }
27
+ export async function user_and_domain(domain_id) {
28
+ const domain = await api_domain.findByPk(domain_id);
29
+ if (!domain) {
30
+ throw new Error(`Unable to look up domain ${domain_id}`);
31
+ }
32
+ const user = await api_user.findByPk(domain.user_id);
33
+ if (!user) {
34
+ throw new Error(`Unable to look up user ${domain.user_id}`);
35
+ }
36
+ return { user, domain };
37
+ }
38
+ function collectHeaderIps(header) {
39
+ if (!header) {
40
+ return [];
41
+ }
42
+ if (Array.isArray(header)) {
43
+ return header
44
+ .join(',')
45
+ .split(',')
46
+ .map((ip) => ip.trim())
47
+ .filter(Boolean);
48
+ }
49
+ return header
50
+ .split(',')
51
+ .map((ip) => ip.trim())
52
+ .filter(Boolean);
53
+ }
54
+ function resolveHeader(headers, key) {
55
+ const direct = headers[key];
56
+ const alt = headers[key.toLowerCase()];
57
+ const value = direct ?? alt;
58
+ if (typeof value === 'string' || Array.isArray(value)) {
59
+ return value;
60
+ }
61
+ return undefined;
62
+ }
63
+ export function buildRequestMeta(rawReq) {
64
+ const req = (rawReq ?? {});
65
+ const headers = req.headers ?? {};
66
+ const ips = [];
67
+ ips.push(...collectHeaderIps(resolveHeader(headers, 'x-forwarded-for')));
68
+ const realIp = resolveHeader(headers, 'x-real-ip');
69
+ if (typeof realIp === 'string' && realIp.trim()) {
70
+ ips.push(realIp.trim());
71
+ }
72
+ const cfIp = resolveHeader(headers, 'cf-connecting-ip');
73
+ if (typeof cfIp === 'string' && cfIp.trim()) {
74
+ ips.push(cfIp.trim());
75
+ }
76
+ const fastlyIp = resolveHeader(headers, 'fastly-client-ip');
77
+ if (typeof fastlyIp === 'string' && fastlyIp.trim()) {
78
+ ips.push(fastlyIp.trim());
79
+ }
80
+ if (req.ip && req.ip.trim()) {
81
+ ips.push(req.ip.trim());
82
+ }
83
+ const remoteAddress = req.socket?.remoteAddress;
84
+ if (remoteAddress) {
85
+ ips.push(remoteAddress);
86
+ }
87
+ const uniqueIps = ips.filter((ip, index) => ips.indexOf(ip) === index);
88
+ const clientIp = uniqueIps[0] || '';
89
+ return {
90
+ client_ip: clientIp,
91
+ received_at: new Date().toISOString(),
92
+ ip_chain: uniqueIps
93
+ };
94
+ }
@@ -0,0 +1,42 @@
1
+ const tplsrv = 'mail-magic';
2
+
3
+ module.exports = {
4
+ apps: [
5
+ {
6
+ name: tplsrv,
7
+ script: 'npm',
8
+ args: 'run start',
9
+ cwd: `/root/deploy/${tplsrv}/source`,
10
+ env: {
11
+ NODE_ENV: 'production'
12
+ }
13
+ },
14
+ {
15
+ name: 'listmonk',
16
+ script: './listmonk',
17
+ cwd: '/var/www/ml.yesmedia.no',
18
+ exec_mode: 'fork',
19
+ instances: 1,
20
+ autorestart: true,
21
+ watch: false,
22
+ max_memory_restart: '1G',
23
+ user: 'listmonk',
24
+ group: 'listmonk',
25
+ env: {
26
+ NODE_ENV: 'production'
27
+ }
28
+ }
29
+ ],
30
+ deploy: {
31
+ 'mail-magic': {
32
+ user: 'root',
33
+ host: 'localhost',
34
+ ref: 'origin/main',
35
+ path: `/root/deploy/${tplsrv}`,
36
+ repo: `git@github.com:technomoron/${tplsrv}`,
37
+ 'pre-deploy-local': '',
38
+ 'pre-setup': '',
39
+ 'post-deploy': `cd /root/deploy/${tplsrv}/source && pnpm install && pnpm upgrade && pnpm run build && pm2 start /root/deploy/ecosystem.config.cjs`
40
+ }
41
+ }
42
+ };
@@ -0,0 +1,104 @@
1
+ import tsParser from '@typescript-eslint/parser';
2
+ import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript';
3
+ import pluginImport from 'eslint-plugin-import';
4
+ import pluginPrettier from 'eslint-plugin-prettier';
5
+ import pluginVue from 'eslint-plugin-vue';
6
+ import jsoncParser from 'jsonc-eslint-parser';
7
+
8
+ export default [
9
+ {
10
+ ignores: [
11
+ 'node_modules',
12
+ 'dist',
13
+ '.output',
14
+ '.nuxt',
15
+ 'coverage',
16
+ '**/*.d.ts',
17
+ 'configure-eslint.js',
18
+ '*.config.js',
19
+ '*.config.ts',
20
+ 'public'
21
+ ]
22
+ },
23
+ ...defineConfigWithVueTs(vueTsConfigs.recommended),
24
+ {
25
+ files: ['**/*.vue'],
26
+ plugins: {
27
+ vue: pluginVue,
28
+ prettier: pluginPrettier
29
+ },
30
+ rules: {
31
+ 'prettier/prettier': 'error', // Enforce Prettier rules
32
+ 'vue/html-indent': 'off', // Let Prettier handle indentation
33
+ 'vue/max-attributes-per-line': 'off', // Let Prettier handle line breaks
34
+ 'vue/first-attribute-linebreak': 'off', // Let Prettier handle attribute positioning
35
+ 'vue/singleline-html-element-content-newline': 'off',
36
+ 'vue/html-self-closing': [
37
+ 'error',
38
+ {
39
+ html: {
40
+ void: 'always',
41
+ normal: 'always',
42
+ component: 'always'
43
+ }
44
+ }
45
+ ],
46
+ 'vue/multi-word-component-names': 'off', // Disable multi-word name restriction
47
+ 'vue/attribute-hyphenation': ['error', 'always']
48
+ }
49
+ },
50
+ {
51
+ files: ['*.json'],
52
+ languageOptions: {
53
+ parser: jsoncParser
54
+ },
55
+ plugins: {
56
+ prettier: pluginPrettier
57
+ },
58
+ rules: {
59
+ quotes: ['error', 'double'], // Enforce double quotes in JSON
60
+ 'prettier/prettier': 'error',
61
+ '@typescript-eslint/no-unused-expressions': 'off',
62
+ '@typescript-eslint/no-unused-vars': 'off'
63
+ }
64
+ },
65
+ {
66
+ files: ['**/*.{ts,mts,tsx,js,mjs,cjs}'],
67
+ languageOptions: {
68
+ parser: tsParser,
69
+ parserOptions: {
70
+ ecmaVersion: 2023,
71
+ sourceType: 'module',
72
+ extraFileExtensions: ['.vue']
73
+ },
74
+ globals: {
75
+ RequestInit: 'readonly',
76
+ process: 'readonly',
77
+ Capacitor: 'readonly',
78
+ chrome: 'readonly'
79
+ }
80
+ },
81
+ plugins: {
82
+ prettier: pluginPrettier,
83
+ import: pluginImport
84
+ },
85
+ rules: {
86
+ indent: ['error', 'tab', { SwitchCase: 1 }], // Use tabs for JS/TS
87
+ quotes: ['warn', 'single', { avoidEscape: true }], // Prefer single quotes
88
+ semi: ['error', 'always'], // Enforce semicolons
89
+ 'comma-dangle': 'off', // Disable trailing commas
90
+ 'prettier/prettier': 'error', // Enforce Prettier rules
91
+ 'import/order': [
92
+ 'error',
93
+ {
94
+ groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index', 'object', 'type'],
95
+ 'newlines-between': 'always',
96
+ alphabetize: { order: 'asc', caseInsensitive: true }
97
+ }
98
+ ],
99
+ '@typescript-eslint/no-explicit-any': ['warn'],
100
+ '@typescript-eslint/no-unused-vars': ['warn'],
101
+ '@typescript-eslint/no-require-imports': 'off'
102
+ }
103
+ }
104
+ ];
package/package.json ADDED
@@ -0,0 +1,67 @@
1
+ {
2
+ "name": "@technomoron/mail-magic",
3
+ "version": "1.0.4",
4
+ "main": "dist/index.js",
5
+ "type": "module",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/technomoron/mail-magic.git"
9
+ },
10
+ "scripts": {
11
+ "start": "node dist/index.js",
12
+ "dev": "NODE_ENV=development nodemon --watch 'src/**/*.ts' --watch 'config/**/*.*' --watch '.env' --exec 'tsx' src/index.ts",
13
+ "run": "NODE_ENV=production npm run start",
14
+ "build": "tsc",
15
+ "scrub": "rm -rf ./node_modules/ ./dist/ pnpm-lock.yaml",
16
+ "lint": "eslint --ext .js,.cjs,.mjs,.ts,.mts,.tsx,.vue ./",
17
+ "lintfix": "eslint --fix --ext .js,.cjs,.mjs,.ts,.mts,.tsx,.vue ./",
18
+ "pretty": "prettier --write \"**/*.{js,jsx,cjs,mjs,ts,tsx,mts,vue,json,css,scss,md}\"",
19
+ "format": "npm run lintfix && npm run pretty",
20
+ "cleanbuild": "rm -rf ./dist/ && npm run lintfix && npm run format && npm run build"
21
+ },
22
+ "keywords": [],
23
+ "author": "Bjørn Erik Jacobsen",
24
+ "license": "MIT",
25
+ "copyright": "Copyright (c) 2025 Bjørn Erik Jacobsen",
26
+ "bugs": {
27
+ "url": "https://github.com/technomoron/mail-magic/issues"
28
+ },
29
+ "dependencies": {
30
+ "@technomoron/api-server-base": "^1.0.40",
31
+ "@technomoron/env-loader": "^1.0.8",
32
+ "@technomoron/unyuck": "^1.0.4",
33
+ "bcryptjs": "^3.0.2",
34
+ "email-addresses": "^5.0.0",
35
+ "html-to-text": "^9.0.5",
36
+ "nodemailer": "^6.10.1",
37
+ "nunjucks": "^3.2.4",
38
+ "sequelize": "^6.37.7",
39
+ "sqlite3": "^5.1.7",
40
+ "swagger-jsdoc": "^6.2.8",
41
+ "swagger-ui-express": "^5.0.1",
42
+ "zod": "^4.1.5"
43
+ },
44
+ "devDependencies": {
45
+ "@types/html-to-text": "^9.0.4",
46
+ "@types/nodemailer": "^6.4.19",
47
+ "@types/nunjucks": "^3.2.6",
48
+ "@typescript-eslint/eslint-plugin": "8.44.1",
49
+ "@typescript-eslint/parser": "8.44.1",
50
+ "@vue/eslint-config-prettier": "10.2.0",
51
+ "@vue/eslint-config-typescript": "^14.6.0",
52
+ "eslint": "9.36.0",
53
+ "eslint-config-prettier": "10.1.8",
54
+ "eslint-import-resolver-alias": "1.1.2",
55
+ "eslint-plugin-import": "2.32.0",
56
+ "eslint-plugin-nuxt": "4.0.0",
57
+ "eslint-plugin-prettier": "5.5.4",
58
+ "eslint-plugin-vue": "^10.5.0",
59
+ "jsonc-eslint-parser": "^2.4.1",
60
+ "nodemon": "^3.1.10",
61
+ "prettier": "3.6.2",
62
+ "tsx": "^4.20.5",
63
+ "typescript": "^5.9.2",
64
+ "vue-eslint-parser": "^10.2.0"
65
+ },
66
+ "homepage": "https://github.com/technomoron/mail-magic#readme"
67
+ }