@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.
- package/.do-realease.sh +10 -0
- package/.editorconfig +9 -0
- package/.env-dist +62 -0
- package/.prettierrc +14 -0
- package/.vscode/extensions.json +15 -0
- package/.vscode/settings.json +123 -0
- package/CHANGES +25 -0
- package/README.md +63 -0
- package/config-example/form-template/default.njk +102 -0
- package/config-example/forms.config.json +8 -0
- package/config-example/init-data.json +33 -0
- package/config-example/tx-template/default.njk +107 -0
- package/dist/api/forms.js +175 -0
- package/dist/api/mailer.js +213 -0
- package/dist/index.js +50 -0
- package/dist/models/db.js +99 -0
- package/dist/models/domain.js +58 -0
- package/dist/models/form.js +168 -0
- package/dist/models/init.js +176 -0
- package/dist/models/txmail.js +167 -0
- package/dist/models/user.js +65 -0
- package/dist/server.js +22 -0
- package/dist/store/envloader.js +116 -0
- package/dist/store/store.js +85 -0
- package/dist/types.js +1 -0
- package/dist/util.js +94 -0
- package/ecosystem.config.cjs +42 -0
- package/eslint.config.mjs +104 -0
- package/package.json +67 -0
- package/src/api/forms.ts +209 -0
- package/src/api/mailer.ts +242 -0
- package/src/index.ts +67 -0
- package/src/models/db.ts +112 -0
- package/src/models/domain.ts +72 -0
- package/src/models/form.ts +198 -0
- package/src/models/init.ts +237 -0
- package/src/models/txmail.ts +199 -0
- package/src/models/user.ts +79 -0
- package/src/server.ts +27 -0
- package/src/store/envloader.ts +117 -0
- package/src/store/store.ts +116 -0
- package/src/types.ts +39 -0
- package/src/util.ts +111 -0
- package/test1.sh +13 -0
- 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
|
+
}
|