@technomoron/mail-magic 1.0.8 → 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.
- package/CHANGES +27 -0
- package/README.md +9 -3
- package/TUTORIAL.MD +1 -0
- package/dist/api/assets.js +153 -0
- package/dist/api/forms.js +2 -0
- package/dist/api/mailer.js +1 -0
- package/dist/bin/mail-magic.js +63 -0
- package/dist/index.js +61 -2
- package/dist/store/envloader.js +3 -3
- package/dist/store/store.js +66 -1
- package/package.json +17 -3
- package/.do-realease.sh +0 -49
- package/.editorconfig +0 -9
- package/.env-dist +0 -71
- package/.prettierrc +0 -14
- package/.vscode/extensions.json +0 -3
- package/.vscode/settings.json +0 -22
- package/config-example/form-template/default.njk +0 -102
- package/config-example/forms.config.json +0 -8
- package/config-example/init-data.json +0 -33
- package/config-example/tx-template/default.njk +0 -107
- package/ecosystem.config.cjs +0 -42
- package/eslint.config.mjs +0 -196
- package/lintconfig.cjs +0 -81
- package/src/api/assets.ts +0 -92
- package/src/api/forms.ts +0 -237
- package/src/api/mailer.ts +0 -269
- package/src/index.ts +0 -71
- package/src/models/db.ts +0 -112
- package/src/models/domain.ts +0 -72
- package/src/models/form.ts +0 -209
- package/src/models/init.ts +0 -240
- package/src/models/txmail.ts +0 -206
- package/src/models/user.ts +0 -79
- package/src/server.ts +0 -27
- package/src/store/envloader.ts +0 -109
- package/src/store/store.ts +0 -118
- package/src/types.ts +0 -39
- package/src/util.ts +0 -137
- package/tests/fixtures/certs/test.crt +0 -19
- package/tests/fixtures/certs/test.key +0 -28
- package/tests/helpers/test-setup.ts +0 -316
- package/tests/mail-magic.test.ts +0 -154
- package/tsconfig.json +0 -14
- package/vitest.config.ts +0 -11
package/src/api/assets.ts
DELETED
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
import fs from 'fs';
|
|
2
|
-
import path from 'path';
|
|
3
|
-
|
|
4
|
-
import { ApiError, ApiModule, ApiRoute } from '@technomoron/api-server-base';
|
|
5
|
-
|
|
6
|
-
import { mailApiServer } from '../server.js';
|
|
7
|
-
import { decodeComponent, sendFileAsync } from '../util.js';
|
|
8
|
-
|
|
9
|
-
import type { ApiRequest } from '@technomoron/api-server-base';
|
|
10
|
-
|
|
11
|
-
const DOMAIN_PATTERN = /^[a-z0-9][a-z0-9._-]*$/i;
|
|
12
|
-
const SEGMENT_PATTERN = /^[a-zA-Z0-9._-]+$/;
|
|
13
|
-
|
|
14
|
-
export class AssetAPI extends ApiModule<mailApiServer> {
|
|
15
|
-
private async getAsset(apiReq: ApiRequest): Promise<[number, null]> {
|
|
16
|
-
const domain = decodeComponent(apiReq.req.params.domain);
|
|
17
|
-
if (!domain || !DOMAIN_PATTERN.test(domain)) {
|
|
18
|
-
throw new ApiError({ code: 404, message: 'Asset not found' });
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
const rawPath = apiReq.req.params[0] ?? '';
|
|
22
|
-
const segments = rawPath
|
|
23
|
-
.split('/')
|
|
24
|
-
.filter(Boolean)
|
|
25
|
-
.map((segment: string) => decodeComponent(segment));
|
|
26
|
-
if (!segments.length || segments.some((segment) => !SEGMENT_PATTERN.test(segment))) {
|
|
27
|
-
throw new ApiError({ code: 404, message: 'Asset not found' });
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
const assetsRoot = path.join(this.server.storage.configpath, domain, 'assets');
|
|
31
|
-
if (!fs.existsSync(assetsRoot)) {
|
|
32
|
-
throw new ApiError({ code: 404, message: 'Asset not found' });
|
|
33
|
-
}
|
|
34
|
-
const resolvedRoot = fs.realpathSync(assetsRoot);
|
|
35
|
-
const normalizedRoot = resolvedRoot.endsWith(path.sep) ? resolvedRoot : resolvedRoot + path.sep;
|
|
36
|
-
const candidate = path.resolve(assetsRoot, path.join(...segments));
|
|
37
|
-
|
|
38
|
-
try {
|
|
39
|
-
const stats = await fs.promises.stat(candidate);
|
|
40
|
-
if (!stats.isFile()) {
|
|
41
|
-
throw new ApiError({ code: 404, message: 'Asset not found' });
|
|
42
|
-
}
|
|
43
|
-
} catch {
|
|
44
|
-
throw new ApiError({ code: 404, message: 'Asset not found' });
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
let realCandidate: string;
|
|
48
|
-
try {
|
|
49
|
-
realCandidate = await fs.promises.realpath(candidate);
|
|
50
|
-
} catch {
|
|
51
|
-
throw new ApiError({ code: 404, message: 'Asset not found' });
|
|
52
|
-
}
|
|
53
|
-
if (!realCandidate.startsWith(normalizedRoot)) {
|
|
54
|
-
throw new ApiError({ code: 404, message: 'Asset not found' });
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
const { res } = apiReq;
|
|
58
|
-
const originalStatus = res.status.bind(res);
|
|
59
|
-
const originalJson = res.json.bind(res);
|
|
60
|
-
res.status = ((code: number) => (res.headersSent ? res : originalStatus(code))) as typeof res.status;
|
|
61
|
-
res.json = ((body: unknown) => (res.headersSent ? res : originalJson(body))) as typeof res.json;
|
|
62
|
-
|
|
63
|
-
res.type(path.extname(realCandidate));
|
|
64
|
-
res.set('Cache-Control', 'public, max-age=300');
|
|
65
|
-
|
|
66
|
-
try {
|
|
67
|
-
await sendFileAsync(res, realCandidate);
|
|
68
|
-
} catch (err) {
|
|
69
|
-
this.server.storage.print_debug(
|
|
70
|
-
`Failed to serve asset ${domain}/${segments.join('/')}: ${err instanceof Error ? err.message : String(err)}`
|
|
71
|
-
);
|
|
72
|
-
if (!res.headersSent) {
|
|
73
|
-
throw new ApiError({ code: 500, message: 'Failed to stream asset' });
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
return [200, null];
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
override defineRoutes(): ApiRoute[] {
|
|
81
|
-
const route = this.server.storage.env.ASSET_ROUTE;
|
|
82
|
-
const normalizedRoute = route.startsWith('/') ? route : `/${route}`;
|
|
83
|
-
return [
|
|
84
|
-
{
|
|
85
|
-
method: 'get',
|
|
86
|
-
path: `${normalizedRoute}/:domain/*`,
|
|
87
|
-
handler: (apiReq) => this.getAsset(apiReq),
|
|
88
|
-
auth: { type: 'none', req: 'any' }
|
|
89
|
-
}
|
|
90
|
-
];
|
|
91
|
-
}
|
|
92
|
-
}
|
package/src/api/forms.ts
DELETED
|
@@ -1,237 +0,0 @@
|
|
|
1
|
-
import path from 'path';
|
|
2
|
-
|
|
3
|
-
import { ApiRoute, ApiRequest, ApiModule, ApiError } from '@technomoron/api-server-base';
|
|
4
|
-
import emailAddresses, { ParsedMailbox } from 'email-addresses';
|
|
5
|
-
import nunjucks from 'nunjucks';
|
|
6
|
-
|
|
7
|
-
import { api_domain } from '../models/domain.js';
|
|
8
|
-
import { api_form } from '../models/form.js';
|
|
9
|
-
import { api_user } from '../models/user.js';
|
|
10
|
-
import { mailApiServer } from '../server.js';
|
|
11
|
-
import { buildRequestMeta, normalizeSlug } from '../util.js';
|
|
12
|
-
|
|
13
|
-
import type { mailApiRequest, UploadedFile } from '../types.js';
|
|
14
|
-
|
|
15
|
-
export class FormAPI extends ApiModule<mailApiServer> {
|
|
16
|
-
private validateEmail(email: string): string | undefined {
|
|
17
|
-
const parsed = emailAddresses.parseOneAddress(email);
|
|
18
|
-
if (parsed) {
|
|
19
|
-
return (parsed as ParsedMailbox).address;
|
|
20
|
-
}
|
|
21
|
-
return undefined;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
private async assertDomainAndUser(apireq: mailApiRequest): Promise<void> {
|
|
25
|
-
const { domain, locale } = apireq.req.body;
|
|
26
|
-
|
|
27
|
-
if (!domain) {
|
|
28
|
-
throw new ApiError({ code: 401, message: 'Missing domain' });
|
|
29
|
-
}
|
|
30
|
-
const user = await api_user.findOne({ where: { token: apireq.token } });
|
|
31
|
-
if (!user) {
|
|
32
|
-
throw new ApiError({ code: 401, message: `Invalid/Unknown API Key/Token '${apireq.token}'` });
|
|
33
|
-
}
|
|
34
|
-
const dbdomain = await api_domain.findOne({ where: { name: domain } });
|
|
35
|
-
if (!dbdomain) {
|
|
36
|
-
throw new ApiError({ code: 401, message: `Unable to look up the domain ${domain}` });
|
|
37
|
-
}
|
|
38
|
-
if (dbdomain.user_id !== user.user_id) {
|
|
39
|
-
throw new ApiError({ code: 403, message: `Domain ${domain} is not owned by this user` });
|
|
40
|
-
}
|
|
41
|
-
apireq.domain = dbdomain;
|
|
42
|
-
apireq.locale = locale || 'en';
|
|
43
|
-
apireq.user = user;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
private async postFormTemplate(apireq: mailApiRequest): Promise<[number, { Status: string }]> {
|
|
47
|
-
await this.assertDomainAndUser(apireq);
|
|
48
|
-
|
|
49
|
-
const {
|
|
50
|
-
template,
|
|
51
|
-
sender = '',
|
|
52
|
-
recipient = '',
|
|
53
|
-
idname,
|
|
54
|
-
subject = '',
|
|
55
|
-
locale = '',
|
|
56
|
-
secret = ''
|
|
57
|
-
} = apireq.req.body;
|
|
58
|
-
|
|
59
|
-
if (!template) {
|
|
60
|
-
throw new ApiError({ code: 400, message: 'Missing template data' });
|
|
61
|
-
}
|
|
62
|
-
if (!idname) {
|
|
63
|
-
throw new ApiError({ code: 400, message: 'Missing form identifier' });
|
|
64
|
-
}
|
|
65
|
-
if (!sender) {
|
|
66
|
-
throw new ApiError({ code: 400, message: 'Missing sender address' });
|
|
67
|
-
}
|
|
68
|
-
if (!recipient) {
|
|
69
|
-
throw new ApiError({ code: 400, message: 'Missing recipient address' });
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
const user = apireq.user!;
|
|
73
|
-
const domain = apireq.domain!;
|
|
74
|
-
const resolvedLocale = locale || apireq.locale || '';
|
|
75
|
-
const userSlug = normalizeSlug(user.idname);
|
|
76
|
-
const domainSlug = normalizeSlug(domain.name);
|
|
77
|
-
const formSlug = normalizeSlug(idname);
|
|
78
|
-
const localeSlug = normalizeSlug(resolvedLocale || domain.locale || user.locale || '');
|
|
79
|
-
const slug = `${userSlug}-${domainSlug}${localeSlug ? '-' + localeSlug : ''}-${formSlug}`;
|
|
80
|
-
const filenameParts = [domainSlug, 'form-template'];
|
|
81
|
-
if (localeSlug) {
|
|
82
|
-
filenameParts.push(localeSlug);
|
|
83
|
-
}
|
|
84
|
-
filenameParts.push(formSlug);
|
|
85
|
-
let filename = path.join(...filenameParts);
|
|
86
|
-
if (!filename.endsWith('.njk')) {
|
|
87
|
-
filename += '.njk';
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
const record = {
|
|
91
|
-
user_id: user.user_id,
|
|
92
|
-
domain_id: domain.domain_id,
|
|
93
|
-
locale: localeSlug,
|
|
94
|
-
idname,
|
|
95
|
-
sender,
|
|
96
|
-
recipient,
|
|
97
|
-
subject,
|
|
98
|
-
template,
|
|
99
|
-
slug,
|
|
100
|
-
filename,
|
|
101
|
-
secret,
|
|
102
|
-
files: []
|
|
103
|
-
};
|
|
104
|
-
|
|
105
|
-
try {
|
|
106
|
-
const [form, created] = await api_form.upsert(record, {
|
|
107
|
-
returning: true,
|
|
108
|
-
conflictFields: ['user_id', 'domain_id', 'locale', 'idname']
|
|
109
|
-
});
|
|
110
|
-
this.server.storage.print_debug(`Form template upserted: ${form.idname} (created=${created})`);
|
|
111
|
-
} catch (error: unknown) {
|
|
112
|
-
throw new ApiError({
|
|
113
|
-
code: 500,
|
|
114
|
-
message: this.server!.guessExceptionText(error, 'Unknown Sequelize Error on upsert form template')
|
|
115
|
-
});
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
return [200, { Status: 'OK' }];
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
private async postSendForm(apireq: ApiRequest): Promise<[number, Record<string, unknown>]> {
|
|
122
|
-
const { formid, secret, recipient, vars = {}, replyTo, reply_to } = apireq.req.body;
|
|
123
|
-
|
|
124
|
-
if (!formid) {
|
|
125
|
-
throw new ApiError({ code: 404, message: 'Missing formid field in form' });
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
const form = await api_form.findOne({ where: { idname: formid } });
|
|
129
|
-
if (!form) {
|
|
130
|
-
throw new ApiError({ code: 404, message: `No such form: ${formid}` });
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
if (form.secret && !secret) {
|
|
134
|
-
throw new ApiError({ code: 401, message: 'This form requires a secret key' });
|
|
135
|
-
}
|
|
136
|
-
if (form.secret && form.secret !== secret) {
|
|
137
|
-
throw new ApiError({ code: 401, message: 'Bad form secret' });
|
|
138
|
-
}
|
|
139
|
-
if (recipient && !form.secret) {
|
|
140
|
-
throw new ApiError({ code: 401, message: "'recipient' parameterer requires form secret to be set" });
|
|
141
|
-
}
|
|
142
|
-
let normalizedReplyTo: string | undefined;
|
|
143
|
-
let normalizedRecipient: string | undefined;
|
|
144
|
-
const replyToValue = (replyTo || reply_to) as string | undefined;
|
|
145
|
-
if (replyToValue) {
|
|
146
|
-
normalizedReplyTo = this.validateEmail(replyToValue);
|
|
147
|
-
if (!normalizedReplyTo) {
|
|
148
|
-
throw new ApiError({ code: 400, message: 'Invalid reply-to email address' });
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
if (recipient) {
|
|
152
|
-
normalizedRecipient = this.validateEmail(String(recipient));
|
|
153
|
-
if (!normalizedRecipient) {
|
|
154
|
-
throw new ApiError({ code: 400, message: 'Invalid recipient email address' });
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
let parsedVars: unknown = vars ?? {};
|
|
159
|
-
if (typeof vars === 'string') {
|
|
160
|
-
try {
|
|
161
|
-
parsedVars = JSON.parse(vars);
|
|
162
|
-
} catch {
|
|
163
|
-
throw new ApiError({ code: 400, message: 'Invalid JSON provided in "vars"' });
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
const thevars = parsedVars as Record<string, unknown>;
|
|
167
|
-
|
|
168
|
-
/*
|
|
169
|
-
console.log('Headers:', apireq.req.headers);
|
|
170
|
-
console.log('Body:', JSON.stringify(apireq.req.body, null, 2));
|
|
171
|
-
console.log('Files:', JSON.stringify(apireq.req.files, null, 2));
|
|
172
|
-
*/
|
|
173
|
-
|
|
174
|
-
const rawFiles = Array.isArray(apireq.req.files) ? (apireq.req.files as UploadedFile[]) : [];
|
|
175
|
-
const attachments = rawFiles.map((file) => ({
|
|
176
|
-
filename: file.originalname,
|
|
177
|
-
path: file.path
|
|
178
|
-
}));
|
|
179
|
-
|
|
180
|
-
const attachmentMap: Record<string, string> = {};
|
|
181
|
-
for (const file of rawFiles) {
|
|
182
|
-
attachmentMap[file.fieldname] = file.originalname;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
const meta = buildRequestMeta(apireq.req);
|
|
186
|
-
|
|
187
|
-
const context = {
|
|
188
|
-
...thevars,
|
|
189
|
-
_rcpt_email_: recipient,
|
|
190
|
-
_attachments_: attachmentMap,
|
|
191
|
-
_vars_: thevars,
|
|
192
|
-
_fields_: apireq.req.body,
|
|
193
|
-
_files_: rawFiles,
|
|
194
|
-
_meta_: meta
|
|
195
|
-
};
|
|
196
|
-
|
|
197
|
-
nunjucks.configure({ autoescape: true });
|
|
198
|
-
const html = nunjucks.renderString(form.template, context);
|
|
199
|
-
|
|
200
|
-
const mailOptions = {
|
|
201
|
-
from: form.sender,
|
|
202
|
-
to: normalizedRecipient || form.recipient,
|
|
203
|
-
subject: form.subject,
|
|
204
|
-
html,
|
|
205
|
-
attachments,
|
|
206
|
-
...(normalizedReplyTo ? { replyTo: normalizedReplyTo } : {})
|
|
207
|
-
};
|
|
208
|
-
|
|
209
|
-
try {
|
|
210
|
-
const info = await this.server.storage.transport!.sendMail(mailOptions);
|
|
211
|
-
this.server.storage.print_debug('Email sent: ' + info.response);
|
|
212
|
-
} catch (error: unknown) {
|
|
213
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
214
|
-
this.server.storage.print_debug('Error sending email: ' + errorMessage);
|
|
215
|
-
return [500, { error: `Error sending email: ${errorMessage}` }];
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
return [200, {}];
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
override defineRoutes(): ApiRoute[] {
|
|
222
|
-
return [
|
|
223
|
-
{
|
|
224
|
-
method: 'post',
|
|
225
|
-
path: '/v1/form/template',
|
|
226
|
-
handler: (req) => this.postFormTemplate(req as mailApiRequest),
|
|
227
|
-
auth: { type: 'yes', req: 'any' }
|
|
228
|
-
},
|
|
229
|
-
{
|
|
230
|
-
method: 'post',
|
|
231
|
-
path: '/v1/form/message',
|
|
232
|
-
handler: (req) => this.postSendForm(req),
|
|
233
|
-
auth: { type: 'none', req: 'any' }
|
|
234
|
-
}
|
|
235
|
-
];
|
|
236
|
-
}
|
|
237
|
-
}
|
package/src/api/mailer.ts
DELETED
|
@@ -1,269 +0,0 @@
|
|
|
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 | undefined {
|
|
19
|
-
const parsed = emailAddresses.parseOneAddress(email);
|
|
20
|
-
if (parsed) {
|
|
21
|
-
return (parsed as ParsedMailbox).address;
|
|
22
|
-
}
|
|
23
|
-
return undefined;
|
|
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
|
-
if (dbdomain.user_id !== user.user_id) {
|
|
65
|
-
throw new ApiError({ code: 403, message: `Domain ${domain} is not owned by this user` });
|
|
66
|
-
}
|
|
67
|
-
apireq.domain = dbdomain;
|
|
68
|
-
apireq.locale = locale || 'en';
|
|
69
|
-
apireq.user = user;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// Store a template in the database
|
|
73
|
-
|
|
74
|
-
private async post_template(apireq: mailApiRequest): Promise<[number, { Status: string }]> {
|
|
75
|
-
await this.assert_domain_and_user(apireq);
|
|
76
|
-
|
|
77
|
-
const { template, sender = '', name, subject = '', locale = '' } = apireq.req.body;
|
|
78
|
-
|
|
79
|
-
if (!template) {
|
|
80
|
-
throw new ApiError({ code: 400, message: 'Missing template data' });
|
|
81
|
-
}
|
|
82
|
-
if (!name) {
|
|
83
|
-
throw new ApiError({ code: 400, message: 'Missing template name' });
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
const data = {
|
|
87
|
-
user_id: apireq.user!.user_id,
|
|
88
|
-
domain_id: apireq.domain!.domain_id,
|
|
89
|
-
name,
|
|
90
|
-
subject,
|
|
91
|
-
locale,
|
|
92
|
-
sender,
|
|
93
|
-
template
|
|
94
|
-
};
|
|
95
|
-
|
|
96
|
-
/*
|
|
97
|
-
console.log(JSON.stringify({
|
|
98
|
-
user: apireq.user,
|
|
99
|
-
domain: apireq.domain,
|
|
100
|
-
domain_id: apireq.domain.domain_id,
|
|
101
|
-
data
|
|
102
|
-
}, undefined, 2)); */
|
|
103
|
-
|
|
104
|
-
try {
|
|
105
|
-
const [templateRecord, created] = await api_txmail.upsert(data, {
|
|
106
|
-
returning: true
|
|
107
|
-
});
|
|
108
|
-
this.server.storage.print_debug(`Template upserted: ${templateRecord.name} (created=${created})`);
|
|
109
|
-
} catch (error: unknown) {
|
|
110
|
-
throw new ApiError({
|
|
111
|
-
code: 500,
|
|
112
|
-
message: this.server!.guessExceptionText(error, 'Unknown Sequelize Error on upsert template')
|
|
113
|
-
});
|
|
114
|
-
}
|
|
115
|
-
return [200, { Status: 'OK' }];
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// Send a template using posted arguments.
|
|
119
|
-
|
|
120
|
-
private async post_send(apireq: mailApiRequest): Promise<[number, Record<string, unknown>]> {
|
|
121
|
-
await this.assert_domain_and_user(apireq);
|
|
122
|
-
|
|
123
|
-
const { name, rcpt, domain = '', locale = '', vars = {}, replyTo, reply_to, headers } = apireq.req.body;
|
|
124
|
-
|
|
125
|
-
if (!name || !rcpt || !domain) {
|
|
126
|
-
throw new ApiError({ code: 400, message: 'name/rcpt/domain required' });
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
let parsedVars: unknown = vars ?? {};
|
|
130
|
-
if (typeof vars === 'string') {
|
|
131
|
-
try {
|
|
132
|
-
parsedVars = JSON.parse(vars);
|
|
133
|
-
} catch {
|
|
134
|
-
throw new ApiError({ code: 400, message: 'Invalid JSON provided in "vars"' });
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
const thevars = parsedVars as Record<string, unknown>;
|
|
138
|
-
|
|
139
|
-
// const dbdomain = await api_domain.findOne({ where: { domain } });
|
|
140
|
-
|
|
141
|
-
const { valid, invalid } = this.validateEmails(rcpt);
|
|
142
|
-
if (invalid.length > 0) {
|
|
143
|
-
throw new ApiError({ code: 400, message: 'Invalid email address(es): ' + invalid.join(',') });
|
|
144
|
-
}
|
|
145
|
-
let template: api_txmail | null = null;
|
|
146
|
-
const deflocale = this.server.storage.deflocale || '';
|
|
147
|
-
const domain_id = apireq.domain!.domain_id;
|
|
148
|
-
|
|
149
|
-
try {
|
|
150
|
-
template =
|
|
151
|
-
(await api_txmail.findOne({ where: { name, domain_id, locale } })) ||
|
|
152
|
-
(await api_txmail.findOne({ where: { name, domain_id, locale: deflocale } })) ||
|
|
153
|
-
(await api_txmail.findOne({ where: { name, domain_id } }));
|
|
154
|
-
} catch (error: unknown) {
|
|
155
|
-
throw new ApiError({
|
|
156
|
-
code: 500,
|
|
157
|
-
message: this.server!.guessExceptionText(error, 'Unknown Sequelize Error')
|
|
158
|
-
});
|
|
159
|
-
}
|
|
160
|
-
if (!template) {
|
|
161
|
-
throw new ApiError({
|
|
162
|
-
code: 404,
|
|
163
|
-
message: `Template "${name}" not found for any locale in domain "${domain}"`
|
|
164
|
-
});
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
const sender = template.sender || apireq.domain!.sender || apireq.user!.email;
|
|
168
|
-
if (!sender) {
|
|
169
|
-
throw new ApiError({ code: 500, message: `Unable to locate sender for ${template.name}` });
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
const rawFiles = Array.isArray(apireq.req.files) ? (apireq.req.files as UploadedFile[]) : [];
|
|
173
|
-
const templateAssets = Array.isArray(template.files) ? template.files : [];
|
|
174
|
-
const attachments = [
|
|
175
|
-
...templateAssets.map((file) => ({
|
|
176
|
-
filename: file.filename,
|
|
177
|
-
path: file.path,
|
|
178
|
-
cid: file.cid
|
|
179
|
-
})),
|
|
180
|
-
...rawFiles.map((file) => ({
|
|
181
|
-
filename: file.originalname,
|
|
182
|
-
path: file.path
|
|
183
|
-
}))
|
|
184
|
-
];
|
|
185
|
-
|
|
186
|
-
const attachmentMap: Record<string, string> = {};
|
|
187
|
-
for (const file of rawFiles) {
|
|
188
|
-
attachmentMap[file.fieldname] = file.originalname;
|
|
189
|
-
}
|
|
190
|
-
this.server.storage.print_debug(`Template vars: ${JSON.stringify({ vars, thevars }, undefined, 2)}`);
|
|
191
|
-
|
|
192
|
-
const meta = buildRequestMeta(apireq.req);
|
|
193
|
-
const replyToValue = (replyTo || reply_to) as string | undefined;
|
|
194
|
-
let normalizedReplyTo: string | undefined;
|
|
195
|
-
if (replyToValue) {
|
|
196
|
-
normalizedReplyTo = this.validateEmail(replyToValue);
|
|
197
|
-
if (!normalizedReplyTo) {
|
|
198
|
-
throw new ApiError({ code: 400, message: 'Invalid reply-to email address' });
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
let normalizedHeaders: Record<string, string> | undefined;
|
|
203
|
-
if (headers !== undefined) {
|
|
204
|
-
if (!headers || typeof headers !== 'object' || Array.isArray(headers)) {
|
|
205
|
-
throw new ApiError({ code: 400, message: 'headers must be a key/value object' });
|
|
206
|
-
}
|
|
207
|
-
normalizedHeaders = {};
|
|
208
|
-
for (const [key, value] of Object.entries(headers as Record<string, unknown>)) {
|
|
209
|
-
if (typeof value !== 'string') {
|
|
210
|
-
throw new ApiError({ code: 400, message: `headers.${key} must be a string` });
|
|
211
|
-
}
|
|
212
|
-
normalizedHeaders[key] = value;
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
try {
|
|
217
|
-
const env = new nunjucks.Environment(null, { autoescape: false });
|
|
218
|
-
|
|
219
|
-
const compiled = nunjucks.compile(template.template, env);
|
|
220
|
-
|
|
221
|
-
for (const recipient of valid) {
|
|
222
|
-
const fullargs = {
|
|
223
|
-
...thevars,
|
|
224
|
-
_rcpt_email_: recipient,
|
|
225
|
-
_attachments_: attachmentMap,
|
|
226
|
-
_vars_: thevars,
|
|
227
|
-
_meta_: meta
|
|
228
|
-
};
|
|
229
|
-
const html = await compiled.render(fullargs);
|
|
230
|
-
const text = convert(html);
|
|
231
|
-
const sendargs = {
|
|
232
|
-
from: sender,
|
|
233
|
-
to: recipient,
|
|
234
|
-
subject: template.subject || apireq.req.body.subject || '',
|
|
235
|
-
html,
|
|
236
|
-
text,
|
|
237
|
-
attachments,
|
|
238
|
-
...(normalizedReplyTo ? { replyTo: normalizedReplyTo } : {}),
|
|
239
|
-
...(normalizedHeaders ? { headers: normalizedHeaders } : {})
|
|
240
|
-
};
|
|
241
|
-
await this.server.storage.transport!.sendMail(sendargs);
|
|
242
|
-
}
|
|
243
|
-
return [200, { Status: 'OK', Message: 'Emails sent successfully' }];
|
|
244
|
-
} catch (error: unknown) {
|
|
245
|
-
// console.log(JSON.stringify(e, null, 2));
|
|
246
|
-
throw new ApiError({
|
|
247
|
-
code: 500,
|
|
248
|
-
message: error instanceof Error ? error.message : String(error)
|
|
249
|
-
});
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
override defineRoutes(): ApiRoute[] {
|
|
254
|
-
return [
|
|
255
|
-
{
|
|
256
|
-
method: 'post',
|
|
257
|
-
path: '/v1/tx/message',
|
|
258
|
-
handler: this.post_send.bind(this),
|
|
259
|
-
auth: { type: 'yes', req: 'any' }
|
|
260
|
-
},
|
|
261
|
-
{
|
|
262
|
-
method: 'post',
|
|
263
|
-
path: '/v1/tx/template',
|
|
264
|
-
handler: this.post_template.bind(this),
|
|
265
|
-
auth: { type: 'yes', req: 'any' }
|
|
266
|
-
}
|
|
267
|
-
];
|
|
268
|
-
}
|
|
269
|
-
}
|
package/src/index.ts
DELETED
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
import { pathToFileURL } from 'node:url';
|
|
2
|
-
|
|
3
|
-
import { AssetAPI } from './api/assets.js';
|
|
4
|
-
import { FormAPI } from './api/forms.js';
|
|
5
|
-
import { MailerAPI } from './api/mailer.js';
|
|
6
|
-
import { mailApiServer } from './server.js';
|
|
7
|
-
import { mailStore } from './store/store.js';
|
|
8
|
-
|
|
9
|
-
import type { ApiServerConf } from '@technomoron/api-server-base';
|
|
10
|
-
|
|
11
|
-
export type MailMagicServerOptions = Partial<ApiServerConf>;
|
|
12
|
-
|
|
13
|
-
export type MailMagicServerBootstrap = {
|
|
14
|
-
server: mailApiServer;
|
|
15
|
-
store: mailStore;
|
|
16
|
-
env: mailStore['env'];
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
function buildServerConfig(store: mailStore, overrides: MailMagicServerOptions): MailMagicServerOptions {
|
|
20
|
-
const env = store.env;
|
|
21
|
-
return {
|
|
22
|
-
apiHost: env.API_HOST,
|
|
23
|
-
apiPort: env.API_PORT,
|
|
24
|
-
uploadPath: env.UPLOAD_PATH,
|
|
25
|
-
debug: env.DEBUG,
|
|
26
|
-
apiBasePath: '',
|
|
27
|
-
swaggerEnabled: env.SWAGGER_ENABLED,
|
|
28
|
-
swaggerPath: env.SWAGGER_PATH,
|
|
29
|
-
...overrides
|
|
30
|
-
};
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export async function createMailMagicServer(overrides: MailMagicServerOptions = {}): Promise<MailMagicServerBootstrap> {
|
|
34
|
-
const store = await new mailStore().init();
|
|
35
|
-
const config = buildServerConfig(store, overrides);
|
|
36
|
-
const server = new mailApiServer(config, store).api(new MailerAPI()).api(new FormAPI()).api(new AssetAPI());
|
|
37
|
-
|
|
38
|
-
return { server, store, env: store.env };
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export async function startMailMagicServer(overrides: MailMagicServerOptions = {}): Promise<MailMagicServerBootstrap> {
|
|
42
|
-
const bootstrap = await createMailMagicServer(overrides);
|
|
43
|
-
await bootstrap.server.start();
|
|
44
|
-
return bootstrap;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
async function bootMailMagic() {
|
|
48
|
-
try {
|
|
49
|
-
const { env } = await startMailMagicServer();
|
|
50
|
-
console.log(`mail-magic server listening on ${env.API_HOST}:${env.API_PORT}`);
|
|
51
|
-
} catch (err) {
|
|
52
|
-
console.error('Failed to start FormMailer:', err);
|
|
53
|
-
process.exit(1);
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
const isDirectExecution = (() => {
|
|
58
|
-
if (!process.argv[1]) {
|
|
59
|
-
return false;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
try {
|
|
63
|
-
return import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
64
|
-
} catch {
|
|
65
|
-
return false;
|
|
66
|
-
}
|
|
67
|
-
})();
|
|
68
|
-
|
|
69
|
-
if (isDirectExecution) {
|
|
70
|
-
void bootMailMagic();
|
|
71
|
-
}
|