@technomoron/mail-magic 1.0.9 → 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.
Files changed (44) hide show
  1. package/CHANGES +19 -0
  2. package/README.md +5 -0
  3. package/dist/api/assets.js +153 -0
  4. package/dist/api/forms.js +2 -0
  5. package/dist/api/mailer.js +1 -0
  6. package/dist/bin/mail-magic.js +63 -0
  7. package/dist/index.js +61 -2
  8. package/dist/store/envloader.js +3 -3
  9. package/dist/store/store.js +66 -1
  10. package/package.json +17 -3
  11. package/.do-realease.sh +0 -54
  12. package/.editorconfig +0 -9
  13. package/.env-dist +0 -71
  14. package/.prettierrc +0 -14
  15. package/.vscode/extensions.json +0 -3
  16. package/.vscode/settings.json +0 -22
  17. package/config-example/form-template/default.njk +0 -102
  18. package/config-example/forms.config.json +0 -8
  19. package/config-example/init-data.json +0 -33
  20. package/config-example/tx-template/default.njk +0 -107
  21. package/ecosystem.config.cjs +0 -42
  22. package/eslint.config.mjs +0 -196
  23. package/lintconfig.cjs +0 -81
  24. package/src/api/assets.ts +0 -92
  25. package/src/api/forms.ts +0 -239
  26. package/src/api/mailer.ts +0 -270
  27. package/src/index.ts +0 -71
  28. package/src/models/db.ts +0 -112
  29. package/src/models/domain.ts +0 -72
  30. package/src/models/form.ts +0 -209
  31. package/src/models/init.ts +0 -240
  32. package/src/models/txmail.ts +0 -206
  33. package/src/models/user.ts +0 -79
  34. package/src/server.ts +0 -27
  35. package/src/store/envloader.ts +0 -109
  36. package/src/store/store.ts +0 -195
  37. package/src/types.ts +0 -39
  38. package/src/util.ts +0 -137
  39. package/tests/fixtures/certs/test.crt +0 -19
  40. package/tests/fixtures/certs/test.key +0 -28
  41. package/tests/helpers/test-setup.ts +0 -317
  42. package/tests/mail-magic.test.ts +0 -171
  43. package/tsconfig.json +0 -14
  44. package/vitest.config.ts +0 -11
