@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
package/src/server.ts ADDED
@@ -0,0 +1,27 @@
1
+ import { ApiServerConf, ApiServer } from '@technomoron/api-server-base';
2
+
3
+ import { api_user } from './models/user.js';
4
+ import { mailStore } from './store/store.js';
5
+
6
+ export class mailApiServer extends ApiServer {
7
+ storage: mailStore;
8
+
9
+ constructor(
10
+ config: Partial<ApiServerConf>,
11
+ private store: mailStore
12
+ ) {
13
+ super(config);
14
+ this.storage = store;
15
+ }
16
+
17
+ override async getApiKey<ApiKey>(token: string): Promise<ApiKey | null> {
18
+ this.storage.print_debug(`Looking up api key ${token}`);
19
+ const user = await api_user.findOne({ where: { token: token } });
20
+ if (!user) {
21
+ this.storage.print_debug(`Unable to find user for token ${token}`);
22
+ return null;
23
+ } else {
24
+ return { uid: user.user_id } as ApiKey;
25
+ }
26
+ }
27
+ }
@@ -0,0 +1,117 @@
1
+ import { defineEnvOptions } from '@technomoron/env-loader';
2
+
3
+ export const envOptions = defineEnvOptions({
4
+ NODE_ENV: {
5
+ description: 'Specifies the environment in which the app is running',
6
+ options: ['development', 'production', 'staging'],
7
+ default: 'development'
8
+ },
9
+ API_PORT: {
10
+ description: 'Defines the port on which the app listens. Default 3780',
11
+ default: '3776',
12
+ type: 'number'
13
+ },
14
+ API_HOST: {
15
+ description: 'Sets the local IP address for the API to listen at',
16
+ default: '0.0.0.0'
17
+ },
18
+ DB_AUTO_RELOAD: {
19
+ description: 'Reload init-data.db automatically on change',
20
+ type: 'boolean',
21
+ default: true
22
+ },
23
+ DB_FORCE_SYNC: {
24
+ description: 'Whether to force sync on table definitions (ALTER TABLE)',
25
+ type: 'boolean',
26
+ default: false
27
+ },
28
+ API_URL: {
29
+ description: 'Sets the public URL for the API (i.e. https://ml.example.com:3790)',
30
+ default: 'http://localhost:3776'
31
+ },
32
+ CONFIG_PATH: {
33
+ description: 'Path to directory where config files are located',
34
+ default: './config/'
35
+ },
36
+ /*
37
+ SWAGGER_ENABLE: {
38
+ description: 'Enable Swagger API docs',
39
+ default: 'false',
40
+ type: 'boolean'
41
+ },
42
+ SWAGGER_PATH: {
43
+ description: 'Path for swagger api docs',
44
+ default: '/api-docs'
45
+ },
46
+ */
47
+ /*
48
+ JWT_SECRET: {
49
+ description: 'Secret key for generating JWT access tokens',
50
+ required: true
51
+ },
52
+ JWT_REFRESH: {
53
+ description: 'Secret key for generating JWT refresh tokens',
54
+ required: true
55
+ },
56
+ */
57
+ DB_USER: {
58
+ description: 'Database username for API database'
59
+ },
60
+ DB_PASS: {
61
+ description: 'Password for API database'
62
+ },
63
+ DB_NAME: {
64
+ description: 'Name of API database. Filename for sqlite3, database name for others',
65
+ default: 'maildata'
66
+ },
67
+ DB_HOST: {
68
+ description: 'Host of API database',
69
+ default: 'localhost'
70
+ },
71
+ DB_TYPE: {
72
+ description: 'Database type of WP database',
73
+ options: ['sqlite'],
74
+ default: 'sqlite'
75
+ },
76
+ DB_LOG: {
77
+ description: 'Log SQL statements',
78
+ default: 'false',
79
+ type: 'boolean'
80
+ },
81
+ DEBUG: {
82
+ description: 'Enable debug output, including nodemailer and API',
83
+ default: false,
84
+ type: 'boolean'
85
+ },
86
+ SMTP_HOST: {
87
+ description: 'Hostname of SMTP sending host',
88
+ default: 'localhost'
89
+ },
90
+ SMTP_PORT: {
91
+ description: 'SMTP host server port',
92
+ default: 587,
93
+ type: 'number'
94
+ },
95
+ SMTP_SECURE: {
96
+ description: 'Use secure connection to SMTP host (SSL/TSL)',
97
+ default: false,
98
+ type: 'boolean'
99
+ },
100
+ SMTP_TLS_REJECT: {
101
+ description: 'Reject bad cert/TLS connection to SMTP host',
102
+ default: false,
103
+ type: 'boolean'
104
+ },
105
+ SMTP_USER: {
106
+ description: 'Username for SMTP host',
107
+ default: ''
108
+ },
109
+ SMTP_PASSWORD: {
110
+ description: 'Password for SMTP host',
111
+ default: ''
112
+ },
113
+ UPLOAD_PATH: {
114
+ description: 'Path for attached files',
115
+ default: './uploads/'
116
+ }
117
+ });
@@ -0,0 +1,116 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+
4
+ import { EnvLoader, envConfig } from '@technomoron/env-loader';
5
+ import { createTransport, Transporter } from 'nodemailer';
6
+ import { Sequelize } from 'sequelize';
7
+
8
+ import { connect_api_db } from '../models/db.js';
9
+ import { importData } from '../models/init.js';
10
+
11
+ import { envOptions } from './envloader.js';
12
+
13
+ import type SMTPTransport from 'nodemailer/lib/smtp-transport';
14
+
15
+ interface api_key {
16
+ keyid: string;
17
+ uid: number;
18
+ domain: number;
19
+ }
20
+
21
+ function create_mail_transport(env: envConfig<typeof envOptions>): Transporter {
22
+ const args: SMTPTransport.Options = {
23
+ host: env.SMTP_HOST,
24
+ port: env.SMTP_PORT,
25
+ secure: env.SMTP_SECURE,
26
+ tls: {
27
+ rejectUnauthorized: env.SMTP_TLS_REJECT
28
+ },
29
+ requireTLS: true,
30
+ logger: env.DEBUG,
31
+ debug: env.DEBUG
32
+ };
33
+ const user = env.SMTP_USER;
34
+ const pass = env.SMTP_PASSWORD;
35
+ if (user && pass) {
36
+ args.auth = { user, pass };
37
+ }
38
+ // console.log(JSON.stringify(args, undefined, 2));
39
+
40
+ const mailer: Transporter = createTransport({
41
+ ...args
42
+ });
43
+ if (!mailer) {
44
+ throw new Error('Unable to create mailer');
45
+ }
46
+ return mailer;
47
+ }
48
+
49
+ export interface ImailStore {
50
+ env: envConfig<typeof envOptions>;
51
+ transport?: Transporter<SMTPTransport.SentMessageInfo>;
52
+ keys: Record<string, api_key>;
53
+ configpath: string;
54
+ }
55
+
56
+ export class mailStore implements ImailStore {
57
+ env!: envConfig<typeof envOptions>;
58
+ transport?: Transporter<SMTPTransport.SentMessageInfo>;
59
+ api_db: Sequelize | null = null;
60
+ keys: Record<string, api_key> = {};
61
+ configpath = '';
62
+
63
+ print_debug(msg: string) {
64
+ if (this.env.DEBUG) {
65
+ console.log(msg);
66
+ }
67
+ }
68
+
69
+ config_filename(name: string): string {
70
+ return path.resolve(path.join(this.configpath, name));
71
+ }
72
+
73
+ private async load_api_keys(cfgpath: string): Promise<Record<string, api_key>> {
74
+ const keyfile = path.resolve(cfgpath, 'api-keys.json');
75
+ if (fs.existsSync(keyfile)) {
76
+ const raw = fs.readFileSync(keyfile, 'utf-8');
77
+ const jsonData = JSON.parse(raw) as Record<string, api_key>;
78
+ this.print_debug(`API Key Database loaded from ${keyfile}`);
79
+ return jsonData;
80
+ }
81
+ this.print_debug(`No api-keys.json file found: tried ${keyfile}`);
82
+ return {};
83
+ }
84
+
85
+ async init(): Promise<this> {
86
+ const env = (this.env = await EnvLoader.createConfigProxy(envOptions, { debug: true }));
87
+ EnvLoader.genTemplate(envOptions, '.env-dist');
88
+ const p = env.CONFIG_PATH;
89
+ this.configpath = path.isAbsolute(p) ? p : path.join(process.cwd(), p);
90
+ console.log(`Config path is ${this.configpath}`);
91
+
92
+ // this.keys = await this.load_api_keys(this.configpath);
93
+
94
+ this.transport = await create_mail_transport(env);
95
+
96
+ this.api_db = await connect_api_db(this);
97
+
98
+ if (this.env.DB_AUTO_RELOAD) {
99
+ this.print_debug('Enabling auto reload of init-data.json');
100
+ fs.watchFile(this.config_filename('init-data.json'), { interval: 2000 }, () => {
101
+ this.print_debug('Config file changed, reloading...');
102
+ try {
103
+ importData(this);
104
+ } catch (err) {
105
+ this.print_debug(`Failed to reload config: ${err}`);
106
+ }
107
+ });
108
+ }
109
+
110
+ return this;
111
+ }
112
+
113
+ public get_api_key(key: string): api_key | null {
114
+ return this.keys[key] || null;
115
+ }
116
+ }
package/src/types.ts ADDED
@@ -0,0 +1,39 @@
1
+ import { ApiRequest } from '@technomoron/api-server-base';
2
+
3
+ import { api_domain } from './models/domain.js';
4
+ import { api_user } from './models/user.js';
5
+
6
+ export interface mailApiKey {
7
+ uid: number;
8
+ }
9
+
10
+ export interface mailApiRequest extends ApiRequest {
11
+ user?: api_user;
12
+ domain?: api_domain;
13
+ locale?: string;
14
+ }
15
+
16
+ export interface formType {
17
+ rcpt: string;
18
+ sender: string;
19
+ subject: string;
20
+ template: string;
21
+ }
22
+
23
+ export interface StoredFile {
24
+ filename: string;
25
+ path: string;
26
+ cid?: string;
27
+ }
28
+
29
+ export interface RequestMeta {
30
+ client_ip: string;
31
+ received_at: string;
32
+ ip_chain: string[];
33
+ }
34
+
35
+ export interface UploadedFile {
36
+ originalname: string;
37
+ path: string;
38
+ fieldname: string;
39
+ }
package/src/util.ts ADDED
@@ -0,0 +1,111 @@
1
+ import { api_domain } from './models/domain.js';
2
+ import { api_user } from './models/user.js';
3
+
4
+ import type { RequestMeta } from './types.js';
5
+
6
+ /**
7
+ * Normalize a string into a safe identifier for slugs, filenames, etc.
8
+ *
9
+ * - Lowercases all characters
10
+ * - Replaces any character that is not `a-z`, `0-9`, `-`, '.' or `_` with `-`
11
+ * - Collapses multiple consecutive dashes into one
12
+ * - Trims leading and trailing dashes
13
+ *
14
+ * Examples:
15
+ * normalizeSlug("Hello World!") -> "hello-world"
16
+ * normalizeSlug(" Áccêntš ") -> "ccnt"
17
+ * normalizeSlug("My--Slug__Test") -> "my-slug__test"
18
+ */
19
+ export function normalizeSlug(input: string): string {
20
+ if (!input) {
21
+ return '';
22
+ }
23
+ return input
24
+ .trim()
25
+ .toLowerCase()
26
+ .replace(/[^a-z0-9-_\.]/g, '-')
27
+ .replace(/--+/g, '-') // collapse multiple dashes
28
+ .replace(/^-+|-+$/g, ''); // trim leading/trailing dashes
29
+ }
30
+
31
+ export async function user_and_domain(domain_id: number): Promise<{ user: api_user; domain: api_domain }> {
32
+ const domain = await api_domain.findByPk(domain_id);
33
+ if (!domain) {
34
+ throw new Error(`Unable to look up domain ${domain_id}`);
35
+ }
36
+ const user = await api_user.findByPk(domain.user_id);
37
+ if (!user) {
38
+ throw new Error(`Unable to look up user ${domain.user_id}`);
39
+ }
40
+ return { user, domain };
41
+ }
42
+
43
+ type HeaderValue = string | string[] | undefined | null;
44
+
45
+ function collectHeaderIps(header: string | string[] | undefined): string[] {
46
+ if (!header) {
47
+ return [];
48
+ }
49
+ if (Array.isArray(header)) {
50
+ return header
51
+ .join(',')
52
+ .split(',')
53
+ .map((ip) => ip.trim())
54
+ .filter(Boolean);
55
+ }
56
+ return header
57
+ .split(',')
58
+ .map((ip) => ip.trim())
59
+ .filter(Boolean);
60
+ }
61
+
62
+ function resolveHeader(headers: Record<string, unknown>, key: string): string | string[] | undefined {
63
+ const direct = headers[key];
64
+ const alt = headers[key.toLowerCase()];
65
+ const value = direct ?? alt;
66
+ if (typeof value === 'string' || Array.isArray(value)) {
67
+ return value;
68
+ }
69
+ return undefined;
70
+ }
71
+
72
+ interface RequestLike {
73
+ headers?: Record<string, HeaderValue>;
74
+ ip?: string | null;
75
+ socket?: { remoteAddress?: string | null } | null;
76
+ }
77
+
78
+ export function buildRequestMeta(rawReq: unknown): RequestMeta {
79
+ const req = (rawReq ?? {}) as RequestLike;
80
+ const headers = req.headers ?? {};
81
+ const ips: string[] = [];
82
+ ips.push(...collectHeaderIps(resolveHeader(headers, 'x-forwarded-for')));
83
+ const realIp = resolveHeader(headers, 'x-real-ip');
84
+ if (typeof realIp === 'string' && realIp.trim()) {
85
+ ips.push(realIp.trim());
86
+ }
87
+ const cfIp = resolveHeader(headers, 'cf-connecting-ip');
88
+ if (typeof cfIp === 'string' && cfIp.trim()) {
89
+ ips.push(cfIp.trim());
90
+ }
91
+ const fastlyIp = resolveHeader(headers, 'fastly-client-ip');
92
+ if (typeof fastlyIp === 'string' && fastlyIp.trim()) {
93
+ ips.push(fastlyIp.trim());
94
+ }
95
+ if (req.ip && req.ip.trim()) {
96
+ ips.push(req.ip.trim());
97
+ }
98
+ const remoteAddress = req.socket?.remoteAddress;
99
+ if (remoteAddress) {
100
+ ips.push(remoteAddress);
101
+ }
102
+
103
+ const uniqueIps = ips.filter((ip, index) => ips.indexOf(ip) === index);
104
+ const clientIp = uniqueIps[0] || '';
105
+
106
+ return {
107
+ client_ip: clientIp,
108
+ received_at: new Date().toISOString(),
109
+ ip_chain: uniqueIps
110
+ };
111
+ }
package/test1.sh ADDED
@@ -0,0 +1,13 @@
1
+ #!/bin/sh
2
+
3
+ curl -X POST http://localhost:3776/api/v1/form/message \
4
+ -F "formid=testform" \
5
+ -F "domain=ml.yesmedia.no" \
6
+ -F "vars={\"x\":\"y\"}" \
7
+ -F "attachment1=@./tsconfig.json" \
8
+ -F "attachment2=@./config/testuser/ml.yesmedia.no/form-template/assets/3075977.png"
9
+
10
+ # -F "rcpt=bjornjac@pm.me" \
11
+ # -H "Authorization: Bearer apikey-j82lkIOjUuj34sd" \
12
+
13
+
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ES2022", // compile as ES modules
5
+ "moduleResolution": "node",
6
+ "outDir": "./dist",
7
+ "rootDir": "./src",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "allowSyntheticDefaultImports": true,
11
+ "typeRoots": ["./node_modules/@types", "./types"]
12
+ },
13
+ "include": ["src"]
14
+ }