@technomoron/mail-magic 1.0.9 → 1.0.11

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 +19 -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 +17 -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
@@ -1,206 +0,0 @@
1
- import path from 'path';
2
-
3
- import { Sequelize, Model, DataTypes } from 'sequelize';
4
- import { z } from 'zod';
5
-
6
- import { StoredFile } from '../types.js';
7
- import { user_and_domain, normalizeSlug } from '../util.js';
8
-
9
- export const api_txmail_schema = z.object({
10
- template_id: z.number().int().nonnegative(),
11
- user_id: z.number().int().nonnegative(),
12
- domain_id: z.number().int().nonnegative(),
13
- name: z.string().min(1),
14
- locale: z.string().default(''),
15
- template: z.string().default(''),
16
- filename: z.string().default(''),
17
- sender: z.string().min(1),
18
- subject: z.string(),
19
- slug: z.string().default(''),
20
- files: z
21
- .array(
22
- z.object({
23
- filename: z.string(),
24
- path: z.string(),
25
- cid: z.string().optional()
26
- })
27
- )
28
- .default([])
29
- });
30
-
31
- export type api_txmail_type = z.infer<typeof api_txmail_schema>;
32
- export class api_txmail extends Model {
33
- declare template_id: number;
34
- declare user_id: number;
35
- declare domain_id: number;
36
- declare name: string;
37
- declare locale: string;
38
- declare template: string;
39
- declare filename: string;
40
- declare sender: string;
41
- declare subject: string;
42
- declare slug: string;
43
- declare files: StoredFile[];
44
- }
45
-
46
- function assertSafeRelativePath(filename: string, label: string): string {
47
- const normalized = path.normalize(filename);
48
- if (path.isAbsolute(normalized)) {
49
- throw new Error(`${label} path must be relative`);
50
- }
51
- if (normalized.split(path.sep).includes('..')) {
52
- throw new Error(`${label} path cannot include '..' segments`);
53
- }
54
- return normalized;
55
- }
56
-
57
- export async function upsert_txmail(record: api_txmail_type): Promise<api_txmail> {
58
- const { user, domain } = await user_and_domain(record.domain_id);
59
-
60
- const idname = normalizeSlug(user.idname);
61
- const dname = normalizeSlug(domain.name);
62
- const name = normalizeSlug(record.name);
63
- const locale = normalizeSlug(record.locale || domain.locale || user.locale || '');
64
-
65
- if (!record.slug) {
66
- record.slug = `${idname}-${dname}${locale ? '-' + locale : ''}-${name}`;
67
- }
68
-
69
- if (!record.filename) {
70
- const parts = [dname, 'tx-template'];
71
- if (locale) parts.push(locale);
72
- parts.push(name);
73
- record.filename = path.join(...parts);
74
- } else {
75
- record.filename = path.join(dname, 'tx-template', record.filename);
76
- }
77
- if (!record.filename.endsWith('.njk')) {
78
- record.filename += '.njk';
79
- }
80
- record.filename = assertSafeRelativePath(record.filename, 'Template filename');
81
-
82
- const [instance] = await api_txmail.upsert(record);
83
- return instance;
84
- }
85
-
86
- export async function init_api_txmail(api_db: Sequelize): Promise<typeof api_txmail> {
87
- api_txmail.init(
88
- {
89
- template_id: {
90
- type: DataTypes.INTEGER,
91
- autoIncrement: true,
92
- allowNull: false,
93
- primaryKey: true
94
- },
95
- user_id: {
96
- type: DataTypes.INTEGER,
97
- allowNull: false,
98
- unique: false,
99
- references: {
100
- model: 'user',
101
- key: 'user_id'
102
- },
103
- onDelete: 'CASCADE',
104
- onUpdate: 'CASCADE'
105
- },
106
- domain_id: {
107
- type: DataTypes.INTEGER,
108
- allowNull: false,
109
- unique: false,
110
- references: {
111
- model: 'domain',
112
- key: 'domain_id'
113
- },
114
- onDelete: 'CASCADE',
115
- onUpdate: 'CASCADE'
116
- },
117
- name: {
118
- type: DataTypes.STRING,
119
- allowNull: false,
120
- unique: false
121
- },
122
- locale: {
123
- type: DataTypes.STRING,
124
- allowNull: false,
125
- defaultValue: '',
126
- unique: false
127
- },
128
- template: {
129
- type: DataTypes.TEXT,
130
- allowNull: false,
131
- defaultValue: ''
132
- },
133
- filename: {
134
- type: DataTypes.STRING,
135
- allowNull: false,
136
- defaultValue: ''
137
- },
138
- sender: {
139
- type: DataTypes.STRING,
140
- allowNull: false
141
- },
142
- subject: {
143
- type: DataTypes.STRING,
144
- allowNull: false,
145
- defaultValue: ''
146
- },
147
- slug: {
148
- type: DataTypes.STRING,
149
- allowNull: false,
150
- defaultValue: ''
151
- },
152
- part: {
153
- type: DataTypes.BOOLEAN,
154
- allowNull: false,
155
- defaultValue: false
156
- },
157
- files: {
158
- type: DataTypes.TEXT,
159
- allowNull: false,
160
- defaultValue: '[]',
161
- get() {
162
- const raw = this.getDataValue('files') as string | null;
163
- return raw ? (JSON.parse(raw) as StoredFile[]) : [];
164
- },
165
- set(value: StoredFile[] | null | undefined) {
166
- this.setDataValue('files', JSON.stringify(value ?? []));
167
- }
168
- }
169
- },
170
- {
171
- sequelize: api_db,
172
- tableName: 'txmail',
173
- charset: 'utf8mb4',
174
- collate: 'utf8mb4_unicode_ci',
175
- indexes: [
176
- {
177
- unique: true,
178
- fields: ['user_id', 'domain_id', 'locale', 'name']
179
- }
180
- ]
181
- }
182
- );
183
-
184
- api_txmail.addHook('beforeValidate', async (template: api_txmail) => {
185
- const { user, domain } = await user_and_domain(template.domain_id);
186
-
187
- const dname = normalizeSlug(domain.name);
188
- const name = normalizeSlug(template.name);
189
- const locale = normalizeSlug(template.locale || domain.locale || user.locale || '');
190
-
191
- template.slug ||= `${normalizeSlug(user.idname)}-${dname}${locale ? '-' + locale : ''}-${name}`;
192
-
193
- if (!template.filename) {
194
- const parts = [dname, 'tx-template'];
195
- if (locale) parts.push(locale);
196
- parts.push(name);
197
- template.filename = parts.join('/');
198
- }
199
- if (!template.filename.endsWith('.njk')) {
200
- template.filename += '.njk';
201
- }
202
- template.filename = assertSafeRelativePath(template.filename, 'Template filename');
203
- });
204
-
205
- return api_txmail;
206
- }
@@ -1,79 +0,0 @@
1
- import { Sequelize, Model, DataTypes } from 'sequelize';
2
- import { z } from 'zod';
3
-
4
- export const api_user_schema = z.object({
5
- user_id: z.number().int().nonnegative(),
6
- idname: z.string().min(1),
7
- token: z.string().min(1),
8
- name: z.string().min(1),
9
- email: z.string().email(),
10
- domain: z.number().int().nonnegative().nullable().optional(),
11
- locale: z.string().default('')
12
- });
13
-
14
- export type api_user_type = z.infer<typeof api_user_schema>;
15
- export class api_user extends Model {
16
- declare user_id: number;
17
- declare idname: string;
18
- declare token: string;
19
- declare name: string;
20
- declare email: string;
21
- declare domain: number;
22
- declare locale: string;
23
- }
24
-
25
- export async function init_api_user(api_db: Sequelize): Promise<typeof api_user> {
26
- await api_user.init(
27
- {
28
- user_id: {
29
- type: DataTypes.INTEGER,
30
- autoIncrement: true,
31
- allowNull: false,
32
- primaryKey: true
33
- },
34
- idname: {
35
- type: DataTypes.STRING,
36
- allowNull: false,
37
- defaultValue: ''
38
- },
39
- token: {
40
- type: DataTypes.STRING,
41
- allowNull: false,
42
- defaultValue: ''
43
- },
44
- name: {
45
- type: DataTypes.STRING,
46
- allowNull: false,
47
- defaultValue: ''
48
- },
49
- email: {
50
- type: DataTypes.STRING,
51
- allowNull: false,
52
- defaultValue: ''
53
- },
54
- domain: {
55
- type: DataTypes.INTEGER,
56
- allowNull: true,
57
- references: {
58
- model: 'domain',
59
- key: 'domain_id'
60
- },
61
- onDelete: 'SET NULL',
62
- onUpdate: 'CASCADE',
63
- defaultValue: null
64
- },
65
- locale: {
66
- type: DataTypes.STRING,
67
- allowNull: false,
68
- defaultValue: ''
69
- }
70
- },
71
- {
72
- sequelize: api_db,
73
- tableName: 'user',
74
- charset: 'utf8mb4',
75
- collate: 'utf8mb4_unicode_ci'
76
- }
77
- );
78
- return api_user;
79
- }
package/src/server.ts DELETED
@@ -1,27 +0,0 @@
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
- }
@@ -1,109 +0,0 @@
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
- SWAGGER_ENABLED: {
33
- description: 'Enable the Swagger/OpenAPI endpoint',
34
- type: 'boolean',
35
- default: false
36
- },
37
- SWAGGER_PATH: {
38
- description: 'Path to expose the Swagger/OpenAPI spec (default: /api/swagger when enabled)',
39
- default: ''
40
- },
41
- ASSET_ROUTE: {
42
- description: 'Route prefix exposed for config assets',
43
- default: '/asset'
44
- },
45
- CONFIG_PATH: {
46
- description: 'Path to directory where config files are located',
47
- default: './data/'
48
- },
49
- DB_USER: {
50
- description: 'Database username for API database'
51
- },
52
- DB_PASS: {
53
- description: 'Password for API database'
54
- },
55
- DB_NAME: {
56
- description: 'Name of API database. Filename for sqlite3, database name for others',
57
- default: 'maildata'
58
- },
59
- DB_HOST: {
60
- description: 'Host of API database',
61
- default: 'localhost'
62
- },
63
- DB_TYPE: {
64
- description: 'Database type of WP database',
65
- options: ['sqlite'],
66
- default: 'sqlite'
67
- },
68
- DB_LOG: {
69
- description: 'Log SQL statements',
70
- default: 'false',
71
- type: 'boolean'
72
- },
73
- DEBUG: {
74
- description: 'Enable debug output, including nodemailer and API',
75
- default: false,
76
- type: 'boolean'
77
- },
78
- SMTP_HOST: {
79
- description: 'Hostname of SMTP sending host',
80
- default: 'localhost'
81
- },
82
- SMTP_PORT: {
83
- description: 'SMTP host server port',
84
- default: 587,
85
- type: 'number'
86
- },
87
- SMTP_SECURE: {
88
- description: 'Use secure connection to SMTP host (SSL/TSL)',
89
- default: false,
90
- type: 'boolean'
91
- },
92
- SMTP_TLS_REJECT: {
93
- description: 'Reject bad cert/TLS connection to SMTP host',
94
- default: false,
95
- type: 'boolean'
96
- },
97
- SMTP_USER: {
98
- description: 'Username for SMTP host',
99
- default: ''
100
- },
101
- SMTP_PASSWORD: {
102
- description: 'Password for SMTP host',
103
- default: ''
104
- },
105
- UPLOAD_PATH: {
106
- description: 'Path for attached files. Use {domain} to scope per domain.',
107
- default: './{domain}/uploads'
108
- }
109
- });
@@ -1,195 +0,0 @@
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
- type UploadedFile = {
22
- path: string;
23
- filename?: string;
24
- destination?: string;
25
- };
26
-
27
- function create_mail_transport(env: envConfig<typeof envOptions>): Transporter {
28
- const args: SMTPTransport.Options = {
29
- host: env.SMTP_HOST,
30
- port: env.SMTP_PORT,
31
- secure: env.SMTP_SECURE,
32
- tls: {
33
- rejectUnauthorized: env.SMTP_TLS_REJECT
34
- },
35
- requireTLS: true,
36
- logger: env.DEBUG,
37
- debug: env.DEBUG
38
- };
39
- const user = env.SMTP_USER;
40
- const pass = env.SMTP_PASSWORD;
41
- if (user && pass) {
42
- args.auth = { user, pass };
43
- }
44
- // console.log(JSON.stringify(args, undefined, 2));
45
-
46
- const mailer: Transporter = createTransport({
47
- ...args
48
- });
49
- if (!mailer) {
50
- throw new Error('Unable to create mailer');
51
- }
52
- return mailer;
53
- }
54
-
55
- export interface ImailStore {
56
- env: envConfig<typeof envOptions>;
57
- transport?: Transporter<SMTPTransport.SentMessageInfo>;
58
- keys: Record<string, api_key>;
59
- configpath: string;
60
- deflocale?: string;
61
- uploadTemplate?: string;
62
- uploadStagingPath?: string;
63
- }
64
-
65
- export class mailStore implements ImailStore {
66
- env!: envConfig<typeof envOptions>;
67
- transport?: Transporter<SMTPTransport.SentMessageInfo>;
68
- api_db: Sequelize | null = null;
69
- keys: Record<string, api_key> = {};
70
- configpath = '';
71
- deflocale?: string;
72
- uploadTemplate?: string;
73
- uploadStagingPath?: string;
74
-
75
- print_debug(msg: string) {
76
- if (this.env.DEBUG) {
77
- console.log(msg);
78
- }
79
- }
80
-
81
- config_filename(name: string): string {
82
- return path.resolve(path.join(this.configpath, name));
83
- }
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
-
142
- private async load_api_keys(cfgpath: string): Promise<Record<string, api_key>> {
143
- const keyfile = path.resolve(cfgpath, 'api-keys.json');
144
- if (fs.existsSync(keyfile)) {
145
- const raw = fs.readFileSync(keyfile, 'utf-8');
146
- const jsonData = JSON.parse(raw) as Record<string, api_key>;
147
- this.print_debug(`API Key Database loaded from ${keyfile}`);
148
- return jsonData;
149
- }
150
- this.print_debug(`No api-keys.json file found: tried ${keyfile}`);
151
- return {};
152
- }
153
-
154
- async init(): Promise<this> {
155
- const env = (this.env = await EnvLoader.createConfigProxy(envOptions, { debug: true }));
156
- EnvLoader.genTemplate(envOptions, '.env-dist');
157
- const p = env.CONFIG_PATH;
158
- this.configpath = path.isAbsolute(p) ? p : path.resolve(process.cwd(), p);
159
- console.log(`Config path is ${this.configpath}`);
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
-
171
- // this.keys = await this.load_api_keys(this.configpath);
172
-
173
- this.transport = await create_mail_transport(env);
174
-
175
- this.api_db = await connect_api_db(this);
176
-
177
- if (this.env.DB_AUTO_RELOAD) {
178
- this.print_debug('Enabling auto reload of init-data.json');
179
- fs.watchFile(this.config_filename('init-data.json'), { interval: 2000 }, () => {
180
- this.print_debug('Config file changed, reloading...');
181
- try {
182
- importData(this);
183
- } catch (err) {
184
- this.print_debug(`Failed to reload config: ${err}`);
185
- }
186
- });
187
- }
188
-
189
- return this;
190
- }
191
-
192
- public get_api_key(key: string): api_key | null {
193
- return this.keys[key] || null;
194
- }
195
- }
package/src/types.ts DELETED
@@ -1,39 +0,0 @@
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
- }