@technomoron/mail-magic 1.0.4 → 1.0.6
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 +18 -2
- package/README.md +7 -1
- package/TUTORIAL.MD +293 -0
- package/dist/api/assets.js +64 -0
- package/dist/api/forms.js +1 -1
- package/dist/index.js +3 -1
- package/dist/models/form.js +2 -2
- package/dist/models/init.js +43 -34
- package/dist/models/txmail.js +4 -5
- package/dist/store/envloader.js +4 -21
- package/dist/util.js +23 -0
- package/package.json +1 -1
- package/src/api/assets.ts +79 -0
- package/src/api/forms.ts +1 -1
- package/src/index.ts +3 -1
- package/src/models/form.ts +2 -2
- package/src/models/init.ts +46 -46
- package/src/models/txmail.ts +4 -5
- package/src/store/envloader.ts +4 -21
- package/src/util.ts +26 -0
- package/test1.sh +0 -13
package/CHANGES
CHANGED
|
@@ -1,4 +1,21 @@
|
|
|
1
|
-
|
|
1
|
+
Version 1.0.6 (2025-11-06)
|
|
2
|
+
|
|
3
|
+
- Added the dedicated asset API module so files stored under `config/<domain>/assets`
|
|
4
|
+
are served directly from `/asset/<domain>/<path>` (configurable via `ASSET_ROUTE`).
|
|
5
|
+
- Tightened the template preprocessor to require non-inline assets to live in the
|
|
6
|
+
domain `assets/` folder and automatically rewrite `asset('logo.png')` calls to the
|
|
7
|
+
public route.
|
|
8
|
+
- Clarified inline usage: `asset('logo.png', true)` continues to embed a CID-backed
|
|
9
|
+
attachment, while omitting the second argument keeps the file external and expects
|
|
10
|
+
it under `config/<domain>/assets`.
|
|
11
|
+
|
|
12
|
+
Version 1.0.5 (2025-10-29)
|
|
13
|
+
|
|
14
|
+
- Store compiled templates and assets under domain-rooted directories inside
|
|
15
|
+
`CONFIG_PATH`, removing the redundant user segment while keeping slugs and
|
|
16
|
+
API behaviour unchanged.
|
|
17
|
+
- Update documentation and examples to reflect the domain-first configuration
|
|
18
|
+
layout.
|
|
2
19
|
|
|
3
20
|
Version 1.0.4 (2025-09-27)
|
|
4
21
|
|
|
@@ -22,4 +39,3 @@ Version 1.0.3 (2025-09-26)
|
|
|
22
39
|
to match the renamed package and repository.
|
|
23
40
|
- Added workspace metadata and sample templates so pnpm and new installs pick up example
|
|
24
41
|
assets.
|
|
25
|
-
|
package/README.md
CHANGED
|
@@ -24,11 +24,17 @@ During development you can run `npm run dev` for a watch mode that recompiles on
|
|
|
24
24
|
## Configuration
|
|
25
25
|
|
|
26
26
|
- **Environment variables** are defined in `src/store/envloader.ts`. Important settings include SMTP credentials, API host/port, the config directory path, and database options.
|
|
27
|
-
- **Config directory** (`CONFIG_PATH`) contains JSON seed data (`init-data.json`), optional API key files, and template assets. Review `config-example/` for the recommended layout, in particular the `form-template/` and `tx-template/` folders used for compiled Nunjucks templates.
|
|
27
|
+
- **Config directory** (`CONFIG_PATH`) contains JSON seed data (`init-data.json`), optional API key files, and template assets. Each domain now lives directly under the config root (for example `config/example.com/form-template/…`). Review `config-example/` for the recommended layout, in particular the `form-template/` and `tx-template/` folders used for compiled Nunjucks templates.
|
|
28
28
|
- **Database** defaults to SQLite (`maildata.db`). You can switch dialects by updating the environment options if your deployment requires another database.
|
|
29
29
|
|
|
30
30
|
When `DB_AUTO_RELOAD` is enabled the service watches `init-data.json` and refreshes templates and forms without a restart.
|
|
31
31
|
|
|
32
|
+
### Template assets and inline resources
|
|
33
|
+
|
|
34
|
+
- Keep any non-inline files (images, attachments, etc.) under `config/<domain>/assets`. Mail Magic rewrites `asset('logo.png')` to the public route `/asset/<domain>/logo.png` (or whatever you set via `ASSET_ROUTE`).
|
|
35
|
+
- Pass `true` as the second argument when you want to embed a file as an inline CID attachment: `asset('logo.png', true)` stores the file in Nodemailer and rewrites the HTML to reference `cid:logo.png`.
|
|
36
|
+
- Avoid mixing template-type folders for assets; everything that should be linked externally belongs in the shared `<domain>/assets` tree so it can be served for both form and transactional templates.
|
|
37
|
+
|
|
32
38
|
## API Overview
|
|
33
39
|
|
|
34
40
|
| Method | Path | Description |
|
package/TUTORIAL.MD
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
# Mail Magic Configuration Tutorial (MyOrg)
|
|
2
|
+
|
|
3
|
+
This guide walks through building a standalone configuration tree for the user `myorg` and the domain `myorg.com`. The finished layout adds a contact form template and a transactional welcome template that both reuse partials and embed the MyOrg logo inline so it is shipped as a CID attachment.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 1. Prepare an external config workspace
|
|
8
|
+
|
|
9
|
+
Mail Magic loads configuration from the folder referenced by the `CONFIG_PATH` environment variable. Keeping your custom assets outside the application repository makes upgrades easier.
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
# run this next to the mail-magic checkout
|
|
13
|
+
mkdir -p ../myorg-config
|
|
14
|
+
export CONFIG_ROOT=$(realpath ../myorg-config)
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Update your `.env` (or runtime environment) to point at the new workspace:
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
CONFIG_PATH=${CONFIG_ROOT}
|
|
21
|
+
DB_AUTO_RELOAD=1 # optional: hot‑reload init-data and templates
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
From now on the tutorial assumes `${CONFIG_ROOT}` is the root of the custom config tree.
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## 2. Create the base directory structure
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
mkdir -p \
|
|
32
|
+
"$CONFIG_ROOT"/myorg.com/{form-template,tx-template} \
|
|
33
|
+
"$CONFIG_ROOT"/myorg.com/form-template/{partials,assets} \
|
|
34
|
+
"$CONFIG_ROOT"/myorg.com/tx-template/{partials,assets}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The resulting tree should look like this (logo placement shown for clarity — add the file in step 4):
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
myorg-config/
|
|
41
|
+
├── init-data.json
|
|
42
|
+
└── myorg.com/
|
|
43
|
+
├── form-template/
|
|
44
|
+
│ ├── assets/
|
|
45
|
+
│ ├── contact.njk
|
|
46
|
+
│ └── partials/
|
|
47
|
+
│ ├── footer.njk
|
|
48
|
+
│ └── header.njk
|
|
49
|
+
└── tx-template/
|
|
50
|
+
├── assets/
|
|
51
|
+
│ └── logo.png
|
|
52
|
+
├── partials/
|
|
53
|
+
│ ├── footer.njk
|
|
54
|
+
│ └── header.njk
|
|
55
|
+
└── welcome.njk
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
> **Tip:** If you want to share partials between templates, keep file names aligned (e.g. identical `header.njk` content under both `form-template/partials/` and `tx-template/partials/`).
|
|
59
|
+
|
|
60
|
+
> **Assets vs inline:** Any file you want to serve as an external URL must live under `myorg.com/assets/`. The template helper `asset('logo.png')` will become `/asset/myorg.com/logo.png`. Use `asset('logo.png', true)` when you need the file embedded as a CID attachment instead.
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## 3. Seed users, domains, and templates with `init-data.json`
|
|
65
|
+
|
|
66
|
+
Create `${CONFIG_ROOT}/init-data.json` so the service can bootstrap the MyOrg user, domain, and template metadata:
|
|
67
|
+
|
|
68
|
+
```json
|
|
69
|
+
{
|
|
70
|
+
"user": [
|
|
71
|
+
{
|
|
72
|
+
"user_id": 10,
|
|
73
|
+
"idname": "myorg",
|
|
74
|
+
"token": "<generate-a-32-char-hex-token>",
|
|
75
|
+
"name": "MyOrg",
|
|
76
|
+
"email": "notifications@myorg.com"
|
|
77
|
+
}
|
|
78
|
+
],
|
|
79
|
+
"domain": [
|
|
80
|
+
{
|
|
81
|
+
"domain_id": 10,
|
|
82
|
+
"user_id": 10,
|
|
83
|
+
"name": "myorg.com",
|
|
84
|
+
"sender": "MyOrg Mailer <noreply@myorg.com>"
|
|
85
|
+
}
|
|
86
|
+
],
|
|
87
|
+
"template": [
|
|
88
|
+
{
|
|
89
|
+
"template_id": 100,
|
|
90
|
+
"user_id": 10,
|
|
91
|
+
"domain_id": 10,
|
|
92
|
+
"name": "welcome",
|
|
93
|
+
"slug": "welcome",
|
|
94
|
+
"locale": "",
|
|
95
|
+
"filename": "welcome.njk",
|
|
96
|
+
"sender": "support@myorg.com",
|
|
97
|
+
"subject": "Welcome to MyOrg",
|
|
98
|
+
"template": ""
|
|
99
|
+
}
|
|
100
|
+
],
|
|
101
|
+
"form": [
|
|
102
|
+
{
|
|
103
|
+
"form_id": 100,
|
|
104
|
+
"user_id": 10,
|
|
105
|
+
"domain_id": 10,
|
|
106
|
+
"idname": "contact",
|
|
107
|
+
"filename": "contact.njk",
|
|
108
|
+
"sender": "MyOrg Support <support@myorg.com>",
|
|
109
|
+
"recipient": "contact@myorg.com",
|
|
110
|
+
"subject": "New contact form submission"
|
|
111
|
+
}
|
|
112
|
+
]
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
- Generate the API token with `openssl rand -hex 16` (or any 32-character hex string).
|
|
117
|
+
- Leave `template` empty; Mail Magic will populate it with the flattened HTML the first time it processes the files.
|
|
118
|
+
- Set `DB_AUTO_RELOAD=1` (see step 1) if you want the service to re-import whenever `init-data.json` changes.
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
## 4. Author shared partials and templates
|
|
123
|
+
|
|
124
|
+
### 4.1 Transactional email partials (`tx-template/partials`)
|
|
125
|
+
|
|
126
|
+
`$CONFIG_ROOT/myorg.com/tx-template/partials/header.njk`
|
|
127
|
+
|
|
128
|
+
```njk
|
|
129
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#102347;color:#ffffff;padding:24px 0;font-family:'Segoe UI',Tahoma,sans-serif;">
|
|
130
|
+
<tr>
|
|
131
|
+
<td align="center">
|
|
132
|
+
<img src="asset('logo.png', true)" alt="MyOrg" width="96" height="96" style="display:block;border:none;border-radius:50%;box-shadow:0 4px 14px rgba(0,0,0,0.18);" />
|
|
133
|
+
<h1 style="margin:16px 0 0;font-size:24px;letter-spacing:0.08em;text-transform:uppercase;">MyOrg</h1>
|
|
134
|
+
</td>
|
|
135
|
+
</tr>
|
|
136
|
+
</table>
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
`$CONFIG_ROOT/myorg.com/tx-template/partials/footer.njk`
|
|
140
|
+
|
|
141
|
+
```njk
|
|
142
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f4f6fb;color:#6b7280;padding:24px 0;font-family:'Segoe UI',Tahoma,sans-serif;font-size:12px;">
|
|
143
|
+
<tr>
|
|
144
|
+
<td align="center">
|
|
145
|
+
<p style="margin:0;">You are receiving this email because you created a MyOrg account.</p>
|
|
146
|
+
<p style="margin:8px 0 0;">MyOrg, 123 Demo Street, Oslo</p>
|
|
147
|
+
</td>
|
|
148
|
+
</tr>
|
|
149
|
+
</table>
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### 4.2 Transactional welcome template (`tx-template/welcome.njk`)
|
|
153
|
+
|
|
154
|
+
`$CONFIG_ROOT/myorg.com/tx-template/welcome.njk`
|
|
155
|
+
|
|
156
|
+
```njk
|
|
157
|
+
{% include 'partials/header.njk' %}
|
|
158
|
+
|
|
159
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#ffffff;padding:32px 0;font-family:'Segoe UI',Tahoma,sans-serif;color:#1f2937;">
|
|
160
|
+
<tr>
|
|
161
|
+
<td align="center">
|
|
162
|
+
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:92%;text-align:left;">
|
|
163
|
+
<tr>
|
|
164
|
+
<td style="padding:0 0 24px;font-size:18px;font-weight:600;">Hi {{ _vars_.first_name or _rcpt_email_ }},</td>
|
|
165
|
+
</tr>
|
|
166
|
+
<tr>
|
|
167
|
+
<td style="padding:0 0 16px;font-size:15px;line-height:1.6;">
|
|
168
|
+
<p style="margin:0 0 12px;">Welcome to MyOrg! Your workspace <strong>{{ _vars_.workspace or 'Starter Plan' }}</strong> is ready.</p>
|
|
169
|
+
<p style="margin:0;">Use the button below to confirm your email and finish the setup.</p>
|
|
170
|
+
</td>
|
|
171
|
+
</tr>
|
|
172
|
+
<tr>
|
|
173
|
+
<td align="center" style="padding:12px 0 28px;">
|
|
174
|
+
<a href="{{ _vars_.cta_url or '#' }}" style="display:inline-block;background:#2563eb;color:#ffffff;text-decoration:none;padding:14px 28px;border-radius:6px;font-size:15px;">Confirm email</a>
|
|
175
|
+
</td>
|
|
176
|
+
</tr>
|
|
177
|
+
<tr>
|
|
178
|
+
<td style="font-size:13px;line-height:1.5;color:#6b7280;">
|
|
179
|
+
<p style="margin:0 0 8px;">If you did not sign up for MyOrg, you can ignore this email.</p>
|
|
180
|
+
<p style="margin:0;">Need help? Reply to this message or visit {{ _vars_.support_url or 'https://myorg.com/support' }}.</p>
|
|
181
|
+
</td>
|
|
182
|
+
</tr>
|
|
183
|
+
</table>
|
|
184
|
+
</td>
|
|
185
|
+
</tr>
|
|
186
|
+
</table>
|
|
187
|
+
|
|
188
|
+
{% include 'partials/footer.njk' %}
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
### 4.3 Contact form partials (`form-template/partials`)
|
|
192
|
+
|
|
193
|
+
`$CONFIG_ROOT/myorg.com/form-template/partials/header.njk`
|
|
194
|
+
|
|
195
|
+
```njk
|
|
196
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#102347;color:#ffffff;padding:18px 0;font-family:Arial,sans-serif;">
|
|
197
|
+
<tr>
|
|
198
|
+
<td align="center" style="font-size:20px;font-weight:600;">New MyOrg contact form submission</td>
|
|
199
|
+
</tr>
|
|
200
|
+
</table>
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
`$CONFIG_ROOT/myorg.com/form-template/partials/footer.njk`
|
|
204
|
+
|
|
205
|
+
```njk
|
|
206
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f4f6fb;color:#6b7280;padding:16px 0;font-family:Arial,sans-serif;font-size:12px;">
|
|
207
|
+
<tr>
|
|
208
|
+
<td align="center">
|
|
209
|
+
<p style="margin:0;">Delivered by Mail Magic for MyOrg.</p>
|
|
210
|
+
</td>
|
|
211
|
+
</tr>
|
|
212
|
+
</table>
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
### 4.4 Contact form template (`form-template/contact.njk`)
|
|
216
|
+
|
|
217
|
+
`$CONFIG_ROOT/myorg.com/form-template/contact.njk`
|
|
218
|
+
|
|
219
|
+
```njk
|
|
220
|
+
{% include 'partials/header.njk' %}
|
|
221
|
+
|
|
222
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#ffffff;padding:24px 0;font-family:Arial,sans-serif;color:#111827;">
|
|
223
|
+
<tr>
|
|
224
|
+
<td align="center">
|
|
225
|
+
<table role="presentation" width="640" cellpadding="0" cellspacing="0" style="max-width:94%;text-align:left;border:1px solid #e5e7eb;border-radius:6px;overflow:hidden;">
|
|
226
|
+
<tr>
|
|
227
|
+
<td style="padding:20px 24px;font-size:16px;font-weight:600;background:#f9fafb;">Submitted fields</td>
|
|
228
|
+
</tr>
|
|
229
|
+
<tr>
|
|
230
|
+
<td style="padding:16px 24px;">
|
|
231
|
+
{% if _fields_ %}
|
|
232
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="font-size:14px;">
|
|
233
|
+
{% for field, value in _fields_ %}
|
|
234
|
+
<tr>
|
|
235
|
+
<td style="padding:8px 0;color:#6b7280;width:180px;">{{ field | replace('_', ' ') | title }}</td>
|
|
236
|
+
<td style="padding:8px 0;color:#111827;">{{ value }}</td>
|
|
237
|
+
</tr>
|
|
238
|
+
{% endfor %}
|
|
239
|
+
</table>
|
|
240
|
+
{% else %}
|
|
241
|
+
<p>No form fields were included.</p>
|
|
242
|
+
{% endif %}
|
|
243
|
+
</td>
|
|
244
|
+
</tr>
|
|
245
|
+
{% if _meta_ %}
|
|
246
|
+
<tr>
|
|
247
|
+
<td style="padding:16px 24px;background:#f9fafb;color:#4b5563;font-size:12px;">
|
|
248
|
+
<strong>Sender IP:</strong> {{ _meta_.client_ip | default('unknown') }} ·
|
|
249
|
+
<strong>Received at:</strong> {{ _meta_.received_at | default(now().iso8601()) }}
|
|
250
|
+
</td>
|
|
251
|
+
</tr>
|
|
252
|
+
{% endif %}
|
|
253
|
+
</table>
|
|
254
|
+
</td>
|
|
255
|
+
</tr>
|
|
256
|
+
</table>
|
|
257
|
+
|
|
258
|
+
{% include 'partials/footer.njk' %}
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
### 4.5 Provide the logo asset
|
|
262
|
+
|
|
263
|
+
Copy or design a square PNG logo and add it to both asset folders so the inline references resolve (Mail Magic resolves assets per template type):
|
|
264
|
+
|
|
265
|
+
```bash
|
|
266
|
+
cp path/to/myorg-logo.png "$CONFIG_ROOT"/myorg.com/tx-template/assets/logo.png
|
|
267
|
+
cp path/to/myorg-logo.png "$CONFIG_ROOT"/myorg.com/form-template/assets/logo.png
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
The inline flag (`true`) in `asset('logo.png', true)` tells Mail Magic to attach the image and rewrite the markup to `cid:logo.png` when messages are flattened.
|
|
271
|
+
|
|
272
|
+
---
|
|
273
|
+
|
|
274
|
+
## 5. Start Mail Magic and verify
|
|
275
|
+
|
|
276
|
+
1. Restart `mail-magic` (or run `npm run dev`) so it picks up the new `CONFIG_PATH`.
|
|
277
|
+
2. Confirm the bootstrap worked — the logs should mention importing user `myorg` and domain `myorg.com`.
|
|
278
|
+
3. Trigger a transactional email:
|
|
279
|
+
```bash
|
|
280
|
+
curl -X POST http://localhost:3000/v1/tx/message \
|
|
281
|
+
-H 'Content-Type: application/json' \
|
|
282
|
+
-H 'X-API-Token: <your 32-char token>' \
|
|
283
|
+
-d '{
|
|
284
|
+
"user": "myorg",
|
|
285
|
+
"domain": "myorg.com",
|
|
286
|
+
"slug": "welcome",
|
|
287
|
+
"to": "new.user@myorg.com",
|
|
288
|
+
"variables": {"first_name": "Kai", "cta_url": "https://myorg.com/confirm"}
|
|
289
|
+
}'
|
|
290
|
+
```
|
|
291
|
+
4. Trigger the contact form template the same way your frontend will post to `/v1/form/message` (Supply `form_id` or `idname` of `contact`). With `DB_AUTO_RELOAD=1`, editing the templates or assets is as simple as saving the file.
|
|
292
|
+
|
|
293
|
+
You now have a clean, self-contained configuration for MyOrg that inherits Mail Magic behaviour while keeping templates, partials, and assets under version control in a dedicated folder.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { ApiModule, ApiError } from '@technomoron/api-server-base';
|
|
4
|
+
import { decodeComponent, sendFileAsync } from '../util.js';
|
|
5
|
+
const DOMAIN_PATTERN = /^[a-z0-9][a-z0-9._-]*$/i;
|
|
6
|
+
const SEGMENT_PATTERN = /^[a-zA-Z0-9._-]+$/;
|
|
7
|
+
export class AssetAPI extends ApiModule {
|
|
8
|
+
async getAsset(apiReq) {
|
|
9
|
+
const domain = decodeComponent(apiReq.req.params.domain);
|
|
10
|
+
if (!domain || !DOMAIN_PATTERN.test(domain)) {
|
|
11
|
+
throw new ApiError({ code: 404, message: 'Asset not found' });
|
|
12
|
+
}
|
|
13
|
+
const rawPath = apiReq.req.params[0] ?? '';
|
|
14
|
+
const segments = rawPath
|
|
15
|
+
.split('/')
|
|
16
|
+
.filter(Boolean)
|
|
17
|
+
.map((segment) => decodeComponent(segment));
|
|
18
|
+
if (!segments.length || segments.some((segment) => !SEGMENT_PATTERN.test(segment))) {
|
|
19
|
+
throw new ApiError({ code: 404, message: 'Asset not found' });
|
|
20
|
+
}
|
|
21
|
+
const assetsRoot = path.join(this.server.storage.configpath, domain, 'assets');
|
|
22
|
+
const resolvedRoot = path.resolve(assetsRoot);
|
|
23
|
+
const normalizedRoot = resolvedRoot.endsWith(path.sep) ? resolvedRoot : resolvedRoot + path.sep;
|
|
24
|
+
const candidate = path.resolve(assetsRoot, path.join(...segments));
|
|
25
|
+
if (!candidate.startsWith(normalizedRoot)) {
|
|
26
|
+
throw new ApiError({ code: 404, message: 'Asset not found' });
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
await fs.promises.access(candidate, fs.constants.R_OK);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
throw new ApiError({ code: 404, message: 'Asset not found' });
|
|
33
|
+
}
|
|
34
|
+
const { res } = apiReq;
|
|
35
|
+
const originalStatus = res.status.bind(res);
|
|
36
|
+
const originalJson = res.json.bind(res);
|
|
37
|
+
res.status = ((code) => (res.headersSent ? res : originalStatus(code)));
|
|
38
|
+
res.json = ((body) => (res.headersSent ? res : originalJson(body)));
|
|
39
|
+
res.type(path.extname(candidate));
|
|
40
|
+
res.set('Cache-Control', 'public, max-age=300');
|
|
41
|
+
try {
|
|
42
|
+
await sendFileAsync(res, candidate);
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
this.server.storage.print_debug(`Failed to serve asset ${domain}/${segments.join('/')}: ${err instanceof Error ? err.message : String(err)}`);
|
|
46
|
+
if (!res.headersSent) {
|
|
47
|
+
throw new ApiError({ code: 500, message: 'Failed to stream asset' });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return [200, null];
|
|
51
|
+
}
|
|
52
|
+
defineRoutes() {
|
|
53
|
+
const route = this.server.storage.env.ASSET_ROUTE;
|
|
54
|
+
const normalizedRoute = route.startsWith('/') ? route : `/${route}`;
|
|
55
|
+
return [
|
|
56
|
+
{
|
|
57
|
+
method: 'get',
|
|
58
|
+
path: `${normalizedRoute}/:domain/*`,
|
|
59
|
+
handler: (apiReq) => this.getAsset(apiReq),
|
|
60
|
+
auth: { type: 'none', req: 'any' }
|
|
61
|
+
}
|
|
62
|
+
];
|
|
63
|
+
}
|
|
64
|
+
}
|
package/dist/api/forms.js
CHANGED
|
@@ -46,7 +46,7 @@ export class FormAPI extends ApiModule {
|
|
|
46
46
|
const formSlug = normalizeSlug(idname);
|
|
47
47
|
const localeSlug = normalizeSlug(resolvedLocale || domain.locale || user.locale || '');
|
|
48
48
|
const slug = `${userSlug}-${domainSlug}${localeSlug ? '-' + localeSlug : ''}-${formSlug}`;
|
|
49
|
-
const filenameParts = [
|
|
49
|
+
const filenameParts = [domainSlug, 'form-template'];
|
|
50
50
|
if (localeSlug) {
|
|
51
51
|
filenameParts.push(localeSlug);
|
|
52
52
|
}
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { pathToFileURL } from 'node:url';
|
|
2
|
+
import { AssetAPI } from './api/assets.js';
|
|
2
3
|
import { FormAPI } from './api/forms.js';
|
|
3
4
|
import { MailerAPI } from './api/mailer.js';
|
|
4
5
|
import { mailApiServer } from './server.js';
|
|
@@ -10,13 +11,14 @@ function buildServerConfig(store, overrides) {
|
|
|
10
11
|
apiPort: env.API_PORT,
|
|
11
12
|
uploadPath: env.UPLOAD_PATH,
|
|
12
13
|
debug: env.DEBUG,
|
|
14
|
+
apiBasePath: '',
|
|
13
15
|
...overrides
|
|
14
16
|
};
|
|
15
17
|
}
|
|
16
18
|
export async function createMailMagicServer(overrides = {}) {
|
|
17
19
|
const store = await new mailStore().init();
|
|
18
20
|
const config = buildServerConfig(store, overrides);
|
|
19
|
-
const server = new mailApiServer(config, store).api(new MailerAPI()).api(new FormAPI());
|
|
21
|
+
const server = new mailApiServer(config, store).api(new MailerAPI()).api(new FormAPI()).api(new AssetAPI());
|
|
20
22
|
return { server, store, env: store.env };
|
|
21
23
|
}
|
|
22
24
|
export async function startMailMagicServer(overrides = {}) {
|
package/dist/models/form.js
CHANGED
|
@@ -138,14 +138,14 @@ export async function upsert_form(record) {
|
|
|
138
138
|
record.slug = `${idname}-${dname}${locale ? '-' + locale : ''}-${name}`;
|
|
139
139
|
}
|
|
140
140
|
if (!record.filename) {
|
|
141
|
-
const parts = [
|
|
141
|
+
const parts = [dname, 'form-template'];
|
|
142
142
|
if (locale)
|
|
143
143
|
parts.push(locale);
|
|
144
144
|
parts.push(name);
|
|
145
145
|
record.filename = path.join(...parts);
|
|
146
146
|
}
|
|
147
147
|
else {
|
|
148
|
-
record.filename = path.join(
|
|
148
|
+
record.filename = path.join(dname, 'form-template', record.filename);
|
|
149
149
|
}
|
|
150
150
|
if (!record.filename.endsWith('.njk')) {
|
|
151
151
|
record.filename += '.njk';
|
package/dist/models/init.js
CHANGED
|
@@ -14,34 +14,41 @@ const init_data_schema = z.object({
|
|
|
14
14
|
form: z.array(api_form_schema).default([])
|
|
15
15
|
});
|
|
16
16
|
/**
|
|
17
|
-
* Resolve an asset file within ./config/<
|
|
17
|
+
* Resolve an asset file within ./config/<domain>/assets
|
|
18
18
|
*/
|
|
19
|
-
function resolveAsset(basePath,
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
searchPaths.push(path.join(domainName, type, locale));
|
|
19
|
+
function resolveAsset(basePath, domainName, assetName) {
|
|
20
|
+
const assetsRoot = path.join(basePath, domainName, 'assets');
|
|
21
|
+
if (!fs.existsSync(assetsRoot)) {
|
|
22
|
+
return null;
|
|
24
23
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
if (
|
|
29
|
-
|
|
24
|
+
const resolvedRoot = path.resolve(assetsRoot);
|
|
25
|
+
const normalizedRoot = resolvedRoot.endsWith(path.sep) ? resolvedRoot : resolvedRoot + path.sep;
|
|
26
|
+
const candidate = path.resolve(assetsRoot, assetName);
|
|
27
|
+
if (!candidate.startsWith(normalizedRoot)) {
|
|
28
|
+
return null;
|
|
30
29
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const candidate = path.join(basePath, p, 'assets', assetName);
|
|
34
|
-
if (fs.existsSync(candidate)) {
|
|
35
|
-
return candidate;
|
|
36
|
-
}
|
|
30
|
+
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
|
|
31
|
+
return candidate;
|
|
37
32
|
}
|
|
38
33
|
return null;
|
|
39
34
|
}
|
|
35
|
+
function buildAssetUrl(baseUrl, route, domainName, assetPath) {
|
|
36
|
+
const trimmedBase = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
|
|
37
|
+
const normalizedRoute = route.startsWith('/') ? route : `/${route}`;
|
|
38
|
+
const encodedDomain = encodeURIComponent(domainName);
|
|
39
|
+
const encodedPath = assetPath
|
|
40
|
+
.split('/')
|
|
41
|
+
.filter((segment) => segment.length > 0)
|
|
42
|
+
.map((segment) => encodeURIComponent(segment))
|
|
43
|
+
.join('/');
|
|
44
|
+
const trailing = encodedPath ? `/${encodedPath}` : '';
|
|
45
|
+
return `${trimmedBase}${normalizedRoute}/${encodedDomain}${trailing}`;
|
|
46
|
+
}
|
|
40
47
|
function extractAndReplaceAssets(html, opts) {
|
|
41
48
|
const regex = /src=["']asset\(['"]([^'"]+)['"](?:,\s*(true|false|[01]))?\)["']/g;
|
|
42
49
|
const assets = [];
|
|
43
50
|
const replacedHtml = html.replace(regex, (_m, relPath, inlineFlag) => {
|
|
44
|
-
const fullPath = resolveAsset(opts.basePath, opts.
|
|
51
|
+
const fullPath = resolveAsset(opts.basePath, opts.domainName, relPath);
|
|
45
52
|
if (!fullPath) {
|
|
46
53
|
throw new Error(`Missing asset "${relPath}"`);
|
|
47
54
|
}
|
|
@@ -52,20 +59,24 @@ function extractAndReplaceAssets(html, opts) {
|
|
|
52
59
|
cid: isInline ? relPath : undefined
|
|
53
60
|
};
|
|
54
61
|
assets.push(storedFile);
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
+
if (isInline) {
|
|
63
|
+
return `src="cid:${relPath}"`;
|
|
64
|
+
}
|
|
65
|
+
const domainAssetsRoot = path.join(opts.basePath, opts.domainName, 'assets');
|
|
66
|
+
const relativeToAssets = path.relative(domainAssetsRoot, fullPath);
|
|
67
|
+
if (!relativeToAssets || relativeToAssets.startsWith('..')) {
|
|
68
|
+
throw new Error(`Asset path escapes domain assets directory: ${fullPath}`);
|
|
69
|
+
}
|
|
70
|
+
const normalizedAssetPath = relativeToAssets.split(path.sep).join('/');
|
|
71
|
+
const assetUrl = buildAssetUrl(opts.apiUrl, opts.assetRoute, opts.domainName, normalizedAssetPath);
|
|
72
|
+
return `src="${assetUrl}"`;
|
|
62
73
|
});
|
|
63
74
|
return { html: replacedHtml, assets };
|
|
64
75
|
}
|
|
65
76
|
async function _load_template(store, filename, pathname, user, domain, locale, type) {
|
|
66
|
-
const rootDir = path.join(store.configpath,
|
|
77
|
+
const rootDir = path.join(store.configpath, domain.name, type);
|
|
67
78
|
let relFile = filename;
|
|
68
|
-
const prefix = path.join(
|
|
79
|
+
const prefix = path.join(domain.name, type) + path.sep;
|
|
69
80
|
if (filename.startsWith(prefix)) {
|
|
70
81
|
relFile = filename.slice(prefix.length);
|
|
71
82
|
}
|
|
@@ -81,20 +92,18 @@ async function _load_template(store, filename, pathname, user, domain, locale, t
|
|
|
81
92
|
throw new Error(`Template file "${absPath}" is empty`);
|
|
82
93
|
}
|
|
83
94
|
try {
|
|
84
|
-
const
|
|
85
|
-
const templateKey = path.relative(
|
|
95
|
+
const baseConfigPath = store.configpath;
|
|
96
|
+
const templateKey = path.relative(baseConfigPath, absPath);
|
|
86
97
|
if (!templateKey) {
|
|
87
98
|
throw new Error(`Unable to resolve template path for "${absPath}"`);
|
|
88
99
|
}
|
|
89
|
-
const processor = new Unyuck({ basePath:
|
|
100
|
+
const processor = new Unyuck({ basePath: baseConfigPath });
|
|
90
101
|
const merged = processor.flattenNoAssets(templateKey);
|
|
91
102
|
const { html, assets } = extractAndReplaceAssets(merged, {
|
|
92
|
-
basePath:
|
|
93
|
-
type,
|
|
103
|
+
basePath: baseConfigPath,
|
|
94
104
|
domainName: domain.name,
|
|
95
|
-
locale,
|
|
96
105
|
apiUrl: store.env.API_URL,
|
|
97
|
-
|
|
106
|
+
assetRoute: store.env.ASSET_ROUTE
|
|
98
107
|
});
|
|
99
108
|
return { html, assets };
|
|
100
109
|
}
|
package/dist/models/txmail.js
CHANGED
|
@@ -33,14 +33,14 @@ export async function upsert_txmail(record) {
|
|
|
33
33
|
record.slug = `${idname}-${dname}${locale ? '-' + locale : ''}-${name}`;
|
|
34
34
|
}
|
|
35
35
|
if (!record.filename) {
|
|
36
|
-
const parts = [
|
|
36
|
+
const parts = [dname, 'tx-template'];
|
|
37
37
|
if (locale)
|
|
38
38
|
parts.push(locale);
|
|
39
39
|
parts.push(name);
|
|
40
40
|
record.filename = path.join(...parts);
|
|
41
41
|
}
|
|
42
42
|
else {
|
|
43
|
-
record.filename = path.join(
|
|
43
|
+
record.filename = path.join(dname, 'tx-template', record.filename);
|
|
44
44
|
}
|
|
45
45
|
if (!record.filename.endsWith('.njk')) {
|
|
46
46
|
record.filename += '.njk';
|
|
@@ -146,13 +146,12 @@ export async function init_api_txmail(api_db) {
|
|
|
146
146
|
api_txmail.addHook('beforeValidate', async (template) => {
|
|
147
147
|
const { user, domain } = await user_and_domain(template.domain_id);
|
|
148
148
|
console.log('HERE');
|
|
149
|
-
const idname = normalizeSlug(user.idname);
|
|
150
149
|
const dname = normalizeSlug(domain.name);
|
|
151
150
|
const name = normalizeSlug(template.name);
|
|
152
151
|
const locale = normalizeSlug(template.locale || domain.locale || user.locale || '');
|
|
153
|
-
template.slug ||= `${idname}-${dname}${locale ? '-' + locale : ''}-${name}`;
|
|
152
|
+
template.slug ||= `${normalizeSlug(user.idname)}-${dname}${locale ? '-' + locale : ''}-${name}`;
|
|
154
153
|
if (!template.filename) {
|
|
155
|
-
const parts = [
|
|
154
|
+
const parts = [dname, 'tx-template'];
|
|
156
155
|
if (locale)
|
|
157
156
|
parts.push(locale);
|
|
158
157
|
parts.push(name);
|
package/dist/store/envloader.js
CHANGED
|
@@ -28,31 +28,14 @@ export const envOptions = defineEnvOptions({
|
|
|
28
28
|
description: 'Sets the public URL for the API (i.e. https://ml.example.com:3790)',
|
|
29
29
|
default: 'http://localhost:3776'
|
|
30
30
|
},
|
|
31
|
+
ASSET_ROUTE: {
|
|
32
|
+
description: 'Route prefix exposed for config assets',
|
|
33
|
+
default: '/asset'
|
|
34
|
+
},
|
|
31
35
|
CONFIG_PATH: {
|
|
32
36
|
description: 'Path to directory where config files are located',
|
|
33
37
|
default: './config/'
|
|
34
38
|
},
|
|
35
|
-
/*
|
|
36
|
-
SWAGGER_ENABLE: {
|
|
37
|
-
description: 'Enable Swagger API docs',
|
|
38
|
-
default: 'false',
|
|
39
|
-
type: 'boolean'
|
|
40
|
-
},
|
|
41
|
-
SWAGGER_PATH: {
|
|
42
|
-
description: 'Path for swagger api docs',
|
|
43
|
-
default: '/api-docs'
|
|
44
|
-
},
|
|
45
|
-
*/
|
|
46
|
-
/*
|
|
47
|
-
JWT_SECRET: {
|
|
48
|
-
description: 'Secret key for generating JWT access tokens',
|
|
49
|
-
required: true
|
|
50
|
-
},
|
|
51
|
-
JWT_REFRESH: {
|
|
52
|
-
description: 'Secret key for generating JWT refresh tokens',
|
|
53
|
-
required: true
|
|
54
|
-
},
|
|
55
|
-
*/
|
|
56
39
|
DB_USER: {
|
|
57
40
|
description: 'Database username for API database'
|
|
58
41
|
},
|
package/dist/util.js
CHANGED
|
@@ -92,3 +92,26 @@ export function buildRequestMeta(rawReq) {
|
|
|
92
92
|
ip_chain: uniqueIps
|
|
93
93
|
};
|
|
94
94
|
}
|
|
95
|
+
export function decodeComponent(value) {
|
|
96
|
+
if (!value) {
|
|
97
|
+
return '';
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
return decodeURIComponent(value);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return value;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
export function sendFileAsync(res, file) {
|
|
107
|
+
return new Promise((resolve, reject) => {
|
|
108
|
+
res.sendFile(file, (err) => {
|
|
109
|
+
if (err) {
|
|
110
|
+
reject(err);
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
resolve();
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
import { ApiModule, ApiRoute, ApiError } 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
|
+
const resolvedRoot = path.resolve(assetsRoot);
|
|
32
|
+
const normalizedRoot = resolvedRoot.endsWith(path.sep) ? resolvedRoot : resolvedRoot + path.sep;
|
|
33
|
+
const candidate = path.resolve(assetsRoot, path.join(...segments));
|
|
34
|
+
if (!candidate.startsWith(normalizedRoot)) {
|
|
35
|
+
throw new ApiError({ code: 404, message: 'Asset not found' });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
await fs.promises.access(candidate, fs.constants.R_OK);
|
|
40
|
+
} catch {
|
|
41
|
+
throw new ApiError({ code: 404, message: 'Asset not found' });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const { res } = apiReq;
|
|
45
|
+
const originalStatus = res.status.bind(res);
|
|
46
|
+
const originalJson = res.json.bind(res);
|
|
47
|
+
res.status = ((code: number) => (res.headersSent ? res : originalStatus(code))) as typeof res.status;
|
|
48
|
+
res.json = ((body: unknown) => (res.headersSent ? res : originalJson(body))) as typeof res.json;
|
|
49
|
+
|
|
50
|
+
res.type(path.extname(candidate));
|
|
51
|
+
res.set('Cache-Control', 'public, max-age=300');
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
await sendFileAsync(res, candidate);
|
|
55
|
+
} catch (err) {
|
|
56
|
+
this.server.storage.print_debug(
|
|
57
|
+
`Failed to serve asset ${domain}/${segments.join('/')}: ${err instanceof Error ? err.message : String(err)}`
|
|
58
|
+
);
|
|
59
|
+
if (!res.headersSent) {
|
|
60
|
+
throw new ApiError({ code: 500, message: 'Failed to stream asset' });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return [200, null];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
override defineRoutes(): ApiRoute[] {
|
|
68
|
+
const route = this.server.storage.env.ASSET_ROUTE;
|
|
69
|
+
const normalizedRoute = route.startsWith('/') ? route : `/${route}`;
|
|
70
|
+
return [
|
|
71
|
+
{
|
|
72
|
+
method: 'get',
|
|
73
|
+
path: `${normalizedRoute}/:domain/*`,
|
|
74
|
+
handler: (apiReq) => this.getAsset(apiReq),
|
|
75
|
+
auth: { type: 'none', req: 'any' }
|
|
76
|
+
}
|
|
77
|
+
];
|
|
78
|
+
}
|
|
79
|
+
}
|
package/src/api/forms.ts
CHANGED
|
@@ -65,7 +65,7 @@ export class FormAPI extends ApiModule<mailApiServer> {
|
|
|
65
65
|
const formSlug = normalizeSlug(idname);
|
|
66
66
|
const localeSlug = normalizeSlug(resolvedLocale || domain.locale || user.locale || '');
|
|
67
67
|
const slug = `${userSlug}-${domainSlug}${localeSlug ? '-' + localeSlug : ''}-${formSlug}`;
|
|
68
|
-
const filenameParts = [
|
|
68
|
+
const filenameParts = [domainSlug, 'form-template'];
|
|
69
69
|
if (localeSlug) {
|
|
70
70
|
filenameParts.push(localeSlug);
|
|
71
71
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { pathToFileURL } from 'node:url';
|
|
2
2
|
|
|
3
|
+
import { AssetAPI } from './api/assets.js';
|
|
3
4
|
import { FormAPI } from './api/forms.js';
|
|
4
5
|
import { MailerAPI } from './api/mailer.js';
|
|
5
6
|
import { mailApiServer } from './server.js';
|
|
@@ -22,6 +23,7 @@ function buildServerConfig(store: mailStore, overrides: MailMagicServerOptions):
|
|
|
22
23
|
apiPort: env.API_PORT,
|
|
23
24
|
uploadPath: env.UPLOAD_PATH,
|
|
24
25
|
debug: env.DEBUG,
|
|
26
|
+
apiBasePath: '',
|
|
25
27
|
...overrides
|
|
26
28
|
};
|
|
27
29
|
}
|
|
@@ -29,7 +31,7 @@ function buildServerConfig(store: mailStore, overrides: MailMagicServerOptions):
|
|
|
29
31
|
export async function createMailMagicServer(overrides: MailMagicServerOptions = {}): Promise<MailMagicServerBootstrap> {
|
|
30
32
|
const store = await new mailStore().init();
|
|
31
33
|
const config = buildServerConfig(store, overrides);
|
|
32
|
-
const server = new mailApiServer(config, store).api(new MailerAPI()).api(new FormAPI());
|
|
34
|
+
const server = new mailApiServer(config, store).api(new MailerAPI()).api(new FormAPI()).api(new AssetAPI());
|
|
33
35
|
|
|
34
36
|
return { server, store, env: store.env };
|
|
35
37
|
}
|
package/src/models/form.ts
CHANGED
|
@@ -169,12 +169,12 @@ export async function upsert_form(record: api_form_type): Promise<api_form> {
|
|
|
169
169
|
}
|
|
170
170
|
|
|
171
171
|
if (!record.filename) {
|
|
172
|
-
const parts = [
|
|
172
|
+
const parts = [dname, 'form-template'];
|
|
173
173
|
if (locale) parts.push(locale);
|
|
174
174
|
parts.push(name);
|
|
175
175
|
record.filename = path.join(...parts);
|
|
176
176
|
} else {
|
|
177
|
-
record.filename = path.join(
|
|
177
|
+
record.filename = path.join(dname, 'form-template', record.filename);
|
|
178
178
|
}
|
|
179
179
|
if (!record.filename.endsWith('.njk')) {
|
|
180
180
|
record.filename += '.njk';
|
package/src/models/init.ts
CHANGED
|
@@ -28,48 +28,45 @@ const init_data_schema = z.object({
|
|
|
28
28
|
type InitData = z.infer<typeof init_data_schema>;
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
|
-
* Resolve an asset file within ./config/<
|
|
31
|
+
* Resolve an asset file within ./config/<domain>/assets
|
|
32
32
|
*/
|
|
33
|
-
function resolveAsset(
|
|
34
|
-
basePath
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
assetName: string,
|
|
38
|
-
locale?: string | null
|
|
39
|
-
): string | null {
|
|
40
|
-
const searchPaths: string[] = [];
|
|
41
|
-
|
|
42
|
-
// always domain-scoped
|
|
43
|
-
if (locale) {
|
|
44
|
-
searchPaths.push(path.join(domainName, type, locale));
|
|
33
|
+
function resolveAsset(basePath: string, domainName: string, assetName: string): string | null {
|
|
34
|
+
const assetsRoot = path.join(basePath, domainName, 'assets');
|
|
35
|
+
if (!fs.existsSync(assetsRoot)) {
|
|
36
|
+
return null;
|
|
45
37
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
searchPaths.push(path.join(type, locale));
|
|
38
|
+
const resolvedRoot = path.resolve(assetsRoot);
|
|
39
|
+
const normalizedRoot = resolvedRoot.endsWith(path.sep) ? resolvedRoot : resolvedRoot + path.sep;
|
|
40
|
+
const candidate = path.resolve(assetsRoot, assetName);
|
|
41
|
+
if (!candidate.startsWith(normalizedRoot)) {
|
|
42
|
+
return null;
|
|
52
43
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
for (const p of searchPaths) {
|
|
56
|
-
const candidate = path.join(basePath, p, 'assets', assetName);
|
|
57
|
-
if (fs.existsSync(candidate)) {
|
|
58
|
-
return candidate;
|
|
59
|
-
}
|
|
44
|
+
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
|
|
45
|
+
return candidate;
|
|
60
46
|
}
|
|
61
47
|
return null;
|
|
62
48
|
}
|
|
63
49
|
|
|
50
|
+
function buildAssetUrl(baseUrl: string, route: string, domainName: string, assetPath: string): string {
|
|
51
|
+
const trimmedBase = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
|
|
52
|
+
const normalizedRoute = route.startsWith('/') ? route : `/${route}`;
|
|
53
|
+
const encodedDomain = encodeURIComponent(domainName);
|
|
54
|
+
const encodedPath = assetPath
|
|
55
|
+
.split('/')
|
|
56
|
+
.filter((segment) => segment.length > 0)
|
|
57
|
+
.map((segment) => encodeURIComponent(segment))
|
|
58
|
+
.join('/');
|
|
59
|
+
const trailing = encodedPath ? `/${encodedPath}` : '';
|
|
60
|
+
return `${trimmedBase}${normalizedRoute}/${encodedDomain}${trailing}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
64
63
|
function extractAndReplaceAssets(
|
|
65
64
|
html: string,
|
|
66
65
|
opts: {
|
|
67
66
|
basePath: string;
|
|
68
|
-
type: 'form-template' | 'tx-template';
|
|
69
67
|
domainName: string;
|
|
70
|
-
locale?: string | null;
|
|
71
68
|
apiUrl: string;
|
|
72
|
-
|
|
69
|
+
assetRoute: string;
|
|
73
70
|
}
|
|
74
71
|
): { html: string; assets: StoredFile[] } {
|
|
75
72
|
const regex = /src=["']asset\(['"]([^'"]+)['"](?:,\s*(true|false|[01]))?\)["']/g;
|
|
@@ -77,7 +74,7 @@ function extractAndReplaceAssets(
|
|
|
77
74
|
const assets: StoredFile[] = [];
|
|
78
75
|
|
|
79
76
|
const replacedHtml = html.replace(regex, (_m, relPath: string, inlineFlag?: string) => {
|
|
80
|
-
const fullPath = resolveAsset(opts.basePath, opts.
|
|
77
|
+
const fullPath = resolveAsset(opts.basePath, opts.domainName, relPath);
|
|
81
78
|
if (!fullPath) {
|
|
82
79
|
throw new Error(`Missing asset "${relPath}"`);
|
|
83
80
|
}
|
|
@@ -91,13 +88,18 @@ function extractAndReplaceAssets(
|
|
|
91
88
|
|
|
92
89
|
assets.push(storedFile);
|
|
93
90
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
91
|
+
if (isInline) {
|
|
92
|
+
return `src="cid:${relPath}"`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const domainAssetsRoot = path.join(opts.basePath, opts.domainName, 'assets');
|
|
96
|
+
const relativeToAssets = path.relative(domainAssetsRoot, fullPath);
|
|
97
|
+
if (!relativeToAssets || relativeToAssets.startsWith('..')) {
|
|
98
|
+
throw new Error(`Asset path escapes domain assets directory: ${fullPath}`);
|
|
99
|
+
}
|
|
100
|
+
const normalizedAssetPath = relativeToAssets.split(path.sep).join('/');
|
|
101
|
+
const assetUrl = buildAssetUrl(opts.apiUrl, opts.assetRoute, opts.domainName, normalizedAssetPath);
|
|
102
|
+
return `src="${assetUrl}"`;
|
|
101
103
|
});
|
|
102
104
|
|
|
103
105
|
return { html: replacedHtml, assets };
|
|
@@ -112,10 +114,10 @@ async function _load_template(
|
|
|
112
114
|
locale: string | null,
|
|
113
115
|
type: 'form-template' | 'tx-template'
|
|
114
116
|
): Promise<LoadedTemplate> {
|
|
115
|
-
const rootDir = path.join(store.configpath,
|
|
117
|
+
const rootDir = path.join(store.configpath, domain.name, type);
|
|
116
118
|
|
|
117
119
|
let relFile = filename;
|
|
118
|
-
const prefix = path.join(
|
|
120
|
+
const prefix = path.join(domain.name, type) + path.sep;
|
|
119
121
|
if (filename.startsWith(prefix)) {
|
|
120
122
|
relFile = filename.slice(prefix.length);
|
|
121
123
|
}
|
|
@@ -135,22 +137,20 @@ async function _load_template(
|
|
|
135
137
|
}
|
|
136
138
|
|
|
137
139
|
try {
|
|
138
|
-
const
|
|
139
|
-
const templateKey = path.relative(
|
|
140
|
+
const baseConfigPath = store.configpath;
|
|
141
|
+
const templateKey = path.relative(baseConfigPath, absPath);
|
|
140
142
|
if (!templateKey) {
|
|
141
143
|
throw new Error(`Unable to resolve template path for "${absPath}"`);
|
|
142
144
|
}
|
|
143
145
|
|
|
144
|
-
const processor = new Unyuck({ basePath:
|
|
146
|
+
const processor = new Unyuck({ basePath: baseConfigPath });
|
|
145
147
|
const merged = processor.flattenNoAssets(templateKey);
|
|
146
148
|
|
|
147
149
|
const { html, assets } = extractAndReplaceAssets(merged, {
|
|
148
|
-
basePath:
|
|
149
|
-
type,
|
|
150
|
+
basePath: baseConfigPath,
|
|
150
151
|
domainName: domain.name,
|
|
151
|
-
locale,
|
|
152
152
|
apiUrl: store.env.API_URL,
|
|
153
|
-
|
|
153
|
+
assetRoute: store.env.ASSET_ROUTE
|
|
154
154
|
});
|
|
155
155
|
|
|
156
156
|
return { html, assets };
|
package/src/models/txmail.ts
CHANGED
|
@@ -56,12 +56,12 @@ export async function upsert_txmail(record: api_txmail_type): Promise<api_txmail
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
if (!record.filename) {
|
|
59
|
-
const parts = [
|
|
59
|
+
const parts = [dname, 'tx-template'];
|
|
60
60
|
if (locale) parts.push(locale);
|
|
61
61
|
parts.push(name);
|
|
62
62
|
record.filename = path.join(...parts);
|
|
63
63
|
} else {
|
|
64
|
-
record.filename = path.join(
|
|
64
|
+
record.filename = path.join(dname, 'tx-template', record.filename);
|
|
65
65
|
}
|
|
66
66
|
if (!record.filename.endsWith('.njk')) {
|
|
67
67
|
record.filename += '.njk';
|
|
@@ -175,15 +175,14 @@ export async function init_api_txmail(api_db: Sequelize): Promise<typeof api_txm
|
|
|
175
175
|
|
|
176
176
|
console.log('HERE');
|
|
177
177
|
|
|
178
|
-
const idname = normalizeSlug(user.idname);
|
|
179
178
|
const dname = normalizeSlug(domain.name);
|
|
180
179
|
const name = normalizeSlug(template.name);
|
|
181
180
|
const locale = normalizeSlug(template.locale || domain.locale || user.locale || '');
|
|
182
181
|
|
|
183
|
-
template.slug ||= `${idname}-${dname}${locale ? '-' + locale : ''}-${name}`;
|
|
182
|
+
template.slug ||= `${normalizeSlug(user.idname)}-${dname}${locale ? '-' + locale : ''}-${name}`;
|
|
184
183
|
|
|
185
184
|
if (!template.filename) {
|
|
186
|
-
const parts = [
|
|
185
|
+
const parts = [dname, 'tx-template'];
|
|
187
186
|
if (locale) parts.push(locale);
|
|
188
187
|
parts.push(name);
|
|
189
188
|
template.filename = parts.join('/');
|
package/src/store/envloader.ts
CHANGED
|
@@ -29,31 +29,14 @@ 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
|
+
ASSET_ROUTE: {
|
|
33
|
+
description: 'Route prefix exposed for config assets',
|
|
34
|
+
default: '/asset'
|
|
35
|
+
},
|
|
32
36
|
CONFIG_PATH: {
|
|
33
37
|
description: 'Path to directory where config files are located',
|
|
34
38
|
default: './config/'
|
|
35
39
|
},
|
|
36
|
-
/*
|
|
37
|
-
SWAGGER_ENABLE: {
|
|
38
|
-
description: 'Enable Swagger API docs',
|
|
39
|
-
default: 'false',
|
|
40
|
-
type: 'boolean'
|
|
41
|
-
},
|
|
42
|
-
SWAGGER_PATH: {
|
|
43
|
-
description: 'Path for swagger api docs',
|
|
44
|
-
default: '/api-docs'
|
|
45
|
-
},
|
|
46
|
-
*/
|
|
47
|
-
/*
|
|
48
|
-
JWT_SECRET: {
|
|
49
|
-
description: 'Secret key for generating JWT access tokens',
|
|
50
|
-
required: true
|
|
51
|
-
},
|
|
52
|
-
JWT_REFRESH: {
|
|
53
|
-
description: 'Secret key for generating JWT refresh tokens',
|
|
54
|
-
required: true
|
|
55
|
-
},
|
|
56
|
-
*/
|
|
57
40
|
DB_USER: {
|
|
58
41
|
description: 'Database username for API database'
|
|
59
42
|
},
|
package/src/util.ts
CHANGED
|
@@ -109,3 +109,29 @@ export function buildRequestMeta(rawReq: unknown): RequestMeta {
|
|
|
109
109
|
ip_chain: uniqueIps
|
|
110
110
|
};
|
|
111
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
|
+
}
|
package/test1.sh
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
#!/bin/sh
|
|
2
|
-
|
|
3
|
-
curl -X POST http://localhost:3776/api/v1/form/message \
|
|
4
|
-
-F "formid=testform" \
|
|
5
|
-
-F "domain=ml.yesmedia.no" \
|
|
6
|
-
-F "vars={\"x\":\"y\"}" \
|
|
7
|
-
-F "attachment1=@./tsconfig.json" \
|
|
8
|
-
-F "attachment2=@./config/testuser/ml.yesmedia.no/form-template/assets/3075977.png"
|
|
9
|
-
|
|
10
|
-
# -F "rcpt=bjornjac@pm.me" \
|
|
11
|
-
# -H "Authorization: Bearer apikey-j82lkIOjUuj34sd" \
|
|
12
|
-
|
|
13
|
-
|