@technomoron/mail-magic 1.0.6 → 1.0.8
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 +40 -1
- package/.env-dist +9 -0
- package/.vscode/extensions.json +1 -13
- package/.vscode/settings.json +13 -114
- package/CHANGES +14 -0
- package/dist/api/assets.js +19 -6
- package/dist/api/forms.js +31 -4
- package/dist/api/mailer.js +34 -8
- package/dist/index.js +2 -0
- package/dist/models/form.js +11 -1
- package/dist/models/init.js +10 -7
- package/dist/models/txmail.js +13 -4
- package/dist/store/envloader.js +9 -0
- package/dist/store/store.js +1 -0
- package/eslint.config.mjs +133 -41
- package/lintconfig.cjs +81 -0
- package/package.json +30 -20
- package/src/api/assets.ts +19 -6
- package/src/api/forms.ts +33 -5
- package/src/api/mailer.ts +36 -9
- package/src/index.ts +2 -0
- package/src/models/form.ts +12 -1
- package/src/models/init.ts +10 -7
- package/src/models/txmail.ts +14 -6
- package/src/store/envloader.ts +9 -0
- package/src/store/store.ts +2 -0
- package/tests/fixtures/certs/test.crt +19 -0
- package/tests/fixtures/certs/test.key +28 -0
- package/tests/helpers/test-setup.ts +316 -0
- package/tests/mail-magic.test.ts +154 -0
- package/vitest.config.ts +11 -0
package/src/models/init.ts
CHANGED
|
@@ -35,16 +35,17 @@ function resolveAsset(basePath: string, domainName: string, assetName: string):
|
|
|
35
35
|
if (!fs.existsSync(assetsRoot)) {
|
|
36
36
|
return null;
|
|
37
37
|
}
|
|
38
|
-
const resolvedRoot =
|
|
38
|
+
const resolvedRoot = fs.realpathSync(assetsRoot);
|
|
39
39
|
const normalizedRoot = resolvedRoot.endsWith(path.sep) ? resolvedRoot : resolvedRoot + path.sep;
|
|
40
40
|
const candidate = path.resolve(assetsRoot, assetName);
|
|
41
|
-
if (!candidate.
|
|
41
|
+
if (!fs.existsSync(candidate) || !fs.statSync(candidate).isFile()) {
|
|
42
42
|
return null;
|
|
43
43
|
}
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
const realCandidate = fs.realpathSync(candidate);
|
|
45
|
+
if (!realCandidate.startsWith(normalizedRoot)) {
|
|
46
|
+
return null;
|
|
46
47
|
}
|
|
47
|
-
return
|
|
48
|
+
return realCandidate;
|
|
48
49
|
}
|
|
49
50
|
|
|
50
51
|
function buildAssetUrl(baseUrl: string, route: string, domainName: string, assetPath: string): string {
|
|
@@ -122,9 +123,11 @@ async function _load_template(
|
|
|
122
123
|
relFile = filename.slice(prefix.length);
|
|
123
124
|
}
|
|
124
125
|
|
|
125
|
-
const
|
|
126
|
+
const resolvedRoot = path.resolve(rootDir);
|
|
127
|
+
const normalizedRoot = resolvedRoot.endsWith(path.sep) ? resolvedRoot : resolvedRoot + path.sep;
|
|
128
|
+
const absPath = path.resolve(resolvedRoot, pathname || '', relFile);
|
|
126
129
|
|
|
127
|
-
if (!absPath.startsWith(
|
|
130
|
+
if (!absPath.startsWith(normalizedRoot)) {
|
|
128
131
|
throw new Error(`Invalid template path "${filename}"`);
|
|
129
132
|
}
|
|
130
133
|
if (!fs.existsSync(absPath)) {
|
package/src/models/txmail.ts
CHANGED
|
@@ -43,6 +43,17 @@ export class api_txmail extends Model {
|
|
|
43
43
|
declare files: StoredFile[];
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
function assertSafeRelativePath(filename: string, label: string): string {
|
|
47
|
+
const normalized = path.normalize(filename);
|
|
48
|
+
if (path.isAbsolute(normalized)) {
|
|
49
|
+
throw new Error(`${label} path must be relative`);
|
|
50
|
+
}
|
|
51
|
+
if (normalized.split(path.sep).includes('..')) {
|
|
52
|
+
throw new Error(`${label} path cannot include '..' segments`);
|
|
53
|
+
}
|
|
54
|
+
return normalized;
|
|
55
|
+
}
|
|
56
|
+
|
|
46
57
|
export async function upsert_txmail(record: api_txmail_type): Promise<api_txmail> {
|
|
47
58
|
const { user, domain } = await user_and_domain(record.domain_id);
|
|
48
59
|
|
|
@@ -66,7 +77,7 @@ export async function upsert_txmail(record: api_txmail_type): Promise<api_txmail
|
|
|
66
77
|
if (!record.filename.endsWith('.njk')) {
|
|
67
78
|
record.filename += '.njk';
|
|
68
79
|
}
|
|
69
|
-
record.filename =
|
|
80
|
+
record.filename = assertSafeRelativePath(record.filename, 'Template filename');
|
|
70
81
|
|
|
71
82
|
const [instance] = await api_txmail.upsert(record);
|
|
72
83
|
return instance;
|
|
@@ -115,7 +126,7 @@ export async function init_api_txmail(api_db: Sequelize): Promise<typeof api_txm
|
|
|
115
126
|
unique: false
|
|
116
127
|
},
|
|
117
128
|
template: {
|
|
118
|
-
type: DataTypes.
|
|
129
|
+
type: DataTypes.TEXT,
|
|
119
130
|
allowNull: false,
|
|
120
131
|
defaultValue: ''
|
|
121
132
|
},
|
|
@@ -173,8 +184,6 @@ export async function init_api_txmail(api_db: Sequelize): Promise<typeof api_txm
|
|
|
173
184
|
api_txmail.addHook('beforeValidate', async (template: api_txmail) => {
|
|
174
185
|
const { user, domain } = await user_and_domain(template.domain_id);
|
|
175
186
|
|
|
176
|
-
console.log('HERE');
|
|
177
|
-
|
|
178
187
|
const dname = normalizeSlug(domain.name);
|
|
179
188
|
const name = normalizeSlug(template.name);
|
|
180
189
|
const locale = normalizeSlug(template.locale || domain.locale || user.locale || '');
|
|
@@ -190,8 +199,7 @@ export async function init_api_txmail(api_db: Sequelize): Promise<typeof api_txm
|
|
|
190
199
|
if (!template.filename.endsWith('.njk')) {
|
|
191
200
|
template.filename += '.njk';
|
|
192
201
|
}
|
|
193
|
-
|
|
194
|
-
console.log(`FILENAME IS: ${template.filename}`);
|
|
202
|
+
template.filename = assertSafeRelativePath(template.filename, 'Template filename');
|
|
195
203
|
});
|
|
196
204
|
|
|
197
205
|
return api_txmail;
|
package/src/store/envloader.ts
CHANGED
|
@@ -29,6 +29,15 @@ export const envOptions = defineEnvOptions({
|
|
|
29
29
|
description: 'Sets the public URL for the API (i.e. https://ml.example.com:3790)',
|
|
30
30
|
default: 'http://localhost:3776'
|
|
31
31
|
},
|
|
32
|
+
SWAGGER_ENABLED: {
|
|
33
|
+
description: 'Enable the Swagger/OpenAPI endpoint',
|
|
34
|
+
type: 'boolean',
|
|
35
|
+
default: false
|
|
36
|
+
},
|
|
37
|
+
SWAGGER_PATH: {
|
|
38
|
+
description: 'Path to expose the Swagger/OpenAPI spec (default: /api/swagger when enabled)',
|
|
39
|
+
default: ''
|
|
40
|
+
},
|
|
32
41
|
ASSET_ROUTE: {
|
|
33
42
|
description: 'Route prefix exposed for config assets',
|
|
34
43
|
default: '/asset'
|
package/src/store/store.ts
CHANGED
|
@@ -51,6 +51,7 @@ export interface ImailStore {
|
|
|
51
51
|
transport?: Transporter<SMTPTransport.SentMessageInfo>;
|
|
52
52
|
keys: Record<string, api_key>;
|
|
53
53
|
configpath: string;
|
|
54
|
+
deflocale?: string;
|
|
54
55
|
}
|
|
55
56
|
|
|
56
57
|
export class mailStore implements ImailStore {
|
|
@@ -59,6 +60,7 @@ export class mailStore implements ImailStore {
|
|
|
59
60
|
api_db: Sequelize | null = null;
|
|
60
61
|
keys: Record<string, api_key> = {};
|
|
61
62
|
configpath = '';
|
|
63
|
+
deflocale?: string;
|
|
62
64
|
|
|
63
65
|
print_debug(msg: string) {
|
|
64
66
|
if (this.env.DEBUG) {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
-----BEGIN CERTIFICATE-----
|
|
2
|
+
MIIDCTCCAfGgAwIBAgIUdLeUy6x+te3ab//X8pY28fcoWgkwDQYJKoZIhvcNAQEL
|
|
3
|
+
BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI1MTIzMTEyNDY0MloXDTI2MTIz
|
|
4
|
+
MTEyNDY0MlowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF
|
|
5
|
+
AAOCAQ8AMIIBCgKCAQEA6CIYqJHvEMqZo5tZ6AhCUrTUOiD+kGz3eI+wPhjw9qYT
|
|
6
|
+
9bgsxqAQWryWg5K8VU6wTBT2YkXuCFyRaKXFjP9O1Gh/KgoB6K3zQYfZhqqV+EG2
|
|
7
|
+
d0feM32C8NQt3ELkQjS0DAmm/XOkXh0R8gnCorXzk92llhaBV9ps7u2Ov9qLsHw0
|
|
8
|
+
85hPdOU7/uN/lB8G2HZSqz10gewNNE2O7fJieo04sOOaTg7m/SyrHtBAqzc1FT6v
|
|
9
|
+
CNQF8A3pCuOQCVUZzpze+/vZzQOjwcxxOfaHqP9Ff7a8IgzSgLBjwydMAjKiikPK
|
|
10
|
+
9pA8Uw30qYe42fHwfvT0nVf5KN+bmEZNTv9zHnBZLQIDAQABo1MwUTAdBgNVHQ4E
|
|
11
|
+
FgQUndM22wxNv6N+L1Nz/1L8o+ri2Q0wHwYDVR0jBBgwFoAUndM22wxNv6N+L1Nz
|
|
12
|
+
/1L8o+ri2Q0wDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAneWq
|
|
13
|
+
A5FrJ77oyy6HEyMUA1jY6mDvY3lfRFuV9zrTcfocc6XZZis6433XG416noWlBZIT
|
|
14
|
+
Jb1TYKn+k/8+kCjSzFLeL9q9gSYHMt4yQjtmXdQ6ZhyrFsIymEHqDcLRTY/PqLd6
|
|
15
|
+
mBFhf6tvX2pudduSG4mWkQVAaa3pa+t8to7M2fTraOOPWPXasdP1QOLcUooG/FWu
|
|
16
|
+
WZXHo5v/1PTPWtFb3CwlhxjV1su81Uc6H6/qlw0DghlJMYwuixBdw1b4T5RqP/MO
|
|
17
|
+
94Fs/GfLvHgPFNPwWD/+1Q2OxwFd0VgeQvChUW1856r4dDV/ZUbAx143GHrb/S3C
|
|
18
|
+
s7P6f2l+3faq+LiH1Q==
|
|
19
|
+
-----END CERTIFICATE-----
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
-----BEGIN PRIVATE KEY-----
|
|
2
|
+
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDoIhioke8Qypmj
|
|
3
|
+
m1noCEJStNQ6IP6QbPd4j7A+GPD2phP1uCzGoBBavJaDkrxVTrBMFPZiRe4IXJFo
|
|
4
|
+
pcWM/07UaH8qCgHorfNBh9mGqpX4QbZ3R94zfYLw1C3cQuRCNLQMCab9c6ReHRHy
|
|
5
|
+
CcKitfOT3aWWFoFX2mzu7Y6/2ouwfDTzmE905Tv+43+UHwbYdlKrPXSB7A00TY7t
|
|
6
|
+
8mJ6jTiw45pODub9LKse0ECrNzUVPq8I1AXwDekK45AJVRnOnN77+9nNA6PBzHE5
|
|
7
|
+
9oeo/0V/trwiDNKAsGPDJ0wCMqKKQ8r2kDxTDfSph7jZ8fB+9PSdV/ko35uYRk1O
|
|
8
|
+
/3MecFktAgMBAAECggEAB9zFyIne1ssB7q5vmmITOwFoccqVzLcAH8uAHO5T1QrV
|
|
9
|
+
gLxa+eRIgYZDM9QnwFzwsDcCjFwRfqOCAlEhEpBAL4YVjos1utePdm//QGYtO7Ig
|
|
10
|
+
F8StpEFTSsxo/D2gxRRLZ9/40btVvSFPbwsBFmlCxYabmeyLt3nMuEAAFoP0uMa/
|
|
11
|
+
rqA9t+Tv/lzV1Uon0ED4Z2vmA4vCX/LoSlKpHAGt8rEXIu7S7rRLqNW3MsJPs4zB
|
|
12
|
+
M0jE3635LTHZMboX8g90yCBajtSSCVQiVEYW3nLecsgjLTKKgMSK0FyRJnvcwlBQ
|
|
13
|
+
pRCmsn664diwSyyYKSG6dm4j6bm6hKg8842d6ZO18QKBgQD9jk36T4AWzxWQZ9Tv
|
|
14
|
+
SjYE5npy0uq4esKD1d/ROKppEnG10kkUu40YPwGxfFQutzdCFeqou5/8A4AV3lUE
|
|
15
|
+
mTwi+q2g+M8UpjzgmziiZfARFxdQrohCDmbU9OptS2FU2ZOW5sViBpi+GE9ndOaA
|
|
16
|
+
cPui/1OuOr7vjhCnJfTi3ojS8QKBgQDqXu1lXOhferraiy1VpD8EWIUj8UmX4Sea
|
|
17
|
+
TiNpxFi9S+xN8tMvo+K14+L39jsNw6rJwTzJL9V6fQyl1wIUyX/kKJ3r4jtSMnZv
|
|
18
|
+
GVhcgcyksTZGIfwFcLJEOmaPTgL0AItrHa8kyKzCBOo5etrJyGLVVSzR4TkuFfGJ
|
|
19
|
+
AZ605tPx/QKBgBOXk17sFbGtfrUR0NpMma/3Py7wLULj+XPGauz3u/MygabTAOKh
|
|
20
|
+
O13MQI0+ViLl9Vcd6mvvU4Vdn+AQtfENBiCNzizKDPZDgiC43b9usQYhCqQpWE4C
|
|
21
|
+
Xt/FrPeVA4hS55yZaFcSu2q05i3QUp9KG6eUoxqrX2WTTKYdwLZnC5uBAoGBAIze
|
|
22
|
+
V7QIHsdcvjijVLFYEmRrTEMpQQGf3Czb8F8fG/NTUgob/KFy0M5g1cgSYLZKODoi
|
|
23
|
+
AoYuURLZXKPFUsPpxQv++cSQ6vThzdvDESAxCC6pMSUAQjmG3i8yJvjVe+Lq/OF6
|
|
24
|
+
Kw5h66yGRb4cwKpt3jG5i0HvLG4t1Ep0Bc9XumaFAoGAR0UIShGDKmvMg8T7wE7a
|
|
25
|
+
OeVvUqRu1mWxLMKd/YZZya2OTXIRu3gtHw7CtuzyfwJCZqPEqGzhgQg2843X/bS0
|
|
26
|
+
H4dT1MQBQMhYijwzTI41RLofItC41f4wPNvjHkJ9NCN6ojVGT1uSYJs+tBWgJ2Et
|
|
27
|
+
R6II7nP1/ybElZeCLdy9YDU=
|
|
28
|
+
-----END PRIVATE KEY-----
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
import { simpleParser } from 'mailparser';
|
|
7
|
+
import { SMTPServer } from 'smtp-server';
|
|
8
|
+
|
|
9
|
+
import { createMailMagicServer } from '../../src/index.js';
|
|
10
|
+
|
|
11
|
+
import type { mailApiServer } from '../../src/server.js';
|
|
12
|
+
import type { mailStore } from '../../src/store/store.js';
|
|
13
|
+
import type { ParsedMail } from 'mailparser';
|
|
14
|
+
import type { AddressInfo } from 'node:net';
|
|
15
|
+
|
|
16
|
+
type SmtpCapture = {
|
|
17
|
+
server: SMTPServer;
|
|
18
|
+
port: number;
|
|
19
|
+
messages: ParsedMail[];
|
|
20
|
+
waitForMessage: (timeoutMs?: number) => Promise<ParsedMail>;
|
|
21
|
+
reset: () => void;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type EnvSnapshot = Record<string, string | undefined>;
|
|
25
|
+
|
|
26
|
+
export type TestContext = {
|
|
27
|
+
server: mailApiServer;
|
|
28
|
+
store: mailStore;
|
|
29
|
+
smtp: SmtpCapture;
|
|
30
|
+
tempDir: string;
|
|
31
|
+
configPath: string;
|
|
32
|
+
uploadFile: string;
|
|
33
|
+
domainName: string;
|
|
34
|
+
userToken: string;
|
|
35
|
+
apiUrl: string;
|
|
36
|
+
cleanup: () => Promise<void>;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
40
|
+
const CERT_PATH = path.join(__dirname, '../fixtures/certs/test.crt');
|
|
41
|
+
const KEY_PATH = path.join(__dirname, '../fixtures/certs/test.key');
|
|
42
|
+
|
|
43
|
+
function snapshotEnv(keys: string[]): EnvSnapshot {
|
|
44
|
+
return keys.reduce<EnvSnapshot>((acc, key) => {
|
|
45
|
+
acc[key] = process.env[key];
|
|
46
|
+
return acc;
|
|
47
|
+
}, {});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function restoreEnv(snapshot: EnvSnapshot) {
|
|
51
|
+
for (const [key, value] of Object.entries(snapshot)) {
|
|
52
|
+
if (value === undefined) {
|
|
53
|
+
delete process.env[key];
|
|
54
|
+
} else {
|
|
55
|
+
process.env[key] = value;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function writeFixtureConfig(configPath: string, domainName: string) {
|
|
61
|
+
const domainRoot = path.join(configPath, domainName);
|
|
62
|
+
const assetsRoot = path.join(domainRoot, 'assets');
|
|
63
|
+
const txRoot = path.join(domainRoot, 'tx-template');
|
|
64
|
+
const formRoot = path.join(domainRoot, 'form-template');
|
|
65
|
+
|
|
66
|
+
fs.mkdirSync(path.join(assetsRoot, 'images'), { recursive: true });
|
|
67
|
+
fs.mkdirSync(path.join(assetsRoot, 'files'), { recursive: true });
|
|
68
|
+
fs.mkdirSync(path.join(txRoot, 'partials'), { recursive: true });
|
|
69
|
+
fs.mkdirSync(path.join(formRoot, 'partials'), { recursive: true });
|
|
70
|
+
|
|
71
|
+
fs.writeFileSync(path.join(assetsRoot, 'images', 'logo.png'), 'logo-bytes');
|
|
72
|
+
fs.writeFileSync(path.join(assetsRoot, 'files', 'banner.png'), 'banner-bytes');
|
|
73
|
+
|
|
74
|
+
const txBase = `<!doctype html>
|
|
75
|
+
<html>
|
|
76
|
+
<head><title>{{ title }}</title></head>
|
|
77
|
+
<body>
|
|
78
|
+
{% block body %}{% endblock %}
|
|
79
|
+
</body>
|
|
80
|
+
</html>
|
|
81
|
+
`;
|
|
82
|
+
const txHeader = `<h1>{{ heading }}</h1>`;
|
|
83
|
+
const txWelcome = `{% extends "base.njk" %}
|
|
84
|
+
{% block body %}
|
|
85
|
+
{% include "partials/header.njk" %}
|
|
86
|
+
<p>Hello {{ name }}</p>
|
|
87
|
+
<img src="asset('images/logo.png', true)" alt="logo" />
|
|
88
|
+
<img src="asset('files/banner.png')" alt="banner" />
|
|
89
|
+
{% endblock %}
|
|
90
|
+
`;
|
|
91
|
+
|
|
92
|
+
fs.writeFileSync(path.join(txRoot, 'base.njk'), txBase);
|
|
93
|
+
fs.writeFileSync(path.join(txRoot, 'partials', 'header.njk'), txHeader);
|
|
94
|
+
fs.writeFileSync(path.join(txRoot, 'welcome.njk'), txWelcome);
|
|
95
|
+
|
|
96
|
+
const formBase = `<!doctype html>
|
|
97
|
+
<html>
|
|
98
|
+
<body>
|
|
99
|
+
{% block body %}{% endblock %}
|
|
100
|
+
</body>
|
|
101
|
+
</html>
|
|
102
|
+
`;
|
|
103
|
+
const formFields = `<p>Name: {{ _fields_.name }}</p>
|
|
104
|
+
<p>Email: {{ _fields_.email }}</p>`;
|
|
105
|
+
const formContact = `{% extends "base.njk" %}
|
|
106
|
+
{% block body %}
|
|
107
|
+
{% include "partials/fields.njk" %}
|
|
108
|
+
<p>IP: {{ _meta_.client_ip }}</p>
|
|
109
|
+
<img src="asset('images/logo.png', true)" alt="logo" />
|
|
110
|
+
{% endblock %}
|
|
111
|
+
`;
|
|
112
|
+
|
|
113
|
+
fs.writeFileSync(path.join(formRoot, 'base.njk'), formBase);
|
|
114
|
+
fs.writeFileSync(path.join(formRoot, 'partials', 'fields.njk'), formFields);
|
|
115
|
+
fs.writeFileSync(path.join(formRoot, 'contact.njk'), formContact);
|
|
116
|
+
|
|
117
|
+
const initData = {
|
|
118
|
+
user: [
|
|
119
|
+
{
|
|
120
|
+
user_id: 1,
|
|
121
|
+
idname: 'testuser',
|
|
122
|
+
token: 'test-token',
|
|
123
|
+
name: 'Test User',
|
|
124
|
+
email: 'testuser@example.test',
|
|
125
|
+
domain: 1
|
|
126
|
+
}
|
|
127
|
+
],
|
|
128
|
+
domain: [
|
|
129
|
+
{
|
|
130
|
+
domain_id: 1,
|
|
131
|
+
user_id: 1,
|
|
132
|
+
name: domainName,
|
|
133
|
+
sender: 'Test Sender <sender@example.test>',
|
|
134
|
+
is_default: true
|
|
135
|
+
}
|
|
136
|
+
],
|
|
137
|
+
template: [
|
|
138
|
+
{
|
|
139
|
+
template_id: 1,
|
|
140
|
+
user_id: 1,
|
|
141
|
+
domain_id: 1,
|
|
142
|
+
name: 'welcome',
|
|
143
|
+
locale: '',
|
|
144
|
+
template: '',
|
|
145
|
+
filename: '',
|
|
146
|
+
sender: 'sender@example.test',
|
|
147
|
+
subject: 'Welcome!',
|
|
148
|
+
slug: ''
|
|
149
|
+
}
|
|
150
|
+
],
|
|
151
|
+
form: [
|
|
152
|
+
{
|
|
153
|
+
form_id: 1,
|
|
154
|
+
user_id: 1,
|
|
155
|
+
domain_id: 1,
|
|
156
|
+
locale: '',
|
|
157
|
+
idname: 'contact',
|
|
158
|
+
sender: 'forms@example.test',
|
|
159
|
+
recipient: 'owner@example.test',
|
|
160
|
+
subject: 'Contact',
|
|
161
|
+
template: '',
|
|
162
|
+
filename: '',
|
|
163
|
+
slug: '',
|
|
164
|
+
secret: 's3cret',
|
|
165
|
+
files: []
|
|
166
|
+
}
|
|
167
|
+
]
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
fs.writeFileSync(path.join(configPath, 'init-data.json'), JSON.stringify(initData, null, 2));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function startSmtpServer(): Promise<SmtpCapture> {
|
|
174
|
+
const cert = fs.readFileSync(CERT_PATH);
|
|
175
|
+
const key = fs.readFileSync(KEY_PATH);
|
|
176
|
+
const messages: ParsedMail[] = [];
|
|
177
|
+
|
|
178
|
+
const server = new SMTPServer({
|
|
179
|
+
secure: true,
|
|
180
|
+
key,
|
|
181
|
+
cert,
|
|
182
|
+
authOptional: true,
|
|
183
|
+
onData(stream, _session, callback) {
|
|
184
|
+
const chunks: Buffer[] = [];
|
|
185
|
+
stream.on('data', (chunk) => chunks.push(chunk));
|
|
186
|
+
stream.on('end', async () => {
|
|
187
|
+
try {
|
|
188
|
+
const parsed = await simpleParser(Buffer.concat(chunks));
|
|
189
|
+
messages.push(parsed);
|
|
190
|
+
callback();
|
|
191
|
+
} catch (err) {
|
|
192
|
+
callback(err as Error);
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
await new Promise<void>((resolve, reject) => {
|
|
199
|
+
server.once('error', reject);
|
|
200
|
+
server.listen(0, '127.0.0.1', () => resolve());
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
const address = server.server.address() as AddressInfo;
|
|
204
|
+
const port = address.port;
|
|
205
|
+
|
|
206
|
+
const waitForMessage = async (timeoutMs = 5000) => {
|
|
207
|
+
const start = Date.now();
|
|
208
|
+
while (messages.length === 0) {
|
|
209
|
+
if (Date.now() - start > timeoutMs) {
|
|
210
|
+
throw new Error('Timed out waiting for SMTP message');
|
|
211
|
+
}
|
|
212
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
213
|
+
}
|
|
214
|
+
const next = messages.shift();
|
|
215
|
+
if (!next) {
|
|
216
|
+
throw new Error('SMTP message queue was empty');
|
|
217
|
+
}
|
|
218
|
+
return next;
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
server,
|
|
223
|
+
port,
|
|
224
|
+
messages,
|
|
225
|
+
waitForMessage,
|
|
226
|
+
reset: () => {
|
|
227
|
+
messages.length = 0;
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export async function createTestContext(): Promise<TestContext> {
|
|
233
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mail-magic-test-'));
|
|
234
|
+
const configPath = path.join(tempDir, 'config');
|
|
235
|
+
fs.mkdirSync(configPath, { recursive: true });
|
|
236
|
+
|
|
237
|
+
const domainName = 'example.test';
|
|
238
|
+
writeFixtureConfig(configPath, domainName);
|
|
239
|
+
|
|
240
|
+
const smtp = await startSmtpServer();
|
|
241
|
+
|
|
242
|
+
const uploadPath = path.join(tempDir, 'uploads');
|
|
243
|
+
fs.mkdirSync(uploadPath, { recursive: true });
|
|
244
|
+
|
|
245
|
+
const uploadFile = path.join(tempDir, 'upload.txt');
|
|
246
|
+
fs.writeFileSync(uploadFile, 'upload-bytes');
|
|
247
|
+
|
|
248
|
+
const apiUrl = 'http://mail.test/api';
|
|
249
|
+
|
|
250
|
+
const envKeys = [
|
|
251
|
+
'NODE_ENV',
|
|
252
|
+
'CONFIG_PATH',
|
|
253
|
+
'DB_NAME',
|
|
254
|
+
'DB_TYPE',
|
|
255
|
+
'DB_FORCE_SYNC',
|
|
256
|
+
'DB_AUTO_RELOAD',
|
|
257
|
+
'API_URL',
|
|
258
|
+
'ASSET_ROUTE',
|
|
259
|
+
'API_HOST',
|
|
260
|
+
'API_PORT',
|
|
261
|
+
'UPLOAD_PATH',
|
|
262
|
+
'SMTP_HOST',
|
|
263
|
+
'SMTP_PORT',
|
|
264
|
+
'SMTP_SECURE',
|
|
265
|
+
'SMTP_TLS_REJECT',
|
|
266
|
+
'SMTP_USER',
|
|
267
|
+
'SMTP_PASSWORD',
|
|
268
|
+
'DEBUG'
|
|
269
|
+
];
|
|
270
|
+
const envSnapshot = snapshotEnv(envKeys);
|
|
271
|
+
|
|
272
|
+
process.env.NODE_ENV = 'development';
|
|
273
|
+
process.env.CONFIG_PATH = configPath;
|
|
274
|
+
process.env.DB_NAME = path.join(tempDir, 'mailmagic-test.db');
|
|
275
|
+
process.env.DB_TYPE = 'sqlite';
|
|
276
|
+
process.env.DB_FORCE_SYNC = 'true';
|
|
277
|
+
process.env.DB_AUTO_RELOAD = 'false';
|
|
278
|
+
process.env.API_URL = apiUrl;
|
|
279
|
+
process.env.ASSET_ROUTE = '/asset';
|
|
280
|
+
process.env.API_HOST = '127.0.0.1';
|
|
281
|
+
process.env.API_PORT = '0';
|
|
282
|
+
process.env.UPLOAD_PATH = uploadPath;
|
|
283
|
+
process.env.SMTP_HOST = '127.0.0.1';
|
|
284
|
+
process.env.SMTP_PORT = String(smtp.port);
|
|
285
|
+
process.env.SMTP_SECURE = 'true';
|
|
286
|
+
process.env.SMTP_TLS_REJECT = 'false';
|
|
287
|
+
process.env.SMTP_USER = '';
|
|
288
|
+
process.env.SMTP_PASSWORD = '';
|
|
289
|
+
process.env.DEBUG = 'false';
|
|
290
|
+
|
|
291
|
+
const bootstrap = await createMailMagicServer({ apiBasePath: '' });
|
|
292
|
+
|
|
293
|
+
const cleanup = async () => {
|
|
294
|
+
await new Promise<void>((resolve) => {
|
|
295
|
+
smtp.server.close(() => resolve());
|
|
296
|
+
});
|
|
297
|
+
if (bootstrap.store.api_db) {
|
|
298
|
+
await bootstrap.store.api_db.close();
|
|
299
|
+
}
|
|
300
|
+
restoreEnv(envSnapshot);
|
|
301
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
return {
|
|
305
|
+
server: bootstrap.server,
|
|
306
|
+
store: bootstrap.store,
|
|
307
|
+
smtp,
|
|
308
|
+
tempDir,
|
|
309
|
+
configPath,
|
|
310
|
+
uploadFile,
|
|
311
|
+
domainName,
|
|
312
|
+
userToken: 'test-token',
|
|
313
|
+
apiUrl,
|
|
314
|
+
cleanup
|
|
315
|
+
};
|
|
316
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import request from 'supertest';
|
|
2
|
+
|
|
3
|
+
import { api_form } from '../src/models/form.js';
|
|
4
|
+
import { api_txmail } from '../src/models/txmail.js';
|
|
5
|
+
|
|
6
|
+
import { createTestContext } from './helpers/test-setup.js';
|
|
7
|
+
|
|
8
|
+
import type { TestContext } from './helpers/test-setup.js';
|
|
9
|
+
|
|
10
|
+
describe('mail-magic API', () => {
|
|
11
|
+
let ctx: TestContext | null = null;
|
|
12
|
+
let api: ReturnType<typeof request>;
|
|
13
|
+
|
|
14
|
+
beforeAll(async () => {
|
|
15
|
+
ctx = await createTestContext();
|
|
16
|
+
api = request((ctx.server as unknown as { app: unknown }).app);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterAll(async () => {
|
|
20
|
+
if (ctx) {
|
|
21
|
+
await ctx.cleanup();
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
ctx?.smtp.reset();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('loads transactional templates with hierarchy and assets', async () => {
|
|
30
|
+
const template = await api_txmail.findOne({ where: { name: 'welcome' } });
|
|
31
|
+
expect(template).toBeTruthy();
|
|
32
|
+
if (!template) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
expect(template.template).toContain('<title>{{ title }}</title>');
|
|
37
|
+
expect(template.template).toContain('<h1>{{ heading }}</h1>');
|
|
38
|
+
expect(template.template).toContain('cid:images/logo.png');
|
|
39
|
+
|
|
40
|
+
const expectedUrl = `${ctx.apiUrl}/asset/${ctx.domainName}/files/banner.png`;
|
|
41
|
+
expect(template.template).toContain(expectedUrl);
|
|
42
|
+
|
|
43
|
+
const inline = template.files.find((file) => file.filename === 'images/logo.png');
|
|
44
|
+
const external = template.files.find((file) => file.filename === 'files/banner.png');
|
|
45
|
+
|
|
46
|
+
expect(inline?.cid).toBe('images/logo.png');
|
|
47
|
+
expect(external?.cid).toBeUndefined();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('loads form templates with hierarchy and asset rewrites', async () => {
|
|
51
|
+
const form = await api_form.findOne({ where: { idname: 'contact' } });
|
|
52
|
+
expect(form).toBeTruthy();
|
|
53
|
+
if (!form) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
expect(form.template).toContain('Name: {{ _fields_.name }}');
|
|
58
|
+
expect(form.template).toContain('Email: {{ _fields_.email }}');
|
|
59
|
+
expect(form.template).toContain('cid:images/logo.png');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('serves assets from the public route and blocks traversal', async () => {
|
|
63
|
+
const assetPath = `/api/asset/${ctx.domainName}/files/banner.png`;
|
|
64
|
+
const res = await api.get(assetPath);
|
|
65
|
+
expect(res.status).toBe(200);
|
|
66
|
+
expect(res.headers['cache-control']).toContain('max-age=300');
|
|
67
|
+
expect(res.headers['content-type']).toContain('image/png');
|
|
68
|
+
expect(res.body.toString()).toBe('banner-bytes');
|
|
69
|
+
|
|
70
|
+
const bad = await api.get(`/api/asset/${ctx.domainName}/%2e%2e/secret.txt`);
|
|
71
|
+
expect(bad.status).toBe(404);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('responds to ping', async () => {
|
|
75
|
+
const res = await api.get('/api/v1/ping');
|
|
76
|
+
expect(res.status).toBe(200);
|
|
77
|
+
expect(res.body.success).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('stores templates via the API', async () => {
|
|
81
|
+
const res = await api.post('/api/v1/tx/template').set('Authorization', `Bearer apikey-${ctx.userToken}`).send({
|
|
82
|
+
name: 'custom',
|
|
83
|
+
domain: ctx.domainName,
|
|
84
|
+
sender: 'sender@example.test',
|
|
85
|
+
subject: 'Custom',
|
|
86
|
+
template: '<p>Custom {{ name }}</p>'
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
expect(res.status).toBe(200);
|
|
90
|
+
|
|
91
|
+
const stored = await api_txmail.findOne({ where: { name: 'custom' } });
|
|
92
|
+
expect(stored?.template).toContain('Custom');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('sends transactional mail with inline assets and attachments', async () => {
|
|
96
|
+
const res = await api
|
|
97
|
+
.post('/api/v1/tx/message')
|
|
98
|
+
.set('Authorization', `Bearer apikey-${ctx.userToken}`)
|
|
99
|
+
.field('name', 'welcome')
|
|
100
|
+
.field('domain', ctx.domainName)
|
|
101
|
+
.field('rcpt', 'recipient@example.test')
|
|
102
|
+
.field('vars', JSON.stringify({ title: 'Hello', heading: 'Mail Magic', name: 'Jane' }))
|
|
103
|
+
.attach('file1', ctx.uploadFile);
|
|
104
|
+
|
|
105
|
+
expect(res.status).toBe(200);
|
|
106
|
+
|
|
107
|
+
const message = await ctx.smtp.waitForMessage();
|
|
108
|
+
const html = typeof message.html === 'string' ? message.html : String(message.html ?? '');
|
|
109
|
+
expect(message.subject).toBe('Welcome!');
|
|
110
|
+
expect(html).toContain('Mail Magic');
|
|
111
|
+
expect(html).toContain('Hello Jane');
|
|
112
|
+
|
|
113
|
+
const filenames = message.attachments.map((att) => att.filename ?? '');
|
|
114
|
+
expect(filenames.some((name) => name.endsWith('logo.png'))).toBe(true);
|
|
115
|
+
expect(filenames.some((name) => name.endsWith('banner.png'))).toBe(true);
|
|
116
|
+
expect(filenames).toContain('upload.txt');
|
|
117
|
+
const inline = message.attachments.find((att) => att.contentId?.includes('images/logo.png'));
|
|
118
|
+
expect(inline).toBeTruthy();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('rejects form submissions without the secret', async () => {
|
|
122
|
+
const res = await api.post('/api/v1/form/message').send({
|
|
123
|
+
formid: 'contact',
|
|
124
|
+
name: 'Ada',
|
|
125
|
+
email: 'ada@example.test'
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
expect(res.status).toBe(401);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('accepts form submissions and delivers mail', async () => {
|
|
132
|
+
const res = await api
|
|
133
|
+
.post('/api/v1/form/message')
|
|
134
|
+
.set('x-forwarded-for', '203.0.113.10')
|
|
135
|
+
.field('formid', 'contact')
|
|
136
|
+
.field('secret', 's3cret')
|
|
137
|
+
.field('recipient', 'receiver@example.test')
|
|
138
|
+
.field('name', 'Ada')
|
|
139
|
+
.field('email', 'ada@example.test')
|
|
140
|
+
.attach('file1', ctx.uploadFile);
|
|
141
|
+
|
|
142
|
+
expect(res.status).toBe(200);
|
|
143
|
+
|
|
144
|
+
const message = await ctx.smtp.waitForMessage();
|
|
145
|
+
const html = typeof message.html === 'string' ? message.html : String(message.html ?? '');
|
|
146
|
+
expect(message.subject).toBe('Contact');
|
|
147
|
+
expect(html).toContain('Name: Ada');
|
|
148
|
+
expect(html).toContain('Email: ada@example.test');
|
|
149
|
+
expect(html).toContain('IP: 203.0.113.10');
|
|
150
|
+
|
|
151
|
+
const filenames = message.attachments.map((att) => att.filename ?? '');
|
|
152
|
+
expect(filenames).toContain('upload.txt');
|
|
153
|
+
});
|
|
154
|
+
});
|