@technomoron/mail-magic 1.0.32 → 1.0.34
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 -0
- package/README.md +213 -122
- package/dist/api/assets.js +9 -56
- package/dist/api/auth.js +1 -12
- package/dist/api/form-replyto.js +1 -0
- package/dist/api/form-submission.js +1 -0
- package/dist/api/forms.js +114 -474
- package/dist/api/mailer.js +1 -1
- package/dist/bin/mail-magic.js +2 -2
- package/dist/index.js +30 -18
- package/dist/models/db.js +5 -5
- package/dist/models/domain.js +16 -8
- package/dist/models/form.js +111 -40
- package/dist/models/init.js +44 -74
- package/dist/models/recipient.js +12 -8
- package/dist/models/txmail.js +24 -28
- package/dist/models/user.js +14 -10
- package/dist/server.js +1 -1
- package/dist/store/store.js +53 -22
- package/dist/swagger.js +107 -0
- package/dist/util/captcha.js +24 -0
- package/dist/util/email.js +19 -0
- package/dist/util/form-replyto.js +44 -0
- package/dist/util/form-submission.js +95 -0
- package/dist/util/forms.js +431 -0
- package/dist/util/paths.js +41 -0
- package/dist/util/ratelimit.js +48 -0
- package/dist/util/uploads.js +48 -0
- package/dist/util/utils.js +151 -0
- package/dist/util.js +7 -127
- package/docs/config-example/example.test/assets/files/banner.png +1 -0
- package/docs/config-example/example.test/assets/images/logo.png +1 -0
- package/docs/config-example/example.test/form-template/base.njk +6 -0
- package/docs/config-example/example.test/form-template/contact.njk +9 -0
- package/docs/config-example/example.test/form-template/partials/fields.njk +3 -0
- package/docs/config-example/example.test/tx-template/base.njk +10 -0
- package/docs/config-example/example.test/tx-template/partials/header.njk +1 -0
- package/docs/config-example/example.test/tx-template/welcome.njk +10 -0
- package/docs/config-example/init-data.json +57 -0
- package/docs/form-security.md +194 -0
- package/docs/swagger/openapi.json +1321 -0
- package/{TUTORIAL.MD → docs/tutorial.md} +24 -15
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import { FormAPI } from './api/forms.js';
|
|
|
4
4
|
import { MailerAPI } from './api/mailer.js';
|
|
5
5
|
import { mailApiServer } from './server.js';
|
|
6
6
|
import { mailStore } from './store/store.js';
|
|
7
|
+
import { installMailMagicSwagger } from './swagger.js';
|
|
7
8
|
function normalizeRoute(value, fallback = '') {
|
|
8
9
|
if (!value) {
|
|
9
10
|
return fallback;
|
|
@@ -26,7 +27,7 @@ function mergeStaticDirs(base, override) {
|
|
|
26
27
|
return merged;
|
|
27
28
|
}
|
|
28
29
|
function buildServerConfig(store, overrides) {
|
|
29
|
-
const env = store.
|
|
30
|
+
const env = store.vars;
|
|
30
31
|
return {
|
|
31
32
|
apiHost: env.API_HOST,
|
|
32
33
|
apiPort: env.API_PORT,
|
|
@@ -39,14 +40,14 @@ function buildServerConfig(store, overrides) {
|
|
|
39
40
|
...overrides
|
|
40
41
|
};
|
|
41
42
|
}
|
|
42
|
-
export async function createMailMagicServer(overrides = {}) {
|
|
43
|
-
const store = await new mailStore().init();
|
|
43
|
+
export async function createMailMagicServer(overrides = {}, envOverrides = {}) {
|
|
44
|
+
const store = await new mailStore().init(envOverrides);
|
|
44
45
|
if (typeof overrides.apiBasePath === 'string') {
|
|
45
|
-
store.
|
|
46
|
+
store.vars.API_BASE_PATH = overrides.apiBasePath;
|
|
46
47
|
}
|
|
47
48
|
const baseStaticDirs = {};
|
|
48
49
|
let adminUiPath = null;
|
|
49
|
-
if (store.
|
|
50
|
+
if (store.vars.ADMIN_ENABLED) {
|
|
50
51
|
adminUiPath = await resolveAdminUiPath(store);
|
|
51
52
|
if (adminUiPath) {
|
|
52
53
|
baseStaticDirs['/'] = adminUiPath;
|
|
@@ -57,11 +58,22 @@ export async function createMailMagicServer(overrides = {}) {
|
|
|
57
58
|
staticDirs: mergeStaticDirs(baseStaticDirs, overrides.staticDirs)
|
|
58
59
|
};
|
|
59
60
|
const config = buildServerConfig(store, mergedOverrides);
|
|
60
|
-
|
|
61
|
+
// ApiServerBase's built-in swagger handler loads from process.cwd(); install our own handler so
|
|
62
|
+
// SWAGGER_ENABLED works regardless of where the .env lives (mail-magic CLI chdir's to the env dir).
|
|
63
|
+
const { swaggerEnabled, swaggerPath } = config;
|
|
64
|
+
const serverConfig = { ...config, swaggerEnabled: false, swaggerPath: '' };
|
|
65
|
+
const server = new mailApiServer(serverConfig, store).api(new MailerAPI()).api(new FormAPI()).api(new AssetAPI());
|
|
66
|
+
installMailMagicSwagger(server, {
|
|
67
|
+
apiBasePath: String(config.apiBasePath || '/api'),
|
|
68
|
+
assetRoute: String(store.vars.ASSET_ROUTE || '/asset'),
|
|
69
|
+
apiUrl: String(store.vars.API_URL || ''),
|
|
70
|
+
swaggerEnabled,
|
|
71
|
+
swaggerPath
|
|
72
|
+
});
|
|
61
73
|
// Serve domain assets from a public route with traversal protection and caching.
|
|
62
|
-
const assetRoute = normalizeRoute(store.
|
|
74
|
+
const assetRoute = normalizeRoute(store.vars.ASSET_ROUTE, '/asset');
|
|
63
75
|
const assetPrefix = assetRoute === '/' ? '' : assetRoute;
|
|
64
|
-
const apiBasePath = normalizeRoute(store.
|
|
76
|
+
const apiBasePath = normalizeRoute(store.vars.API_BASE_PATH, '/api');
|
|
65
77
|
const apiBasePrefix = apiBasePath === '/' ? '' : apiBasePath;
|
|
66
78
|
const assetHandler = createAssetHandler(server);
|
|
67
79
|
const assetMounts = new Set();
|
|
@@ -76,23 +88,23 @@ export async function createMailMagicServer(overrides = {}) {
|
|
|
76
88
|
// (and remain reachable before the API 404 handler).
|
|
77
89
|
server.useExpress(`${prefix}/:domain/*path`, assetHandler);
|
|
78
90
|
}
|
|
79
|
-
if (store.
|
|
91
|
+
if (store.vars.ADMIN_ENABLED) {
|
|
80
92
|
await enableAdminFeatures(server, store, adminUiPath);
|
|
81
93
|
}
|
|
82
94
|
else {
|
|
83
95
|
store.print_debug('Admin UI/API disabled via ADMIN_ENABLED');
|
|
84
96
|
}
|
|
85
|
-
return { server, store,
|
|
97
|
+
return { server, store, vars: store.vars };
|
|
86
98
|
}
|
|
87
|
-
export async function startMailMagicServer(overrides = {}) {
|
|
88
|
-
const bootstrap = await createMailMagicServer(overrides);
|
|
99
|
+
export async function startMailMagicServer(overrides = {}, envOverrides = {}) {
|
|
100
|
+
const bootstrap = await createMailMagicServer(overrides, envOverrides);
|
|
89
101
|
await bootstrap.server.start();
|
|
90
102
|
return bootstrap;
|
|
91
103
|
}
|
|
92
104
|
async function bootMailMagic() {
|
|
93
105
|
try {
|
|
94
|
-
const {
|
|
95
|
-
console.log(`mail-magic server listening on ${
|
|
106
|
+
const { vars } = await startMailMagicServer();
|
|
107
|
+
console.log(`mail-magic server listening on ${vars.API_HOST}:${vars.API_PORT}`);
|
|
96
108
|
}
|
|
97
109
|
catch (err) {
|
|
98
110
|
console.error('Failed to start FormMailer:', err);
|
|
@@ -117,7 +129,7 @@ async function resolveAdminUiPath(store) {
|
|
|
117
129
|
try {
|
|
118
130
|
const mod = (await import('@technomoron/mail-magic-admin'));
|
|
119
131
|
if (typeof mod?.resolveAdminDist === 'function') {
|
|
120
|
-
return mod.resolveAdminDist(store.
|
|
132
|
+
return mod.resolveAdminDist(store.vars.ADMIN_APP_PATH, (message) => store.print_debug(message));
|
|
121
133
|
}
|
|
122
134
|
}
|
|
123
135
|
catch (err) {
|
|
@@ -130,9 +142,9 @@ async function enableAdminFeatures(server, store, adminUiPath) {
|
|
|
130
142
|
const mod = (await import('@technomoron/mail-magic-admin'));
|
|
131
143
|
if (typeof mod?.registerAdmin === 'function') {
|
|
132
144
|
await mod.registerAdmin(server, {
|
|
133
|
-
apiBasePath: normalizeRoute(store.
|
|
134
|
-
assetRoute: normalizeRoute(store.
|
|
135
|
-
appPath: adminUiPath ?? store.
|
|
145
|
+
apiBasePath: normalizeRoute(store.vars.API_BASE_PATH, '/api'),
|
|
146
|
+
assetRoute: normalizeRoute(store.vars.ASSET_ROUTE, '/asset'),
|
|
147
|
+
appPath: adminUiPath ?? store.vars.ADMIN_APP_PATH,
|
|
136
148
|
logger: (message) => store.print_debug(message),
|
|
137
149
|
staticFallback: Boolean(adminUiPath)
|
|
138
150
|
});
|
package/dist/models/db.js
CHANGED
|
@@ -72,13 +72,13 @@ export async function init_api_db(db, store) {
|
|
|
72
72
|
as: 'domain'
|
|
73
73
|
});
|
|
74
74
|
await db.query('PRAGMA foreign_keys = OFF');
|
|
75
|
-
const alter = Boolean(store.
|
|
76
|
-
store.print_debug(`DB sync: alter=${alter} force=${store.
|
|
77
|
-
await db.sync({ alter, force: store.
|
|
75
|
+
const alter = Boolean(store.vars.DB_SYNC_ALTER);
|
|
76
|
+
store.print_debug(`DB sync: alter=${alter} force=${store.vars.DB_FORCE_SYNC}`);
|
|
77
|
+
await db.sync({ alter, force: store.vars.DB_FORCE_SYNC });
|
|
78
78
|
await db.query('PRAGMA foreign_keys = ON');
|
|
79
79
|
await importData(store);
|
|
80
80
|
try {
|
|
81
|
-
const { migrated, cleared } = await migrateLegacyApiTokens(store.
|
|
81
|
+
const { migrated, cleared } = await migrateLegacyApiTokens(store.vars.API_TOKEN_PEPPER);
|
|
82
82
|
if (migrated || cleared) {
|
|
83
83
|
store.print_debug(`Migrated ${migrated} legacy API token(s) and cleared ${cleared} plaintext token(s).`);
|
|
84
84
|
}
|
|
@@ -90,7 +90,7 @@ export async function init_api_db(db, store) {
|
|
|
90
90
|
}
|
|
91
91
|
export async function connect_api_db(store) {
|
|
92
92
|
console.log('DB INIT');
|
|
93
|
-
const env = store.
|
|
93
|
+
const env = store.vars;
|
|
94
94
|
const dbparams = {
|
|
95
95
|
logging: false, // env.DB_LOG ? console.log : false,
|
|
96
96
|
dialect: env.DB_TYPE,
|
package/dist/models/domain.js
CHANGED
|
@@ -1,14 +1,22 @@
|
|
|
1
1
|
import { Model, DataTypes } from 'sequelize';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
const DOMAIN_PATTERN = /^[a-z0-9][a-z0-9._-]*$/i;
|
|
4
|
-
export const api_domain_schema = z
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
4
|
+
export const api_domain_schema = z
|
|
5
|
+
.object({
|
|
6
|
+
domain_id: z.number().int().nonnegative().describe('Database primary key for the domain record.'),
|
|
7
|
+
user_id: z.number().int().nonnegative().describe('Owning user ID.'),
|
|
8
|
+
name: z
|
|
9
|
+
.string()
|
|
10
|
+
.min(1)
|
|
11
|
+
.regex(DOMAIN_PATTERN, 'Invalid domain name')
|
|
12
|
+
.describe('Domain name (config identifier).'),
|
|
13
|
+
sender: z.string().default('').describe('Default sender address for this domain.'),
|
|
14
|
+
locale: z.string().default('').describe('Default locale for this domain.'),
|
|
15
|
+
is_default: z.boolean().default(false).describe('If true, this is the default domain for the user.')
|
|
16
|
+
})
|
|
17
|
+
.describe('Domain configuration record.');
|
|
18
|
+
// Sequelize typing pattern: merge the Zod-inferred attribute type onto the model instance type.
|
|
19
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
|
|
12
20
|
export class api_domain extends Model {
|
|
13
21
|
}
|
|
14
22
|
export async function init_api_domain(api_db) {
|
package/dist/models/form.js
CHANGED
|
@@ -1,31 +1,75 @@
|
|
|
1
1
|
import path from 'path';
|
|
2
2
|
import { nanoid } from 'nanoid';
|
|
3
|
-
import { Model, DataTypes } from 'sequelize';
|
|
3
|
+
import { Model, DataTypes, UniqueConstraintError } from 'sequelize';
|
|
4
4
|
import { z } from 'zod';
|
|
5
|
+
import { assertSafeRelativePath } from '../util/paths.js';
|
|
5
6
|
import { user_and_domain, normalizeSlug } from '../util.js';
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
7
|
+
const stored_file_schema = z
|
|
8
|
+
.object({
|
|
9
|
+
filename: z.string().describe('Asset filename (relative to the domain assets directory).'),
|
|
10
|
+
path: z.string().describe('Absolute path on disk where the asset is stored.'),
|
|
11
|
+
cid: z.string().optional().describe('Content-ID used for inline attachments (cid:...) when set.')
|
|
12
|
+
})
|
|
13
|
+
.describe('A stored file/asset referenced by a template.');
|
|
14
|
+
export const api_form_schema = z
|
|
15
|
+
.object({
|
|
16
|
+
form_id: z.number().int().nonnegative().describe('Database primary key for the form configuration record.'),
|
|
17
|
+
form_key: z
|
|
18
|
+
.string()
|
|
19
|
+
.trim()
|
|
20
|
+
.min(1)
|
|
21
|
+
.default(() => nanoid())
|
|
22
|
+
.describe('Public form key required by the unauthenticated form submission endpoint (globally unique).'),
|
|
23
|
+
user_id: z.number().int().nonnegative().describe('Owning user ID.'),
|
|
24
|
+
domain_id: z.number().int().nonnegative().describe('Owning domain ID.'),
|
|
25
|
+
locale: z
|
|
26
|
+
.string()
|
|
27
|
+
.default('')
|
|
28
|
+
.describe('Locale for this form configuration (used for lookup/rendering and template path generation).'),
|
|
29
|
+
idname: z.string().min(1).describe('Form identifier within the domain (slug-like).'),
|
|
30
|
+
sender: z.string().min(1).describe('Email From header used when delivering form submissions.'),
|
|
31
|
+
recipient: z
|
|
32
|
+
.string()
|
|
33
|
+
.min(1)
|
|
34
|
+
.describe('Default email recipient (To) used when delivering form submissions (unless recipients are overridden).'),
|
|
35
|
+
subject: z.string().describe('Email subject used when delivering form submissions.'),
|
|
36
|
+
template: z
|
|
37
|
+
.string()
|
|
38
|
+
.default('')
|
|
39
|
+
.describe('Nunjucks template content used to render the outbound email body for this form.'),
|
|
40
|
+
filename: z
|
|
41
|
+
.string()
|
|
42
|
+
.default('')
|
|
43
|
+
.describe('Relative path (within the config tree) of the source .njk template file for this form.'),
|
|
44
|
+
slug: z.string().default('').describe('Generated slug for this form record (domain + locale + idname).'),
|
|
45
|
+
secret: z
|
|
46
|
+
.string()
|
|
47
|
+
.default('')
|
|
48
|
+
.describe('Legacy form secret (stored for compatibility; not part of the public form submission contract).'),
|
|
49
|
+
replyto_email: z
|
|
50
|
+
.string()
|
|
51
|
+
.default('')
|
|
52
|
+
.describe('Optional forced Reply-To email address used when reply-to extraction is disabled or fails.'),
|
|
53
|
+
replyto_from_fields: z
|
|
54
|
+
.boolean()
|
|
55
|
+
.default(false)
|
|
56
|
+
.describe('If true, attempt to extract Reply-To from submitted form fields (email + name).'),
|
|
57
|
+
allowed_fields: z
|
|
58
|
+
.array(z.string())
|
|
59
|
+
.default([])
|
|
60
|
+
.describe('Optional allowlist of submitted field names that are exposed to templates as _fields_. When empty, all non-system fields are exposed.'),
|
|
61
|
+
captcha_required: z
|
|
62
|
+
.boolean()
|
|
63
|
+
.default(false)
|
|
64
|
+
.describe('If true, require a captcha token for public submissions to this form (in addition to any server-level requirement).'),
|
|
21
65
|
files: z
|
|
22
|
-
.array(
|
|
23
|
-
filename: z.string(),
|
|
24
|
-
path: z.string(),
|
|
25
|
-
cid: z.string().optional()
|
|
26
|
-
}))
|
|
66
|
+
.array(stored_file_schema)
|
|
27
67
|
.default([])
|
|
28
|
-
|
|
68
|
+
.describe('Derived list of template-referenced assets (inline cids and external links) resolved during preprocessing/import.')
|
|
69
|
+
})
|
|
70
|
+
.describe('Form configuration and template used by the public form submission endpoint.');
|
|
71
|
+
// Sequelize typing pattern: merge the Zod-inferred attribute type onto the model instance type.
|
|
72
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
|
|
29
73
|
export class api_form extends Model {
|
|
30
74
|
}
|
|
31
75
|
export async function init_api_form(api_db) {
|
|
@@ -38,8 +82,7 @@ export async function init_api_form(api_db) {
|
|
|
38
82
|
},
|
|
39
83
|
form_key: {
|
|
40
84
|
type: DataTypes.STRING,
|
|
41
|
-
allowNull:
|
|
42
|
-
defaultValue: null
|
|
85
|
+
allowNull: false
|
|
43
86
|
},
|
|
44
87
|
user_id: {
|
|
45
88
|
type: DataTypes.INTEGER,
|
|
@@ -110,6 +153,29 @@ export async function init_api_form(api_db) {
|
|
|
110
153
|
allowNull: false,
|
|
111
154
|
defaultValue: ''
|
|
112
155
|
},
|
|
156
|
+
replyto_email: {
|
|
157
|
+
type: DataTypes.STRING,
|
|
158
|
+
allowNull: false,
|
|
159
|
+
defaultValue: ''
|
|
160
|
+
},
|
|
161
|
+
replyto_from_fields: {
|
|
162
|
+
type: DataTypes.BOOLEAN,
|
|
163
|
+
allowNull: false,
|
|
164
|
+
defaultValue: false
|
|
165
|
+
},
|
|
166
|
+
allowed_fields: {
|
|
167
|
+
type: DataTypes.TEXT,
|
|
168
|
+
allowNull: false,
|
|
169
|
+
defaultValue: '[]',
|
|
170
|
+
get() {
|
|
171
|
+
// This column is stored as JSON text but exposed as `string[]` via getter/setter.
|
|
172
|
+
const raw = this.getDataValue('allowed_fields');
|
|
173
|
+
return raw ? JSON.parse(raw) : [];
|
|
174
|
+
},
|
|
175
|
+
set(value) {
|
|
176
|
+
this.setDataValue('allowed_fields', JSON.stringify(value ?? []));
|
|
177
|
+
}
|
|
178
|
+
},
|
|
113
179
|
captcha_required: {
|
|
114
180
|
type: DataTypes.BOOLEAN,
|
|
115
181
|
allowNull: false,
|
|
@@ -120,6 +186,7 @@ export async function init_api_form(api_db) {
|
|
|
120
186
|
allowNull: false,
|
|
121
187
|
defaultValue: '[]',
|
|
122
188
|
get() {
|
|
189
|
+
// This column is stored as JSON text but exposed as `StoredFile[]` via getter/setter.
|
|
123
190
|
const raw = this.getDataValue('files');
|
|
124
191
|
return raw ? JSON.parse(raw) : [];
|
|
125
192
|
},
|
|
@@ -145,24 +212,13 @@ export async function init_api_form(api_db) {
|
|
|
145
212
|
});
|
|
146
213
|
return api_form;
|
|
147
214
|
}
|
|
148
|
-
function assertSafeRelativePath(filename, label) {
|
|
149
|
-
const normalized = path.normalize(filename);
|
|
150
|
-
if (path.isAbsolute(normalized)) {
|
|
151
|
-
throw new Error(`${label} path must be relative`);
|
|
152
|
-
}
|
|
153
|
-
if (normalized.split(path.sep).includes('..')) {
|
|
154
|
-
throw new Error(`${label} path cannot include '..' segments`);
|
|
155
|
-
}
|
|
156
|
-
return normalized;
|
|
157
|
-
}
|
|
158
215
|
export async function upsert_form(record) {
|
|
159
216
|
const { user, domain } = await user_and_domain(record.domain_id);
|
|
160
|
-
const idname = normalizeSlug(user.idname);
|
|
161
217
|
const dname = normalizeSlug(domain.name);
|
|
162
218
|
const name = normalizeSlug(record.idname);
|
|
163
219
|
const locale = normalizeSlug(record.locale || domain.locale || user.locale || '');
|
|
164
220
|
if (!record.slug) {
|
|
165
|
-
record.slug = `${
|
|
221
|
+
record.slug = `${dname}${locale ? '-' + locale : ''}-${name}`;
|
|
166
222
|
}
|
|
167
223
|
if (!record.filename) {
|
|
168
224
|
const parts = [dname, 'form-template'];
|
|
@@ -181,16 +237,31 @@ export async function upsert_form(record) {
|
|
|
181
237
|
let instance = null;
|
|
182
238
|
instance = await api_form.findByPk(record.form_id);
|
|
183
239
|
if (instance) {
|
|
184
|
-
|
|
240
|
+
// Existing forms should always have a form_key. If not, repair it.
|
|
241
|
+
if (!String(instance.form_key ?? '').trim()) {
|
|
185
242
|
record.form_key = nanoid();
|
|
186
243
|
}
|
|
187
244
|
await instance.update(record);
|
|
188
245
|
}
|
|
189
246
|
else {
|
|
190
|
-
|
|
191
|
-
|
|
247
|
+
// form_key must be globally unique; retry on collisions.
|
|
248
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
249
|
+
record.form_key = String(record.form_key ?? '').trim() || nanoid();
|
|
250
|
+
try {
|
|
251
|
+
instance = await api_form.create(record);
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
catch (err) {
|
|
255
|
+
if (err instanceof UniqueConstraintError) {
|
|
256
|
+
const conflicted = err.errors?.some((e) => e.path === 'form_key');
|
|
257
|
+
if (conflicted) {
|
|
258
|
+
record.form_key = nanoid();
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
throw err;
|
|
263
|
+
}
|
|
192
264
|
}
|
|
193
|
-
instance = await api_form.create(record);
|
|
194
265
|
}
|
|
195
266
|
if (!instance) {
|
|
196
267
|
throw new Error(`Unable to update/create form ${record.form_id}`);
|
package/dist/models/init.js
CHANGED
|
@@ -2,79 +2,27 @@ import fs from 'fs';
|
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import { Unyuck } from '@technomoron/unyuck';
|
|
4
4
|
import { z } from 'zod';
|
|
5
|
+
import { buildAssetUrl } from '../util/paths.js';
|
|
5
6
|
import { user_and_domain } from '../util.js';
|
|
6
7
|
import { api_domain, api_domain_schema } from './domain.js';
|
|
7
8
|
import { api_form_schema, upsert_form } from './form.js';
|
|
8
9
|
import { api_txmail_schema, upsert_txmail } from './txmail.js';
|
|
9
10
|
import { apiTokenToHmac, api_user, api_user_schema } from './user.js';
|
|
11
|
+
function buildInlineAssetCid(urlPath) {
|
|
12
|
+
// Many mail clients are picky about Content-ID values. Keep it stable and avoid path separators.
|
|
13
|
+
// Use a sanitized urlPath so nested assets remain unique without embedding `/` in the CID.
|
|
14
|
+
const normalized = String(urlPath || '')
|
|
15
|
+
.trim()
|
|
16
|
+
.replace(/\\/g, '/');
|
|
17
|
+
const safe = normalized.replace(/[^A-Za-z0-9._-]/g, '_').replace(/_+/g, '_');
|
|
18
|
+
return (safe || 'asset').slice(0, 200);
|
|
19
|
+
}
|
|
10
20
|
const init_data_schema = z.object({
|
|
11
21
|
user: z.array(api_user_schema).default([]),
|
|
12
22
|
domain: z.array(api_domain_schema).default([]),
|
|
13
23
|
template: z.array(api_txmail_schema).default([]),
|
|
14
24
|
form: z.array(api_form_schema).default([])
|
|
15
25
|
});
|
|
16
|
-
/**
|
|
17
|
-
* Resolve an asset file within ./config/<domain>/assets
|
|
18
|
-
*/
|
|
19
|
-
function resolveAsset(basePath, domainName, assetName) {
|
|
20
|
-
const assetsRoot = path.join(basePath, domainName, 'assets');
|
|
21
|
-
if (!fs.existsSync(assetsRoot)) {
|
|
22
|
-
return null;
|
|
23
|
-
}
|
|
24
|
-
const resolvedRoot = fs.realpathSync(assetsRoot);
|
|
25
|
-
const normalizedRoot = resolvedRoot.endsWith(path.sep) ? resolvedRoot : resolvedRoot + path.sep;
|
|
26
|
-
const candidate = path.resolve(assetsRoot, assetName);
|
|
27
|
-
if (!fs.existsSync(candidate) || !fs.statSync(candidate).isFile()) {
|
|
28
|
-
return null;
|
|
29
|
-
}
|
|
30
|
-
const realCandidate = fs.realpathSync(candidate);
|
|
31
|
-
if (!realCandidate.startsWith(normalizedRoot)) {
|
|
32
|
-
return null;
|
|
33
|
-
}
|
|
34
|
-
return realCandidate;
|
|
35
|
-
}
|
|
36
|
-
function buildAssetUrl(baseUrl, route, domainName, assetPath) {
|
|
37
|
-
const trimmedBase = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
|
|
38
|
-
const normalizedRoute = route ? (route.startsWith('/') ? route : `/${route}`) : '';
|
|
39
|
-
const encodedDomain = encodeURIComponent(domainName);
|
|
40
|
-
const encodedPath = assetPath
|
|
41
|
-
.split('/')
|
|
42
|
-
.filter((segment) => segment.length > 0)
|
|
43
|
-
.map((segment) => encodeURIComponent(segment))
|
|
44
|
-
.join('/');
|
|
45
|
-
const trailing = encodedPath ? `/${encodedPath}` : '';
|
|
46
|
-
return `${trimmedBase}${normalizedRoute}/${encodedDomain}${trailing}`;
|
|
47
|
-
}
|
|
48
|
-
function extractAndReplaceAssets(html, opts) {
|
|
49
|
-
const regex = /src=["']asset\(['"]([^'"]+)['"](?:,\s*(true|false|[01]))?\)["']/g;
|
|
50
|
-
const assets = [];
|
|
51
|
-
const replacedHtml = html.replace(regex, (_m, relPath, inlineFlag) => {
|
|
52
|
-
const fullPath = resolveAsset(opts.basePath, opts.domainName, relPath);
|
|
53
|
-
if (!fullPath) {
|
|
54
|
-
throw new Error(`Missing asset "${relPath}"`);
|
|
55
|
-
}
|
|
56
|
-
const isInline = inlineFlag === 'true' || inlineFlag === '1';
|
|
57
|
-
const storedFile = {
|
|
58
|
-
filename: relPath,
|
|
59
|
-
path: fullPath,
|
|
60
|
-
cid: isInline ? relPath : undefined
|
|
61
|
-
};
|
|
62
|
-
assets.push(storedFile);
|
|
63
|
-
if (isInline) {
|
|
64
|
-
return `src="cid:${relPath}"`;
|
|
65
|
-
}
|
|
66
|
-
const domainAssetsRoot = path.join(opts.basePath, opts.domainName, 'assets');
|
|
67
|
-
const relativeToAssets = path.relative(domainAssetsRoot, fullPath);
|
|
68
|
-
if (!relativeToAssets || relativeToAssets.startsWith('..')) {
|
|
69
|
-
throw new Error(`Asset path escapes domain assets directory: ${fullPath}`);
|
|
70
|
-
}
|
|
71
|
-
const normalizedAssetPath = relativeToAssets.split(path.sep).join('/');
|
|
72
|
-
const baseUrl = opts.assetBaseUrl?.trim() ? opts.assetBaseUrl : opts.apiUrl;
|
|
73
|
-
const assetUrl = buildAssetUrl(baseUrl, opts.assetRoute, opts.domainName, normalizedAssetPath);
|
|
74
|
-
return `src="${assetUrl}"`;
|
|
75
|
-
});
|
|
76
|
-
return { html: replacedHtml, assets };
|
|
77
|
-
}
|
|
78
26
|
async function _load_template(store, filename, pathname, user, domain, locale, type) {
|
|
79
27
|
const rootDir = path.join(store.configpath, domain.name, type);
|
|
80
28
|
let relFile = filename;
|
|
@@ -97,20 +45,42 @@ async function _load_template(store, filename, pathname, user, domain, locale, t
|
|
|
97
45
|
}
|
|
98
46
|
try {
|
|
99
47
|
const baseConfigPath = store.configpath;
|
|
100
|
-
const
|
|
101
|
-
|
|
48
|
+
const domainRoot = path.join(baseConfigPath, domain.name);
|
|
49
|
+
const templateKey = path.relative(domainRoot, absPath);
|
|
50
|
+
if (!templateKey || templateKey.startsWith('..')) {
|
|
102
51
|
throw new Error(`Unable to resolve template path for "${absPath}"`);
|
|
103
52
|
}
|
|
104
|
-
const
|
|
105
|
-
const
|
|
106
|
-
const
|
|
107
|
-
basePath:
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
53
|
+
const assetBaseUrl = store.vars.ASSET_PUBLIC_BASE?.trim() ? store.vars.ASSET_PUBLIC_BASE : store.vars.API_URL;
|
|
54
|
+
const assetRoute = store.vars.ASSET_ROUTE;
|
|
55
|
+
const processor = new Unyuck({
|
|
56
|
+
basePath: domainRoot,
|
|
57
|
+
baseUrl: assetBaseUrl,
|
|
58
|
+
collectAssets: true,
|
|
59
|
+
assetFormatter: (ctx) => buildAssetUrl(assetBaseUrl, assetRoute, domain.name, ctx.urlPath)
|
|
60
|
+
});
|
|
61
|
+
const { html: mergedHtml, assets } = processor.flattenWithAssets(templateKey);
|
|
62
|
+
let html = mergedHtml;
|
|
63
|
+
const mappedAssets = assets.map((asset) => {
|
|
64
|
+
const rel = asset.filename.replace(/\\/g, '/');
|
|
65
|
+
const urlPath = rel.startsWith('assets/') ? rel.slice('assets/'.length) : rel;
|
|
66
|
+
return {
|
|
67
|
+
filename: urlPath,
|
|
68
|
+
path: asset.path,
|
|
69
|
+
cid: asset.cid ? buildInlineAssetCid(urlPath) : undefined
|
|
70
|
+
};
|
|
112
71
|
});
|
|
113
|
-
|
|
72
|
+
for (const asset of assets) {
|
|
73
|
+
if (!asset.cid) {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const rel = asset.filename.replace(/\\/g, '/');
|
|
77
|
+
const urlPath = rel.startsWith('assets/') ? rel.slice('assets/'.length) : rel;
|
|
78
|
+
const desiredCid = buildInlineAssetCid(urlPath);
|
|
79
|
+
if (asset.cid !== desiredCid) {
|
|
80
|
+
html = html.replaceAll(`cid:${asset.cid}`, `cid:${desiredCid}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return { html, assets: mappedAssets };
|
|
114
84
|
}
|
|
115
85
|
catch (err) {
|
|
116
86
|
throw new Error(`Template "${absPath}" failed to preprocess: ${err.message}`);
|
|
@@ -149,7 +119,7 @@ export async function importData(store) {
|
|
|
149
119
|
resolvedTokenHmac = token_hmac;
|
|
150
120
|
}
|
|
151
121
|
else if (typeof token === 'string' && token) {
|
|
152
|
-
resolvedTokenHmac = apiTokenToHmac(token, store.
|
|
122
|
+
resolvedTokenHmac = apiTokenToHmac(token, store.vars.API_TOKEN_PEPPER);
|
|
153
123
|
}
|
|
154
124
|
else {
|
|
155
125
|
throw new Error(`User ${record.user_id} is missing token or token_hmac`);
|
package/dist/models/recipient.js
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
import { Model, DataTypes } from 'sequelize';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
-
export const api_recipient_schema = z
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
export const api_recipient_schema = z
|
|
4
|
+
.object({
|
|
5
|
+
recipient_id: z.number().int().nonnegative().describe('Database primary key for the recipient record.'),
|
|
6
|
+
domain_id: z.number().int().nonnegative().describe('Owning domain ID.'),
|
|
6
7
|
// Empty string means "domain-wide"; otherwise scope to a specific form_key.
|
|
7
|
-
form_key: z.string().default(''),
|
|
8
|
-
idname: z.string().min(1),
|
|
9
|
-
email: z.string().min(1),
|
|
10
|
-
name: z.string().default('')
|
|
11
|
-
})
|
|
8
|
+
form_key: z.string().default('').describe('Form key scope. Empty string means domain-wide recipient.'),
|
|
9
|
+
idname: z.string().min(1).describe('Recipient identifier within the scope.'),
|
|
10
|
+
email: z.string().min(1).describe('Recipient email address.'),
|
|
11
|
+
name: z.string().default('').describe('Optional recipient display name.')
|
|
12
|
+
})
|
|
13
|
+
.describe('Recipient routing record for form submissions.');
|
|
14
|
+
// Sequelize typing pattern: merge the Zod-inferred attribute type onto the model instance type.
|
|
15
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
|
|
12
16
|
export class api_recipient extends Model {
|
|
13
17
|
}
|
|
14
18
|
export async function init_api_recipient(api_db) {
|
package/dist/models/txmail.js
CHANGED
|
@@ -1,46 +1,42 @@
|
|
|
1
1
|
import path from 'path';
|
|
2
2
|
import { Model, DataTypes } from 'sequelize';
|
|
3
3
|
import { z } from 'zod';
|
|
4
|
+
import { assertSafeRelativePath } from '../util/paths.js';
|
|
4
5
|
import { user_and_domain, normalizeSlug } from '../util.js';
|
|
5
|
-
export const api_txmail_schema = z
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
6
|
+
export const api_txmail_schema = z
|
|
7
|
+
.object({
|
|
8
|
+
template_id: z.number().int().nonnegative().describe('Database primary key for the template record.'),
|
|
9
|
+
user_id: z.number().int().nonnegative().describe('Owning user ID.'),
|
|
10
|
+
domain_id: z.number().int().nonnegative().describe('Owning domain ID.'),
|
|
11
|
+
name: z.string().min(1).describe('Template name within the domain.'),
|
|
12
|
+
locale: z.string().default('').describe('Locale for this template configuration.'),
|
|
13
|
+
template: z.string().default('').describe('Nunjucks template content used for rendering.'),
|
|
14
|
+
filename: z.string().default('').describe('Relative path of the source .njk template file.'),
|
|
15
|
+
sender: z.string().min(1).describe('Email From header used when delivering this template.'),
|
|
16
|
+
subject: z.string().describe('Email subject used when delivering this template.'),
|
|
17
|
+
slug: z.string().default('').describe('Generated slug for this template record (domain + locale + name).'),
|
|
18
|
+
part: z.boolean().default(false).describe('If true, template is a partial (not a standalone send).'),
|
|
16
19
|
files: z
|
|
17
20
|
.array(z.object({
|
|
18
|
-
filename: z.string(),
|
|
19
|
-
path: z.string(),
|
|
20
|
-
cid: z.string().optional()
|
|
21
|
+
filename: z.string().describe('Asset filename (relative to the domain assets directory).'),
|
|
22
|
+
path: z.string().describe('Absolute path on disk where the asset is stored.'),
|
|
23
|
+
cid: z.string().optional().describe('Content-ID used for inline attachments when set.')
|
|
21
24
|
}))
|
|
22
25
|
.default([])
|
|
23
|
-
|
|
26
|
+
.describe('Derived list of template-referenced assets resolved during preprocessing/import.')
|
|
27
|
+
})
|
|
28
|
+
.describe('Transactional email template configuration.');
|
|
29
|
+
// Sequelize typing pattern: merge the Zod-inferred attribute type onto the model instance type.
|
|
30
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
|
|
24
31
|
export class api_txmail extends Model {
|
|
25
32
|
}
|
|
26
|
-
function assertSafeRelativePath(filename, label) {
|
|
27
|
-
const normalized = path.normalize(filename);
|
|
28
|
-
if (path.isAbsolute(normalized)) {
|
|
29
|
-
throw new Error(`${label} path must be relative`);
|
|
30
|
-
}
|
|
31
|
-
if (normalized.split(path.sep).includes('..')) {
|
|
32
|
-
throw new Error(`${label} path cannot include '..' segments`);
|
|
33
|
-
}
|
|
34
|
-
return normalized;
|
|
35
|
-
}
|
|
36
33
|
export async function upsert_txmail(record) {
|
|
37
34
|
const { user, domain } = await user_and_domain(record.domain_id);
|
|
38
|
-
const idname = normalizeSlug(user.idname);
|
|
39
35
|
const dname = normalizeSlug(domain.name);
|
|
40
36
|
const name = normalizeSlug(record.name);
|
|
41
37
|
const locale = normalizeSlug(record.locale || domain.locale || user.locale || '');
|
|
42
38
|
if (!record.slug) {
|
|
43
|
-
record.slug = `${
|
|
39
|
+
record.slug = `${dname}${locale ? '-' + locale : ''}-${name}`;
|
|
44
40
|
}
|
|
45
41
|
if (!record.filename) {
|
|
46
42
|
const parts = [dname, 'tx-template'];
|
|
@@ -158,7 +154,7 @@ export async function init_api_txmail(api_db) {
|
|
|
158
154
|
const dname = normalizeSlug(domain.name);
|
|
159
155
|
const name = normalizeSlug(template.name);
|
|
160
156
|
const locale = normalizeSlug(template.locale || domain.locale || user.locale || '');
|
|
161
|
-
template.slug ||= `${
|
|
157
|
+
template.slug ||= `${dname}${locale ? '-' + locale : ''}-${name}`;
|
|
162
158
|
if (!template.filename) {
|
|
163
159
|
const parts = [dname, 'tx-template'];
|
|
164
160
|
if (locale)
|