@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,168 @@
1
+ import path from 'path';
2
+ import { Model, DataTypes } from 'sequelize';
3
+ import { z } from 'zod';
4
+ import { user_and_domain, normalizeSlug } from '../util.js';
5
+ export const api_form_schema = z.object({
6
+ form_id: z.number().int().nonnegative(),
7
+ user_id: z.number().int().nonnegative(),
8
+ domain_id: z.number().int().nonnegative(),
9
+ locale: z.string().default(''),
10
+ idname: z.string().min(1),
11
+ sender: z.string().min(1),
12
+ recipient: z.string().min(1),
13
+ subject: z.string(),
14
+ template: z.string().default(''),
15
+ filename: z.string().default(''),
16
+ slug: z.string().default(''),
17
+ secret: z.string().default(''),
18
+ files: z
19
+ .array(z.object({
20
+ filename: z.string(),
21
+ path: z.string(),
22
+ cid: z.string().optional()
23
+ }))
24
+ .default([])
25
+ });
26
+ export class api_form extends Model {
27
+ }
28
+ export async function init_api_form(api_db) {
29
+ api_form.init({
30
+ form_id: {
31
+ type: DataTypes.INTEGER,
32
+ autoIncrement: true,
33
+ allowNull: false,
34
+ primaryKey: true
35
+ },
36
+ user_id: {
37
+ type: DataTypes.INTEGER,
38
+ allowNull: false,
39
+ unique: false,
40
+ references: {
41
+ model: 'user',
42
+ key: 'user_id'
43
+ },
44
+ onDelete: 'CASCADE',
45
+ onUpdate: 'CASCADE'
46
+ },
47
+ domain_id: {
48
+ type: DataTypes.INTEGER,
49
+ allowNull: false,
50
+ unique: false,
51
+ references: {
52
+ model: 'domain',
53
+ key: 'domain_id'
54
+ },
55
+ onDelete: 'CASCADE',
56
+ onUpdate: 'CASCADE'
57
+ },
58
+ locale: {
59
+ type: DataTypes.STRING,
60
+ allowNull: false,
61
+ defaultValue: '',
62
+ unique: false
63
+ },
64
+ idname: {
65
+ type: DataTypes.STRING,
66
+ allowNull: false,
67
+ unique: false,
68
+ defaultValue: ''
69
+ },
70
+ sender: {
71
+ type: DataTypes.STRING,
72
+ allowNull: false,
73
+ defaultValue: ''
74
+ },
75
+ recipient: {
76
+ type: DataTypes.STRING,
77
+ allowNull: false,
78
+ defaultValue: ''
79
+ },
80
+ subject: {
81
+ type: DataTypes.STRING,
82
+ allowNull: false,
83
+ defaultValue: ''
84
+ },
85
+ filename: {
86
+ type: DataTypes.STRING,
87
+ allowNull: false,
88
+ defaultValue: ''
89
+ },
90
+ template: {
91
+ type: DataTypes.TEXT,
92
+ allowNull: false,
93
+ defaultValue: ''
94
+ },
95
+ slug: {
96
+ type: DataTypes.STRING,
97
+ allowNull: false,
98
+ defaultValue: ''
99
+ },
100
+ secret: {
101
+ type: DataTypes.STRING,
102
+ allowNull: false,
103
+ defaultValue: ''
104
+ },
105
+ files: {
106
+ type: DataTypes.TEXT,
107
+ allowNull: false,
108
+ defaultValue: '[]',
109
+ get() {
110
+ const raw = this.getDataValue('files');
111
+ return raw ? JSON.parse(raw) : [];
112
+ },
113
+ set(value) {
114
+ this.setDataValue('files', JSON.stringify(value ?? []));
115
+ }
116
+ }
117
+ }, {
118
+ sequelize: api_db,
119
+ tableName: 'form',
120
+ charset: 'utf8mb4',
121
+ collate: 'utf8mb4_unicode_ci',
122
+ indexes: [
123
+ {
124
+ unique: true,
125
+ fields: ['user_id', 'domain_id', 'locale', 'idname']
126
+ }
127
+ ]
128
+ });
129
+ return api_form;
130
+ }
131
+ export async function upsert_form(record) {
132
+ const { user, domain } = await user_and_domain(record.domain_id);
133
+ const idname = normalizeSlug(user.idname);
134
+ const dname = normalizeSlug(domain.name);
135
+ const name = normalizeSlug(record.idname);
136
+ const locale = normalizeSlug(record.locale || domain.locale || user.locale || '');
137
+ if (!record.slug) {
138
+ record.slug = `${idname}-${dname}${locale ? '-' + locale : ''}-${name}`;
139
+ }
140
+ if (!record.filename) {
141
+ const parts = [idname, dname, 'form-template'];
142
+ if (locale)
143
+ parts.push(locale);
144
+ parts.push(name);
145
+ record.filename = path.join(...parts);
146
+ }
147
+ else {
148
+ record.filename = path.join(idname, dname, 'form-template', record.filename);
149
+ }
150
+ if (!record.filename.endsWith('.njk')) {
151
+ record.filename += '.njk';
152
+ }
153
+ record.filename = path.normalize(record.filename);
154
+ let instance = null;
155
+ instance = await api_form.findByPk(record.form_id);
156
+ if (instance) {
157
+ await instance.update(record);
158
+ }
159
+ else {
160
+ console.log('CREATE', JSON.stringify(record, undefined, 2));
161
+ instance = await api_form.create(record);
162
+ console.log(`INSTANCE IS ${instance}`);
163
+ }
164
+ if (!instance) {
165
+ throw new Error(`Unable to update/create form ${record.form_id}`);
166
+ }
167
+ return instance;
168
+ }
@@ -0,0 +1,176 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { Unyuck } from '@technomoron/unyuck';
4
+ import { z } from 'zod';
5
+ import { user_and_domain } from '../util.js';
6
+ import { api_domain, api_domain_schema } from './domain.js';
7
+ import { api_form_schema, upsert_form } from './form.js';
8
+ import { api_txmail_schema, upsert_txmail } from './txmail.js';
9
+ import { api_user, api_user_schema } from './user.js';
10
+ const init_data_schema = z.object({
11
+ user: z.array(api_user_schema).default([]),
12
+ domain: z.array(api_domain_schema).default([]),
13
+ template: z.array(api_txmail_schema).default([]),
14
+ form: z.array(api_form_schema).default([])
15
+ });
16
+ /**
17
+ * Resolve an asset file within ./config/<userid>/<domain>/<type>/assets
18
+ */
19
+ function resolveAsset(basePath, type, domainName, assetName, locale) {
20
+ const searchPaths = [];
21
+ // always domain-scoped
22
+ if (locale) {
23
+ searchPaths.push(path.join(domainName, type, locale));
24
+ }
25
+ searchPaths.push(path.join(domainName, type));
26
+ // no domain fallback → do not leak assets between domains
27
+ // but allow locale fallbacks inside type
28
+ if (locale) {
29
+ searchPaths.push(path.join(type, locale));
30
+ }
31
+ searchPaths.push(type);
32
+ for (const p of searchPaths) {
33
+ const candidate = path.join(basePath, p, 'assets', assetName);
34
+ if (fs.existsSync(candidate)) {
35
+ return candidate;
36
+ }
37
+ }
38
+ return null;
39
+ }
40
+ function extractAndReplaceAssets(html, opts) {
41
+ const regex = /src=["']asset\(['"]([^'"]+)['"](?:,\s*(true|false|[01]))?\)["']/g;
42
+ const assets = [];
43
+ const replacedHtml = html.replace(regex, (_m, relPath, inlineFlag) => {
44
+ const fullPath = resolveAsset(opts.basePath, opts.type, opts.domainName, relPath, opts.locale ?? undefined);
45
+ if (!fullPath) {
46
+ throw new Error(`Missing asset "${relPath}"`);
47
+ }
48
+ const isInline = inlineFlag === 'true' || inlineFlag === '1';
49
+ const storedFile = {
50
+ filename: relPath,
51
+ path: fullPath,
52
+ cid: isInline ? relPath : undefined
53
+ };
54
+ assets.push(storedFile);
55
+ return isInline
56
+ ? `src="cid:${relPath}"`
57
+ : `src="${opts.apiUrl}/image/${opts.idname}/${opts.type}/` +
58
+ `${opts.domainName ? opts.domainName + '/' : ''}` +
59
+ `${opts.locale ? opts.locale + '/' : ''}` +
60
+ relPath +
61
+ '"';
62
+ });
63
+ return { html: replacedHtml, assets };
64
+ }
65
+ async function _load_template(store, filename, pathname, user, domain, locale, type) {
66
+ const rootDir = path.join(store.configpath, user.idname, domain.name, type);
67
+ let relFile = filename;
68
+ const prefix = path.join(user.idname, domain.name, type) + path.sep;
69
+ if (filename.startsWith(prefix)) {
70
+ relFile = filename.slice(prefix.length);
71
+ }
72
+ const absPath = path.resolve(rootDir, pathname || '', relFile);
73
+ if (!absPath.startsWith(rootDir)) {
74
+ throw new Error(`Invalid template path "${filename}"`);
75
+ }
76
+ if (!fs.existsSync(absPath)) {
77
+ throw new Error(`Missing template file "${absPath}"`);
78
+ }
79
+ const raw = fs.readFileSync(absPath, 'utf8');
80
+ if (!raw.trim()) {
81
+ throw new Error(`Template file "${absPath}" is empty`);
82
+ }
83
+ try {
84
+ const baseUserPath = path.join(store.configpath, user.idname);
85
+ const templateKey = path.relative(baseUserPath, absPath);
86
+ if (!templateKey) {
87
+ throw new Error(`Unable to resolve template path for "${absPath}"`);
88
+ }
89
+ const processor = new Unyuck({ basePath: baseUserPath });
90
+ const merged = processor.flattenNoAssets(templateKey);
91
+ const { html, assets } = extractAndReplaceAssets(merged, {
92
+ basePath: path.join(store.configpath, user.idname),
93
+ type,
94
+ domainName: domain.name,
95
+ locale,
96
+ apiUrl: store.env.API_URL,
97
+ idname: user.idname
98
+ });
99
+ return { html, assets };
100
+ }
101
+ catch (err) {
102
+ throw new Error(`Template "${absPath}" failed to preprocess: ${err.message}`);
103
+ }
104
+ }
105
+ export async function loadFormTemplate(store, form) {
106
+ const { user, domain } = await user_and_domain(form.domain_id);
107
+ const locale = form.locale || domain.locale || user.locale || null;
108
+ return _load_template(store, form.filename, '', user, domain, locale, 'form-template');
109
+ }
110
+ export async function loadTxTemplate(store, template) {
111
+ const { user, domain } = await user_and_domain(template.domain_id);
112
+ const locale = template.locale || domain.locale || user.locale || null;
113
+ return _load_template(store, template.filename, '', user, domain, locale, 'tx-template');
114
+ }
115
+ export async function importData(store) {
116
+ const initfile = path.join(store.configpath, 'init-data.json');
117
+ if (fs.existsSync(initfile)) {
118
+ store.print_debug(`Loading init data from ${initfile}`);
119
+ const data = await fs.promises.readFile(initfile, 'utf8');
120
+ let records;
121
+ try {
122
+ records = init_data_schema.parse(JSON.parse(data));
123
+ }
124
+ catch (err) {
125
+ store.print_debug(`Invalid init-data.json: ${err}`);
126
+ return;
127
+ }
128
+ const pendingUserDomains = [];
129
+ if (records.user) {
130
+ store.print_debug('Creating user records');
131
+ for (const record of records.user) {
132
+ const { domain, ...userWithoutDomain } = record;
133
+ await api_user.upsert({ ...userWithoutDomain, domain: null });
134
+ if (typeof domain === 'number') {
135
+ pendingUserDomains.push({ user_id: record.user_id, domain });
136
+ }
137
+ }
138
+ }
139
+ if (records.domain) {
140
+ store.print_debug('Creating domain records');
141
+ for (const record of records.domain) {
142
+ await api_domain.upsert(record);
143
+ }
144
+ }
145
+ if (pendingUserDomains.length) {
146
+ store.print_debug('Linking user default domains');
147
+ for (const entry of pendingUserDomains) {
148
+ await api_user.update({ domain: entry.domain }, { where: { user_id: entry.user_id } });
149
+ }
150
+ }
151
+ if (records.template) {
152
+ store.print_debug('Creating template records');
153
+ for (const record of records.template) {
154
+ const fixed = await upsert_txmail(record);
155
+ if (!fixed.template) {
156
+ const { html, assets } = await loadTxTemplate(store, fixed);
157
+ await fixed.update({ template: html, files: assets });
158
+ }
159
+ }
160
+ }
161
+ if (records.form) {
162
+ store.print_debug('Creating form records');
163
+ for (const record of records.form) {
164
+ const fixed = await upsert_form(record);
165
+ if (!fixed.template) {
166
+ const { html, assets } = await loadFormTemplate(store, fixed);
167
+ await fixed.update({ template: html, files: assets });
168
+ }
169
+ }
170
+ }
171
+ store.print_debug('Initdata upserted successfully.');
172
+ }
173
+ else {
174
+ store.print_debug(`No init data file, tried ${initfile}`);
175
+ }
176
+ }
@@ -0,0 +1,167 @@
1
+ import path from 'path';
2
+ import { Model, DataTypes } from 'sequelize';
3
+ import { z } from 'zod';
4
+ import { user_and_domain, normalizeSlug } from '../util.js';
5
+ export const api_txmail_schema = z.object({
6
+ template_id: z.number().int().nonnegative(),
7
+ user_id: z.number().int().nonnegative(),
8
+ domain_id: z.number().int().nonnegative(),
9
+ name: z.string().min(1),
10
+ locale: z.string().default(''),
11
+ template: z.string().default(''),
12
+ filename: z.string().default(''),
13
+ sender: z.string().min(1),
14
+ subject: z.string(),
15
+ slug: z.string().default(''),
16
+ files: z
17
+ .array(z.object({
18
+ filename: z.string(),
19
+ path: z.string(),
20
+ cid: z.string().optional()
21
+ }))
22
+ .default([])
23
+ });
24
+ export class api_txmail extends Model {
25
+ }
26
+ export async function upsert_txmail(record) {
27
+ const { user, domain } = await user_and_domain(record.domain_id);
28
+ const idname = normalizeSlug(user.idname);
29
+ const dname = normalizeSlug(domain.name);
30
+ const name = normalizeSlug(record.name);
31
+ const locale = normalizeSlug(record.locale || domain.locale || user.locale || '');
32
+ if (!record.slug) {
33
+ record.slug = `${idname}-${dname}${locale ? '-' + locale : ''}-${name}`;
34
+ }
35
+ if (!record.filename) {
36
+ const parts = [idname, dname, 'tx-template'];
37
+ if (locale)
38
+ parts.push(locale);
39
+ parts.push(name);
40
+ record.filename = path.join(...parts);
41
+ }
42
+ else {
43
+ record.filename = path.join(idname, dname, 'tx-template', record.filename);
44
+ }
45
+ if (!record.filename.endsWith('.njk')) {
46
+ record.filename += '.njk';
47
+ }
48
+ record.filename = path.normalize(record.filename);
49
+ const [instance] = await api_txmail.upsert(record);
50
+ return instance;
51
+ }
52
+ export async function init_api_txmail(api_db) {
53
+ api_txmail.init({
54
+ template_id: {
55
+ type: DataTypes.INTEGER,
56
+ autoIncrement: true,
57
+ allowNull: false,
58
+ primaryKey: true
59
+ },
60
+ user_id: {
61
+ type: DataTypes.INTEGER,
62
+ allowNull: false,
63
+ unique: false,
64
+ references: {
65
+ model: 'user',
66
+ key: 'user_id'
67
+ },
68
+ onDelete: 'CASCADE',
69
+ onUpdate: 'CASCADE'
70
+ },
71
+ domain_id: {
72
+ type: DataTypes.INTEGER,
73
+ allowNull: false,
74
+ unique: false,
75
+ references: {
76
+ model: 'domain',
77
+ key: 'domain_id'
78
+ },
79
+ onDelete: 'CASCADE',
80
+ onUpdate: 'CASCADE'
81
+ },
82
+ name: {
83
+ type: DataTypes.STRING,
84
+ allowNull: false,
85
+ unique: false
86
+ },
87
+ locale: {
88
+ type: DataTypes.STRING,
89
+ allowNull: false,
90
+ defaultValue: '',
91
+ unique: false
92
+ },
93
+ template: {
94
+ type: DataTypes.STRING,
95
+ allowNull: false,
96
+ defaultValue: ''
97
+ },
98
+ filename: {
99
+ type: DataTypes.STRING,
100
+ allowNull: false,
101
+ defaultValue: ''
102
+ },
103
+ sender: {
104
+ type: DataTypes.STRING,
105
+ allowNull: false
106
+ },
107
+ subject: {
108
+ type: DataTypes.STRING,
109
+ allowNull: false,
110
+ defaultValue: ''
111
+ },
112
+ slug: {
113
+ type: DataTypes.STRING,
114
+ allowNull: false,
115
+ defaultValue: ''
116
+ },
117
+ part: {
118
+ type: DataTypes.BOOLEAN,
119
+ allowNull: false,
120
+ defaultValue: false
121
+ },
122
+ files: {
123
+ type: DataTypes.TEXT,
124
+ allowNull: false,
125
+ defaultValue: '[]',
126
+ get() {
127
+ const raw = this.getDataValue('files');
128
+ return raw ? JSON.parse(raw) : [];
129
+ },
130
+ set(value) {
131
+ this.setDataValue('files', JSON.stringify(value ?? []));
132
+ }
133
+ }
134
+ }, {
135
+ sequelize: api_db,
136
+ tableName: 'txmail',
137
+ charset: 'utf8mb4',
138
+ collate: 'utf8mb4_unicode_ci',
139
+ indexes: [
140
+ {
141
+ unique: true,
142
+ fields: ['user_id', 'domain_id', 'locale', 'name']
143
+ }
144
+ ]
145
+ });
146
+ api_txmail.addHook('beforeValidate', async (template) => {
147
+ const { user, domain } = await user_and_domain(template.domain_id);
148
+ console.log('HERE');
149
+ const idname = normalizeSlug(user.idname);
150
+ const dname = normalizeSlug(domain.name);
151
+ const name = normalizeSlug(template.name);
152
+ const locale = normalizeSlug(template.locale || domain.locale || user.locale || '');
153
+ template.slug ||= `${idname}-${dname}${locale ? '-' + locale : ''}-${name}`;
154
+ if (!template.filename) {
155
+ const parts = [idname, dname, 'tx-template'];
156
+ if (locale)
157
+ parts.push(locale);
158
+ parts.push(name);
159
+ template.filename = parts.join('/');
160
+ }
161
+ if (!template.filename.endsWith('.njk')) {
162
+ template.filename += '.njk';
163
+ }
164
+ console.log(`FILENAME IS: ${template.filename}`);
165
+ });
166
+ return api_txmail;
167
+ }
@@ -0,0 +1,65 @@
1
+ import { Model, DataTypes } from 'sequelize';
2
+ import { z } from 'zod';
3
+ export const api_user_schema = z.object({
4
+ user_id: z.number().int().nonnegative(),
5
+ idname: z.string().min(1),
6
+ token: z.string().min(1),
7
+ name: z.string().min(1),
8
+ email: z.string().email(),
9
+ domain: z.number().int().nonnegative().nullable().optional(),
10
+ locale: z.string().default('')
11
+ });
12
+ export class api_user extends Model {
13
+ }
14
+ export async function init_api_user(api_db) {
15
+ await api_user.init({
16
+ user_id: {
17
+ type: DataTypes.INTEGER,
18
+ autoIncrement: true,
19
+ allowNull: false,
20
+ primaryKey: true
21
+ },
22
+ idname: {
23
+ type: DataTypes.STRING,
24
+ allowNull: false,
25
+ defaultValue: ''
26
+ },
27
+ token: {
28
+ type: DataTypes.STRING,
29
+ allowNull: false,
30
+ defaultValue: ''
31
+ },
32
+ name: {
33
+ type: DataTypes.STRING,
34
+ allowNull: false,
35
+ defaultValue: ''
36
+ },
37
+ email: {
38
+ type: DataTypes.STRING,
39
+ allowNull: false,
40
+ defaultValue: ''
41
+ },
42
+ domain: {
43
+ type: DataTypes.INTEGER,
44
+ allowNull: true,
45
+ references: {
46
+ model: 'domain',
47
+ key: 'domain_id'
48
+ },
49
+ onDelete: 'SET NULL',
50
+ onUpdate: 'CASCADE',
51
+ defaultValue: null
52
+ },
53
+ locale: {
54
+ type: DataTypes.STRING,
55
+ allowNull: false,
56
+ defaultValue: ''
57
+ }
58
+ }, {
59
+ sequelize: api_db,
60
+ tableName: 'user',
61
+ charset: 'utf8mb4',
62
+ collate: 'utf8mb4_unicode_ci'
63
+ });
64
+ return api_user;
65
+ }
package/dist/server.js ADDED
@@ -0,0 +1,22 @@
1
+ import { ApiServer } from '@technomoron/api-server-base';
2
+ import { 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 ${token}`);
13
+ const user = await api_user.findOne({ where: { token: token } });
14
+ if (!user) {
15
+ this.storage.print_debug(`Unable to find user for token ${token}`);
16
+ return null;
17
+ }
18
+ else {
19
+ return { uid: user.user_id };
20
+ }
21
+ }
22
+ }