@technomoron/mail-magic 1.0.6 → 1.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.do-realease.sh CHANGED
@@ -4,7 +4,46 @@ VERSION=$(node -p "require('./package.json').version")
4
4
 
5
5
  echo "Creating release for ${VERSION}"
6
6
 
7
+ if [ -n "$(git status --porcelain)" ]; then
8
+ echo "Working tree is not clean. Commit or stash changes before release." >&2
9
+ exit 1
10
+ fi
11
+
12
+ UPSTREAM=$(git rev-parse --abbrev-ref --symbolic-full-name @{u} 2>/dev/null || true)
13
+ if [ -z "$UPSTREAM" ]; then
14
+ echo "No upstream configured for $(git rev-parse --abbrev-ref HEAD). Set upstream before release." >&2
15
+ exit 1
16
+ fi
17
+
18
+ if ! git fetch --quiet; then
19
+ echo "Failed to fetch remote updates. Check your network or remote access." >&2
20
+ exit 1
21
+ fi
22
+
23
+ set -- $(git rev-list --left-right --count "${UPSTREAM}...HEAD")
24
+ BEHIND_COUNT=$1
25
+ AHEAD_COUNT=$2
26
+
27
+ if [ "$BEHIND_COUNT" -ne 0 ] || [ "$AHEAD_COUNT" -ne 0 ]; then
28
+ echo "Branch is not in sync with ${UPSTREAM} (behind ${BEHIND_COUNT}, ahead ${AHEAD_COUNT})." >&2
29
+ echo "Pull/push until the branch matches upstream before release." >&2
30
+ exit 1
31
+ fi
32
+
33
+ if git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then
34
+ echo "Tag v${VERSION} already exists. Aborting." >&2
35
+ exit 1
36
+ fi
37
+
7
38
  git tag -a "v${VERSION}" -m "Release version ${VERSION}"
8
39
  git push origin "v${VERSION}"
9
40
 
10
- npm publish --access=public
41
+ # detect prerelease versions (contains a hyphen)
42
+ if echo "$VERSION" | grep -q "-"; then
43
+ TAG=$(echo "$VERSION" | sed 's/^[0-9.]*-\([a-zA-Z0-9]*\).*/\1/')
44
+ echo "Detected prerelease. Publishing with tag '$TAG'"
45
+ npm publish --tag "$TAG" --access=public
46
+ else
47
+ echo "Stable release. Publishing as latest"
48
+ npm publish --access=public
49
+ fi
package/.env-dist CHANGED
@@ -16,6 +16,15 @@ DB_FORCE_SYNC=false
16
16
  # Sets the public URL for the API (i.e. https://ml.example.com:3790)
17
17
  API_URL=http://localhost:3776
18
18
 
19
+ # Enable the Swagger/OpenAPI endpoint [boolean]
20
+ SWAGGER_ENABLED=false
21
+
22
+ # Path to expose the Swagger/OpenAPI spec (default: /api/swagger when enabled)
23
+ SWAGGER_PATH=
24
+
25
+ # Route prefix exposed for config assets
26
+ ASSET_ROUTE=/asset
27
+
19
28
  # Path to directory where config files are located
20
29
  CONFIG_PATH=./config/
21
30
 
@@ -1,15 +1,3 @@
1
1
  {
2
- "recommendations": [
3
- "dbaeumer.vscode-eslint",
4
- "esbenp.prettier-vscode",
5
- "editorconfig.editorconfig",
6
- "Vue.volar",
7
- "wayou.vscode-todo-highlight"
8
- ],
9
- "unwantedRecommendations": [
10
- "octref.vetur",
11
- "hookyqr.beautify",
12
- "dbaeumer.jshint",
13
- "ms-vscode.vscode-typescript-tslint-plugin"
14
- ]
2
+ "recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"]
15
3
  }