package/src/util.ts DELETED
@@ -1,137 +0,0 @@
1
- import { api_domain } from './models/domain.js';
2
- import { api_user } from './models/user.js';
3
-
4
- import type { RequestMeta } from './types.js';
5
-
6
- /**
7
- * Normalize a string into a safe identifier for slugs, filenames, etc.
8
- *
9
- * - Lowercases all characters
10
- * - Replaces any character that is not `a-z`, `0-9`, `-`, '.' or `_` with `-`
11
- * - Collapses multiple consecutive dashes into one
12
- * - Trims leading and trailing dashes
13
- *
14
- * Examples:
15
- * normalizeSlug("Hello World!") -> "hello-world"
16
- * normalizeSlug(" Áccêntš ") -> "ccnt"
17
- * normalizeSlug("My--Slug__Test") -> "my-slug__test"
18
- */
19
- export function normalizeSlug(input: string): string {
20
- if (!input) {
21
- return '';
22
- }
23
- return input
24
- .trim()
25
- .toLowerCase()
26
- .replace(/[^a-z0-9-_\.]/g, '-')
27
- .replace(/--+/g, '-') // collapse multiple dashes
28
- .replace(/^-+|-+$/g, ''); // trim leading/trailing dashes
29
- }
30
-
31
- export async function user_and_domain(domain_id: number): Promise<{ user: api_user; domain: api_domain }> {
32
- const domain = await api_domain.findByPk(domain_id);
33
- if (!domain) {
34
- throw new Error(`Unable to look up domain ${domain_id}`);
35
- }
36
- const user = await api_user.findByPk(domain.user_id);
37
- if (!user) {
38
- throw new Error(`Unable to look up user ${domain.user_id}`);
39
- }
40
- return { user, domain };
41
- }
42
-
43
- type HeaderValue = string | string[] | undefined | null;
44
-
45
- function collectHeaderIps(header: string | string[] | undefined): string[] {
46
- if (!header) {
47
- return [];
48
- }
49
- if (Array.isArray(header)) {
50
- return header
51
- .join(',')
52
- .split(',')
53
- .map((ip) => ip.trim())
54
- .filter(Boolean);
55
- }
56
- return header
57
- .split(',')
58
- .map((ip) => ip.trim())
59
- .filter(Boolean);
60
- }
61
-
62
- function resolveHeader(headers: Record<string, unknown>, key: string): string | string[] | undefined {
63
- const direct = headers[key];
64
- const alt = headers[key.toLowerCase()];
65
- const value = direct ?? alt;
66
- if (typeof value === 'string' || Array.isArray(value)) {
67
- return value;
68
- }
69
- return undefined;
70
- }
71
-
72
- interface RequestLike {
73
- headers?: Record<string, HeaderValue>;
74
- ip?: string | null;
75
- socket?: { remoteAddress?: string | null } | null;
76
- }
77
-
78
- export function buildRequestMeta(rawReq: unknown): RequestMeta {
79
- const req = (rawReq ?? {}) as RequestLike;
80
- const headers = req.headers ?? {};
81
- const ips: string[] = [];
82
- ips.push(...collectHeaderIps(resolveHeader(headers, 'x-forwarded-for')));
83
- const realIp = resolveHeader(headers, 'x-real-ip');
84
- if (typeof realIp === 'string' && realIp.trim()) {
85
- ips.push(realIp.trim());
86
- }
87
- const cfIp = resolveHeader(headers, 'cf-connecting-ip');
88
- if (typeof cfIp === 'string' && cfIp.trim()) {
89
- ips.push(cfIp.trim());
90
- }
91
- const fastlyIp = resolveHeader(headers, 'fastly-client-ip');
92
- if (typeof fastlyIp === 'string' && fastlyIp.trim()) {
93
- ips.push(fastlyIp.trim());
94
- }
95
- if (req.ip && req.ip.trim()) {
96
- ips.push(req.ip.trim());
97
- }
98
- const remoteAddress = req.socket?.remoteAddress;
99
- if (remoteAddress) {
100
- ips.push(remoteAddress);
101
- }
102
-
103
- const uniqueIps = ips.filter((ip, index) => ips.indexOf(ip) === index);
104
- const clientIp = uniqueIps[0] || '';
105
-
106
- return {
107
- client_ip: clientIp,
108
- received_at: new Date().toISOString(),
109
- ip_chain: uniqueIps
110
- };
111
- }
112
-
113
- export function decodeComponent(value: string | undefined): string {
114
- if (!value) {
115
- return '';
116
- }
117
- try {
118
- return decodeURIComponent(value);
119
- } catch {
120
- return value;
121
- }
122
- }
123
-
124
- export function sendFileAsync(
125
- res: { sendFile: (path: string, cb: (err?: Error | null) => void) => void },
126
- file: string
127
- ): Promise<void> {
128
- return new Promise((resolve, reject) => {
129
- res.sendFile(file, (err) => {
130
- if (err) {
131
- reject(err);
132
- } else {
133
- resolve();
134
- }
135
- });
136
- });
137
- }
@@ -1,19 +0,0 @@
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-----
@@ -1,28 +0,0 @@
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-----
@@ -1,317 +0,0 @@
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
- uploadsPath: string;
34
- domainName: string;
35
- userToken: string;
36
- apiUrl: string;
37
- cleanup: () => Promise<void>;
38
- };
39
-
40
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
41
- const CERT_PATH = path.join(__dirname, '../fixtures/certs/test.crt');
42
- const KEY_PATH = path.join(__dirname, '../fixtures/certs/test.key');
43
-
44
- function snapshotEnv(keys: string[]): EnvSnapshot {
45
- return keys.reduce<EnvSnapshot>((acc, key) => {
46
- acc[key] = process.env[key];
47
- return acc;
48
- }, {});
49
- }
50
-
51
- function restoreEnv(snapshot: EnvSnapshot) {
52
- for (const [key, value] of Object.entries(snapshot)) {
53
- if (value === undefined) {
54
- delete process.env[key];
55
- } else {
56
- process.env[key] = value;
57
- }
58
- }
59
- }
60
-
61
- function writeFixtureConfig(configPath: string, domainName: string) {
62
- const domainRoot = path.join(configPath, domainName);
63
- const assetsRoot = path.join(domainRoot, 'assets');
64
- const txRoot = path.join(domainRoot, 'tx-template');
65
- const formRoot = path.join(domainRoot, 'form-template');
66
-
67
- fs.mkdirSync(path.join(assetsRoot, 'images'), { recursive: true });
68
- fs.mkdirSync(path.join(assetsRoot, 'files'), { recursive: true });
69
- fs.mkdirSync(path.join(txRoot, 'partials'), { recursive: true });
70
- fs.mkdirSync(path.join(formRoot, 'partials'), { recursive: true });
71
-
72
- fs.writeFileSync(path.join(assetsRoot, 'images', 'logo.png'), 'logo-bytes');
73
- fs.writeFileSync(path.join(assetsRoot, 'files', 'banner.png'), 'banner-bytes');
74
-
75
- const txBase = `<!doctype html>
76
- <html>
77
- <head><title>{{ title }}</title></head>
78
- <body>
79
- {% block body %}{% endblock %}
80
- </body>
81
- </html>
82
- `;
83
- const txHeader = `<h1>{{ heading }}</h1>`;
84
- const txWelcome = `{% extends "base.njk" %}
85
- {% block body %}
86
- {% include "partials/header.njk" %}
87
- <p>Hello {{ name }}</p>
88
- <img src="asset('images/logo.png', true)" alt="logo" />
89
- <img src="asset('files/banner.png')" alt="banner" />
90
- {% endblock %}
91
- `;
92
-
93
- fs.writeFileSync(path.join(txRoot, 'base.njk'), txBase);
94
- fs.writeFileSync(path.join(txRoot, 'partials', 'header.njk'), txHeader);
95
- fs.writeFileSync(path.join(txRoot, 'welcome.njk'), txWelcome);
96
-
97
- const formBase = `<!doctype html>
98
- <html>
99
- <body>
100
- {% block body %}{% endblock %}
101
- </body>
102
- </html>
103
- `;
104
- const formFields = `<p>Name: {{ _fields_.name }}</p>
105
- <p>Email: {{ _fields_.email }}</p>`;
106
- const formContact = `{% extends "base.njk" %}
107
- {% block body %}
108
- {% include "partials/fields.njk" %}
109
- <p>IP: {{ _meta_.client_ip }}</p>
110
- <img src="asset('images/logo.png', true)" alt="logo" />
111
- {% endblock %}
112
- `;
113
-
114
- fs.writeFileSync(path.join(formRoot, 'base.njk'), formBase);
115
- fs.writeFileSync(path.join(formRoot, 'partials', 'fields.njk'), formFields);
116
- fs.writeFileSync(path.join(formRoot, 'contact.njk'), formContact);
117
-
118
- const initData = {
119
- user: [
120
- {
121
- user_id: 1,
122
- idname: 'testuser',
123
- token: 'test-token',
124
- name: 'Test User',
125
- email: 'testuser@example.test',
126
- domain: 1
127
- }
128
- ],
129
- domain: [
130
- {
131
- domain_id: 1,
132
- user_id: 1,
133
- name: domainName,
134
- sender: 'Test Sender <sender@example.test>',
135
- is_default: true
136
- }
137
- ],
138
- template: [
139
- {
140
- template_id: 1,
141
- user_id: 1,
142
- domain_id: 1,
143
- name: 'welcome',
144
- locale: '',
145
- template: '',
146
- filename: '',
147
- sender: 'sender@example.test',
148
- subject: 'Welcome!',
149
- slug: ''
150
- }
151
- ],
152
- form: [
153
- {
154
- form_id: 1,
155
- user_id: 1,
156
- domain_id: 1,
157
- locale: '',
158
- idname: 'contact',
159
- sender: 'forms@example.test',
160
- recipient: 'owner@example.test',
161
- subject: 'Contact',
162
- template: '',
163
- filename: '',
164
- slug: '',
165
- secret: 's3cret',
166
- files: []
167
- }
168
- ]
169
- };
170
-
171
- fs.writeFileSync(path.join(configPath, 'init-data.json'), JSON.stringify(initData, null, 2));
172
- }
173
-
174
- async function startSmtpServer(): Promise<SmtpCapture> {
175
- const cert = fs.readFileSync(CERT_PATH);
176
- const key = fs.readFileSync(KEY_PATH);
177
- const messages: ParsedMail[] = [];
178
-
179
- const server = new SMTPServer({
180
- secure: true,
181
- key,
182
- cert,
183
- authOptional: true,
184
- onData(stream, _session, callback) {
185
- const chunks: Buffer[] = [];
186
- stream.on('data', (chunk) => chunks.push(chunk));
187
- stream.on('end', async () => {
188
- try {
189
- const parsed = await simpleParser(Buffer.concat(chunks));
190
- messages.push(parsed);
191
- callback();
192
- } catch (err) {
193
- callback(err as Error);
194
- }
195
- });
196
- }
197
- });
198
-
199
- await new Promise<void>((resolve, reject) => {
200
- server.once('error', reject);
201
- server.listen(0, '127.0.0.1', () => resolve());
202
- });
203
-
204
- const address = server.server.address() as AddressInfo;
205
- const port = address.port;
206
-
207
- const waitForMessage = async (timeoutMs = 5000) => {
208
- const start = Date.now();
209
- while (messages.length === 0) {
210
- if (Date.now() - start > timeoutMs) {
211
- throw new Error('Timed out waiting for SMTP message');
212
- }
213
- await new Promise((resolve) => setTimeout(resolve, 25));
214
- }
215
- const next = messages.shift();
216
- if (!next) {
217
- throw new Error('SMTP message queue was empty');
218
- }
219
- return next;
220
- };
221
-
222
- return {
223
- server,
224
- port,
225
- messages,
226
- waitForMessage,
227
- reset: () => {
228
- messages.length = 0;
229
- }
230
- };
231
- }
232
-
233
- export async function createTestContext(): Promise<TestContext> {
234
- const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mail-magic-test-'));
235
- const configPath = path.join(tempDir, 'config');
236
- fs.mkdirSync(configPath, { recursive: true });
237
-
238
- const domainName = 'example.test';
239
- writeFixtureConfig(configPath, domainName);
240
-
241
- const smtp = await startSmtpServer();
242
-
243
- const uploadsPath = path.join(configPath, domainName, 'uploads');
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 = './{domain}/uploads';
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
- uploadsPath,
312
- domainName,
313
- userToken: 'test-token',
314
- apiUrl,
315
- cleanup
316
- };
317
- }
@@ -1,171 +0,0 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
-
4
- import request from 'supertest';
5
-
6
- import { api_form } from '../src/models/form.js';
7
- import { api_txmail } from '../src/models/txmail.js';
8
-
9
- import { createTestContext } from './helpers/test-setup.js';
10
-
11
- import type { TestContext } from './helpers/test-setup.js';
12
-
13
- describe('mail-magic API', () => {
14
- let ctx: TestContext | null = null;
15
- let api: ReturnType<typeof request>;
16
-
17
- beforeAll(async () => {
18
- ctx = await createTestContext();
19
- api = request((ctx.server as unknown as { app: unknown }).app);
20
- });
21
-
22
- afterAll(async () => {
23
- if (ctx) {
24
- await ctx.cleanup();
25
- }
26
- });
27
-
28
- beforeEach(() => {
29
- ctx?.smtp.reset();
30
- });
31
-
32
- test('loads transactional templates with hierarchy and assets', async () => {
33
- const template = await api_txmail.findOne({ where: { name: 'welcome' } });
34
- expect(template).toBeTruthy();
35
- if (!template) {
36
- return;
37
- }
38
-
39
- expect(template.template).toContain('<title>{{ title }}</title>');
40
- expect(template.template).toContain('<h1>{{ heading }}</h1>');
41
- expect(template.template).toContain('cid:images/logo.png');
42
-
43
- const expectedUrl = `${ctx.apiUrl}/asset/${ctx.domainName}/files/banner.png`;
44
- expect(template.template).toContain(expectedUrl);
45
-
46
- const inline = template.files.find((file) => file.filename === 'images/logo.png');
47
- const external = template.files.find((file) => file.filename === 'files/banner.png');
48
-
49
- expect(inline?.cid).toBe('images/logo.png');
50
- expect(external?.cid).toBeUndefined();
51
- });
52
-
53
- test('loads form templates with hierarchy and asset rewrites', async () => {
54
- const form = await api_form.findOne({ where: { idname: 'contact' } });
55
- expect(form).toBeTruthy();
56
- if (!form) {
57
- return;
58
- }
59
-
60
- expect(form.template).toContain('Name: {{ _fields_.name }}');
61
- expect(form.template).toContain('Email: {{ _fields_.email }}');
62
- expect(form.template).toContain('cid:images/logo.png');
63
- });
64
-
65
- test('serves assets from the public route and blocks traversal', async () => {
66
- const assetPath = `/api/asset/${ctx.domainName}/files/banner.png`;
67
- const res = await api.get(assetPath);
68
- expect(res.status).toBe(200);
69
- expect(res.headers['cache-control']).toContain('max-age=300');
70
- expect(res.headers['content-type']).toContain('image/png');
71
- expect(res.body.toString()).toBe('banner-bytes');
72
-
73
- const bad = await api.get(`/api/asset/${ctx.domainName}/%2e%2e/secret.txt`);
74
- expect(bad.status).toBe(404);
75
- });
76
-
77
- test('responds to ping', async () => {
78
- const res = await api.get('/api/v1/ping');
79
- expect(res.status).toBe(200);
80
- expect(res.body.success).toBe(true);
81
- });
82
-
83
- test('stores templates via the API', async () => {
84
- const res = await api.post('/api/v1/tx/template').set('Authorization', `Bearer apikey-${ctx.userToken}`).send({
85
- name: 'custom',
86
- domain: ctx.domainName,
87
- sender: 'sender@example.test',
88
- subject: 'Custom',
89
- template: '<p>Custom {{ name }}</p>'
90
- });
91
-
92
- expect(res.status).toBe(200);
93
-
94
- const stored = await api_txmail.findOne({ where: { name: 'custom' } });
95
- expect(stored?.template).toContain('Custom');
96
- });
97
-
98
- test('sends transactional mail with inline assets and attachments', async () => {
99
- const uploadsDir = ctx.uploadsPath;
100
- const beforeUploads = fs.existsSync(uploadsDir) ? fs.readdirSync(uploadsDir) : [];
101
- const res = await api
102
- .post('/api/v1/tx/message')
103
- .set('Authorization', `Bearer apikey-${ctx.userToken}`)
104
- .field('name', 'welcome')
105
- .field('domain', ctx.domainName)
106
- .field('rcpt', 'recipient@example.test')
107
- .field('vars', JSON.stringify({ title: 'Hello', heading: 'Mail Magic', name: 'Jane' }))
108
- .attach('file1', ctx.uploadFile);
109
-
110
- expect(res.status).toBe(200);
111
-
112
- const message = await ctx.smtp.waitForMessage();
113
- const html = typeof message.html === 'string' ? message.html : String(message.html ?? '');
114
- expect(message.subject).toBe('Welcome!');
115
- expect(html).toContain('Mail Magic');
116
- expect(html).toContain('Hello Jane');
117
-
118
- const filenames = message.attachments.map((att) => att.filename ?? '');
119
- expect(filenames.some((name) => name.endsWith('logo.png'))).toBe(true);
120
- expect(filenames.some((name) => name.endsWith('banner.png'))).toBe(true);
121
- expect(filenames).toContain('upload.txt');
122
- const inline = message.attachments.find((att) => att.contentId?.includes('images/logo.png'));
123
- expect(inline).toBeTruthy();
124
-
125
- expect(fs.existsSync(uploadsDir)).toBe(true);
126
- const afterUploads = fs.readdirSync(uploadsDir);
127
- const newUploads = afterUploads.filter((name) => !beforeUploads.includes(name));
128
- expect(newUploads.length).toBeGreaterThan(0);
129
- const uploadedPath = path.join(uploadsDir, newUploads[0]);
130
- expect(fs.readFileSync(uploadedPath, 'utf8')).toBe('upload-bytes');
131
-
132
- const stagingDir = path.join(ctx.configPath, '_uploads');
133
- if (fs.existsSync(stagingDir)) {
134
- expect(fs.existsSync(path.join(stagingDir, newUploads[0]))).toBe(false);
135
- }
136
- });
137
-
138
- test('rejects form submissions without the secret', async () => {
139
- const res = await api.post('/api/v1/form/message').send({
140
- formid: 'contact',
141
- name: 'Ada',
142
- email: 'ada@example.test'
143
- });
144
-
145
- expect(res.status).toBe(401);
146
- });
147
-
148
- test('accepts form submissions and delivers mail', async () => {
149
- const res = await api
150
- .post('/api/v1/form/message')
151
- .set('x-forwarded-for', '203.0.113.10')
152
- .field('formid', 'contact')
153
- .field('secret', 's3cret')
154
- .field('recipient', 'receiver@example.test')
155
- .field('name', 'Ada')
156
- .field('email', 'ada@example.test')
157
- .attach('file1', ctx.uploadFile);
158
-
159
- expect(res.status).toBe(200);
160
-
161
- const message = await ctx.smtp.waitForMessage();
162
- const html = typeof message.html === 'string' ? message.html : String(message.html ?? '');
163
- expect(message.subject).toBe('Contact');
164
- expect(html).toContain('Name: Ada');
165
- expect(html).toContain('Email: ada@example.test');
166
- expect(html).toContain('IP: 203.0.113.10');
167
-
168
- const filenames = message.attachments.map((att) => att.filename ?? '');
169
- expect(filenames).toContain('upload.txt');
170
- });
171
- });
package/tsconfig.json DELETED
@@ -1,14 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "target": "ES2022",
4
- "module": "ES2022", // compile as ES modules
5
- "moduleResolution": "node",
6
- "outDir": "./dist",
7
- "rootDir": "./src",
8
- "strict": true,
9
- "esModuleInterop": true,
10
- "allowSyntheticDefaultImports": true,
11
- "typeRoots": ["./node_modules/@types", "./types"]
12
- },
13
- "include": ["src"]
14
- }
package/vitest.config.ts DELETED
@@ -1,11 +0,0 @@
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
- });