@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
package/src/api/forms.ts
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
|
|
3
|
+
import { ApiRoute, ApiRequest, ApiModule, ApiError } from '@technomoron/api-server-base';
|
|
4
|
+
import nunjucks from 'nunjucks';
|
|
5
|
+
|
|
6
|
+
import { api_domain } from '../models/domain.js';
|
|
7
|
+
import { api_form } from '../models/form.js';
|
|
8
|
+
import { api_user } from '../models/user.js';
|
|
9
|
+
import { mailApiServer } from '../server.js';
|
|
10
|
+
import { buildRequestMeta, normalizeSlug } from '../util.js';
|
|
11
|
+
|
|
12
|
+
import type { mailApiRequest, UploadedFile } from '../types.js';
|
|
13
|
+
|
|
14
|
+
export class FormAPI extends ApiModule<mailApiServer> {
|
|
15
|
+
private async assertDomainAndUser(apireq: mailApiRequest): Promise<void> {
|
|
16
|
+
const { domain, locale } = apireq.req.body;
|
|
17
|
+
|
|
18
|
+
if (!domain) {
|
|
19
|
+
throw new ApiError({ code: 401, message: 'Missing domain' });
|
|
20
|
+
}
|
|
21
|
+
const user = await api_user.findOne({ where: { token: apireq.token } });
|
|
22
|
+
if (!user) {
|
|
23
|
+
throw new ApiError({ code: 401, message: `Invalid/Unknown API Key/Token '${apireq.token}'` });
|
|
24
|
+
}
|
|
25
|
+
const dbdomain = await api_domain.findOne({ where: { name: domain } });
|
|
26
|
+
if (!dbdomain) {
|
|
27
|
+
throw new ApiError({ code: 401, message: `Unable to look up the domain ${domain}` });
|
|
28
|
+
}
|
|
29
|
+
apireq.domain = dbdomain;
|
|
30
|
+
apireq.locale = locale || 'en';
|
|
31
|
+
apireq.user = user;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
private async postFormTemplate(apireq: mailApiRequest): Promise<[number, { Status: string }]> {
|
|
35
|
+
await this.assertDomainAndUser(apireq);
|
|
36
|
+
|
|
37
|
+
const {
|
|
38
|
+
template,
|
|
39
|
+
sender = '',
|
|
40
|
+
recipient = '',
|
|
41
|
+
idname,
|
|
42
|
+
subject = '',
|
|
43
|
+
locale = '',
|
|
44
|
+
secret = ''
|
|
45
|
+
} = apireq.req.body;
|
|
46
|
+
|
|
47
|
+
if (!template) {
|
|
48
|
+
throw new ApiError({ code: 400, message: 'Missing template data' });
|
|
49
|
+
}
|
|
50
|
+
if (!idname) {
|
|
51
|
+
throw new ApiError({ code: 400, message: 'Missing form identifier' });
|
|
52
|
+
}
|
|
53
|
+
if (!sender) {
|
|
54
|
+
throw new ApiError({ code: 400, message: 'Missing sender address' });
|
|
55
|
+
}
|
|
56
|
+
if (!recipient) {
|
|
57
|
+
throw new ApiError({ code: 400, message: 'Missing recipient address' });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const user = apireq.user!;
|
|
61
|
+
const domain = apireq.domain!;
|
|
62
|
+
const resolvedLocale = locale || apireq.locale || '';
|
|
63
|
+
const userSlug = normalizeSlug(user.idname);
|
|
64
|
+
const domainSlug = normalizeSlug(domain.name);
|
|
65
|
+
const formSlug = normalizeSlug(idname);
|
|
66
|
+
const localeSlug = normalizeSlug(resolvedLocale || domain.locale || user.locale || '');
|
|
67
|
+
const slug = `${userSlug}-${domainSlug}${localeSlug ? '-' + localeSlug : ''}-${formSlug}`;
|
|
68
|
+
const filenameParts = [userSlug, domainSlug, 'form-template'];
|
|
69
|
+
if (localeSlug) {
|
|
70
|
+
filenameParts.push(localeSlug);
|
|
71
|
+
}
|
|
72
|
+
filenameParts.push(formSlug);
|
|
73
|
+
let filename = path.join(...filenameParts);
|
|
74
|
+
if (!filename.endsWith('.njk')) {
|
|
75
|
+
filename += '.njk';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const record = {
|
|
79
|
+
user_id: user.user_id,
|
|
80
|
+
domain_id: domain.domain_id,
|
|
81
|
+
locale: localeSlug,
|
|
82
|
+
idname,
|
|
83
|
+
sender,
|
|
84
|
+
recipient,
|
|
85
|
+
subject,
|
|
86
|
+
template,
|
|
87
|
+
slug,
|
|
88
|
+
filename,
|
|
89
|
+
secret,
|
|
90
|
+
files: []
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
const [form, created] = await api_form.upsert(record, {
|
|
95
|
+
returning: true,
|
|
96
|
+
conflictFields: ['user_id', 'domain_id', 'locale', 'idname']
|
|
97
|
+
});
|
|
98
|
+
this.server.storage.print_debug(`Form template upserted: ${form.idname} (created=${created})`);
|
|
99
|
+
} catch (error: unknown) {
|
|
100
|
+
throw new ApiError({
|
|
101
|
+
code: 500,
|
|
102
|
+
message: this.server!.guessExceptionText(error, 'Unknown Sequelize Error on upsert form template')
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return [200, { Status: 'OK' }];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private async postSendForm(apireq: ApiRequest): Promise<[number, Record<string, unknown>]> {
|
|
110
|
+
const { formid, secret, recipient, vars = {} } = apireq.req.body;
|
|
111
|
+
|
|
112
|
+
if (!formid) {
|
|
113
|
+
throw new ApiError({ code: 404, message: 'Missing formid field in form' });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const form = await api_form.findOne({ where: { idname: formid } });
|
|
117
|
+
if (!form) {
|
|
118
|
+
throw new ApiError({ code: 404, message: `No such form: ${formid}` });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (form.secret && !secret) {
|
|
122
|
+
throw new ApiError({ code: 401, message: 'This form requires a secret key' });
|
|
123
|
+
}
|
|
124
|
+
if (form.secret && form.secret !== secret) {
|
|
125
|
+
throw new ApiError({ code: 401, message: 'Bad form secret' });
|
|
126
|
+
}
|
|
127
|
+
if (recipient && !form.secret) {
|
|
128
|
+
throw new ApiError({ code: 401, message: "'recipient' parameterer requires form secret to be set" });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let parsedVars: unknown = vars ?? {};
|
|
132
|
+
if (typeof vars === 'string') {
|
|
133
|
+
try {
|
|
134
|
+
parsedVars = JSON.parse(vars);
|
|
135
|
+
} catch (error) {
|
|
136
|
+
throw new ApiError({ code: 400, message: 'Invalid JSON provided in "vars"' });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const thevars = parsedVars as Record<string, unknown>;
|
|
140
|
+
|
|
141
|
+
/*
|
|
142
|
+
console.log('Headers:', apireq.req.headers);
|
|
143
|
+
console.log('Body:', JSON.stringify(apireq.req.body, null, 2));
|
|
144
|
+
console.log('Files:', JSON.stringify(apireq.req.files, null, 2));
|
|
145
|
+
*/
|
|
146
|
+
|
|
147
|
+
const rawFiles = Array.isArray(apireq.req.files) ? (apireq.req.files as UploadedFile[]) : [];
|
|
148
|
+
const attachments = rawFiles.map((file) => ({
|
|
149
|
+
filename: file.originalname,
|
|
150
|
+
path: file.path
|
|
151
|
+
}));
|
|
152
|
+
|
|
153
|
+
const attachmentMap: Record<string, string> = {};
|
|
154
|
+
for (const file of rawFiles) {
|
|
155
|
+
attachmentMap[file.fieldname] = file.originalname;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const meta = buildRequestMeta(apireq.req);
|
|
159
|
+
|
|
160
|
+
const context = {
|
|
161
|
+
...thevars,
|
|
162
|
+
_rcpt_email_: recipient,
|
|
163
|
+
_attachments_: attachmentMap,
|
|
164
|
+
_vars_: thevars,
|
|
165
|
+
_fields_: apireq.req.body,
|
|
166
|
+
_files_: rawFiles,
|
|
167
|
+
_meta_: meta
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
nunjucks.configure({ autoescape: true });
|
|
171
|
+
const html = nunjucks.renderString(form.template, context);
|
|
172
|
+
|
|
173
|
+
const mailOptions = {
|
|
174
|
+
from: form.sender,
|
|
175
|
+
to: recipient || form.recipient,
|
|
176
|
+
subject: form.subject,
|
|
177
|
+
html,
|
|
178
|
+
attachments
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
const info = await this.server.storage.transport!.sendMail(mailOptions);
|
|
183
|
+
this.server.storage.print_debug('Email sent: ' + info.response);
|
|
184
|
+
} catch (error) {
|
|
185
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
186
|
+
this.server.storage.print_debug('Error sending email: ' + errorMessage);
|
|
187
|
+
return [500, { error: `Error sending email: ${errorMessage}` }];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return [200, {}];
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
override defineRoutes(): ApiRoute[] {
|
|
194
|
+
return [
|
|
195
|
+
{
|
|
196
|
+
method: 'post',
|
|
197
|
+
path: '/v1/form/template',
|
|
198
|
+
handler: (req) => this.postFormTemplate(req as mailApiRequest),
|
|
199
|
+
auth: { type: 'yes', req: 'any' }
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
method: 'post',
|
|
203
|
+
path: '/v1/form/message',
|
|
204
|
+
handler: (req) => this.postSendForm(req),
|
|
205
|
+
auth: { type: 'none', req: 'any' }
|
|
206
|
+
}
|
|
207
|
+
];
|
|
208
|
+
}
|
|
209
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { ApiModule, ApiRoute, ApiError } from '@technomoron/api-server-base';
|
|
2
|
+
import emailAddresses, { ParsedMailbox } from 'email-addresses';
|
|
3
|
+
import { convert } from 'html-to-text';
|
|
4
|
+
import nunjucks from 'nunjucks';
|
|
5
|
+
|
|
6
|
+
import { api_domain } from '../models/domain.js';
|
|
7
|
+
import { api_txmail } from '../models/txmail.js';
|
|
8
|
+
import { api_user } from '../models/user.js';
|
|
9
|
+
import { mailApiServer } from '../server.js';
|
|
10
|
+
import { buildRequestMeta } from '../util.js';
|
|
11
|
+
|
|
12
|
+
import type { mailApiRequest, UploadedFile } from '../types.js';
|
|
13
|
+
|
|
14
|
+
export class MailerAPI extends ApiModule<mailApiServer> {
|
|
15
|
+
//
|
|
16
|
+
// Validate and return the parsed email address
|
|
17
|
+
//
|
|
18
|
+
validateEmail(email: string): string | null {
|
|
19
|
+
const parsed = emailAddresses.parseOneAddress(email);
|
|
20
|
+
if (parsed) {
|
|
21
|
+
return (parsed as ParsedMailbox).address;
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
//
|
|
27
|
+
// Validate a set of email addresses. Return arrays of invalid
|
|
28
|
+
// and valid email addresses.
|
|
29
|
+
//
|
|
30
|
+
|
|
31
|
+
validateEmails(list: string): { valid: string[]; invalid: string[] } {
|
|
32
|
+
const valid = [] as string[],
|
|
33
|
+
invalid = [] as string[];
|
|
34
|
+
|
|
35
|
+
const emails = list
|
|
36
|
+
.split(',')
|
|
37
|
+
.map((email) => email.trim())
|
|
38
|
+
.filter((email) => email !== '');
|
|
39
|
+
emails.forEach((email) => {
|
|
40
|
+
const addr = this.validateEmail(email);
|
|
41
|
+
if (addr) {
|
|
42
|
+
valid.push(addr);
|
|
43
|
+
} else {
|
|
44
|
+
invalid.push(email);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
return { valid, invalid };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async assert_domain_and_user(apireq: mailApiRequest) {
|
|
51
|
+
const { domain, locale } = apireq.req.body;
|
|
52
|
+
|
|
53
|
+
if (!domain) {
|
|
54
|
+
throw new ApiError({ code: 401, message: 'Missing domain' });
|
|
55
|
+
}
|
|
56
|
+
const user = await api_user.findOne({ where: { token: apireq.token } });
|
|
57
|
+
if (!user) {
|
|
58
|
+
throw new ApiError({ code: 401, message: `Invalid/Unknown API Key/Token '${apireq.token}'` });
|
|
59
|
+
}
|
|
60
|
+
const dbdomain = await api_domain.findOne({ where: { name: domain } });
|
|
61
|
+
if (!dbdomain) {
|
|
62
|
+
throw new ApiError({ code: 401, message: `Unable to look up the domain ${domain}` });
|
|
63
|
+
}
|
|
64
|
+
apireq.domain = dbdomain;
|
|
65
|
+
apireq.locale = locale || 'en';
|
|
66
|
+
apireq.user = user;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Store a template in the database
|
|
70
|
+
|
|
71
|
+
private async post_template(apireq: mailApiRequest): Promise<[number, { Status: string }]> {
|
|
72
|
+
await this.assert_domain_and_user(apireq);
|
|
73
|
+
|
|
74
|
+
const { template, sender = '', name, subject = '', locale = '' } = apireq.req.body;
|
|
75
|
+
|
|
76
|
+
if (!template) {
|
|
77
|
+
throw new ApiError({ code: 400, message: 'Missing template data' });
|
|
78
|
+
}
|
|
79
|
+
if (!name) {
|
|
80
|
+
throw new ApiError({ code: 400, message: 'Missing template name' });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const data = {
|
|
84
|
+
user_id: apireq.user!.user_id,
|
|
85
|
+
domain_id: apireq.domain!.domain_id,
|
|
86
|
+
name,
|
|
87
|
+
subject,
|
|
88
|
+
locale,
|
|
89
|
+
sender,
|
|
90
|
+
template
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/*
|
|
94
|
+
console.log(JSON.stringify({
|
|
95
|
+
user: apireq.user,
|
|
96
|
+
domain: apireq.domain,
|
|
97
|
+
domain_id: apireq.domain.domain_id,
|
|
98
|
+
data
|
|
99
|
+
}, undefined, 2)); */
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
const [templateRecord, created] = await api_txmail.upsert(data, {
|
|
103
|
+
returning: true
|
|
104
|
+
});
|
|
105
|
+
console.log('Template upserted:', templateRecord.name, 'Created:', created);
|
|
106
|
+
} catch (error: unknown) {
|
|
107
|
+
throw new ApiError({
|
|
108
|
+
code: 500,
|
|
109
|
+
message: this.server!.guessExceptionText(error, 'Unknown Sequelize Error on upsert template')
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
return [200, { Status: 'OK' }];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Send a template using posted arguments.
|
|
116
|
+
|
|
117
|
+
private async post_send(apireq: mailApiRequest): Promise<[number, Record<string, unknown>]> {
|
|
118
|
+
await this.assert_domain_and_user(apireq);
|
|
119
|
+
|
|
120
|
+
const { name, rcpt, domain = '', locale = '', vars = {} } = apireq.req.body;
|
|
121
|
+
|
|
122
|
+
if (!name || !rcpt || !domain) {
|
|
123
|
+
throw new ApiError({ code: 400, message: 'name/rcpt/domain required' });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let parsedVars: unknown = vars ?? {};
|
|
127
|
+
if (typeof vars === 'string') {
|
|
128
|
+
try {
|
|
129
|
+
parsedVars = JSON.parse(vars);
|
|
130
|
+
} catch (error) {
|
|
131
|
+
throw new ApiError({ code: 400, message: 'Invalid JSON provided in "vars"' });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const thevars = parsedVars as Record<string, unknown>;
|
|
135
|
+
|
|
136
|
+
// const dbdomain = await api_domain.findOne({ where: { domain } });
|
|
137
|
+
|
|
138
|
+
const { valid, invalid } = this.validateEmails(rcpt);
|
|
139
|
+
if (invalid.length > 0) {
|
|
140
|
+
throw new ApiError({ code: 400, message: 'Invalid email address(es): ' + invalid.join(',') });
|
|
141
|
+
}
|
|
142
|
+
let template: api_txmail | null = null;
|
|
143
|
+
const deflocale = apireq.server.store.deflocale || '';
|
|
144
|
+
const domain_id = apireq.domain!.domain_id;
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
template =
|
|
148
|
+
(await api_txmail.findOne({ where: { name, domain_id, locale } })) ||
|
|
149
|
+
(await api_txmail.findOne({ where: { name, domain_id, locale: deflocale } })) ||
|
|
150
|
+
(await api_txmail.findOne({ where: { name, domain_id } }));
|
|
151
|
+
} catch (error: unknown) {
|
|
152
|
+
throw new ApiError({
|
|
153
|
+
code: 500,
|
|
154
|
+
message: this.server!.guessExceptionText(error, 'Unknown Sequelize Error')
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
if (!template) {
|
|
158
|
+
throw new ApiError({
|
|
159
|
+
code: 404,
|
|
160
|
+
message: `Template "${name}" not found for any locale in domain "${domain}"`
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const sender = template.sender || apireq.domain!.sender || apireq.user!.email;
|
|
165
|
+
if (!sender) {
|
|
166
|
+
throw new ApiError({ code: 500, message: `Unable to locate sender for ${template.name}` });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const rawFiles = Array.isArray(apireq.req.files) ? (apireq.req.files as UploadedFile[]) : [];
|
|
170
|
+
const templateAssets = Array.isArray(template.files) ? template.files : [];
|
|
171
|
+
const attachments = [
|
|
172
|
+
...templateAssets.map((file) => ({
|
|
173
|
+
filename: file.filename,
|
|
174
|
+
path: file.path,
|
|
175
|
+
cid: file.cid
|
|
176
|
+
})),
|
|
177
|
+
...rawFiles.map((file) => ({
|
|
178
|
+
filename: file.originalname,
|
|
179
|
+
path: file.path
|
|
180
|
+
}))
|
|
181
|
+
];
|
|
182
|
+
|
|
183
|
+
const attachmentMap: Record<string, string> = {};
|
|
184
|
+
for (const file of rawFiles) {
|
|
185
|
+
attachmentMap[file.fieldname] = file.originalname;
|
|
186
|
+
}
|
|
187
|
+
console.log(JSON.stringify({ vars, thevars }, undefined, 2));
|
|
188
|
+
|
|
189
|
+
const meta = buildRequestMeta(apireq.req);
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
const env = new nunjucks.Environment(null, { autoescape: false });
|
|
193
|
+
|
|
194
|
+
const compiled = nunjucks.compile(template.template, env);
|
|
195
|
+
|
|
196
|
+
for (const recipient of valid) {
|
|
197
|
+
const fullargs = {
|
|
198
|
+
...thevars,
|
|
199
|
+
_rcpt_email_: recipient,
|
|
200
|
+
_attachments_: attachmentMap,
|
|
201
|
+
_vars_: thevars,
|
|
202
|
+
_meta_: meta
|
|
203
|
+
};
|
|
204
|
+
const html = await compiled.render(fullargs);
|
|
205
|
+
const text = convert(html);
|
|
206
|
+
const sendargs = {
|
|
207
|
+
from: sender,
|
|
208
|
+
to: recipient,
|
|
209
|
+
subject: template.subject || apireq.req.body.subject || '',
|
|
210
|
+
html,
|
|
211
|
+
text,
|
|
212
|
+
attachments
|
|
213
|
+
};
|
|
214
|
+
await apireq.server.storage.transport.sendMail(sendargs);
|
|
215
|
+
}
|
|
216
|
+
return [200, { Status: 'OK', Message: 'Emails sent successfully' }];
|
|
217
|
+
} catch (error: unknown) {
|
|
218
|
+
// console.log(JSON.stringify(e, null, 2));
|
|
219
|
+
throw new ApiError({
|
|
220
|
+
code: 500,
|
|
221
|
+
message: error instanceof Error ? error.message : String(error)
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
override defineRoutes(): ApiRoute[] {
|
|
227
|
+
return [
|
|
228
|
+
{
|
|
229
|
+
method: 'post',
|
|
230
|
+
path: '/v1/tx/message',
|
|
231
|
+
handler: this.post_send.bind(this),
|
|
232
|
+
auth: { type: 'yes', req: 'any' }
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
method: 'post',
|
|
236
|
+
path: '/v1/tx/template',
|
|
237
|
+
handler: this.post_template.bind(this),
|
|
238
|
+
auth: { type: 'yes', req: 'any' }
|
|
239
|
+
}
|
|
240
|
+
];
|
|
241
|
+
}
|
|
242
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { pathToFileURL } from 'node:url';
|
|
2
|
+
|
|
3
|
+
import { FormAPI } from './api/forms.js';
|
|
4
|
+
import { MailerAPI } from './api/mailer.js';
|
|
5
|
+
import { mailApiServer } from './server.js';
|
|
6
|
+
import { mailStore } from './store/store.js';
|
|
7
|
+
|
|
8
|
+
import type { ApiServerConf } from '@technomoron/api-server-base';
|
|
9
|
+
|
|
10
|
+
export type MailMagicServerOptions = Partial<ApiServerConf>;
|
|
11
|
+
|
|
12
|
+
export type MailMagicServerBootstrap = {
|
|
13
|
+
server: mailApiServer;
|
|
14
|
+
store: mailStore;
|
|
15
|
+
env: mailStore['env'];
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function buildServerConfig(store: mailStore, overrides: MailMagicServerOptions): MailMagicServerOptions {
|
|
19
|
+
const env = store.env;
|
|
20
|
+
return {
|
|
21
|
+
apiHost: env.API_HOST,
|
|
22
|
+
apiPort: env.API_PORT,
|
|
23
|
+
uploadPath: env.UPLOAD_PATH,
|
|
24
|
+
debug: env.DEBUG,
|
|
25
|
+
...overrides
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function createMailMagicServer(overrides: MailMagicServerOptions = {}): Promise<MailMagicServerBootstrap> {
|
|
30
|
+
const store = await new mailStore().init();
|
|
31
|
+
const config = buildServerConfig(store, overrides);
|
|
32
|
+
const server = new mailApiServer(config, store).api(new MailerAPI()).api(new FormAPI());
|
|
33
|
+
|
|
34
|
+
return { server, store, env: store.env };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function startMailMagicServer(overrides: MailMagicServerOptions = {}): Promise<MailMagicServerBootstrap> {
|
|
38
|
+
const bootstrap = await createMailMagicServer(overrides);
|
|
39
|
+
await bootstrap.server.start();
|
|
40
|
+
return bootstrap;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function bootMailMagic() {
|
|
44
|
+
try {
|
|
45
|
+
const { env } = await startMailMagicServer();
|
|
46
|
+
console.log(`mail-magic server listening on ${env.API_HOST}:${env.API_PORT}`);
|
|
47
|
+
} catch (err) {
|
|
48
|
+
console.error('Failed to start FormMailer:', err);
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const isDirectExecution = (() => {
|
|
54
|
+
if (!process.argv[1]) {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
return import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
60
|
+
} catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
})();
|
|
64
|
+
|
|
65
|
+
if (isDirectExecution) {
|
|
66
|
+
void bootMailMagic();
|
|
67
|
+
}
|
package/src/models/db.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { Sequelize } from 'sequelize';
|
|
2
|
+
|
|
3
|
+
import { mailStore } from './../store/store.js';
|
|
4
|
+
import { init_api_domain, api_domain } from './domain.js';
|
|
5
|
+
import { init_api_form, api_form } from './form.js';
|
|
6
|
+
import { importData } from './init.js';
|
|
7
|
+
import { init_api_txmail, api_txmail } from './txmail.js';
|
|
8
|
+
import { init_api_user, api_user } from './user.js';
|
|
9
|
+
|
|
10
|
+
import type { Dialect, Options } from 'sequelize';
|
|
11
|
+
|
|
12
|
+
export async function init_api_db(db: Sequelize, store: mailStore) {
|
|
13
|
+
await init_api_user(db);
|
|
14
|
+
await init_api_domain(db);
|
|
15
|
+
await init_api_txmail(db);
|
|
16
|
+
await init_api_form(db);
|
|
17
|
+
|
|
18
|
+
// User ↔ Domain
|
|
19
|
+
api_user.hasMany(api_domain, {
|
|
20
|
+
foreignKey: 'user_id',
|
|
21
|
+
as: 'domains'
|
|
22
|
+
});
|
|
23
|
+
api_domain.belongsTo(api_user, {
|
|
24
|
+
foreignKey: 'user_id',
|
|
25
|
+
as: 'user'
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// User ↔ Template
|
|
29
|
+
api_user.hasMany(api_txmail, {
|
|
30
|
+
foreignKey: 'user_id',
|
|
31
|
+
as: 'txmail'
|
|
32
|
+
});
|
|
33
|
+
api_txmail.belongsTo(api_user, {
|
|
34
|
+
foreignKey: 'user_id',
|
|
35
|
+
as: 'user'
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// Domain ↔ Template
|
|
39
|
+
api_domain.hasMany(api_txmail, {
|
|
40
|
+
foreignKey: 'domain_id',
|
|
41
|
+
as: 'txmail'
|
|
42
|
+
});
|
|
43
|
+
api_txmail.belongsTo(api_domain, {
|
|
44
|
+
foreignKey: 'domain_id',
|
|
45
|
+
as: 'domain'
|
|
46
|
+
});
|
|
47
|
+
api_user.belongsTo(api_domain, {
|
|
48
|
+
foreignKey: 'domain',
|
|
49
|
+
as: 'defaultDomain'
|
|
50
|
+
});
|
|
51
|
+
api_domain.hasMany(api_user, {
|
|
52
|
+
foreignKey: 'domain',
|
|
53
|
+
as: 'usersWithDefault'
|
|
54
|
+
});
|
|
55
|
+
api_user.hasMany(api_form, {
|
|
56
|
+
foreignKey: 'user_id',
|
|
57
|
+
as: 'forms'
|
|
58
|
+
});
|
|
59
|
+
api_form.belongsTo(api_user, {
|
|
60
|
+
foreignKey: 'user_id',
|
|
61
|
+
as: 'user'
|
|
62
|
+
});
|
|
63
|
+
api_domain.hasMany(api_form, {
|
|
64
|
+
foreignKey: 'domain_id',
|
|
65
|
+
as: 'forms'
|
|
66
|
+
});
|
|
67
|
+
api_form.belongsTo(api_domain, {
|
|
68
|
+
foreignKey: 'domain_id',
|
|
69
|
+
as: 'domain'
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
await db.query('PRAGMA foreign_keys = OFF');
|
|
73
|
+
store.print_debug(`Force alter tables: ${store.env.DB_FORCE_SYNC}`);
|
|
74
|
+
await db.sync({ alter: true, force: store.env.DB_FORCE_SYNC });
|
|
75
|
+
await db.query('PRAGMA foreign_keys = ON');
|
|
76
|
+
|
|
77
|
+
await importData(store);
|
|
78
|
+
store.print_debug('API Database Initialized...');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function connect_api_db(store: mailStore): Promise<Sequelize> {
|
|
82
|
+
console.log('DB INIT');
|
|
83
|
+
|
|
84
|
+
const env = store.env;
|
|
85
|
+
const dbparams: Options = {
|
|
86
|
+
logging: false, // env.DB_LOG ? console.log : false,
|
|
87
|
+
dialect: env.DB_TYPE as Dialect,
|
|
88
|
+
dialectOptions: {
|
|
89
|
+
charset: 'utf8mb4'
|
|
90
|
+
},
|
|
91
|
+
define: {
|
|
92
|
+
charset: 'utf8mb4',
|
|
93
|
+
collate: 'utf8mb4_unicode_ci'
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
if (env.DB_TYPE === 'sqlite') {
|
|
97
|
+
dbparams.storage = env.DB_NAME.endsWith('.db') ? env.DB_NAME : `${env.DB_NAME}.db`;
|
|
98
|
+
} else {
|
|
99
|
+
dbparams.host = env.DB_HOST;
|
|
100
|
+
dbparams.database = env.DB_NAME;
|
|
101
|
+
dbparams.username = env.DB_USER;
|
|
102
|
+
dbparams.password = env.DB_PASS;
|
|
103
|
+
}
|
|
104
|
+
store.print_debug(`Database params are:\n${JSON.stringify(dbparams, undefined, 2)}`);
|
|
105
|
+
const db = new Sequelize(dbparams);
|
|
106
|
+
await db.authenticate();
|
|
107
|
+
|
|
108
|
+
store.print_debug('API Database Connected');
|
|
109
|
+
|
|
110
|
+
await init_api_db(db, store);
|
|
111
|
+
return db;
|
|
112
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { Sequelize, Model, DataTypes } from 'sequelize';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
export const api_domain_schema = z.object({
|
|
5
|
+
domain_id: z.number().int().nonnegative(),
|
|
6
|
+
user_id: z.number().int().nonnegative(),
|
|
7
|
+
name: z.string().min(1),
|
|
8
|
+
sender: z.string().default(''),
|
|
9
|
+
locale: z.string().default(''),
|
|
10
|
+
is_default: z.boolean().default(false)
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export type api_domain_type = z.infer<typeof api_domain_schema>;
|
|
14
|
+
|
|
15
|
+
export class api_domain extends Model {
|
|
16
|
+
declare domain_id: number;
|
|
17
|
+
declare user_id: number;
|
|
18
|
+
declare name: string;
|
|
19
|
+
declare sender: string;
|
|
20
|
+
declare locale: string;
|
|
21
|
+
declare is_default: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function init_api_domain(api_db: Sequelize): Promise<typeof api_domain> {
|
|
25
|
+
api_domain.init(
|
|
26
|
+
{
|
|
27
|
+
domain_id: {
|
|
28
|
+
type: DataTypes.INTEGER,
|
|
29
|
+
autoIncrement: true,
|
|
30
|
+
allowNull: false,
|
|
31
|
+
primaryKey: true
|
|
32
|
+
},
|
|
33
|
+
user_id: {
|
|
34
|
+
type: DataTypes.INTEGER,
|
|
35
|
+
allowNull: false,
|
|
36
|
+
references: {
|
|
37
|
+
model: 'user',
|
|
38
|
+
key: 'user_id'
|
|
39
|
+
},
|
|
40
|
+
onDelete: 'CASCADE',
|
|
41
|
+
onUpdate: 'CASCADE'
|
|
42
|
+
},
|
|
43
|
+
name: {
|
|
44
|
+
type: DataTypes.STRING,
|
|
45
|
+
allowNull: false,
|
|
46
|
+
defaultValue: ''
|
|
47
|
+
},
|
|
48
|
+
sender: {
|
|
49
|
+
type: DataTypes.STRING,
|
|
50
|
+
allowNull: false,
|
|
51
|
+
defaultValue: ''
|
|
52
|
+
},
|
|
53
|
+
locale: {
|
|
54
|
+
type: DataTypes.STRING,
|
|
55
|
+
allowNull: false,
|
|
56
|
+
defaultValue: ''
|
|
57
|
+
},
|
|
58
|
+
is_default: {
|
|
59
|
+
type: DataTypes.BOOLEAN,
|
|
60
|
+
allowNull: false,
|
|
61
|
+
defaultValue: false
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
sequelize: api_db,
|
|
66
|
+
tableName: 'domain',
|
|
67
|
+
charset: 'utf8mb4',
|
|
68
|
+
collate: 'utf8mb4_unicode_ci'
|
|
69
|
+
}
|
|
70
|
+
);
|
|
71
|
+
return api_domain;
|
|
72
|
+
}
|