@@ -1,123 +1,22 @@
1
1
  {
2
- "editor.detectIndentation": false,
3
- "editor.insertSpaces": false,
4
- "editor.tabSize": 4,
5
- "editor.bracketPairColorization.enabled": true,
6
- "editor.guides.bracketPairs": true,
7
2
  "editor.formatOnSave": true,
8
3
  "editor.defaultFormatter": "esbenp.prettier-vscode",
9
- "editor.codeActionsOnSave": {
10
- "source.fixAll": "always",
11
- "source.fixAll.eslint": "always"
4
+ "[javascript]": {
5
+ "editor.defaultFormatter": "esbenp.prettier-vscode"
12
6
  },
13
- "[json]": {
7
+ "[typescript]": {
8
+ "editor.defaultFormatter": "esbenp.prettier-vscode"
9
+ },
10
+ "[vue]": {
14
11
  "editor.defaultFormatter": "esbenp.prettier-vscode"
15
12
  },
16
- "[jsonc]": {
13
+ "[json]": {
17
14
  "editor.defaultFormatter": "esbenp.prettier-vscode"
18
15
  },
19
- "eslint.alwaysShowStatus": true,
20
- "prettier.enable": true,
21
- "typescript.tsdk": "node_modules/typescript/lib",
22
- "eslint.run": "onSave",
23
- "eslint.format.enable": true,
24
- "editor.formatOnType": true,
25
- "eslint.useFlatConfig": true,
26
- "eslint.probe": ["javascript", "javascriptreact", "typescript", "typescriptreact", "vue"],
27
- "eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact", "vue"],
28
- "explorer.fileNesting.enabled": true,
29
- "explorer.fileNesting.expand": false,
30
- "explorer.fileNesting.patterns": {
31
- ".clang-tidy": ".clang-format, .clangd, compile_commands.json",
32
- ".env": "*.env, .env.*, .envrc, env.d.ts",
33
- ".gitignore": ".gitattributes, .gitmodules, .gitmessage, .lfsconfig, .mailmap, .git-blame*",
34
- ".project": ".classpath",
35
- "+layout.svelte": "+layout.ts,+layout.ts,+layout.js,+layout.server.ts,+layout.server.js,+layout.gql",
36
- "+page.svelte": "+page.server.ts,+page.server.js,+page.ts,+page.js,+page.gql",
37
- "ansible.cfg": "ansible.cfg, .ansible-lint, requirements.yml",
38
- "app.config.*": "*.env, .babelrc*, .codecov, .cssnanorc*, .env.*, .envrc, .htmlnanorc*, .lighthouserc.*, .mocha*, .postcssrc*, .terserrc*, api-extractor.json, ava.config.*, babel.config.*, capacitor.config.*, content.config.*, contentlayer.config.*, cssnano.config.*, cypress.*, env.d.ts, formkit.config.*, formulate.config.*, histoire.config.*, htmlnanorc.*, i18n.config.*, ionic.config.*, jasmine.*, jest.config.*, jsconfig.*, karma*, lighthouserc.*, panda.config.*, playwright.config.*, postcss.config.*, puppeteer.config.*, react-router.config.*, rspack.config.*, sst.config.*, svgo.config.*, tailwind.config.*, tsconfig.*, tsdoc.*, uno.config.*, unocss.config.*, vitest.config.*, vuetify.config.*, webpack.config.*, windi.config.*",
39
- "application.properties": "*.properties",
40
- "artisan": "*.env, .babelrc*, .codecov, .cssnanorc*, .env.*, .envrc, .htmlnanorc*, .lighthouserc.*, .mocha*, .postcssrc*, .terserrc*, api-extractor.json, ava.config.*, babel.config.*, capacitor.config.*, content.config.*, contentlayer.config.*, cssnano.config.*, cypress.*, env.d.ts, formkit.config.*, formulate.config.*, histoire.config.*, htmlnanorc.*, i18n.config.*, ionic.config.*, jasmine.*, jest.config.*, jsconfig.*, karma*, lighthouserc.*, panda.config.*, playwright.config.*, postcss.config.*, puppeteer.config.*, react-router.config.*, rspack.config.*, server.php, sst.config.*, svgo.config.*, tailwind.config.*, tsconfig.*, tsdoc.*, uno.config.*, unocss.config.*, vitest.config.*, vuetify.config.*, webpack.config.*, webpack.mix.js, windi.config.*",
41
- "astro.config.*": "*.env, .babelrc*, .codecov, .cssnanorc*, .env.*, .envrc, .htmlnanorc*, .lighthouserc.*, .mocha*, .postcssrc*, .terserrc*, api-extractor.json, ava.config.*, babel.config.*, capacitor.config.*, content.config.*, contentlayer.config.*, cssnano.config.*, cypress.*, env.d.ts, formkit.config.*, formulate.config.*, histoire.config.*, htmlnanorc.*, i18n.config.*, ionic.config.*, jasmine.*, jest.config.*, jsconfig.*, karma*, lighthouserc.*, panda.config.*, playwright.config.*, postcss.config.*, puppeteer.config.*, react-router.config.*, rspack.config.*, sst.config.*, svgo.config.*, tailwind.config.*, tsconfig.*, tsdoc.*, uno.config.*, unocss.config.*, vitest.config.*, vuetify.config.*, webpack.config.*, windi.config.*",
42
- "build-wrapper.log": "build-wrapper*.log, build-wrapper-dump*.json, build-wrapper-win*.exe, build-wrapper-linux*, build-wrapper-macosx*",
43
- "BUILD.bazel": "*.bzl, *.bazel, *.bazelrc, bazel.rc, .bazelignore, .bazelproject, .bazelversion, MODULE.bazel.lock, WORKSPACE",
44
- "Cargo.toml": ".clippy.toml, .rustfmt.toml, Cargo.Bazel.lock, Cargo.lock, clippy.toml, cross.toml, insta.yaml, rust-toolchain.toml, rustfmt.toml",
45
- "CMakeLists.txt": "*.cmake, *.cmake.in, .cmake-format.yaml, CMakePresets.json, CMakeCache.txt",
46
- "composer.json": ".php*.cache, composer.lock, phpunit.xml*, psalm*.xml",
47
- "default.nix": "shell.nix",
48
- "deno.json*": "*.env, .env.*, .envrc, api-extractor.json, deno.lock, env.d.ts, import-map.json, import_map.json, jsconfig.*, tsconfig.*, tsdoc.*",
49
- "Dockerfile": "*.dockerfile, .devcontainer.*, .dockerignore, captain-definition, compose.*, docker-compose.*, dockerfile*",
50
- "flake.nix": "flake.lock",
51
- "gatsby-config.*": "*.env, .babelrc*, .codecov, .cssnanorc*, .env.*, .envrc, .htmlnanorc*, .lighthouserc.*, .mocha*, .postcssrc*, .terserrc*, api-extractor.json, ava.config.*, babel.config.*, capacitor.config.*, content.config.*, contentlayer.config.*, cssnano.config.*, cypress.*, env.d.ts, formkit.config.*, formulate.config.*, gatsby-browser.*, gatsby-node.*, gatsby-ssr.*, gatsby-transformer.*, histoire.config.*, htmlnanorc.*, i18n.config.*, ionic.config.*, jasmine.*, jest.config.*, jsconfig.*, karma*, lighthouserc.*, panda.config.*, playwright.config.*, postcss.config.*, puppeteer.config.*, react-router.config.*, rspack.config.*, sst.config.*, svgo.config.*, tailwind.config.*, tsconfig.*, tsdoc.*, uno.config.*, unocss.config.*, vitest.config.*, vuetify.config.*, webpack.config.*, windi.config.*",
52
- "gemfile": ".ruby-version, gemfile.lock",
53
- "go.mod": ".air*, go.sum",
54
- "go.work": "go.work.sum",
55
- "hatch.toml": ".editorconfig, .flake8, .isort.cfg, .python-version, hatch.toml, requirements*.in, requirements*.pip, requirements*.txt, tox.ini",
56
- "I*.cs": "$(capture).cs",
57
- "Makefile": "*.mk",
58
- "mix.exs": ".credo.exs, .dialyzer_ignore.exs, .formatter.exs, .iex.exs, .tool-versions, mix.lock",
59
- "next.config.*": "*.env, .babelrc*, .codecov, .cssnanorc*, .env.*, .envrc, .htmlnanorc*, .lighthouserc.*, .mocha*, .postcssrc*, .terserrc*, api-extractor.json, ava.config.*, babel.config.*, capacitor.config.*, content.config.*, contentlayer.config.*, cssnano.config.*, cypress.*, env.d.ts, formkit.config.*, formulate.config.*, histoire.config.*, htmlnanorc.*, i18n.config.*, ionic.config.*, jasmine.*, jest.config.*, jsconfig.*, karma*, lighthouserc.*, next-env.d.ts, next-i18next.config.*, panda.config.*, playwright.config.*, postcss.config.*, puppeteer.config.*, react-router.config.*, rspack.config.*, sst.config.*, svgo.config.*, tailwind.config.*, tsconfig.*, tsdoc.*, uno.config.*, unocss.config.*, vitest.config.*, vuetify.config.*, webpack.config.*, windi.config.*",
60
- "nuxt.config.*": "*.env, .babelrc*, .codecov, .cssnanorc*, .env.*, .envrc, .htmlnanorc*, .lighthouserc.*, .mocha*, .nuxtignore, .nuxtrc, .postcssrc*, .terserrc*, api-extractor.json, ava.config.*, babel.config.*, capacitor.config.*, content.config.*, contentlayer.config.*, cssnano.config.*, cypress.*, env.d.ts, formkit.config.*, formulate.config.*, histoire.config.*, htmlnanorc.*, i18n.config.*, ionic.config.*, jasmine.*, jest.config.*, jsconfig.*, karma*, lighthouserc.*, panda.config.*, playwright.config.*, postcss.config.*, puppeteer.config.*, react-router.config.*, rspack.config.*, sst.config.*, svgo.config.*, tailwind.config.*, tsconfig.*, tsdoc.*, uno.config.*, unocss.config.*, vitest.config.*, vuetify.config.*, webpack.config.*, windi.config.*",
61
- "package.json": "*.code-workspace, .browserslist*, .circleci*, .commitlint*, .cspell*, .cursor*, .cz-config.js, .czrc, .dlint.json, .dprint.json*, .editorconfig, .eslint*, .firebase*, .flowconfig, .github*, .gitlab*, .gitmojirc.json, .gitpod*, .huskyrc*, .jslint*, .knip.*, .lintstagedrc*, .ls-lint.yml, .markdownlint*, .node-version, .nodemon*, .npm*, .nvmrc, .pm2*, .pnp.*, .pnpm*, .prettier*, .pylintrc, .release-please*.json, .releaserc*, .ruff.toml, .sentry*, .shellcheckrc, .simple-git-hooks*, .stackblitz*, .styleci*, .stylelint*, .tazerc*, .textlint*, .tool-versions, .travis*, .versionrc*, .vscode*, .watchman*, .windsurfrules, .xo-config*, .yamllint*, .yarnrc*, Procfile, apollo.config.*, appveyor*, azure-pipelines*, biome.json*, bower.json, build.config.*, bun.lock, bun.lockb, bunfig.toml, colada.options.ts, commitlint*, crowdin*, cspell*, dangerfile*, dlint.json, dprint.json*, ec.config.*, electron-builder.*, eslint*, firebase.json, grunt*, gulp*, jenkins*, knip.*, lerna*, lint-staged*, nest-cli.*, netlify*, nixpacks*, nodemon*, npm-shrinkwrap.json, nx.*, package-lock.json, package.nls*.json, phpcs.xml, pm2.*, pnpm*, prettier*, pullapprove*, pyrightconfig.json, release-please*.json, release-tasks.sh, release.config.*, renovate*, rolldown.config.*, rollup.config.*, rspack*, ruff.toml, sentry.*.config.ts, simple-git-hooks*, sonar-project.properties, stylelint*, tsdown.config.*, tslint*, tsup.config.*, turbo*, typedoc*, unlighthouse*, vercel*, vetur.config.*, webpack*, workspace.json, wrangler.*, xo.config.*, yarn*",
62
- "Pipfile": ".editorconfig, .flake8, .isort.cfg, .python-version, Pipfile, Pipfile.lock, requirements*.in, requirements*.pip, requirements*.txt, tox.ini",
63
- "pom.xml": "mvnw*",
64
- "pubspec.yaml": ".metadata, .packages, all_lint_rules.yaml, analysis_options.yaml, build.yaml, pubspec.lock, pubspec_overrides.yaml",
65
- "pyproject.toml": ".commitlint*, .cspell*, .dlint.json, .dprint.json*, .editorconfig, .eslint*, .flake8, .flowconfig, .isort.cfg, .jslint*, .lintstagedrc*, .ls-lint.yml, .markdownlint*, .pdm-python, .pdm.toml, .prettier*, .pylintrc, .python-version, .ruff.toml, .shellcheckrc, .stylelint*, .textlint*, .xo-config*, .yamllint*, MANIFEST.in, Pipfile, Pipfile.lock, biome.json*, commitlint*, cspell*, dangerfile*, dlint.json, dprint.json*, eslint*, hatch.toml, lint-staged*, pdm.lock, phpcs.xml, poetry.lock, poetry.toml, prettier*, pyproject.toml, pyrightconfig.json, requirements*.in, requirements*.pip, requirements*.txt, ruff.toml, setup.cfg, setup.py, stylelint*, tox.ini, tslint*, uv.lock, uv.toml, xo.config.*",
66
- "quasar.conf.js": "*.env, .babelrc*, .codecov, .cssnanorc*, .env.*, .envrc, .htmlnanorc*, .lighthouserc.*, .mocha*, .postcssrc*, .terserrc*, api-extractor.json, ava.config.*, babel.config.*, capacitor.config.*, content.config.*, contentlayer.config.*, cssnano.config.*, cypress.*, env.d.ts, formkit.config.*, formulate.config.*, histoire.config.*, htmlnanorc.*, i18n.config.*, ionic.config.*, jasmine.*, jest.config.*, jsconfig.*, karma*, lighthouserc.*, panda.config.*, playwright.config.*, postcss.config.*, puppeteer.config.*, quasar.extensions.json, react-router.config.*, rspack.config.*, sst.config.*, svgo.config.*, tailwind.config.*, tsconfig.*, tsdoc.*, uno.config.*, unocss.config.*, vitest.config.*, vuetify.config.*, webpack.config.*, windi.config.*",
67
- "readme*": "AUTHORS, Authors, BACKERS*, Backers*, CHANGELOG*, CITATION*, CODEOWNERS, CODE_OF_CONDUCT*, CONTRIBUTING*, CONTRIBUTORS, COPYING*, CREDITS, Changelog*, Citation*, Code_Of_Conduct*, Codeowners, Contributing*, Contributors, Copying*, Credits, GOVERNANCE.MD, Governance.md, HISTORY.MD, History.md, LICENSE*, License*, MAINTAINERS, Maintainers, README-*, README_*, RELEASE_NOTES*, ROADMAP.MD, Readme-*, Readme_*, Release_Notes*, Roadmap.md, SECURITY.MD, SPONSORS*, Security.md, Sponsors*, authors, backers*, changelog*, citation*, code_of_conduct*, codeowners, contributing*, contributors, copying*, credits, governance.md, history.md, license*, maintainers, readme-*, readme_*, release_notes*, roadmap.md, security.md, sponsors*",
68
- "Readme*": "AUTHORS, Authors, BACKERS*, Backers*, CHANGELOG*, CITATION*, CODEOWNERS, CODE_OF_CONDUCT*, CONTRIBUTING*, CONTRIBUTORS, COPYING*, CREDITS, Changelog*, Citation*, Code_Of_Conduct*, Codeowners, Contributing*, Contributors, Copying*, Credits, GOVERNANCE.MD, Governance.md, HISTORY.MD, History.md, LICENSE*, License*, MAINTAINERS, Maintainers, README-*, README_*, RELEASE_NOTES*, ROADMAP.MD, Readme-*, Readme_*, Release_Notes*, Roadmap.md, SECURITY.MD, SPONSORS*, Security.md, Sponsors*, authors, backers*, changelog*, citation*, code_of_conduct*, codeowners, contributing*, contributors, copying*, credits, governance.md, history.md, license*, maintainers, readme-*, readme_*, release_notes*, roadmap.md, security.md, sponsors*",
69
- "README*": "AUTHORS, Authors, BACKERS*, Backers*, CHANGELOG*, CITATION*, CODEOWNERS, CODE_OF_CONDUCT*, CONTRIBUTING*, CONTRIBUTORS, COPYING*, CREDITS, Changelog*, Citation*, Code_Of_Conduct*, Codeowners, Contributing*, Contributors, Copying*, Credits, GOVERNANCE.MD, Governance.md, HISTORY.MD, History.md, LICENSE*, License*, MAINTAINERS, Maintainers, README-*, README_*, RELEASE_NOTES*, ROADMAP.MD, Readme-*, Readme_*, Release_Notes*, Roadmap.md, SECURITY.MD, SPONSORS*, Security.md, Sponsors*, authors, backers*, changelog*, citation*, code_of_conduct*, codeowners, contributing*, contributors, copying*, credits, governance.md, history.md, license*, maintainers, readme-*, readme_*, release_notes*, roadmap.md, security.md, sponsors*",
70
- "remix.config.*": "*.env, .babelrc*, .codecov, .cssnanorc*, .env.*, .envrc, .htmlnanorc*, .lighthouserc.*, .mocha*, .postcssrc*, .terserrc*, api-extractor.json, ava.config.*, babel.config.*, capacitor.config.*, content.config.*, contentlayer.config.*, cssnano.config.*, cypress.*, env.d.ts, formkit.config.*, formulate.config.*, histoire.config.*, htmlnanorc.*, i18n.config.*, ionic.config.*, jasmine.*, jest.config.*, jsconfig.*, karma*, lighthouserc.*, panda.config.*, playwright.config.*, postcss.config.*, puppeteer.config.*, react-router.config.*, remix.*, rspack.config.*, sst.config.*, svgo.config.*, tailwind.config.*, tsconfig.*, tsdoc.*, uno.config.*, unocss.config.*, vitest.config.*, vuetify.config.*, webpack.config.*, windi.config.*",
71
- "requirements.txt": ".editorconfig, .flake8, .isort.cfg, .python-version, requirements*.in, requirements*.pip, requirements*.txt, tox.ini",
72
- "rush.json": "*.code-workspace, .browserslist*, .circleci*, .commitlint*, .cspell*, .cursor*, .cz-config.js, .czrc, .dlint.json, .dprint.json*, .editorconfig, .eslint*, .firebase*, .flowconfig, .github*, .gitlab*, .gitmojirc.json, .gitpod*, .huskyrc*, .jslint*, .knip.*, .lintstagedrc*, .ls-lint.yml, .markdownlint*, .node-version, .nodemon*, .npm*, .nvmrc, .pm2*, .pnp.*, .pnpm*, .prettier*, .pylintrc, .release-please*.json, .releaserc*, .ruff.toml, .sentry*, .shellcheckrc, .simple-git-hooks*, .stackblitz*, .styleci*, .stylelint*, .tazerc*, .textlint*, .tool-versions, .travis*, .versionrc*, .vscode*, .watchman*, .windsurfrules, .xo-config*, .yamllint*, .yarnrc*, Procfile, apollo.config.*, appveyor*, azure-pipelines*, biome.json*, bower.json, build.config.*, bun.lock, bun.lockb, bunfig.toml, colada.options.ts, commitlint*, crowdin*, cspell*, dangerfile*, dlint.json, dprint.json*, ec.config.*, electron-builder.*, eslint*, firebase.json, grunt*, gulp*, jenkins*, knip.*, lerna*, lint-staged*, nest-cli.*, netlify*, nixpacks*, nodemon*, npm-shrinkwrap.json, nx.*, package-lock.json, package.nls*.json, phpcs.xml, pm2.*, pnpm*, prettier*, pullapprove*, pyrightconfig.json, release-please*.json, release-tasks.sh, release.config.*, renovate*, rolldown.config.*, rollup.config.*, rspack*, ruff.toml, sentry.*.config.ts, simple-git-hooks*, sonar-project.properties, stylelint*, tsdown.config.*, tslint*, tsup.config.*, turbo*, typedoc*, unlighthouse*, vercel*, vetur.config.*, webpack*, workspace.json, wrangler.*, xo.config.*, yarn*",
73
- "sanity.config.*": "sanity.cli.*, sanity.types.ts, schema.json",
74
- "setup.cfg": ".editorconfig, .flake8, .isort.cfg, .python-version, MANIFEST.in, requirements*.in, requirements*.pip, requirements*.txt, setup.cfg, tox.ini",
75
- "setup.py": ".editorconfig, .flake8, .isort.cfg, .python-version, MANIFEST.in, requirements*.in, requirements*.pip, requirements*.txt, setup.cfg, setup.py, tox.ini",
76
- "shims.d.ts": "*.d.ts",
77
- "svelte.config.*": "*.env, .babelrc*, .codecov, .cssnanorc*, .env.*, .envrc, .htmlnanorc*, .lighthouserc.*, .mocha*, .postcssrc*, .terserrc*, api-extractor.json, ava.config.*, babel.config.*, capacitor.config.*, content.config.*, contentlayer.config.*, cssnano.config.*, cypress.*, env.d.ts, formkit.config.*, formulate.config.*, histoire.config.*, houdini.config.*, htmlnanorc.*, i18n.config.*, ionic.config.*, jasmine.*, jest.config.*, jsconfig.*, karma*, lighthouserc.*, mdsvex.config.js, panda.config.*, playwright.config.*, postcss.config.*, puppeteer.config.*, react-router.config.*, rspack.config.*, sst.config.*, svgo.config.*, tailwind.config.*, tsconfig.*, tsdoc.*, uno.config.*, unocss.config.*, vite.config.*, vitest.config.*, vuetify.config.*, webpack.config.*, windi.config.*",
78
- "vite.config.*": "*.env, .babelrc*, .codecov, .cssnanorc*, .env.*, .envrc, .htmlnanorc*, .lighthouserc.*, .mocha*, .postcssrc*, .terserrc*, api-extractor.json, ava.config.*, babel.config.*, capacitor.config.*, content.config.*, contentlayer.config.*, cssnano.config.*, cypress.*, env.d.ts, formkit.config.*, formulate.config.*, histoire.config.*, htmlnanorc.*, i18n.config.*, ionic.config.*, jasmine.*, jest.config.*, jsconfig.*, karma*, lighthouserc.*, panda.config.*, playwright.config.*, postcss.config.*, puppeteer.config.*, react-router.config.*, rspack.config.*, sst.config.*, svgo.config.*, tailwind.config.*, tsconfig.*, tsdoc.*, uno.config.*, unocss.config.*, vitest.config.*, vuetify.config.*, webpack.config.*, windi.config.*",
79
- "vue.config.*": "*.env, .babelrc*, .codecov, .cssnanorc*, .env.*, .envrc, .htmlnanorc*, .lighthouserc.*, .mocha*, .postcssrc*, .terserrc*, api-extractor.json, ava.config.*, babel.config.*, capacitor.config.*, content.config.*, contentlayer.config.*, cssnano.config.*, cypress.*, env.d.ts, formkit.config.*, formulate.config.*, histoire.config.*, htmlnanorc.*, i18n.config.*, ionic.config.*, jasmine.*, jest.config.*, jsconfig.*, karma*, lighthouserc.*, panda.config.*, playwright.config.*, postcss.config.*, puppeteer.config.*, react-router.config.*, rspack.config.*, sst.config.*, svgo.config.*, tailwind.config.*, tsconfig.*, tsdoc.*, uno.config.*, unocss.config.*, vitest.config.*, vuetify.config.*, webpack.config.*, windi.config.*",
80
- "*.asax": "$(capture).*.cs, $(capture).*.vb",
81
- "*.ascx": "$(capture).*.cs, $(capture).*.vb",
82
- "*.ashx": "$(capture).*.cs, $(capture).*.vb",
83
- "*.aspx": "$(capture).*.cs, $(capture).*.vb",
84
- "*.axaml": "$(capture).axaml.cs",
85
- "*.bloc.dart": "$(capture).event.dart, $(capture).state.dart",
86
- "*.c": "$(capture).h",
87
- "*.cc": "$(capture).hpp, $(capture).h, $(capture).hxx, $(capture).hh",
88
- "*.cjs": "$(capture).cjs.map, $(capture).*.cjs, $(capture)_*.cjs",
89
- "*.component.ts": "$(capture).component.html, $(capture).component.spec.ts, $(capture).component.css, $(capture).component.scss, $(capture).component.sass, $(capture).component.less",
90
- "*.cpp": "$(capture).hpp, $(capture).h, $(capture).hxx, $(capture).hh",
91
- "*.cs": "$(capture).*.cs",
92
- "*.cshtml": "$(capture).cshtml.cs, $(capture).cshtml.css",
93
- "*.csproj": "*.config, *proj.user, appsettings.*, bundleconfig.json",
94
- "*.css": "$(capture).css.map, $(capture).*.css",
95
- "*.cxx": "$(capture).hpp, $(capture).h, $(capture).hxx, $(capture).hh",
96
- "*.dart": "$(capture).freezed.dart, $(capture).g.dart",
97
- "*.db": "*.db-shm, *.db-wal",
98
- "*.ex": "$(capture).html.eex, $(capture).html.heex, $(capture).html.leex",
99
- "*.fs": "$(capture).fs.js, $(capture).fs.js.map, $(capture).fs.jsx, $(capture).fs.ts, $(capture).fs.tsx, $(capture).fs.rs, $(capture).fs.php, $(capture).fs.dart",
100
- "*.go": "$(capture)_test.go",
101
- "*.java": "$(capture).class",
102
- "*.js": "$(capture).js.map, $(capture).*.js, $(capture)_*.js, $(capture).d.ts, $(capture).d.ts.map, $(capture).js.flow",
103
- "*.jsx": "$(capture).js, $(capture).*.jsx, $(capture)_*.js, $(capture)_*.jsx, $(capture).css, $(capture).module.css, $(capture).less, $(capture).module.less, $(capture).module.less.d.ts, $(capture).scss, $(capture).module.scss, $(capture).module.scss.d.ts",
104
- "*.master": "$(capture).*.cs, $(capture).*.vb",
105
- "*.md": "$(capture).*",
106
- "*.mjs": "$(capture).mjs.map, $(capture).*.mjs, $(capture)_*.mjs",
107
- "*.module.ts": "$(capture).resolver.ts, $(capture).controller.ts, $(capture).service.ts",
108
- "*.mts": "$(capture).mts.map, $(capture).*.mts, $(capture)_*.mts",
109
- "*.proto": "$(capture).pb.go, $(capture).pb.micro.go",
110
- "*.pubxml": "$(capture).pubxml.user",
111
- "*.py": "$(capture).pyi",
112
- "*.razor": "$(capture).razor.cs, $(capture).razor.css, $(capture).razor.scss",
113
- "*.resx": "$(capture).*.resx, $(capture).designer.cs, $(capture).designer.vb",
114
- "*.tex": "$(capture).acn, $(capture).acr, $(capture).alg, $(capture).aux, $(capture).bbl, $(capture).bbl-SAVE-ERROR, $(capture).bcf, $(capture).blg, $(capture).fdb_latexmk, $(capture).fls, $(capture).glg, $(capture).glo, $(capture).gls, $(capture).idx, $(capture).ind, $(capture).ist, $(capture).lof, $(capture).log, $(capture).lot, $(capture).nav, $(capture).out, $(capture).run.xml, $(capture).snm, $(capture).synctex.gz, $(capture).toc, $(capture).xdv",
115
- "*.ts": "$(capture).js, $(capture).d.ts.map, $(capture).*.ts, $(capture)_*.js, $(capture)_*.ts",
116
- "*.tsx": "$(capture).ts, $(capture).*.tsx, $(capture)_*.ts, $(capture)_*.tsx, $(capture).css, $(capture).module.css, $(capture).less, $(capture).module.less, $(capture).module.less.d.ts, $(capture).scss, $(capture).module.scss, $(capture).module.scss.d.ts, $(capture).css.ts",
117
- "*.vbproj": "*.config, *proj.user, appsettings.*, bundleconfig.json",
118
- "*.vue": "$(capture).*.ts, $(capture).*.js, $(capture).story.vue",
119
- "*.w": "$(capture).*.w, I$(capture).w",
120
- "*.wat": "$(capture).wasm",
121
- "*.xaml": "$(capture).xaml.cs"
122
- }
16
+ "editor.codeActionsOnSave": {
17
+ "source.fixAll.eslint": "explicit"
18
+ },
19
+ "eslint.enable": true,
20
+ "eslint.workingDirectories": ["./"],
21
+ "eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact", "vue"]
123
22
  }
package/CHANGES CHANGED
@@ -1,7 +1,21 @@
1
+ Version 1.0.7 (2026-01-01)
2
+
3
+ - Harden asset serving and template resolution against traversal/symlink escapes by
4
+ resolving real paths and requiring files to exist.
5
+ - Enforce domain ownership for form/template and mailer endpoints, validate
6
+ reply-to/recipient addresses, and allow reply-to plus custom headers on
7
+ transactional sends.
8
+ - Constrain stored template/form filenames to safe relative paths and widen the
9
+ txmail template column to TEXT for larger templates.
10
+ - Added Vitest-based integration tests with a local SMTP harness, plus test
11
+ scripts and dependencies.
12
+
1
13
  Version 1.0.6 (2025-11-06)
2
14
 
3
15
  - Added the dedicated asset API module so files stored under `config/<domain>/assets`
4
16
  are served directly from `/asset/<domain>/<path>` (configurable via `ASSET_ROUTE`).
17
+ - Updated `@technomoron/api-server-base` to the latest 2.x beta and aligned mailer
18
+ server access with the new API server types.
5
19
  - Tightened the template preprocessor to require non-inline assets to live in the
6
20
  domain `assets/` folder and automatically rewrite `asset('logo.png')` calls to the
7
21
  public route.
@@ -1,6 +1,6 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
- import { ApiModule, ApiError } from '@technomoron/api-server-base';
3
+ import { ApiError, ApiModule } from '@technomoron/api-server-base';
4
4
  import { decodeComponent, sendFileAsync } from '../util.js';
5
5
  const DOMAIN_PATTERN = /^[a-z0-9][a-z0-9._-]*$/i;
6
6
  const SEGMENT_PATTERN = /^[a-zA-Z0-9._-]+$/;
@@ -19,27 +19,40 @@ export class AssetAPI extends ApiModule {
19
19
  throw new ApiError({ code: 404, message: 'Asset not found' });
20
20
  }
21
21
  const assetsRoot = path.join(this.server.storage.configpath, domain, 'assets');
22
- const resolvedRoot = path.resolve(assetsRoot);
22
+ if (!fs.existsSync(assetsRoot)) {
23
+ throw new ApiError({ code: 404, message: 'Asset not found' });
24
+ }
25
+ const resolvedRoot = fs.realpathSync(assetsRoot);
23
26
  const normalizedRoot = resolvedRoot.endsWith(path.sep) ? resolvedRoot : resolvedRoot + path.sep;
24
27
  const candidate = path.resolve(assetsRoot, path.join(...segments));
25
- if (!candidate.startsWith(normalizedRoot)) {
28
+ try {
29
+ const stats = await fs.promises.stat(candidate);
30
+ if (!stats.isFile()) {
31
+ throw new ApiError({ code: 404, message: 'Asset not found' });
32
+ }
33
+ }
34
+ catch {
26
35
  throw new ApiError({ code: 404, message: 'Asset not found' });
27
36
  }
37
+ let realCandidate;
28
38
  try {
29
- await fs.promises.access(candidate, fs.constants.R_OK);
39
+ realCandidate = await fs.promises.realpath(candidate);
30
40
  }
31
41
  catch {
32
42
  throw new ApiError({ code: 404, message: 'Asset not found' });
33
43
  }
44
+ if (!realCandidate.startsWith(normalizedRoot)) {
45
+ throw new ApiError({ code: 404, message: 'Asset not found' });
46
+ }
34
47
  const { res } = apiReq;
35
48
  const originalStatus = res.status.bind(res);
36
49
  const originalJson = res.json.bind(res);
37
50
  res.status = ((code) => (res.headersSent ? res : originalStatus(code)));
38
51
  res.json = ((body) => (res.headersSent ? res : originalJson(body)));
39
- res.type(path.extname(candidate));
52
+ res.type(path.extname(realCandidate));
40
53
  res.set('Cache-Control', 'public, max-age=300');
41
54
  try {
42
- await sendFileAsync(res, candidate);
55
+ await sendFileAsync(res, realCandidate);
43
56
  }
44
57
  catch (err) {
45
58
  this.server.storage.print_debug(`Failed to serve asset ${domain}/${segments.join('/')}: ${err instanceof Error ? err.message : String(err)}`);
package/dist/api/forms.js CHANGED
@@ -1,11 +1,19 @@
1
1
  import path from 'path';
2
2
  import { ApiModule, ApiError } from '@technomoron/api-server-base';
3
+ import emailAddresses from 'email-addresses';
3
4
  import nunjucks from 'nunjucks';
4
5
  import { api_domain } from '../models/domain.js';
5
6
  import { api_form } from '../models/form.js';
6
7
  import { api_user } from '../models/user.js';
7
8
  import { buildRequestMeta, normalizeSlug } from '../util.js';
8
9
  export class FormAPI extends ApiModule {
10
+ validateEmail(email) {
11
+ const parsed = emailAddresses.parseOneAddress(email);
12
+ if (parsed) {
13
+ return parsed.address;
14
+ }
15
+ return undefined;
16
+ }
9
17
  async assertDomainAndUser(apireq) {
10
18
  const { domain, locale } = apireq.req.body;
11
19
  if (!domain) {
@@ -19,6 +27,9 @@ export class FormAPI extends ApiModule {
19
27
  if (!dbdomain) {
20
28
  throw new ApiError({ code: 401, message: `Unable to look up the domain ${domain}` });
21
29
  }
30
+ if (dbdomain.user_id !== user.user_id) {
31
+ throw new ApiError({ code: 403, message: `Domain ${domain} is not owned by this user` });
32
+ }
22
33
  apireq.domain = dbdomain;
23
34
  apireq.locale = locale || 'en';
24
35
  apireq.user = user;
@@ -85,7 +96,7 @@ export class FormAPI extends ApiModule {
85
96
  return [200, { Status: 'OK' }];
86
97
  }
87
98
  async postSendForm(apireq) {
88
- const { formid, secret, recipient, vars = {} } = apireq.req.body;
99
+ const { formid, secret, recipient, vars = {}, replyTo, reply_to } = apireq.req.body;
89
100
  if (!formid) {
90
101
  throw new ApiError({ code: 404, message: 'Missing formid field in form' });
91
102
  }
@@ -102,12 +113,27 @@ export class FormAPI extends ApiModule {
102
113
  if (recipient && !form.secret) {
103
114
  throw new ApiError({ code: 401, message: "'recipient' parameterer requires form secret to be set" });
104
115
  }
116
+ let normalizedReplyTo;
117
+ let normalizedRecipient;
118
+ const replyToValue = (replyTo || reply_to);
119
+ if (replyToValue) {
120
+ normalizedReplyTo = this.validateEmail(replyToValue);
121
+ if (!normalizedReplyTo) {
122
+ throw new ApiError({ code: 400, message: 'Invalid reply-to email address' });
123
+ }
124
+ }
125
+ if (recipient) {
126
+ normalizedRecipient = this.validateEmail(String(recipient));
127
+ if (!normalizedRecipient) {
128
+ throw new ApiError({ code: 400, message: 'Invalid recipient email address' });
129
+ }
130
+ }
105
131
  let parsedVars = vars ?? {};
106
132
  if (typeof vars === 'string') {
107
133
  try {
108
134
  parsedVars = JSON.parse(vars);
109
135
  }
110
- catch (error) {
136
+ catch {
111
137
  throw new ApiError({ code: 400, message: 'Invalid JSON provided in "vars"' });
112
138
  }
113
139
  }
@@ -140,10 +166,11 @@ export class FormAPI extends ApiModule {
140
166
  const html = nunjucks.renderString(form.template, context);
141
167
  const mailOptions = {
142
168
  from: form.sender,
143
- to: recipient || form.recipient,
169
+ to: normalizedRecipient || form.recipient,
144
170
  subject: form.subject,
145
171
  html,
146
- attachments
172
+ attachments,
173
+ ...(normalizedReplyTo ? { replyTo: normalizedReplyTo } : {})
147
174
  };
148
175
  try {
149
176
  const info = await this.server.storage.transport.sendMail(mailOptions);
@@ -15,7 +15,7 @@ export class MailerAPI extends ApiModule {
15
15
  if (parsed) {
16
16
  return parsed.address;
17
17
  }
18
- return null;
18
+ return undefined;
19
19
  }
20
20
  //
21
21
  // Validate a set of email addresses. Return arrays of invalid
@@ -51,6 +51,9 @@ export class MailerAPI extends ApiModule {
51
51
  if (!dbdomain) {
52
52
  throw new ApiError({ code: 401, message: `Unable to look up the domain ${domain}` });
53
53
  }
54
+ if (dbdomain.user_id !== user.user_id) {
55
+ throw new ApiError({ code: 403, message: `Domain ${domain} is not owned by this user` });
56
+ }
54
57
  apireq.domain = dbdomain;
55
58
  apireq.locale = locale || 'en';
56
59
  apireq.user = user;
@@ -85,7 +88,7 @@ export class MailerAPI extends ApiModule {
85
88
  const [templateRecord, created] = await api_txmail.upsert(data, {
86
89
  returning: true
87
90
  });
88
- console.log('Template upserted:', templateRecord.name, 'Created:', created);
91
+ this.server.storage.print_debug(`Template upserted: ${templateRecord.name} (created=${created})`);
89
92
  }
90
93
  catch (error) {
91
94
  throw new ApiError({
@@ -98,7 +101,7 @@ export class MailerAPI extends ApiModule {
98
101
  // Send a template using posted arguments.
99
102
  async post_send(apireq) {
100
103
  await this.assert_domain_and_user(apireq);
101
- const { name, rcpt, domain = '', locale = '', vars = {} } = apireq.req.body;
104
+ const { name, rcpt, domain = '', locale = '', vars = {}, replyTo, reply_to, headers } = apireq.req.body;
102
105
  if (!name || !rcpt || !domain) {
103
106
  throw new ApiError({ code: 400, message: 'name/rcpt/domain required' });
104
107
  }
@@ -107,7 +110,7 @@ export class MailerAPI extends ApiModule {
107
110
  try {
108
111
  parsedVars = JSON.parse(vars);
109
112
  }
110
- catch (error) {
113
+ catch {
111
114
  throw new ApiError({ code: 400, message: 'Invalid JSON provided in "vars"' });
112
115
  }
113
116
  }
@@ -118,7 +121,7 @@ export class MailerAPI extends ApiModule {
118
121
  throw new ApiError({ code: 400, message: 'Invalid email address(es): ' + invalid.join(',') });
119
122
  }
120
123
  let template = null;
121
- const deflocale = apireq.server.store.deflocale || '';
124
+ const deflocale = this.server.storage.deflocale || '';
122
125
  const domain_id = apireq.domain.domain_id;
123
126
  try {
124
127
  template =
@@ -159,8 +162,29 @@ export class MailerAPI extends ApiModule {
159
162
  for (const file of rawFiles) {
160
163
  attachmentMap[file.fieldname] = file.originalname;
161
164
  }
162
- console.log(JSON.stringify({ vars, thevars }, undefined, 2));
165
+ this.server.storage.print_debug(`Template vars: ${JSON.stringify({ vars, thevars }, undefined, 2)}`);
163
166
  const meta = buildRequestMeta(apireq.req);
167
+ const replyToValue = (replyTo || reply_to);
168
+ let normalizedReplyTo;
169
+ if (replyToValue) {
170
+ normalizedReplyTo = this.validateEmail(replyToValue);
171
+ if (!normalizedReplyTo) {
172
+ throw new ApiError({ code: 400, message: 'Invalid reply-to email address' });
173
+ }
174
+ }
175
+ let normalizedHeaders;
176
+ if (headers !== undefined) {
177
+ if (!headers || typeof headers !== 'object' || Array.isArray(headers)) {
178
+ throw new ApiError({ code: 400, message: 'headers must be a key/value object' });
179
+ }
180
+ normalizedHeaders = {};
181
+ for (const [key, value] of Object.entries(headers)) {
182
+ if (typeof value !== 'string') {
183
+ throw new ApiError({ code: 400, message: `headers.${key} must be a string` });
184
+ }
185
+ normalizedHeaders[key] = value;
186
+ }
187
+ }
164
188
  try {
165
189
  const env = new nunjucks.Environment(null, { autoescape: false });
166
190
  const compiled = nunjucks.compile(template.template, env);
@@ -180,9 +204,11 @@ export class MailerAPI extends ApiModule {
180
204
  subject: template.subject || apireq.req.body.subject || '',
181
205
  html,
182
206
  text,
183
- attachments
207
+ attachments,
208
+ ...(normalizedReplyTo ? { replyTo: normalizedReplyTo } : {}),
209
+ ...(normalizedHeaders ? { headers: normalizedHeaders } : {})
184
210
  };
185
- await apireq.server.storage.transport.sendMail(sendargs);
211
+ await this.server.storage.transport.sendMail(sendargs);
186
212
  }
187
213
  return [200, { Status: 'OK', Message: 'Emails sent successfully' }];
188
214
  }
package/dist/index.js CHANGED
@@ -12,6 +12,8 @@ function buildServerConfig(store, overrides) {
12
12
  uploadPath: env.UPLOAD_PATH,
13
13
  debug: env.DEBUG,
14
14
  apiBasePath: '',
15
+ swaggerEnabled: env.SWAGGER_ENABLED,
16
+ swaggerPath: env.SWAGGER_PATH,
15
17
  ...overrides
16
18
  };
17
19
  }
@@ -128,6 +128,16 @@ export async function init_api_form(api_db) {
128
128
  });
129
129
  return api_form;
130
130
  }
131
+ function assertSafeRelativePath(filename, label) {
132
+ const normalized = path.normalize(filename);
133
+ if (path.isAbsolute(normalized)) {
134
+ throw new Error(`${label} path must be relative`);
135
+ }
136
+ if (normalized.split(path.sep).includes('..')) {
137
+ throw new Error(`${label} path cannot include '..' segments`);
138
+ }
139
+ return normalized;
140
+ }
131
141
  export async function upsert_form(record) {
132
142
  const { user, domain } = await user_and_domain(record.domain_id);
133
143
  const idname = normalizeSlug(user.idname);
@@ -150,7 +160,7 @@ export async function upsert_form(record) {
150
160
  if (!record.filename.endsWith('.njk')) {
151
161
  record.filename += '.njk';
152
162
  }
153
- record.filename = path.normalize(record.filename);
163
+ record.filename = assertSafeRelativePath(record.filename, 'Form filename');
154
164
  let instance = null;
155
165
  instance = await api_form.findByPk(record.form_id);
156
166
  if (instance) {
@@ -21,16 +21,17 @@ function resolveAsset(basePath, domainName, assetName) {
21
21
  if (!fs.existsSync(assetsRoot)) {
22
22
  return null;
23
23
  }
24
- const resolvedRoot = path.resolve(assetsRoot);
24
+ const resolvedRoot = fs.realpathSync(assetsRoot);
25
25
  const normalizedRoot = resolvedRoot.endsWith(path.sep) ? resolvedRoot : resolvedRoot + path.sep;
26
26
  const candidate = path.resolve(assetsRoot, assetName);
27
- if (!candidate.startsWith(normalizedRoot)) {
27
+ if (!fs.existsSync(candidate) || !fs.statSync(candidate).isFile()) {
28
28
  return null;
29
29
  }
30
- if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
31
- return candidate;
30
+ const realCandidate = fs.realpathSync(candidate);
31
+ if (!realCandidate.startsWith(normalizedRoot)) {
32
+ return null;
32
33
  }
33
- return null;
34
+ return realCandidate;
34
35
  }
35
36
  function buildAssetUrl(baseUrl, route, domainName, assetPath) {
36
37
  const trimmedBase = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
@@ -80,8 +81,10 @@ async function _load_template(store, filename, pathname, user, domain, locale, t
80
81
  if (filename.startsWith(prefix)) {
81
82
  relFile = filename.slice(prefix.length);
82
83
  }
83
- const absPath = path.resolve(rootDir, pathname || '', relFile);
84
- if (!absPath.startsWith(rootDir)) {
84
+ const resolvedRoot = path.resolve(rootDir);
85
+ const normalizedRoot = resolvedRoot.endsWith(path.sep) ? resolvedRoot : resolvedRoot + path.sep;
86
+ const absPath = path.resolve(resolvedRoot, pathname || '', relFile);
87
+ if (!absPath.startsWith(normalizedRoot)) {
85
88
  throw new Error(`Invalid template path "${filename}"`);
86
89
  }
87
90
  if (!fs.existsSync(absPath)) {
@@ -23,6 +23,16 @@ export const api_txmail_schema = z.object({
23
23
  });
24
24
  export class api_txmail extends Model {
25
25
  }
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
+ }
26
36
  export async function upsert_txmail(record) {
27
37
  const { user, domain } = await user_and_domain(record.domain_id);
28
38
  const idname = normalizeSlug(user.idname);
@@ -45,7 +55,7 @@ export async function upsert_txmail(record) {
45
55
  if (!record.filename.endsWith('.njk')) {
46
56
  record.filename += '.njk';
47
57
  }
48
- record.filename = path.normalize(record.filename);
58
+ record.filename = assertSafeRelativePath(record.filename, 'Template filename');
49
59
  const [instance] = await api_txmail.upsert(record);
50
60
  return instance;
51
61
  }
@@ -91,7 +101,7 @@ export async function init_api_txmail(api_db) {
91
101
  unique: false
92
102
  },
93
103
  template: {
94
- type: DataTypes.STRING,
104
+ type: DataTypes.TEXT,
95
105
  allowNull: false,
96
106
  defaultValue: ''
97
107
  },
@@ -145,7 +155,6 @@ export async function init_api_txmail(api_db) {
145
155
  });
146
156
  api_txmail.addHook('beforeValidate', async (template) => {
147
157
  const { user, domain } = await user_and_domain(template.domain_id);
148
- console.log('HERE');
149
158
  const dname = normalizeSlug(domain.name);
150
159
  const name = normalizeSlug(template.name);
151
160
  const locale = normalizeSlug(template.locale || domain.locale || user.locale || '');
@@ -160,7 +169,7 @@ export async function init_api_txmail(api_db) {
160
169
  if (!template.filename.endsWith('.njk')) {
161
170
  template.filename += '.njk';
162
171
  }
163
- console.log(`FILENAME IS: ${template.filename}`);
172
+ template.filename = assertSafeRelativePath(template.filename, 'Template filename');
164
173
  });
165
174
  return api_txmail;
166
175
  }
@@ -28,6 +28,15 @@ 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
+ SWAGGER_ENABLED: {
32
+ description: 'Enable the Swagger/OpenAPI endpoint',
33
+ type: 'boolean',
34
+ default: false
35
+ },
36
+ SWAGGER_PATH: {
37
+ description: 'Path to expose the Swagger/OpenAPI spec (default: /api/swagger when enabled)',
38
+ default: ''
39
+ },
31
40
  ASSET_ROUTE: {
32
41
  description: 'Route prefix exposed for config assets',
33
42
  default: '/asset'