@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.
@@ -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 = path.resolve(assetsRoot);
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.startsWith(normalizedRoot)) {
41
+ if (!fs.existsSync(candidate) || !fs.statSync(candidate).isFile()) {
42
42
  return null;
43
43
  }
44
- if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
45
- return candidate;
44
+ const realCandidate = fs.realpathSync(candidate);
45
+ if (!realCandidate.startsWith(normalizedRoot)) {
46
+ return null;
46
47
  }
47
- return null;
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 absPath = path.resolve(rootDir, pathname || '', relFile);
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(rootDir)) {
130
+ if (!absPath.startsWith(normalizedRoot)) {
128
131
  throw new Error(`Invalid template path "${filename}"`);
129
132
  }
130
133
  if (!fs.existsSync(absPath)) {
@@ -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 = path.normalize(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.STRING,
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;
@@ -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'
@@ -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
+ });
@@ -0,0 +1,11 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ environment: 'node',
6
+ globals: true,
7
+ include: ['tests/**/*.test.ts'],
8
+ testTimeout: 20000,
9
+ hookTimeout: 20000
10
+ }
11
+ });