first commit
This commit is contained in:
+10
@@ -0,0 +1,10 @@
|
||||
/mailgoserver
|
||||
settings.ini
|
||||
*.db
|
||||
*.crt
|
||||
*.key
|
||||
tests/
|
||||
|
||||
.claude/
|
||||
|
||||
go.sum
|
||||
@@ -0,0 +1,73 @@
|
||||
# Deploying mailgoserver
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
cd mailgoserver
|
||||
go build -o mailgoserver .
|
||||
```
|
||||
|
||||
One static binary — no venv, no `pip install`, no gunicorn.
|
||||
|
||||
## Bind ports 25/587 without root
|
||||
|
||||
```bash
|
||||
sudo setcap 'cap_net_bind_service=+ep' ./mailgoserver
|
||||
```
|
||||
|
||||
Same purpose as `script_setup_py_environment.sh`'s `setcap` step on the Python venv, applied to the compiled binary instead.
|
||||
|
||||
## systemd (unified process)
|
||||
|
||||
The Go binary runs the SMTP listeners and the web UI in one process (no GIL, so no
|
||||
need to split them into separate services the way `script_install_service.sh` split
|
||||
`pymta-smtp.service` / `pymta-web.service` for the Python version). One unit is enough:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=mailgoserver (SMTP + web admin)
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/opt/mailgoserver
|
||||
ExecStart=/opt/mailgoserver/mailgoserver --host 127.0.0.1 --port 5000
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||||
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/opt/mailgoserver
|
||||
ProtectHome=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
If you do want the SMTP and web parts as separate services (matching the Python
|
||||
split exactly), run two units with `--smtp-only` and `--web-only` respectively —
|
||||
both flags exist for this.
|
||||
|
||||
## nginx
|
||||
|
||||
No changes needed. `script_nginx_setup.sh` reverse-proxies to `http://127.0.0.1:5000`
|
||||
and terminates its own TLS for the web UI — mailgoserver listens on the same host:port
|
||||
by default, so the existing nginx config works unmodified. The SMTP TLS listener still
|
||||
consumes `ssl_certs/server.crt`/`server.key`, same as before.
|
||||
|
||||
## Admin dashboard login
|
||||
|
||||
First run seeds one account: username `admin`, password `Password123!`. Logging in
|
||||
with it immediately forces a username + password change before anything else in the
|
||||
dashboard is reachable — the default credentials can never be left in place.
|
||||
|
||||
Optional second factors, enabled per-account from **Account** in the sidebar:
|
||||
- **Authenticator app (TOTP)** — works anywhere, no extra config.
|
||||
- **Passkeys / security keys (WebAuthn)** — bound to the exact origin the dashboard
|
||||
is served at. Set `[Auth] rp_id` / `rp_origin` in `settings.ini` to your real public
|
||||
domain before registering passkeys in production (e.g. `rp_id = mail.example.com`,
|
||||
`rp_origin = https://mail.example.com`). The defaults (`localhost` /
|
||||
`http://localhost:5000`) only work for local testing — WebAuthn requires either
|
||||
HTTPS or the literal host `localhost`, so passkeys need the nginx+TLS setup above
|
||||
to work behind a real domain.
|
||||
@@ -0,0 +1,35 @@
|
||||
module mailgoserver
|
||||
|
||||
go 1.26.4
|
||||
|
||||
require (
|
||||
github.com/emersion/go-msgauth v0.7.0
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6
|
||||
github.com/emersion/go-smtp v0.24.0
|
||||
golang.org/x/crypto v0.55.0
|
||||
gopkg.in/ini.v1 v1.67.3
|
||||
modernc.org/sqlite v1.56.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/go-webauthn/webauthn v0.17.4 // indirect
|
||||
github.com/go-webauthn/x v0.2.6 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/google/go-tpm v0.9.8 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/pquerna/otp v1.5.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/tinylib/msgp v1.6.4 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
modernc.org/libc v1.74.4 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,106 @@
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/emersion/go-message v0.18.1/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
|
||||
github.com/emersion/go-milter v0.4.1/go.mod h1:erCQVl0mH4SX9jEvwe+wyndit0rQtmvMLH86V6NGtkI=
|
||||
github.com/emersion/go-msgauth v0.7.0 h1:vj2hMn6KhFtW41kshIBTXvp6KgYSqpA/ZN9Pv4g1INc=
|
||||
github.com/emersion/go-msgauth v0.7.0/go.mod h1:mmS9I6HkSovrNgq0HNXTeu8l3sRAAuQ9RMvbM4KU7Ck=
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||
github.com/emersion/go-smtp v0.24.0 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6Wk=
|
||||
github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ=
|
||||
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
|
||||
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/go-webauthn/webauthn v0.17.4 h1:KFTSz3R2RYDiUn/0cDi3XTJgFenSG74eKTTHlqWhlxk=
|
||||
github.com/go-webauthn/webauthn v0.17.4/go.mod h1:pZk63EE/BdztlmyS4Yc+9H5g4a8blNlbtGmdHQHbZX8=
|
||||
github.com/go-webauthn/x v0.2.6 h1:TEyDuQAIiEgYpx60nKiBJIX/5nSUC8LxNbH+uf5U9uk=
|
||||
github.com/go-webauthn/x v0.2.6/go.mod h1:45bA7YEqyQhRcQJ/TiBb46Ww8yqHBGvgEhQ3WWF0aDo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
|
||||
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/ini.v1 v1.67.3 h1:iM9Lhz5MRSGhHVGGwCuzG9KO8PoirCXj/m/qTmOJJQw=
|
||||
gopkg.in/ini.v1 v1.67.3/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
|
||||
modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
|
||||
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
|
||||
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k=
|
||||
modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.56.0 h1:/D8e2RfFqoy/Zc6PuC76U28zFwmI/sYx1Kjm4yEn9e0=
|
||||
modernc.org/sqlite v1.56.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
@@ -0,0 +1 @@
|
||||
{"0": "DKIM/SPF DNS Management UI", "1": "Web UI Database & Settings Utils", "2": "Admin Template Pages", "3": "App Bootstrap & Domain Models", "4": "DKIM Key Manager", "5": "SMTP Custom Protocol & Controllers", "6": "Sender/IP Authentication Logic", "7": "Models Core & Logging Utils", "8": "Email Relay Delivery", "9": "Unified App Entry Point", "10": "Dashboard & Auth Log Web UI", "11": "IP Whitelist Web UI", "12": "SMTP Handler Message Processing", "13": "Sender Management Web UI", "14": "Domain Management Web UI", "15": "README Unified: Structure & Deployment", "16": "README Unified & Requirements: SMTP/DB Deps", "17": "Manual Testing Docs (swaks/CLI)", "18": "Settings Loader & TLS Utils", "19": "DKIM/Custom Header Models", "20": "Combined Authenticators", "21": "CLI Usage & IP Whitelisting Docs", "22": "Message Viewer Web UI", "23": "DKIM/DNS Dependencies", "24": "README: License & Auth Features", "25": "systemd Install Script", "26": "Auth Order Fix Notes", "27": "Sender Model", "28": "send_email.py Test Script", "29": "SMTP Management JS (Frontend)", "30": "SMTP Handler Module & Message ID Util", "31": "Template Datetime Filter", "32": "Dashboard Route", "33": "nginx Setup Script", "34": "email_server Package Init", "35": "DKIM Management JS (Frontend)", "36": "Python Env Setup Script", "37": "bash_send_email Test Script", "38": "Hello.jpg Test Fixture (Doge Meme)"}
|
||||
@@ -0,0 +1 @@
|
||||
/home/haku/.local/share/uv/tools/graphifyy/bin/python
|
||||
@@ -0,0 +1 @@
|
||||
/home/haku/projects/PyMTA-server
|
||||
@@ -0,0 +1,248 @@
|
||||
# Graph Report - . (2026-08-12)
|
||||
|
||||
## Corpus Check
|
||||
- Corpus is ~39,032 words - fits in a single context window. You may not need a graph.
|
||||
|
||||
## Summary
|
||||
- 426 nodes · 861 edges · 39 communities (33 shown, 6 thin omitted)
|
||||
- Extraction: 92% EXTRACTED · 8% INFERRED · 0% AMBIGUOUS · INFERRED: 71 edges (avg confidence: 0.62)
|
||||
- Token cost: 243,577 input · 0 output
|
||||
|
||||
## Community Hubs (Navigation)
|
||||
- DKIM/SPF DNS Management UI
|
||||
- Web UI Database & Settings Utils
|
||||
- Admin Template Pages
|
||||
- App Bootstrap & Domain Models
|
||||
- DKIM Key Manager
|
||||
- SMTP Custom Protocol & Controllers
|
||||
- Sender/IP Authentication Logic
|
||||
- Models Core & Logging Utils
|
||||
- Email Relay Delivery
|
||||
- Unified App Entry Point
|
||||
- Dashboard & Auth Log Web UI
|
||||
- IP Whitelist Web UI
|
||||
- SMTP Handler Message Processing
|
||||
- Sender Management Web UI
|
||||
- Domain Management Web UI
|
||||
- README Unified: Structure & Deployment
|
||||
- README Unified & Requirements: SMTP/DB Deps
|
||||
- Manual Testing Docs (swaks/CLI)
|
||||
- Settings Loader & TLS Utils
|
||||
- DKIM/Custom Header Models
|
||||
- Combined Authenticators
|
||||
- CLI Usage & IP Whitelisting Docs
|
||||
- Message Viewer Web UI
|
||||
- DKIM/DNS Dependencies
|
||||
- README: License & Auth Features
|
||||
- systemd Install Script
|
||||
- Auth Order Fix Notes
|
||||
- Sender Model
|
||||
- send_email.py Test Script
|
||||
- SMTP Management JS (Frontend)
|
||||
- SMTP Handler Module & Message ID Util
|
||||
- Template Datetime Filter
|
||||
- Dashboard Route
|
||||
- nginx Setup Script
|
||||
- email_server Package Init
|
||||
- DKIM Management JS (Frontend)
|
||||
- Python Env Setup Script
|
||||
- bash_send_email Test Script
|
||||
- Hello.jpg Test Fixture (Doge Meme)
|
||||
|
||||
## God Nodes (most connected - your core abstractions)
|
||||
1. `DKIMManager` - 34 edges
|
||||
2. `load_settings()` - 22 edges
|
||||
3. `get_logger()` - 21 edges
|
||||
4. `Domain` - 20 edges
|
||||
5. `EmailLog` - 20 edges
|
||||
6. `SMTPServerApp` - 19 edges
|
||||
7. `EmailRelay` - 17 edges
|
||||
8. `Sender` - 17 edges
|
||||
9. `EnhancedCustomSMTPHandler` - 17 edges
|
||||
10. `Base Layout Template` - 17 edges
|
||||
|
||||
## Surprising Connections (you probably didn't know these)
|
||||
- `PyMTA-server README` --semantically_similar_to--> `SMTP Server with Web Management Frontend README (legacy layout)` [INFERRED] [semantically similar]
|
||||
README.md → README_unified.md
|
||||
- `DKIM Support (auto key gen + signing)` --conceptually_related_to--> `DKIM Key Generation (manual test setup)` [INFERRED]
|
||||
README_unified.md → tests/run_tests_manually.md
|
||||
- `Lorem Ipsum Test Email Body` --semantically_similar_to--> `PDF Test Document (French Lipsum)` [INFERRED] [semantically similar]
|
||||
tests/email_body.txt → tests/pdf_test_1.pdf
|
||||
- `SMTPServerApp` --uses--> `DKIMManager` [INFERRED]
|
||||
app.py → email_server/dkim_manager.py
|
||||
- `SMTPServerApp` --uses--> `AuthLog` [INFERRED]
|
||||
app.py → email_server/models.py
|
||||
|
||||
## Import Cycles
|
||||
- None detected.
|
||||
|
||||
## Hyperedges (group relationships)
|
||||
- **Domain CRUD Flow** — email_server_server_web_ui_templates_add_domain_page, email_server_server_web_ui_templates_domains_page, email_server_server_web_ui_templates_edit_domain_page, email_server_server_web_ui_templates_domains_domain_model [INFERRED 0.80]
|
||||
- **DKIM/SPF DNS Verification Flow** — email_server_server_web_ui_templates_dkim_page, email_server_server_web_ui_templates_edit_dkim_page, email_server_server_web_ui_templates_dkim_dkim_key_model, email_server_server_web_ui_templates_domains_domain_model [INFERRED 0.80]
|
||||
- **Email Log Message Viewer Flow** — email_server_server_web_ui_templates_logs_page, email_server_server_web_ui_templates_view_message_content_page, email_server_server_web_ui_templates_logs_email_log_model, email_server_server_web_ui_templates_view_message_content_attachment_model [INFERRED 0.80]
|
||||
- **SMTP Authentication & IP Whitelist Flow** — tests_note_authentication_order_fix_authentication_order_fix, tests_note_authentication_order_fix_ip_whitelisting_fallback, readme_unified_user_authentication, readme_unified_ip_whitelisting [INFERRED 0.85]
|
||||
- **Manual SMTP Testing Workflow** — tests_general_cli_usage_swaks, tests_run_tests_manually_swaks, tests_email_body_lorem_ipsum, tests_general_cli_usage_send_email_script [INFERRED 0.80]
|
||||
- **DKIM Key Generation & DNS Verification Flow** — readme_unified_dkim_support, readme_unified_dns_configuration, tests_general_cli_usage_dkim_key_management, tests_run_tests_manually_dkim_key_generation [INFERRED 0.85]
|
||||
|
||||
## Communities (39 total, 6 thin omitted)
|
||||
|
||||
### Community 0 - "DKIM/SPF DNS Management UI"
|
||||
Cohesion: 0.11
|
||||
Nodes (30): check_dkim_dns(), check_spf_dns(), create_dkim(), dkim_list(), edit_dkim(), route, DKIM blueprint for the SMTP server web UI. This module provides DKIM key…, Regenerate DKIM key for domain. (+22 more)
|
||||
|
||||
### Community 1 - "Web UI Database & Settings Utils"
|
||||
Cohesion: 0.10
|
||||
Nodes (28): import_or_install_driver(), install_package(), Database utilities for the SMTP server., Install a Python package using pip. Args: package_name: Name of the package to…, Import database driver, installing it if necessary. Args: db_type: Type of…, Test if a database connection can be established. Args: url: Database…, test_database_connection(), allowed_file() (+20 more)
|
||||
|
||||
### Community 2 - "Admin Template Pages"
|
||||
Cohesion: 0.23
|
||||
Nodes (27): Add Domain Page, Add IP Whitelist Page, Add Sender Page, check_health() Service Status Pattern, Base Layout Template, Dashboard Page, DKIM Key Data Model, DKIM Key Management Page (+19 more)
|
||||
|
||||
### Community 3 - "App Bootstrap & Domain Models"
|
||||
Cohesion: 0.15
|
||||
Nodes (15): Initialize the database with sample data for testing, create_tables(), Domain, Check if this IP can send emails for the given domain. Args: domain_name: The…, Create all database tables using ESRV schema., Domain model with enhanced security features., IP whitelist model with domain-specific authentication. Security feature: - IPs…, WhitelistedIP (+7 more)
|
||||
|
||||
### Community 4 - "DKIM Key Manager"
|
||||
Cohesion: 0.12
|
||||
Nodes (11): DKIMManager, Get the active DKIM key for a domain (only one active per selector)., Get DKIM private key for a domain., Get DKIM public key DNS record for a domain (active key only)., Sign email content with DKIM. Only add one DKIM header, after all modifications., Manages DKIM keys and email signing., Initialize DKIM keys for existing domains that don't have them., Initialize DKIMManager with a selector. If not provided, use random. (+3 more)
|
||||
|
||||
### Community 5 - "SMTP Custom Protocol & Controllers"
|
||||
Cohesion: 0.13
|
||||
Nodes (11): AIOSMTP, Controller, EmailAttachment, Attachment metadata and file path, linked to EmailLog., CustomSMTP, PlainController, Custom SMTP class with configurable banner and secure AUTH handling., Override AUTH command to close connection after failed authentication. (+3 more)
|
||||
|
||||
### Community 6 - "Sender/IP Authentication Logic"
|
||||
Cohesion: 0.14
|
||||
Nodes (15): get_authenticated_domain_id(), Enhanced authentication modules for the SMTP server using ESRV schema. Security…, Check if IP can authenticate for a specific domain. Args: ip_address: Client IP…, Validate if the authenticated entity can send as the specified from address.…, Get the domain ID for the authenticated entity. Args: session: SMTP session…, validate_sender_authorization(), check_password(), get_domain_by_name() (+7 more)
|
||||
|
||||
### Community 7 - "Models Core & Logging Utils"
|
||||
Cohesion: 0.16
|
||||
Nodes (13): Email relay functionality for the SMTP server., EmailRecipientLog, log_email(), Database models for the SMTP server using ESRV schema. Enhanced security…, Log for each recipient of an email, including status and error details., Log an email send attempt. Args: from_address: Sender email to_address:…, ensure_folder_exists(), get_logger() (+5 more)
|
||||
|
||||
### Community 8 - "Email Relay Delivery"
|
||||
Cohesion: 0.15
|
||||
Nodes (9): EmailRelay, Relay email to recipients' mail servers asynchronously with encryption.…, Handles relaying emails to recipient mail servers., Synchronous wrapper for relay_email_async for compatibility., Modify email headers to set To and Cc fields, preserving original structure for…, Log email activity to database, including per-recipient results., Prepare a copy of the email for a specific recipient without modifying original…, EmailLog (+1 more)
|
||||
|
||||
### Community 9 - "Unified App Entry Point"
|
||||
Cohesion: 0.18
|
||||
Nodes (8): main(), Start the SMTP server in async context, Run the unified application, Check the health of all services, Unified SMTP Server and Web Frontend Application, Convert relative database URL to absolute path for Flask-SQLAlchemy, Create and configure the Flask application, SMTPServerApp
|
||||
|
||||
### Community 10 - "Dashboard & Auth Log Web UI"
|
||||
Cohesion: 0.19
|
||||
Nodes (9): AuthLog, Authentication log model for security auditing., Dashboard routes for the SMTP server web UI. This module provides the main…, SMTP Server Web UI Package This package provides a web interface for managing…, logs(), route, Logs blueprint for the SMTP server web UI. This module provides email and…, Display email and authentication logs. (+1 more)
|
||||
|
||||
### Community 11 - "IP Whitelist Web UI"
|
||||
Cohesion: 0.20
|
||||
Nodes (13): add_ip(), disable_ip(), edit_ip(), enable_ip(), ips_list(), route, IP Whitelist blueprint for the SMTP server web UI. This module provides IP…, Enable whitelisted IP. (+5 more)
|
||||
|
||||
### Community 12 - "SMTP Handler Message Processing"
|
||||
Cohesion: 0.18
|
||||
Nodes (8): EnhancedCustomSMTPHandler, Ensure all required email headers are present and properly formatted., Handle incoming email data with improved header management and logging., Handle RCPT TO command - validate recipients., Handle MAIL FROM command with enhanced sender validation. Security Features: -…, Generate the storage path for attachments based on sender domain,…, Get the correct content type for a file, trying multiple methods., Enhanced custom SMTP handler with security controls.
|
||||
|
||||
### Community 13 - "Sender Management Web UI"
|
||||
Cohesion: 0.24
|
||||
Nodes (12): hash_password(), Hash a password using bcrypt., add_sender(), delete_sender(), edit_sender(), enable_sender(), route, Senders blueprint for the SMTP server web UI. This module provides sender… (+4 more)
|
||||
|
||||
### Community 14 - "Domain Management Web UI"
|
||||
Cohesion: 0.24
|
||||
Nodes (11): add_domain(), delete_domain(), domains_list(), edit_domain(), route, Domains blueprint for the SMTP server web UI. This module provides domain…, Toggle domain active status (Enable/Disable)., Permanently remove domain and all associated data. (+3 more)
|
||||
|
||||
### Community 15 - "README Unified: Structure & Deployment"
|
||||
Cohesion: 0.17
|
||||
Nodes (12): REST API Endpoints (/health, /api/server/status, /api/server/restart), SMTP Server with Web Management Frontend README (legacy layout), Domain Management, Email Relay (forward to external servers), Project Structure (app.py, main.py, email_server/, email_frontend/), Nginx Reverse Proxy, settings.ini Configuration File, Systemd Service (production deployment) (+4 more)
|
||||
|
||||
### Community 16 - "README Unified & Requirements: SMTP/DB Deps"
|
||||
Cohesion: 0.20
|
||||
Nodes (12): SQLite Database Schema (domains, users, whitelisted_ips, dkim_keys, email_logs, auth_logs, custom_headers), Full SMTP Server (send/receive with auth), aiosmtpd (dependency), aiosmtplib (dependency), Unified Requirements File, Flask-SQLAlchemy (dependency), Jinja2 (dependency), python-dotenv (dependency) (+4 more)
|
||||
|
||||
### Community 17 - "Manual Testing Docs (swaks/CLI)"
|
||||
Cohesion: 0.18
|
||||
Nodes (11): Lorem Ipsum Test Email Body, tests/send_email.py script, swaks (SMTP test CLI tool), PDF Test Document (French Lipsum), Beach Pebbles Photo (embedded image), Check DB Logs (sqlite3 email_logs query), DKIM Key Generation (manual test setup), Manual Test Setup Instructions (+3 more)
|
||||
|
||||
### Community 18 - "Settings Loader & TLS Utils"
|
||||
Cohesion: 0.31
|
||||
Nodes (8): ConfigParser, generate_settings_ini(), load_settings(), Settings loader for the SMTP server. Automatically generates settings.ini with…, Generate settings.ini with default values and comments if it does not exist., Load settings from settings.ini, generating it if needed., TLS utilities for the SMTP server., Path
|
||||
|
||||
### Community 19 - "DKIM/Custom Header Models"
|
||||
Cohesion: 0.25
|
||||
Nodes (6): Base, DKIM key management and email signing functionality., CustomHeader, DKIMKey, DKIM key model for email signing., Custom header model for domain-specific email headers.
|
||||
|
||||
### Community 20 - "Combined Authenticators"
|
||||
Cohesion: 0.33
|
||||
Nodes (6): EnhancedAuthenticator, EnhancedIPAuthenticator, Enhanced username/password authenticator with sender validation. Features: -…, Enhanced IP-based authenticator with domain-specific authorization. Features: -…, EnhancedCombinedAuthenticator, Enhanced combined authenticator with sender validation support. Features: -…
|
||||
|
||||
### Community 21 - "CLI Usage & IP Whitelisting Docs"
|
||||
Cohesion: 0.22
|
||||
Nodes (9): Sender IP Whitelisting, IP Whitelisting (auth-free sending), Direct sqlite3 DB Access/Queries, CLI/Web Usage Guide (tests), Domain Management (web UI: /email/domains), IP Whitelist Management (web UI: /email/ips), User Management (web UI: /email/users), Web Interface Setup Workflow (dev/prod) (+1 more)
|
||||
|
||||
### Community 22 - "Message Viewer Web UI"
|
||||
Cohesion: 0.38
|
||||
Nodes (6): delete_attachment(), download_attachment(), route, Route to view full email message content if stored., View the full message content for an email log if stored., view_message_content()
|
||||
|
||||
### Community 23 - "DKIM/DNS Dependencies"
|
||||
Cohesion: 0.29
|
||||
Nodes (7): DKIM (planned feature), DKIM Support (auto key gen + signing), DNS Configuration (DKIM/SPF/MX records), cryptography (dependency), dkimpy (dependency), dnspython (dependency), DKIM Key Management (web UI: /email/dkim)
|
||||
|
||||
### Community 24 - "README: License & Auth Features"
|
||||
Cohesion: 0.29
|
||||
Nodes (7): PyMTA-server README, AGPL-3.0 License, SPF DNS Records (delivery success), Optional Storing of Sent Emails, MIT License, Username/Password Authentication, gunicorn (dependency)
|
||||
|
||||
### Community 25 - "systemd Install Script"
|
||||
Cohesion: 0.52
|
||||
Nodes (6): remove_system_services(), remove_user_services(), script_install_service.sh script, usage(), write_system_services(), write_user_services()
|
||||
|
||||
### Community 26 - "Auth Order Fix Notes"
|
||||
Cohesion: 0.33
|
||||
Nodes (6): Per-user/per-domain User Authentication, bcrypt (dependency), AuthResult / email_server/auth.py __call__ (code snippet), AUTH Advertised on Both Plain SMTP and TLS Ports, SMTP Authentication Order Fix (June 2025), STARTTLS Not Advertised (direct TLS only)
|
||||
|
||||
### Community 27 - "Sender Model"
|
||||
Cohesion: 0.40
|
||||
Nodes (3): Sender model with enhanced authentication controls. Security features: -…, Check if this sender can send emails as the given from_address. Args:…, Sender
|
||||
|
||||
### Community 28 - "send_email.py Test Script"
|
||||
Cohesion: 0.40
|
||||
Nodes (4): Send a test email using plain SMTP., Send a test email using STARTTLS., send_test_email(), send_test_email_tls()
|
||||
|
||||
### Community 29 - "SMTP Management JS (Frontend)"
|
||||
Cohesion: 0.50
|
||||
Nodes (3): DNSVerification, FormValidation, SMTPManagement
|
||||
|
||||
### Community 30 - "SMTP Handler Module & Message ID Util"
|
||||
Cohesion: 0.50
|
||||
Nodes (3): Enhanced SMTP handler for processing incoming emails with security controls.…, generate_message_id(), Generate a consistent Message-ID for both email headers and database storage.…
|
||||
|
||||
### Community 31 - "Template Datetime Filter"
|
||||
Cohesion: 0.67
|
||||
Nodes (3): app_template_filter, format_datetime(), Format datetime with the correct timezone from settings or argument.
|
||||
|
||||
### Community 32 - "Dashboard Route"
|
||||
Cohesion: 0.67
|
||||
Nodes (3): dashboard(), route, Main dashboard showing overview of the email server.
|
||||
|
||||
## Ambiguous Edges - Review These
|
||||
- `Edit Domain Page` → `Sidebar Navigation Partial` [AMBIGUOUS]
|
||||
email_server/server_web_ui/templates/edit_domain.html · relation: references
|
||||
- `AGPL-3.0 License` → `MIT License` [AMBIGUOUS]
|
||||
README.md · relation: conceptually_related_to
|
||||
|
||||
## Knowledge Gaps
|
||||
- **36 isolated node(s):** `DKIMManagement`, `SMTPManagement`, `DNSVerification`, `FormValidation`, `script_nginx_setup.sh script` (+31 more)
|
||||
These have ≤1 connection - possible missing edges or undocumented components.
|
||||
- **6 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
|
||||
|
||||
## Suggested Questions
|
||||
_Questions this graph is uniquely positioned to answer:_
|
||||
|
||||
- **What is the exact relationship between `Edit Domain Page` and `Sidebar Navigation Partial`?**
|
||||
_Edge tagged AMBIGUOUS (relation: references) - confidence is low._
|
||||
- **What is the exact relationship between `AGPL-3.0 License` and `MIT License`?**
|
||||
_Edge tagged AMBIGUOUS (relation: conceptually_related_to) - confidence is low._
|
||||
- **Why does `DKIMManager` connect `DKIM Key Manager` to `DKIM/SPF DNS Management UI`, `App Bootstrap & Domain Models`, `SMTP Custom Protocol & Controllers`, `Unified App Entry Point`, `SMTP Handler Message Processing`, `Domain Management Web UI`, `DKIM/Custom Header Models`, `Combined Authenticators`, `SMTP Handler Module & Message ID Util`?**
|
||||
_High betweenness centrality (0.098) - this node is a cross-community bridge._
|
||||
- **Why does `load_settings()` connect `Settings Loader & TLS Utils` to `DKIM/SPF DNS Management UI`, `Web UI Database & Settings Utils`, `App Bootstrap & Domain Models`, `Models Core & Logging Utils`, `Unified App Entry Point`, `Dashboard & Auth Log Web UI`, `DKIM/Custom Header Models`, `SMTP Handler Module & Message ID Util`, `Template Datetime Filter`?**
|
||||
_High betweenness centrality (0.041) - this node is a cross-community bridge._
|
||||
- **Why does `EmailRelay` connect `Email Relay Delivery` to `SMTP Custom Protocol & Controllers`, `Models Core & Logging Utils`, `SMTP Handler Message Processing`, `Combined Authenticators`, `SMTP Handler Module & Message ID Util`?**
|
||||
_High betweenness centrality (0.036) - this node is a cross-community bridge._
|
||||
- **Are the 9 inferred relationships involving `DKIMManager` (e.g. with `SMTPServerApp` and `CustomHeader`) actually correct?**
|
||||
_`DKIMManager` has 9 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **Are the 4 inferred relationships involving `Domain` (e.g. with `SMTPServerApp` and `EnhancedAuthenticator`) actually correct?**
|
||||
_`Domain` has 4 INFERRED edges - model-reasoned connections that need verification._
|
||||
graphify-out/cache/ast/v0.9.37/05251c18c30787c8d604a53604a58d4047972c3840a19a3615bc59b75aee09f9.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/0cc1b8940b22ee0b0015d25c7819912defc72c760460cc7cf04b3278e00f29bc.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/2cbfa3c216771845653d30fee2e9003c4fed127d81dfd24e183a37567faaea28.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/2eb9f60a797877a5036bfc46a427240a9effa5da641c768ec5ec73bbe649ec3f.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/343c952ee4114c9166787cd08cff7e8e01560bc79f30b207e2cbf6fc6bf32c3b.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/4c8ecc79aca4fd9640c5b9a840125973c41bc325456748db03e2f0e7fb4cf6aa.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/4cb75c2dac0d659c2e794ce2f75e9162d8aacbea7ddabdf63d91b859166d78d4.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/4d43f0ffdbc1bfaff9e8878442c253981ea30a743cc355aa4f05167bfab5723e.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/5418f4925af9f157470f18a7bc90d4aeb97e4b0b1eebd4127fc4e3a6be5d4182.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/57f7b0fa0147dcad0c34ea666da0574d80fef057854ca253e281c31224311b63.json
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "$graphify-root$_script_setup_py_environment_sh", "label": "script_setup_py_environment.sh", "file_type": "code", "source_file": "script_setup_py_environment.sh", "source_location": "L1", "metadata": {"language": "bash", "kind": "file"}}, {"id": "$graphify-root$_script_setup_py_environment_sh__entry", "label": "script_setup_py_environment.sh script", "file_type": "code", "source_file": "script_setup_py_environment.sh", "source_location": "L1", "metadata": {"language": "bash", "kind": "bash_entrypoint"}}], "edges": [{"source": "$graphify-root$_script_setup_py_environment_sh", "target": "$graphify-root$_script_setup_py_environment_sh__entry", "relation": "contains", "confidence": "EXTRACTED", "source_file": "script_setup_py_environment.sh", "source_location": "L1", "weight": 1.0}], "raw_calls": [{"language": "bash", "callee": "python3", "caller_nid": "$graphify-root$_script_setup_py_environment_sh__entry", "source_file": "script_setup_py_environment.sh", "source_location": "L4"}, {"language": "bash", "callee": "echo", "caller_nid": "$graphify-root$_script_setup_py_environment_sh__entry", "source_file": "script_setup_py_environment.sh", "source_location": "L9"}, {"language": "bash", "callee": "sudo", "caller_nid": "$graphify-root$_script_setup_py_environment_sh__entry", "source_file": "script_setup_py_environment.sh", "source_location": "L11"}], "bash_sources": []}
|
||||
graphify-out/cache/ast/v0.9.37/73552cd042316daf4925b63d54e9017dba06c64cdc3eb13c09027fee46932a51.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/7bbfa1b55787fd0c9bc29b45cca75e11d7146bda4514e0c4d365f94b2e2a42db.json
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "$graphify-root$_email_server_init_py", "label": "__init__.py", "file_type": "code", "source_file": "email_server/__init__.py", "source_location": "L1"}, {"id": "$graphify-root$_email_server_init_rationale_1", "label": "PyMTA Server email package", "file_type": "rationale", "source_file": "email_server/__init__.py", "source_location": "L1"}], "edges": [{"source": "$graphify-root$_email_server_init_rationale_1", "target": "$graphify-root$_email_server_init_py", "relation": "rationale_for", "confidence": "EXTRACTED", "source_file": "email_server/__init__.py", "source_location": "L1", "weight": 1.0}], "raw_calls": []}
|
||||
graphify-out/cache/ast/v0.9.37/8c531e17c84573bd83a67ef3b456f9d7dbbbe33d626f48a4115bc01438ea1174.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/8d39786cf5b3da3b2a98aa3dc3ba656e9661208c833f48a7758ef6f587be9571.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/942a2ceeef2447853d96b06f31af1bd5f903f2bb5a1aa1b2a8af45e5a80d98cc.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/a5649989118c33ca032b514efc2c85753464c6302189270afb000a7013b59981.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/ad38a35c39e2aa05007ebe74529fee8583c558b70b4fdeb33e8f6818d4c0cbd3.json
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "$graphify-root$_tests_bash_send_email_sh", "label": "bash_send_email.sh", "file_type": "code", "source_file": "tests/bash_send_email.sh", "source_location": "L1", "metadata": {"language": "bash", "kind": "file"}}, {"id": "$graphify-root$_tests_bash_send_email_sh__entry", "label": "bash_send_email.sh script", "file_type": "code", "source_file": "tests/bash_send_email.sh", "source_location": "L1", "metadata": {"language": "bash", "kind": "bash_entrypoint"}}], "edges": [{"source": "$graphify-root$_tests_bash_send_email_sh", "target": "$graphify-root$_tests_bash_send_email_sh__entry", "relation": "contains", "confidence": "EXTRACTED", "source_file": "tests/bash_send_email.sh", "source_location": "L1", "weight": 1.0}], "raw_calls": [{"language": "bash", "callee": "--cc", "caller_nid": "$graphify-root$_tests_bash_send_email_sh__entry", "source_file": "tests/bash_send_email.sh", "source_location": "L20"}, {"language": "bash", "callee": "swaks", "caller_nid": "$graphify-root$_tests_bash_send_email_sh__entry", "source_file": "tests/bash_send_email.sh", "source_location": "L25"}, {"language": "bash", "callee": "com", "caller_nid": "$graphify-root$_tests_bash_send_email_sh__entry", "source_file": "tests/bash_send_email.sh", "source_location": "L40"}], "bash_sources": []}
|
||||
graphify-out/cache/ast/v0.9.37/b013764224b9ff43f3703b70a1d988acbbaf215305f2ea02898430d9d9ce746e.json
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "$graphify-root$_email_server_server_web_ui_init_py", "label": "__init__.py", "file_type": "code", "source_file": "email_server/server_web_ui/__init__.py", "source_location": "L1"}, {"id": "$graphify-root$_email_server_server_web_ui_init_rationale_1", "label": "SMTP Server Web UI Package This package provides a web interface for managing\u2026", "file_type": "rationale", "source_file": "email_server/server_web_ui/__init__.py", "source_location": "L1"}], "edges": [{"source": "$graphify-root$_email_server_server_web_ui_init_py", "target": "$graphify-root$_email_server_server_web_ui_routes_py", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "email_server/server_web_ui/__init__.py", "source_location": "L8", "weight": 1.0, "target_file": "$graphify-root$/email_server/server_web_ui/routes.py"}, {"source": "$graphify-root$_email_server_server_web_ui_init_py", "target": "$graphify-root$_email_server_server_web_ui_dashboard_py", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "email_server/server_web_ui/__init__.py", "source_location": "L11", "weight": 1.0, "target_file": "$graphify-root$/email_server/server_web_ui/dashboard.py"}, {"source": "$graphify-root$_email_server_server_web_ui_init_py", "target": "$graphify-root$_email_server_server_web_ui_domains_py", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "email_server/server_web_ui/__init__.py", "source_location": "L12", "weight": 1.0, "target_file": "$graphify-root$/email_server/server_web_ui/domains.py"}, {"source": "$graphify-root$_email_server_server_web_ui_init_py", "target": "$graphify-root$_email_server_server_web_ui_senders_py", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "email_server/server_web_ui/__init__.py", "source_location": "L13", "weight": 1.0, "target_file": "$graphify-root$/email_server/server_web_ui/senders.py"}, {"source": "$graphify-root$_email_server_server_web_ui_init_py", "target": "$graphify-root$_email_server_server_web_ui_ip_whitelist_py", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "email_server/server_web_ui/__init__.py", "source_location": "L14", "weight": 1.0, "target_file": "$graphify-root$/email_server/server_web_ui/ip_whitelist.py"}, {"source": "$graphify-root$_email_server_server_web_ui_init_py", "target": "$graphify-root$_email_server_server_web_ui_dkim_py", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "email_server/server_web_ui/__init__.py", "source_location": "L15", "weight": 1.0, "target_file": "$graphify-root$/email_server/server_web_ui/dkim.py"}, {"source": "$graphify-root$_email_server_server_web_ui_init_py", "target": "$graphify-root$_email_server_server_web_ui_settings_py", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "email_server/server_web_ui/__init__.py", "source_location": "L16", "weight": 1.0, "target_file": "$graphify-root$/email_server/server_web_ui/settings.py"}, {"source": "$graphify-root$_email_server_server_web_ui_init_py", "target": "$graphify-root$_email_server_server_web_ui_logs_py", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "email_server/server_web_ui/__init__.py", "source_location": "L17", "weight": 1.0, "target_file": "$graphify-root$/email_server/server_web_ui/logs.py"}, {"source": "$graphify-root$_email_server_server_web_ui_init_py", "target": "$graphify-root$_email_server_server_web_ui_view_message_py", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "email_server/server_web_ui/__init__.py", "source_location": "L18", "weight": 1.0, "target_file": "$graphify-root$/email_server/server_web_ui/view_message.py"}, {"source": "$graphify-root$_email_server_server_web_ui_init_rationale_1", "target": "$graphify-root$_email_server_server_web_ui_init_py", "relation": "rationale_for", "confidence": "EXTRACTED", "source_file": "email_server/server_web_ui/__init__.py", "source_location": "L1", "weight": 1.0}], "raw_calls": []}
|
||||
graphify-out/cache/ast/v0.9.37/bcda078e5fce29334bb7fd3ebfa5e41ff6c3d05409b9e05ab1e9b028f710e0bd.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/bcf615dd96ae8d600e5bb49d966729ddef6479f806a57df02d5f1001c4a5c790.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/cb66b7b4567fe8005046435eeb57111626591f9f0473d1b7cd63c70c34cce33e.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/d02e9085659d7c0ee4ca204b92a49a288f46488423e4c0c762f8f6608ed302c9.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/d90980483980ea4ae23d9292908946220f23d36f547a8c9ef1640e6d373ee842.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/e428638c29836f809dc52800b8097257019f60de16815b82b8eee236072594d2.json
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "$graphify-root$_script_nginx_setup_sh", "label": "script_nginx_setup.sh", "file_type": "code", "source_file": "script_nginx_setup.sh", "source_location": "L1", "metadata": {"language": "bash", "kind": "file"}}, {"id": "$graphify-root$_script_nginx_setup_sh__entry", "label": "script_nginx_setup.sh script", "file_type": "code", "source_file": "script_nginx_setup.sh", "source_location": "L1", "metadata": {"language": "bash", "kind": "bash_entrypoint"}}, {"id": "$graphify-root$_script_nginx_setup_error_messages", "label": "ERROR_MESSAGES", "file_type": "code", "source_file": "script_nginx_setup.sh", "source_location": "L26", "metadata": {"language": "bash", "kind": "code"}}], "edges": [{"source": "$graphify-root$_script_nginx_setup_sh", "target": "$graphify-root$_script_nginx_setup_sh__entry", "relation": "contains", "confidence": "EXTRACTED", "source_file": "script_nginx_setup.sh", "source_location": "L1", "weight": 1.0}, {"source": "$graphify-root$_script_nginx_setup_sh", "target": "$graphify-root$_script_nginx_setup_error_messages", "relation": "defines", "confidence": "EXTRACTED", "source_file": "script_nginx_setup.sh", "source_location": "L26", "weight": 1.0}], "raw_calls": [{"language": "bash", "callee": "set", "caller_nid": "$graphify-root$_script_nginx_setup_sh__entry", "source_file": "script_nginx_setup.sh", "source_location": "L22"}, {"language": "bash", "callee": "echo", "caller_nid": "$graphify-root$_script_nginx_setup_sh__entry", "source_file": "script_nginx_setup.sh", "source_location": "L39"}, {"language": "bash", "callee": "exit", "caller_nid": "$graphify-root$_script_nginx_setup_sh__entry", "source_file": "script_nginx_setup.sh", "source_location": "L40"}, {"language": "bash", "callee": "apt", "caller_nid": "$graphify-root$_script_nginx_setup_sh__entry", "source_file": "script_nginx_setup.sh", "source_location": "L45"}, {"language": "bash", "callee": "mkdir", "caller_nid": "$graphify-root$_script_nginx_setup_sh__entry", "source_file": "script_nginx_setup.sh", "source_location": "L50"}, {"language": "bash", "callee": "python3", "caller_nid": "$graphify-root$_script_nginx_setup_sh__entry", "source_file": "script_nginx_setup.sh", "source_location": "L51"}, {"language": "bash", "callee": "pip3", "caller_nid": "$graphify-root$_script_nginx_setup_sh__entry", "source_file": "script_nginx_setup.sh", "source_location": "L56"}, {"language": "bash", "callee": "deactivate", "caller_nid": "$graphify-root$_script_nginx_setup_sh__entry", "source_file": "script_nginx_setup.sh", "source_location": "L62"}, {"language": "bash", "callee": "cat", "caller_nid": "$graphify-root$_script_nginx_setup_sh__entry", "source_file": "script_nginx_setup.sh", "source_location": "L76"}, {"language": "bash", "callee": "chown", "caller_nid": "$graphify-root$_script_nginx_setup_sh__entry", "source_file": "script_nginx_setup.sh", "source_location": "L101"}, {"language": "bash", "callee": "chmod", "caller_nid": "$graphify-root$_script_nginx_setup_sh__entry", "source_file": "script_nginx_setup.sh", "source_location": "L102"}, {"language": "bash", "callee": "ln", "caller_nid": "$graphify-root$_script_nginx_setup_sh__entry", "source_file": "script_nginx_setup.sh", "source_location": "L136"}, {"language": "bash", "callee": "rm", "caller_nid": "$graphify-root$_script_nginx_setup_sh__entry", "source_file": "script_nginx_setup.sh", "source_location": "L138"}, {"language": "bash", "callee": "nginx", "caller_nid": "$graphify-root$_script_nginx_setup_sh__entry", "source_file": "script_nginx_setup.sh", "source_location": "L142"}, {"language": "bash", "callee": "systemctl", "caller_nid": "$graphify-root$_script_nginx_setup_sh__entry", "source_file": "script_nginx_setup.sh", "source_location": "L146"}, {"language": "bash", "callee": "certbot", "caller_nid": "$graphify-root$_script_nginx_setup_sh__entry", "source_file": "script_nginx_setup.sh", "source_location": "L152"}, {"language": "bash", "callee": "cp", "caller_nid": "$graphify-root$_script_nginx_setup_sh__entry", "source_file": "script_nginx_setup.sh", "source_location": "L168"}, {"language": "bash", "callee": "crontab", "caller_nid": "$graphify-root$_script_nginx_setup_sh__entry", "source_file": "script_nginx_setup.sh", "source_location": "L287"}], "bash_sources": []}
|
||||
graphify-out/cache/ast/v0.9.37/e435f54e2eb7b2b07ba12b6671ce4ca682cea39731566f3b95e60aaa99f03969.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/e8ad59f7a3fcb4cca8357809ae0e1647107f549753fd0473126cd000ae147e86.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/eb1f5a98cf68dfd5b4fb5d0b6ac1c5ce70867aec6ce9dcff1906a71f09458baf.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.9.37/f7563fcd503cf1ec30243d80866d3ef33e9caab8c665b3cb3efa0d8302793f3d.json
Vendored
+1
File diff suppressed because one or more lines are too long
+1
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "email_server_server_web_ui_templates_senders_page", "label": "Senders List Page", "file_type": "code", "source_file": "email_server/server_web_ui/templates/senders.html", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}, {"id": "email_server_server_web_ui_templates_senders_sender_model", "label": "Sender/User Data Model", "file_type": "concept", "source_file": "email_server/server_web_ui/templates/senders.html", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}], "edges": [{"source": "email_server_server_web_ui_templates_senders_page", "target": "email_server_server_web_ui_templates_base_page", "relation": "implements", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/senders.html", "source_location": null, "weight": 1.0}, {"source": "email_server_server_web_ui_templates_senders_page", "target": "email_server_server_web_ui_templates_add_sender_page", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/senders.html", "source_location": null, "weight": 1.0}, {"source": "email_server_server_web_ui_templates_senders_page", "target": "email_server_server_web_ui_templates_edit_sender_page", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/senders.html", "source_location": null, "weight": 1.0}, {"source": "email_server_server_web_ui_templates_senders_page", "target": "email_server_server_web_ui_templates_senders_sender_model", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/senders.html", "source_location": null, "weight": 1.0}, {"source": "email_server_server_web_ui_templates_senders_page", "target": "email_server_server_web_ui_templates_domains_domain_model", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/senders.html", "source_location": null, "weight": 1.0}], "hyperedges": []}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "tests_pdf_test_1_document", "label": "PDF Test Document (French Lipsum)", "file_type": "document", "source_file": "tests/pdf_test_1.pdf", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}, {"id": "tests_pdf_test_1_pebbles_image", "label": "Beach Pebbles Photo (embedded image)", "file_type": "image", "source_file": "tests/pdf_test_1.pdf", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}], "edges": [{"source": "tests_pdf_test_1_document", "target": "tests_pdf_test_1_pebbles_image", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "tests/pdf_test_1.pdf", "source_location": null, "weight": 1.0}], "hyperedges": []}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "email_server_server_web_ui_templates_settings_page", "label": "Server Settings Page", "file_type": "code", "source_file": "email_server/server_web_ui/templates/settings.html", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}, {"id": "email_server_server_web_ui_templates_settings_server_settings_model", "label": "Server Settings (INI) Data Model", "file_type": "concept", "source_file": "email_server/server_web_ui/templates/settings.html", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}], "edges": [{"source": "email_server_server_web_ui_templates_settings_page", "target": "email_server_server_web_ui_templates_base_page", "relation": "implements", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/settings.html", "source_location": null, "weight": 1.0}, {"source": "email_server_server_web_ui_templates_settings_page", "target": "email_server_server_web_ui_templates_settings_server_settings_model", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/settings.html", "source_location": null, "weight": 1.0}], "hyperedges": []}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "email_server_server_web_ui_templates_add_domain_page", "label": "Add Domain Page", "file_type": "code", "source_file": "email_server/server_web_ui/templates/add_domain.html", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}], "edges": [{"source": "email_server_server_web_ui_templates_add_domain_page", "target": "email_server_server_web_ui_templates_base_page", "relation": "implements", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/add_domain.html", "source_location": null, "weight": 1.0}, {"source": "email_server_server_web_ui_templates_add_domain_page", "target": "email_server_server_web_ui_templates_domains_page", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/add_domain.html", "source_location": null, "weight": 1.0}, {"source": "email_server_server_web_ui_templates_add_domain_page", "target": "email_server_server_web_ui_templates_domains_domain_model", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/add_domain.html", "source_location": null, "weight": 1.0}], "hyperedges": []}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "email_server_server_web_ui_templates_add_sender_page", "label": "Add Sender Page", "file_type": "code", "source_file": "email_server/server_web_ui/templates/add_sender.html", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}], "edges": [{"source": "email_server_server_web_ui_templates_add_sender_page", "target": "email_server_server_web_ui_templates_base_page", "relation": "implements", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/add_sender.html", "source_location": null, "weight": 1.0}, {"source": "email_server_server_web_ui_templates_add_sender_page", "target": "email_server_server_web_ui_templates_senders_page", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/add_sender.html", "source_location": null, "weight": 1.0}, {"source": "email_server_server_web_ui_templates_add_sender_page", "target": "email_server_server_web_ui_templates_senders_sender_model", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/add_sender.html", "source_location": null, "weight": 1.0}, {"source": "email_server_server_web_ui_templates_add_sender_page", "target": "email_server_server_web_ui_templates_domains_domain_model", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/add_sender.html", "source_location": null, "weight": 1.0}], "hyperedges": []}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "email_server_server_web_ui_templates_domains_page", "label": "Domains List Page", "file_type": "code", "source_file": "email_server/server_web_ui/templates/domains.html", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}, {"id": "email_server_server_web_ui_templates_domains_domain_model", "label": "Domain Data Model", "file_type": "concept", "source_file": "email_server/server_web_ui/templates/domains.html", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}], "edges": [{"source": "email_server_server_web_ui_templates_domains_page", "target": "email_server_server_web_ui_templates_base_page", "relation": "implements", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/domains.html", "source_location": null, "weight": 1.0}, {"source": "email_server_server_web_ui_templates_domains_page", "target": "email_server_server_web_ui_templates_add_domain_page", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/domains.html", "source_location": null, "weight": 1.0}, {"source": "email_server_server_web_ui_templates_domains_page", "target": "email_server_server_web_ui_templates_edit_domain_page", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/domains.html", "source_location": null, "weight": 1.0}, {"source": "email_server_server_web_ui_templates_domains_page", "target": "email_server_server_web_ui_templates_domains_domain_model", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/domains.html", "source_location": null, "weight": 1.0}, {"source": "email_server_server_web_ui_templates_domains_page", "target": "email_server_server_web_ui_templates_dkim_dkim_key_model", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/domains.html", "source_location": null, "weight": 1.0}], "hyperedges": [{"id": "domain_crud_flow", "label": "Domain CRUD Flow", "nodes": ["email_server_server_web_ui_templates_add_domain_page", "email_server_server_web_ui_templates_domains_page", "email_server_server_web_ui_templates_edit_domain_page", "email_server_server_web_ui_templates_domains_domain_model"], "relation": "participate_in", "confidence": "INFERRED", "confidence_score": 0.8, "source_file": "email_server/server_web_ui/templates/domains.html"}]}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "email_server_server_web_ui_templates_sidebar_email_partial", "label": "Sidebar Navigation Partial", "file_type": "code", "source_file": "email_server/server_web_ui/templates/sidebar_email.html", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}], "edges": [{"source": "email_server_server_web_ui_templates_sidebar_email_partial", "target": "email_server_server_web_ui_templates_dashboard_page", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/sidebar_email.html", "source_location": null, "weight": 1.0}, {"source": "email_server_server_web_ui_templates_sidebar_email_partial", "target": "email_server_server_web_ui_templates_domains_page", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/sidebar_email.html", "source_location": null, "weight": 1.0}, {"source": "email_server_server_web_ui_templates_sidebar_email_partial", "target": "email_server_server_web_ui_templates_senders_page", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/sidebar_email.html", "source_location": null, "weight": 1.0}, {"source": "email_server_server_web_ui_templates_sidebar_email_partial", "target": "email_server_server_web_ui_templates_ips_page", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/sidebar_email.html", "source_location": null, "weight": 1.0}, {"source": "email_server_server_web_ui_templates_sidebar_email_partial", "target": "email_server_server_web_ui_templates_dkim_page", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/sidebar_email.html", "source_location": null, "weight": 1.0}, {"source": "email_server_server_web_ui_templates_sidebar_email_partial", "target": "email_server_server_web_ui_templates_logs_page", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/sidebar_email.html", "source_location": null, "weight": 1.0}, {"source": "email_server_server_web_ui_templates_sidebar_email_partial", "target": "email_server_server_web_ui_templates_settings_page", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/sidebar_email.html", "source_location": null, "weight": 1.0}, {"source": "email_server_server_web_ui_templates_sidebar_email_partial", "target": "email_server_server_web_ui_templates_domains_domain_model", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/sidebar_email.html", "source_location": null, "weight": 1.0}, {"source": "email_server_server_web_ui_templates_sidebar_email_partial", "target": "email_server_server_web_ui_templates_senders_sender_model", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/sidebar_email.html", "source_location": null, "weight": 1.0}, {"source": "email_server_server_web_ui_templates_sidebar_email_partial", "target": "email_server_server_web_ui_templates_ips_ip_whitelist_model", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/sidebar_email.html", "source_location": null, "weight": 1.0}, {"source": "email_server_server_web_ui_templates_sidebar_email_partial", "target": "email_server_server_web_ui_templates_dkim_dkim_key_model", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/sidebar_email.html", "source_location": null, "weight": 1.0}, {"source": "email_server_server_web_ui_templates_sidebar_email_partial", "target": "email_server_server_web_ui_templates_base_health_check_concept", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/sidebar_email.html", "source_location": null, "weight": 1.0}], "hyperedges": []}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "email_server_server_web_ui_templates_logs_page", "label": "Emails/Auth Logs Page", "file_type": "code", "source_file": "email_server/server_web_ui/templates/logs.html", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}, {"id": "email_server_server_web_ui_templates_logs_email_log_model", "label": "Email Log Data Model", "file_type": "concept", "source_file": "email_server/server_web_ui/templates/logs.html", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}, {"id": "email_server_server_web_ui_templates_logs_auth_log_model", "label": "Auth Log Data Model", "file_type": "concept", "source_file": "email_server/server_web_ui/templates/logs.html", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}], "edges": [{"source": "email_server_server_web_ui_templates_logs_page", "target": "email_server_server_web_ui_templates_base_page", "relation": "implements", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/logs.html", "source_location": null, "weight": 1.0}, {"source": "email_server_server_web_ui_templates_logs_page", "target": "email_server_server_web_ui_templates_view_message_content_page", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/logs.html", "source_location": null, "weight": 1.0}, {"source": "email_server_server_web_ui_templates_logs_page", "target": "email_server_server_web_ui_templates_logs_email_log_model", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/logs.html", "source_location": null, "weight": 1.0}, {"source": "email_server_server_web_ui_templates_logs_page", "target": "email_server_server_web_ui_templates_logs_auth_log_model", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/logs.html", "source_location": null, "weight": 1.0}], "hyperedges": [{"id": "email_log_message_viewer_flow", "label": "Email Log Message Viewer Flow", "nodes": ["email_server_server_web_ui_templates_logs_page", "email_server_server_web_ui_templates_view_message_content_page", "email_server_server_web_ui_templates_logs_email_log_model", "email_server_server_web_ui_templates_view_message_content_attachment_model"], "relation": "participate_in", "confidence": "INFERRED", "confidence_score": 0.8, "source_file": "email_server/server_web_ui/templates/logs.html"}]}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "email_server_server_web_ui_templates_edit_ip_page", "label": "Edit IP Whitelist Page", "file_type": "code", "source_file": "email_server/server_web_ui/templates/edit_ip.html", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}], "edges": [{"source": "email_server_server_web_ui_templates_edit_ip_page", "target": "email_server_server_web_ui_templates_base_page", "relation": "implements", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/edit_ip.html", "source_location": null, "weight": 1.0}, {"source": "email_server_server_web_ui_templates_edit_ip_page", "target": "email_server_server_web_ui_templates_ips_page", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/edit_ip.html", "source_location": null, "weight": 1.0}, {"source": "email_server_server_web_ui_templates_edit_ip_page", "target": "email_server_server_web_ui_templates_ips_ip_whitelist_model", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/edit_ip.html", "source_location": null, "weight": 1.0}, {"source": "email_server_server_web_ui_templates_edit_ip_page", "target": "email_server_server_web_ui_templates_domains_domain_model", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/edit_ip.html", "source_location": null, "weight": 1.0}], "hyperedges": []}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "readme_doc", "label": "PyMTA-server README", "file_type": "document", "source_file": "README.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}, {"id": "readme_dkim", "label": "DKIM (planned feature)", "file_type": "concept", "source_file": "README.md", "source_location": "Plan section", "source_url": null, "captured_at": null, "author": null, "contributor": null}, {"id": "readme_spf", "label": "SPF DNS Records (delivery success)", "file_type": "concept", "source_file": "README.md", "source_location": "Plan section", "source_url": null, "captured_at": null, "author": null, "contributor": null}, {"id": "readme_ip_whitelisting", "label": "Sender IP Whitelisting", "file_type": "concept", "source_file": "README.md", "source_location": "Plan section", "source_url": null, "captured_at": null, "author": null, "contributor": null}, {"id": "readme_username_password_auth", "label": "Username/Password Authentication", "file_type": "concept", "source_file": "README.md", "source_location": "Plan section", "source_url": null, "captured_at": null, "author": null, "contributor": null}, {"id": "readme_store_sent_emails", "label": "Optional Storing of Sent Emails", "file_type": "concept", "source_file": "README.md", "source_location": "Plan section", "source_url": null, "captured_at": null, "author": null, "contributor": null}, {"id": "readme_license", "label": "AGPL-3.0 License", "file_type": "concept", "source_file": "README.md", "source_location": "License section", "source_url": null, "captured_at": null, "author": null, "contributor": null}], "edges": [{"source": "readme_doc", "target": "readme_dkim", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "README.md", "source_location": "Plan", "weight": 1.0}, {"source": "readme_doc", "target": "readme_spf", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "README.md", "source_location": "Plan", "weight": 1.0}, {"source": "readme_doc", "target": "readme_ip_whitelisting", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "README.md", "source_location": "Plan", "weight": 1.0}, {"source": "readme_doc", "target": "readme_username_password_auth", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "README.md", "source_location": "Plan", "weight": 1.0}, {"source": "readme_doc", "target": "readme_store_sent_emails", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "README.md", "source_location": "Plan", "weight": 1.0}, {"source": "readme_doc", "target": "readme_license", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "README.md", "source_location": "License", "weight": 1.0}, {"source": "readme_doc", "target": "tests_general_cli_usage_doc", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "README.md", "source_location": "Tests - examples: [CLI commands and usage example]", "weight": 1.0}, {"source": "readme_doc", "target": "tests_run_tests_manually_doc", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "README.md", "source_location": "Tests - examples: [Send Test Emails examples]", "weight": 1.0}, {"source": "requirements_gunicorn", "target": "readme_doc", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "README.md", "source_location": "Production: gunicorn -w 4 -b 0.0.0.0:5000 app:flask_app", "weight": 1.0}, {"source": "readme_doc", "target": "readme_unified_doc", "relation": "semantically_similar_to", "confidence": "INFERRED", "confidence_score": 0.85, "source_file": "README.md", "source_location": null, "weight": 1.0}, {"source": "readme_dkim", "target": "readme_unified_dkim_support", "relation": "conceptually_related_to", "confidence": "INFERRED", "confidence_score": 0.95, "source_file": "README.md", "source_location": null, "weight": 1.0}, {"source": "readme_ip_whitelisting", "target": "readme_unified_ip_whitelisting", "relation": "conceptually_related_to", "confidence": "INFERRED", "confidence_score": 0.95, "source_file": "README.md", "source_location": null, "weight": 1.0}, {"source": "readme_license", "target": "readme_unified_mit_license", "relation": "conceptually_related_to", "confidence": "AMBIGUOUS", "confidence_score": 0.2, "source_file": "README.md", "source_location": null, "weight": 1.0}], "hyperedges": []}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "email_server_server_web_ui_templates_base_page", "label": "Base Layout Template", "file_type": "code", "source_file": "email_server/server_web_ui/templates/base.html", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}, {"id": "email_server_server_web_ui_templates_base_health_check_concept", "label": "check_health() Service Status Pattern", "file_type": "concept", "source_file": "email_server/server_web_ui/templates/base.html", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}], "edges": [{"source": "email_server_server_web_ui_templates_base_page", "target": "email_server_server_web_ui_templates_sidebar_email_partial", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/base.html", "source_location": null, "weight": 1.0}], "hyperedges": []}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "email_server_server_web_ui_templates_add_ip_page", "label": "Add IP Whitelist Page", "file_type": "code", "source_file": "email_server/server_web_ui/templates/add_ip.html", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}], "edges": [{"source": "email_server_server_web_ui_templates_add_ip_page", "target": "email_server_server_web_ui_templates_base_page", "relation": "implements", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/add_ip.html", "source_location": null, "weight": 1.0}, {"source": "email_server_server_web_ui_templates_add_ip_page", "target": "email_server_server_web_ui_templates_ips_page", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/add_ip.html", "source_location": null, "weight": 1.0}, {"source": "email_server_server_web_ui_templates_add_ip_page", "target": "email_server_server_web_ui_templates_ips_ip_whitelist_model", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/add_ip.html", "source_location": null, "weight": 1.0}, {"source": "email_server_server_web_ui_templates_add_ip_page", "target": "email_server_server_web_ui_templates_domains_domain_model", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/add_ip.html", "source_location": null, "weight": 1.0}, {"source": "email_server_server_web_ui_templates_add_ip_page", "target": "email_server_server_web_ui_templates_ips_page", "relation": "semantically_similar_to", "confidence": "INFERRED", "confidence_score": 0.85, "source_file": "email_server/server_web_ui/templates/add_ip.html", "source_location": null, "weight": 1.0}], "hyperedges": []}
|
||||
+1
File diff suppressed because one or more lines are too long
+1
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "tests_email_body_lorem_ipsum", "label": "Lorem Ipsum Test Email Body", "file_type": "document", "source_file": "tests/email_body.txt", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}], "edges": [{"source": "tests_email_body_lorem_ipsum", "target": "tests_pdf_test_1_document", "relation": "semantically_similar_to", "confidence": "INFERRED", "confidence_score": 0.75, "source_file": "tests/email_body.txt", "source_location": null, "weight": 1.0}], "hyperedges": []}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "email_server_server_web_ui_templates_ips_page", "label": "Whitelisted IPs List Page", "file_type": "code", "source_file": "email_server/server_web_ui/templates/ips.html", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}, {"id": "email_server_server_web_ui_templates_ips_ip_whitelist_model", "label": "IP Whitelist Data Model", "file_type": "concept", "source_file": "email_server/server_web_ui/templates/ips.html", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}], "edges": [{"source": "email_server_server_web_ui_templates_ips_page", "target": "email_server_server_web_ui_templates_base_page", "relation": "implements", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/ips.html", "source_location": null, "weight": 1.0}, {"source": "email_server_server_web_ui_templates_ips_page", "target": "email_server_server_web_ui_templates_add_ip_page", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/ips.html", "source_location": null, "weight": 1.0}, {"source": "email_server_server_web_ui_templates_ips_page", "target": "email_server_server_web_ui_templates_edit_ip_page", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/ips.html", "source_location": null, "weight": 1.0}, {"source": "email_server_server_web_ui_templates_ips_page", "target": "email_server_server_web_ui_templates_ips_ip_whitelist_model", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/ips.html", "source_location": null, "weight": 1.0}, {"source": "email_server_server_web_ui_templates_ips_page", "target": "email_server_server_web_ui_templates_domains_domain_model", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/ips.html", "source_location": null, "weight": 1.0}], "hyperedges": []}
|
||||
+1
File diff suppressed because one or more lines are too long
+1
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "email_server_server_web_ui_templates_edit_dkim_page", "label": "Edit DKIM Selector Page", "file_type": "code", "source_file": "email_server/server_web_ui/templates/edit_dkim.html", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}], "edges": [{"source": "email_server_server_web_ui_templates_edit_dkim_page", "target": "email_server_server_web_ui_templates_base_page", "relation": "implements", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/edit_dkim.html", "source_location": null, "weight": 1.0}, {"source": "email_server_server_web_ui_templates_edit_dkim_page", "target": "email_server_server_web_ui_templates_dkim_page", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/edit_dkim.html", "source_location": null, "weight": 1.0}, {"source": "email_server_server_web_ui_templates_edit_dkim_page", "target": "email_server_server_web_ui_templates_dkim_dkim_key_model", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/edit_dkim.html", "source_location": null, "weight": 1.0}, {"source": "email_server_server_web_ui_templates_edit_dkim_page", "target": "email_server_server_web_ui_templates_domains_domain_model", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/edit_dkim.html", "source_location": null, "weight": 1.0}], "hyperedges": []}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "email_server_server_web_ui_templates_error_page", "label": "Error Page", "file_type": "code", "source_file": "email_server/server_web_ui/templates/error.html", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}], "edges": [{"source": "email_server_server_web_ui_templates_error_page", "target": "email_server_server_web_ui_templates_base_page", "relation": "implements", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/error.html", "source_location": null, "weight": 1.0}, {"source": "email_server_server_web_ui_templates_error_page", "target": "email_server_server_web_ui_templates_dashboard_page", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/error.html", "source_location": null, "weight": 1.0}], "hyperedges": []}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "tests_run_tests_manually_doc", "label": "Manual Test Setup Instructions", "file_type": "document", "source_file": "tests/run_tests_manually.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}, {"id": "tests_run_tests_manually_web_interface_setup", "label": "Web Interface Setup for Manual Testing", "file_type": "concept", "source_file": "tests/run_tests_manually.md", "source_location": "Setup via Web Interface", "source_url": null, "captured_at": null, "author": null, "contributor": null}, {"id": "tests_run_tests_manually_dkim_key_generation", "label": "DKIM Key Generation (manual test setup)", "file_type": "concept", "source_file": "tests/run_tests_manually.md", "source_location": "Setup via Web Interface", "source_url": null, "captured_at": null, "author": null, "contributor": null}, {"id": "tests_run_tests_manually_send_email_script", "label": "tests/send_email.py script (manual test usage)", "file_type": "concept", "source_file": "tests/run_tests_manually.md", "source_location": "Send emails using python script", "source_url": null, "captured_at": null, "author": null, "contributor": null}, {"id": "tests_run_tests_manually_swaks", "label": "swaks (SMTP test CLI tool, manual test usage)", "file_type": "concept", "source_file": "tests/run_tests_manually.md", "source_location": "Linux send emails using swaks", "source_url": null, "captured_at": null, "author": null, "contributor": null}, {"id": "tests_run_tests_manually_db_log_check", "label": "Check DB Logs (sqlite3 email_logs query)", "file_type": "concept", "source_file": "tests/run_tests_manually.md", "source_location": "Check db logs", "source_url": null, "captured_at": null, "author": null, "contributor": null}], "edges": [{"source": "tests_run_tests_manually_doc", "target": "tests_run_tests_manually_web_interface_setup", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "tests/run_tests_manually.md", "source_location": "Setup via Web Interface", "weight": 1.0}, {"source": "tests_run_tests_manually_doc", "target": "tests_run_tests_manually_dkim_key_generation", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "tests/run_tests_manually.md", "source_location": "Setup via Web Interface", "weight": 1.0}, {"source": "tests_run_tests_manually_doc", "target": "tests_run_tests_manually_send_email_script", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "tests/run_tests_manually.md", "source_location": "Send emails using python script", "weight": 1.0}, {"source": "tests_run_tests_manually_doc", "target": "tests_run_tests_manually_swaks", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "tests/run_tests_manually.md", "source_location": "Linux send emails using swaks", "weight": 1.0}, {"source": "tests_run_tests_manually_doc", "target": "tests_run_tests_manually_db_log_check", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "tests/run_tests_manually.md", "source_location": "Check db logs", "weight": 1.0}], "hyperedges": []}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "email_server_server_web_ui_templates_view_message_content_page", "label": "View Full Message Content Page", "file_type": "code", "source_file": "email_server/server_web_ui/templates/view_message_content.html", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}, {"id": "email_server_server_web_ui_templates_view_message_content_attachment_model", "label": "Email Attachment Data Model", "file_type": "concept", "source_file": "email_server/server_web_ui/templates/view_message_content.html", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}], "edges": [{"source": "email_server_server_web_ui_templates_view_message_content_page", "target": "email_server_server_web_ui_templates_base_page", "relation": "implements", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/view_message_content.html", "source_location": null, "weight": 1.0}, {"source": "email_server_server_web_ui_templates_view_message_content_page", "target": "email_server_server_web_ui_templates_logs_page", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/view_message_content.html", "source_location": null, "weight": 1.0}, {"source": "email_server_server_web_ui_templates_view_message_content_page", "target": "email_server_server_web_ui_templates_logs_email_log_model", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/view_message_content.html", "source_location": null, "weight": 1.0}, {"source": "email_server_server_web_ui_templates_view_message_content_page", "target": "email_server_server_web_ui_templates_view_message_content_attachment_model", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/view_message_content.html", "source_location": null, "weight": 1.0}], "hyperedges": []}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "tests_hello_dogecoin_meme_image", "label": "Hello.jpg (Dogecoin Doge Meme Image)", "file_type": "image", "source_file": "tests/Hello.jpg", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}, {"id": "tests_hello_email_attachment_test_fixture", "label": "Test Email Attachment Fixture", "file_type": "concept", "source_file": "tests/Hello.jpg", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}], "edges": [{"source": "tests_hello_dogecoin_meme_image", "target": "tests_hello_email_attachment_test_fixture", "relation": "conceptually_related_to", "confidence": "INFERRED", "confidence_score": 0.75, "source_file": "tests/Hello.jpg", "source_location": null, "weight": 1.0}], "hyperedges": []}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "email_server_server_web_ui_templates_edit_sender_page", "label": "Edit Sender Page", "file_type": "code", "source_file": "email_server/server_web_ui/templates/edit_sender.html", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}], "edges": [{"source": "email_server_server_web_ui_templates_edit_sender_page", "target": "email_server_server_web_ui_templates_base_page", "relation": "implements", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/edit_sender.html", "source_location": null, "weight": 1.0}, {"source": "email_server_server_web_ui_templates_edit_sender_page", "target": "email_server_server_web_ui_templates_senders_page", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/edit_sender.html", "source_location": null, "weight": 1.0}, {"source": "email_server_server_web_ui_templates_edit_sender_page", "target": "email_server_server_web_ui_templates_senders_sender_model", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/edit_sender.html", "source_location": null, "weight": 1.0}, {"source": "email_server_server_web_ui_templates_edit_sender_page", "target": "email_server_server_web_ui_templates_domains_domain_model", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/edit_sender.html", "source_location": null, "weight": 1.0}], "hyperedges": []}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "tests_note_authentication_order_fix_authentication_order_fix", "label": "SMTP Authentication Order Fix (June 2025)", "file_type": "rationale", "source_file": "tests/note_authentication_order_fix.md", "source_location": "Summary of Fixes", "source_url": null, "captured_at": null, "author": null, "contributor": null, "rationale": "Server previously hung the SMTP session on bad credentials. Fixed by having the authenticator return AuthResult(success=False, handled=False, message='535 Authentication failed') so aiosmtpd immediately relays the error to the client instead of the connection hanging; the server also no longer force-closes the connection after failed auth (lets the client retry/quit per SMTP protocol), and AUTH LOGIN/PLAIN plus IP-whitelist fallback are advertised on both the plain SMTP port and the TLS port. STARTTLS is deliberately not advertised since only direct TLS is desired. Rationale: correctness/UX (no hanging clients) and defense-in-depth (auth first, IP whitelist as fallback, full audit logging of every attempt)."}, {"id": "tests_note_authentication_order_fix_auth_call", "label": "AuthResult / email_server/auth.py __call__ (code snippet)", "file_type": "code", "source_file": "tests/note_authentication_order_fix.md", "source_location": "Code Snippet for Immediate Authentication Failure Response", "source_url": null, "captured_at": null, "author": null, "contributor": null}, {"id": "tests_note_authentication_order_fix_ip_whitelisting_fallback", "label": "IP Whitelisting as Secondary/Fallback Authentication", "file_type": "concept", "source_file": "tests/note_authentication_order_fix.md", "source_location": "Authentication Order and Logic", "source_url": null, "captured_at": null, "author": null, "contributor": null}, {"id": "tests_note_authentication_order_fix_auth_ports", "label": "AUTH Advertised on Both Plain SMTP and TLS Ports", "file_type": "concept", "source_file": "tests/note_authentication_order_fix.md", "source_location": "What Was Fixed", "source_url": null, "captured_at": null, "author": null, "contributor": null}, {"id": "tests_note_authentication_order_fix_no_starttls", "label": "STARTTLS Not Advertised (direct TLS only)", "file_type": "rationale", "source_file": "tests/note_authentication_order_fix.md", "source_location": "Best Practices for Future Development", "source_url": null, "captured_at": null, "author": null, "contributor": null, "rationale": "STARTTLS is deliberately not used/advertised on any port because the design intent is direct TLS only (a fixed TLS port) rather than opportunistic upgrade, simplifying the security model and avoiding STARTTLS downgrade-style ambiguity."}], "edges": [{"source": "tests_note_authentication_order_fix_authentication_order_fix", "target": "tests_note_authentication_order_fix_auth_call", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "tests/note_authentication_order_fix.md", "source_location": "Code Snippet for Immediate Authentication Failure Response", "weight": 1.0}, {"source": "tests_note_authentication_order_fix_authentication_order_fix", "target": "tests_note_authentication_order_fix_ip_whitelisting_fallback", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "tests/note_authentication_order_fix.md", "source_location": "Authentication Order and Logic", "weight": 1.0}, {"source": "tests_note_authentication_order_fix_authentication_order_fix", "target": "tests_note_authentication_order_fix_auth_ports", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "tests/note_authentication_order_fix.md", "source_location": "What Was Fixed / AUTH on Both Ports", "weight": 1.0}, {"source": "tests_note_authentication_order_fix_authentication_order_fix", "target": "tests_note_authentication_order_fix_no_starttls", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "tests/note_authentication_order_fix.md", "source_location": "Best Practices for Future Development", "weight": 1.0}], "hyperedges": [{"id": "smtp_auth_ip_whitelist_flow", "label": "SMTP Authentication & IP Whitelist Flow", "nodes": ["tests_note_authentication_order_fix_authentication_order_fix", "tests_note_authentication_order_fix_ip_whitelisting_fallback", "readme_unified_user_authentication", "readme_unified_ip_whitelisting"], "relation": "participate_in", "confidence": "INFERRED", "confidence_score": 0.85, "source_file": "tests/note_authentication_order_fix.md"}]}
|
||||
+1
File diff suppressed because one or more lines are too long
+1
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "email_server_server_web_ui_templates_edit_domain_page", "label": "Edit Domain Page", "file_type": "code", "source_file": "email_server/server_web_ui/templates/edit_domain.html", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}], "edges": [{"source": "email_server_server_web_ui_templates_edit_domain_page", "target": "email_server_server_web_ui_templates_base_page", "relation": "implements", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/edit_domain.html", "source_location": null, "weight": 1.0}, {"source": "email_server_server_web_ui_templates_edit_domain_page", "target": "email_server_server_web_ui_templates_sidebar_email_partial", "relation": "references", "confidence": "AMBIGUOUS", "confidence_score": 0.2, "source_file": "email_server/server_web_ui/templates/edit_domain.html", "source_location": null, "weight": 1.0}, {"source": "email_server_server_web_ui_templates_edit_domain_page", "target": "email_server_server_web_ui_templates_domains_page", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/edit_domain.html", "source_location": null, "weight": 1.0}, {"source": "email_server_server_web_ui_templates_edit_domain_page", "target": "email_server_server_web_ui_templates_domains_domain_model", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/edit_domain.html", "source_location": null, "weight": 1.0}], "hyperedges": []}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "email_server_server_web_ui_templates_dkim_page", "label": "DKIM Key Management Page", "file_type": "code", "source_file": "email_server/server_web_ui/templates/dkim.html", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}, {"id": "email_server_server_web_ui_templates_dkim_dkim_key_model", "label": "DKIM Key Data Model", "file_type": "concept", "source_file": "email_server/server_web_ui/templates/dkim.html", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}], "edges": [{"source": "email_server_server_web_ui_templates_dkim_page", "target": "email_server_server_web_ui_templates_base_page", "relation": "implements", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/dkim.html", "source_location": null, "weight": 1.0}, {"source": "email_server_server_web_ui_templates_dkim_page", "target": "email_server_server_web_ui_templates_edit_dkim_page", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/dkim.html", "source_location": null, "weight": 1.0}, {"source": "email_server_server_web_ui_templates_dkim_page", "target": "email_server_server_web_ui_templates_add_domain_page", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/dkim.html", "source_location": null, "weight": 1.0}, {"source": "email_server_server_web_ui_templates_dkim_page", "target": "email_server_server_web_ui_templates_dkim_dkim_key_model", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/dkim.html", "source_location": null, "weight": 1.0}, {"source": "email_server_server_web_ui_templates_dkim_page", "target": "email_server_server_web_ui_templates_domains_domain_model", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "email_server/server_web_ui/templates/dkim.html", "source_location": null, "weight": 1.0}], "hyperedges": [{"id": "dkim_dns_verification_flow", "label": "DKIM/SPF DNS Verification Flow", "nodes": ["email_server_server_web_ui_templates_dkim_page", "email_server_server_web_ui_templates_edit_dkim_page", "email_server_server_web_ui_templates_dkim_dkim_key_model", "email_server_server_web_ui_templates_domains_domain_model"], "relation": "participate_in", "confidence": "INFERRED", "confidence_score": 0.8, "source_file": "email_server/server_web_ui/templates/dkim.html"}]}
|
||||
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"runs": [
|
||||
{
|
||||
"date": "2026-08-12T04:16:18.090227+00:00",
|
||||
"input_tokens": 243577,
|
||||
"output_tokens": 0,
|
||||
"files": 58
|
||||
}
|
||||
],
|
||||
"total_input_tokens": 243577,
|
||||
"total_output_tokens": 0
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
+15027
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,292 @@
|
||||
{
|
||||
"app.py": {
|
||||
"mtime": 1786507445.420008,
|
||||
"ast_hash": "74aa21d6233b7e78d881f6d1510a8b33",
|
||||
"semantic_hash": "74aa21d6233b7e78d881f6d1510a8b33"
|
||||
},
|
||||
"email_server/__init__.py": {
|
||||
"mtime": 1786507445.420008,
|
||||
"ast_hash": "716fb9eecb7eb9fe7f975f2acaff5336",
|
||||
"semantic_hash": "716fb9eecb7eb9fe7f975f2acaff5336"
|
||||
},
|
||||
"email_server/auth.py": {
|
||||
"mtime": 1786507445.420218,
|
||||
"ast_hash": "a444385e425610fa8792651931aeefb6",
|
||||
"semantic_hash": "a444385e425610fa8792651931aeefb6"
|
||||
},
|
||||
"email_server/dkim_manager.py": {
|
||||
"mtime": 1786507445.420524,
|
||||
"ast_hash": "6b60aee77636c07a7791fc19943b79c7",
|
||||
"semantic_hash": "6b60aee77636c07a7791fc19943b79c7"
|
||||
},
|
||||
"email_server/email_relay.py": {
|
||||
"mtime": 1786507445.4208057,
|
||||
"ast_hash": "2104fd1a19cc11f2bda2cb810840c2e8",
|
||||
"semantic_hash": "2104fd1a19cc11f2bda2cb810840c2e8"
|
||||
},
|
||||
"email_server/models.py": {
|
||||
"mtime": 1786507445.421146,
|
||||
"ast_hash": "087a53f23d5d85aab276667e47926185",
|
||||
"semantic_hash": "087a53f23d5d85aab276667e47926185"
|
||||
},
|
||||
"email_server/server_runner.py": {
|
||||
"mtime": 1786507445.4213533,
|
||||
"ast_hash": "9b1b5a9097ee1203e5e6d62379d6e890",
|
||||
"semantic_hash": "9b1b5a9097ee1203e5e6d62379d6e890"
|
||||
},
|
||||
"email_server/server_web_ui/__init__.py": {
|
||||
"mtime": 1786507445.4213533,
|
||||
"ast_hash": "0a81840b8f81d3bed3c1508e8cbbeb78",
|
||||
"semantic_hash": "0a81840b8f81d3bed3c1508e8cbbeb78"
|
||||
},
|
||||
"email_server/server_web_ui/dashboard.py": {
|
||||
"mtime": 1786507445.4215565,
|
||||
"ast_hash": "999a07393e92b4f4dd613c04991b00f0",
|
||||
"semantic_hash": "999a07393e92b4f4dd613c04991b00f0"
|
||||
},
|
||||
"email_server/server_web_ui/database.py": {
|
||||
"mtime": 1786507445.4215565,
|
||||
"ast_hash": "4314ef255c59e8ba7317851c873c540b",
|
||||
"semantic_hash": "4314ef255c59e8ba7317851c873c540b"
|
||||
},
|
||||
"email_server/server_web_ui/dkim.py": {
|
||||
"mtime": 1786507445.4219456,
|
||||
"ast_hash": "8e6adb021d099b9168280318f998c583",
|
||||
"semantic_hash": "8e6adb021d099b9168280318f998c583"
|
||||
},
|
||||
"email_server/server_web_ui/domains.py": {
|
||||
"mtime": 1786507445.42228,
|
||||
"ast_hash": "1d7db380f0d4cbf85a3b2b42c6828fba",
|
||||
"semantic_hash": "1d7db380f0d4cbf85a3b2b42c6828fba"
|
||||
},
|
||||
"email_server/server_web_ui/ip_whitelist.py": {
|
||||
"mtime": 1786507445.4224377,
|
||||
"ast_hash": "7112f0220772e0090d461c19232822c0",
|
||||
"semantic_hash": "7112f0220772e0090d461c19232822c0"
|
||||
},
|
||||
"email_server/server_web_ui/logs.py": {
|
||||
"mtime": 1786507445.4228892,
|
||||
"ast_hash": "c3134f64de97480555e826eafc4016b3",
|
||||
"semantic_hash": "c3134f64de97480555e826eafc4016b3"
|
||||
},
|
||||
"email_server/server_web_ui/routes.py": {
|
||||
"mtime": 1786507445.4228892,
|
||||
"ast_hash": "85913d9a1142507b12e83a853452f0c1",
|
||||
"semantic_hash": "85913d9a1142507b12e83a853452f0c1"
|
||||
},
|
||||
"email_server/server_web_ui/senders.py": {
|
||||
"mtime": 1786507445.4231021,
|
||||
"ast_hash": "79872ba3cdaa85082f7a5afef0a2e081",
|
||||
"semantic_hash": "79872ba3cdaa85082f7a5afef0a2e081"
|
||||
},
|
||||
"email_server/server_web_ui/settings.py": {
|
||||
"mtime": 1786507445.4232998,
|
||||
"ast_hash": "9b8cf76d3df3835008eb2822099bf50e",
|
||||
"semantic_hash": "9b8cf76d3df3835008eb2822099bf50e"
|
||||
},
|
||||
"email_server/server_web_ui/static/js/dkim-management.js": {
|
||||
"mtime": 1786507445.4234388,
|
||||
"ast_hash": "9636e71baceeca4a7300e83cb7a92dbd",
|
||||
"semantic_hash": "9636e71baceeca4a7300e83cb7a92dbd"
|
||||
},
|
||||
"email_server/server_web_ui/static/js/smtp-management.js": {
|
||||
"mtime": 1786507445.4238064,
|
||||
"ast_hash": "5947686b5b1f277be78edc24a7149b9a",
|
||||
"semantic_hash": "5947686b5b1f277be78edc24a7149b9a"
|
||||
},
|
||||
"email_server/server_web_ui/utils.py": {
|
||||
"mtime": 1786507445.4270105,
|
||||
"ast_hash": "01d75f9cbc67f7889d38c2348deac329",
|
||||
"semantic_hash": "01d75f9cbc67f7889d38c2348deac329"
|
||||
},
|
||||
"email_server/server_web_ui/view_message.py": {
|
||||
"mtime": 1786507445.427105,
|
||||
"ast_hash": "772385cb7c5591d1880a30c251088de0",
|
||||
"semantic_hash": "772385cb7c5591d1880a30c251088de0"
|
||||
},
|
||||
"email_server/settings_loader.py": {
|
||||
"mtime": 1786507445.4271889,
|
||||
"ast_hash": "e94e850797497c2d623e48d7784fcc22",
|
||||
"semantic_hash": "e94e850797497c2d623e48d7784fcc22"
|
||||
},
|
||||
"email_server/smtp_handler.py": {
|
||||
"mtime": 1786507445.4275122,
|
||||
"ast_hash": "bf0a627e4cd31dc370a4985ac318ad94",
|
||||
"semantic_hash": "bf0a627e4cd31dc370a4985ac318ad94"
|
||||
},
|
||||
"email_server/tls_utils.py": {
|
||||
"mtime": 1786507445.4276311,
|
||||
"ast_hash": "930f99ecb58c7fb530eec8e26f17da20",
|
||||
"semantic_hash": "930f99ecb58c7fb530eec8e26f17da20"
|
||||
},
|
||||
"email_server/tool_box.py": {
|
||||
"mtime": 1786507445.427724,
|
||||
"ast_hash": "23bb9d388d999504eb5e457b79fadadf",
|
||||
"semantic_hash": "23bb9d388d999504eb5e457b79fadadf"
|
||||
},
|
||||
"migrations/add_email_attachments_table.sql": {
|
||||
"mtime": 1786507445.427724,
|
||||
"ast_hash": "e713c2bb34caf226587422dc02877ec6",
|
||||
"semantic_hash": "e713c2bb34caf226587422dc02877ec6"
|
||||
},
|
||||
"script_install_service.sh": {
|
||||
"mtime": 1786507445.4279666,
|
||||
"ast_hash": "e87676f5b71ee58d61a6835c19161d0a",
|
||||
"semantic_hash": "e87676f5b71ee58d61a6835c19161d0a"
|
||||
},
|
||||
"script_nginx_setup.sh": {
|
||||
"mtime": 1786507445.428081,
|
||||
"ast_hash": "cc3f0b0b094d065704b09860843e087c",
|
||||
"semantic_hash": "cc3f0b0b094d065704b09860843e087c"
|
||||
},
|
||||
"script_setup_py_environment.sh": {
|
||||
"mtime": 1786507445.428146,
|
||||
"ast_hash": "43485bc63fe9ff87484f705c9916b7b7",
|
||||
"semantic_hash": "43485bc63fe9ff87484f705c9916b7b7"
|
||||
},
|
||||
"tests/bash_send_email.sh": {
|
||||
"mtime": 1786507445.4288027,
|
||||
"ast_hash": "a8ca89c951cffdfadd24a5f046a52f6c",
|
||||
"semantic_hash": "a8ca89c951cffdfadd24a5f046a52f6c"
|
||||
},
|
||||
"tests/send_email.py": {
|
||||
"mtime": 1786507445.4310372,
|
||||
"ast_hash": "9af6cd850c485ef4adac8f632b5e985c",
|
||||
"semantic_hash": "9af6cd850c485ef4adac8f632b5e985c"
|
||||
},
|
||||
"README.md": {
|
||||
"mtime": 1786507445.4194906,
|
||||
"ast_hash": "8f3976f03b135442097a7acf9ebad587",
|
||||
"semantic_hash": "8f3976f03b135442097a7acf9ebad587"
|
||||
},
|
||||
"README_unified.md": {
|
||||
"mtime": 1786507445.419653,
|
||||
"ast_hash": "18cbf2d66ec27eac6ac683a7ec5a8623",
|
||||
"semantic_hash": "18cbf2d66ec27eac6ac683a7ec5a8623"
|
||||
},
|
||||
"email_server/server_web_ui/templates/add_domain.html": {
|
||||
"mtime": 1786507445.4238307,
|
||||
"ast_hash": "b3e488309b31eca000039cd2aedf32dc",
|
||||
"semantic_hash": "b3e488309b31eca000039cd2aedf32dc"
|
||||
},
|
||||
"email_server/server_web_ui/templates/add_ip.html": {
|
||||
"mtime": 1786507445.4246278,
|
||||
"ast_hash": "d71a779d57612f2776bc126c72acd289",
|
||||
"semantic_hash": "d71a779d57612f2776bc126c72acd289"
|
||||
},
|
||||
"email_server/server_web_ui/templates/add_sender.html": {
|
||||
"mtime": 1786507445.424706,
|
||||
"ast_hash": "fd557547eb245677ca9eec7572ce684e",
|
||||
"semantic_hash": "fd557547eb245677ca9eec7572ce684e"
|
||||
},
|
||||
"email_server/server_web_ui/templates/base.html": {
|
||||
"mtime": 1786507445.4248886,
|
||||
"ast_hash": "8708a86db306ce9653a0088a0c223768",
|
||||
"semantic_hash": "8708a86db306ce9653a0088a0c223768"
|
||||
},
|
||||
"email_server/server_web_ui/templates/dashboard.html": {
|
||||
"mtime": 1786507445.4253263,
|
||||
"ast_hash": "6afb31115c8ead5499b4494c693d00a8",
|
||||
"semantic_hash": "6afb31115c8ead5499b4494c693d00a8"
|
||||
},
|
||||
"email_server/server_web_ui/templates/dkim.html": {
|
||||
"mtime": 1786507445.4254458,
|
||||
"ast_hash": "071316e0c4a7b05c6769f1f95822e900",
|
||||
"semantic_hash": "071316e0c4a7b05c6769f1f95822e900"
|
||||
},
|
||||
"email_server/server_web_ui/templates/domains.html": {
|
||||
"mtime": 1786507445.4255998,
|
||||
"ast_hash": "4f18599d5b24accb1133e947ee991a47",
|
||||
"semantic_hash": "4f18599d5b24accb1133e947ee991a47"
|
||||
},
|
||||
"email_server/server_web_ui/templates/edit_dkim.html": {
|
||||
"mtime": 1786507445.4257405,
|
||||
"ast_hash": "e1af807e0b5d6cb4114a895f719390e7",
|
||||
"semantic_hash": "e1af807e0b5d6cb4114a895f719390e7"
|
||||
},
|
||||
"email_server/server_web_ui/templates/edit_domain.html": {
|
||||
"mtime": 1786507445.4257405,
|
||||
"ast_hash": "138451b7f2232bd8f043a9bd2dea44c5",
|
||||
"semantic_hash": "138451b7f2232bd8f043a9bd2dea44c5"
|
||||
},
|
||||
"email_server/server_web_ui/templates/edit_ip.html": {
|
||||
"mtime": 1786507445.4259565,
|
||||
"ast_hash": "8860ca3ae5914a21a36f877a1e7d93bd",
|
||||
"semantic_hash": "8860ca3ae5914a21a36f877a1e7d93bd"
|
||||
},
|
||||
"email_server/server_web_ui/templates/edit_sender.html": {
|
||||
"mtime": 1786507445.4260433,
|
||||
"ast_hash": "a455eba24fe2b83ee2fc7e0b1d898877",
|
||||
"semantic_hash": "a455eba24fe2b83ee2fc7e0b1d898877"
|
||||
},
|
||||
"email_server/server_web_ui/templates/error.html": {
|
||||
"mtime": 1786507445.4261494,
|
||||
"ast_hash": "1cd9c84b5e41292ddfc4f240eb44a3d4",
|
||||
"semantic_hash": "1cd9c84b5e41292ddfc4f240eb44a3d4"
|
||||
},
|
||||
"email_server/server_web_ui/templates/ips.html": {
|
||||
"mtime": 1786507445.4262903,
|
||||
"ast_hash": "c9c9f8a919efcc73562956d220188ed5",
|
||||
"semantic_hash": "c9c9f8a919efcc73562956d220188ed5"
|
||||
},
|
||||
"email_server/server_web_ui/templates/logs.html": {
|
||||
"mtime": 1786507445.4265242,
|
||||
"ast_hash": "99726a3e0e453b26183c58711024d532",
|
||||
"semantic_hash": "99726a3e0e453b26183c58711024d532"
|
||||
},
|
||||
"email_server/server_web_ui/templates/senders.html": {
|
||||
"mtime": 1786507445.4265242,
|
||||
"ast_hash": "18bfeee2ae2703d2b2e973a43e7e89ec",
|
||||
"semantic_hash": "18bfeee2ae2703d2b2e973a43e7e89ec"
|
||||
},
|
||||
"email_server/server_web_ui/templates/settings.html": {
|
||||
"mtime": 1786507445.426726,
|
||||
"ast_hash": "6e8399fa83764aa37068333f82ebd0d6",
|
||||
"semantic_hash": "6e8399fa83764aa37068333f82ebd0d6"
|
||||
},
|
||||
"email_server/server_web_ui/templates/sidebar_email.html": {
|
||||
"mtime": 1786507445.4268527,
|
||||
"ast_hash": "582b97df95b72a1b0623a84dd0d849ef",
|
||||
"semantic_hash": "582b97df95b72a1b0623a84dd0d849ef"
|
||||
},
|
||||
"email_server/server_web_ui/templates/view_message_content.html": {
|
||||
"mtime": 1786507445.4268527,
|
||||
"ast_hash": "aa18c525b8ca99d65e6f34ad0135843d",
|
||||
"semantic_hash": "aa18c525b8ca99d65e6f34ad0135843d"
|
||||
},
|
||||
"requirements.txt": {
|
||||
"mtime": 1786507445.427886,
|
||||
"ast_hash": "7d27d99d1d67f00c7a3fe65cbdc4742f",
|
||||
"semantic_hash": "7d27d99d1d67f00c7a3fe65cbdc4742f"
|
||||
},
|
||||
"tests/email_body.txt": {
|
||||
"mtime": 1786507445.4288692,
|
||||
"ast_hash": "28b2419589e713968c89e8f29b1f5da9",
|
||||
"semantic_hash": "28b2419589e713968c89e8f29b1f5da9"
|
||||
},
|
||||
"tests/general_cli_usage.md": {
|
||||
"mtime": 1786507445.4289312,
|
||||
"ast_hash": "910ba2ced2405bc17590f389cd70dfb9",
|
||||
"semantic_hash": "910ba2ced2405bc17590f389cd70dfb9"
|
||||
},
|
||||
"tests/note_authentication_order_fix.md": {
|
||||
"mtime": 1786507445.4290037,
|
||||
"ast_hash": "f34a0b1f012afd8431d89b2581cace24",
|
||||
"semantic_hash": "f34a0b1f012afd8431d89b2581cace24"
|
||||
},
|
||||
"tests/run_tests_manually.md": {
|
||||
"mtime": 1786507445.4308133,
|
||||
"ast_hash": "290cb5c01bc6f7382565f056322ff7d9",
|
||||
"semantic_hash": "290cb5c01bc6f7382565f056322ff7d9"
|
||||
},
|
||||
"tests/pdf_test_1.pdf": {
|
||||
"mtime": 1786507445.4308133,
|
||||
"ast_hash": "969aa7be407ae7b97df1aa6c362df7e6",
|
||||
"semantic_hash": "969aa7be407ae7b97df1aa6c362df7e6"
|
||||
},
|
||||
"tests/Hello.jpg": {
|
||||
"mtime": 1786507445.4286962,
|
||||
"ast_hash": "3e57187ec3810aaec71f0513744fe574",
|
||||
"semantic_hash": "3e57187ec3810aaec71f0513744fe574"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
// Package config loads and generates settings.ini, mirroring email_server/settings_loader.py.
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gopkg.in/ini.v1"
|
||||
)
|
||||
|
||||
// defaultKV is one key/value pair with the comment line Python renders above it.
|
||||
type defaultKV struct {
|
||||
Key string
|
||||
Value string
|
||||
Comment string
|
||||
}
|
||||
|
||||
// defaults mirrors settings_loader.py's DEFAULTS table section-by-section, in order.
|
||||
// The [Attachments] section does not exist in the Python defaults (a bug: it crashes
|
||||
// attachment storage there) — it is added here deliberately, per the approved plan.
|
||||
var defaults = []struct {
|
||||
Section string
|
||||
Keys []defaultKV
|
||||
}{
|
||||
{"Server", []defaultKV{
|
||||
{"", "", "Server configuration for SMTP ports and hostname"},
|
||||
{"", "", "Plain SMTP port for internal/whitelisted IPs"},
|
||||
{"SMTP_PORT", "4025", ""},
|
||||
{"", "", "TLS SMTP port for authenticated users"},
|
||||
{"SMTP_TLS_PORT", "40465", ""},
|
||||
{"", "", "Server hostname for HELO/EHLO identification"},
|
||||
{"HOSTNAME", "mail.example.com", ""},
|
||||
{"", "", "Override HELO hostname"},
|
||||
{"helo_hostname", "mail.example.com", ""},
|
||||
{"", "", `IP address to bind to (0.0.0.0 = all interfaces), on Windows must use specific IP`},
|
||||
{"BIND_IP", "0.0.0.0", ""},
|
||||
{"", "", `Custom server banner (to make it empty use "" must be double quotes)`},
|
||||
{"server_banner", "", ""},
|
||||
{"", "", "Time zone for the server"},
|
||||
{"TIME_ZONE", "Europe/London", ""},
|
||||
}},
|
||||
{"Database", []defaultKV{
|
||||
{"", "", "Database configuration"},
|
||||
{"DATABASE_URL", "sqlite:///server_data/smtp_server.db", ""},
|
||||
}},
|
||||
{"Logging", []defaultKV{
|
||||
{"", "", "Logging configuration"},
|
||||
{"", "", "Log level: DEBUG, INFO, WARNING, ERROR, CRITICAL"},
|
||||
{"LOG_LEVEL", "INFO", ""},
|
||||
{"", "", "Hide verbose aiosmtpd-equivalent INFO messages when LOG_LEVEL = INFO"},
|
||||
{"hide_info_aiosmtpd", "true", ""},
|
||||
}},
|
||||
{"Relay", []defaultKV{
|
||||
{"", "", "Timeout in seconds for external SMTP connections"},
|
||||
{"RELAY_TIMEOUT", "30", ""},
|
||||
}},
|
||||
{"TLS", []defaultKV{
|
||||
{"", "", "TLS/SSL certificate configuration"},
|
||||
{"TLS_CERT_FILE", "ssl_certs/server.crt", ""},
|
||||
{"TLS_KEY_FILE", "ssl_certs/server.key", ""},
|
||||
}},
|
||||
{"DKIM", []defaultKV{
|
||||
{"", "", "DKIM signing configuration"},
|
||||
{"", "", "RSA key size for DKIM keys (1024, 2048, 4096)"},
|
||||
{"DKIM_KEY_SIZE", "2048", ""},
|
||||
{"", "", "Provide Public IP address of server, used for SPF in case detection fails"},
|
||||
{"SPF_SERVER_IP", "192.168.1.1", ""},
|
||||
}},
|
||||
{"Attachments", []defaultKV{
|
||||
{"", "", "Directory where stored message attachments are written (fixed: missing in the Python defaults)"},
|
||||
{"attachments_path", "server_data/attachments", ""},
|
||||
}},
|
||||
{"Auth", []defaultKV{
|
||||
{"", "", "Admin dashboard login / passkey (WebAuthn) configuration"},
|
||||
{"", "", "Must match the domain the admin dashboard is actually accessed at — passkeys are bound to this"},
|
||||
{"rp_id", "localhost", ""},
|
||||
{"", "", "Display name shown in the authenticator/passkey prompt"},
|
||||
{"rp_display_name", "mailgoserver", ""},
|
||||
{"", "", `Full origin (scheme+host+port) the dashboard is served at, e.g. "https://mail.example.com"`},
|
||||
{"rp_origin", "http://localhost:5000", ""},
|
||||
}},
|
||||
}
|
||||
|
||||
// GenerateSettingsIni writes settings.ini with default values and comments if it does
|
||||
// not already exist. Mirrors settings_loader.generate_settings_ini: never overwrites or
|
||||
// merges into an existing file.
|
||||
func GenerateSettingsIni(path string) error {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cfg := ini.Empty()
|
||||
for _, sec := range defaults {
|
||||
section, err := cfg.NewSection(sec.Section)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, kv := range sec.Keys {
|
||||
if kv.Key == "" {
|
||||
// Comment-only line, e.g. a section header comment. No trailing
|
||||
// newline: ini.v1's writer splits Comment on "\n" and indexes
|
||||
// line[0] unconditionally, so a trailing separator produces an
|
||||
// empty final line and panics.
|
||||
if section.Comment != "" {
|
||||
section.Comment += "\n"
|
||||
}
|
||||
section.Comment += kv.Comment
|
||||
continue
|
||||
}
|
||||
key, err := section.NewKey(kv.Key, kv.Value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if kv.Comment != "" {
|
||||
key.Comment = kv.Comment
|
||||
}
|
||||
}
|
||||
}
|
||||
return cfg.SaveTo(path)
|
||||
}
|
||||
|
||||
// Load reads settings.ini at path, generating it with defaults first if missing.
|
||||
// Mirrors settings_loader.load_settings: always regenerate-if-missing, then read fresh.
|
||||
func Load(path string) (*ini.File, error) {
|
||||
if err := GenerateSettingsIni(path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ini.Load(path)
|
||||
}
|
||||
|
||||
// AbsoluteSQLitePath converts a "sqlite:///relative/path" database URL into an absolute
|
||||
// filesystem path resolved against root, mirroring app.py's _get_absolute_database_url.
|
||||
func AbsoluteSQLitePath(databaseURL, root string) string {
|
||||
const prefix = "sqlite:///"
|
||||
if len(databaseURL) < len(prefix) || databaseURL[:len(prefix)] != prefix {
|
||||
return databaseURL
|
||||
}
|
||||
rel := databaseURL[len(prefix):]
|
||||
if filepath.IsAbs(rel) {
|
||||
return rel
|
||||
}
|
||||
return filepath.Join(root, rel)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerateAndLoadRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "settings.ini")
|
||||
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load (generate): %v", err)
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("settings.ini was not written: %v", err)
|
||||
}
|
||||
if got := cfg.Section("Server").Key("SMTP_PORT").String(); got != "4025" {
|
||||
t.Errorf("SMTP_PORT = %q, want 4025", got)
|
||||
}
|
||||
if got := cfg.Section("Attachments").Key("attachments_path").String(); got == "" {
|
||||
t.Error("Attachments.attachments_path default is missing (the approved bug fix)")
|
||||
}
|
||||
|
||||
// Load again against the now-existing file — must not regenerate/overwrite.
|
||||
cfg2, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load (existing): %v", err)
|
||||
}
|
||||
if got := cfg2.Section("Server").Key("SMTP_PORT").String(); got != "4025" {
|
||||
t.Errorf("second Load: SMTP_PORT = %q, want 4025", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAbsoluteSQLitePath(t *testing.T) {
|
||||
cases := []struct{ url, root, want string }{
|
||||
{"sqlite:///server_data/db.sqlite", "/app", "/app/server_data/db.sqlite"},
|
||||
{"sqlite:////abs/db.sqlite", "/app", "/abs/db.sqlite"},
|
||||
{"mysql://x", "/app", "mysql://x"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := AbsoluteSQLitePath(c.url, c.root); got != c.want {
|
||||
t.Errorf("AbsoluteSQLitePath(%q, %q) = %q, want %q", c.url, c.root, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package db
|
||||
|
||||
import "time"
|
||||
|
||||
type AdminUser struct {
|
||||
ID int64
|
||||
Username string
|
||||
PasswordHash string
|
||||
MustChangePassword bool
|
||||
TOTPSecret string
|
||||
TOTPEnabled bool
|
||||
IsGlobalAdmin bool
|
||||
CreatedBy *int64
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type AdminSession struct {
|
||||
Token string
|
||||
UserID int64
|
||||
MFAVerified bool
|
||||
CreatedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type WebAuthnCredential struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
Name string
|
||||
CredentialID string
|
||||
CredentialData string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// GetDomainByNameExact looks up a domain by exact (case-sensitive) name, regardless of
|
||||
// is_active, mirroring the raw `filter_by(domain_name=domain_name)` query used inside
|
||||
// DKIMManager.generate_dkim_keypair (unlike get_domain_by_name, which is case-insensitive
|
||||
// and active-only).
|
||||
func (d *DB) GetDomainByNameExact(name string) (*Domain, error) {
|
||||
row := d.QueryRow(`SELECT `+domainColumns+` FROM esrv_domains WHERE domain_name = ?`, name)
|
||||
return scanDomain(row)
|
||||
}
|
||||
|
||||
// GetDomainByID looks up a domain by primary key, regardless of is_active — used by the
|
||||
// admin web UI's edit/delete/toggle actions, which operate on a specific row by id.
|
||||
func (d *DB) GetDomainByID(id int64) (*Domain, error) {
|
||||
row := d.QueryRow(`SELECT `+domainColumns+` FROM esrv_domains WHERE id = ?`, id)
|
||||
return scanDomain(row)
|
||||
}
|
||||
|
||||
func (d *DB) GetDKIMKeyByDomainAndSelector(domainID int64, selector string) (*DKIMKey, error) {
|
||||
row := d.QueryRow(`SELECT id, domain_id, selector, private_key, public_key, is_active, created_at, replaced_at
|
||||
FROM esrv_dkim_keys WHERE domain_id = ? AND selector = ?`, domainID, selector)
|
||||
return scanDKIMKey(row)
|
||||
}
|
||||
|
||||
func (d *DB) GetActiveDKIMKeyByDomainID(domainID int64) (*DKIMKey, error) {
|
||||
row := d.QueryRow(`SELECT id, domain_id, selector, private_key, public_key, is_active, created_at, replaced_at
|
||||
FROM esrv_dkim_keys WHERE domain_id = ? AND is_active = 1`, domainID)
|
||||
return scanDKIMKey(row)
|
||||
}
|
||||
|
||||
func (d *DB) GetDKIMKeyByID(id int64) (*DKIMKey, error) {
|
||||
row := d.QueryRow(`SELECT id, domain_id, selector, private_key, public_key, is_active, created_at, replaced_at
|
||||
FROM esrv_dkim_keys WHERE id = ?`, id)
|
||||
return scanDKIMKey(row)
|
||||
}
|
||||
|
||||
func scanDKIMKey(row *sql.Row) (*DKIMKey, error) {
|
||||
var k DKIMKey
|
||||
var createdAt string
|
||||
var replacedAt sql.NullString
|
||||
if err := row.Scan(&k.ID, &k.DomainID, &k.Selector, &k.PrivateKey, &k.PublicKey, &k.IsActive, &createdAt, &replacedAt); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
k.CreatedAt, _ = parseTime(createdAt)
|
||||
if replacedAt.Valid {
|
||||
t, _ := parseTime(replacedAt.String)
|
||||
k.ReplacedAt = &t
|
||||
}
|
||||
return &k, nil
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
const adminUserColumns = `id, username, password_hash, must_change_password, totp_secret, totp_enabled, is_global_admin, created_by, created_at`
|
||||
|
||||
func scanAdminUser(row *sql.Row) (*AdminUser, error) {
|
||||
var u AdminUser
|
||||
var createdAt string
|
||||
var createdBy sql.NullInt64
|
||||
if err := row.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.MustChangePassword, &u.TOTPSecret, &u.TOTPEnabled, &u.IsGlobalAdmin, &createdBy, &createdAt); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
u.CreatedAt, _ = parseTime(createdAt)
|
||||
if createdBy.Valid {
|
||||
u.CreatedBy = &createdBy.Int64
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
func (d *DB) CountAdminUsers() (int, error) {
|
||||
var n int
|
||||
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_admin_users`).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// DefaultAdminUsername/Password are the seeded first-run credentials — the admin is
|
||||
// forced to change both before they can use the rest of the dashboard (see
|
||||
// AdminUser.MustChangePassword and the login flow).
|
||||
const (
|
||||
DefaultAdminUsername = "admin"
|
||||
DefaultAdminPassword = "Password123!"
|
||||
)
|
||||
|
||||
// SeedDefaultAdminIfEmpty creates the default admin account on a brand-new install
|
||||
// (no admin users yet at all) with must_change_password set, so the default
|
||||
// credentials can never be left in place silently.
|
||||
func (d *DB) SeedDefaultAdminIfEmpty() error {
|
||||
n, err := d.CountAdminUsers()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n > 0 {
|
||||
return nil
|
||||
}
|
||||
hash, err := HashPassword(DefaultAdminPassword)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = d.CreateAdminUser(DefaultAdminUsername, hash, true)
|
||||
return err
|
||||
}
|
||||
|
||||
// CreateAdminUser inserts a new global-admin account (full access, no domain
|
||||
// restriction). mustChangePassword should be true for the seeded default account so
|
||||
// it can't keep running on default credentials.
|
||||
func (d *DB) CreateAdminUser(username, passwordHash string, mustChangePassword bool) (int64, error) {
|
||||
res, err := d.Exec(`INSERT INTO esrv_admin_users (username, password_hash, must_change_password, is_global_admin) VALUES (?, ?, ?, 1)`,
|
||||
username, passwordHash, mustChangePassword)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
// CreateScopedAdminUser inserts a new domain-scoped admin (delegated access), owned by
|
||||
// createdBy, and grants it access to exactly domainIDs — mirrors the delegation flow:
|
||||
// a scoped admin can create other scoped admins limited to domains within their own.
|
||||
func (d *DB) CreateScopedAdminUser(username, passwordHash string, createdBy int64, domainIDs []int64) (int64, error) {
|
||||
tx, err := d.Begin()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
res, err := tx.Exec(`INSERT INTO esrv_admin_users (username, password_hash, must_change_password, is_global_admin, created_by) VALUES (?, ?, 1, 0, ?)`,
|
||||
username, passwordHash, createdBy)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, domainID := range domainIDs {
|
||||
if _, err := tx.Exec(`INSERT INTO esrv_admin_domain_access (admin_user_id, domain_id) VALUES (?, ?)`, id, domainID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
return id, tx.Commit()
|
||||
}
|
||||
|
||||
// ListAllAdminUsers returns every admin account — for a global admin's user-management
|
||||
// view.
|
||||
func (d *DB) ListAllAdminUsers() ([]AdminUser, error) {
|
||||
rows, err := d.Query(`SELECT ` + adminUserColumns + ` FROM esrv_admin_users ORDER BY username`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return scanAdminUsers(rows)
|
||||
}
|
||||
|
||||
// ListScopedAdminUsers returns every non-global admin. Combined with AccessibleDomainIDs
|
||||
// per user, this lets the caller compute "which of these can I (a scoped admin)
|
||||
// manage" — the subset check happens in Go since the admin counts here are always
|
||||
// small (a handful of delegated accounts, not enterprise scale).
|
||||
func (d *DB) ListScopedAdminUsers() ([]AdminUser, error) {
|
||||
rows, err := d.Query(`SELECT ` + adminUserColumns + ` FROM esrv_admin_users WHERE is_global_admin = 0 ORDER BY username`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return scanAdminUsers(rows)
|
||||
}
|
||||
|
||||
func scanAdminUsers(rows *sql.Rows) ([]AdminUser, error) {
|
||||
defer rows.Close()
|
||||
var out []AdminUser
|
||||
for rows.Next() {
|
||||
var u AdminUser
|
||||
var createdAt string
|
||||
var createdBy sql.NullInt64
|
||||
if err := rows.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.MustChangePassword, &u.TOTPSecret, &u.TOTPEnabled, &u.IsGlobalAdmin, &createdBy, &createdAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.CreatedAt, _ = parseTime(createdAt)
|
||||
if createdBy.Valid {
|
||||
u.CreatedBy = &createdBy.Int64
|
||||
}
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// AccessibleDomainIDs returns the domains a scoped admin can see/manage. Meaningless
|
||||
// for a global admin (they can access everything regardless of this table).
|
||||
func (d *DB) AccessibleDomainIDs(userID int64) ([]int64, error) {
|
||||
rows, err := d.Query(`SELECT domain_id FROM esrv_admin_domain_access WHERE admin_user_id = ?`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []int64
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GrantDomainAccess mirrors auto-assigning a newly-created domain to the scoped admin
|
||||
// who created it.
|
||||
func (d *DB) GrantDomainAccess(userID, domainID int64) error {
|
||||
_, err := d.Exec(`INSERT OR IGNORE INTO esrv_admin_domain_access (admin_user_id, domain_id) VALUES (?, ?)`, userID, domainID)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetAdminDomainAccess replaces a scoped admin's entire domain assignment set.
|
||||
func (d *DB) SetAdminDomainAccess(userID int64, domainIDs []int64) error {
|
||||
tx, err := d.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.Exec(`DELETE FROM esrv_admin_domain_access WHERE admin_user_id = ?`, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, id := range domainIDs {
|
||||
if _, err := tx.Exec(`INSERT INTO esrv_admin_domain_access (admin_user_id, domain_id) VALUES (?, ?)`, userID, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// DeleteAdminUser removes an admin account and everything tied to it.
|
||||
func (d *DB) DeleteAdminUser(id int64) error {
|
||||
tx, err := d.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
for _, stmt := range []string{
|
||||
`DELETE FROM esrv_admin_domain_access WHERE admin_user_id = ?`,
|
||||
`DELETE FROM esrv_admin_sessions WHERE user_id = ?`,
|
||||
`DELETE FROM esrv_webauthn_credentials WHERE user_id = ?`,
|
||||
`DELETE FROM esrv_admin_users WHERE id = ?`,
|
||||
} {
|
||||
if _, err := tx.Exec(stmt, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (d *DB) GetAdminUserByUsername(username string) (*AdminUser, error) {
|
||||
row := d.QueryRow(`SELECT `+adminUserColumns+` FROM esrv_admin_users WHERE lower(username) = lower(?)`, username)
|
||||
return scanAdminUser(row)
|
||||
}
|
||||
|
||||
func (d *DB) GetAdminUserByID(id int64) (*AdminUser, error) {
|
||||
row := d.QueryRow(`SELECT `+adminUserColumns+` FROM esrv_admin_users WHERE id = ?`, id)
|
||||
return scanAdminUser(row)
|
||||
}
|
||||
|
||||
// UpdateAdminCredentials mirrors the forced first-login change: new username,
|
||||
// password hash, and clears must_change_password in one step.
|
||||
func (d *DB) UpdateAdminCredentials(id int64, username, passwordHash string) error {
|
||||
_, err := d.Exec(`UPDATE esrv_admin_users SET username = ?, password_hash = ?, must_change_password = 0 WHERE id = ?`, username, passwordHash, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) UpdateAdminPassword(id int64, passwordHash string) error {
|
||||
_, err := d.Exec(`UPDATE esrv_admin_users SET password_hash = ? WHERE id = ?`, passwordHash, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) SetAdminTOTPSecret(id int64, secret string, enabled bool) error {
|
||||
_, err := d.Exec(`UPDATE esrv_admin_users SET totp_secret = ?, totp_enabled = ? WHERE id = ?`, secret, enabled, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) DisableAdminTOTP(id int64) error {
|
||||
_, err := d.Exec(`UPDATE esrv_admin_users SET totp_secret = '', totp_enabled = 0 WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// --- Sessions ---
|
||||
|
||||
func newSessionToken() string {
|
||||
b := make([]byte, 32)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// CreateSession mirrors starting a new login session; mfaVerified should be true only
|
||||
// when the account has no second factor enabled (nothing left to verify) or the second
|
||||
// factor was just satisfied.
|
||||
func (d *DB) CreateSession(userID int64, mfaVerified bool, ttl time.Duration) (string, error) {
|
||||
token := newSessionToken()
|
||||
_, err := d.Exec(`INSERT INTO esrv_admin_sessions (token, user_id, mfa_verified, expires_at) VALUES (?, ?, ?, ?)`,
|
||||
token, userID, mfaVerified, time.Now().Add(ttl))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (d *DB) GetSession(token string) (*AdminSession, error) {
|
||||
row := d.QueryRow(`SELECT token, user_id, mfa_verified, created_at, expires_at FROM esrv_admin_sessions WHERE token = ?`, token)
|
||||
var s AdminSession
|
||||
var createdAt, expiresAt string
|
||||
if err := row.Scan(&s.Token, &s.UserID, &s.MFAVerified, &createdAt, &expiresAt); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
s.CreatedAt, _ = parseTime(createdAt)
|
||||
s.ExpiresAt, _ = parseTime(expiresAt)
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
func (d *DB) MarkSessionMFAVerified(token string) error {
|
||||
_, err := d.Exec(`UPDATE esrv_admin_sessions SET mfa_verified = 1 WHERE token = ?`, token)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) DeleteSession(token string) error {
|
||||
_, err := d.Exec(`DELETE FROM esrv_admin_sessions WHERE token = ?`, token)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteExpiredSessions is a lightweight best-effort sweep, called opportunistically
|
||||
// rather than on a schedule — this admin UI has at most a handful of sessions ever.
|
||||
func (d *DB) DeleteExpiredSessions() error {
|
||||
_, err := d.Exec(`DELETE FROM esrv_admin_sessions WHERE expires_at < ?`, time.Now())
|
||||
return err
|
||||
}
|
||||
|
||||
// --- WebAuthn credentials ---
|
||||
|
||||
func (d *DB) ListWebAuthnCredentials(userID int64) ([]WebAuthnCredential, error) {
|
||||
rows, err := d.Query(`SELECT id, user_id, name, credential_id, credential_data, created_at FROM esrv_webauthn_credentials WHERE user_id = ? ORDER BY created_at`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []WebAuthnCredential
|
||||
for rows.Next() {
|
||||
var c WebAuthnCredential
|
||||
var createdAt string
|
||||
if err := rows.Scan(&c.ID, &c.UserID, &c.Name, &c.CredentialID, &c.CredentialData, &createdAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.CreatedAt, _ = parseTime(createdAt)
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (d *DB) CreateWebAuthnCredential(userID int64, name, credentialID, credentialData string) error {
|
||||
_, err := d.Exec(`INSERT INTO esrv_webauthn_credentials (user_id, name, credential_id, credential_data) VALUES (?, ?, ?, ?)`,
|
||||
userID, name, credentialID, credentialData)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) DeleteWebAuthnCredential(id, userID int64) error {
|
||||
_, err := d.Exec(`DELETE FROM esrv_webauthn_credentials WHERE id = ? AND user_id = ?`, id, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) CountWebAuthnCredentials(userID int64) (int, error) {
|
||||
var n int
|
||||
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_webauthn_credentials WHERE user_id = ?`, userID).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package db
|
||||
|
||||
func (d *DB) CountSendersForDomain(domainID int64) (int, error) {
|
||||
var n int
|
||||
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_senders WHERE domain_id = ?`, domainID).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (d *DB) HasActiveDKIMForDomain(domainID int64) (bool, error) {
|
||||
var n int
|
||||
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_dkim_keys WHERE domain_id = ? AND is_active = 1`, domainID).Scan(&n)
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
func (d *DB) HasAnyDKIMForDomain(domainID int64) (bool, error) {
|
||||
var n int
|
||||
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_dkim_keys WHERE domain_id = ?`, domainID).Scan(&n)
|
||||
return n > 0, err
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package db
|
||||
|
||||
type DKIMKeyWithDomain struct {
|
||||
DKIMKey
|
||||
DomainName string
|
||||
}
|
||||
|
||||
func (d *DB) ListActiveDKIMKeysWithDomain() ([]DKIMKeyWithDomain, error) {
|
||||
rows, err := d.Query(`SELECT k.id, k.domain_id, k.selector, k.private_key, k.public_key, k.is_active, k.created_at, k.replaced_at, dm.domain_name
|
||||
FROM esrv_dkim_keys k JOIN esrv_domains dm ON dm.id = k.domain_id WHERE k.is_active = 1 ORDER BY dm.domain_name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanDKIMKeysWithDomain(rows)
|
||||
}
|
||||
|
||||
func (d *DB) ListInactiveDKIMKeysWithDomain() ([]DKIMKeyWithDomain, error) {
|
||||
rows, err := d.Query(`SELECT k.id, k.domain_id, k.selector, k.private_key, k.public_key, k.is_active, k.created_at, k.replaced_at, dm.domain_name
|
||||
FROM esrv_dkim_keys k JOIN esrv_domains dm ON dm.id = k.domain_id WHERE k.is_active = 0
|
||||
ORDER BY dm.domain_name, (k.replaced_at IS NULL), k.replaced_at DESC, k.created_at DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanDKIMKeysWithDomain(rows)
|
||||
}
|
||||
|
||||
func scanDKIMKeysWithDomain(rows interface {
|
||||
Next() bool
|
||||
Scan(...any) error
|
||||
Err() error
|
||||
}) ([]DKIMKeyWithDomain, error) {
|
||||
var out []DKIMKeyWithDomain
|
||||
for rows.Next() {
|
||||
var k DKIMKeyWithDomain
|
||||
var createdAt string
|
||||
var replacedAt *string
|
||||
if err := rows.Scan(&k.ID, &k.DomainID, &k.Selector, &k.PrivateKey, &k.PublicKey, &k.IsActive, &createdAt, &replacedAt, &k.DomainName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
k.CreatedAt, _ = parseTime(createdAt)
|
||||
if replacedAt != nil {
|
||||
t, _ := parseTime(*replacedAt)
|
||||
k.ReplacedAt = &t
|
||||
}
|
||||
out = append(out, k)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (d *DB) CountActiveDKIMKeys() (int, error) {
|
||||
var n int
|
||||
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_dkim_keys WHERE is_active = 1`).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (d *DB) DeactivateActiveDKIMKeysForDomain(domainID int64, replacedAt any) error {
|
||||
_, err := d.Exec(`UPDATE esrv_dkim_keys SET is_active = 0, replaced_at = ? WHERE domain_id = ? AND is_active = 1`, replacedAt, domainID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) SetDKIMKeyActive(id int64, active bool, replacedAt any) error {
|
||||
if active {
|
||||
_, err := d.Exec(`UPDATE esrv_dkim_keys SET is_active = 1, replaced_at = NULL WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
_, err := d.Exec(`UPDATE esrv_dkim_keys SET is_active = 0, replaced_at = ? WHERE id = ?`, replacedAt, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) UpdateDKIMKeySelector(id int64, selector string) error {
|
||||
_, err := d.Exec(`UPDATE esrv_dkim_keys SET selector = ? WHERE id = ?`, selector, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) SelectorExistsForDomain(domainID int64, selector string, excludeID int64) (bool, error) {
|
||||
var n int
|
||||
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_dkim_keys WHERE domain_id = ? AND selector = ? AND is_active = 1 AND id != ?`, domainID, selector, excludeID).Scan(&n)
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
func (d *DB) RemoveDKIMKey(id int64) error {
|
||||
_, err := d.Exec(`DELETE FROM esrv_dkim_keys WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (d *DB) ListDomains() ([]Domain, error) {
|
||||
rows, err := d.Query(`SELECT ` + domainColumns + ` FROM esrv_domains ORDER BY domain_name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Domain
|
||||
for rows.Next() {
|
||||
var dm Domain
|
||||
var createdAt string
|
||||
var verifiedAt *string
|
||||
if err := rows.Scan(&dm.ID, &dm.DomainName, &dm.IsActive, &createdAt, &dm.VerificationToken, &dm.IsVerified, &verifiedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dm.CreatedAt, _ = parseTime(createdAt)
|
||||
if verifiedAt != nil {
|
||||
t, _ := parseTime(*verifiedAt)
|
||||
dm.VerifiedAt = &t
|
||||
}
|
||||
out = append(out, dm)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ListActiveDomains mirrors the `domains` query used to populate <select> lists on the
|
||||
// add/edit sender and IP forms.
|
||||
func (d *DB) ListActiveDomains() ([]Domain, error) {
|
||||
all, err := d.ListDomains()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []Domain
|
||||
for _, dm := range all {
|
||||
if dm.IsActive {
|
||||
out = append(out, dm)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (d *DB) CountActiveDomains() (int, error) {
|
||||
var n int
|
||||
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_domains WHERE is_active = 1`).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// generateVerificationToken returns a random 32-hex-char token for the DNS TXT
|
||||
// ownership check, mirroring the randomness quality already used for DKIM selectors.
|
||||
func generateVerificationToken() string {
|
||||
b := make([]byte, 16)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// CreateDomain inserts a new, unverified domain with a freshly generated DNS
|
||||
// verification token.
|
||||
func (d *DB) CreateDomain(name string) (int64, error) {
|
||||
res, err := d.Exec(`INSERT INTO esrv_domains (domain_name, is_active, verification_token, is_verified) VALUES (?, 1, ?, 0)`,
|
||||
name, generateVerificationToken())
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
func (d *DB) UpdateDomain(id int64, name string, requiresAuth bool) error {
|
||||
_, err := d.Exec(`UPDATE esrv_domains SET domain_name = ? WHERE id = ?`, name, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) SetDomainActive(id int64, active bool) error {
|
||||
_, err := d.Exec(`UPDATE esrv_domains SET is_active = ? WHERE id = ?`, active, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetDomainVerified mirrors marking a domain as DNS-ownership-verified (or reverting
|
||||
// it, e.g. if an admin wants to force re-verification).
|
||||
func (d *DB) SetDomainVerified(id int64, verified bool) error {
|
||||
if verified {
|
||||
_, err := d.Exec(`UPDATE esrv_domains SET is_verified = 1, verified_at = ? WHERE id = ?`, time.Now(), id)
|
||||
return err
|
||||
}
|
||||
_, err := d.Exec(`UPDATE esrv_domains SET is_verified = 0, verified_at = NULL WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// RegenerateVerificationToken mirrors resetting a domain back to a fresh, unverified
|
||||
// token — used if an admin wants a new TXT value (e.g. suspected leak, or restarting
|
||||
// the ownership proof).
|
||||
func (d *DB) RegenerateVerificationToken(id int64) (string, error) {
|
||||
token := generateVerificationToken()
|
||||
_, err := d.Exec(`UPDATE esrv_domains SET verification_token = ?, is_verified = 0, verified_at = NULL WHERE id = ?`, token, id)
|
||||
return token, err
|
||||
}
|
||||
|
||||
// RemoveDomainCascade hard-deletes a domain and every row that references it, mirroring
|
||||
// domains.remove_domain. Returns counts removed for the flash message.
|
||||
func (d *DB) RemoveDomainCascade(id int64) (senders, ips, dkimKeys, headers int, err error) {
|
||||
tx, err := d.Begin()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
for table, count := range map[string]*int{
|
||||
"esrv_senders": &senders,
|
||||
"esrv_whitelisted_ips": &ips,
|
||||
"esrv_dkim_keys": &dkimKeys,
|
||||
"esrv_custom_headers": &headers,
|
||||
} {
|
||||
row := tx.QueryRow(`SELECT COUNT(*) FROM `+table+` WHERE domain_id = ?`, id)
|
||||
if err = row.Scan(count); err != nil {
|
||||
return
|
||||
}
|
||||
if _, err = tx.Exec(`DELETE FROM `+table+` WHERE domain_id = ?`, id); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
if _, err = tx.Exec(`DELETE FROM esrv_domains WHERE id = ?`, id); err != nil {
|
||||
return
|
||||
}
|
||||
err = tx.Commit()
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
)
|
||||
|
||||
type WhitelistedIPWithDomain struct {
|
||||
WhitelistedIP
|
||||
DomainName string
|
||||
}
|
||||
|
||||
func (d *DB) ListWhitelistedIPs() ([]WhitelistedIPWithDomain, error) {
|
||||
rows, err := d.Query(`SELECT w.id, w.ip_address, w.domain_id, w.is_active, w.created_at, w.store_message_content, dm.domain_name
|
||||
FROM esrv_whitelisted_ips w JOIN esrv_domains dm ON dm.id = w.domain_id ORDER BY w.ip_address`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []WhitelistedIPWithDomain
|
||||
for rows.Next() {
|
||||
var w WhitelistedIPWithDomain
|
||||
var createdAt string
|
||||
if err := rows.Scan(&w.ID, &w.IPAddress, &w.DomainID, &w.IsActive, &createdAt, &w.StoreMessageContent, &w.DomainName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
w.CreatedAt, _ = parseTime(createdAt)
|
||||
out = append(out, w)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (d *DB) GetWhitelistedIPByID(id int64) (*WhitelistedIP, error) {
|
||||
row := d.QueryRow(`SELECT id, ip_address, domain_id, is_active, created_at, store_message_content FROM esrv_whitelisted_ips WHERE id = ?`, id)
|
||||
var w WhitelistedIP
|
||||
var createdAt string
|
||||
if err := row.Scan(&w.ID, &w.IPAddress, &w.DomainID, &w.IsActive, &createdAt, &w.StoreMessageContent); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
w.CreatedAt, _ = parseTime(createdAt)
|
||||
return &w, nil
|
||||
}
|
||||
|
||||
func (d *DB) IPPairExists(ip string, domainID, excludeID int64) (bool, error) {
|
||||
var n int
|
||||
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_whitelisted_ips WHERE ip_address = ? AND domain_id = ? AND id != ?`, ip, domainID, excludeID).Scan(&n)
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
func (d *DB) CreateWhitelistedIP(ip string, domainID int64, storeMessageContent bool) (int64, error) {
|
||||
res, err := d.Exec(`INSERT INTO esrv_whitelisted_ips (ip_address, domain_id, is_active, store_message_content) VALUES (?, ?, 1, ?)`, ip, domainID, storeMessageContent)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
func (d *DB) UpdateWhitelistedIP(id int64, ip string, domainID int64, storeMessageContent bool) error {
|
||||
_, err := d.Exec(`UPDATE esrv_whitelisted_ips SET ip_address = ?, domain_id = ?, store_message_content = ? WHERE id = ?`, ip, domainID, storeMessageContent, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) SetWhitelistedIPActive(id int64, active bool) error {
|
||||
_, err := d.Exec(`UPDATE esrv_whitelisted_ips SET is_active = ? WHERE id = ?`, active, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) RemoveWhitelistedIP(id int64) error {
|
||||
_, err := d.Exec(`DELETE FROM esrv_whitelisted_ips WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
)
|
||||
|
||||
func (d *DB) GetEmailLogByID(id int64) (*EmailLog, error) {
|
||||
row := d.QueryRow(`SELECT id, message_id, timestamp, peer_ip, mail_from, to_address, cc_addresses, bcc_addresses, subject, email_headers, message_body, status, dkim_signed, username, created_at
|
||||
FROM esrv_email_logs WHERE id = ?`, id)
|
||||
return scanEmailLog(row)
|
||||
}
|
||||
|
||||
func scanEmailLog(row *sql.Row) (*EmailLog, error) {
|
||||
var l EmailLog
|
||||
var ts, createdAt string
|
||||
if err := row.Scan(&l.ID, &l.MessageID, &ts, &l.PeerIP, &l.MailFrom, &l.ToAddress, &l.CcAddresses, &l.BccAddresses, &l.Subject, &l.EmailHeaders, &l.MessageBody, &l.Status, &l.DKIMSigned, &l.Username, &createdAt); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
l.Timestamp, _ = parseTime(ts)
|
||||
l.CreatedAt, _ = parseTime(createdAt)
|
||||
return &l, nil
|
||||
}
|
||||
|
||||
func (d *DB) ListEmailLogsPage(offset, limit int) ([]EmailLog, error) {
|
||||
rows, err := d.Query(`SELECT id, message_id, timestamp, peer_ip, mail_from, to_address, cc_addresses, bcc_addresses, subject, email_headers, message_body, status, dkim_signed, username, created_at
|
||||
FROM esrv_email_logs ORDER BY created_at DESC LIMIT ? OFFSET ?`, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []EmailLog
|
||||
for rows.Next() {
|
||||
var l EmailLog
|
||||
var ts, createdAt string
|
||||
if err := rows.Scan(&l.ID, &l.MessageID, &ts, &l.PeerIP, &l.MailFrom, &l.ToAddress, &l.CcAddresses, &l.BccAddresses, &l.Subject, &l.EmailHeaders, &l.MessageBody, &l.Status, &l.DKIMSigned, &l.Username, &createdAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
l.Timestamp, _ = parseTime(ts)
|
||||
l.CreatedAt, _ = parseTime(createdAt)
|
||||
out = append(out, l)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (d *DB) ListAuthLogsPage(offset, limit int) ([]AuthLog, error) {
|
||||
rows, err := d.Query(`SELECT id, auth_type, identifier, ip_address, success, message, created_at FROM esrv_auth_logs ORDER BY created_at DESC LIMIT ? OFFSET ?`, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []AuthLog
|
||||
for rows.Next() {
|
||||
var a AuthLog
|
||||
var createdAt string
|
||||
if err := rows.Scan(&a.ID, &a.AuthType, &a.Identifier, &a.IPAddress, &a.Success, &a.Message, &createdAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.CreatedAt, _ = parseTime(createdAt)
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (d *DB) ListRecentAuthLogs(limit int) ([]AuthLog, error) {
|
||||
return d.ListAuthLogsPage(0, limit)
|
||||
}
|
||||
|
||||
func (d *DB) ListRecipientLogsForEmail(emailLogID int64) ([]EmailRecipientLog, error) {
|
||||
rows, err := d.Query(`SELECT id, email_log_id, recipient, recipient_type, status, error_code, error_message, server_response FROM esrv_email_recipient_logs WHERE email_log_id = ?`, emailLogID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []EmailRecipientLog
|
||||
for rows.Next() {
|
||||
var r EmailRecipientLog
|
||||
if err := rows.Scan(&r.ID, &r.EmailLogID, &r.Recipient, &r.RecipientType, &r.Status, &r.ErrorCode, &r.ErrorMessage, &r.ServerResponse); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (d *DB) ListAttachmentsForEmail(emailLogID int64) ([]EmailAttachment, error) {
|
||||
rows, err := d.Query(`SELECT id, email_log_id, filename, content_type, file_path, size, uploaded_at FROM esrv_email_attachments WHERE email_log_id = ?`, emailLogID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []EmailAttachment
|
||||
for rows.Next() {
|
||||
var a EmailAttachment
|
||||
var uploadedAt string
|
||||
if err := rows.Scan(&a.ID, &a.EmailLogID, &a.Filename, &a.ContentType, &a.FilePath, &a.Size, &uploadedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.UploadedAt, _ = parseTime(uploadedAt)
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (d *DB) GetAttachmentByID(id int64) (*EmailAttachment, error) {
|
||||
row := d.QueryRow(`SELECT id, email_log_id, filename, content_type, file_path, size, uploaded_at FROM esrv_email_attachments WHERE id = ?`, id)
|
||||
var a EmailAttachment
|
||||
var uploadedAt string
|
||||
if err := row.Scan(&a.ID, &a.EmailLogID, &a.Filename, &a.ContentType, &a.FilePath, &a.Size, &uploadedAt); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
a.UploadedAt, _ = parseTime(uploadedAt)
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
func (d *DB) RemoveAttachment(id int64) error {
|
||||
_, err := d.Exec(`DELETE FROM esrv_email_attachments WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// SenderWithDomain joins a Sender with its Domain's name, mirroring the
|
||||
// Sender+Domain join used by senders.py's list view.
|
||||
type SenderWithDomain struct {
|
||||
Sender
|
||||
DomainName string
|
||||
}
|
||||
|
||||
func (d *DB) ListSenders() ([]SenderWithDomain, error) {
|
||||
rows, err := d.Query(`SELECT s.id, s.email, s.password_hash, s.domain_id, s.can_send_as_domain, s.is_active, s.created_at, s.store_message_content, dm.domain_name
|
||||
FROM esrv_senders s JOIN esrv_domains dm ON dm.id = s.domain_id ORDER BY s.email`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []SenderWithDomain
|
||||
for rows.Next() {
|
||||
var s SenderWithDomain
|
||||
var createdAt string
|
||||
if err := rows.Scan(&s.ID, &s.Email, &s.PasswordHash, &s.DomainID, &s.CanSendAsDomain, &s.IsActive, &createdAt, &s.StoreMessageContent, &s.DomainName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.CreatedAt, _ = parseTime(createdAt)
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (d *DB) GetSenderByID(id int64) (*Sender, error) {
|
||||
row := d.QueryRow(`SELECT id, email, password_hash, domain_id, can_send_as_domain, is_active, created_at, store_message_content
|
||||
FROM esrv_senders WHERE id = ?`, id)
|
||||
var s Sender
|
||||
var createdAt string
|
||||
if err := row.Scan(&s.ID, &s.Email, &s.PasswordHash, &s.DomainID, &s.CanSendAsDomain, &s.IsActive, &createdAt, &s.StoreMessageContent); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
s.CreatedAt, _ = parseTime(createdAt)
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
func (d *DB) CountActiveSenders() (int, error) {
|
||||
var n int
|
||||
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_senders WHERE is_active = 1`).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (d *DB) EmailExists(email string, excludeID int64) (bool, error) {
|
||||
var n int
|
||||
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_senders WHERE lower(email) = lower(?) AND id != ?`, email, excludeID).Scan(&n)
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
func (d *DB) CreateSender(email, passwordHash string, domainID int64, canSendAsDomain, storeMessageContent bool) (int64, error) {
|
||||
res, err := d.Exec(`INSERT INTO esrv_senders (email, password_hash, domain_id, can_send_as_domain, is_active, store_message_content)
|
||||
VALUES (?, ?, ?, ?, 1, ?)`, email, passwordHash, domainID, canSendAsDomain, storeMessageContent)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
func (d *DB) UpdateSender(id int64, email, passwordHash string, domainID int64, canSendAsDomain, storeMessageContent bool) error {
|
||||
if passwordHash == "" {
|
||||
_, err := d.Exec(`UPDATE esrv_senders SET email = ?, domain_id = ?, can_send_as_domain = ?, store_message_content = ? WHERE id = ?`,
|
||||
email, domainID, canSendAsDomain, storeMessageContent, id)
|
||||
return err
|
||||
}
|
||||
_, err := d.Exec(`UPDATE esrv_senders SET email = ?, password_hash = ?, domain_id = ?, can_send_as_domain = ?, store_message_content = ? WHERE id = ?`,
|
||||
email, passwordHash, domainID, canSendAsDomain, storeMessageContent, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) SetSenderActive(id int64, active bool) error {
|
||||
_, err := d.Exec(`UPDATE esrv_senders SET is_active = ? WHERE id = ?`, active, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) RemoveSender(id int64) error {
|
||||
_, err := d.Exec(`DELETE FROM esrv_senders WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package db
|
||||
|
||||
import "time"
|
||||
|
||||
// InsertEmailLog mirrors the EmailLog row creation in EmailRelay.log_email. Returns the
|
||||
// new row's id (needed before recipient/attachment child rows can be inserted).
|
||||
func (d *DB) InsertEmailLog(l EmailLog) (int64, error) {
|
||||
res, err := d.Exec(`INSERT INTO esrv_email_logs
|
||||
(message_id, timestamp, peer_ip, mail_from, to_address, cc_addresses, bcc_addresses, subject, email_headers, message_body, status, dkim_signed, username)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
l.MessageID, l.Timestamp, l.PeerIP, l.MailFrom, l.ToAddress, l.CcAddresses, l.BccAddresses, l.Subject, l.EmailHeaders, l.MessageBody, l.Status, l.DKIMSigned, l.Username)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
// InsertEmailRecipientLog mirrors one EmailRecipientLog row creation.
|
||||
func (d *DB) InsertEmailRecipientLog(l EmailRecipientLog) error {
|
||||
_, err := d.Exec(`INSERT INTO esrv_email_recipient_logs
|
||||
(email_log_id, recipient, recipient_type, status, error_code, error_message, server_response)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
l.EmailLogID, l.Recipient, l.RecipientType, l.Status, l.ErrorCode, l.ErrorMessage, l.ServerResponse)
|
||||
return err
|
||||
}
|
||||
|
||||
// InsertEmailAttachment mirrors one EmailAttachment row creation.
|
||||
func (d *DB) InsertEmailAttachment(a EmailAttachment) error {
|
||||
_, err := d.Exec(`INSERT INTO esrv_email_attachments
|
||||
(email_log_id, filename, content_type, file_path, size, uploaded_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
a.EmailLogID, a.Filename, a.ContentType, a.FilePath, a.Size, time.Now())
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package db
|
||||
|
||||
import "time"
|
||||
|
||||
type Domain struct {
|
||||
ID int64
|
||||
DomainName string
|
||||
IsActive bool
|
||||
CreatedAt time.Time
|
||||
VerificationToken string
|
||||
IsVerified bool
|
||||
VerifiedAt *time.Time
|
||||
}
|
||||
|
||||
type Sender struct {
|
||||
ID int64
|
||||
Email string
|
||||
PasswordHash string
|
||||
DomainID int64
|
||||
CanSendAsDomain bool
|
||||
IsActive bool
|
||||
CreatedAt time.Time
|
||||
StoreMessageContent bool
|
||||
}
|
||||
|
||||
// CanSendAs mirrors Sender.can_send_as in models.py.
|
||||
func (s Sender) CanSendAs(fromAddress string) bool {
|
||||
if equalFold(fromAddress, s.Email) {
|
||||
return true
|
||||
}
|
||||
if !s.CanSendAsDomain {
|
||||
return false
|
||||
}
|
||||
senderDomain := domainPart(s.Email)
|
||||
fromDomain := domainPart(fromAddress)
|
||||
return senderDomain != "" && senderDomain == fromDomain
|
||||
}
|
||||
|
||||
type WhitelistedIP struct {
|
||||
ID int64
|
||||
IPAddress string
|
||||
DomainID int64
|
||||
IsActive bool
|
||||
CreatedAt time.Time
|
||||
StoreMessageContent bool
|
||||
}
|
||||
|
||||
type EmailLog struct {
|
||||
ID int64
|
||||
MessageID string
|
||||
Timestamp time.Time
|
||||
PeerIP string
|
||||
MailFrom string
|
||||
ToAddress string
|
||||
CcAddresses string
|
||||
BccAddresses string
|
||||
Subject string
|
||||
EmailHeaders string
|
||||
MessageBody string
|
||||
Status string
|
||||
DKIMSigned bool
|
||||
Username string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type EmailRecipientLog struct {
|
||||
ID int64
|
||||
EmailLogID int64
|
||||
Recipient string
|
||||
RecipientType string
|
||||
Status string
|
||||
ErrorCode string
|
||||
ErrorMessage string
|
||||
ServerResponse string
|
||||
}
|
||||
|
||||
type AuthLog struct {
|
||||
ID int64
|
||||
AuthType string
|
||||
Identifier string
|
||||
IPAddress string
|
||||
Success bool
|
||||
Message string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type DKIMKey struct {
|
||||
ID int64
|
||||
DomainID int64
|
||||
Selector string
|
||||
PrivateKey string
|
||||
PublicKey string
|
||||
IsActive bool
|
||||
CreatedAt time.Time
|
||||
ReplacedAt *time.Time
|
||||
}
|
||||
|
||||
type CustomHeader struct {
|
||||
ID int64
|
||||
DomainID int64
|
||||
HeaderName string
|
||||
HeaderValue string
|
||||
IsActive bool
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type EmailAttachment struct {
|
||||
ID int64
|
||||
EmailLogID int64
|
||||
Filename string
|
||||
ContentType string
|
||||
FilePath string
|
||||
Size int64
|
||||
UploadedAt time.Time
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func equalFold(a, b string) bool { return strings.EqualFold(a, b) }
|
||||
|
||||
func domainPart(address string) string {
|
||||
i := strings.LastIndex(address, "@")
|
||||
if i < 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(address[i+1:])
|
||||
}
|
||||
|
||||
// bcryptCost is pinned to 12 to match Python's bcrypt.gensalt() default, since Go's
|
||||
// bcrypt.DefaultCost is 10 and would otherwise silently produce weaker hashes.
|
||||
const bcryptCost = 12
|
||||
|
||||
// HashPassword mirrors models.hash_password.
|
||||
func HashPassword(password string) (string, error) {
|
||||
b, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// CheckPassword mirrors models.check_password.
|
||||
func CheckPassword(password, hash string) bool {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
|
||||
}
|
||||
|
||||
// GetSenderByEmail mirrors models.get_sender_by_email: case-insensitive match against
|
||||
// the lower-cased stored email, active senders only.
|
||||
func (d *DB) GetSenderByEmail(email string) (*Sender, error) {
|
||||
row := d.QueryRow(`SELECT id, email, password_hash, domain_id, can_send_as_domain, is_active, created_at, store_message_content
|
||||
FROM esrv_senders WHERE lower(email) = lower(?) AND is_active = 1`, email)
|
||||
var s Sender
|
||||
var createdAt string
|
||||
if err := row.Scan(&s.ID, &s.Email, &s.PasswordHash, &s.DomainID, &s.CanSendAsDomain, &s.IsActive, &createdAt, &s.StoreMessageContent); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
s.CreatedAt, _ = parseTime(createdAt)
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
const domainColumns = `id, domain_name, is_active, created_at, verification_token, is_verified, verified_at`
|
||||
|
||||
// scanDomain scans a row selected with domainColumns, in that order.
|
||||
func scanDomain(row *sql.Row) (*Domain, error) {
|
||||
var dom Domain
|
||||
var createdAt string
|
||||
var verifiedAt sql.NullString
|
||||
if err := row.Scan(&dom.ID, &dom.DomainName, &dom.IsActive, &createdAt, &dom.VerificationToken, &dom.IsVerified, &verifiedAt); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
dom.CreatedAt, _ = parseTime(createdAt)
|
||||
if verifiedAt.Valid {
|
||||
t, _ := parseTime(verifiedAt.String)
|
||||
dom.VerifiedAt = &t
|
||||
}
|
||||
return &dom, nil
|
||||
}
|
||||
|
||||
// GetDomainByName mirrors models.get_domain_by_name.
|
||||
func (d *DB) GetDomainByName(name string) (*Domain, error) {
|
||||
row := d.QueryRow(`SELECT `+domainColumns+` FROM esrv_domains
|
||||
WHERE lower(domain_name) = lower(?) AND is_active = 1`, name)
|
||||
return scanDomain(row)
|
||||
}
|
||||
|
||||
// GetWhitelistedIP mirrors models.get_whitelisted_ip. domainName == "" means no domain
|
||||
// filter, matching the Python default parameter.
|
||||
func (d *DB) GetWhitelistedIP(ipAddress, domainName string) (*WhitelistedIP, error) {
|
||||
var row *sql.Row
|
||||
if domainName == "" {
|
||||
row = d.QueryRow(`SELECT id, ip_address, domain_id, is_active, created_at, store_message_content
|
||||
FROM esrv_whitelisted_ips WHERE ip_address = ? AND is_active = 1`, ipAddress)
|
||||
} else {
|
||||
dom, err := d.GetDomainByName(domainName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if dom == nil {
|
||||
return nil, nil
|
||||
}
|
||||
row = d.QueryRow(`SELECT id, ip_address, domain_id, is_active, created_at, store_message_content
|
||||
FROM esrv_whitelisted_ips WHERE ip_address = ? AND is_active = 1 AND domain_id = ?`, ipAddress, dom.ID)
|
||||
}
|
||||
var w WhitelistedIP
|
||||
var createdAt string
|
||||
if err := row.Scan(&w.ID, &w.IPAddress, &w.DomainID, &w.IsActive, &createdAt, &w.StoreMessageContent); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
w.CreatedAt, _ = parseTime(createdAt)
|
||||
return &w, nil
|
||||
}
|
||||
|
||||
// CanSendForDomain mirrors WhitelistedIP.can_send_for_domain. Not called from the live
|
||||
// auth path (models.py's own equivalent isn't either) — kept for interface parity.
|
||||
func (w WhitelistedIP) CanSendForDomain(d *DB, domainName string) (bool, error) {
|
||||
if !w.IsActive {
|
||||
return false, nil
|
||||
}
|
||||
dom, err := d.GetDomainByName(domainName)
|
||||
if err != nil || dom == nil {
|
||||
return false, err
|
||||
}
|
||||
return w.DomainID == dom.ID, nil
|
||||
}
|
||||
|
||||
// LogAuthAttempt mirrors models.log_auth_attempt.
|
||||
func (d *DB) LogAuthAttempt(authType, identifier, ipAddress string, success bool, message string) error {
|
||||
_, err := d.Exec(`INSERT INTO esrv_auth_logs (auth_type, identifier, ip_address, success, message)
|
||||
VALUES (?, ?, ?, ?, ?)`, authType, identifier, ipAddress, success, message)
|
||||
return err
|
||||
}
|
||||
|
||||
func parseTime(s string) (time.Time, error) {
|
||||
for _, layout := range []string{"2006-01-02 15:04:05.999999999-07:00", "2006-01-02 15:04:05", time.RFC3339} {
|
||||
if t, err := time.Parse(layout, s); err == nil {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
return time.Time{}, errors.New("unparseable time: " + s)
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
// Package db is the SQLite data layer, mirroring email_server/models.py. It uses plain
|
||||
// database/sql + hand-written SQL rather than an ORM — the schema is small and fixed,
|
||||
// so an ORM would be an unrequested abstraction.
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// schema creates all esrv_* tables if missing. There is no migration framework here,
|
||||
// matching the Python precedent (its own migrations/ directory is a single manual SQL
|
||||
// patch file, never auto-applied) — CREATE TABLE IF NOT EXISTS covers the whole surface.
|
||||
const schema = `
|
||||
CREATE TABLE IF NOT EXISTS esrv_domains (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
domain_name TEXT NOT NULL UNIQUE,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
verification_token TEXT NOT NULL DEFAULT '',
|
||||
is_verified INTEGER NOT NULL DEFAULT 0,
|
||||
verified_at DATETIME
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS esrv_senders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
domain_id INTEGER NOT NULL REFERENCES esrv_domains(id),
|
||||
can_send_as_domain INTEGER NOT NULL DEFAULT 0,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
store_message_content INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS esrv_whitelisted_ips (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ip_address TEXT NOT NULL,
|
||||
domain_id INTEGER NOT NULL REFERENCES esrv_domains(id),
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
store_message_content INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS esrv_email_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
message_id TEXT NOT NULL UNIQUE,
|
||||
timestamp DATETIME NOT NULL,
|
||||
peer_ip TEXT NOT NULL,
|
||||
mail_from TEXT NOT NULL,
|
||||
to_address TEXT NOT NULL DEFAULT '',
|
||||
cc_addresses TEXT DEFAULT '',
|
||||
bcc_addresses TEXT DEFAULT '',
|
||||
subject TEXT,
|
||||
email_headers TEXT NOT NULL,
|
||||
message_body TEXT,
|
||||
status TEXT NOT NULL,
|
||||
dkim_signed INTEGER NOT NULL DEFAULT 0,
|
||||
username TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS esrv_email_recipient_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email_log_id INTEGER NOT NULL REFERENCES esrv_email_logs(id),
|
||||
recipient TEXT NOT NULL,
|
||||
recipient_type TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
error_code TEXT,
|
||||
error_message TEXT,
|
||||
server_response TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS esrv_auth_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
auth_type TEXT NOT NULL,
|
||||
identifier TEXT NOT NULL,
|
||||
ip_address TEXT,
|
||||
success INTEGER NOT NULL,
|
||||
message TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS esrv_dkim_keys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
domain_id INTEGER NOT NULL REFERENCES esrv_domains(id),
|
||||
selector TEXT NOT NULL DEFAULT 'default',
|
||||
private_key TEXT NOT NULL,
|
||||
public_key TEXT NOT NULL,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
replaced_at DATETIME
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS esrv_custom_headers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
domain_id INTEGER NOT NULL REFERENCES esrv_domains(id),
|
||||
header_name TEXT NOT NULL,
|
||||
header_value TEXT NOT NULL,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS esrv_email_attachments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email_log_id INTEGER NOT NULL REFERENCES esrv_email_logs(id),
|
||||
filename TEXT NOT NULL,
|
||||
content_type TEXT,
|
||||
file_path TEXT NOT NULL,
|
||||
size INTEGER,
|
||||
uploaded_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS esrv_admin_users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
must_change_password INTEGER NOT NULL DEFAULT 0,
|
||||
totp_secret TEXT NOT NULL DEFAULT '',
|
||||
totp_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
is_global_admin INTEGER NOT NULL DEFAULT 0,
|
||||
created_by INTEGER,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Which domains a non-global admin is allowed to see/manage. Global admins have no
|
||||
-- rows here at all — their access is implicit (AdminUser.IsGlobalAdmin).
|
||||
CREATE TABLE IF NOT EXISTS esrv_admin_domain_access (
|
||||
admin_user_id INTEGER NOT NULL REFERENCES esrv_admin_users(id),
|
||||
domain_id INTEGER NOT NULL REFERENCES esrv_domains(id),
|
||||
PRIMARY KEY (admin_user_id, domain_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS esrv_admin_sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES esrv_admin_users(id),
|
||||
mfa_verified INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at DATETIME NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS esrv_webauthn_credentials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES esrv_admin_users(id),
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
credential_id TEXT NOT NULL UNIQUE,
|
||||
credential_data TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`
|
||||
|
||||
// migrateAddedColumns best-effort ALTER TABLEs the columns added to esrv_domains
|
||||
// after its first release, for dev DBs created before this feature existed.
|
||||
// CREATE TABLE IF NOT EXISTS doesn't retrofit columns onto an existing table, and
|
||||
// there's no migration framework here (see the schema comment above) — errors are
|
||||
// ignored since SQLite has no "ADD COLUMN IF NOT EXISTS" and a duplicate-column
|
||||
// error just means the column is already there.
|
||||
func migrateAddedColumns(db *sql.DB) {
|
||||
stmts := []string{
|
||||
`ALTER TABLE esrv_domains ADD COLUMN verification_token TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE esrv_domains ADD COLUMN is_verified INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE esrv_domains ADD COLUMN verified_at DATETIME`,
|
||||
`ALTER TABLE esrv_admin_users ADD COLUMN is_global_admin INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE esrv_admin_users ADD COLUMN created_by INTEGER`,
|
||||
}
|
||||
for _, stmt := range stmts {
|
||||
db.Exec(stmt)
|
||||
}
|
||||
}
|
||||
|
||||
// DB wraps *sql.DB with the query helpers below.
|
||||
type DB struct {
|
||||
*sql.DB
|
||||
}
|
||||
|
||||
// Open opens (creating if needed) the SQLite file at path and ensures the schema exists.
|
||||
func Open(path string) (*DB, error) {
|
||||
sqlDB, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open sqlite: %w", err)
|
||||
}
|
||||
if _, err := sqlDB.Exec(schema); err != nil {
|
||||
sqlDB.Close()
|
||||
return nil, fmt.Errorf("create tables: %w", err)
|
||||
}
|
||||
migrateAddedColumns(sqlDB)
|
||||
return &DB{sqlDB}, nil
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
// Package dkim manages per-domain DKIM keys and signs outbound mail, mirroring
|
||||
// email_server/dkim_manager.py.
|
||||
package dkim
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
msgdkim "github.com/emersion/go-msgauth/dkim"
|
||||
"mailgoserver/internal/db"
|
||||
)
|
||||
|
||||
// FixedHeaders is the exact 8-header list DKIM signs over, in this fixed order,
|
||||
// mirroring dkim_manager.sign_email's `headers` list.
|
||||
var FixedHeaders = []string{
|
||||
"from", "to", "subject", "date", "message-id", "mime-version", "content-type", "content-transfer-encoding",
|
||||
}
|
||||
|
||||
const selectorChars = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
|
||||
// GenerateSelector mirrors DKIMManager._generate_random_selector(length=12).
|
||||
func GenerateSelector() string {
|
||||
b := make([]byte, 12)
|
||||
max := big.NewInt(int64(len(selectorChars)))
|
||||
for i := range b {
|
||||
n, _ := rand.Int(rand.Reader, max)
|
||||
b[i] = selectorChars[n.Int64()]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// Manager mirrors DKIMManager, keyed to a DB handle.
|
||||
type Manager struct {
|
||||
DB *db.DB
|
||||
KeySize int
|
||||
}
|
||||
|
||||
func New(database *db.DB, keySize int) *Manager {
|
||||
if keySize == 0 {
|
||||
keySize = 2048
|
||||
}
|
||||
return &Manager{DB: database, KeySize: keySize}
|
||||
}
|
||||
|
||||
// GenerateDKIMKeypair mirrors DKIMManager.generate_dkim_keypair. Returns false if the
|
||||
// domain doesn't exist (looked up by exact name, active or not — matching the Python
|
||||
// query, which has no is_active filter here).
|
||||
func (m *Manager) GenerateDKIMKeypair(domainName, selector string, forceNewKey bool) (bool, error) {
|
||||
dom, err := m.DB.GetDomainByNameExact(domainName)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if dom == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if _, err := m.DB.Exec(`UPDATE esrv_dkim_keys SET is_active = 0, replaced_at = ? WHERE domain_id = ? AND is_active = 1`, now, dom.ID); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if selector == "" {
|
||||
selector = GenerateSelector()
|
||||
}
|
||||
|
||||
if !forceNewKey {
|
||||
existing, err := m.DB.GetDKIMKeyByDomainAndSelector(dom.ID, selector)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if existing != nil {
|
||||
if _, err := m.DB.Exec(`UPDATE esrv_dkim_keys SET is_active = 1, replaced_at = NULL WHERE id = ?`, existing.ID); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
priv, err := rsa.GenerateKey(rand.Reader, m.KeySize)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
privPEM, pubPEM, err := encodeKeyPair(priv)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if _, err := m.DB.Exec(`INSERT INTO esrv_dkim_keys (domain_id, selector, private_key, public_key, is_active, created_at)
|
||||
VALUES (?, ?, ?, ?, 1, ?)`, dom.ID, selector, privPEM, pubPEM, now); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func encodeKeyPair(priv *rsa.PrivateKey) (privPEM, pubPEM string, err error) {
|
||||
privBytes, err := x509.MarshalPKCS8PrivateKey(priv)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
privPEM = string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privBytes}))
|
||||
|
||||
pubBytes, err := x509.MarshalPKIXPublicKey(&priv.PublicKey)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
pubPEM = string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubBytes}))
|
||||
return privPEM, pubPEM, nil
|
||||
}
|
||||
|
||||
// GetActiveDKIMKey mirrors DKIMManager.get_active_dkim_key.
|
||||
func (m *Manager) GetActiveDKIMKey(domainName string) (*db.DKIMKey, error) {
|
||||
dom, err := m.DB.GetDomainByName(domainName)
|
||||
if err != nil || dom == nil {
|
||||
return nil, err
|
||||
}
|
||||
return m.DB.GetActiveDKIMKeyByDomainID(dom.ID)
|
||||
}
|
||||
|
||||
// DNSRecord is the DNS TXT record for a domain's active DKIM key, mirroring
|
||||
// DKIMManager.get_dkim_public_key_record's return shape.
|
||||
type DNSRecord struct {
|
||||
Name string
|
||||
Type string
|
||||
Value string
|
||||
}
|
||||
|
||||
// GetDKIMPublicKeyRecord mirrors DKIMManager.get_dkim_public_key_record.
|
||||
func (m *Manager) GetDKIMPublicKeyRecord(domainName string) (*DNSRecord, error) {
|
||||
key, err := m.GetActiveDKIMKey(domainName)
|
||||
if err != nil || key == nil {
|
||||
return nil, err
|
||||
}
|
||||
raw := rawBase64FromPEM(key.PublicKey)
|
||||
return &DNSRecord{
|
||||
Name: fmt.Sprintf("%s._domainkey.%s", key.Selector, domainName),
|
||||
Type: "TXT",
|
||||
Value: fmt.Sprintf(`"v=DKIM1; k=rsa; p=%s"`, raw),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func rawBase64FromPEM(pemStr string) string {
|
||||
block, _ := pem.Decode([]byte(pemStr))
|
||||
if block == nil {
|
||||
return ""
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(block.Bytes)
|
||||
}
|
||||
|
||||
// Sign mirrors DKIMManager.sign_email: strips any existing DKIM-Signature header,
|
||||
// signs over the fixed 8-header list with relaxed/relaxed canonicalization, and
|
||||
// returns the original content unmodified on any failure (including "no active key").
|
||||
func (m *Manager) Sign(content, domainName string) string {
|
||||
key, err := m.GetActiveDKIMKey(domainName)
|
||||
if err != nil || key == nil {
|
||||
return content
|
||||
}
|
||||
block, _ := pem.Decode([]byte(key.PrivateKey))
|
||||
if block == nil {
|
||||
return content
|
||||
}
|
||||
privAny, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return content
|
||||
}
|
||||
priv, ok := privAny.(*rsa.PrivateKey)
|
||||
if !ok {
|
||||
return content
|
||||
}
|
||||
|
||||
stripped := stripExistingSignature(content)
|
||||
|
||||
var out strings.Builder
|
||||
err = msgdkim.Sign(&out, strings.NewReader(stripped), &msgdkim.SignOptions{
|
||||
Domain: domainName,
|
||||
Selector: key.Selector,
|
||||
Signer: priv,
|
||||
Hash: crypto.SHA256,
|
||||
HeaderCanonicalization: msgdkim.CanonicalizationRelaxed,
|
||||
BodyCanonicalization: msgdkim.CanonicalizationRelaxed,
|
||||
HeaderKeys: FixedHeaders,
|
||||
})
|
||||
if err != nil {
|
||||
return content
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
// stripExistingSignature removes a pre-existing DKIM-Signature header (including any
|
||||
// folded continuation lines), mirroring the regex in dkim_manager.sign_email.
|
||||
func stripExistingSignature(content string) string {
|
||||
lines := strings.Split(content, "\n")
|
||||
var out []string
|
||||
skipping := false
|
||||
for _, line := range lines {
|
||||
lower := strings.ToLower(line)
|
||||
if !skipping && strings.HasPrefix(lower, "dkim-signature:") {
|
||||
skipping = true
|
||||
continue
|
||||
}
|
||||
if skipping {
|
||||
if len(line) > 0 && (line[0] == ' ' || line[0] == '\t') {
|
||||
continue // folded continuation line
|
||||
}
|
||||
skipping = false
|
||||
}
|
||||
out = append(out, line)
|
||||
}
|
||||
return strings.Join(out, "\n")
|
||||
}
|
||||
|
||||
// GetActiveCustomHeaders mirrors DKIMManager.get_active_custom_headers.
|
||||
func (m *Manager) GetActiveCustomHeaders(domainName string) ([][2]string, error) {
|
||||
dom, err := m.DB.GetDomainByName(domainName)
|
||||
if err != nil || dom == nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := m.DB.Query(`SELECT header_name, header_value FROM esrv_custom_headers WHERE domain_id = ? AND is_active = 1`, dom.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out [][2]string
|
||||
for rows.Next() {
|
||||
var name, value string
|
||||
if err := rows.Scan(&name, &value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, [2]string{name, value})
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package dkim
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
msgdkim "github.com/emersion/go-msgauth/dkim"
|
||||
"mailgoserver/internal/db"
|
||||
)
|
||||
|
||||
func TestGenerateSignVerify(t *testing.T) {
|
||||
f, err := os.CreateTemp("", "dkim-test-*.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.Close()
|
||||
defer os.Remove(f.Name())
|
||||
|
||||
database, err := db.Open(f.Name())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
|
||||
if _, err := database.Exec(`INSERT INTO esrv_domains (domain_name, is_active) VALUES ('example.com', 1)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
mgr := New(database, 1024) // small key for test speed
|
||||
ok, err := mgr.GenerateDKIMKeypair("example.com", "sel1", false)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("GenerateDKIMKeypair: ok=%v err=%v", ok, err)
|
||||
}
|
||||
|
||||
rec, err := mgr.GetDKIMPublicKeyRecord("example.com")
|
||||
if err != nil || rec == nil {
|
||||
t.Fatalf("GetDKIMPublicKeyRecord: %v %v", rec, err)
|
||||
}
|
||||
if rec.Name != "sel1._domainkey.example.com" || rec.Type != "TXT" || !strings.HasPrefix(rec.Value, `"v=DKIM1; k=rsa; p=`) {
|
||||
t.Fatalf("unexpected DNS record: %+v", rec)
|
||||
}
|
||||
|
||||
msg := "From: sender@example.com\r\nTo: rcpt@example.org\r\nSubject: hi\r\nDate: Mon, 01 Jan 2024 00:00:00 +0000\r\nMessage-ID: <abc@example.com>\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\nContent-Transfer-Encoding: 7bit\r\n\r\nhello world\r\n"
|
||||
signed := mgr.Sign(msg, "example.com")
|
||||
if signed == msg {
|
||||
t.Fatal("Sign did not add a signature")
|
||||
}
|
||||
if !strings.HasPrefix(signed, "DKIM-Signature:") {
|
||||
t.Fatalf("expected DKIM-Signature as first header, got: %s", signed[:60])
|
||||
}
|
||||
|
||||
// Verify against the key we just generated instead of a live DNS lookup
|
||||
// (example.com has no real TXT record for our test selector).
|
||||
verifications, err := msgdkim.VerifyWithOptions(strings.NewReader(signed), &msgdkim.VerifyOptions{
|
||||
LookupTXT: func(domain string) ([]string, error) {
|
||||
if domain != rec.Name {
|
||||
t.Fatalf("unexpected TXT lookup domain: %s (want %s)", domain, rec.Name)
|
||||
}
|
||||
return []string{strings.Trim(rec.Value, `"`)}, nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Verify error: %v", err)
|
||||
}
|
||||
if len(verifications) != 1 {
|
||||
t.Fatalf("expected 1 verification, got %d", len(verifications))
|
||||
}
|
||||
if verifications[0].Err != nil {
|
||||
t.Fatalf("verification failed: %v", verifications[0].Err)
|
||||
}
|
||||
|
||||
// Re-signing must strip the old signature, not stack two.
|
||||
resigned := mgr.Sign(signed, "example.com")
|
||||
if strings.Count(resigned, "DKIM-Signature:") != 1 {
|
||||
t.Fatalf("expected exactly one DKIM-Signature after re-sign, got: %s", resigned)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package relay
|
||||
|
||||
import (
|
||||
"mailgoserver/internal/db"
|
||||
"mailgoserver/internal/toolbox"
|
||||
|
||||
"gopkg.in/ini.v1"
|
||||
)
|
||||
|
||||
// LogEmail mirrors EmailRelay.log_email: computes the overall status from per-recipient
|
||||
// results (relayed/partial/failed) and persists the EmailLog + EmailRecipientLog rows.
|
||||
func (r *Relay) LogEmail(cfg *ini.File, peerIP, mailFrom, toAddress, ccAddresses, bccAddresses, subject, emailHeaders, messageBody, messageID, username string, dkimSigned bool, results []Result) (int64, error) {
|
||||
overall := overallStatus(results)
|
||||
|
||||
logID, err := r.DB.InsertEmailLog(db.EmailLog{
|
||||
MessageID: messageID,
|
||||
Timestamp: toolbox.GetCurrentTime(cfg),
|
||||
PeerIP: peerIP,
|
||||
MailFrom: mailFrom,
|
||||
ToAddress: toAddress,
|
||||
CcAddresses: ccAddresses,
|
||||
BccAddresses: bccAddresses,
|
||||
Subject: subject,
|
||||
EmailHeaders: emailHeaders,
|
||||
MessageBody: messageBody,
|
||||
Status: overall,
|
||||
DKIMSigned: dkimSigned,
|
||||
Username: username,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
for _, res := range results {
|
||||
recipientType := res.RecipientType
|
||||
if recipientType == "" {
|
||||
recipientType = "to"
|
||||
}
|
||||
if err := r.DB.InsertEmailRecipientLog(db.EmailRecipientLog{
|
||||
EmailLogID: logID,
|
||||
Recipient: res.Recipient,
|
||||
RecipientType: recipientType,
|
||||
Status: res.Status,
|
||||
ErrorCode: res.ErrorCode,
|
||||
ErrorMessage: res.ErrorMessage,
|
||||
ServerResponse: res.ServerResponse,
|
||||
}); err != nil {
|
||||
r.Logger.Error("Failed to log recipient %s: %v", res.Recipient, err)
|
||||
}
|
||||
}
|
||||
|
||||
return logID, nil
|
||||
}
|
||||
|
||||
func overallStatus(results []Result) string {
|
||||
if len(results) == 0 {
|
||||
return "failed"
|
||||
}
|
||||
success, failed := 0, 0
|
||||
for _, res := range results {
|
||||
if res.Status == "success" {
|
||||
success++
|
||||
} else {
|
||||
failed++
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case success > 0 && failed > 0:
|
||||
return "partial"
|
||||
case success > 0:
|
||||
return "relayed"
|
||||
default:
|
||||
return "failed"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
// Package relay resolves MX records and delivers mail directly to recipient servers
|
||||
// (no smart-host relay), mirroring email_server/email_relay.py.
|
||||
package relay
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/ini.v1"
|
||||
"mailgoserver/internal/db"
|
||||
"mailgoserver/internal/toolbox"
|
||||
)
|
||||
|
||||
const mxPort = 25
|
||||
|
||||
// Result mirrors one entry of email_relay's per-recipient results list.
|
||||
type Result struct {
|
||||
Recipient string
|
||||
RecipientType string // "to" | "cc" | "bcc"
|
||||
Status string // "success" | "failed"
|
||||
ErrorCode string
|
||||
ErrorMessage string
|
||||
ServerResponse string
|
||||
}
|
||||
|
||||
type Relay struct {
|
||||
DB *db.DB
|
||||
Timeout time.Duration
|
||||
// Hostname is used as the outbound EHLO/HELO identity, mirroring
|
||||
// email_relay.py's self.hostname (helo_hostname, falling back to hostname).
|
||||
Hostname string
|
||||
Logger *toolbox.Logger
|
||||
}
|
||||
|
||||
// New builds a Relay from settings.ini. Unlike email_relay.py (which reads
|
||||
// relay_timeout from the wrong [Server] section and so always falls back to its
|
||||
// hardcoded default of 30s), this reads the value from [Relay] as the config file's
|
||||
// own comments say it should — the approved bug fix.
|
||||
func New(database *db.DB, cfg *ini.File, logger *toolbox.Logger) *Relay {
|
||||
timeoutSecs := cfg.Section("Relay").Key("RELAY_TIMEOUT").MustInt(30)
|
||||
hostname := cfg.Section("Server").Key("helo_hostname").String()
|
||||
if hostname == "" {
|
||||
hostname = cfg.Section("Server").Key("HOSTNAME").MustString("localhost")
|
||||
}
|
||||
return &Relay{DB: database, Timeout: time.Duration(timeoutSecs) * time.Second, Hostname: hostname, Logger: logger}
|
||||
}
|
||||
|
||||
// prepareEmailForRecipient mirrors email_relay._prepare_email_for_recipient: strips any
|
||||
// Bcc header line from the header block only, leaves the body untouched.
|
||||
func prepareEmailForRecipient(content string) string {
|
||||
idx := strings.Index(content, "\r\n\r\n")
|
||||
sep := "\r\n\r\n"
|
||||
if idx < 0 {
|
||||
idx = strings.Index(content, "\n\n")
|
||||
sep = "\n\n"
|
||||
if idx < 0 {
|
||||
idx = len(content)
|
||||
sep = "\r\n\r\n"
|
||||
}
|
||||
}
|
||||
headerBlock, body := content[:idx], content[idx+len(sep):]
|
||||
|
||||
var kept []string
|
||||
for _, line := range strings.Split(headerBlock, "\n") {
|
||||
trimmed := strings.TrimRight(line, "\r")
|
||||
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(trimmed)), "bcc:") {
|
||||
continue
|
||||
}
|
||||
kept = append(kept, trimmed)
|
||||
}
|
||||
return strings.Join(kept, "\r\n") + "\r\n\r\n" + body
|
||||
}
|
||||
|
||||
// RelayEmailAsync mirrors email_relay.relay_email_async: TO/CC recipients are grouped
|
||||
// by domain and delivered in one shared SMTP transaction per domain; each BCC recipient
|
||||
// gets its own transaction. MX hosts are tried once each, in preference order, with
|
||||
// opportunistic STARTTLS.
|
||||
func (r *Relay) RelayEmailAsync(mailFrom string, rcptTos []string, content string, recipientTypes []string) []Result {
|
||||
if len(recipientTypes) != len(rcptTos) {
|
||||
recipientTypes = make([]string, len(rcptTos))
|
||||
for i := range recipientTypes {
|
||||
recipientTypes[i] = "to"
|
||||
}
|
||||
}
|
||||
|
||||
type group struct{ to, cc []string }
|
||||
domainGroups := map[string]*group{}
|
||||
var bccList []string
|
||||
|
||||
for i, rcpt := range rcptTos {
|
||||
typ := recipientTypes[i]
|
||||
if typ == "bcc" {
|
||||
bccList = append(bccList, rcpt)
|
||||
continue
|
||||
}
|
||||
domain := domainOf(rcpt)
|
||||
g, ok := domainGroups[domain]
|
||||
if !ok {
|
||||
g = &group{}
|
||||
domainGroups[domain] = g
|
||||
}
|
||||
if typ == "cc" {
|
||||
g.cc = append(g.cc, rcpt)
|
||||
} else {
|
||||
g.to = append(g.to, rcpt)
|
||||
}
|
||||
}
|
||||
|
||||
var results []Result
|
||||
prepared := prepareEmailForRecipient(content)
|
||||
|
||||
for domain, g := range domainGroups {
|
||||
all := append(append([]string{}, g.to...), g.cc...)
|
||||
if len(all) == 0 {
|
||||
continue
|
||||
}
|
||||
status, serverResp, errCode, errMsg := r.deliverToDomain(domain, mailFrom, all, prepared)
|
||||
for _, rcpt := range g.to {
|
||||
results = append(results, Result{Recipient: rcpt, RecipientType: "to", Status: status, ErrorCode: errCode, ErrorMessage: errMsg, ServerResponse: serverResp})
|
||||
}
|
||||
for _, rcpt := range g.cc {
|
||||
results = append(results, Result{Recipient: rcpt, RecipientType: "cc", Status: status, ErrorCode: errCode, ErrorMessage: errMsg, ServerResponse: serverResp})
|
||||
}
|
||||
}
|
||||
|
||||
for _, bcc := range bccList {
|
||||
status, serverResp, errCode, errMsg := r.deliverToDomain(domainOf(bcc), mailFrom, []string{bcc}, prepared)
|
||||
results = append(results, Result{Recipient: bcc, RecipientType: "bcc", Status: status, ErrorCode: errCode, ErrorMessage: errMsg, ServerResponse: serverResp})
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func domainOf(address string) string {
|
||||
if i := strings.LastIndex(address, "@"); i >= 0 {
|
||||
return strings.ToLower(address[i+1:])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// deliverToDomain resolves MX hosts for domain and tries each in preference order once,
|
||||
// mirroring the MX-iteration loop in relay_email_async.
|
||||
func (r *Relay) deliverToDomain(domain, mailFrom string, rcpts []string, content string) (status, serverResponse, errorCode, errorMessage string) {
|
||||
mxRecords, err := net.LookupMX(domain)
|
||||
if err != nil || len(mxRecords) == 0 {
|
||||
return "failed", "", "MX", fmt.Sprintf("MX lookup failed for %s: %v", domain, err)
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for _, mx := range mxRecords {
|
||||
host := strings.TrimSuffix(mx.Host, ".")
|
||||
resp, err := r.trySend(host, mailFrom, rcpts, content)
|
||||
if err == nil {
|
||||
return "success", resp, "", ""
|
||||
}
|
||||
lastErr = err
|
||||
r.Logger.Warning("Relay to %s (%s) failed: %v", host, domain, err)
|
||||
}
|
||||
return "failed", "", "RELAY", fmt.Sprintf("%v", lastErr)
|
||||
}
|
||||
|
||||
func (r *Relay) trySend(host, mailFrom string, rcpts []string, content string) (string, error) {
|
||||
conn, err := net.DialTimeout("tcp", net.JoinHostPort(host, strconv.Itoa(mxPort)), r.Timeout)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
conn.SetDeadline(time.Now().Add(r.Timeout))
|
||||
defer conn.Close()
|
||||
|
||||
c, err := smtp.NewClient(conn, host)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
if err := c.Hello(r.Hostname); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Opportunistic STARTTLS: upgrade if offered, send in plaintext otherwise —
|
||||
// mirrors relay_email_async's "if starttls in extensions" check with no hard
|
||||
// requirement, and no strict certificate verification since arbitrary receiving
|
||||
// MTAs commonly present certs that don't chain cleanly (matches the Python code,
|
||||
// which never configures certificate verification for this opportunistic hop).
|
||||
if ok, _ := c.Extension("STARTTLS"); ok {
|
||||
tlsConfig := &tls.Config{ServerName: host, InsecureSkipVerify: true}
|
||||
if err := c.StartTLS(tlsConfig); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
if err := c.Mail(mailFrom); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, rcpt := range rcpts {
|
||||
if err := c.Rcpt(rcpt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
w, err := c.Data()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := w.Write([]byte(content)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
_ = c.Quit()
|
||||
return "250 OK", nil
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package smtpserver
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net/mail"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// attachmentStoragePath mirrors smtp_handler.get_attachment_storage_path:
|
||||
// {base}/{safe_domain}/{username_or_ip}/{YYYY-DD-MMM}/
|
||||
func attachmentStoragePath(base, domain, usernameOrIP string, now time.Time) string {
|
||||
safeDomain := sanitizePathSegment(domain, "/\\")
|
||||
dateFolder := now.Format("2006-02-Jan")
|
||||
parts := []string{base, safeDomain}
|
||||
if usernameOrIP != "" {
|
||||
parts = append(parts, usernameOrIP)
|
||||
}
|
||||
parts = append(parts, dateFolder)
|
||||
return filepath.Join(parts...)
|
||||
}
|
||||
|
||||
func sanitizePathSegment(s string, chars string) string {
|
||||
for _, c := range chars {
|
||||
s = strings.ReplaceAll(s, string(c), "_")
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// cleanMessageIDPrefix strips everything from "@" onward, mirroring the
|
||||
// clean_message_id computation used to build attachment filenames.
|
||||
func cleanMessageIDPrefix(messageID string) string {
|
||||
if i := strings.Index(messageID, "@"); i >= 0 {
|
||||
return messageID[:i]
|
||||
}
|
||||
return messageID
|
||||
}
|
||||
|
||||
type attachmentPart struct {
|
||||
Filename string
|
||||
ContentType string
|
||||
Data []byte
|
||||
}
|
||||
|
||||
type parsedMessage struct {
|
||||
HeaderLines []string // "Name: value" per header, in order
|
||||
BodyText string // concatenated text/* parts
|
||||
Attachments []attachmentPart
|
||||
}
|
||||
|
||||
// parseMessage mirrors the repeated BytesParser(policy=policy.default) passes in
|
||||
// handle_DATA: it extracts header lines for logging, concatenated text body, and any
|
||||
// attachment parts (Content-Disposition: attachment with a filename).
|
||||
func parseMessage(raw []byte) (*parsedMessage, error) {
|
||||
msg, err := mail.ReadMessage(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := &parsedMessage{}
|
||||
for k, vs := range msg.Header {
|
||||
for _, v := range vs {
|
||||
out.HeaderLines = append(out.HeaderLines, k+": "+v)
|
||||
}
|
||||
}
|
||||
|
||||
contentType := msg.Header.Get("Content-Type")
|
||||
mediaType, params, err := mime.ParseMediaType(contentType)
|
||||
if err != nil {
|
||||
mediaType = "text/plain"
|
||||
}
|
||||
|
||||
if strings.HasPrefix(mediaType, "multipart/") {
|
||||
mr := multipart.NewReader(msg.Body, params["boundary"])
|
||||
for {
|
||||
part, err := mr.NextPart()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
data, _ := io.ReadAll(part)
|
||||
disp, dispParams, _ := mime.ParseMediaType(part.Header.Get("Content-Disposition"))
|
||||
partCT := part.Header.Get("Content-Type")
|
||||
partMediaType, _, _ := mime.ParseMediaType(partCT)
|
||||
|
||||
if disp == "attachment" && dispParams["filename"] != "" {
|
||||
out.Attachments = append(out.Attachments, attachmentPart{
|
||||
Filename: dispParams["filename"],
|
||||
ContentType: getContentType(partMediaType, dispParams["filename"]),
|
||||
Data: data,
|
||||
})
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(partMediaType, "text/") && disp != "attachment" {
|
||||
out.BodyText += string(data) + "\n"
|
||||
}
|
||||
}
|
||||
} else if strings.HasPrefix(mediaType, "text/") {
|
||||
data, _ := io.ReadAll(msg.Body)
|
||||
out.BodyText = string(data)
|
||||
}
|
||||
out.BodyText = strings.TrimSpace(out.BodyText)
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package smtpserver
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-sasl"
|
||||
"github.com/emersion/go-smtp"
|
||||
"mailgoserver/internal/db"
|
||||
)
|
||||
|
||||
// loginServer implements the LOGIN SASL mechanism server-side (go-sasl only ships the
|
||||
// client half), mirroring the state machine aiosmtpd's built-in LOGIN handler drives:
|
||||
// ask for username (unless an initial response already supplied it), then password.
|
||||
type loginServer struct {
|
||||
state int // 0: need username, 1: need password
|
||||
username string
|
||||
verify func(username, password string) error
|
||||
}
|
||||
|
||||
func (s *loginServer) Next(response []byte) (challenge []byte, done bool, err error) {
|
||||
switch s.state {
|
||||
case 0:
|
||||
if response == nil {
|
||||
return []byte("Username:"), false, nil
|
||||
}
|
||||
s.username = string(response)
|
||||
s.state = 1
|
||||
return []byte("Password:"), false, nil
|
||||
case 1:
|
||||
password := string(response)
|
||||
if err := s.verify(s.username, password); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return nil, true, nil
|
||||
default:
|
||||
return nil, false, fmt.Errorf("unexpected LOGIN state")
|
||||
}
|
||||
}
|
||||
|
||||
// AuthMechanisms mirrors CustomSMTP._get_auth_methods's effective mechanism set
|
||||
// (aiosmtpd's default LOGIN/PLAIN) once auth is allowed at all — the TLS-required gate
|
||||
// itself is handled by go-smtp's own AllowInsecureAuth/isTLS check per listener.
|
||||
func (s *Session) AuthMechanisms() []string {
|
||||
return []string{sasl.Login, sasl.Plain}
|
||||
}
|
||||
|
||||
// Auth mirrors EnhancedCombinedAuthenticator.__call__ for the LOGIN/PLAIN case (the
|
||||
// only mechanisms advertised): credentials are always present by the time verify runs,
|
||||
// so the "no auth_data supplied" fallback branch in the Python version is unreachable
|
||||
// here and isn't replicated.
|
||||
func (s *Session) Auth(mech string) (sasl.Server, error) {
|
||||
switch mech {
|
||||
case sasl.Login:
|
||||
return &loginServer{verify: s.authenticate}, nil
|
||||
case sasl.Plain:
|
||||
return sasl.NewPlainServer(func(identity, username, password string) error {
|
||||
return s.authenticate(username, password)
|
||||
}), nil
|
||||
default:
|
||||
return nil, smtp.ErrAuthUnknownMechanism
|
||||
}
|
||||
}
|
||||
|
||||
// authenticate mirrors EnhancedAuthenticator.__call__: verifies credentials, logs an
|
||||
// AuthLog row either way, and on any failure returns a *smtp.SMTPError carrying the
|
||||
// exact Python response code/message, arming the connection to close right after that
|
||||
// response is flushed — mirroring CustomSMTP.smtp_AUTH's transport.close() override.
|
||||
func (s *Session) authenticate(username, password string) error {
|
||||
sender, err := s.backend.DB.GetSenderByEmail(username)
|
||||
if err != nil {
|
||||
s.backend.Logger.Error("Authentication error: %v", err)
|
||||
_ = s.backend.DB.LogAuthAttempt("sender", username, s.peerIP, false, fmt.Sprintf("Authentication error: %v", err))
|
||||
return s.failAuth(451, "Internal server error")
|
||||
}
|
||||
if sender == nil || !db.CheckPassword(password, sender.PasswordHash) {
|
||||
_ = s.backend.DB.LogAuthAttempt("sender", username, s.peerIP, false, fmt.Sprintf("Invalid credentials for %s", username))
|
||||
return s.failAuth(535, "Authentication failed")
|
||||
}
|
||||
|
||||
s.authenticatedSender = sender
|
||||
s.authType = "sender"
|
||||
s.username = username
|
||||
_ = s.backend.DB.LogAuthAttempt("sender", username, s.peerIP, true, "Successful sender authentication")
|
||||
return nil
|
||||
}
|
||||
|
||||
// failAuth builds the SMTPError for a failed AUTH attempt and closes the connection
|
||||
// shortly after go-smtp writes this response, mirroring CustomSMTP.smtp_AUTH's
|
||||
// transport.close() override. go-smtp writes the response synchronously right after
|
||||
// this error is returned, so a short delay comfortably outlasts that write without
|
||||
// needing to intercept the raw connection (which would break TLS detection on the
|
||||
// implicit-TLS listener — see server.go).
|
||||
func (s *Session) failAuth(code int, message string) error {
|
||||
conn := s.conn
|
||||
go func() {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
conn.Close()
|
||||
}()
|
||||
return &smtp.SMTPError{Code: code, EnhancedCode: smtp.NoEnhancedCode, Message: message}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package smtpserver
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"mime"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"mailgoserver/internal/toolbox"
|
||||
)
|
||||
|
||||
// extractMessageID scans the raw content's header block for an existing Message-ID
|
||||
// header and, if its hostname doesn't match heloHostname, rewrites it to use
|
||||
// heloHostname — mirroring the pre-scan in smtp_handler.handle_DATA. Unlike the Python
|
||||
// version, a missing "@" or missing header entirely is handled explicitly instead of
|
||||
// crashing (the approved bug fix), by falling back to a freshly generated Message-ID.
|
||||
func extractMessageID(content, heloHostname string) string {
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
line = strings.TrimRight(line, "\r")
|
||||
if line == "" {
|
||||
break // end of header block
|
||||
}
|
||||
lower := strings.ToLower(line)
|
||||
if !strings.HasPrefix(lower, "message-id:") {
|
||||
continue
|
||||
}
|
||||
value := strings.TrimSpace(line[len("message-id:"):])
|
||||
value = strings.Trim(value, "<>")
|
||||
at := strings.LastIndex(value, "@")
|
||||
if at < 0 {
|
||||
break // malformed header, no "@" — fall through to generating a fresh one
|
||||
}
|
||||
prefix, hostname := value[:at], value[at+1:]
|
||||
if !strings.EqualFold(hostname, heloHostname) {
|
||||
return fmt.Sprintf("%s@%s", prefix, heloHostname)
|
||||
}
|
||||
return value
|
||||
}
|
||||
return toolbox.GenerateMessageID(heloHostname)
|
||||
}
|
||||
|
||||
// existingHeaders parses the raw header block into a lowercase-keyed map of the first
|
||||
// value seen per header name, folding continuation lines, mirroring the case-insensitive
|
||||
// existing-header lookups in _ensure_required_headers.
|
||||
func existingHeaders(content string) map[string]string {
|
||||
lines := strings.Split(content, "\n")
|
||||
out := map[string]string{}
|
||||
var lastKey string
|
||||
for _, raw := range lines {
|
||||
line := strings.TrimRight(raw, "\r")
|
||||
if line == "" {
|
||||
break
|
||||
}
|
||||
if (strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t")) && lastKey != "" {
|
||||
out[lastKey] += " " + strings.TrimSpace(line)
|
||||
continue
|
||||
}
|
||||
idx := strings.Index(line, ":")
|
||||
if idx < 0 {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(strings.TrimSpace(line[:idx]))
|
||||
val := strings.TrimSpace(line[idx+1:])
|
||||
out[key] = val
|
||||
lastKey = key
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// splitHeadersBody separates the header block from the body on the first blank line,
|
||||
// accepting either CRLF or bare-LF line endings (source content is LF-only from the
|
||||
// SMTP DATA decode; ensureRequiredHeaders' own output is CRLF).
|
||||
func splitHeadersBody(content string) (headerBlock, body string) {
|
||||
if idx := strings.Index(content, "\r\n\r\n"); idx >= 0 {
|
||||
return content[:idx], content[idx+4:]
|
||||
}
|
||||
if idx := strings.Index(content, "\n\n"); idx >= 0 {
|
||||
return content[:idx], content[idx+2:]
|
||||
}
|
||||
return content, ""
|
||||
}
|
||||
|
||||
// ensureRequiredHeaders performs a full header-block *replacement* (not augmentation),
|
||||
// mirroring smtp_handler._ensure_required_headers exactly: a fixed, ordered whitelist
|
||||
// of headers is emitted, copying values from the original message where present and
|
||||
// defaulting where absent; anything outside that whitelist is dropped, then the
|
||||
// domain's custom headers plus X-Originating-IP/X-Mailer/X-Priority are appended (only
|
||||
// if not already present under the same name).
|
||||
func ensureRequiredHeaders(content, messageID string, envelopeRcptTos []string, mailFrom string, customHeaders [][2]string) string {
|
||||
headerBlock, body := splitHeadersBody(content)
|
||||
existing := existingHeaders(headerBlock)
|
||||
|
||||
var out []string
|
||||
out = append(out, "Message-ID: <"+messageID+">")
|
||||
|
||||
if v, ok := existing["date"]; ok {
|
||||
out = append(out, "Date: "+v)
|
||||
} else {
|
||||
out = append(out, "Date: "+time.Now().Format(time.RFC1123Z))
|
||||
}
|
||||
|
||||
if v, ok := existing["mime-version"]; ok {
|
||||
out = append(out, "MIME-Version: "+v)
|
||||
} else {
|
||||
out = append(out, "MIME-Version: 1.0")
|
||||
}
|
||||
|
||||
if v, ok := existing["to"]; ok {
|
||||
out = append(out, "To: "+v)
|
||||
} else {
|
||||
out = append(out, "To: "+strings.Join(envelopeRcptTos, ", "))
|
||||
}
|
||||
|
||||
if v, ok := existing["cc"]; ok {
|
||||
out = append(out, "Cc: "+v)
|
||||
}
|
||||
|
||||
if v, ok := existing["from"]; ok {
|
||||
out = append(out, "From: "+v)
|
||||
} else {
|
||||
out = append(out, "From: "+mailFrom)
|
||||
}
|
||||
|
||||
if v, ok := existing["subject"]; ok {
|
||||
out = append(out, "Subject: "+v)
|
||||
} else {
|
||||
out = append(out, "Subject: ")
|
||||
}
|
||||
|
||||
if v, ok := existing["content-type"]; ok {
|
||||
out = append(out, "Content-Type: "+v)
|
||||
} else {
|
||||
out = append(out, `Content-Type: text/plain; charset=UTF-8; format=flowed`)
|
||||
}
|
||||
|
||||
if v, ok := existing["content-transfer-encoding"]; ok {
|
||||
out = append(out, "Content-Transfer-Encoding: "+v)
|
||||
} else {
|
||||
out = append(out, "Content-Transfer-Encoding: 7bit")
|
||||
}
|
||||
|
||||
for _, kv := range customHeaders {
|
||||
if _, already := existing[strings.ToLower(kv[0])]; already {
|
||||
continue
|
||||
}
|
||||
out = append(out, kv[0]+": "+kv[1])
|
||||
}
|
||||
|
||||
return strings.Join(out, "\r\n") + "\r\n\r\n" + body
|
||||
}
|
||||
|
||||
// getContentType mirrors smtp_handler.get_content_type: prefer the part's own type,
|
||||
// fall back to extension sniffing, then a small fixed extension map.
|
||||
func getContentType(partContentType, filename string) string {
|
||||
if partContentType != "" && partContentType != "application/octet-stream" {
|
||||
return partContentType
|
||||
}
|
||||
if guessed := mime.TypeByExtension(extOf(filename)); guessed != "" {
|
||||
return guessed
|
||||
}
|
||||
switch strings.ToLower(extOf(filename)) {
|
||||
case ".txt":
|
||||
return "text/plain"
|
||||
case ".csv":
|
||||
return "text/csv"
|
||||
case ".jpg", ".jpeg":
|
||||
return "image/jpeg"
|
||||
case ".png":
|
||||
return "image/png"
|
||||
case ".gif":
|
||||
return "image/gif"
|
||||
case ".pdf":
|
||||
return "application/pdf"
|
||||
case ".json":
|
||||
return "application/json"
|
||||
case ".xml":
|
||||
return "application/xml"
|
||||
case ".html", ".htm":
|
||||
return "text/html"
|
||||
default:
|
||||
return "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
func extOf(filename string) string {
|
||||
if i := strings.LastIndex(filename, "."); i >= 0 {
|
||||
return filename[i:]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// parseAddressList mirrors the lowercase address parsing used to classify To/Cc/Bcc.
|
||||
func parseAddressList(headerValue string) []string {
|
||||
if strings.TrimSpace(headerValue) == "" {
|
||||
return nil
|
||||
}
|
||||
addrs, err := mail.ParseAddressList(headerValue)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, len(addrs))
|
||||
for i, a := range addrs {
|
||||
out[i] = strings.ToLower(a.Address)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package smtpserver
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEnsureRequiredHeadersFixedOrder(t *testing.T) {
|
||||
raw := "Subject: hi\r\nX-Custom: drop-me\r\n\r\nbody text"
|
||||
out := ensureRequiredHeaders(raw, "msg123@host", []string{"rcpt@example.com"}, "from@example.com", nil)
|
||||
|
||||
headerBlock, body := splitHeadersBody(out)
|
||||
var names []string
|
||||
for _, line := range strings.Split(headerBlock, "\r\n") {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
names = append(names, strings.SplitN(line, ":", 2)[0])
|
||||
}
|
||||
want := []string{"Message-ID", "Date", "MIME-Version", "To", "From", "Subject", "Content-Type", "Content-Transfer-Encoding"}
|
||||
if len(names) != len(want) {
|
||||
t.Fatalf("header names = %v, want %v", names, want)
|
||||
}
|
||||
for i := range want {
|
||||
if names[i] != want[i] {
|
||||
t.Errorf("header[%d] = %q, want %q", i, names[i], want[i])
|
||||
}
|
||||
}
|
||||
if strings.Contains(headerBlock, "X-Custom") {
|
||||
t.Error("unwhitelisted header X-Custom should have been dropped, not carried through")
|
||||
}
|
||||
if !strings.Contains(headerBlock, "To: rcpt@example.com") {
|
||||
t.Error("missing To header should be synthesized from envelope recipients")
|
||||
}
|
||||
if body != "body text" {
|
||||
t.Errorf("body = %q, want %q", body, "body text")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractMessageIDDoesNotCrashOnMalformedHeader(t *testing.T) {
|
||||
// No "@" in the Message-ID value — the fixed bug: Python's original crashes
|
||||
// here (UnboundLocalError); the Go port must fall back to a generated ID.
|
||||
raw := "Message-ID: not-an-id\r\nSubject: x\r\n\r\nbody"
|
||||
id := extractMessageID(raw, "mail.example.com")
|
||||
if id == "" {
|
||||
t.Fatal("expected a generated fallback Message-ID, got empty string")
|
||||
}
|
||||
if !strings.HasSuffix(id, "@mail.example.com") {
|
||||
t.Errorf("fallback Message-ID = %q, want suffix @mail.example.com", id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractMessageIDRehostsOnHostnameMismatch(t *testing.T) {
|
||||
raw := "Message-ID: <abc123@other-host.com>\r\nSubject: x\r\n\r\nbody"
|
||||
id := extractMessageID(raw, "mail.example.com")
|
||||
if id != "abc123@mail.example.com" {
|
||||
t.Errorf("id = %q, want rehosted to mail.example.com", id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractMessageIDKeepsMatchingHostname(t *testing.T) {
|
||||
raw := "Message-ID: <abc123@mail.example.com>\r\nSubject: x\r\n\r\nbody"
|
||||
id := extractMessageID(raw, "mail.example.com")
|
||||
if id != "abc123@mail.example.com" {
|
||||
t.Errorf("id = %q, want unchanged", id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package smtpserver
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-smtp"
|
||||
"gopkg.in/ini.v1"
|
||||
)
|
||||
|
||||
// ResolveBanner mirrors CustomSMTP's server_banner handling (the '""' literal-quotes
|
||||
// convention for "explicitly empty"). go-smtp's greeting is always
|
||||
// "220 <Domain> ESMTP Service Ready" with no hook to drop the " ESMTP Service Ready"
|
||||
// suffix the way aiosmtpd's raw __ident__ override can — so when no custom banner is
|
||||
// configured, this falls back to heloHostname (a normal, protocol-correct greeting)
|
||||
// rather than Python's degenerate literally-empty banner. This is a disclosed, cosmetic
|
||||
// interface deviation: no test tooling in this project inspects the SMTP banner text.
|
||||
func ResolveBanner(cfg *ini.File, heloHostname string) string {
|
||||
raw := cfg.Section("Server").Key("server_banner").String()
|
||||
if raw == `""` {
|
||||
raw = ""
|
||||
}
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return heloHostname
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// NewPlainServer mirrors server_runner.py's PlainController: no TLS context at all, so
|
||||
// STARTTLS is never offered, and AUTH is advertised and usable in plaintext
|
||||
// (auth_require_tls=False).
|
||||
func NewPlainServer(backend *Backend, addr, banner string) *smtp.Server {
|
||||
s := smtp.NewServer(backend)
|
||||
s.Addr = addr
|
||||
s.Domain = banner
|
||||
s.AllowInsecureAuth = true
|
||||
s.ReadTimeout = 5 * time.Minute
|
||||
s.WriteTimeout = 5 * time.Minute
|
||||
return s
|
||||
}
|
||||
|
||||
// NewTLSServer mirrors server_runner.py's TLSController: implicit/direct TLS (like
|
||||
// SMTPS on port 465) — the whole connection is encrypted from the first byte, not
|
||||
// STARTTLS-negotiated. Call ListenAndServeTLS (not ListenAndServe) to run it.
|
||||
func NewTLSServer(backend *Backend, addr, banner string, tlsConfig *tls.Config) *smtp.Server {
|
||||
s := smtp.NewServer(backend)
|
||||
s.Addr = addr
|
||||
s.Domain = banner
|
||||
s.TLSConfig = tlsConfig
|
||||
// The session is always already TLS on this listener, so AUTH is always allowed
|
||||
// either way (auth_require_tls=True in Python, which is trivially satisfied here).
|
||||
s.AllowInsecureAuth = true
|
||||
s.ReadTimeout = 5 * time.Minute
|
||||
s.WriteTimeout = 5 * time.Minute
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package smtpserver
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/smtp"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gopkg.in/ini.v1"
|
||||
"mailgoserver/internal/db"
|
||||
"mailgoserver/internal/dkim"
|
||||
"mailgoserver/internal/relay"
|
||||
"mailgoserver/internal/toolbox"
|
||||
)
|
||||
|
||||
func newTestBackend(t *testing.T) *Backend {
|
||||
t.Helper()
|
||||
f, err := os.CreateTemp("", "smtp-test-*.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.Close()
|
||||
t.Cleanup(func() { os.Remove(f.Name()) })
|
||||
|
||||
database, err := db.Open(f.Name())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { database.Close() })
|
||||
|
||||
if _, err := database.Exec(`INSERT INTO esrv_domains (domain_name, is_active, is_verified) VALUES ('example.com', 1, 1)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, err := db.HashPassword("testpass123")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := database.Exec(`INSERT INTO esrv_senders (email, password_hash, domain_id, is_active) VALUES (?, ?, 1, 1)`, "test@example.com", hash); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := database.Exec(`INSERT INTO esrv_whitelisted_ips (ip_address, domain_id, is_active) VALUES ('127.0.0.1', 1, 1)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg := ini.Empty()
|
||||
logger := toolbox.GetLogger("test")
|
||||
|
||||
return &Backend{
|
||||
DB: database,
|
||||
DKIM: dkim.New(database, 1024),
|
||||
Relay: relay.New(database, cfg, logger),
|
||||
Cfg: cfg,
|
||||
Logger: logger,
|
||||
HeloHostname: "mail.example.com",
|
||||
AttachmentsBasePath: t.TempDir(),
|
||||
}
|
||||
}
|
||||
|
||||
func startTestServer(t *testing.T, backend *Backend) string {
|
||||
t.Helper()
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := NewPlainServer(backend, l.Addr().String(), "mail.example.com")
|
||||
go srv.Serve(l)
|
||||
t.Cleanup(func() { srv.Close() })
|
||||
return l.Addr().String()
|
||||
}
|
||||
|
||||
func TestAuthSuccessAndSenderAuthorization(t *testing.T) {
|
||||
backend := newTestBackend(t)
|
||||
addr := startTestServer(t, backend)
|
||||
|
||||
c, err := smtp.Dial(addr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
if err := c.Auth(smtp.PlainAuth("", "test@example.com", "testpass123", "127.0.0.1")); err != nil {
|
||||
t.Fatalf("expected auth success, got: %v", err)
|
||||
}
|
||||
if err := c.Mail("test@example.com"); err != nil {
|
||||
t.Fatalf("expected MAIL FROM as own address to succeed, got: %v", err)
|
||||
}
|
||||
if err := c.Rcpt("someone@elsewhere.example"); err != nil {
|
||||
t.Fatalf("expected RCPT to accept any address, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthFailureClosesConnection(t *testing.T) {
|
||||
backend := newTestBackend(t)
|
||||
addr := startTestServer(t, backend)
|
||||
|
||||
c, err := smtp.Dial(addr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
err = c.Auth(smtp.PlainAuth("", "test@example.com", "wrongpassword", "127.0.0.1"))
|
||||
if err == nil {
|
||||
t.Fatal("expected auth failure")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "535") {
|
||||
t.Fatalf("expected 535 response, got: %v", err)
|
||||
}
|
||||
|
||||
// The server should close the connection shortly after — a subsequent command
|
||||
// must fail rather than succeed.
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
if err := c.Mail("test@example.com"); err == nil {
|
||||
t.Fatal("expected connection to have been closed after failed AUTH")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPWhitelistFallbackWithoutAuth(t *testing.T) {
|
||||
backend := newTestBackend(t)
|
||||
addr := startTestServer(t, backend)
|
||||
|
||||
c, err := smtp.Dial(addr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
// No AUTH at all: MAIL FROM a domain whitelisted for our (loopback) peer IP.
|
||||
if err := c.Mail("anyone@example.com"); err != nil {
|
||||
t.Fatalf("expected IP-whitelist fallback to authorize, got: %v", err)
|
||||
}
|
||||
if err := c.Rcpt("rcpt@elsewhere.example"); err != nil {
|
||||
t.Fatalf("expected RCPT to accept, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailFromRejectedForUnauthorizedDomain(t *testing.T) {
|
||||
backend := newTestBackend(t)
|
||||
addr := startTestServer(t, backend)
|
||||
|
||||
c, err := smtp.Dial(addr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
err = c.Mail("nobody@not-whitelisted.example")
|
||||
if err == nil {
|
||||
t.Fatal("expected MAIL FROM to be rejected for a non-whitelisted, non-authenticated domain")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "550") {
|
||||
t.Fatalf("expected 550 response, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnverifiedDomainCannotSend(t *testing.T) {
|
||||
backend := newTestBackend(t)
|
||||
|
||||
domainID, err := backend.DB.CreateDomain("unverified.example")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, _ := db.HashPassword("testpass123")
|
||||
if _, err := backend.DB.CreateSender("sender@unverified.example", hash, domainID, false, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
addr := startTestServer(t, backend)
|
||||
c, err := smtp.Dial(addr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
if err := c.Auth(smtp.PlainAuth("", "sender@unverified.example", "testpass123", "127.0.0.1")); err != nil {
|
||||
t.Fatalf("auth: %v", err)
|
||||
}
|
||||
err = c.Mail("sender@unverified.example")
|
||||
if err == nil {
|
||||
t.Fatal("expected MAIL FROM to be rejected for an unverified domain, even for an authenticated sender")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "550") || !strings.Contains(err.Error(), "verif") {
|
||||
t.Fatalf("expected a 550 mentioning verification, got: %v", err)
|
||||
}
|
||||
|
||||
// Now verify the domain directly (bypassing DNS) and confirm sending is unblocked.
|
||||
if err := backend.DB.SetDomainVerified(domainID, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.Mail("sender@unverified.example"); err != nil {
|
||||
t.Fatalf("expected MAIL FROM to succeed once domain is verified, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSenderCannotSpoofOtherAddress(t *testing.T) {
|
||||
backend := newTestBackend(t)
|
||||
addr := startTestServer(t, backend)
|
||||
|
||||
c, err := smtp.Dial(addr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
if err := c.Auth(smtp.PlainAuth("", "test@example.com", "testpass123", "127.0.0.1")); err != nil {
|
||||
t.Fatalf("auth: %v", err)
|
||||
}
|
||||
err = c.Mail("someoneelse@example.com")
|
||||
if err == nil {
|
||||
t.Fatal("expected MAIL FROM spoofing another address to be rejected (can_send_as_domain is false)")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "550") {
|
||||
t.Fatalf("expected 550 response, got: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
package smtpserver
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-smtp"
|
||||
"gopkg.in/ini.v1"
|
||||
"mailgoserver/internal/db"
|
||||
"mailgoserver/internal/dkim"
|
||||
"mailgoserver/internal/relay"
|
||||
"mailgoserver/internal/toolbox"
|
||||
)
|
||||
|
||||
// Backend holds the shared dependencies every connection's Session uses, mirroring the
|
||||
// constructor args threaded through smtp_handler.EnhancedCustomSMTPHandler /
|
||||
// email_server/server_runner.py.
|
||||
type Backend struct {
|
||||
DB *db.DB
|
||||
DKIM *dkim.Manager
|
||||
Relay *relay.Relay
|
||||
Cfg *ini.File
|
||||
Logger *toolbox.Logger
|
||||
HeloHostname string
|
||||
AttachmentsBasePath string
|
||||
}
|
||||
|
||||
func (b *Backend) NewSession(c *smtp.Conn) (smtp.Session, error) {
|
||||
host, _, _ := net.SplitHostPort(c.Conn().RemoteAddr().String())
|
||||
if host == "" {
|
||||
host = c.Conn().RemoteAddr().String()
|
||||
}
|
||||
return &Session{backend: b, conn: c, peerIP: host}, nil
|
||||
}
|
||||
|
||||
// Session implements smtp.Session + smtp.AuthSession for one SMTP connection, mirroring
|
||||
// EnhancedCustomSMTPHandler's per-connection behavior in smtp_handler.py.
|
||||
type Session struct {
|
||||
backend *Backend
|
||||
conn *smtp.Conn
|
||||
peerIP string
|
||||
|
||||
authenticatedSender *db.Sender
|
||||
authType string // "sender" | "ip" | ""
|
||||
authorizedDomain string
|
||||
username string
|
||||
|
||||
mailFrom string
|
||||
rcptTos []string
|
||||
}
|
||||
|
||||
func (s *Session) Reset() {
|
||||
s.mailFrom = ""
|
||||
s.rcptTos = nil
|
||||
}
|
||||
|
||||
func (s *Session) Logout() error { return nil }
|
||||
|
||||
// Mail mirrors EnhancedCustomSMTPHandler.handle_MAIL, delegating authorization to
|
||||
// validateSenderAuthorization (== auth.validate_sender_authorization).
|
||||
func (s *Session) Mail(from string, opts *smtp.MailOptions) error {
|
||||
ok, message := s.validateSenderAuthorization(from)
|
||||
if !ok {
|
||||
return &smtp.SMTPError{Code: 550, EnhancedCode: smtp.NoEnhancedCode, Message: message}
|
||||
}
|
||||
s.mailFrom = from
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateSenderAuthorization mirrors auth.validate_sender_authorization exactly,
|
||||
// including its two branches (already-authenticated sender vs. IP whitelist fallback)
|
||||
// and the AuthLog rows each path writes.
|
||||
func (s *Session) validateSenderAuthorization(mailFrom string) (bool, string) {
|
||||
if mailFrom == "" {
|
||||
return false, "No sender address provided"
|
||||
}
|
||||
fromDomain := domainOfAddr(mailFrom)
|
||||
if fromDomain == "" {
|
||||
return false, "Invalid sender address format"
|
||||
}
|
||||
|
||||
// A domain must have its DNS ownership TXT record verified before it can send —
|
||||
// otherwise anyone could add a domain they don't control and relay mail as it.
|
||||
dom, err := s.backend.DB.GetDomainByName(fromDomain)
|
||||
if err != nil {
|
||||
s.backend.Logger.Error("domain lookup failed: %v", err)
|
||||
return false, fmt.Sprintf("Not authorized to send for domain %s", fromDomain)
|
||||
}
|
||||
if dom == nil {
|
||||
return false, fmt.Sprintf("Domain %s is not configured on this server", fromDomain)
|
||||
}
|
||||
if !dom.IsVerified {
|
||||
return false, fmt.Sprintf("Domain %s has not completed DNS ownership verification yet", fromDomain)
|
||||
}
|
||||
|
||||
if s.authenticatedSender != nil {
|
||||
sender := s.authenticatedSender
|
||||
if sender.CanSendAs(mailFrom) {
|
||||
return true, fmt.Sprintf("Sender authorized to send as %s", mailFrom)
|
||||
}
|
||||
_ = s.backend.DB.LogAuthAttempt("sender_validation", fmt.Sprintf("%s -> %s", sender.Email, mailFrom), s.peerIP, false, "")
|
||||
return false, fmt.Sprintf("Sender %s not authorized to send as %s", sender.Email, mailFrom)
|
||||
}
|
||||
|
||||
wl, err := s.backend.DB.GetWhitelistedIP(s.peerIP, fromDomain)
|
||||
if err != nil {
|
||||
s.backend.Logger.Error("IP authorization lookup failed: %v", err)
|
||||
return false, fmt.Sprintf("Not authorized to send for domain %s", fromDomain)
|
||||
}
|
||||
if wl != nil {
|
||||
s.authType = "ip"
|
||||
s.authorizedDomain = fromDomain
|
||||
s.username = "IP:" + s.peerIP
|
||||
_ = s.backend.DB.LogAuthAttempt("ip", fmt.Sprintf("%s -> %s", s.peerIP, fromDomain), s.peerIP, true, fmt.Sprintf("IP %s authorized for domain %s", s.peerIP, fromDomain))
|
||||
return true, fmt.Sprintf("IP authorized for domain %s", fromDomain)
|
||||
}
|
||||
_ = s.backend.DB.LogAuthAttempt("ip", fmt.Sprintf("%s -> %s", s.peerIP, fromDomain), s.peerIP, false, fmt.Sprintf("IP %s not authorized for domain %s", s.peerIP, fromDomain))
|
||||
return false, fmt.Sprintf("Not authorized to send for domain %s", fromDomain)
|
||||
}
|
||||
|
||||
func domainOfAddr(address string) string {
|
||||
i := strings.LastIndex(address, "@")
|
||||
if i < 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(address[i+1:])
|
||||
}
|
||||
|
||||
// Rcpt mirrors handle_RCPT: accepts any address, no validation.
|
||||
func (s *Session) Rcpt(to string, opts *smtp.RcptOptions) error {
|
||||
s.rcptTos = append(s.rcptTos, to)
|
||||
return nil
|
||||
}
|
||||
|
||||
func internalError(msg string) error {
|
||||
return &smtp.SMTPError{Code: 550, EnhancedCode: smtp.NoEnhancedCode, Message: msg}
|
||||
}
|
||||
|
||||
// Data mirrors EnhancedCustomSMTPHandler.handle_DATA end to end: Message-ID
|
||||
// extraction/rehost, full header rebuild, DKIM signing, attachment extraction/storage,
|
||||
// relay delivery, and EmailLog/EmailRecipientLog/EmailAttachment persistence.
|
||||
func (s *Session) Data(r io.Reader) error {
|
||||
raw, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return internalError("Internal server error")
|
||||
}
|
||||
content := string(raw)
|
||||
|
||||
messageID := extractMessageID(content, s.backend.HeloHostname)
|
||||
senderDomain := domainOfAddr(s.mailFrom)
|
||||
|
||||
var customHeaders [][2]string
|
||||
if senderDomain != "" {
|
||||
customHeaders, _ = s.backend.DKIM.GetActiveCustomHeaders(senderDomain)
|
||||
}
|
||||
customHeaders = append(customHeaders,
|
||||
[2]string{"X-Originating-IP", "[" + s.peerIP + "]"},
|
||||
[2]string{"X-Mailer", "NetBro Mail Server 1.0"},
|
||||
[2]string{"X-Priority", "3"},
|
||||
)
|
||||
|
||||
rebuilt := ensureRequiredHeaders(content, messageID, s.rcptTos, s.mailFrom, customHeaders)
|
||||
|
||||
signedContent := rebuilt
|
||||
dkimSigned := false
|
||||
if senderDomain != "" {
|
||||
signedContent = s.backend.DKIM.Sign(rebuilt, senderDomain)
|
||||
dkimSigned = signedContent != rebuilt
|
||||
}
|
||||
|
||||
rebuiltHeaders := existingHeaders(rebuilt)
|
||||
toHeader := rebuiltHeaders["to"]
|
||||
ccHeader := rebuiltHeaders["cc"]
|
||||
subject := rebuiltHeaders["subject"]
|
||||
|
||||
// Attachment storage: only if the authenticated sender or whitelisted IP opted in.
|
||||
storeMessage := false
|
||||
if sender, _ := s.backend.DB.GetSenderByEmail(s.mailFrom); sender != nil && sender.StoreMessageContent {
|
||||
storeMessage = true
|
||||
} else if wl, _ := s.backend.DB.GetWhitelistedIP(s.peerIP, senderDomain); wl != nil && wl.StoreMessageContent {
|
||||
storeMessage = true
|
||||
}
|
||||
|
||||
parsed, parseErr := parseMessage(raw)
|
||||
|
||||
type savedAttachment struct {
|
||||
Filename, ContentType, FilePath string
|
||||
Size int64
|
||||
}
|
||||
var toSave []savedAttachment
|
||||
if storeMessage && parseErr == nil && len(parsed.Attachments) > 0 {
|
||||
usernameOrIP := s.username
|
||||
if usernameOrIP == "" && s.peerIP != "" {
|
||||
usernameOrIP = sanitizePathSegment(s.peerIP, ":")
|
||||
} else {
|
||||
usernameOrIP = sanitizePathSegment(usernameOrIP, "/\\")
|
||||
}
|
||||
storagePath := attachmentStoragePath(s.backend.AttachmentsBasePath, senderDomain, usernameOrIP, time.Now())
|
||||
if err := os.MkdirAll(storagePath, 0o755); err == nil {
|
||||
prefix := cleanMessageIDPrefix(messageID)
|
||||
for _, a := range parsed.Attachments {
|
||||
filename := prefix + "_" + a.Filename
|
||||
fullPath := filepath.Join(storagePath, filename)
|
||||
if err := os.WriteFile(fullPath, a.Data, 0o644); err == nil {
|
||||
toSave = append(toSave, savedAttachment{Filename: a.Filename, ContentType: a.ContentType, FilePath: fullPath, Size: int64(len(a.Data))})
|
||||
} else {
|
||||
s.backend.Logger.Error("Failed to write attachment %s: %v", filename, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Classify each envelope recipient as to/cc/bcc by presence in the To/Cc headers —
|
||||
// anything not literally present in either is inferred BCC.
|
||||
toList := parseAddressList(toHeader)
|
||||
ccList := parseAddressList(ccHeader)
|
||||
recipientTypes := make([]string, len(s.rcptTos))
|
||||
for i, rcpt := range s.rcptTos {
|
||||
lower := strings.ToLower(rcpt)
|
||||
switch {
|
||||
case containsStr(toList, lower):
|
||||
recipientTypes[i] = "to"
|
||||
case containsStr(ccList, lower):
|
||||
recipientTypes[i] = "cc"
|
||||
default:
|
||||
recipientTypes[i] = "bcc"
|
||||
}
|
||||
}
|
||||
|
||||
results := s.backend.Relay.RelayEmailAsync(s.mailFrom, s.rcptTos, signedContent, recipientTypes)
|
||||
|
||||
allSucceeded := len(results) > 0
|
||||
for _, res := range results {
|
||||
if res.Status != "success" {
|
||||
allSucceeded = false
|
||||
}
|
||||
}
|
||||
|
||||
var emailHeaders, messageBody string
|
||||
if parseErr == nil {
|
||||
emailHeaders = strings.Join(parsed.HeaderLines, "\n")
|
||||
messageBody = parsed.BodyText
|
||||
}
|
||||
|
||||
logID, logErr := s.backend.Relay.LogEmail(s.backend.Cfg, s.peerIP, s.mailFrom, toHeader, ccHeader, "", subject, emailHeaders, messageBody, messageID, s.username, dkimSigned, results)
|
||||
if logErr != nil {
|
||||
s.backend.Logger.Error("Failed to log email: %v", logErr)
|
||||
} else {
|
||||
for _, a := range toSave {
|
||||
if err := s.backend.DB.InsertEmailAttachment(db.EmailAttachment{
|
||||
EmailLogID: logID, Filename: a.Filename, ContentType: a.ContentType, FilePath: a.FilePath, Size: a.Size,
|
||||
}); err != nil {
|
||||
s.backend.Logger.Error("Failed to record attachment %s: %v", a.Filename, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if allSucceeded {
|
||||
return &smtp.SMTPError{Code: 250, EnhancedCode: smtp.NoEnhancedCode, Message: "Message accepted for delivery"}
|
||||
}
|
||||
return &smtp.SMTPError{Code: 550, EnhancedCode: smtp.NoEnhancedCode, Message: "Message relay failed"}
|
||||
}
|
||||
|
||||
func containsStr(list []string, s string) bool {
|
||||
for _, v := range list {
|
||||
if v == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// Package tlsutil generates the self-signed certificate used by the direct-TLS SMTP
|
||||
// listener and builds its tls.Config, mirroring email_server/tls_utils.py.
|
||||
package tlsutil
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// GenerateSelfSignedCert mirrors tls_utils.generate_self_signed_cert: skips generation
|
||||
// if both files already exist; otherwise writes an RSA-2048/SHA-256, 1-year-valid,
|
||||
// self-signed cert with the same subject fields as the Python version.
|
||||
func GenerateSelfSignedCert(certFile, keyFile string) error {
|
||||
if _, err := os.Stat(certFile); err == nil {
|
||||
if _, err := os.Stat(keyFile); err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(certFile), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(keyFile), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
priv, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
subject := pkix.Name{
|
||||
CommonName: "localhost",
|
||||
Organization: []string{"PyMTA Server"},
|
||||
Country: []string{"GB"},
|
||||
}
|
||||
template := x509.Certificate{
|
||||
SerialNumber: big.NewInt(1000),
|
||||
Subject: subject,
|
||||
Issuer: subject,
|
||||
NotBefore: time.Now(),
|
||||
NotAfter: time.Now().Add(365 * 24 * time.Hour),
|
||||
SignatureAlgorithm: x509.SHA256WithRSA,
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
certOut, err := os.Create(certFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer certOut.Close()
|
||||
if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: certDER}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
keyOut, err := os.OpenFile(keyFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer keyOut.Close()
|
||||
keyDER, err := x509.MarshalPKCS8PrivateKey(priv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return pem.Encode(keyOut, &pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})
|
||||
}
|
||||
|
||||
// CreateSSLContext mirrors tls_utils.create_ssl_context: loads the cert/key pair and
|
||||
// pins MinVersion to TLS 1.2 (Python's ssl.create_default_context leaves this to the
|
||||
// environment's OpenSSL defaults, which is typically TLS 1.2+ on modern systems —
|
||||
// pinning it explicitly here is the closest deterministic equivalent). Cipher suites
|
||||
// are left at Go's own secure defaults, matching the Python code's "DEFAULT" relaxation.
|
||||
func CreateSSLContext(certFile, keyFile string) (*tls.Config, error) {
|
||||
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &tls.Config{
|
||||
Certificates: []tls.Certificate{cert},
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
// Package toolbox provides small shared helpers, mirroring email_server/tool_box.py:
|
||||
// logging, the configured-timezone clock, and Message-ID generation.
|
||||
package toolbox
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/big"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/ini.v1"
|
||||
)
|
||||
|
||||
// Logger is a tiny leveled logger matching the Python format:
|
||||
// "%(asctime)s - %(name)s - %(levelname)s - %(message)s".
|
||||
type Logger struct {
|
||||
name string
|
||||
level Level
|
||||
out *log.Logger
|
||||
}
|
||||
|
||||
type Level int
|
||||
|
||||
const (
|
||||
LevelDebug Level = iota
|
||||
LevelInfo
|
||||
LevelWarning
|
||||
LevelError
|
||||
LevelCritical
|
||||
)
|
||||
|
||||
func parseLevel(s string) Level {
|
||||
switch strings.ToUpper(strings.TrimSpace(s)) {
|
||||
case "DEBUG":
|
||||
return LevelDebug
|
||||
case "WARNING", "WARN":
|
||||
return LevelWarning
|
||||
case "ERROR":
|
||||
return LevelError
|
||||
case "CRITICAL":
|
||||
return LevelCritical
|
||||
default:
|
||||
return LevelInfo
|
||||
}
|
||||
}
|
||||
|
||||
func (l Level) String() string {
|
||||
switch l {
|
||||
case LevelDebug:
|
||||
return "DEBUG"
|
||||
case LevelWarning:
|
||||
return "WARNING"
|
||||
case LevelError:
|
||||
return "ERROR"
|
||||
case LevelCritical:
|
||||
return "CRITICAL"
|
||||
default:
|
||||
return "INFO"
|
||||
}
|
||||
}
|
||||
|
||||
var globalLevel = LevelInfo
|
||||
|
||||
// Configure sets the process-wide log level from settings.ini's [Logging] section,
|
||||
// mirroring tool_box.setup_logging.
|
||||
func Configure(cfg *ini.File) {
|
||||
section := cfg.Section("Logging")
|
||||
globalLevel = parseLevel(section.Key("LOG_LEVEL").MustString("INFO"))
|
||||
}
|
||||
|
||||
// GetLogger returns a Logger for the given component name, mirroring tool_box.get_logger.
|
||||
// Python derives the name from the caller's filename when omitted; Go callers pass it
|
||||
// explicitly instead, since introspecting the caller module isn't idiomatic here.
|
||||
func GetLogger(name string) *Logger {
|
||||
return &Logger{name: name, level: globalLevel, out: log.New(os.Stderr, "", 0)}
|
||||
}
|
||||
|
||||
func (l *Logger) log(level Level, format string, args ...any) {
|
||||
if level < globalLevel {
|
||||
return
|
||||
}
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
ts := time.Now().Format("2006-01-02 15:04:05,000")
|
||||
l.out.Printf("%s - %s - %s - %s", ts, l.name, level, msg)
|
||||
}
|
||||
|
||||
func (l *Logger) Debug(format string, args ...any) { l.log(LevelDebug, format, args...) }
|
||||
func (l *Logger) Info(format string, args ...any) { l.log(LevelInfo, format, args...) }
|
||||
func (l *Logger) Warning(format string, args ...any) { l.log(LevelWarning, format, args...) }
|
||||
func (l *Logger) Error(format string, args ...any) { l.log(LevelError, format, args...) }
|
||||
func (l *Logger) Critical(format string, args ...any) { l.log(LevelCritical, format, args...) }
|
||||
|
||||
// EnsureFolderExists creates the parent directory of filepath (a file path, not a
|
||||
// directory path), mirroring tool_box.ensure_folder_exists including its handling of
|
||||
// "sqlite:///" prefixed database URLs.
|
||||
func EnsureFolderExists(path string) error {
|
||||
path = strings.TrimPrefix(path, "sqlite:///")
|
||||
dir := path
|
||||
if idx := strings.LastIndexAny(path, "/\\"); idx >= 0 {
|
||||
dir = path[:idx]
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
if dir == "" {
|
||||
return nil
|
||||
}
|
||||
return os.MkdirAll(dir, 0o755)
|
||||
}
|
||||
|
||||
// GetCurrentTime returns the current time in the configured server timezone, mirroring
|
||||
// tool_box.get_current_time. Falls back to UTC if the configured zone can't be loaded.
|
||||
func GetCurrentTime(cfg *ini.File) time.Time {
|
||||
tzName := cfg.Section("Server").Key("time_zone").MustString("UTC")
|
||||
loc, err := time.LoadLocation(tzName)
|
||||
if err != nil {
|
||||
loc = time.UTC
|
||||
}
|
||||
return time.Now().In(loc)
|
||||
}
|
||||
|
||||
// GenerateMessageID builds a Message-ID local-part@hostname string, mirroring
|
||||
// tool_box.generate_message_id: system wall-clock time (not the configured timezone)
|
||||
// plus a 6-digit random suffix.
|
||||
func GenerateMessageID(hostname string) string {
|
||||
digits := make([]byte, 6)
|
||||
for i := range digits {
|
||||
n, _ := rand.Int(rand.Reader, big.NewInt(10))
|
||||
digits[i] = byte('0' + n.Int64())
|
||||
}
|
||||
return fmt.Sprintf("%s.%s@%s", time.Now().Format("20060102150405"), string(digits), hostname)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"image/png"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/pquerna/otp/totp"
|
||||
"mailgoserver/internal/db"
|
||||
)
|
||||
|
||||
// accountPage shows the admin their own profile: password change, TOTP MFA
|
||||
// enable/disable, and registered passkeys (passkey registration itself is wired up
|
||||
// in webauthn.go).
|
||||
func (a *App) accountPage(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFromContext(r)
|
||||
creds, _ := a.DB.ListWebAuthnCredentials(user.ID)
|
||||
a.render(w, r, "account.html", M{"active": "account", "user": user, "passkeys": creds})
|
||||
}
|
||||
|
||||
// changePassword mirrors a normal (not forced) password change from account settings.
|
||||
func (a *App) changePassword(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFromContext(r)
|
||||
current := r.FormValue("current_password")
|
||||
newPassword := r.FormValue("new_password")
|
||||
confirm := r.FormValue("new_password_confirm")
|
||||
|
||||
if !db.CheckPassword(current, user.PasswordHash) {
|
||||
setFlash(w, "error", "Current password is incorrect")
|
||||
http.Redirect(w, r, Prefix+"/account", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if !isStrongPassword(newPassword) {
|
||||
setFlash(w, "error", "New password must be at least 10 characters and include a letter, a number, and a symbol")
|
||||
http.Redirect(w, r, Prefix+"/account", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if newPassword != confirm {
|
||||
setFlash(w, "error", "New passwords don't match")
|
||||
http.Redirect(w, r, Prefix+"/account", http.StatusFound)
|
||||
return
|
||||
}
|
||||
hash, err := db.HashPassword(newPassword)
|
||||
if err != nil {
|
||||
setFlash(w, "error", "Something went wrong")
|
||||
http.Redirect(w, r, Prefix+"/account", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if err := a.DB.UpdateAdminPassword(user.ID, hash); err != nil {
|
||||
setFlash(w, "error", "Something went wrong")
|
||||
http.Redirect(w, r, Prefix+"/account", http.StatusFound)
|
||||
return
|
||||
}
|
||||
setFlash(w, "success", "Password updated")
|
||||
http.Redirect(w, r, Prefix+"/account", http.StatusFound)
|
||||
}
|
||||
|
||||
// totpSetupBegin generates a fresh (not-yet-enabled) TOTP secret and shows it as a
|
||||
// scannable QR code (rendered inline as a data: URI — simplest way to hand the
|
||||
// browser an image without a second round-trip route) plus the manual entry key.
|
||||
func (a *App) totpSetupBegin(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFromContext(r)
|
||||
key, err := totp.Generate(totp.GenerateOpts{
|
||||
Issuer: "mailgoserver",
|
||||
AccountName: user.Username,
|
||||
})
|
||||
if err != nil {
|
||||
setFlash(w, "error", "Could not generate a TOTP secret")
|
||||
http.Redirect(w, r, Prefix+"/account", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if err := a.DB.SetAdminTOTPSecret(user.ID, key.Secret(), false); err != nil {
|
||||
setFlash(w, "error", "Could not save the TOTP secret")
|
||||
http.Redirect(w, r, Prefix+"/account", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
img, err := key.Image(256, 256)
|
||||
qrDataURI := ""
|
||||
if err == nil {
|
||||
var buf bytes.Buffer
|
||||
if png.Encode(&buf, img) == nil {
|
||||
qrDataURI = "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes())
|
||||
}
|
||||
}
|
||||
a.render(w, r, "totp_setup.html", M{"secret": key.Secret(), "qr_data_uri": qrDataURI})
|
||||
}
|
||||
|
||||
// totpSetupConfirm verifies a code against the pending secret and, if correct, flips
|
||||
// TOTP on for the account.
|
||||
func (a *App) totpSetupConfirm(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFromContext(r)
|
||||
code := strings.TrimSpace(r.FormValue("code"))
|
||||
if user.TOTPSecret == "" || !totp.Validate(code, user.TOTPSecret) {
|
||||
setFlash(w, "error", "That code didn't match — try scanning the QR code again")
|
||||
http.Redirect(w, r, Prefix+"/account", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if err := a.DB.SetAdminTOTPSecret(user.ID, user.TOTPSecret, true); err != nil {
|
||||
setFlash(w, "error", "Something went wrong enabling MFA")
|
||||
http.Redirect(w, r, Prefix+"/account", http.StatusFound)
|
||||
return
|
||||
}
|
||||
setFlash(w, "success", "Authenticator app MFA enabled")
|
||||
http.Redirect(w, r, Prefix+"/account", http.StatusFound)
|
||||
}
|
||||
|
||||
func (a *App) totpDisable(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFromContext(r)
|
||||
if err := a.DB.DisableAdminTOTP(user.ID); err != nil {
|
||||
setFlash(w, "error", "Something went wrong")
|
||||
} else {
|
||||
setFlash(w, "success", "Authenticator app MFA disabled")
|
||||
}
|
||||
http.Redirect(w, r, Prefix+"/account", http.StatusFound)
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"mailgoserver/internal/db"
|
||||
)
|
||||
|
||||
var errNotOwned = errors.New("domain not in the requesting admin's scope")
|
||||
|
||||
// manageableAdmins mirrors the delegation rule: a global admin manages everyone; a
|
||||
// scoped admin manages any other scoped admin whose entire domain assignment is a
|
||||
// subset of their own (not just admins they personally created) — see the approved
|
||||
// design in the conversation this shipped from.
|
||||
func (a *App) manageableAdmins(r *http.Request) ([]db.AdminUser, error) {
|
||||
user := userFromContext(r)
|
||||
if user.IsGlobalAdmin {
|
||||
return a.DB.ListAllAdminUsers()
|
||||
}
|
||||
scope := scopeFromContext(r)
|
||||
scoped, err := a.DB.ListScopedAdminUsers()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []db.AdminUser
|
||||
for _, other := range scoped {
|
||||
if other.ID == user.ID {
|
||||
continue
|
||||
}
|
||||
theirDomains, err := a.DB.AccessibleDomainIDs(other.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if isDomainSubset(theirDomains, scope) {
|
||||
out = append(out, other)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func isDomainSubset(ids []int64, scope accessScope) bool {
|
||||
for _, id := range ids {
|
||||
if !scope.Allowed(id) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// canManageAdmin re-checks a specific target admin against the current admin's
|
||||
// delegation rights — used by the mutating routes so they don't just trust whatever
|
||||
// the list page happened to render.
|
||||
func (a *App) canManageAdmin(r *http.Request, target *db.AdminUser) (bool, error) {
|
||||
user := userFromContext(r)
|
||||
if target.ID == user.ID {
|
||||
return false, nil
|
||||
}
|
||||
if user.IsGlobalAdmin {
|
||||
return true, nil
|
||||
}
|
||||
if target.IsGlobalAdmin {
|
||||
return false, nil
|
||||
}
|
||||
theirDomains, err := a.DB.AccessibleDomainIDs(target.ID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return isDomainSubset(theirDomains, scopeFromContext(r)), nil
|
||||
}
|
||||
|
||||
func (a *App) adminsList(w http.ResponseWriter, r *http.Request) {
|
||||
admins, err := a.manageableAdmins(r)
|
||||
if err != nil {
|
||||
setFlash(w, "error", "Error loading admins")
|
||||
}
|
||||
var rows []M
|
||||
for _, u := range admins {
|
||||
domainIDs, _ := a.DB.AccessibleDomainIDs(u.ID)
|
||||
var domainNames []string
|
||||
for _, id := range domainIDs {
|
||||
if dom, _ := a.DB.GetDomainByID(id); dom != nil {
|
||||
domainNames = append(domainNames, dom.DomainName)
|
||||
}
|
||||
}
|
||||
rows = append(rows, M{"user": u, "domain_names": domainNames})
|
||||
}
|
||||
a.render(w, r, "admins.html", M{"active": "admins", "rows": rows})
|
||||
}
|
||||
|
||||
func (a *App) addAdminForm(w http.ResponseWriter, r *http.Request) {
|
||||
domains, _ := a.accessibleDomains(r)
|
||||
a.render(w, r, "add_admin.html", M{"active": "admins", "domains": domains, "can_grant_global": userFromContext(r).IsGlobalAdmin})
|
||||
}
|
||||
|
||||
// addAdmin mirrors the delegation flow: creates a new scoped admin (or, for a global
|
||||
// admin, optionally a new global admin), forced to change their password on first
|
||||
// login exactly like the seeded default account.
|
||||
func (a *App) addAdmin(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFromContext(r)
|
||||
username := strings.TrimSpace(r.FormValue("username"))
|
||||
password := r.FormValue("password")
|
||||
makeGlobal := user.IsGlobalAdmin && r.FormValue("is_global_admin") == "on"
|
||||
|
||||
if username == "" || !isStrongPassword(password) {
|
||||
setFlash(w, "error", "Username is required and password must be at least 10 characters with a letter, a number, and a symbol")
|
||||
http.Redirect(w, r, Prefix+"/admins/add", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if existing, _ := a.DB.GetAdminUserByUsername(username); existing != nil {
|
||||
setFlash(w, "error", "That username is already taken")
|
||||
http.Redirect(w, r, Prefix+"/admins/add", http.StatusFound)
|
||||
return
|
||||
}
|
||||
hash, err := db.HashPassword(password)
|
||||
if err != nil {
|
||||
setFlash(w, "error", "Something went wrong")
|
||||
http.Redirect(w, r, Prefix+"/admins/add", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
if makeGlobal {
|
||||
if _, err := a.DB.CreateAdminUser(username, hash, true); err != nil {
|
||||
setFlash(w, "error", "Error creating admin")
|
||||
http.Redirect(w, r, Prefix+"/admins/add", http.StatusFound)
|
||||
return
|
||||
}
|
||||
setFlash(w, "success", "Global admin created")
|
||||
http.Redirect(w, r, Prefix+"/admins", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
domainIDs, err := a.parseOwnedDomainIDs(r)
|
||||
if err != nil {
|
||||
setFlash(w, "error", "You can only assign domains you manage yourself")
|
||||
http.Redirect(w, r, Prefix+"/admins/add", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if _, err := a.DB.CreateScopedAdminUser(username, hash, user.ID, domainIDs); err != nil {
|
||||
setFlash(w, "error", "Error creating admin")
|
||||
http.Redirect(w, r, Prefix+"/admins/add", http.StatusFound)
|
||||
return
|
||||
}
|
||||
setFlash(w, "success", "Admin created and given access to the selected domains")
|
||||
http.Redirect(w, r, Prefix+"/admins", http.StatusFound)
|
||||
}
|
||||
|
||||
// parseOwnedDomainIDs reads the "domain_ids" checkbox list from the form and rejects
|
||||
// the request outright if any of them fall outside the current admin's own scope —
|
||||
// the actual enforcement point for "can only delegate domains you have yourself".
|
||||
func (a *App) parseOwnedDomainIDs(r *http.Request) ([]int64, error) {
|
||||
scope := scopeFromContext(r)
|
||||
var ids []int64
|
||||
for _, v := range r.Form["domain_ids"] {
|
||||
id, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if !scope.Allowed(id) {
|
||||
return nil, errNotOwned
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (a *App) editAdminDomainsForm(w http.ResponseWriter, r *http.Request) {
|
||||
target, ok := a.adminWithManageAccess(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
domains, _ := a.accessibleDomains(r)
|
||||
assigned, _ := a.DB.AccessibleDomainIDs(target.ID)
|
||||
assignedSet := make(map[int64]bool, len(assigned))
|
||||
for _, id := range assigned {
|
||||
assignedSet[id] = true
|
||||
}
|
||||
a.render(w, r, "edit_admin.html", M{"active": "admins", "target": target, "domains": domains, "assigned": assignedSet})
|
||||
}
|
||||
|
||||
func (a *App) editAdminDomains(w http.ResponseWriter, r *http.Request) {
|
||||
target, ok := a.adminWithManageAccess(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
setFlash(w, "error", "Invalid form data")
|
||||
http.Redirect(w, r, Prefix+"/admins", http.StatusFound)
|
||||
return
|
||||
}
|
||||
domainIDs, err := a.parseOwnedDomainIDs(r)
|
||||
if err != nil {
|
||||
setFlash(w, "error", "You can only assign domains you manage yourself")
|
||||
http.Redirect(w, r, Prefix+"/admins/"+strconv.FormatInt(target.ID, 10)+"/edit", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if err := a.DB.SetAdminDomainAccess(target.ID, domainIDs); err != nil {
|
||||
setFlash(w, "error", "Error updating domain access")
|
||||
} else {
|
||||
setFlash(w, "success", "Domain access updated")
|
||||
}
|
||||
http.Redirect(w, r, Prefix+"/admins", http.StatusFound)
|
||||
}
|
||||
|
||||
// adminWithManageAccess fetches the target admin by path ID and re-validates the
|
||||
// delegation rule server-side (never trust that the list page's filtering was the
|
||||
// only gate).
|
||||
func (a *App) adminWithManageAccess(w http.ResponseWriter, r *http.Request) (*db.AdminUser, bool) {
|
||||
target, err := a.DB.GetAdminUserByID(pathID(r))
|
||||
if err != nil || target == nil {
|
||||
http.NotFound(w, r)
|
||||
return nil, false
|
||||
}
|
||||
allowed, err := a.canManageAdmin(r, target)
|
||||
if err != nil || !allowed {
|
||||
http.NotFound(w, r)
|
||||
return nil, false
|
||||
}
|
||||
return target, true
|
||||
}
|
||||
|
||||
func (a *App) removeAdmin(w http.ResponseWriter, r *http.Request) {
|
||||
target, ok := a.adminWithManageAccess(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if target.IsGlobalAdmin {
|
||||
if all, err := a.DB.ListAllAdminUsers(); err == nil {
|
||||
remaining := 0
|
||||
for _, u := range all {
|
||||
if u.IsGlobalAdmin {
|
||||
remaining++
|
||||
}
|
||||
}
|
||||
if remaining <= 1 {
|
||||
setFlash(w, "error", "Can't remove the last global admin")
|
||||
http.Redirect(w, r, Prefix+"/admins", http.StatusFound)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := a.DB.DeleteAdminUser(target.ID); err != nil {
|
||||
setFlash(w, "error", "Error removing admin")
|
||||
} else {
|
||||
setFlash(w, "success", "Admin removed")
|
||||
}
|
||||
http.Redirect(w, r, Prefix+"/admins", http.StatusFound)
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"mailgoserver/internal/db"
|
||||
)
|
||||
|
||||
const (
|
||||
sessionCookieName = "mailgoserver_session"
|
||||
sessionTTL = 7 * 24 * time.Hour
|
||||
)
|
||||
|
||||
type ctxKey int
|
||||
|
||||
const (
|
||||
ctxUserKey ctxKey = iota
|
||||
ctxScopeKey
|
||||
)
|
||||
|
||||
// accessScope is which domains the current admin can see/manage. A global admin
|
||||
// bypasses the domain-ID check entirely; a scoped admin is restricted to exactly the
|
||||
// domains in DomainIDs — computed once per request in requireAuth and reused by every
|
||||
// handler via scopeFromContext, rather than re-querying esrv_admin_domain_access
|
||||
// repeatedly within the same request.
|
||||
type accessScope struct {
|
||||
Global bool
|
||||
DomainIDs map[int64]bool
|
||||
}
|
||||
|
||||
func (s accessScope) Allowed(domainID int64) bool {
|
||||
return s.Global || s.DomainIDs[domainID]
|
||||
}
|
||||
|
||||
// IDs returns the accessible domain IDs as a slice — nil (not empty) for a global
|
||||
// admin, since "nil" is the signal callers should treat as "no filter" rather than
|
||||
// "empty set" when building an IN (...) clause or similar.
|
||||
func (s accessScope) IDs() []int64 {
|
||||
if s.Global {
|
||||
return nil
|
||||
}
|
||||
ids := make([]int64, 0, len(s.DomainIDs))
|
||||
for id := range s.DomainIDs {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func scopeFromContext(r *http.Request) accessScope {
|
||||
s, _ := r.Context().Value(ctxScopeKey).(accessScope)
|
||||
return s
|
||||
}
|
||||
|
||||
// requireDomainAccess checks the current admin's scope covers domainID; if not, it
|
||||
// writes a 404 (not 403 — a scoped admin shouldn't be able to distinguish "doesn't
|
||||
// exist" from "exists but isn't mine" by probing IDs) and returns false, matching the
|
||||
// existing "not found" handling every route already does for a missing resource.
|
||||
func requireDomainAccess(w http.ResponseWriter, r *http.Request, domainID int64) bool {
|
||||
if scopeFromContext(r).Allowed(domainID) {
|
||||
return true
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *App) buildAccessScope(user *db.AdminUser) (accessScope, error) {
|
||||
if user.IsGlobalAdmin {
|
||||
return accessScope{Global: true}, nil
|
||||
}
|
||||
ids, err := a.DB.AccessibleDomainIDs(user.ID)
|
||||
if err != nil {
|
||||
return accessScope{}, err
|
||||
}
|
||||
m := make(map[int64]bool, len(ids))
|
||||
for _, id := range ids {
|
||||
m[id] = true
|
||||
}
|
||||
return accessScope{DomainIDs: m}, nil
|
||||
}
|
||||
|
||||
func setSessionCookie(w http.ResponseWriter, token string, secure bool) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: int(sessionTTL.Seconds()),
|
||||
})
|
||||
}
|
||||
|
||||
func clearSessionCookie(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{Name: sessionCookieName, Value: "", Path: "/", MaxAge: -1})
|
||||
}
|
||||
|
||||
// currentSession loads the session + user for the request's cookie, if any and valid
|
||||
// (exists, not expired). A nil session/user (no error) means "not logged in".
|
||||
func (a *App) currentSession(r *http.Request) (*db.AdminSession, *db.AdminUser, error) {
|
||||
c, err := r.Cookie(sessionCookieName)
|
||||
if err != nil || c.Value == "" {
|
||||
return nil, nil, nil
|
||||
}
|
||||
sess, err := a.DB.GetSession(c.Value)
|
||||
if err != nil || sess == nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if time.Now().After(sess.ExpiresAt) {
|
||||
_ = a.DB.DeleteSession(sess.Token)
|
||||
return nil, nil, nil
|
||||
}
|
||||
user, err := a.DB.GetAdminUserByID(sess.UserID)
|
||||
if err != nil || user == nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return sess, user, nil
|
||||
}
|
||||
|
||||
func userFromContext(r *http.Request) *db.AdminUser {
|
||||
u, _ := r.Context().Value(ctxUserKey).(*db.AdminUser)
|
||||
return u
|
||||
}
|
||||
|
||||
// requireAuth gates every admin route behind a valid, fully-authenticated session:
|
||||
// logged in, second factor satisfied if one is enabled, and not stuck in the forced
|
||||
// first-login credential change. Unauthenticated/incomplete requests are redirected
|
||||
// to the right step of the login flow rather than shown an error.
|
||||
func (a *App) requireAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
sess, user, err := a.currentSession(r)
|
||||
if err != nil {
|
||||
a.Logger.Error("session lookup: %v", err)
|
||||
}
|
||||
if sess == nil || user == nil {
|
||||
http.Redirect(w, r, Prefix+"/login?next="+r.URL.Path, http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
needsMFA := user.TOTPEnabled
|
||||
if !needsMFA {
|
||||
if n, _ := a.DB.CountWebAuthnCredentials(user.ID); n > 0 {
|
||||
needsMFA = true
|
||||
}
|
||||
}
|
||||
if needsMFA && !sess.MFAVerified {
|
||||
http.Redirect(w, r, Prefix+"/login/mfa", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
if user.MustChangePassword && r.URL.Path != Prefix+"/first-login" {
|
||||
http.Redirect(w, r, Prefix+"/first-login", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
scope, err := a.buildAccessScope(user)
|
||||
if err != nil {
|
||||
a.Logger.Error("build access scope: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), ctxUserKey, user)
|
||||
ctx = context.WithValue(ctx, ctxScopeKey, scope)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"mailgoserver/internal/db"
|
||||
)
|
||||
|
||||
func emailDomain(addr string) string {
|
||||
if i := strings.LastIndex(addr, "@"); i >= 0 {
|
||||
return strings.ToLower(addr[i+1:])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// dashboard mirrors dashboard.py's dashboard(), scoped to the current admin's
|
||||
// assigned domains unless they're a global admin.
|
||||
func (a *App) dashboard(w http.ResponseWriter, r *http.Request) {
|
||||
scope := scopeFromContext(r)
|
||||
allowedNames, isGlobal, err := a.accessibleDomainNames(r)
|
||||
if err != nil {
|
||||
a.Logger.Error("dashboard: %v", err)
|
||||
}
|
||||
|
||||
var domainCount, senderCount, dkimCount int
|
||||
if isGlobal {
|
||||
domainCount, _ = a.DB.CountActiveDomains()
|
||||
senderCount, _ = a.DB.CountActiveSenders()
|
||||
dkimCount, _ = a.DB.CountActiveDKIMKeys()
|
||||
} else {
|
||||
domains, _ := a.DB.ListDomains()
|
||||
for _, d := range domains {
|
||||
if d.IsActive && scope.Allowed(d.ID) {
|
||||
domainCount++
|
||||
}
|
||||
}
|
||||
senders, _ := a.DB.ListSenders()
|
||||
for _, s := range senders {
|
||||
if s.IsActive && scope.Allowed(s.DomainID) {
|
||||
senderCount++
|
||||
}
|
||||
}
|
||||
keys, _ := a.DB.ListActiveDKIMKeysWithDomain()
|
||||
for _, k := range keys {
|
||||
if scope.Allowed(k.DomainID) {
|
||||
dkimCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
allEmails, err := a.DB.ListEmailLogsPage(0, 50)
|
||||
if err != nil {
|
||||
setFlash(w, "error", "Error loading recent activity")
|
||||
}
|
||||
var recentEmails []db.EmailLog
|
||||
for _, e := range allEmails {
|
||||
if isGlobal || allowedNames[emailDomain(e.MailFrom)] {
|
||||
recentEmails = append(recentEmails, e)
|
||||
}
|
||||
if len(recentEmails) == 10 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
allAuths, _ := a.DB.ListRecentAuthLogs(50)
|
||||
var recentAuths []db.AuthLog
|
||||
for _, au := range allAuths {
|
||||
if isGlobal || allowedNames[authLogDomain(au.Identifier)] {
|
||||
recentAuths = append(recentAuths, au)
|
||||
}
|
||||
if len(recentAuths) == 10 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
a.render(w, r, "dashboard.html", M{
|
||||
"active": "dashboard",
|
||||
"domain_count": domainCount,
|
||||
"sender_count": senderCount,
|
||||
"dkim_count": dkimCount,
|
||||
"recent_emails": recentEmails,
|
||||
"recent_auths": recentAuths,
|
||||
})
|
||||
}
|
||||
|
||||
// authLogDomain best-effort extracts a domain name from an AuthLog identifier, whose
|
||||
// format varies by auth_type: a bare email ("sender"), "ip -> domain" (ip), or
|
||||
// "sender@x -> target@y" (sender_validation). There's no domain_id column on this
|
||||
// table (it predates admin scoping), so this is a text heuristic, not a foreign key.
|
||||
func authLogDomain(identifier string) string {
|
||||
if idx := strings.LastIndex(identifier, "->"); idx >= 0 {
|
||||
return emailOrBareDomain(strings.TrimSpace(identifier[idx+2:]))
|
||||
}
|
||||
return emailOrBareDomain(identifier)
|
||||
}
|
||||
|
||||
func emailOrBareDomain(s string) string {
|
||||
if strings.Contains(s, "@") {
|
||||
return emailDomain(s)
|
||||
}
|
||||
return strings.ToLower(s)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user