updated layout for webmail and added http dns letsencrypt
This commit is contained in:
@@ -23,8 +23,11 @@ a Python venv + separate services.
|
|||||||
filter-rule management, and PGP (OpenPGP encrypt/decrypt/sign/verify) and S/MIME
|
filter-rule management, and PGP (OpenPGP encrypt/decrypt/sign/verify) and S/MIME
|
||||||
(sign/verify) support per mailbox.
|
(sign/verify) support per mailbox.
|
||||||
- **Admin dashboard** (`/pymta-manager`) — manage domains, senders, mailboxes, DKIM
|
- **Admin dashboard** (`/pymta-manager`) — manage domains, senders, mailboxes, DKIM
|
||||||
keys, IP whitelisting, TLS (self-signed or Let's Encrypt via DNS-01: Cloudflare,
|
keys, IP whitelisting, TLS (self-signed/custom, or up to two simultaneous Let's
|
||||||
Route53, DigitalOcean, Google Cloud DNS), and review email + auth logs.
|
Encrypt certificates — DNS-01 via Cloudflare/Route53/DigitalOcean/Google Cloud DNS, and
|
||||||
|
HTTP-01 for domains you don't manage DNS for, optionally covering the server's own IP
|
||||||
|
too — independently assignable per listener, e.g. HTTP-01 for mail and DNS-01 for the
|
||||||
|
dashboard), and review email + auth logs.
|
||||||
- **Security hardening built in** — CSRF protection, security headers (CSP, X-Frame-
|
- **Security hardening built in** — CSRF protection, security headers (CSP, X-Frame-
|
||||||
Options, etc.), Cloudflare-aware trusted-proxy IP resolution, login rate limiting and
|
Options, etc.), Cloudflare-aware trusted-proxy IP resolution, login rate limiting and
|
||||||
account lockout, TOTP + WebAuthn/passkey MFA (admin and mailbox owners), and automatic
|
account lockout, TOTP + WebAuthn/passkey MFA (admin and mailbox owners), and automatic
|
||||||
@@ -83,7 +86,9 @@ IMAP `143`, direct-TLS IMAP `993` — the real standard mail ports, so binding t
|
|||||||
directly needs root or `setcap` (see below), which the Docker deployment already
|
directly needs root or `setcap` (see below), which the Docker deployment already
|
||||||
handles for you. Admin/webmail HTTP `5000` / HTTPS `5001` stay deliberately
|
handles for you. Admin/webmail HTTP `5000` / HTTPS `5001` stay deliberately
|
||||||
non-privileged; put a reverse proxy or your own `80`/`443` mapping in front of those if
|
non-privileged; put a reverse proxy or your own `80`/`443` mapping in front of those if
|
||||||
you want the dashboard on standard web ports too.
|
you want the dashboard on standard web ports too. Port `80` is also used, but only
|
||||||
|
transiently, if you enable Let's Encrypt's HTTP-01 challenge (see below) — the same
|
||||||
|
setcap/root/Docker rule applies to it as to the mail ports.
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
@@ -92,7 +97,7 @@ cd mailgoserver
|
|||||||
go build -o mailgoserver .
|
go build -o mailgoserver .
|
||||||
```
|
```
|
||||||
|
|
||||||
## Bind ports 25/143/465/993 without root
|
## Bind ports 25/143/465/993 (and 80, for Let's Encrypt) without root
|
||||||
|
|
||||||
The default SMTP/IMAP ports are the real standard ones now, so running the binary
|
The default SMTP/IMAP ports are the real standard ones now, so running the binary
|
||||||
directly (not via Docker) needs one of:
|
directly (not via Docker) needs one of:
|
||||||
@@ -103,7 +108,10 @@ sudo setcap 'cap_net_bind_service=+ep' ./mailgoserver
|
|||||||
|
|
||||||
or run it as root, or via the systemd unit below (which grants the capability instead
|
or run it as root, or via the systemd unit below (which grants the capability instead
|
||||||
of running as root). Same purpose as `script_setup_py_environment.sh`'s `setcap` step
|
of running as root). Same purpose as `script_setup_py_environment.sh`'s `setcap` step
|
||||||
on the Python venv, applied to the compiled binary instead. **Not needed for the Docker
|
on the Python venv, applied to the compiled binary instead. `cap_net_bind_service`
|
||||||
|
covers every port under 1024, so this one grant is also what lets Let's Encrypt's
|
||||||
|
HTTP-01 challenge bind :80 (only while an obtain/renew is actually running, see the
|
||||||
|
Let's Encrypt page below). **Not needed for the Docker
|
||||||
deployment** — those containers run as root, so binding 25/143/465/993 directly just
|
deployment** — those containers run as root, so binding 25/143/465/993 directly just
|
||||||
works with no extra setup.
|
works with no extra setup.
|
||||||
|
|
||||||
@@ -152,6 +160,42 @@ See [`docker-deploy/`](docker-deploy/) — a standalone image and one that bundl
|
|||||||
latest rspamd in the same container, both via a single `docker-compose.yml` using
|
latest rspamd in the same container, both via a single `docker-compose.yml` using
|
||||||
Compose profiles.
|
Compose profiles.
|
||||||
|
|
||||||
|
## Certificates
|
||||||
|
|
||||||
|
Three independent listeners need a TLS certificate: SMTP direct-TLS (465), IMAP
|
||||||
|
direct-TLS (993), and the admin/webmail HTTPS UI (5001). Each can be assigned a
|
||||||
|
different one, from the admin dashboard's **Settings** page (TLS/SSL Configuration
|
||||||
|
card):
|
||||||
|
|
||||||
|
- **Custom** — self-signed by default (generated on first run), or your own uploaded
|
||||||
|
cert/key.
|
||||||
|
- **Let's Encrypt (DNS-01)** — automatic, needs a supported DNS provider (Cloudflare,
|
||||||
|
Route53, DigitalOcean, Google Cloud DNS). Configure on the dashboard's **Let's
|
||||||
|
Encrypt** page.
|
||||||
|
- **Let's Encrypt (HTTP-01)** — automatic, needs no DNS provider at all, only port 80
|
||||||
|
reachable from the internet — the right choice for a domain whose DNS isn't hosted
|
||||||
|
anywhere this server can automate. Once enabled (needs a restart to take effect), this
|
||||||
|
binds a small HTTP server that stays up for the life of the process — hit it directly
|
||||||
|
(`curl http://your-host/`) and you should get a plain `200 ok`, which is the easiest
|
||||||
|
way to confirm your router/reverse-proxy port-forwarding actually reaches this host,
|
||||||
|
independent of running a real obtain. Optionally also covers the server's own public IP
|
||||||
|
address on the same certificate (autodetected, or a manual override) — note this forces
|
||||||
|
Let's Encrypt's `shortlived` certificate profile (the only one that currently allows IP
|
||||||
|
identifiers), so those certificates are valid for only ~6 days and renew far more often,
|
||||||
|
handled automatically. Configure on the **Let's Encrypt** page. The local bind port
|
||||||
|
defaults to `80` (`[Server] HTTP_LETSENCRYPT_PORT`) — change this only if something
|
||||||
|
ahead of this host (a router or reverse proxy) forwards the internet-facing port 80 to
|
||||||
|
a different local port; Let's Encrypt itself always connects to port 80, there's no way
|
||||||
|
to make it use a different port on the CA side.
|
||||||
|
|
||||||
|
A common setup: HTTP-01 for the mail listeners (SMTP-TLS/IMAP-TLS) since mail clients
|
||||||
|
rarely validate hostnames strictly, paired with DNS-01 (or a real custom cert) for the
|
||||||
|
web UI where browsers do. Both Let's Encrypt certificates can be enabled at once — they're
|
||||||
|
obtained and renewed independently — and switching which listener uses which needs a
|
||||||
|
restart to take effect. All of this is also settable directly in `settings.ini`: see the
|
||||||
|
`[TLS]` (`smtp_tls_cert`/`imap_tls_cert`/`web_https_cert`), `[LetsEncrypt]` (DNS-01), and
|
||||||
|
`[LetsEncryptHTTP]` (HTTP-01) sections.
|
||||||
|
|
||||||
## Admin dashboard login
|
## Admin dashboard login
|
||||||
|
|
||||||
First run seeds one account: username `admin`, password `Password123!`. Logging in
|
First run seeds one account: username `admin`, password `Password123!`. Logging in
|
||||||
|
|||||||
@@ -5,5 +5,6 @@ SMTP_PORT=25
|
|||||||
SMTP_TLS_PORT=465
|
SMTP_TLS_PORT=465
|
||||||
IMAP_PORT=143
|
IMAP_PORT=143
|
||||||
IMAP_TLS_PORT=993
|
IMAP_TLS_PORT=993
|
||||||
|
ACME_HTTP_PORT=80
|
||||||
WEB_HTTP_PORT=5000
|
WEB_HTTP_PORT=5000
|
||||||
WEB_HTTPS_PORT=5001
|
WEB_HTTPS_PORT=5001
|
||||||
|
|||||||
@@ -39,8 +39,10 @@ VOLUME ["/app/data"]
|
|||||||
# SMTP 465, IMAP 143, direct-TLS IMAP 993 (the actual standard ports — binding them
|
# SMTP 465, IMAP 143, direct-TLS IMAP 993 (the actual standard ports — binding them
|
||||||
# needs no setcap/capability here since this container runs as root), admin/webmail
|
# needs no setcap/capability here since this container runs as root), admin/webmail
|
||||||
# HTTP 5000, HTTPS 5001 (deliberately non-privileged; put a reverse proxy or the host's
|
# HTTP 5000, HTTPS 5001 (deliberately non-privileged; put a reverse proxy or the host's
|
||||||
# own 80/443 in front if you want those too).
|
# own 80/443 in front if you want those too). Port 80 is only actually bound while
|
||||||
EXPOSE 25 465 143 993 5000 5001
|
# [LetsEncrypt] challenge_type=http-01 is enabled and an obtain/renew is in flight —
|
||||||
|
# harmless to expose even when unused.
|
||||||
|
EXPOSE 25 465 143 993 80 5000 5001
|
||||||
|
|
||||||
# --host 0.0.0.0 is required: the binary's own default is 127.0.0.1, which would only
|
# --host 0.0.0.0 is required: the binary's own default is 127.0.0.1, which would only
|
||||||
# be reachable from inside this container, never through a published port.
|
# be reachable from inside this container, never through a published port.
|
||||||
|
|||||||
@@ -39,7 +39,9 @@ RUN chmod +x /usr/local/bin/entrypoint-rspamd.sh
|
|||||||
WORKDIR /app/data
|
WORKDIR /app/data
|
||||||
VOLUME ["/app/data", "/var/lib/rspamd"]
|
VOLUME ["/app/data", "/var/lib/rspamd"]
|
||||||
|
|
||||||
EXPOSE 25 465 143 993 5000 5001
|
# Port 80 is only actually bound while [LetsEncrypt] challenge_type=http-01 is enabled
|
||||||
|
# and an obtain/renew is in flight — harmless to expose even when unused.
|
||||||
|
EXPOSE 25 465 143 993 80 5000 5001
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||||
CMD curl -fs http://127.0.0.1:5000/health || exit 1
|
CMD curl -fs http://127.0.0.1:5000/health || exit 1
|
||||||
|
|||||||
+25
-5
@@ -23,11 +23,12 @@ docker compose --profile with-rspamd up -d --build
|
|||||||
Either way, the app itself now binds the real standard mail ports by default — 25
|
Either way, the app itself now binds the real standard mail ports by default — 25
|
||||||
(SMTP), 465 (direct-TLS SMTP), 143 (IMAP), 993 (direct-TLS IMAP) — and the admin/webmail
|
(SMTP), 465 (direct-TLS SMTP), 143 (IMAP), 993 (direct-TLS IMAP) — and the admin/webmail
|
||||||
UI on its usual non-privileged 5000/5001 (HTTP/HTTPS); put your own reverse proxy or a
|
UI on its usual non-privileged 5000/5001 (HTTP/HTTPS); put your own reverse proxy or a
|
||||||
`80:5000`/`443:5001` port mapping in front if you want those on 80/443 too. Binding the
|
`80:5000`/`443:5001` port mapping in front if you want those on 80/443 too (note port 80
|
||||||
low mail ports needs no extra capability here since the container runs as root. Copy
|
is already published here for Let's Encrypt HTTP-01, see below — pick a different host
|
||||||
`.env.example` to `.env` in this folder to change any host-side port — useful if
|
port for the web UI's 80 mapping if you use both). Binding the low mail ports needs no
|
||||||
something else on the host already owns 25/143/etc., or if you want to run both
|
extra capability here since the container runs as root. Copy `.env.example` to `.env` in
|
||||||
profiles side by side.
|
this folder to change any host-side port — useful if something else on the host already
|
||||||
|
owns 25/143/etc., or if you want to run both profiles side by side.
|
||||||
|
|
||||||
## What happens on first boot
|
## What happens on first boot
|
||||||
|
|
||||||
@@ -76,6 +77,25 @@ This bundle intentionally skips Redis — rspamd runs fine without it for SPF/DK
|
|||||||
regexp-based scoring, but Bayes learning and greylisting need it. Add a `redis` service
|
regexp-based scoring, but Bayes learning and greylisting need it. Add a `redis` service
|
||||||
to `docker-compose.yml` and point rspamd's `redis.conf` at it if you need those.
|
to `docker-compose.yml` and point rspamd's `redis.conf` at it if you need those.
|
||||||
|
|
||||||
|
## Let's Encrypt HTTP-01 (no DNS provider needed)
|
||||||
|
|
||||||
|
If this domain's DNS isn't hosted anywhere the app can automate, enable HTTP-01 on the
|
||||||
|
admin dashboard's Let's Encrypt page and restart the container — it runs independently
|
||||||
|
alongside (or instead of) the DNS-01 flow above, obtaining its own separate certificate.
|
||||||
|
Port 80 (already published by `docker-compose.yml`) stays bound for the container's
|
||||||
|
whole lifetime once enabled, not just during an obtain — `curl` it and you should get a
|
||||||
|
plain `200 ok`, the quickest way to confirm your port-forwarding/reverse-proxy setup
|
||||||
|
actually reaches this container. Optionally also request the certificate for this
|
||||||
|
container's public IP address (autodetected, or a manual override) so clients connecting
|
||||||
|
by bare IP get a trusted cert too — note this uses Let's Encrypt's `shortlived` profile,
|
||||||
|
so those certificates renew roughly every few days instead of every couple months
|
||||||
|
(handled automatically).
|
||||||
|
|
||||||
|
Which listener actually uses which certificate — the DNS-01 cert, the HTTP-01 cert, or
|
||||||
|
the custom/self-signed one — is chosen independently per listener (SMTP-TLS, IMAP-TLS,
|
||||||
|
web UI) on the admin dashboard's Settings page. A common setup: HTTP-01 for
|
||||||
|
SMTP/IMAP, DNS-01 (or a real custom cert) for the web UI.
|
||||||
|
|
||||||
## Persistence
|
## Persistence
|
||||||
|
|
||||||
| Volume | What's in it |
|
| Volume | What's in it |
|
||||||
|
|||||||
@@ -4,9 +4,11 @@
|
|||||||
# docker compose --profile with-rspamd up -d --build # mailserver + rspamd, same container
|
# docker compose --profile with-rspamd up -d --build # mailserver + rspamd, same container
|
||||||
#
|
#
|
||||||
# Both default to the standard mail ports on the host (25/465/143/993) — the app itself
|
# Both default to the standard mail ports on the host (25/465/143/993) — the app itself
|
||||||
# now binds those directly, no port remapping needed — plus the app's own non-privileged
|
# now binds those directly, no port remapping needed — plus port 80 (only actually used
|
||||||
# web ports (5000/5001; put a reverse proxy or your own 80/443 mapping in front of those
|
# while Let's Encrypt HTTP-01 is enabled, see internal/webui's Let's Encrypt page) and
|
||||||
# if you want the dashboard on standard web ports too). Only run one profile at a time
|
# the app's own non-privileged web ports (5000/5001; put a reverse proxy or your own
|
||||||
|
# 80/443 mapping in front of those if you want the dashboard on standard web ports too —
|
||||||
|
# note port 80 is already claimed here for HTTP-01 if you enable it). Only run one profile at a time
|
||||||
# unless you've overridden the host ports for one of them (see .env.example) — they'd
|
# unless you've overridden the host ports for one of them (see .env.example) — they'd
|
||||||
# otherwise both try to bind the same host ports.
|
# otherwise both try to bind the same host ports.
|
||||||
services:
|
services:
|
||||||
@@ -22,6 +24,7 @@ services:
|
|||||||
- "${SMTP_TLS_PORT:-465}:465"
|
- "${SMTP_TLS_PORT:-465}:465"
|
||||||
- "${IMAP_PORT:-143}:143"
|
- "${IMAP_PORT:-143}:143"
|
||||||
- "${IMAP_TLS_PORT:-993}:993"
|
- "${IMAP_TLS_PORT:-993}:993"
|
||||||
|
- "${ACME_HTTP_PORT:-80}:80"
|
||||||
- "${WEB_HTTP_PORT:-5000}:5000"
|
- "${WEB_HTTP_PORT:-5000}:5000"
|
||||||
- "${WEB_HTTPS_PORT:-5001}:5001"
|
- "${WEB_HTTPS_PORT:-5001}:5001"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -39,6 +42,7 @@ services:
|
|||||||
- "${SMTP_TLS_PORT:-465}:465"
|
- "${SMTP_TLS_PORT:-465}:465"
|
||||||
- "${IMAP_PORT:-143}:143"
|
- "${IMAP_PORT:-143}:143"
|
||||||
- "${IMAP_TLS_PORT:-993}:993"
|
- "${IMAP_TLS_PORT:-993}:993"
|
||||||
|
- "${ACME_HTTP_PORT:-80}:80"
|
||||||
- "${WEB_HTTP_PORT:-5000}:5000"
|
- "${WEB_HTTP_PORT:-5000}:5000"
|
||||||
- "${WEB_HTTPS_PORT:-5001}:5001"
|
- "${WEB_HTTPS_PORT:-5001}:5001"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
package acmecert
|
package acmecert
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/rsa"
|
"crypto/rsa"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"crypto/x509/pkix"
|
"crypto/x509/pkix"
|
||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
"math/big"
|
"math/big"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -14,6 +17,8 @@ import (
|
|||||||
|
|
||||||
"github.com/go-acme/lego/v4/registration"
|
"github.com/go-acme/lego/v4/registration"
|
||||||
"gopkg.in/ini.v1"
|
"gopkg.in/ini.v1"
|
||||||
|
|
||||||
|
"mailgoserver/internal/tlsutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestLoadOrCreateAccountGeneratesAndPersistsKey(t *testing.T) {
|
func TestLoadOrCreateAccountGeneratesAndPersistsKey(t *testing.T) {
|
||||||
@@ -102,6 +107,63 @@ func TestNeedsRenewal(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNeedsRenewalTrueForSelfSignedPlaceholderEvenWithLongExpiry(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
certFile := filepath.Join(dir, "server.crt")
|
||||||
|
keyFile := filepath.Join(dir, "server.key")
|
||||||
|
if err := tlsutil.GenerateSelfSignedCert(certFile, keyFile); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mgr := &Manager{Cfg: ini.Empty(), CertFile: certFile}
|
||||||
|
|
||||||
|
// tlsutil's self-signed cert is valid for a year — a pure expiry check would say
|
||||||
|
// "no renewal needed," which is exactly the bug: a restart must still recognize
|
||||||
|
// this as "no real certificate obtained yet" and trigger the first real obtain.
|
||||||
|
needs, err := mgr.NeedsRenewal()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !needs {
|
||||||
|
t.Fatal("expected NeedsRenewal=true for the self-signed placeholder despite its long expiry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNeedsRenewalUsesShortThresholdForHTTP01IncludeIP(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
certFile := filepath.Join(dir, "server.crt")
|
||||||
|
cfg := ini.Empty()
|
||||||
|
cfg.Section("LetsEncryptHTTP").Key("include_ip").SetValue("true")
|
||||||
|
mgr := &Manager{Cfg: cfg, Section: "LetsEncryptHTTP", ChallengeType: "http-01", CertFile: certFile}
|
||||||
|
|
||||||
|
// 4 days left: not within the 1-day short threshold, even though it WOULD be
|
||||||
|
// within the normal 30-day one — proves the short threshold is actually in effect.
|
||||||
|
writeFixtureCert(t, certFile, time.Now().Add(4*24*time.Hour))
|
||||||
|
if needs, err := mgr.NeedsRenewal(); err != nil || needs {
|
||||||
|
t.Fatalf("expected NeedsRenewal=false with 4 days left under the short threshold, got %v (err=%v)", needs, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 12 hours left: within the 1-day short threshold.
|
||||||
|
writeFixtureCert(t, certFile, time.Now().Add(12*time.Hour))
|
||||||
|
if needs, err := mgr.NeedsRenewal(); err != nil || !needs {
|
||||||
|
t.Fatalf("expected NeedsRenewal=true with 12 hours left, got %v (err=%v)", needs, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNeedsRenewalUsesNormalThresholdForHTTP01WithoutIncludeIP(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
certFile := filepath.Join(dir, "server.crt")
|
||||||
|
cfg := ini.Empty()
|
||||||
|
cfg.Section("LetsEncryptHTTP").Key("include_ip").SetValue("false")
|
||||||
|
mgr := &Manager{Cfg: cfg, Section: "LetsEncryptHTTP", ChallengeType: "http-01", CertFile: certFile}
|
||||||
|
|
||||||
|
// 4 days left: within the normal 30-day threshold — confirms include_ip=false
|
||||||
|
// gets the normal threshold, not the short one.
|
||||||
|
writeFixtureCert(t, certFile, time.Now().Add(4*24*time.Hour))
|
||||||
|
if needs, err := mgr.NeedsRenewal(); err != nil || !needs {
|
||||||
|
t.Fatalf("expected NeedsRenewal=true with 4 days left under the normal threshold, got %v (err=%v)", needs, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNeedsRenewalMissingCertIsTrue(t *testing.T) {
|
func TestNeedsRenewalMissingCertIsTrue(t *testing.T) {
|
||||||
mgr := &Manager{Cfg: ini.Empty(), CertFile: filepath.Join(t.TempDir(), "does-not-exist.crt")}
|
mgr := &Manager{Cfg: ini.Empty(), CertFile: filepath.Join(t.TempDir(), "does-not-exist.crt")}
|
||||||
needs, err := mgr.NeedsRenewal()
|
needs, err := mgr.NeedsRenewal()
|
||||||
@@ -131,6 +193,101 @@ func TestBuildDNSProviderDigitalOceanRequiresToken(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDetectWANIP(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("203.0.113.42\n"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
old := wanIPServiceURL
|
||||||
|
wanIPServiceURL = srv.URL
|
||||||
|
defer func() { wanIPServiceURL = old }()
|
||||||
|
|
||||||
|
ip, err := DetectWANIP(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if ip != "203.0.113.42" {
|
||||||
|
t.Fatalf("expected trimmed IP %q, got %q", "203.0.113.42", ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveIdentifiersDNS01IgnoresIPSettings(t *testing.T) {
|
||||||
|
cfg := ini.Empty()
|
||||||
|
sec := cfg.Section("LetsEncrypt")
|
||||||
|
sec.Key("domains").SetValue("mail.example.com")
|
||||||
|
sec.Key("include_ip").SetValue("true") // should be ignored outside http-01
|
||||||
|
mgr := &Manager{Cfg: cfg, Section: "LetsEncrypt", ChallengeType: "dns-01"}
|
||||||
|
|
||||||
|
got, err := mgr.resolveIdentifiers(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(got) != 1 || got[0] != "mail.example.com" {
|
||||||
|
t.Fatalf("expected just the domain, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDomainsAreLowercased(t *testing.T) {
|
||||||
|
cfg := ini.Empty()
|
||||||
|
sec := cfg.Section("LetsEncrypt")
|
||||||
|
sec.Key("domains").SetValue("adsl-1-2-3-4.example.ISP.COM, Mail.Example.com")
|
||||||
|
mgr := &Manager{Cfg: cfg, Section: "LetsEncrypt", ChallengeType: "dns-01"}
|
||||||
|
|
||||||
|
got := mgr.domains()
|
||||||
|
want := []string{"adsl-1-2-3-4.example.isp.com", "mail.example.com"}
|
||||||
|
if len(got) != 2 || got[0] != want[0] || got[1] != want[1] {
|
||||||
|
t.Fatalf("expected lowercased %v, got %v", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveIdentifiersHTTP01WithManualIPOverride(t *testing.T) {
|
||||||
|
cfg := ini.Empty()
|
||||||
|
sec := cfg.Section("LetsEncryptHTTP")
|
||||||
|
sec.Key("domains").SetValue("mail.example.com")
|
||||||
|
sec.Key("include_ip").SetValue("true")
|
||||||
|
sec.Key("ip_override").SetValue("198.51.100.7")
|
||||||
|
mgr := &Manager{Cfg: cfg, Section: "LetsEncryptHTTP", ChallengeType: "http-01"}
|
||||||
|
|
||||||
|
got, err := mgr.resolveIdentifiers(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := []string{"mail.example.com", "198.51.100.7"}
|
||||||
|
if len(got) != 2 || got[0] != want[0] || got[1] != want[1] {
|
||||||
|
t.Fatalf("expected %v, got %v", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveIdentifiersHTTP01AutodetectsIPWhenOverrideBlank(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("203.0.113.99"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
old := wanIPServiceURL
|
||||||
|
wanIPServiceURL = srv.URL
|
||||||
|
defer func() { wanIPServiceURL = old }()
|
||||||
|
|
||||||
|
cfg := ini.Empty()
|
||||||
|
sec := cfg.Section("LetsEncryptHTTP")
|
||||||
|
sec.Key("include_ip").SetValue("true")
|
||||||
|
mgr := &Manager{Cfg: cfg, Section: "LetsEncryptHTTP", ChallengeType: "http-01"}
|
||||||
|
|
||||||
|
got, err := mgr.resolveIdentifiers(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(got) != 1 || got[0] != "203.0.113.99" {
|
||||||
|
t.Fatalf("expected autodetected IP as the sole identifier, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveIdentifiersEmptyIsError(t *testing.T) {
|
||||||
|
mgr := &Manager{Cfg: ini.Empty(), Section: "LetsEncrypt", ChallengeType: "dns-01"}
|
||||||
|
if _, err := mgr.resolveIdentifiers(context.Background()); err == nil {
|
||||||
|
t.Fatal("expected an error when no domains and no IP are configured")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBuildDNSProviderCloudflare(t *testing.T) {
|
func TestBuildDNSProviderCloudflare(t *testing.T) {
|
||||||
cfg := ini.Empty()
|
cfg := ini.Empty()
|
||||||
sec := cfg.Section("LetsEncrypt")
|
sec := cfg.Section("LetsEncrypt")
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package acmecert
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"mailgoserver/internal/toolbox"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HTTP01Server is a long-lived HTTP server dedicated to the [LetsEncryptHTTP] HTTP-01
|
||||||
|
// challenge — started once at boot (if enabled) and kept running for the whole process
|
||||||
|
// lifetime, unlike lego's own http01.ProviderServer (challenge/http01), which binds and
|
||||||
|
// unbinds the port on every single obtain/renew. Staying up lets an operator behind
|
||||||
|
// NAT/a reverse proxy verify their port-forwarding actually reaches this host (curl it
|
||||||
|
// directly, get a 200) without waiting for or burning a real, rate-limited ACME attempt.
|
||||||
|
// It implements lego's challenge.Provider interface (Present/CleanUp) so a Manager hands
|
||||||
|
// it token/keyAuth pairs as they come, instead of each obtain spinning up its own
|
||||||
|
// server.
|
||||||
|
type HTTP01Server struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
tokens map[string]string // token -> keyAuth
|
||||||
|
|
||||||
|
server *http.Server
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHTTP01Server() *HTTP01Server {
|
||||||
|
s := &HTTP01Server{tokens: map[string]string{}}
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/.well-known/acme-challenge/", s.serveChallenge)
|
||||||
|
mux.HandleFunc("/", s.serveHealth)
|
||||||
|
s.server = &http.Server{Handler: mux}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *HTTP01Server) serveHealth(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/plain")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte("ok"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *HTTP01Server) serveChallenge(w http.ResponseWriter, r *http.Request) {
|
||||||
|
token := strings.TrimPrefix(r.URL.Path, "/.well-known/acme-challenge/")
|
||||||
|
s.mu.RLock()
|
||||||
|
keyAuth, ok := s.tokens[token]
|
||||||
|
s.mu.RUnlock()
|
||||||
|
if !ok {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/plain")
|
||||||
|
w.Write([]byte(keyAuth))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start binds addr (e.g. ":80") and serves in the background until the process exits.
|
||||||
|
// Call once at boot. A bind failure (port already in use, missing
|
||||||
|
// CAP_NET_BIND_SERVICE) is logged rather than crashing the process — HTTP-01
|
||||||
|
// obtain/renew attempts then fail with a clear error from lego instead, same as any
|
||||||
|
// other misconfiguration surfaced via Status.LastError.
|
||||||
|
func (s *HTTP01Server) Start(addr string, logger *toolbox.Logger) {
|
||||||
|
s.server.Addr = addr
|
||||||
|
go func() {
|
||||||
|
if err := s.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
logger.Error("HTTP-01 challenge server: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Present and CleanUp implement github.com/go-acme/lego/v4/challenge.Provider.
|
||||||
|
func (s *HTTP01Server) Present(domain, token, keyAuth string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.tokens[token] = keyAuth
|
||||||
|
s.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *HTTP01Server) CleanUp(domain, token, keyAuth string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
delete(s.tokens, token)
|
||||||
|
s.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package acmecert
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"mailgoserver/internal/toolbox"
|
||||||
|
)
|
||||||
|
|
||||||
|
// freePort asks the OS for an unused TCP port, mirroring the pattern used elsewhere in
|
||||||
|
// this codebase's tests for binding to an ephemeral port deterministically.
|
||||||
|
func freePort(t *testing.T) int {
|
||||||
|
t.Helper()
|
||||||
|
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer l.Close()
|
||||||
|
return l.Addr().(*net.TCPAddr).Port
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitUntilUp(t *testing.T, url string) {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if resp, err := http.Get(url); err == nil {
|
||||||
|
resp.Body.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatalf("server at %s never came up", url)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHTTP01ServerHealthEndpointReturns200(t *testing.T) {
|
||||||
|
port := freePort(t)
|
||||||
|
s := NewHTTP01Server()
|
||||||
|
s.Start(fmt.Sprintf("127.0.0.1:%d", port), toolbox.GetLogger("test"))
|
||||||
|
|
||||||
|
url := fmt.Sprintf("http://127.0.0.1:%d/anything", port)
|
||||||
|
waitUntilUp(t, url)
|
||||||
|
|
||||||
|
resp, err := http.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHTTP01ServerServesPresentedChallenge(t *testing.T) {
|
||||||
|
port := freePort(t)
|
||||||
|
s := NewHTTP01Server()
|
||||||
|
s.Start(fmt.Sprintf("127.0.0.1:%d", port), toolbox.GetLogger("test"))
|
||||||
|
waitUntilUp(t, fmt.Sprintf("http://127.0.0.1:%d/", port))
|
||||||
|
|
||||||
|
if err := s.Present("example.com", "sometoken", "sometoken.keyauth"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/.well-known/acme-challenge/sometoken", port))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode != http.StatusOK || string(body) != "sometoken.keyauth" {
|
||||||
|
t.Fatalf("expected 200 with keyAuth body, got %d %q", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.CleanUp("example.com", "sometoken", "sometoken.keyauth"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
resp2, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/.well-known/acme-challenge/sometoken", port))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp2.Body.Close()
|
||||||
|
if resp2.StatusCode != http.StatusNotFound {
|
||||||
|
t.Fatalf("expected 404 after CleanUp, got %d", resp2.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
+146
-46
@@ -1,9 +1,9 @@
|
|||||||
// Package acmecert obtains and renews Let's Encrypt certificates via the DNS-01
|
// Package acmecert obtains and renews Let's Encrypt certificates via DNS-01 or HTTP-01,
|
||||||
// challenge, as an admin-configurable alternative to the self-signed certificate
|
// as an admin-configurable alternative to the self-signed certificate tlsutil generates
|
||||||
// tlsutil generates by default. Obtained certificates are written to the same
|
// by default. main.go runs up to two independent Manager instances at once (one per
|
||||||
// cert/key file paths the self-signed generator already uses, so the SMTP/IMAP TLS
|
// challenge type, reading from separate ini sections and writing to separate cert/key
|
||||||
// listeners (via tlsutil.CertReloader) never need to know which produced the active
|
// files) so a DNS-01 cert and an HTTP-01 cert can be obtained simultaneously and
|
||||||
// certificate.
|
// assigned to different listeners — see [TLS]'s *_cert settings.
|
||||||
package acmecert
|
package acmecert
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -28,38 +28,62 @@ import (
|
|||||||
|
|
||||||
// renewalThreshold mirrors the standard ACME-client convention (certbot/lego CLI):
|
// renewalThreshold mirrors the standard ACME-client convention (certbot/lego CLI):
|
||||||
// renew once a certificate is within 30 days of its (90-day, for Let's Encrypt) expiry.
|
// renew once a certificate is within 30 days of its (90-day, for Let's Encrypt) expiry.
|
||||||
|
// Used for every case except shortLivedRenewalThreshold below.
|
||||||
const renewalThreshold = 30 * 24 * time.Hour
|
const renewalThreshold = 30 * 24 * time.Hour
|
||||||
|
|
||||||
|
// shortLivedRenewalThreshold applies only to HTTP-01 with include_ip set, which forces
|
||||||
|
// Let's Encrypt's "shortlived" profile (~6 day validity — see obtain()'s Profile
|
||||||
|
// handling). Using the normal 30-day threshold there would mean the cert looks "due for
|
||||||
|
// renewal" on literally every single renewal check from the moment it's issued,
|
||||||
|
// hammering the ACME API every 12h instead of renewing roughly once every ~5 days as
|
||||||
|
// intended — a 1-day buffer before expiry keeps a comfortable margin without that.
|
||||||
|
const shortLivedRenewalThreshold = 24 * time.Hour
|
||||||
|
|
||||||
// Status is a read-only snapshot of the current Let's Encrypt configuration and the
|
// Status is a read-only snapshot of the current Let's Encrypt configuration and the
|
||||||
// last renewal attempt, for the admin settings page.
|
// last renewal attempt, for the admin settings page.
|
||||||
type Status struct {
|
type Status struct {
|
||||||
Enabled bool
|
Enabled bool
|
||||||
Staging bool
|
Staging bool
|
||||||
Domains []string
|
ChallengeType string // "dns-01" or "http-01"
|
||||||
Provider string
|
Domains []string
|
||||||
NotAfter time.Time // parsed live from CertFile each call — never cached
|
Provider string
|
||||||
LastAttempt time.Time // zero value = no attempt yet this process run
|
IncludeIP bool
|
||||||
LastError string // empty if the last attempt succeeded, or none has run yet
|
NotAfter time.Time // parsed live from CertFile each call — never cached
|
||||||
|
LastAttempt time.Time // zero value = no attempt yet this process run
|
||||||
|
LastError string // empty if the last attempt succeeded, or none has run yet
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manager obtains and renews certificates for one configured domain set.
|
// Manager obtains and renews certificates for one configured domain set, using one
|
||||||
|
// fixed challenge type read from one fixed ini section (both set once at construction,
|
||||||
|
// via New — never toggled at runtime, since main.go runs one Manager per challenge
|
||||||
|
// type). ChallengeType is "dns-01" or "http-01".
|
||||||
type Manager struct {
|
type Manager struct {
|
||||||
Cfg *ini.File
|
Cfg *ini.File
|
||||||
|
Section string
|
||||||
|
ChallengeType string
|
||||||
CertFile, KeyFile string
|
CertFile, KeyFile string
|
||||||
DataDir string
|
DataDir string
|
||||||
Reloader *tlsutil.CertReloader
|
Reloader *tlsutil.CertReloader
|
||||||
Logger *toolbox.Logger
|
Logger *toolbox.Logger
|
||||||
|
|
||||||
|
// HTTP01Server is the long-lived challenge responder (see http01server.go) this
|
||||||
|
// Manager hands token/keyAuth pairs to during an obtain. Only set (by main.go) on
|
||||||
|
// the HTTP-01 Manager instance; nil on the DNS-01 one, which never uses it.
|
||||||
|
HTTP01Server *HTTP01Server
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
lastAttempt time.Time
|
lastAttempt time.Time
|
||||||
lastError string
|
lastError string
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(cfg *ini.File, certFile, keyFile, dataDir string, reloader *tlsutil.CertReloader, logger *toolbox.Logger) *Manager {
|
func New(cfg *ini.File, section, challengeType, certFile, keyFile, dataDir string, reloader *tlsutil.CertReloader, logger *toolbox.Logger) *Manager {
|
||||||
return &Manager{Cfg: cfg, CertFile: certFile, KeyFile: keyFile, DataDir: dataDir, Reloader: reloader, Logger: logger}
|
return &Manager{
|
||||||
|
Cfg: cfg, Section: section, ChallengeType: challengeType,
|
||||||
|
CertFile: certFile, KeyFile: keyFile, DataDir: dataDir, Reloader: reloader, Logger: logger,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) section() *ini.Section { return m.Cfg.Section("LetsEncrypt") }
|
func (m *Manager) section() *ini.Section { return m.Cfg.Section(m.Section) }
|
||||||
|
|
||||||
func (m *Manager) domains() []string {
|
func (m *Manager) domains() []string {
|
||||||
raw := m.section().Key("domains").String()
|
raw := m.section().Key("domains").String()
|
||||||
@@ -69,7 +93,14 @@ func (m *Manager) domains() []string {
|
|||||||
parts := strings.Split(raw, ",")
|
parts := strings.Split(raw, ",")
|
||||||
out := make([]string, 0, len(parts))
|
out := make([]string, 0, len(parts))
|
||||||
for _, p := range parts {
|
for _, p := range parts {
|
||||||
if p = strings.TrimSpace(p); p != "" {
|
// Lowercased: DNS names are case-insensitive, and Let's Encrypt's order
|
||||||
|
// response always comes back lowercased regardless of what was submitted —
|
||||||
|
// lego's RFC 8555 §7.4 compliance check then compares the two verbatim, so a
|
||||||
|
// mixed-case domain (e.g. an ISP-assigned "adsl-1-2-3-4.example.ISP.COM"
|
||||||
|
// reverse-DNS hostname) fails with a spurious "order identifiers have been
|
||||||
|
// modified" error unless normalized before submission. Confirmed live: a user
|
||||||
|
// hit exactly this with an uppercase-suffixed rDNS hostname.
|
||||||
|
if p = strings.ToLower(strings.TrimSpace(p)); p != "" {
|
||||||
out = append(out, p)
|
out = append(out, p)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -83,12 +114,19 @@ func (m *Manager) domains() []string {
|
|||||||
func (m *Manager) Status() Status {
|
func (m *Manager) Status() Status {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
s := Status{
|
s := Status{
|
||||||
Enabled: m.section().Key("enabled").MustBool(false),
|
Enabled: m.section().Key("enabled").MustBool(false),
|
||||||
Staging: m.section().Key("staging").MustBool(false),
|
Staging: m.section().Key("staging").MustBool(false),
|
||||||
Domains: m.domains(),
|
ChallengeType: m.ChallengeType,
|
||||||
Provider: m.section().Key("dns_provider").String(),
|
Domains: m.domains(),
|
||||||
LastAttempt: m.lastAttempt,
|
LastAttempt: m.lastAttempt,
|
||||||
LastError: m.lastError,
|
LastError: m.lastError,
|
||||||
|
}
|
||||||
|
if m.ChallengeType == "http-01" {
|
||||||
|
httpPort := m.Cfg.Section("Server").Key("HTTP_LETSENCRYPT_PORT").MustString("80")
|
||||||
|
s.Provider = "HTTP-01 (port " + httpPort + ")"
|
||||||
|
s.IncludeIP = m.section().Key("include_ip").MustBool(false)
|
||||||
|
} else {
|
||||||
|
s.Provider = m.section().Key("dns_provider").String()
|
||||||
}
|
}
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
|
|
||||||
@@ -98,17 +136,30 @@ func (m *Manager) Status() Status {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
// NeedsRenewal reports whether the certificate currently at CertFile is within 30 days
|
// NeedsRenewal reports whether the certificate currently at CertFile is within
|
||||||
// of expiry (or unreadable/unparseable, which is treated as "yes" — nothing usable is
|
// renewalThreshold (30 days) — or shortLivedRenewalThreshold (1 day) for HTTP-01 with
|
||||||
// there to keep). This is a pure expiry check; it makes no attempt to distinguish a
|
// include_ip, since that cert is only valid ~6 days to begin with — of expiry,
|
||||||
// self-signed cert from an ACME-obtained one (see the caller in main.go's renewal
|
// unreadable/unparseable (nothing usable there to keep), or is still the self-signed
|
||||||
// ticker for how the very-first-check case is handled instead).
|
// placeholder tlsutil generates by default (recognized by its Issuer CN — see
|
||||||
|
// tlsutil.GenerateSelfSignedCert — since a freshly-generated one has ~1 year left and
|
||||||
|
// would otherwise never look like it "needs" replacing by the first real certificate).
|
||||||
|
// This is a pure disk-state check with no dependency on in-memory process state, so it
|
||||||
|
// gives the same correct answer whether this is the first check after boot or the
|
||||||
|
// hundredth — restarting the process must never by itself trigger a redundant
|
||||||
|
// re-obtain of an already-valid, already-real certificate.
|
||||||
func (m *Manager) NeedsRenewal() (bool, error) {
|
func (m *Manager) NeedsRenewal() (bool, error) {
|
||||||
cert, err := readLeafCertificate(m.CertFile)
|
cert, err := readLeafCertificate(m.CertFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
return time.Until(cert.NotAfter) < renewalThreshold, nil
|
if cert.Issuer.CommonName == "localhost" {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
threshold := renewalThreshold
|
||||||
|
if m.ChallengeType == "http-01" && m.section().Key("include_ip").MustBool(false) {
|
||||||
|
threshold = shortLivedRenewalThreshold
|
||||||
|
}
|
||||||
|
return time.Until(cert.NotAfter) < threshold, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func readLeafCertificate(certFile string) (*x509.Certificate, error) {
|
func readLeafCertificate(certFile string) (*x509.Certificate, error) {
|
||||||
@@ -123,14 +174,17 @@ func readLeafCertificate(certFile string) (*x509.Certificate, error) {
|
|||||||
return x509.ParseCertificate(block.Bytes)
|
return x509.ParseCertificate(block.Bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enabled reports whether this manager's ini section has 'enabled = true'.
|
||||||
|
func (m *Manager) Enabled() bool { return m.section().Key("enabled").MustBool(false) }
|
||||||
|
|
||||||
// ObtainOrRenew requests a certificate for the configured domains and, on success,
|
// ObtainOrRenew requests a certificate for the configured domains and, on success,
|
||||||
// writes it to CertFile/KeyFile and hot-reloads the live TLS listeners. Used for both
|
// writes it to CertFile/KeyFile and hot-reloads the live TLS listeners. Used for both
|
||||||
// first issuance and renewal — lego's Obtain covers both identically, so there is no
|
// first issuance and renewal — lego's Obtain covers both identically, so there is no
|
||||||
// separate renewal code path. A no-op (nil error) if Let's Encrypt isn't enabled. On
|
// separate renewal code path. A no-op (nil error) if this manager isn't enabled. On
|
||||||
// any failure, the cert/key files on disk are left untouched — whatever was already
|
// any failure, the cert/key files on disk are left untouched — whatever was already
|
||||||
// serving (self-signed or a previous ACME cert) keeps working.
|
// serving (self-signed or a previous ACME cert) keeps working.
|
||||||
func (m *Manager) ObtainOrRenew(ctx context.Context) error {
|
func (m *Manager) ObtainOrRenew(ctx context.Context) error {
|
||||||
if !m.section().Key("enabled").MustBool(false) {
|
if !m.Enabled() {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,10 +203,11 @@ func (m *Manager) ObtainOrRenew(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) obtain(ctx context.Context) error {
|
func (m *Manager) obtain(ctx context.Context) error {
|
||||||
domains := m.domains()
|
identifiers, err := m.resolveIdentifiers(ctx)
|
||||||
if len(domains) == 0 {
|
if err != nil {
|
||||||
return fmt.Errorf("acmecert: no domains configured")
|
return err
|
||||||
}
|
}
|
||||||
|
m.Logger.Info("%s: starting obtain/renew for %s", m.ChallengeType, strings.Join(identifiers, ", "))
|
||||||
|
|
||||||
email := m.section().Key("contact_email").String()
|
email := m.section().Key("contact_email").String()
|
||||||
user, err := loadOrCreateAccount(m.DataDir, email)
|
user, err := loadOrCreateAccount(m.DataDir, email)
|
||||||
@@ -171,12 +226,21 @@ func (m *Manager) obtain(ctx context.Context) error {
|
|||||||
return fmt.Errorf("create ACME client: %w", err)
|
return fmt.Errorf("create ACME client: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
provider, err := buildDNSProvider(m.Cfg)
|
if m.ChallengeType == "http-01" {
|
||||||
if err != nil {
|
if m.HTTP01Server == nil {
|
||||||
return fmt.Errorf("configure DNS provider: %w", err)
|
return fmt.Errorf("HTTP-01 challenge server is not running (enable [LetsEncryptHTTP] and restart)")
|
||||||
}
|
}
|
||||||
if err := client.Challenge.SetDNS01Provider(provider); err != nil {
|
if err := client.Challenge.SetHTTP01Provider(m.HTTP01Server); err != nil {
|
||||||
return fmt.Errorf("set DNS-01 provider: %w", err)
|
return fmt.Errorf("set HTTP-01 provider: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
provider, err := buildDNSProvider(m.Cfg)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("configure DNS provider: %w", err)
|
||||||
|
}
|
||||||
|
if err := client.Challenge.SetDNS01Provider(provider); err != nil {
|
||||||
|
return fmt.Errorf("set DNS-01 provider: %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if user.Registration == nil {
|
if user.Registration == nil {
|
||||||
@@ -190,10 +254,18 @@ func (m *Manager) obtain(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cert, err := client.Certificate.Obtain(certificate.ObtainRequest{
|
req := certificate.ObtainRequest{Domains: identifiers, Bundle: true}
|
||||||
Domains: domains,
|
if m.ChallengeType == "http-01" && m.section().Key("include_ip").MustBool(false) {
|
||||||
Bundle: true,
|
// Let's Encrypt's default profile rejects IP identifiers outright ("Default
|
||||||
})
|
// profile does not permit IP address identifiers") — only the "shortlived"
|
||||||
|
// profile currently supports them (mixed with DNS names too), at the cost of a
|
||||||
|
// much shorter (~6 day) validity. NeedsRenewal's 30-day threshold already
|
||||||
|
// treats that as "always needs renewal," which is exactly right here — it'll
|
||||||
|
// just get renewed on essentially every 12h tick instead of sitting idle for
|
||||||
|
// weeks, which is the correct behavior for a cert this short-lived.
|
||||||
|
req.Profile = "shortlived"
|
||||||
|
}
|
||||||
|
cert, err := client.Certificate.Obtain(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("obtain certificate: %w", err)
|
return fmt.Errorf("obtain certificate: %w", err)
|
||||||
}
|
}
|
||||||
@@ -208,6 +280,34 @@ func (m *Manager) obtain(ctx context.Context) error {
|
|||||||
return fmt.Errorf("reload TLS certificate: %w", err)
|
return fmt.Errorf("reload TLS certificate: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
m.Logger.Info("Let's Encrypt certificate obtained for %s", strings.Join(domains, ", "))
|
m.Logger.Info("Let's Encrypt certificate obtained for %s", strings.Join(identifiers, ", "))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveIdentifiers builds the domain/IP list to request a certificate for: the
|
||||||
|
// configured domains, plus (for http-01 with include_ip set) one IP address — lego's
|
||||||
|
// ACME client auto-detects an IP-shaped string in this list and requests it as an
|
||||||
|
// RFC 8738 IP identifier rather than a DNS identifier. The IP is either the manual
|
||||||
|
// override or, if that's blank, autodetected via DetectWANIP. See obtain()'s Profile
|
||||||
|
// handling: an IP identifier needs Let's Encrypt's "shortlived" profile, which does
|
||||||
|
// support mixing DNS names and an IP in one order.
|
||||||
|
func (m *Manager) resolveIdentifiers(ctx context.Context) ([]string, error) {
|
||||||
|
identifiers := m.domains()
|
||||||
|
|
||||||
|
if m.ChallengeType == "http-01" && m.section().Key("include_ip").MustBool(false) {
|
||||||
|
ip := m.section().Key("ip_override").String()
|
||||||
|
if ip == "" {
|
||||||
|
detected, err := DetectWANIP(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("autodetect WAN IP: %w", err)
|
||||||
|
}
|
||||||
|
ip = detected
|
||||||
|
}
|
||||||
|
identifiers = append(identifiers, ip)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(identifiers) == 0 {
|
||||||
|
return nil, fmt.Errorf("acmecert: no domains configured")
|
||||||
|
}
|
||||||
|
return identifiers, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package acmecert
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// wanIPServiceURL returns the caller's public IP as plain text. Overridden by tests.
|
||||||
|
var wanIPServiceURL = "https://api.ipify.org"
|
||||||
|
|
||||||
|
// DetectWANIP asks a public IP-echo service what address this host is reachable from,
|
||||||
|
// for pre-filling the Let's Encrypt HTTP-01 "certificate for my IP" option. There's no
|
||||||
|
// stdlib or local way to learn a WAN-facing IP from behind NAT/a cloud LB, so an
|
||||||
|
// outbound HTTP call is the only option here.
|
||||||
|
func DetectWANIP(ctx context.Context) (string, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, wanIPServiceURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("detect WAN IP: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("detect WAN IP: unexpected status %s", resp.Status)
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 256))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("detect WAN IP: %w", err)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(body)), nil
|
||||||
|
}
|
||||||
@@ -40,6 +40,11 @@ var defaults = []struct {
|
|||||||
{"WEB_HTTP_PORT", "5000", ""},
|
{"WEB_HTTP_PORT", "5000", ""},
|
||||||
{"", "", "HTTPS port for the admin web UI (self-signed by default, or the Let's Encrypt cert when enabled)"},
|
{"", "", "HTTPS port for the admin web UI (self-signed by default, or the Let's Encrypt cert when enabled)"},
|
||||||
{"WEB_HTTPS_PORT", "5001", ""},
|
{"WEB_HTTPS_PORT", "5001", ""},
|
||||||
|
{"", "", "Port the [LetsEncryptHTTP] HTTP-01 challenge server binds while an obtain/renew is"},
|
||||||
|
{"", "", "actually running (never left listening otherwise). Must be 80 for a real Let's"},
|
||||||
|
{"", "", "Encrypt HTTP-01 challenge to validate - the CA always connects on port 80. Only"},
|
||||||
|
{"", "", "change this if you're proxying/forwarding port 80 to a different port on this host."},
|
||||||
|
{"HTTP_LETSENCRYPT_PORT", "80", ""},
|
||||||
{"", "", `Custom server banner (to make it empty use "" must be double quotes)`},
|
{"", "", `Custom server banner (to make it empty use "" must be double quotes)`},
|
||||||
{"server_banner", "", ""},
|
{"server_banner", "", ""},
|
||||||
{"", "", "Time zone for the server"},
|
{"", "", "Time zone for the server"},
|
||||||
@@ -66,8 +71,16 @@ var defaults = []struct {
|
|||||||
}},
|
}},
|
||||||
{"TLS", []defaultKV{
|
{"TLS", []defaultKV{
|
||||||
{"", "", "TLS/SSL certificate configuration"},
|
{"", "", "TLS/SSL certificate configuration"},
|
||||||
|
{"", "", "The 'custom' certificate: self-signed on first run, or your own uploaded cert/key"},
|
||||||
{"TLS_CERT_FILE", "ssl_certs/server.crt", ""},
|
{"TLS_CERT_FILE", "ssl_certs/server.crt", ""},
|
||||||
{"TLS_KEY_FILE", "ssl_certs/server.key", ""},
|
{"TLS_KEY_FILE", "ssl_certs/server.key", ""},
|
||||||
|
{"", "", "Which certificate each TLS-serving listener uses: custom, letsencrypt_dns"},
|
||||||
|
{"", "", "(the [LetsEncrypt] DNS-01 cert), or letsencrypt_http (the [LetsEncryptHTTP]"},
|
||||||
|
{"", "", "HTTP-01 cert). Independent per listener - e.g. run the HTTP-01 cert on"},
|
||||||
|
{"", "", "SMTP/IMAP while the web UI keeps a DNS-01 or custom cert, or vice versa."},
|
||||||
|
{"smtp_tls_cert", "custom", ""},
|
||||||
|
{"imap_tls_cert", "custom", ""},
|
||||||
|
{"web_https_cert", "custom", ""},
|
||||||
}},
|
}},
|
||||||
{"DKIM", []defaultKV{
|
{"DKIM", []defaultKV{
|
||||||
{"", "", "DKIM signing configuration"},
|
{"", "", "DKIM signing configuration"},
|
||||||
@@ -143,8 +156,10 @@ var defaults = []struct {
|
|||||||
{"reject_score", "15", ""},
|
{"reject_score", "15", ""},
|
||||||
}},
|
}},
|
||||||
{"LetsEncrypt", []defaultKV{
|
{"LetsEncrypt", []defaultKV{
|
||||||
{"", "", "Let's Encrypt (ACME, DNS-01 only) automatic certificate configuration for the"},
|
{"", "", "Let's Encrypt (ACME, DNS-01) automatic certificate configuration. This obtains a"},
|
||||||
{"", "", "SMTP/IMAP TLS listeners. Leave 'enabled' false to keep using the self-signed cert."},
|
{"", "", "separate certificate from [LetsEncryptHTTP] below - assign each independently to"},
|
||||||
|
{"", "", "the SMTP/IMAP/web-UI listeners via [TLS]'s *_cert settings. Leave 'enabled' false"},
|
||||||
|
{"", "", "to not obtain this one."},
|
||||||
{"enabled", "false", ""},
|
{"enabled", "false", ""},
|
||||||
{"", "", "Use Let's Encrypt's staging directory (untrusted certs, no rate limits) for testing"},
|
{"", "", "Use Let's Encrypt's staging directory (untrusted certs, no rate limits) for testing"},
|
||||||
{"staging", "false", ""},
|
{"staging", "false", ""},
|
||||||
@@ -168,6 +183,31 @@ var defaults = []struct {
|
|||||||
{"", "", "Path to an uploaded service-account JSON key; leave blank to use Application Default Credentials"},
|
{"", "", "Path to an uploaded service-account JSON key; leave blank to use Application Default Credentials"},
|
||||||
{"gcloud_service_account_json_path", "", ""},
|
{"gcloud_service_account_json_path", "", ""},
|
||||||
}},
|
}},
|
||||||
|
{"LetsEncryptHTTP", []defaultKV{
|
||||||
|
{"", "", "Let's Encrypt (ACME, HTTP-01) automatic certificate configuration - an"},
|
||||||
|
{"", "", "alternative to [LetsEncrypt] above for domains you don't manage DNS for. Needs"},
|
||||||
|
{"", "", "no DNS provider, only port 80 reachable from the internet; that port is only"},
|
||||||
|
{"", "", "ever bound for the few seconds an obtain/renew is actually running, never left"},
|
||||||
|
{"", "", "listening otherwise. Produces a separate certificate from [LetsEncrypt] - assign"},
|
||||||
|
{"", "", "each independently to the SMTP/IMAP/web-UI listeners via [TLS]'s *_cert settings."},
|
||||||
|
{"", "", "(Both sections share the same underlying ACME account, registered once using"},
|
||||||
|
{"", "", "whichever of the two 'contact_email' values is obtained with first.)"},
|
||||||
|
{"enabled", "false", ""},
|
||||||
|
{"", "", "Use Let's Encrypt's staging directory (untrusted certs, no rate limits) for testing"},
|
||||||
|
{"staging", "false", ""},
|
||||||
|
{"", "", "Contact email for the ACME account"},
|
||||||
|
{"contact_email", "", ""},
|
||||||
|
{"", "", "Comma-separated domains to request"},
|
||||||
|
{"domains", "", ""},
|
||||||
|
{"", "", "Also request this certificate for the server's public IP address (RFC 8738 IP"},
|
||||||
|
{"", "", "identifier), so clients connecting by bare IP get a trusted cert too. Note: some"},
|
||||||
|
{"", "", "CAs reject an order mixing a domain name and an IP - check this page's status"},
|
||||||
|
{"", "", "after enabling this if the domain-only cert stops working."},
|
||||||
|
{"include_ip", "false", ""},
|
||||||
|
{"", "", "IP address to request the cert for. Leave blank to autodetect this host's WAN IP"},
|
||||||
|
{"", "", "on every obtain/renew."},
|
||||||
|
{"ip_override", "", ""},
|
||||||
|
}},
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateSettingsIni writes settings.ini with default values and comments if it does
|
// GenerateSettingsIni writes settings.ini with default values and comments if it does
|
||||||
@@ -213,11 +253,54 @@ func GenerateSettingsIni(path string) error {
|
|||||||
|
|
||||||
// Load reads settings.ini at path, generating it with defaults first if missing.
|
// 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.
|
// Mirrors settings_loader.load_settings: always regenerate-if-missing, then read fresh.
|
||||||
|
// Existing values are never touched (GenerateSettingsIni's "never overwritten or merged
|
||||||
|
// into" guarantee still holds), but any key added to the defaults table by a later
|
||||||
|
// version of this program — like [TLS]'s *_cert routing settings or the whole
|
||||||
|
// [LetsEncryptHTTP] section — is backfilled onto an older, already-existing file, so it
|
||||||
|
// shows up (and actually saves) on the generic Settings page instead of silently
|
||||||
|
// behaving as if unset until the file is regenerated from scratch.
|
||||||
func Load(path string) (*ini.File, error) {
|
func Load(path string) (*ini.File, error) {
|
||||||
if err := GenerateSettingsIni(path); err != nil {
|
if err := GenerateSettingsIni(path); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return ini.Load(path)
|
cfg, err := ini.Load(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := backfillMissingDefaults(cfg, path); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// backfillMissingDefaults adds any defaults-table key not already present in cfg,
|
||||||
|
// leaving every existing key's value untouched, and saves to path only if it actually
|
||||||
|
// added something.
|
||||||
|
func backfillMissingDefaults(cfg *ini.File, path string) error {
|
||||||
|
changed := false
|
||||||
|
for _, sec := range defaults {
|
||||||
|
section, err := cfg.NewSection(sec.Section) // no-op if the section already exists
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, kv := range sec.Keys {
|
||||||
|
if kv.Key == "" || section.HasKey(kv.Key) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key, err := section.NewKey(kv.Key, kv.Value)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if kv.Comment != "" {
|
||||||
|
key.Comment = kv.Comment
|
||||||
|
}
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return cfg.SaveTo(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AbsoluteSQLitePath converts a "sqlite:///relative/path" database URL into an absolute
|
// AbsoluteSQLitePath converts a "sqlite:///relative/path" database URL into an absolute
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package config
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -34,6 +35,45 @@ func TestGenerateAndLoadRoundTrip(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadBackfillsMissingKeysWithoutTouchingExistingValues(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "settings.ini")
|
||||||
|
|
||||||
|
// Simulate an older settings.ini written before [LetsEncryptHTTP] and [TLS]'s
|
||||||
|
// *_cert keys existed, with a deliberately non-default value on a key that IS
|
||||||
|
// already present — Load must never touch that.
|
||||||
|
old := "[Server]\nHOSTNAME = old.example.com\n\n[TLS]\ntls_cert_file = custom/path.crt\n"
|
||||||
|
if err := os.WriteFile(path, []byte(old), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if got := cfg.Section("Server").Key("HOSTNAME").String(); got != "old.example.com" {
|
||||||
|
t.Errorf("existing value clobbered: HOSTNAME = %q", got)
|
||||||
|
}
|
||||||
|
if got := cfg.Section("TLS").Key("tls_cert_file").String(); got != "custom/path.crt" {
|
||||||
|
t.Errorf("existing value clobbered: tls_cert_file = %q", got)
|
||||||
|
}
|
||||||
|
if got := cfg.Section("TLS").Key("smtp_tls_cert").String(); got != "custom" {
|
||||||
|
t.Errorf("smtp_tls_cert not backfilled: got %q, want default %q", got, "custom")
|
||||||
|
}
|
||||||
|
if !cfg.Section("LetsEncryptHTTP").HasKey("enabled") {
|
||||||
|
t.Error("[LetsEncryptHTTP] section was not backfilled")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The backfill must be persisted to disk, not just held in memory.
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(raw), "smtp_tls_cert") {
|
||||||
|
t.Error("backfilled key was not saved back to settings.ini")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAbsoluteSQLitePath(t *testing.T) {
|
func TestAbsoluteSQLitePath(t *testing.T) {
|
||||||
cases := []struct{ url, root, want string }{
|
cases := []struct{ url, root, want string }{
|
||||||
{"sqlite:///server_data/db.sqlite", "/app", "/app/server_data/db.sqlite"},
|
{"sqlite:///server_data/db.sqlite", "/app", "/app/server_data/db.sqlite"},
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCountMessagesByFolder(t *testing.T) {
|
||||||
|
d := openTestDB(t)
|
||||||
|
const mailboxID = int64(1)
|
||||||
|
|
||||||
|
insert := func(folder, flags string) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := d.InsertMessage(mailboxID, folder, "", flags, time.Now(), 10, "/dev/null", []byte("nonce"), "a@example.com", "b@example.com", "subj", ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
insert("INBOX", "")
|
||||||
|
insert("INBOX", "")
|
||||||
|
insert("INBOX", `\Seen`)
|
||||||
|
insert("Sent", `\Seen`)
|
||||||
|
|
||||||
|
totals, err := d.CountMessagesByFolder(mailboxID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if totals["INBOX"] != 3 {
|
||||||
|
t.Errorf("INBOX total = %d, want 3", totals["INBOX"])
|
||||||
|
}
|
||||||
|
if totals["Sent"] != 1 {
|
||||||
|
t.Errorf("Sent total = %d, want 1", totals["Sent"])
|
||||||
|
}
|
||||||
|
|
||||||
|
unread, err := d.CountUnreadByFolder(mailboxID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if unread["INBOX"] != 2 {
|
||||||
|
t.Errorf("INBOX unread = %d, want 2", unread["INBOX"])
|
||||||
|
}
|
||||||
|
if _, ok := unread["Sent"]; ok {
|
||||||
|
t.Errorf("expected Sent to have no unread entry, got %d", unread["Sent"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,23 +10,23 @@ import (
|
|||||||
// InsertMessage records a stored message's index row (the ciphertext itself already
|
// InsertMessage records a stored message's index row (the ciphertext itself already
|
||||||
// lives at storagePath — see internal/mailstore). Returns the new row's id, which
|
// lives at storagePath — see internal/mailstore). Returns the new row's id, which
|
||||||
// doubles as the IMAP UID in later milestones.
|
// doubles as the IMAP UID in later milestones.
|
||||||
func (d *DB) InsertMessage(mailboxID int64, folder, messageIDHeader, flags string, internalDate time.Time, sizeBytes int64, storagePath string, nonce []byte, cachedFrom, cachedTo, cachedSubject string) (int64, error) {
|
func (d *DB) InsertMessage(mailboxID int64, folder, messageIDHeader, flags string, internalDate time.Time, sizeBytes int64, storagePath string, nonce []byte, cachedFrom, cachedTo, cachedSubject, cachedPreview string) (int64, error) {
|
||||||
res, err := d.Exec(`INSERT INTO esrv_mailbox_messages
|
res, err := d.Exec(`INSERT INTO esrv_mailbox_messages
|
||||||
(mailbox_id, folder, message_id_header, flags, internal_date, size_bytes, storage_path, nonce, cached_from, cached_to, cached_subject)
|
(mailbox_id, folder, message_id_header, flags, internal_date, size_bytes, storage_path, nonce, cached_from, cached_to, cached_subject, cached_preview)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
mailboxID, folder, messageIDHeader, flags, internalDate, sizeBytes, storagePath, nonce, cachedFrom, cachedTo, cachedSubject)
|
mailboxID, folder, messageIDHeader, flags, internalDate, sizeBytes, storagePath, nonce, cachedFrom, cachedTo, cachedSubject, cachedPreview)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
return res.LastInsertId()
|
return res.LastInsertId()
|
||||||
}
|
}
|
||||||
|
|
||||||
const mailboxMessageColumns = `id, mailbox_id, folder, message_id_header, flags, internal_date, size_bytes, cached_from, cached_to, cached_subject, storage_path, nonce, created_at`
|
const mailboxMessageColumns = `id, mailbox_id, folder, message_id_header, flags, internal_date, size_bytes, cached_from, cached_to, cached_subject, cached_preview, storage_path, nonce, created_at`
|
||||||
|
|
||||||
func scanMailboxMessage(scan func(dest ...any) error) (MailboxMessage, error) {
|
func scanMailboxMessage(scan func(dest ...any) error) (MailboxMessage, error) {
|
||||||
var m MailboxMessage
|
var m MailboxMessage
|
||||||
var internalDate, createdAt string
|
var internalDate, createdAt string
|
||||||
err := scan(&m.ID, &m.MailboxID, &m.Folder, &m.MessageIDHeader, &m.Flags, &internalDate, &m.SizeBytes, &m.CachedFrom, &m.CachedTo, &m.CachedSubject, &m.StoragePath, &m.Nonce, &createdAt)
|
err := scan(&m.ID, &m.MailboxID, &m.Folder, &m.MessageIDHeader, &m.Flags, &internalDate, &m.SizeBytes, &m.CachedFrom, &m.CachedTo, &m.CachedSubject, &m.CachedPreview, &m.StoragePath, &m.Nonce, &createdAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return m, err
|
return m, err
|
||||||
}
|
}
|
||||||
@@ -103,6 +103,17 @@ func (d *DB) ListMessagesForMailbox(mailboxID int64) ([]MailboxMessage, error) {
|
|||||||
return scanMailboxMessages(rows)
|
return scanMailboxMessages(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateMessageCachedFields overwrites a message's cached_from/cached_to/
|
||||||
|
// cached_subject/cached_preview — the display-only fields derived from the message's
|
||||||
|
// own content at store time. Used by mailstore.RebuildMessageCache to re-derive them
|
||||||
|
// for messages stored before a caching fix/addition landed (those fields are
|
||||||
|
// otherwise only ever computed once, at delivery time, never retroactively).
|
||||||
|
func (d *DB) UpdateMessageCachedFields(id int64, cachedFrom, cachedTo, cachedSubject, cachedPreview string) error {
|
||||||
|
_, err := d.Exec(`UPDATE esrv_mailbox_messages SET cached_from = ?, cached_to = ?, cached_subject = ?, cached_preview = ? WHERE id = ?`,
|
||||||
|
cachedFrom, cachedTo, cachedSubject, cachedPreview, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// SetMessageFlags overwrites a message's stored IMAP flags (space-separated), scoped
|
// SetMessageFlags overwrites a message's stored IMAP flags (space-separated), scoped
|
||||||
// to mailboxID so a session can't touch another mailbox's message by guessing a UID.
|
// to mailboxID so a session can't touch another mailbox's message by guessing a UID.
|
||||||
func (d *DB) SetMessageFlags(mailboxID, uid int64, flags string) error {
|
func (d *DB) SetMessageFlags(mailboxID, uid int64, flags string) error {
|
||||||
@@ -124,9 +135,35 @@ func (d *DB) ListMessagesInFolder(mailboxID int64, folder string) ([]MailboxMess
|
|||||||
// ListMessagesInFolderPage is ListMessagesInFolder with newest-first pagination, for
|
// ListMessagesInFolderPage is ListMessagesInFolder with newest-first pagination, for
|
||||||
// the webmail client's folder view — a mailbox can accumulate far more mail than is
|
// the webmail client's folder view — a mailbox can accumulate far more mail than is
|
||||||
// reasonable to render in one page.
|
// reasonable to render in one page.
|
||||||
func (d *DB) ListMessagesInFolderPage(mailboxID int64, folder string, offset, limit int) ([]MailboxMessage, error) {
|
// sortColumnAndDir maps the folder view's ?sort=/&dir= query params to a safe,
|
||||||
rows, err := d.Query(`SELECT `+mailboxMessageColumns+` FROM esrv_mailbox_messages
|
// hardcoded SQL ORDER BY fragment — never interpolates the raw query values
|
||||||
WHERE mailbox_id = ? AND folder = ? ORDER BY id DESC LIMIT ? OFFSET ?`, mailboxID, folder, limit, offset)
|
// themselves, only picks between two known-safe literals, so this stays injection-safe
|
||||||
|
// however sort/dir arrive from the URL. "date" (the default) sorts by id, which tracks
|
||||||
|
// insertion/received order — the same ordering ListMessagesInFolderPage always used,
|
||||||
|
// just now also selectable ascending.
|
||||||
|
func sortColumnAndDir(sortBy, sortDir string) string {
|
||||||
|
col := "id"
|
||||||
|
if sortBy == "from" {
|
||||||
|
col = "cached_from"
|
||||||
|
}
|
||||||
|
dir := "DESC"
|
||||||
|
if sortDir == "asc" {
|
||||||
|
dir = "ASC"
|
||||||
|
}
|
||||||
|
// Tie-break on id in the same direction so same-sender/same-instant rows still
|
||||||
|
// have a stable, deterministic order across pages.
|
||||||
|
return col + " " + dir + ", id " + dir
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) ListMessagesInFolderPage(mailboxID int64, folder string, unreadOnly bool, sortBy, sortDir string, offset, limit int) ([]MailboxMessage, error) {
|
||||||
|
query := `SELECT ` + mailboxMessageColumns + ` FROM esrv_mailbox_messages WHERE mailbox_id = ? AND folder = ?`
|
||||||
|
args := []any{mailboxID, folder}
|
||||||
|
if unreadOnly {
|
||||||
|
query += ` AND flags NOT LIKE '%\Seen%' ESCAPE '\'`
|
||||||
|
}
|
||||||
|
query += ` ORDER BY ` + sortColumnAndDir(sortBy, sortDir) + ` LIMIT ? OFFSET ?`
|
||||||
|
args = append(args, limit, offset)
|
||||||
|
rows, err := d.Query(query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -134,9 +171,14 @@ func (d *DB) ListMessagesInFolderPage(mailboxID int64, folder string, offset, li
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CountMessagesInFolder backs ListMessagesInFolderPage's pagination controls.
|
// CountMessagesInFolder backs ListMessagesInFolderPage's pagination controls.
|
||||||
func (d *DB) CountMessagesInFolder(mailboxID int64, folder string) (int, error) {
|
func (d *DB) CountMessagesInFolder(mailboxID int64, folder string, unreadOnly bool) (int, error) {
|
||||||
|
query := `SELECT COUNT(*) FROM esrv_mailbox_messages WHERE mailbox_id = ? AND folder = ?`
|
||||||
|
args := []any{mailboxID, folder}
|
||||||
|
if unreadOnly {
|
||||||
|
query += ` AND flags NOT LIKE '%\Seen%' ESCAPE '\'`
|
||||||
|
}
|
||||||
var n int
|
var n int
|
||||||
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_mailbox_messages WHERE mailbox_id = ? AND folder = ?`, mailboxID, folder).Scan(&n)
|
err := d.QueryRow(query, args...).Scan(&n)
|
||||||
return n, err
|
return n, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,6 +252,28 @@ func (d *DB) CountUnreadByFolder(mailboxID int64) (map[string]int, error) {
|
|||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CountMessagesByFolder returns every folder's total message count in one query — the
|
||||||
|
// total half of the sidebar's "total / unread" display, mirroring CountUnreadByFolder's
|
||||||
|
// shape exactly (a folder with zero messages simply has no entry in the returned map).
|
||||||
|
func (d *DB) CountMessagesByFolder(mailboxID int64) (map[string]int, error) {
|
||||||
|
rows, err := d.Query(`SELECT folder, COUNT(*) FROM esrv_mailbox_messages
|
||||||
|
WHERE mailbox_id = ? GROUP BY folder`, mailboxID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := map[string]int{}
|
||||||
|
for rows.Next() {
|
||||||
|
var folder string
|
||||||
|
var n int
|
||||||
|
if err := rows.Scan(&folder, &n); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out[folder] = n
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// SuggestRecipients returns up to 10 distinct addresses (as originally cached — a
|
// SuggestRecipients returns up to 10 distinct addresses (as originally cached — a
|
||||||
// display name like "Name <addr@example.com>" is kept as-is, not parsed apart, since
|
// display name like "Name <addr@example.com>" is kept as-is, not parsed apart, since
|
||||||
// that's exactly what a To/Cc/Bcc field already accepts) this mailbox has previously
|
// that's exactly what a To/Cc/Bcc field already accepts) this mailbox has previously
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
const mailboxColumns = `id, email, domain_id, password_hash, is_active, quota_bytes, used_bytes, dek_wrapped, dek_nonce, created_at, created_by, totp_secret, totp_enabled, mfa_exempt`
|
const mailboxColumns = `id, email, domain_id, password_hash, is_active, quota_bytes, used_bytes, dek_wrapped, dek_nonce, created_at, created_by, totp_secret, totp_enabled, mfa_exempt, group_messages`
|
||||||
|
|
||||||
func scanMailbox(row *sql.Row) (*Mailbox, error) {
|
func scanMailbox(row *sql.Row) (*Mailbox, error) {
|
||||||
var m Mailbox
|
var m Mailbox
|
||||||
var createdAt string
|
var createdAt string
|
||||||
var createdBy sql.NullInt64
|
var createdBy sql.NullInt64
|
||||||
if err := row.Scan(&m.ID, &m.Email, &m.DomainID, &m.PasswordHash, &m.IsActive, &m.QuotaBytes, &m.UsedBytes, &m.DEKWrapped, &m.DEKNonce, &createdAt, &createdBy, &m.TOTPSecret, &m.TOTPEnabled, &m.MFAExempt); err != nil {
|
if err := row.Scan(&m.ID, &m.Email, &m.DomainID, &m.PasswordHash, &m.IsActive, &m.QuotaBytes, &m.UsedBytes, &m.DEKWrapped, &m.DEKNonce, &createdAt, &createdBy, &m.TOTPSecret, &m.TOTPEnabled, &m.MFAExempt, &m.GroupMessages); err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -31,7 +31,7 @@ type MailboxWithDomain struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (d *DB) ListMailboxes() ([]MailboxWithDomain, error) {
|
func (d *DB) ListMailboxes() ([]MailboxWithDomain, error) {
|
||||||
rows, err := d.Query(`SELECT m.id, m.email, m.domain_id, m.password_hash, m.is_active, m.quota_bytes, m.used_bytes, m.dek_wrapped, m.dek_nonce, m.created_at, m.created_by, m.totp_secret, m.totp_enabled, m.mfa_exempt, dm.domain_name
|
rows, err := d.Query(`SELECT m.id, m.email, m.domain_id, m.password_hash, m.is_active, m.quota_bytes, m.used_bytes, m.dek_wrapped, m.dek_nonce, m.created_at, m.created_by, m.totp_secret, m.totp_enabled, m.mfa_exempt, m.group_messages, dm.domain_name
|
||||||
FROM esrv_mailboxes m JOIN esrv_domains dm ON dm.id = m.domain_id ORDER BY m.email`)
|
FROM esrv_mailboxes m JOIN esrv_domains dm ON dm.id = m.domain_id ORDER BY m.email`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -42,7 +42,7 @@ func (d *DB) ListMailboxes() ([]MailboxWithDomain, error) {
|
|||||||
var m MailboxWithDomain
|
var m MailboxWithDomain
|
||||||
var createdAt string
|
var createdAt string
|
||||||
var createdBy sql.NullInt64
|
var createdBy sql.NullInt64
|
||||||
if err := rows.Scan(&m.ID, &m.Email, &m.DomainID, &m.PasswordHash, &m.IsActive, &m.QuotaBytes, &m.UsedBytes, &m.DEKWrapped, &m.DEKNonce, &createdAt, &createdBy, &m.TOTPSecret, &m.TOTPEnabled, &m.MFAExempt, &m.DomainName); err != nil {
|
if err := rows.Scan(&m.ID, &m.Email, &m.DomainID, &m.PasswordHash, &m.IsActive, &m.QuotaBytes, &m.UsedBytes, &m.DEKWrapped, &m.DEKNonce, &createdAt, &createdBy, &m.TOTPSecret, &m.TOTPEnabled, &m.MFAExempt, &m.GroupMessages, &m.DomainName); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
m.CreatedAt, _ = parseTime(createdAt)
|
m.CreatedAt, _ = parseTime(createdAt)
|
||||||
@@ -65,7 +65,7 @@ func (d *DB) ListMailboxesForDomain(domainID int64) ([]Mailbox, error) {
|
|||||||
var m Mailbox
|
var m Mailbox
|
||||||
var createdAt string
|
var createdAt string
|
||||||
var createdBy sql.NullInt64
|
var createdBy sql.NullInt64
|
||||||
if err := rows.Scan(&m.ID, &m.Email, &m.DomainID, &m.PasswordHash, &m.IsActive, &m.QuotaBytes, &m.UsedBytes, &m.DEKWrapped, &m.DEKNonce, &createdAt, &createdBy, &m.TOTPSecret, &m.TOTPEnabled, &m.MFAExempt); err != nil {
|
if err := rows.Scan(&m.ID, &m.Email, &m.DomainID, &m.PasswordHash, &m.IsActive, &m.QuotaBytes, &m.UsedBytes, &m.DEKWrapped, &m.DEKNonce, &createdAt, &createdBy, &m.TOTPSecret, &m.TOTPEnabled, &m.MFAExempt, &m.GroupMessages); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
m.CreatedAt, _ = parseTime(createdAt)
|
m.CreatedAt, _ = parseTime(createdAt)
|
||||||
@@ -120,6 +120,11 @@ func (d *DB) SetMailboxMFAExempt(id int64, exempt bool) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (d *DB) SetMailboxGroupMessages(id int64, group bool) error {
|
||||||
|
_, err := d.Exec(`UPDATE esrv_mailboxes SET group_messages = ? WHERE id = ?`, group, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (d *DB) SetMailboxQuota(id int64, quotaBytes int64) error {
|
func (d *DB) SetMailboxQuota(id int64, quotaBytes int64) error {
|
||||||
_, err := d.Exec(`UPDATE esrv_mailboxes SET quota_bytes = ? WHERE id = ?`, quotaBytes, id)
|
_, err := d.Exec(`UPDATE esrv_mailboxes SET quota_bytes = ? WHERE id = ?`, quotaBytes, id)
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ type Mailbox struct {
|
|||||||
// MFAExempt overrides [Auth] enforce_mailbox_mfa off for this mailbox specifically,
|
// MFAExempt overrides [Auth] enforce_mailbox_mfa off for this mailbox specifically,
|
||||||
// even if its domain isn't exempt.
|
// even if its domain isn't exempt.
|
||||||
MFAExempt bool
|
MFAExempt bool
|
||||||
|
// GroupMessages collapses a run of same-subject messages in a folder view into one
|
||||||
|
// expandable row when true. Off by default — a display preference, not a policy.
|
||||||
|
GroupMessages bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// MailboxSession is a self-service webmail portal login — a parallel schema to
|
// MailboxSession is a self-service webmail portal login — a parallel schema to
|
||||||
@@ -143,6 +146,7 @@ type MailboxMessage struct {
|
|||||||
CachedFrom string
|
CachedFrom string
|
||||||
CachedTo string
|
CachedTo string
|
||||||
CachedSubject string
|
CachedSubject string
|
||||||
|
CachedPreview string
|
||||||
StoragePath string
|
StoragePath string
|
||||||
Nonce []byte
|
Nonce []byte
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
|
|||||||
+18
-1
@@ -208,7 +208,10 @@ CREATE TABLE IF NOT EXISTS esrv_mailboxes (
|
|||||||
created_by INTEGER REFERENCES esrv_admin_users(id),
|
created_by INTEGER REFERENCES esrv_admin_users(id),
|
||||||
totp_secret TEXT NOT NULL DEFAULT '',
|
totp_secret TEXT NOT NULL DEFAULT '',
|
||||||
totp_enabled INTEGER NOT NULL DEFAULT 0,
|
totp_enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
mfa_exempt INTEGER NOT NULL DEFAULT 0
|
mfa_exempt INTEGER NOT NULL DEFAULT 0,
|
||||||
|
-- Off by default: collapse a run of same-subject messages in a folder view into one
|
||||||
|
-- expandable row. Per-mailbox, not global, since this is purely a display preference.
|
||||||
|
group_messages INTEGER NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Self-service webmail portal sessions — deliberately a parallel schema to
|
-- Self-service webmail portal sessions — deliberately a parallel schema to
|
||||||
@@ -309,6 +312,10 @@ CREATE TABLE IF NOT EXISTS esrv_mailbox_messages (
|
|||||||
cached_from TEXT NOT NULL DEFAULT '',
|
cached_from TEXT NOT NULL DEFAULT '',
|
||||||
cached_to TEXT NOT NULL DEFAULT '',
|
cached_to TEXT NOT NULL DEFAULT '',
|
||||||
cached_subject TEXT NOT NULL DEFAULT '',
|
cached_subject TEXT NOT NULL DEFAULT '',
|
||||||
|
-- First ~150 characters of the plain-text body, cached in plain text (like the
|
||||||
|
-- other cached_* columns) so the folder list can show a preview snippet without
|
||||||
|
-- decrypting the full message just to render the list.
|
||||||
|
cached_preview TEXT NOT NULL DEFAULT '',
|
||||||
storage_path TEXT NOT NULL,
|
storage_path TEXT NOT NULL,
|
||||||
nonce BLOB NOT NULL,
|
nonce BLOB NOT NULL,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
@@ -432,6 +439,16 @@ func migrateAddedColumns(db *sql.DB) {
|
|||||||
// unrecoverable-without-code-that-no-longer-exists) keys, same "not migrated"
|
// unrecoverable-without-code-that-no-longer-exists) keys, same "not migrated"
|
||||||
// treatment as the singular-table identities before them.
|
// treatment as the singular-table identities before them.
|
||||||
`ALTER TABLE esrv_mailbox_smime_identities ADD COLUMN key_pem TEXT NOT NULL DEFAULT ''`,
|
`ALTER TABLE esrv_mailbox_smime_identities ADD COLUMN key_pem TEXT NOT NULL DEFAULT ''`,
|
||||||
|
`ALTER TABLE esrv_mailboxes ADD COLUMN group_messages INTEGER NOT NULL DEFAULT 0`,
|
||||||
|
`ALTER TABLE esrv_mailbox_messages ADD COLUMN cached_preview TEXT NOT NULL DEFAULT ''`,
|
||||||
|
}
|
||||||
|
// The three old columns above were NOT NULL with no default, so simply adding
|
||||||
|
// key_pem left them behind still blocking every new insert (which only ever sets
|
||||||
|
// key_pem, never these) on any DB created before this migration — confirmed live:
|
||||||
|
// "NOT NULL constraint failed: esrv_mailbox_smime_identities.key_ciphertext". Needs
|
||||||
|
// SQLite 3.35+ for DROP COLUMN; modernc.org/sqlite is well past that.
|
||||||
|
for _, col := range []string{"key_ciphertext", "key_nonce", "key_salt"} {
|
||||||
|
db.Exec(`ALTER TABLE esrv_mailbox_smime_identities DROP COLUMN ` + col)
|
||||||
}
|
}
|
||||||
for _, stmt := range stmts {
|
for _, stmt := range stmts {
|
||||||
db.Exec(stmt)
|
db.Exec(stmt)
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSMIMEIdentityInsertWorksAfterLegacyColumnMigration reproduces a live bug: a DB
|
||||||
|
// created before the S/MIME redesign (passphrase-wrapped key_ciphertext/key_nonce/
|
||||||
|
// key_salt, all NOT NULL) only ever got key_pem ADDed by migrateAddedColumns, never had
|
||||||
|
// the old NOT-NULL columns removed — so CreateSMIMEIdentity (which only sets key_pem)
|
||||||
|
// failed with "NOT NULL constraint failed: esrv_mailbox_smime_identities.key_ciphertext"
|
||||||
|
// on any pre-existing installation, confirmed against a real user's database.
|
||||||
|
func TestSMIMEIdentityInsertWorksAfterLegacyColumnMigration(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "test.db")
|
||||||
|
|
||||||
|
raw, err := sql.Open("sqlite", path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := raw.Exec(`
|
||||||
|
CREATE TABLE esrv_mailbox_smime_identities (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
mailbox_id INTEGER NOT NULL,
|
||||||
|
cert_pem TEXT NOT NULL,
|
||||||
|
key_ciphertext BLOB NOT NULL,
|
||||||
|
key_nonce BLOB NOT NULL,
|
||||||
|
key_salt BLOB NOT NULL,
|
||||||
|
not_after DATETIME NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := raw.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
database, err := Open(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { database.Close() })
|
||||||
|
|
||||||
|
if _, err := database.CreateSMIMEIdentity(1, "cert-pem", "key-pem", time.Now().Add(365*24*time.Hour)); err != nil {
|
||||||
|
t.Fatalf("CreateSMIMEIdentity after migrating a legacy DB: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,9 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"mailgoserver/internal/db"
|
"mailgoserver/internal/db"
|
||||||
)
|
)
|
||||||
@@ -104,6 +106,82 @@ func TestStoreFetchRoundTrip(t *testing.T) {
|
|||||||
if mbox.UsedBytes != int64(len(raw)) {
|
if mbox.UsedBytes != int64(len(raw)) {
|
||||||
t.Fatalf("used_bytes = %d, want %d", mbox.UsedBytes, len(raw))
|
t.Fatalf("used_bytes = %d, want %d", mbox.UsedBytes, len(raw))
|
||||||
}
|
}
|
||||||
|
if msg.CachedPreview != "hello world" {
|
||||||
|
t.Errorf("CachedPreview = %q, want %q", msg.CachedPreview, "hello world")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStoreMessagePreviewTruncatesLongBodyRuneSafely confirms the cached preview is
|
||||||
|
// capped at previewSnippetLen characters (not bytes — a naive byte-slice cap could
|
||||||
|
// split a multi-byte UTF-8 character) and that non-ASCII text survives intact.
|
||||||
|
func TestStoreMessagePreviewTruncatesLongBodyRuneSafely(t *testing.T) {
|
||||||
|
s, mailboxID := newTestMailbox(t, 1024*1024)
|
||||||
|
longBody := strings.Repeat("héllo ", 100) // well over previewSnippetLen once joined
|
||||||
|
raw := []byte("From: a@example.com\r\nSubject: hi\r\n\r\n" + longBody)
|
||||||
|
|
||||||
|
uid, err := s.StoreMessage(mailboxID, "INBOX", raw, "<abc@example.com>", "a@example.com", "hi")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
msg, err := s.DB.GetMessageByUID(mailboxID, uid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n := len([]rune(msg.CachedPreview)); n != previewSnippetLen {
|
||||||
|
t.Errorf("preview length = %d runes, want %d", n, previewSnippetLen)
|
||||||
|
}
|
||||||
|
if !utf8.ValidString(msg.CachedPreview) {
|
||||||
|
t.Error("preview is not valid UTF-8 — truncation split a multi-byte character")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRebuildMessageCacheRederivesFromExistingContent simulates a message stored
|
||||||
|
// before the "cache the From: header's display name" fix existed: cached_from was
|
||||||
|
// passed as the bare envelope address even though the stored raw content always had
|
||||||
|
// the full header. RebuildMessageCache should bring it up to date without needing the
|
||||||
|
// message re-delivered.
|
||||||
|
func TestRebuildMessageCacheRederivesFromExistingContent(t *testing.T) {
|
||||||
|
s, mailboxID := newTestMailbox(t, 1024*1024)
|
||||||
|
raw := []byte("From: Bob Marley <bob@example.com>\r\nTo: user@example.com\r\nSubject: One love\r\n\r\nHello there, this is the body.")
|
||||||
|
|
||||||
|
// "bob@example.com" mimics what the old (pre-fix) code would have cached — the
|
||||||
|
// bare envelope address — despite the header above always having the display name.
|
||||||
|
uid, err := s.StoreMessage(mailboxID, "INBOX", raw, "<abc@example.com>", "bob@example.com", "One love")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
before, err := s.DB.GetMessageByUID(mailboxID, uid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if before.CachedFrom != "bob@example.com" {
|
||||||
|
t.Fatalf("test setup: expected the stale bare address before rebuild, got %q", before.CachedFrom)
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, skipped := s.RebuildMessageCache(mailboxID)
|
||||||
|
if len(skipped) != 0 {
|
||||||
|
t.Fatalf("expected no skipped messages, got %v", skipped)
|
||||||
|
}
|
||||||
|
if updated != 1 {
|
||||||
|
t.Fatalf("expected 1 message updated, got %d", updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
after, err := s.DB.GetMessageByUID(mailboxID, uid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if after.CachedFrom != "Bob Marley <bob@example.com>" {
|
||||||
|
t.Errorf("CachedFrom after rebuild = %q, want the header's display name", after.CachedFrom)
|
||||||
|
}
|
||||||
|
if after.CachedPreview != "Hello there, this is the body." {
|
||||||
|
t.Errorf("CachedPreview after rebuild = %q", after.CachedPreview)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-running is a safe no-op once everything's already correct.
|
||||||
|
updated2, _ := s.RebuildMessageCache(mailboxID)
|
||||||
|
if updated2 != 0 {
|
||||||
|
t.Errorf("expected 0 messages updated on a second run, got %d", updated2)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestQuotaExceeded(t *testing.T) {
|
func TestQuotaExceeded(t *testing.T) {
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ import (
|
|||||||
"net/mail"
|
"net/mail"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"mailgoserver/internal/mailview"
|
||||||
)
|
)
|
||||||
|
|
||||||
// extractHeaderValue reads a single header out of raw without parsing the body — used
|
// extractHeaderValue reads a single header out of raw without parsing the body — used
|
||||||
@@ -25,6 +28,29 @@ func extractHeaderValue(raw []byte, name string) string {
|
|||||||
return msg.Header.Get(name)
|
return msg.Header.Get(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const previewSnippetLen = 150
|
||||||
|
|
||||||
|
// previewSnippet extracts up to previewSnippetLen characters of the plain-text body
|
||||||
|
// for the folder list's preview line — cached in plain text alongside cached_from/
|
||||||
|
// cached_subject (see schema.go's comment on cached_preview for why that's consistent
|
||||||
|
// with the existing cached_* columns, not a new exposure). HTML-only mail (no
|
||||||
|
// text/plain part) gets no preview rather than a crude tag-stripped approximation —
|
||||||
|
// an accepted scope limit, not a bug: most real mail includes a text/plain
|
||||||
|
// alternative regardless of whether the sender expects it to be shown.
|
||||||
|
func previewSnippet(raw []byte) string {
|
||||||
|
parsed, err := mailview.Parse(raw)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
text := strings.Join(strings.Fields(parsed.TextBody), " ")
|
||||||
|
// Rune-safe truncation — a plain byte slice could split a multi-byte UTF-8
|
||||||
|
// character in half and produce invalid text.
|
||||||
|
if runes := []rune(text); len(runes) > previewSnippetLen {
|
||||||
|
text = string(runes[:previewSnippetLen])
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
// ErrQuotaExceeded is returned by StoreMessage when storing raw would push the
|
// ErrQuotaExceeded is returned by StoreMessage when storing raw would push the
|
||||||
// mailbox over its quota. No row, file, or used_bytes change occurs in that case.
|
// mailbox over its quota. No row, file, or used_bytes change occurs in that case.
|
||||||
var ErrQuotaExceeded = errors.New("mailstore: mailbox quota exceeded")
|
var ErrQuotaExceeded = errors.New("mailstore: mailbox quota exceeded")
|
||||||
@@ -66,7 +92,7 @@ func (s *Store) StoreMessage(mailboxID int64, folder string, raw []byte, message
|
|||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
uid, err = s.DB.InsertMessage(mailboxID, folder, messageIDHeader, "", now, int64(len(raw)), storagePath, nonce, from, extractHeaderValue(raw, "To"), subject)
|
uid, err = s.DB.InsertMessage(mailboxID, folder, messageIDHeader, "", now, int64(len(raw)), storagePath, nonce, from, extractHeaderValue(raw, "To"), subject, previewSnippet(raw))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
os.Remove(storagePath)
|
os.Remove(storagePath)
|
||||||
return 0, err
|
return 0, err
|
||||||
@@ -77,6 +103,52 @@ func (s *Store) StoreMessage(mailboxID int64, folder string, raw []byte, message
|
|||||||
return uid, nil
|
return uid, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RebuildMessageCache re-derives cached_from/cached_to/cached_subject/cached_preview
|
||||||
|
// for every message already stored in mailboxID, from each message's own decrypted
|
||||||
|
// content — these fields are otherwise only ever computed once, at delivery time
|
||||||
|
// (see StoreMessage), so mail stored before a caching fix or addition landed (e.g.
|
||||||
|
// caching the From: header's display name instead of the bare envelope address, or
|
||||||
|
// the cached_preview column itself) keeps showing the old/blank value forever unless
|
||||||
|
// something re-derives it. Returns how many rows actually changed; a message that
|
||||||
|
// fails to decrypt/parse is skipped (counted in the error map, not fatal to the rest).
|
||||||
|
func (s *Store) RebuildMessageCache(mailboxID int64) (updated int, skipped map[int64]error) {
|
||||||
|
skipped = map[int64]error{}
|
||||||
|
msgs, err := s.DB.ListMessagesForMailbox(mailboxID)
|
||||||
|
if err != nil {
|
||||||
|
skipped[0] = err
|
||||||
|
return 0, skipped
|
||||||
|
}
|
||||||
|
for _, m := range msgs {
|
||||||
|
raw, err := s.FetchMessage(mailboxID, m.ID)
|
||||||
|
if err != nil {
|
||||||
|
skipped[m.ID] = err
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
from := extractHeaderValue(raw, "From")
|
||||||
|
if from == "" {
|
||||||
|
from = m.CachedFrom
|
||||||
|
}
|
||||||
|
to := extractHeaderValue(raw, "To")
|
||||||
|
if to == "" {
|
||||||
|
to = m.CachedTo
|
||||||
|
}
|
||||||
|
subject := extractHeaderValue(raw, "Subject")
|
||||||
|
if subject == "" {
|
||||||
|
subject = m.CachedSubject
|
||||||
|
}
|
||||||
|
preview := previewSnippet(raw)
|
||||||
|
if from == m.CachedFrom && to == m.CachedTo && subject == m.CachedSubject && preview == m.CachedPreview {
|
||||||
|
continue // already correct — don't churn a write for nothing
|
||||||
|
}
|
||||||
|
if err := s.DB.UpdateMessageCachedFields(m.ID, from, to, subject, preview); err != nil {
|
||||||
|
skipped[m.ID] = err
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
updated++
|
||||||
|
}
|
||||||
|
return updated, skipped
|
||||||
|
}
|
||||||
|
|
||||||
// FetchMessage decrypts a stored message on demand. Plaintext is never written to disk
|
// FetchMessage decrypts a stored message on demand. Plaintext is never written to disk
|
||||||
// or cached — only returned to the caller.
|
// or cached — only returned to the caller.
|
||||||
func (s *Store) FetchMessage(mailboxID, uid int64) ([]byte, error) {
|
func (s *Store) FetchMessage(mailboxID, uid int64) ([]byte, error) {
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package relay
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"mailgoserver/internal/mailstore"
|
||||||
|
"mailgoserver/internal/toolbox"
|
||||||
|
)
|
||||||
|
|
||||||
|
// buildBounceMessage renders a simplified delivery-status notification: plain
|
||||||
|
// text/plain, not the full RFC 3464 multipart/report shape (a machine-readable
|
||||||
|
// message/delivery-status part), since every real mail client just shows the
|
||||||
|
// human-readable part anyway and this avoids a second MIME structure to maintain for
|
||||||
|
// something informational, not delivery-critical.
|
||||||
|
func buildBounceMessage(hostname, to, originalSubject, originalMessageID string, failed []Result) (content, messageID string) {
|
||||||
|
messageID = toolbox.GenerateMessageID(hostname)
|
||||||
|
var body strings.Builder
|
||||||
|
body.WriteString("This is an automatically generated Delivery Status Notification.\r\n\r\n")
|
||||||
|
body.WriteString("Delivery to the following recipient(s) failed permanently:\r\n\r\n")
|
||||||
|
for _, f := range failed {
|
||||||
|
reason := f.ErrorMessage
|
||||||
|
if reason == "" {
|
||||||
|
reason = f.ServerResponse
|
||||||
|
}
|
||||||
|
if reason == "" {
|
||||||
|
reason = "unknown error"
|
||||||
|
}
|
||||||
|
body.WriteString(fmt.Sprintf(" - %s\r\n", f.Recipient))
|
||||||
|
if f.ErrorCode != "" {
|
||||||
|
body.WriteString(fmt.Sprintf(" Reason: %s (%s)\r\n\r\n", reason, f.ErrorCode))
|
||||||
|
} else {
|
||||||
|
body.WriteString(fmt.Sprintf(" Reason: %s\r\n\r\n", reason))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body.WriteString("----- Original message -----\r\n")
|
||||||
|
if originalSubject != "" {
|
||||||
|
body.WriteString("Subject: " + originalSubject + "\r\n")
|
||||||
|
}
|
||||||
|
if originalMessageID != "" {
|
||||||
|
body.WriteString("Message-ID: <" + originalMessageID + ">\r\n")
|
||||||
|
}
|
||||||
|
body.WriteString("\r\nThis is an automated message from " + hostname + " — please do not reply.\r\n")
|
||||||
|
|
||||||
|
headers := []string{
|
||||||
|
"Message-ID: <" + messageID + ">",
|
||||||
|
"Date: " + time.Now().Format(time.RFC1123Z),
|
||||||
|
"From: Mail Delivery System <mailer-daemon@" + hostname + ">",
|
||||||
|
"To: " + to,
|
||||||
|
"Subject: Undelivered Mail Returned to Sender",
|
||||||
|
// Marks this as an automated notification (RFC 3834) so any auto-responder or
|
||||||
|
// bounce-of-a-bounce logic on the receiving end knows not to reply to it —
|
||||||
|
// same anti-loop convention SendBounce itself relies on for its own delivery.
|
||||||
|
"Auto-Submitted: auto-replied",
|
||||||
|
`Content-Type: text/plain; charset="UTF-8"`,
|
||||||
|
"Content-Transfer-Encoding: 8bit",
|
||||||
|
"MIME-Version: 1.0",
|
||||||
|
}
|
||||||
|
return strings.Join(headers, "\r\n") + "\r\n\r\n" + body.String(), messageID
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendBounce notifies to that delivery failed for the recipients in failed, mirroring
|
||||||
|
// what a real MTA does when it can't express "delivered to some, not others" as a
|
||||||
|
// single SMTP response and has to accept-then-notify instead of reject-and-let-the-
|
||||||
|
// client's-own-MTA-bounce-it. A no-op if to is empty (this itself would be responding
|
||||||
|
// to a null-sender/already-bounced message — replying to those is the classic bounce-
|
||||||
|
// loop bug, so it's refused unconditionally, not left to the caller to remember) or if
|
||||||
|
// there's nothing failed to report.
|
||||||
|
//
|
||||||
|
// Delivery is local (straight into to's own INBOX, no SMTP round-trip) when to
|
||||||
|
// resolves to a mailbox this server hosts, otherwise it's relayed out exactly like any
|
||||||
|
// other outbound message — using a null reverse-path (empty MAIL FROM, i.e. the wire
|
||||||
|
// form "MAIL FROM:<>") so a failure bouncing the bounce itself can never recurse.
|
||||||
|
func (r *Relay) SendBounce(to, originalSubject, originalMessageID string, failed []Result) error {
|
||||||
|
if to == "" || len(failed) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
content, bounceMessageID := buildBounceMessage(r.Hostname, to, originalSubject, originalMessageID, failed)
|
||||||
|
|
||||||
|
if r.Mailstore != nil {
|
||||||
|
if mbox, err := r.Mailstore.ResolveRecipient(to); err == nil && mbox != nil {
|
||||||
|
_, err := r.Mailstore.StoreMessage(mbox.ID, "INBOX", []byte(content), bounceMessageID,
|
||||||
|
"Mail Delivery System <mailer-daemon@"+r.Hostname+">", "Undelivered Mail Returned to Sender")
|
||||||
|
if err == mailstore.ErrQuotaExceeded {
|
||||||
|
// Nothing sensible to do — the mailbox that would receive the bounce
|
||||||
|
// is itself over quota. Drop it; the sender already saw an inline
|
||||||
|
// error (webmail) or their own MTA is retrying (SMTP), so this isn't
|
||||||
|
// the only signal they have.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results := r.RelayEmailAsync("", []string{to}, content, []string{"to"})
|
||||||
|
if len(results) > 0 && results[0].Status != "success" {
|
||||||
|
return fmt.Errorf("bounce to %s: %s", to, results[0].ErrorMessage)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package relay
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"mailgoserver/internal/db"
|
||||||
|
"mailgoserver/internal/mailstore"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildBounceMessageContainsFailureDetails(t *testing.T) {
|
||||||
|
content, messageID := buildBounceMessage("mail.example.com", "sender@example.com", "Hello", "orig-id@example.com", []Result{
|
||||||
|
{Recipient: "nobody@remote.example", ErrorMessage: "MX lookup failed", ErrorCode: "MX"},
|
||||||
|
})
|
||||||
|
if messageID == "" {
|
||||||
|
t.Fatal("expected a non-empty bounce Message-ID")
|
||||||
|
}
|
||||||
|
for _, want := range []string{
|
||||||
|
"To: sender@example.com",
|
||||||
|
"Subject: Undelivered Mail Returned to Sender",
|
||||||
|
"From: Mail Delivery System <mailer-daemon@mail.example.com>",
|
||||||
|
"Auto-Submitted: auto-replied",
|
||||||
|
"nobody@remote.example",
|
||||||
|
"MX lookup failed",
|
||||||
|
"Subject: Hello",
|
||||||
|
"Message-ID: <orig-id@example.com>",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(content, want) {
|
||||||
|
t.Errorf("expected bounce content to contain %q, got:\n%s", want, content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendBounceNoOpWithoutSenderOrFailures(t *testing.T) {
|
||||||
|
r := &Relay{Hostname: "mail.example.com"}
|
||||||
|
if err := r.SendBounce("", "subj", "id", []Result{{Recipient: "x@example.com", ErrorMessage: "boom"}}); err != nil {
|
||||||
|
t.Fatalf("expected nil error for empty sender, got %v", err)
|
||||||
|
}
|
||||||
|
if err := r.SendBounce("sender@example.com", "subj", "id", nil); err != nil {
|
||||||
|
t.Fatalf("expected nil error for no failures, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTestMailbox mirrors mailstore's own test helper of the same name — kept local
|
||||||
|
// since it's unexported there and this package needs its own small Store+mailbox
|
||||||
|
// fixture to test SendBounce's local-delivery shortcut.
|
||||||
|
func newTestMailbox(t *testing.T, email string) (*mailstore.Store, int64) {
|
||||||
|
t.Helper()
|
||||||
|
database, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { database.Close() })
|
||||||
|
|
||||||
|
domainID, err := database.CreateDomain("example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s := mailstore.New(database, mailstore.GenerateDEK(), t.TempDir())
|
||||||
|
dek := mailstore.GenerateDEK()
|
||||||
|
wrapped, nonce, err := s.WrapDEK(dek)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
hash, err := db.HashPassword("irrelevant-portal-password")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mailboxID, err := database.CreateMailbox(email, hash, domainID, 1<<30, wrapped, nonce)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return s, mailboxID
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendBounceDeliversLocallyWhenRecipientIsLocalMailbox(t *testing.T) {
|
||||||
|
store, mailboxID := newTestMailbox(t, "sender@example.com")
|
||||||
|
r := &Relay{Hostname: "mail.example.com", Mailstore: store, Logger: nil}
|
||||||
|
|
||||||
|
err := r.SendBounce("sender@example.com", "Hello", "orig-id@example.com", []Result{
|
||||||
|
{Recipient: "nobody@remote.example", ErrorMessage: "MX lookup failed", ErrorCode: "MX"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SendBounce: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
inbox, err := store.DB.ListMessagesInFolder(mailboxID, "INBOX")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(inbox) != 1 {
|
||||||
|
t.Fatalf("expected 1 bounce message in INBOX, got %d", len(inbox))
|
||||||
|
}
|
||||||
|
if inbox[0].CachedSubject != "Undelivered Mail Returned to Sender" {
|
||||||
|
t.Errorf("bounce subject = %q", inbox[0].CachedSubject)
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
-1
@@ -13,6 +13,7 @@ import (
|
|||||||
|
|
||||||
"gopkg.in/ini.v1"
|
"gopkg.in/ini.v1"
|
||||||
"mailgoserver/internal/db"
|
"mailgoserver/internal/db"
|
||||||
|
"mailgoserver/internal/mailstore"
|
||||||
"mailgoserver/internal/toolbox"
|
"mailgoserver/internal/toolbox"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -26,15 +27,29 @@ type Result struct {
|
|||||||
ErrorCode string
|
ErrorCode string
|
||||||
ErrorMessage string
|
ErrorMessage string
|
||||||
ServerResponse string
|
ServerResponse string
|
||||||
|
// Quarantined is true for a local delivery that landed in Spam rather than INBOX.
|
||||||
|
// Only ever set by smtpserver's local-delivery path (always false for outbound
|
||||||
|
// relay results) — it's the signal Data() uses to keep this message's body in the
|
||||||
|
// admin log despite content-logging otherwise being off by default, so a spam/
|
||||||
|
// malicious report can actually be reviewed.
|
||||||
|
Quarantined bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type Relay struct {
|
type Relay struct {
|
||||||
DB *db.DB
|
DB *db.DB
|
||||||
Timeout time.Duration
|
Timeout time.Duration
|
||||||
// Hostname is used as the outbound EHLO/HELO identity, mirroring
|
// Hostname is used as the outbound EHLO/HELO identity, mirroring
|
||||||
// email_relay.py's self.hostname (helo_hostname, falling back to hostname).
|
// email_relay.py's self.hostname (helo_hostname, falling back to hostname), and as
|
||||||
|
// the domain part of SendBounce's mailer-daemon@ From address.
|
||||||
Hostname string
|
Hostname string
|
||||||
Logger *toolbox.Logger
|
Logger *toolbox.Logger
|
||||||
|
|
||||||
|
// Mailstore backs SendBounce's local-delivery shortcut (straight into a bounce
|
||||||
|
// recipient's own INBOX when they're a mailbox this server hosts, no SMTP
|
||||||
|
// round-trip needed). Set by main.go once mailstore.New has run — relay.New runs
|
||||||
|
// before that, so this is assigned afterward rather than threaded through the
|
||||||
|
// constructor. Nil-safe: SendBounce falls back to relaying out when unset.
|
||||||
|
Mailstore *mailstore.Store
|
||||||
}
|
}
|
||||||
|
|
||||||
// New builds a Relay from settings.ini. Unlike email_relay.py (which reads
|
// New builds a Relay from settings.ini. Unlike email_relay.py (which reads
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package smtpserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/smtp"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"mailgoserver/internal/db"
|
||||||
|
"mailgoserver/internal/mailstore"
|
||||||
|
)
|
||||||
|
|
||||||
|
// createTestMailboxWithQuota mirrors newTestBackendWithMailbox's inline mailbox setup,
|
||||||
|
// parameterized by quota so this file can create both a normal and an
|
||||||
|
// effectively-always-full mailbox.
|
||||||
|
func createTestMailboxWithQuota(t *testing.T, backend *Backend, store *mailstore.Store, email string, quotaBytes int64) int64 {
|
||||||
|
t.Helper()
|
||||||
|
dek := mailstore.GenerateDEK()
|
||||||
|
wrapped, nonce, err := store.WrapDEK(dek)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
hash, err := db.HashPassword("portal-password-unused")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mailboxID, err := backend.DB.CreateMailbox(email, hash, 1, quotaBytes, wrapped, nonce)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return mailboxID
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPartialLocalDeliveryFailureBouncesAndAccepts confirms a multi-recipient
|
||||||
|
// transaction where one local mailbox accepts the message and another can't (quota
|
||||||
|
// exceeded, discovered only during DATA — RCPT can't catch it) is accepted (250, not
|
||||||
|
// 550 — the successful recipient already has it, so the client must not retry the
|
||||||
|
// whole transaction) and that the sender gets a bounce in their own mailbox describing
|
||||||
|
// the recipient that failed.
|
||||||
|
func TestPartialLocalDeliveryFailureBouncesAndAccepts(t *testing.T) {
|
||||||
|
backend, okMailboxID := newTestBackendWithMailbox(t)
|
||||||
|
store := backend.Mailstore
|
||||||
|
|
||||||
|
fullMailboxID := createTestMailboxWithQuota(t, backend, store, "full@example.com", 1)
|
||||||
|
senderMailboxID := createTestMailboxWithQuota(t, backend, store, "test@example.com", 5*1024*1024*1024)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
if err := c.Mail("test@example.com"); err != nil {
|
||||||
|
t.Fatalf("MAIL FROM: %v", err)
|
||||||
|
}
|
||||||
|
if err := c.Rcpt("inbox@example.com"); err != nil {
|
||||||
|
t.Fatalf("RCPT (ok mailbox): %v", err)
|
||||||
|
}
|
||||||
|
if err := c.Rcpt("full@example.com"); err != nil {
|
||||||
|
t.Fatalf("RCPT (over-quota mailbox, still accepted at RCPT time): %v", err)
|
||||||
|
}
|
||||||
|
w, err := c.Data()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := w.Write([]byte("Subject: hello\r\n\r\nhi there")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := w.Close(); err != nil {
|
||||||
|
t.Fatalf("expected DATA to succeed (250, partial success) despite one recipient failing, got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
okMsgs, err := backend.DB.ListMessagesInFolder(okMailboxID, "INBOX")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(okMsgs) != 1 {
|
||||||
|
t.Fatalf("expected the message delivered to inbox@example.com, got %d messages", len(okMsgs))
|
||||||
|
}
|
||||||
|
|
||||||
|
fullMsgs, err := backend.DB.ListMessagesInFolder(fullMailboxID, "INBOX")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(fullMsgs) != 0 {
|
||||||
|
t.Fatalf("expected no message delivered to the over-quota mailbox, got %d", len(fullMsgs))
|
||||||
|
}
|
||||||
|
|
||||||
|
bounces, err := backend.DB.ListMessagesInFolder(senderMailboxID, "INBOX")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(bounces) != 1 {
|
||||||
|
t.Fatalf("expected 1 bounce message in the sender's own mailbox, got %d", len(bounces))
|
||||||
|
}
|
||||||
|
if bounces[0].CachedSubject != "Undelivered Mail Returned to Sender" {
|
||||||
|
t.Errorf("bounce subject = %q", bounces[0].CachedSubject)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package smtpserver
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// TestEmailLogBodyOmittedByDefault confirms the admin-visible email log gets the
|
||||||
|
// message headers but never the body by default — only Subject/headers are diagnostic
|
||||||
|
// metadata; the body is content, which shouldn't sit in a log unless explicitly opted
|
||||||
|
// into (store_message_content) or the message needed spam review (see
|
||||||
|
// TestEmailLogBodyKeptWhenQuarantined).
|
||||||
|
func TestEmailLogBodyOmittedByDefault(t *testing.T) {
|
||||||
|
backend, _ := newTestBackendWithMailbox(t) // spam_reject_score set sky-high, so nothing quarantines here
|
||||||
|
addr := startTestServer(t, backend)
|
||||||
|
|
||||||
|
if err := sendTestMessage(t, addr, "hello"); err != nil {
|
||||||
|
t.Fatalf("send: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logs, err := backend.DB.ListEmailLogsPage(0, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(logs) != 1 {
|
||||||
|
t.Fatalf("expected 1 email log entry, got %d", len(logs))
|
||||||
|
}
|
||||||
|
if logs[0].MessageBody != "" {
|
||||||
|
t.Errorf("expected no body logged by default, got %q", logs[0].MessageBody)
|
||||||
|
}
|
||||||
|
if logs[0].EmailHeaders == "" {
|
||||||
|
t.Error("expected headers to still be logged even with body omitted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEmailLogBodyKeptWhenStoreMessageContentEnabled confirms the sender's own
|
||||||
|
// "Store Full Message Content" opt-in (esrv_senders.store_message_content) still works
|
||||||
|
// despite the new default-off body logging.
|
||||||
|
func TestEmailLogBodyKeptWhenStoreMessageContentEnabled(t *testing.T) {
|
||||||
|
backend, _ := newTestBackendWithMailbox(t)
|
||||||
|
if _, err := backend.DB.Exec(`UPDATE esrv_senders SET store_message_content = 1 WHERE email = 'test@example.com'`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
addr := startTestServer(t, backend)
|
||||||
|
|
||||||
|
if err := sendTestMessage(t, addr, "hello"); err != nil {
|
||||||
|
t.Fatalf("send: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logs, err := backend.DB.ListEmailLogsPage(0, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(logs) != 1 || logs[0].MessageBody == "" {
|
||||||
|
t.Fatalf("expected the opted-in sender's message body to be logged, got %+v", logs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEmailLogBodyKeptWhenQuarantined confirms a message quarantined to Spam still
|
||||||
|
// gets its body logged even without any opt-in, so an admin can actually review a
|
||||||
|
// spam/abuse report — the one deliberate exception to the default-off rule.
|
||||||
|
func TestEmailLogBodyKeptWhenQuarantined(t *testing.T) {
|
||||||
|
backend, mailboxID := newTestBackendWithMailbox(t)
|
||||||
|
rspamd := fakeRspamd(t, 20, "add header")
|
||||||
|
backend.Cfg.Section("Rspamd").Key("enabled").SetValue("true")
|
||||||
|
backend.Cfg.Section("Rspamd").Key("url").SetValue(rspamd.URL)
|
||||||
|
backend.Cfg.Section("Rspamd").Key("reject_score").SetValue("15")
|
||||||
|
addr := startTestServer(t, backend)
|
||||||
|
|
||||||
|
if err := sendTestMessage(t, addr, "hello"); err != nil {
|
||||||
|
t.Fatalf("send: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
spamMsgs, err := backend.DB.ListMessagesInFolder(mailboxID, "Spam")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(spamMsgs) != 1 {
|
||||||
|
t.Fatalf("expected the message quarantined to Spam, got %d Spam messages", len(spamMsgs))
|
||||||
|
}
|
||||||
|
|
||||||
|
logs, err := backend.DB.ListEmailLogsPage(0, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(logs) != 1 || logs[0].MessageBody == "" {
|
||||||
|
t.Fatalf("expected the quarantined message's body to be logged for review, got %+v", logs)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ func newTestBackendWithMailbox(t *testing.T) (*Backend, int64) {
|
|||||||
|
|
||||||
store := mailstore.New(backend.DB, mailstore.GenerateDEK(), t.TempDir())
|
store := mailstore.New(backend.DB, mailstore.GenerateDEK(), t.TempDir())
|
||||||
backend.Mailstore = store
|
backend.Mailstore = store
|
||||||
|
backend.Relay.Mailstore = store // mirrors main.go's wiring, needed for SendBounce's local-delivery shortcut
|
||||||
// Spam/SPF/DNSBL checks make live DNS calls (see internal/mailstore) — deliberately
|
// Spam/SPF/DNSBL checks make live DNS calls (see internal/mailstore) — deliberately
|
||||||
// so in production, but that makes their exact score environment-dependent (e.g. a
|
// so in production, but that makes their exact score environment-dependent (e.g. a
|
||||||
// resolver that hijacks NXDOMAIN, or a real SPF record on the test domain). These
|
// resolver that hijacks NXDOMAIN, or a real SPF record on the test domain). These
|
||||||
@@ -80,6 +81,52 @@ func TestLocalDeliveryToKnownMailbox(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestLocalDeliveryCachesFromHeaderDisplayName confirms cached_from is the message's
|
||||||
|
// own From: header (e.g. "Bob Marley <bob@example.com>"), not the bare SMTP envelope
|
||||||
|
// address — the envelope rarely carries a display name, but the header usually does,
|
||||||
|
// and webmail's folder list wants the display name to show.
|
||||||
|
func TestLocalDeliveryCachesFromHeaderDisplayName(t *testing.T) {
|
||||||
|
backend, mailboxID := newTestBackendWithMailbox(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)
|
||||||
|
}
|
||||||
|
if err := c.Mail("test@example.com"); err != nil {
|
||||||
|
t.Fatalf("MAIL FROM: %v", err)
|
||||||
|
}
|
||||||
|
if err := c.Rcpt("inbox@example.com"); err != nil {
|
||||||
|
t.Fatalf("RCPT: %v", err)
|
||||||
|
}
|
||||||
|
w, err := c.Data()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := w.Write([]byte("From: Bob Marley <test@example.com>\r\nSubject: hello\r\n\r\nhi there")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := w.Close(); err != nil {
|
||||||
|
t.Fatalf("DATA: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
msgs, err := backend.DB.ListMessagesInFolder(mailboxID, "INBOX")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(msgs) != 1 {
|
||||||
|
t.Fatalf("expected 1 message, got %d", len(msgs))
|
||||||
|
}
|
||||||
|
if msgs[0].CachedFrom != "Bob Marley <test@example.com>" {
|
||||||
|
t.Errorf("cached_from = %q, want the From: header value with display name", msgs[0].CachedFrom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLocalDeliveryUnknownMailboxRejected(t *testing.T) {
|
func TestLocalDeliveryUnknownMailboxRejected(t *testing.T) {
|
||||||
backend, _ := newTestBackendWithMailbox(t)
|
backend, _ := newTestBackendWithMailbox(t)
|
||||||
addr := startTestServer(t, backend)
|
addr := startTestServer(t, backend)
|
||||||
|
|||||||
@@ -248,6 +248,14 @@ func (s *Session) Data(r io.Reader) error {
|
|||||||
toHeader := rebuiltHeaders["to"]
|
toHeader := rebuiltHeaders["to"]
|
||||||
ccHeader := rebuiltHeaders["cc"]
|
ccHeader := rebuiltHeaders["cc"]
|
||||||
subject := rebuiltHeaders["subject"]
|
subject := rebuiltHeaders["subject"]
|
||||||
|
// The message's own From: header (e.g. "Bob Marley <bob@example.com>"), not the
|
||||||
|
// bare SMTP envelope address — used only for what's cached/displayed (webmail's
|
||||||
|
// folder list), never for delivery/auth decisions, which stay on s.mailFrom
|
||||||
|
// throughout. Falls back to the envelope address if the header's missing/empty.
|
||||||
|
fromHeader := rebuiltHeaders["from"]
|
||||||
|
if fromHeader == "" {
|
||||||
|
fromHeader = s.mailFrom
|
||||||
|
}
|
||||||
|
|
||||||
// Attachment storage: only if the authenticated sender or whitelisted IP opted in.
|
// Attachment storage: only if the authenticated sender or whitelisted IP opted in.
|
||||||
storeMessage := false
|
storeMessage := false
|
||||||
@@ -321,23 +329,66 @@ func (s *Session) Data(r io.Reader) error {
|
|||||||
results = s.backend.Relay.RelayEmailAsync(s.mailFrom, relayRcpts, signedContent, relayTypes)
|
results = s.backend.Relay.RelayEmailAsync(s.mailFrom, relayRcpts, signedContent, relayTypes)
|
||||||
}
|
}
|
||||||
if len(localRcpts) > 0 {
|
if len(localRcpts) > 0 {
|
||||||
results = append(results, s.deliverLocally(localRcpts, localTypes, signedContent, messageID, subject)...)
|
results = append(results, s.deliverLocally(localRcpts, localTypes, signedContent, messageID, subject, fromHeader)...)
|
||||||
}
|
}
|
||||||
|
|
||||||
allSucceeded := len(results) > 0
|
var failed []relay.Result
|
||||||
for _, res := range results {
|
for _, res := range results {
|
||||||
if res.Status != "success" {
|
if res.Status != "success" {
|
||||||
allSucceeded = false
|
failed = append(failed, res)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
allSucceeded := len(results) > 0 && len(failed) == 0
|
||||||
|
anySucceeded := len(results) > len(failed)
|
||||||
|
|
||||||
|
// A single SMTP response to DATA can't express "delivered to some recipients, not
|
||||||
|
// others" — rejecting the whole transaction here would make the connecting
|
||||||
|
// server's own retry logic re-deliver to the recipients that already succeeded.
|
||||||
|
// So: accept (below) and bounce the failed subset back to our own sender instead,
|
||||||
|
// exactly like a real MTA splitting a multi-recipient transaction's outcome. A
|
||||||
|
// bounce is never sent for a *total* failure — that gets rejected outright (550)
|
||||||
|
// below instead, letting the connecting server's own MTA generate the bounce to
|
||||||
|
// its user, avoiding a double notification. Skipped entirely for a null-sender
|
||||||
|
// message (s.mailFrom == "", already itself a bounce/DSN — replying to one is the
|
||||||
|
// classic bounce-loop bug) and for a currently-blacklisted peer, so a delivery
|
||||||
|
// failure never becomes a free "yes, that mailbox doesn't exist" oracle for abuse.
|
||||||
|
if len(failed) > 0 && anySucceeded && s.mailFrom != "" {
|
||||||
|
if blacklisted, _ := s.backend.DB.IsIPBlacklisted(s.peerIP); !blacklisted {
|
||||||
|
if err := s.backend.Relay.SendBounce(s.mailFrom, subject, messageID, failed); err != nil {
|
||||||
|
s.backend.Logger.Error("send bounce to %s: %v", s.mailFrom, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var emailHeaders, messageBody string
|
var emailHeaders string
|
||||||
if parseErr == nil {
|
if parseErr == nil {
|
||||||
emailHeaders = strings.Join(parsed.HeaderLines, "\n")
|
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)
|
// Privacy default: only headers (and the Subject field, logged separately below
|
||||||
|
// regardless) go into the admin-visible log, never the message itself — unless this
|
||||||
|
// sender/IP explicitly opted in via "Store Full Message Content" (storeMessage
|
||||||
|
// above), or the message was quarantined to Spam for at least one recipient, in
|
||||||
|
// which case an admin genuinely needs to see it to judge a spam/abuse report. When
|
||||||
|
// stored, it's the *entire* raw message (not a plain-text extraction) so the log
|
||||||
|
// viewer can render the real HTML body, inline images, and attachments — re-parsed
|
||||||
|
// on demand via internal/mailview, the same parser webmail's own message view uses
|
||||||
|
// — rather than a degraded text-only approximation.
|
||||||
|
storeContent := storeMessage
|
||||||
|
if !storeContent {
|
||||||
|
for _, res := range results {
|
||||||
|
if res.Quarantined {
|
||||||
|
storeContent = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loggedBody := ""
|
||||||
|
if storeContent {
|
||||||
|
loggedBody = signedContent
|
||||||
|
}
|
||||||
|
|
||||||
|
logID, logErr := s.backend.Relay.LogEmail(s.backend.Cfg, s.peerIP, s.mailFrom, toHeader, ccHeader, "", subject, emailHeaders, loggedBody, messageID, s.username, dkimSigned, results)
|
||||||
if logErr != nil {
|
if logErr != nil {
|
||||||
s.backend.Logger.Error("Failed to log email: %v", logErr)
|
s.backend.Logger.Error("Failed to log email: %v", logErr)
|
||||||
} else {
|
} else {
|
||||||
@@ -353,6 +404,12 @@ func (s *Session) Data(r io.Reader) error {
|
|||||||
if allSucceeded {
|
if allSucceeded {
|
||||||
return &smtp.SMTPError{Code: 250, EnhancedCode: smtp.NoEnhancedCode, Message: "Message accepted for delivery"}
|
return &smtp.SMTPError{Code: 250, EnhancedCode: smtp.NoEnhancedCode, Message: "Message accepted for delivery"}
|
||||||
}
|
}
|
||||||
|
if anySucceeded {
|
||||||
|
// Some recipients already have the message — 250 it (see the bounce comment
|
||||||
|
// above for why), not 550, which would tell the connecting server to retry
|
||||||
|
// the whole thing and re-deliver to those recipients a second time.
|
||||||
|
return &smtp.SMTPError{Code: 250, EnhancedCode: smtp.NoEnhancedCode, Message: "Message accepted for delivery to some recipients"}
|
||||||
|
}
|
||||||
return &smtp.SMTPError{Code: 550, EnhancedCode: smtp.NoEnhancedCode, Message: "Message relay failed"}
|
return &smtp.SMTPError{Code: 550, EnhancedCode: smtp.NoEnhancedCode, Message: "Message relay failed"}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,7 +418,7 @@ func (s *Session) Data(r io.Reader) error {
|
|||||||
// and stores it into each resolved local mailbox, producing one relay.Result per
|
// and stores it into each resolved local mailbox, producing one relay.Result per
|
||||||
// recipient so it can be merged into the same LogEmail/allSucceeded logic as relay
|
// recipient so it can be merged into the same LogEmail/allSucceeded logic as relay
|
||||||
// results.
|
// results.
|
||||||
func (s *Session) deliverLocally(rcpts, types []string, signedContent, messageID, subject string) []relay.Result {
|
func (s *Session) deliverLocally(rcpts, types []string, signedContent, messageID, subject, fromDisplay string) []relay.Result {
|
||||||
senderDomain := domainOfAddr(s.mailFrom)
|
senderDomain := domainOfAddr(s.mailFrom)
|
||||||
dkimPass := senderDomain != "" && dkim.VerifyInbound(signedContent, senderDomain)
|
dkimPass := senderDomain != "" && dkim.VerifyInbound(signedContent, senderDomain)
|
||||||
spfPass := mailstore.CheckSPF(s.mailFrom, s.peerIP)
|
spfPass := mailstore.CheckSPF(s.mailFrom, s.peerIP)
|
||||||
@@ -430,7 +487,7 @@ func (s *Session) deliverLocally(rcpts, types []string, signedContent, messageID
|
|||||||
markRead = action.MarkRead
|
markRead = action.MarkRead
|
||||||
}
|
}
|
||||||
|
|
||||||
uid, err := s.backend.Mailstore.StoreMessage(mbox.ID, folder, []byte(signedContent), messageID, s.mailFrom, subject)
|
uid, err := s.backend.Mailstore.StoreMessage(mbox.ID, folder, []byte(signedContent), messageID, fromDisplay, subject)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errCode, errMsg := "450", err.Error()
|
errCode, errMsg := "450", err.Error()
|
||||||
if err == mailstore.ErrQuotaExceeded {
|
if err == mailstore.ErrQuotaExceeded {
|
||||||
@@ -448,7 +505,7 @@ func (s *Session) deliverLocally(rcpts, types []string, signedContent, messageID
|
|||||||
if spamGated {
|
if spamGated {
|
||||||
serverResponse = "Quarantined to Spam folder"
|
serverResponse = "Quarantined to Spam folder"
|
||||||
}
|
}
|
||||||
results = append(results, relay.Result{Recipient: rcpt, RecipientType: types[i], Status: "success", ServerResponse: serverResponse})
|
results = append(results, relay.Result{Recipient: rcpt, RecipientType: types[i], Status: "success", ServerResponse: serverResponse, Quarantined: spamGated})
|
||||||
}
|
}
|
||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,15 +7,23 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"mailgoserver/internal/acmecert"
|
||||||
)
|
)
|
||||||
|
|
||||||
// leAlwaysOverwriteFields are plain (non-secret) [LetsEncrypt] settings — always
|
// leAlwaysOverwriteFields are plain (non-secret) [LetsEncrypt] (DNS-01) settings —
|
||||||
// persisted from the submitted form, same as any other settings.html field.
|
// always persisted from the submitted form, same as any other settings.html field.
|
||||||
var leAlwaysOverwriteFields = []string{
|
var leAlwaysOverwriteFields = []string{
|
||||||
"enabled", "staging", "contact_email", "domains", "dns_provider",
|
"enabled", "staging", "contact_email", "domains", "dns_provider",
|
||||||
"route53_region", "route53_hosted_zone_id", "gcloud_project",
|
"route53_region", "route53_hosted_zone_id", "gcloud_project",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// leHTTPFields are the [LetsEncryptHTTP] (HTTP-01) settings, all plain — no secrets to
|
||||||
|
// redact, unlike the DNS-01 providers' API credentials.
|
||||||
|
var leHTTPFields = []string{
|
||||||
|
"enabled", "staging", "contact_email", "domains", "include_ip", "ip_override",
|
||||||
|
}
|
||||||
|
|
||||||
// leSecretFields hold DNS provider credentials. They're never rendered back into the
|
// leSecretFields hold DNS provider credentials. They're never rendered back into the
|
||||||
// form (always blank) and the save handler only overwrites the stored value when the
|
// form (always blank) and the save handler only overwrites the stored value when the
|
||||||
// submitted field is non-empty — "leave blank to keep the current value", the same
|
// submitted field is non-empty — "leave blank to keep the current value", the same
|
||||||
@@ -25,8 +33,9 @@ var leSecretFields = []string{
|
|||||||
"digitalocean_api_token", "gcloud_service_account_json_path",
|
"digitalocean_api_token", "gcloud_service_account_json_path",
|
||||||
}
|
}
|
||||||
|
|
||||||
// letsEncryptPage shows the current Let's Encrypt status and configuration form.
|
// letsEncryptPage shows the current Let's Encrypt status and configuration forms for
|
||||||
// Secret fields are always blank in the rendered form — see leSecretFields.
|
// both the DNS-01 and HTTP-01 managers. Secret fields are always blank in the rendered
|
||||||
|
// form — see leSecretFields.
|
||||||
func (a *App) letsEncryptPage(w http.ResponseWriter, r *http.Request) {
|
func (a *App) letsEncryptPage(w http.ResponseWriter, r *http.Request) {
|
||||||
sec := a.Cfg.Section("LetsEncrypt")
|
sec := a.Cfg.Section("LetsEncrypt")
|
||||||
kv := M{}
|
kv := M{}
|
||||||
@@ -36,7 +45,18 @@ func (a *App) letsEncryptPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
for _, k := range leSecretFields {
|
for _, k := range leSecretFields {
|
||||||
kv[k] = ""
|
kv[k] = ""
|
||||||
}
|
}
|
||||||
a.render(w, r, "letsencrypt.html", M{"active": "letsencrypt", "le": kv, "status": a.ACME.Status()})
|
|
||||||
|
httpSec := a.Cfg.Section("LetsEncryptHTTP")
|
||||||
|
httpKV := M{}
|
||||||
|
for _, k := range leHTTPFields {
|
||||||
|
httpKV[k] = httpSec.Key(k).String()
|
||||||
|
}
|
||||||
|
|
||||||
|
a.render(w, r, "letsencrypt.html", M{
|
||||||
|
"active": "letsencrypt",
|
||||||
|
"le": kv, "status": a.ACME.Status(),
|
||||||
|
"leHTTP": httpKV, "statusHTTP": a.ACMEHTTP.Status(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// letsEncryptSave is a dedicated handler (not the generic settingsUpdate reflection)
|
// letsEncryptSave is a dedicated handler (not the generic settingsUpdate reflection)
|
||||||
@@ -79,6 +99,39 @@ func (a *App) letsEncryptObtainNow(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Redirect(w, r, Prefix+"/letsencrypt", http.StatusFound)
|
http.Redirect(w, r, Prefix+"/letsencrypt", http.StatusFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// letsEncryptHTTPSave mirrors letsEncryptSave for the [LetsEncryptHTTP] section — a
|
||||||
|
// separate handler (not the generic settingsUpdate reflection) purely so it can
|
||||||
|
// redirect back to /letsencrypt like its DNS-01 sibling; every field here is plain, so
|
||||||
|
// unlike letsEncryptSave there's no secret-redaction concern.
|
||||||
|
func (a *App) letsEncryptHTTPSave(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
setFlash(w, "error", "Invalid form data")
|
||||||
|
http.Redirect(w, r, Prefix+"/letsencrypt", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sec := a.Cfg.Section("LetsEncryptHTTP")
|
||||||
|
for _, k := range leHTTPFields {
|
||||||
|
sec.Key(k).SetValue(r.FormValue(k))
|
||||||
|
}
|
||||||
|
if err := a.Cfg.SaveTo(a.ConfigPath); err != nil {
|
||||||
|
setFlash(w, "error", "Error saving settings: "+err.Error())
|
||||||
|
http.Redirect(w, r, Prefix+"/letsencrypt", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setFlash(w, "success", `Let's Encrypt HTTP-01 settings saved. If you just changed "Enable HTTP-01" or the port, restart the server before using "Obtain / Renew Now" — the challenge responder only starts at boot.`)
|
||||||
|
http.Redirect(w, r, Prefix+"/letsencrypt", http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// letsEncryptHTTPObtainNow mirrors letsEncryptObtainNow for the HTTP-01 manager.
|
||||||
|
func (a *App) letsEncryptHTTPObtainNow(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := a.ACMEHTTP.ObtainOrRenew(r.Context()); err != nil {
|
||||||
|
setFlash(w, "error", "Could not obtain certificate: "+err.Error())
|
||||||
|
} else {
|
||||||
|
setFlash(w, "success", "Certificate obtained successfully")
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, Prefix+"/letsencrypt", http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
// uploadGCloudServiceAccount mirrors settings.go's uploadTLSFile two-step flow: upload
|
// uploadGCloudServiceAccount mirrors settings.go's uploadTLSFile two-step flow: upload
|
||||||
// the file, return its saved path as JSON, and the browser fills a sibling text input
|
// the file, return its saved path as JSON, and the browser fills a sibling text input
|
||||||
// with that path — the path only actually persists once the surrounding form (Save)
|
// with that path — the path only actually persists once the surrounding form (Save)
|
||||||
@@ -113,3 +166,14 @@ func (a *App) uploadGCloudServiceAccount(w http.ResponseWriter, r *http.Request)
|
|||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, M{"status": "success", "filepath": filePath})
|
writeJSON(w, http.StatusOK, M{"status": "success", "filepath": filePath})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// detectWANIP backs the "Detect" button next to the HTTP-01 manual IP override field —
|
||||||
|
// a live lookup, not persisted anywhere until the surrounding form is saved.
|
||||||
|
func (a *App) detectWANIP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ip, err := acmecert.DetectWANIP(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadGateway, M{"status": "error", "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, M{"status": "success", "ip": ip})
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/mail"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -50,6 +51,26 @@ func (a *App) funcMap() template.FuncMap {
|
|||||||
"lower": strings.ToLower,
|
"lower": strings.ToLower,
|
||||||
"safe": func(s string) template.HTML { return template.HTML(s) },
|
"safe": func(s string) template.HTML { return template.HTML(s) },
|
||||||
"filesize": humanFileSize,
|
"filesize": humanFileSize,
|
||||||
|
// senderName shows just the display name from a "Name <addr@example.com>"
|
||||||
|
// cached_from value (the message's own From: header, cached verbatim — see
|
||||||
|
// smtpserver's fromHeader) — falls back to the bare address when there's no
|
||||||
|
// display name, or the value doesn't parse as one (e.g. an older row cached
|
||||||
|
// before this, or a plain envelope address with no header form).
|
||||||
|
"senderName": func(s string) string {
|
||||||
|
if addr, err := mail.ParseAddress(s); err == nil && addr.Name != "" {
|
||||||
|
return addr.Name
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
},
|
||||||
|
// initial is the avatar-circle letter for a sender name/address — the first
|
||||||
|
// rune, uppercased; falls back to "?" for an empty value rather than an empty
|
||||||
|
// circle.
|
||||||
|
"initial": func(s string) string {
|
||||||
|
for _, r := range s {
|
||||||
|
return strings.ToUpper(string(r))
|
||||||
|
}
|
||||||
|
return "?"
|
||||||
|
},
|
||||||
"dotToDash": func(s string) string { return strings.ReplaceAll(s, ".", "-") },
|
"dotToDash": func(s string) string { return strings.ReplaceAll(s, ".", "-") },
|
||||||
"add": func(a, b int) int { return a + b },
|
"add": func(a, b int) int { return a + b },
|
||||||
"sub": func(a, b int) int { return a - b },
|
"sub": func(a, b int) int { return a - b },
|
||||||
@@ -134,7 +155,7 @@ var pages = []string{
|
|||||||
"ips.html", "add_ip.html", "edit_ip.html",
|
"ips.html", "add_ip.html", "edit_ip.html",
|
||||||
"blacklist.html",
|
"blacklist.html",
|
||||||
"dkim.html", "edit_dkim.html",
|
"dkim.html", "edit_dkim.html",
|
||||||
"settings.html", "letsencrypt.html", "logs.html", "view_message_content.html", "error.html",
|
"settings.html", "letsencrypt.html", "logs.html", "error.html",
|
||||||
"account.html", "first_login.html",
|
"account.html", "first_login.html",
|
||||||
"admins.html", "add_admin.html", "edit_admin.html",
|
"admins.html", "add_admin.html", "edit_admin.html",
|
||||||
}
|
}
|
||||||
@@ -148,6 +169,14 @@ var standalonePages = []string{
|
|||||||
"login.html", "login_mfa.html", "mfa_setup_required.html", "totp_setup.html",
|
"login.html", "login_mfa.html", "mfa_setup_required.html", "totp_setup.html",
|
||||||
"webmail_login.html", "webmail_login_mfa.html", "webmail_account.html", "webmail_totp_setup.html", "webmail_mfa_setup_required.html",
|
"webmail_login.html", "webmail_login_mfa.html", "webmail_account.html", "webmail_totp_setup.html", "webmail_mfa_setup_required.html",
|
||||||
"webmail_folder.html", "webmail_message.html", "webmail_compose.html", "webmail_rules.html", "webmail_certs.html",
|
"webmail_folder.html", "webmail_message.html", "webmail_compose.html", "webmail_rules.html", "webmail_certs.html",
|
||||||
|
// A bare HTML fragment (no <html>/base.html chrome at all), fetched via JS and
|
||||||
|
// injected into logs.html's full-screen modal — not a page anyone navigates to
|
||||||
|
// directly, so it doesn't need to look like a standalone document the way the
|
||||||
|
// other entries in this list (all real standalone pages) do.
|
||||||
|
"view_message_content.html",
|
||||||
|
// Same idea as view_message_content.html above, but for webmail_folder.html's
|
||||||
|
// reading pane instead of the admin log's modal.
|
||||||
|
"webmail_message_pane.html",
|
||||||
}
|
}
|
||||||
|
|
||||||
// pagesWithComposeWidget are the standalone pages that show a Compose/Reply/Forward
|
// pagesWithComposeWidget are the standalone pages that show a Compose/Reply/Forward
|
||||||
|
|||||||
@@ -6,21 +6,29 @@
|
|||||||
<h2><i class="bi bi-patch-check me-2"></i>Let's Encrypt</h2>
|
<h2><i class="bi bi-patch-check me-2"></i>Let's Encrypt</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p class="text-muted">
|
||||||
|
Two independent certificates can be obtained here — DNS-01 (needs a supported DNS provider) and
|
||||||
|
HTTP-01 (needs nothing but port 80 reachable from the internet). Enable either, both, or
|
||||||
|
neither. Which listener (SMTP-TLS, IMAP-TLS, or the admin/webmail HTTPS) actually uses which
|
||||||
|
certificate is chosen on the <a href="/pymta-manager/settings">Settings</a> page's TLS/SSL
|
||||||
|
section — e.g. run the HTTP-01 cert on mail while the dashboard keeps a DNS-01 or custom cert.
|
||||||
|
</p>
|
||||||
|
|
||||||
<div class="card mb-4">
|
<div class="card mb-4">
|
||||||
<div class="card-header"><h5 class="mb-0"><i class="bi bi-shield-check me-2"></i>Status</h5></div>
|
<div class="card-header"><h5 class="mb-0"><i class="bi bi-globe me-2"></i>DNS-01</h5></div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<dl class="row mb-3">
|
<dl class="row mb-3">
|
||||||
<dt class="col-sm-3">Mode</dt>
|
<dt class="col-sm-3">Mode</dt>
|
||||||
<dd class="col-sm-9">
|
<dd class="col-sm-9">
|
||||||
{{if .status.Enabled}}
|
{{if .status.Enabled}}
|
||||||
<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>Let's Encrypt {{if .status.Staging}}(staging){{end}}</span>
|
<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>Enabled {{if .status.Staging}}(staging){{end}}</span>
|
||||||
{{else}}
|
{{else}}
|
||||||
<span class="badge bg-secondary"><i class="bi bi-dash-circle me-1"></i>Self-signed (Let's Encrypt disabled)</span>
|
<span class="badge bg-secondary"><i class="bi bi-dash-circle me-1"></i>Disabled</span>
|
||||||
{{end}}
|
{{end}}
|
||||||
</dd>
|
</dd>
|
||||||
<dt class="col-sm-3">Domains</dt>
|
<dt class="col-sm-3">Domains</dt>
|
||||||
<dd class="col-sm-9">{{if .status.Domains}}{{range .status.Domains}}<code>{{.}}</code> {{end}}{{else}}<span class="text-muted">none configured</span>{{end}}</dd>
|
<dd class="col-sm-9">{{if .status.Domains}}{{range .status.Domains}}<code>{{.}}</code> {{end}}{{else}}<span class="text-muted">none configured</span>{{end}}</dd>
|
||||||
<dt class="col-sm-3">Provider</dt>
|
<dt class="col-sm-3">DNS Provider</dt>
|
||||||
<dd class="col-sm-9">{{if .status.Provider}}{{.status.Provider}}{{else}}<span class="text-muted">none selected</span>{{end}}</dd>
|
<dd class="col-sm-9">{{if .status.Provider}}{{.status.Provider}}{{else}}<span class="text-muted">none selected</span>{{end}}</dd>
|
||||||
<dt class="col-sm-3">Certificate expires</dt>
|
<dt class="col-sm-3">Certificate expires</dt>
|
||||||
<dd class="col-sm-9">{{if .status.NotAfter.IsZero}}<span class="text-muted">unknown</span>{{else}}{{strftime "%Y-%m-%d %H:%M" .status.NotAfter}}{{end}}</dd>
|
<dd class="col-sm-9">{{if .status.NotAfter.IsZero}}<span class="text-muted">unknown</span>{{else}}{{strftime "%Y-%m-%d %H:%M" .status.NotAfter}}{{end}}</dd>
|
||||||
@@ -36,114 +44,188 @@
|
|||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
<form method="post" action="/pymta-manager/letsencrypt/obtain">
|
<form method="post" action="/pymta-manager/letsencrypt/obtain">
|
||||||
<button type="submit" class="btn btn-primary" data-confirm="Obtain or renew the certificate now using the saved configuration?"><i class="bi bi-arrow-repeat me-1"></i>Obtain / Renew Now</button>
|
<button type="submit" class="btn btn-primary" data-confirm="Obtain or renew the DNS-01 certificate now using the saved configuration?"><i class="bi bi-arrow-repeat me-1"></i>Obtain / Renew Now</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
<form method="POST" action="/pymta-manager/letsencrypt/save">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Enable DNS-01</label>
|
||||||
|
<select class="form-select" name="enabled">
|
||||||
|
<option value="false" {{if ne .le.enabled "true"}}selected{{end}}>No</option>
|
||||||
|
<option value="true" {{if eq .le.enabled "true"}}selected{{end}}>Yes</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Staging mode</label>
|
||||||
|
<select class="form-select" name="staging">
|
||||||
|
<option value="false" {{if ne .le.staging "true"}}selected{{end}}>No — request a real, trusted certificate</option>
|
||||||
|
<option value="true" {{if eq .le.staging "true"}}selected{{end}}>Yes — untrusted test certificate, no rate limits</option>
|
||||||
|
</select>
|
||||||
|
<div class="form-text">Recommended while testing a new configuration.</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Contact Email</label>
|
||||||
|
<input type="email" class="form-control" name="contact_email" value="{{.le.contact_email}}">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Domains</label>
|
||||||
|
<input type="text" class="form-control font-monospace" name="domains" value="{{.le.domains}}" placeholder="mail.example.com,*.mail.example.com">
|
||||||
|
<div class="form-text">Comma-separated. Include a wildcard entry (e.g. <code>*.mail.example.com</code>) alongside its bare domain to cover both with one certificate.</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">DNS Provider</label>
|
||||||
|
<select class="form-select" name="dns_provider" id="le_provider">
|
||||||
|
<option value="">Select a provider...</option>
|
||||||
|
<option value="cloudflare" {{if eq .le.dns_provider "cloudflare"}}selected{{end}}>Cloudflare</option>
|
||||||
|
<option value="route53" {{if eq .le.dns_provider "route53"}}selected{{end}}>AWS Route53</option>
|
||||||
|
<option value="digitalocean" {{if eq .le.dns_provider "digitalocean"}}selected{{end}}>DigitalOcean</option>
|
||||||
|
<option value="gcloud" {{if eq .le.dns_provider "gcloud"}}selected{{end}}>Google Cloud DNS</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="provider-fields" id="fields-cloudflare">
|
||||||
|
<div class="setting-section mb-3">
|
||||||
|
<h6>Cloudflare</h6>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">API Token</label>
|
||||||
|
<input type="password" class="form-control" name="cloudflare_api_token" placeholder="Leave blank to keep the current value">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="provider-fields" id="fields-route53">
|
||||||
|
<div class="setting-section mb-3">
|
||||||
|
<h6>AWS Route53</h6>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Access Key ID</label>
|
||||||
|
<input type="password" class="form-control" name="route53_access_key_id" placeholder="Leave blank to keep the current value, or blank both keys to use the host's AWS credential chain">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Secret Access Key</label>
|
||||||
|
<input type="password" class="form-control" name="route53_secret_access_key" placeholder="Leave blank to keep the current value">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Region</label>
|
||||||
|
<input type="text" class="form-control" name="route53_region" value="{{.le.route53_region}}" placeholder="us-east-1">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Hosted Zone ID (optional)</label>
|
||||||
|
<input type="text" class="form-control" name="route53_hosted_zone_id" value="{{.le.route53_hosted_zone_id}}" placeholder="Leave blank to auto-discover">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="provider-fields" id="fields-digitalocean">
|
||||||
|
<div class="setting-section mb-3">
|
||||||
|
<h6>DigitalOcean</h6>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">API Token</label>
|
||||||
|
<input type="password" class="form-control" name="digitalocean_api_token" placeholder="Leave blank to keep the current value">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="provider-fields" id="fields-gcloud">
|
||||||
|
<div class="setting-section mb-3">
|
||||||
|
<h6>Google Cloud DNS</h6>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Project ID</label>
|
||||||
|
<input type="text" class="form-control" name="gcloud_project" value="{{.le.gcloud_project}}">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Service Account Key (optional)</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" class="form-control font-monospace" name="gcloud_service_account_json_path" id="gcloud_sa_path" placeholder="Leave blank to use Application Default Credentials">
|
||||||
|
<input type="file" class="d-none" id="gcloudKeyUpload" accept=".json">
|
||||||
|
<button class="btn btn-outline-secondary" type="button" onclick="document.getElementById('gcloudKeyUpload').click()"><i class="bi bi-upload"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-success"><i class="bi bi-check-lg me-1"></i>Save DNS-01 Configuration</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="POST" action="/pymta-manager/letsencrypt/save">
|
|
||||||
<div class="card mb-4">
|
<div class="card mb-4">
|
||||||
<div class="card-header"><h5 class="mb-0"><i class="bi bi-gear me-2"></i>Configuration</h5></div>
|
<div class="card-header"><h5 class="mb-0"><i class="bi bi-hdd-network me-2"></i>HTTP-01</h5></div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="mb-3">
|
<dl class="row mb-3">
|
||||||
<label class="form-label">Enable Let's Encrypt</label>
|
<dt class="col-sm-3">Mode</dt>
|
||||||
<select class="form-select" name="enabled">
|
<dd class="col-sm-9">
|
||||||
<option value="false" {{if ne .le.enabled "true"}}selected{{end}}>No — keep the self-signed certificate</option>
|
{{if .statusHTTP.Enabled}}
|
||||||
<option value="true" {{if eq .le.enabled "true"}}selected{{end}}>Yes</option>
|
<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>Enabled</span>
|
||||||
</select>
|
{{else}}
|
||||||
</div>
|
<span class="badge bg-secondary"><i class="bi bi-dash-circle me-1"></i>Disabled</span>
|
||||||
<div class="mb-3">
|
{{end}}
|
||||||
<label class="form-label">Staging mode</label>
|
</dd>
|
||||||
<select class="form-select" name="staging">
|
<dt class="col-sm-3">Domains</dt>
|
||||||
<option value="false" {{if ne .le.staging "true"}}selected{{end}}>No — request a real, trusted certificate</option>
|
<dd class="col-sm-9">{{if .statusHTTP.Domains}}{{range .statusHTTP.Domains}}<code>{{.}}</code> {{end}}{{else}}<span class="text-muted">none configured</span>{{end}}{{if .statusHTTP.IncludeIP}} <span class="badge bg-info">+ server IP</span>{{end}}</dd>
|
||||||
<option value="true" {{if eq .le.staging "true"}}selected{{end}}>Yes — untrusted test certificate, no rate limits</option>
|
<dt class="col-sm-3">Certificate expires</dt>
|
||||||
</select>
|
<dd class="col-sm-9">{{if .statusHTTP.NotAfter.IsZero}}<span class="text-muted">unknown</span>{{else}}{{strftime "%Y-%m-%d %H:%M" .statusHTTP.NotAfter}}{{end}}</dd>
|
||||||
<div class="form-text">Recommended while testing a new configuration.</div>
|
<dt class="col-sm-3">Last attempt</dt>
|
||||||
</div>
|
<dd class="col-sm-9">
|
||||||
<div class="mb-3">
|
{{if .statusHTTP.LastAttempt.IsZero}}
|
||||||
<label class="form-label">Contact Email</label>
|
<span class="text-muted">none yet this run</span>
|
||||||
<input type="email" class="form-control" name="contact_email" value="{{.le.contact_email}}">
|
{{else if .statusHTTP.LastError}}
|
||||||
</div>
|
<span class="text-danger"><i class="bi bi-exclamation-triangle me-1"></i>{{strftime "%Y-%m-%d %H:%M" .statusHTTP.LastAttempt}} — {{.statusHTTP.LastError}}</span>
|
||||||
<div class="mb-3">
|
{{else}}
|
||||||
<label class="form-label">Domains</label>
|
<span class="text-success"><i class="bi bi-check-circle me-1"></i>{{strftime "%Y-%m-%d %H:%M" .statusHTTP.LastAttempt}} — success</span>
|
||||||
<input type="text" class="form-control font-monospace" name="domains" value="{{.le.domains}}" placeholder="mail.example.com,*.mail.example.com">
|
{{end}}
|
||||||
<div class="form-text">Comma-separated. Include a wildcard entry (e.g. <code>*.mail.example.com</code>) alongside its bare domain to cover both with one certificate.</div>
|
</dd>
|
||||||
</div>
|
</dl>
|
||||||
<div class="mb-3">
|
<form method="post" action="/pymta-manager/letsencrypt/http/obtain">
|
||||||
<label class="form-label">DNS Provider</label>
|
<button type="submit" class="btn btn-primary" data-confirm="Obtain or renew the HTTP-01 certificate now using the saved configuration?"><i class="bi bi-arrow-repeat me-1"></i>Obtain / Renew Now</button>
|
||||||
<select class="form-select" name="dns_provider" id="le_provider">
|
</form>
|
||||||
<option value="">Select a provider...</option>
|
|
||||||
<option value="cloudflare" {{if eq .le.dns_provider "cloudflare"}}selected{{end}}>Cloudflare</option>
|
|
||||||
<option value="route53" {{if eq .le.dns_provider "route53"}}selected{{end}}>AWS Route53</option>
|
|
||||||
<option value="digitalocean" {{if eq .le.dns_provider "digitalocean"}}selected{{end}}>DigitalOcean</option>
|
|
||||||
<option value="gcloud" {{if eq .le.dns_provider "gcloud"}}selected{{end}}>Google Cloud DNS</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="provider-fields" id="fields-cloudflare">
|
<hr>
|
||||||
<div class="setting-section mb-3">
|
<form method="POST" action="/pymta-manager/letsencrypt/http/save">
|
||||||
<h6>Cloudflare</h6>
|
<div class="mb-3">
|
||||||
<div class="mb-3">
|
<label class="form-label">Enable HTTP-01</label>
|
||||||
<label class="form-label">API Token</label>
|
<select class="form-select" name="enabled">
|
||||||
<input type="password" class="form-control" name="cloudflare_api_token" placeholder="Leave blank to keep the current value">
|
<option value="false" {{if ne .leHTTP.enabled "true"}}selected{{end}}>No</option>
|
||||||
|
<option value="true" {{if eq .leHTTP.enabled "true"}}selected{{end}}>Yes</option>
|
||||||
|
</select>
|
||||||
|
<div class="form-text">No DNS provider needed — Let's Encrypt verifies ownership by requesting a token over plain HTTP on this port. Once enabled and the server is restarted, this port stays bound for the life of the process (not just during an obtain) — so you can confirm your router/proxy port-forwarding actually reaches this host by browsing to it directly and expecting a plain "ok" response.</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Staging mode</label>
|
||||||
|
<select class="form-select" name="staging">
|
||||||
|
<option value="false" {{if ne .leHTTP.staging "true"}}selected{{end}}>No — request a real, trusted certificate</option>
|
||||||
|
<option value="true" {{if eq .leHTTP.staging "true"}}selected{{end}}>Yes — untrusted test certificate, no rate limits</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Contact Email</label>
|
||||||
|
<input type="email" class="form-control" name="contact_email" value="{{.leHTTP.contact_email}}">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Domains</label>
|
||||||
|
<input type="text" class="form-control font-monospace" name="domains" value="{{.leHTTP.domains}}" placeholder="mail.example.com">
|
||||||
|
<div class="form-text">Comma-separated.</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Also get this certificate for the server's IP address</label>
|
||||||
|
<select class="form-select" name="include_ip">
|
||||||
|
<option value="false" {{if ne .leHTTP.include_ip "true"}}selected{{end}}>No — domain only</option>
|
||||||
|
<option value="true" {{if eq .leHTTP.include_ip "true"}}selected{{end}}>Yes</option>
|
||||||
|
</select>
|
||||||
|
<div class="form-text">Adds the IP as a second identifier on the same certificate, so clients connecting by bare IP (no hostname) get a trusted cert too. This automatically switches to Let's Encrypt's "shortlived" certificate profile, the only one that currently allows IP identifiers — those certificates are valid for only about 6 days, so expect much more frequent renewals than the domain-only case (handled automatically by the existing renewal check).</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">IP address override</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" class="form-control font-monospace" name="ip_override" id="le_ip_override" value="{{.leHTTP.ip_override}}" placeholder="Leave blank to autodetect this host's WAN IP on every obtain/renew">
|
||||||
|
<button class="btn btn-outline-secondary" type="button" id="le_detect_ip_btn"><i class="bi bi-broadcast me-1"></i>Detect</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="provider-fields" id="fields-route53">
|
<button type="submit" class="btn btn-success"><i class="bi bi-check-lg me-1"></i>Save HTTP-01 Configuration</button>
|
||||||
<div class="setting-section mb-3">
|
</form>
|
||||||
<h6>AWS Route53</h6>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Access Key ID</label>
|
|
||||||
<input type="password" class="form-control" name="route53_access_key_id" placeholder="Leave blank to keep the current value, or blank both keys to use the host's AWS credential chain">
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Secret Access Key</label>
|
|
||||||
<input type="password" class="form-control" name="route53_secret_access_key" placeholder="Leave blank to keep the current value">
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Region</label>
|
|
||||||
<input type="text" class="form-control" name="route53_region" value="{{.le.route53_region}}" placeholder="us-east-1">
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Hosted Zone ID (optional)</label>
|
|
||||||
<input type="text" class="form-control" name="route53_hosted_zone_id" value="{{.le.route53_hosted_zone_id}}" placeholder="Leave blank to auto-discover">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="provider-fields" id="fields-digitalocean">
|
|
||||||
<div class="setting-section mb-3">
|
|
||||||
<h6>DigitalOcean</h6>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">API Token</label>
|
|
||||||
<input type="password" class="form-control" name="digitalocean_api_token" placeholder="Leave blank to keep the current value">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="provider-fields" id="fields-gcloud">
|
|
||||||
<div class="setting-section mb-3">
|
|
||||||
<h6>Google Cloud DNS</h6>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Project ID</label>
|
|
||||||
<input type="text" class="form-control" name="gcloud_project" value="{{.le.gcloud_project}}">
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Service Account Key (optional)</label>
|
|
||||||
<div class="input-group">
|
|
||||||
<input type="text" class="form-control font-monospace" name="gcloud_service_account_json_path" id="gcloud_sa_path" placeholder="Leave blank to use Application Default Credentials">
|
|
||||||
<input type="file" class="d-none" id="gcloudKeyUpload" accept=".json">
|
|
||||||
<button class="btn btn-outline-secondary" type="button" onclick="document.getElementById('gcloudKeyUpload').click()"><i class="bi bi-upload"></i></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="submit" class="btn btn-success"><i class="bi bi-check-lg me-1"></i>Save Configuration</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "extra_js"}}
|
{{define "extra_js"}}
|
||||||
@@ -157,6 +239,15 @@
|
|||||||
document.getElementById('le_provider').addEventListener('change', updateProviderFields);
|
document.getElementById('le_provider').addEventListener('change', updateProviderFields);
|
||||||
updateProviderFields();
|
updateProviderFields();
|
||||||
|
|
||||||
|
document.getElementById('le_detect_ip_btn').addEventListener('click', function() {
|
||||||
|
fetch('/pymta-manager/api/letsencrypt/detect_ip')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.status === 'success') { document.getElementById('le_ip_override').value = data.ip; showToast('Detected WAN IP: ' + data.ip, 'success'); }
|
||||||
|
else { showToast(data.message || 'Failed to detect IP', 'danger'); }
|
||||||
|
}).catch(() => showToast('Failed to detect IP', 'danger'));
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById('gcloudKeyUpload').addEventListener('change', function(e) {
|
document.getElementById('gcloudKeyUpload').addEventListener('change', function(e) {
|
||||||
const file = e.target.files[0];
|
const file = e.target.files[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|||||||
@@ -62,7 +62,7 @@
|
|||||||
<div class="col-md-6"><strong>Message ID:</strong> <code>{{$log.MessageID}}</code></div>
|
<div class="col-md-6"><strong>Message ID:</strong> <code>{{$log.MessageID}}</code></div>
|
||||||
</div>
|
</div>
|
||||||
{{if $log.Subject}}<div class="mt-2"><strong>Subject:</strong> {{$log.Subject}}</div>{{end}}
|
{{if $log.Subject}}<div class="mt-2"><strong>Subject:</strong> {{$log.Subject}}</div>{{end}}
|
||||||
<div class="mt-2"><a href="/pymta-manager/msg/content/{{$log.ID}}" class="btn btn-sm btn-primary"><i class="bi bi-envelope-open-text"></i> View Message Details</a></div>
|
<div class="mt-2"><button type="button" class="btn btn-sm btn-primary" onclick="openMessageModal({{$log.ID}})"><i class="bi bi-envelope-open-text"></i> View Message Details</button></div>
|
||||||
</div>
|
</div>
|
||||||
{{else}}
|
{{else}}
|
||||||
{{$log := .data}}
|
{{$log := .data}}
|
||||||
@@ -119,7 +119,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if .Subject}}<div class="mt-2"><strong>Subject:</strong> {{.Subject}}</div>{{end}}
|
{{if .Subject}}<div class="mt-2"><strong>Subject:</strong> {{.Subject}}</div>{{end}}
|
||||||
<div class="mt-2"><a href="/pymta-manager/msg/content/{{.ID}}" class="btn btn-outline-info btn-sm"><i class="bi bi-file-earmark-text me-1"></i> View Full Message</a></div>
|
<div class="mt-2"><button type="button" class="btn btn-outline-info btn-sm" onclick="openMessageModal({{.ID}})"><i class="bi bi-file-earmark-text me-1"></i> View Full Message</button></div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{else}}
|
{{else}}
|
||||||
@@ -159,10 +159,35 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="modal fade" id="messageContentModal" tabindex="-1" aria-labelledby="messageContentModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-fullscreen">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="messageContentModalLabel"><i class="bi bi-envelope-open-text me-2"></i>Full Message</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" id="messageContentModalBody">
|
||||||
|
<div class="text-center text-muted py-5"><div class="spinner-border" role="status"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "extra_js"}}
|
{{define "extra_js"}}
|
||||||
<script>
|
<script>
|
||||||
setInterval(function() { if (document.visibilityState === 'visible') { location.reload(); } }, 30000);
|
setInterval(function() { if (document.visibilityState === 'visible') { location.reload(); } }, 30000);
|
||||||
|
|
||||||
|
function openMessageModal(id) {
|
||||||
|
const modalEl = document.getElementById('messageContentModal');
|
||||||
|
const body = document.getElementById('messageContentModalBody');
|
||||||
|
body.innerHTML = '<div class="text-center text-muted py-5"><div class="spinner-border" role="status"></div></div>';
|
||||||
|
bootstrap.Modal.getOrCreateInstance(modalEl).show();
|
||||||
|
fetch('/pymta-manager/msg/content/' + id)
|
||||||
|
.then(function(r) { if (!r.ok) throw new Error(r.status); return r.text(); })
|
||||||
|
.then(function(html) { body.innerHTML = html; })
|
||||||
|
.catch(function() { body.innerHTML = '<p class="text-danger">Failed to load the message.</p>'; });
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -188,14 +188,14 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="setting-section">
|
<div class="setting-section">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-6"><div class="mb-3"><label class="form-label">TLS Certificate File</label>
|
<div class="col-md-6"><div class="mb-3"><label class="form-label">Custom Certificate File</label>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<input type="text" class="form-control font-monospace" name="TLS.tls_cert_file" value="{{.settings.TLS.tls_cert_file}}">
|
<input type="text" class="form-control font-monospace" name="TLS.tls_cert_file" value="{{.settings.TLS.tls_cert_file}}">
|
||||||
<input type="file" class="d-none" id="certFileUpload" accept=".crt,.pem">
|
<input type="file" class="d-none" id="certFileUpload" accept=".crt,.pem">
|
||||||
<button class="btn btn-outline-secondary" type="button" onclick="document.getElementById('certFileUpload').click()"><i class="bi bi-upload"></i></button>
|
<button class="btn btn-outline-secondary" type="button" onclick="document.getElementById('certFileUpload').click()"><i class="bi bi-upload"></i></button>
|
||||||
</div>
|
</div>
|
||||||
</div></div>
|
</div></div>
|
||||||
<div class="col-md-6"><div class="mb-3"><label class="form-label">TLS Private Key File</label>
|
<div class="col-md-6"><div class="mb-3"><label class="form-label">Custom Private Key File</label>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<input type="text" class="form-control font-monospace" name="TLS.tls_key_file" value="{{.settings.TLS.tls_key_file}}">
|
<input type="text" class="form-control font-monospace" name="TLS.tls_key_file" value="{{.settings.TLS.tls_key_file}}">
|
||||||
<input type="file" class="d-none" id="keyFileUpload" accept=".key,.pem">
|
<input type="file" class="d-none" id="keyFileUpload" accept=".key,.pem">
|
||||||
@@ -203,6 +203,35 @@
|
|||||||
</div>
|
</div>
|
||||||
</div></div>
|
</div></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-text mb-3">The "custom" certificate: self-signed on first run, or your own uploaded cert/key above.</div>
|
||||||
|
<hr>
|
||||||
|
<p class="mb-2">Which certificate each TLS listener uses — <code>custom</code> (above), or one of the two Let's
|
||||||
|
Encrypt certificates managed on the <a href="/pymta-manager/letsencrypt">Let's Encrypt</a> page. Independent
|
||||||
|
per listener, e.g. an HTTP-01 cert for mail while the dashboard keeps a DNS-01 or custom cert.</p>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-4"><div class="mb-3"><label class="form-label">SMTP (direct-TLS, 465)</label>
|
||||||
|
<select class="form-select" name="TLS.smtp_tls_cert">
|
||||||
|
<option value="custom" {{if eq .settings.TLS.smtp_tls_cert "custom"}}selected{{end}}>Custom / self-signed</option>
|
||||||
|
<option value="letsencrypt_dns" {{if eq .settings.TLS.smtp_tls_cert "letsencrypt_dns"}}selected{{end}}>Let's Encrypt (DNS-01)</option>
|
||||||
|
<option value="letsencrypt_http" {{if eq .settings.TLS.smtp_tls_cert "letsencrypt_http"}}selected{{end}}>Let's Encrypt (HTTP-01)</option>
|
||||||
|
</select>
|
||||||
|
</div></div>
|
||||||
|
<div class="col-md-4"><div class="mb-3"><label class="form-label">IMAP (direct-TLS, 993)</label>
|
||||||
|
<select class="form-select" name="TLS.imap_tls_cert">
|
||||||
|
<option value="custom" {{if eq .settings.TLS.imap_tls_cert "custom"}}selected{{end}}>Custom / self-signed</option>
|
||||||
|
<option value="letsencrypt_dns" {{if eq .settings.TLS.imap_tls_cert "letsencrypt_dns"}}selected{{end}}>Let's Encrypt (DNS-01)</option>
|
||||||
|
<option value="letsencrypt_http" {{if eq .settings.TLS.imap_tls_cert "letsencrypt_http"}}selected{{end}}>Let's Encrypt (HTTP-01)</option>
|
||||||
|
</select>
|
||||||
|
</div></div>
|
||||||
|
<div class="col-md-4"><div class="mb-3"><label class="form-label">Admin/webmail HTTPS</label>
|
||||||
|
<select class="form-select" name="TLS.web_https_cert">
|
||||||
|
<option value="custom" {{if eq .settings.TLS.web_https_cert "custom"}}selected{{end}}>Custom / self-signed</option>
|
||||||
|
<option value="letsencrypt_dns" {{if eq .settings.TLS.web_https_cert "letsencrypt_dns"}}selected{{end}}>Let's Encrypt (DNS-01)</option>
|
||||||
|
<option value="letsencrypt_http" {{if eq .settings.TLS.web_https_cert "letsencrypt_http"}}selected{{end}}>Let's Encrypt (HTTP-01)</option>
|
||||||
|
</select>
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-text">Changing which certificate a listener uses needs a restart to take effect. Once assigned, that listener's certificate then hot-reloads automatically on every future obtain/renew, no restart needed for that part.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,49 +1,72 @@
|
|||||||
{{define "title"}}View Full Message - Email Log{{end}}
|
{{define "view_message_content.html"}}
|
||||||
|
<div class="mb-3">
|
||||||
|
<strong>From:</strong> {{.log.mail_from}}<br>
|
||||||
|
<strong>To:</strong> {{.log.to_address}}<br>
|
||||||
|
<strong>CC:</strong> {{if .log.cc_addresses}}{{.log.cc_addresses}}{{else}}None{{end}}<br>
|
||||||
|
<strong>BCC:</strong> {{if .log.bcc_addresses}}{{.log.bcc_addresses}}{{else}}None{{end}}<br>
|
||||||
|
<strong>Subject:</strong> {{if .log.subject}}{{.log.subject}}{{else}}N/A{{end}}<br>
|
||||||
|
<strong>Date:</strong> {{strftime "%Y-%m-%d %H:%M:%S" .log.created_at}}<br>
|
||||||
|
</div>
|
||||||
|
|
||||||
{{define "content"}}
|
{{if .log.attachments}}
|
||||||
<div class="container mt-4">
|
<div class="card mb-3">
|
||||||
<h2>Full Message Content</h2>
|
<div class="card-header"><strong>Attachments:</strong></div>
|
||||||
<div class="mb-3">
|
<div class="card-body">
|
||||||
<strong>From:</strong> {{.log.mail_from}}<br>
|
{{range .log.attachments}}
|
||||||
<strong>To:</strong> {{.log.to_address}}<br>
|
<div class="mb-3">
|
||||||
<strong>CC:</strong> {{if .log.cc_addresses}}{{.log.cc_addresses}}{{else}}None{{end}}<br>
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
<strong>BCC:</strong> {{if .log.bcc_addresses}}{{.log.bcc_addresses}}{{else}}None{{end}}<br>
|
<div><i class="bi bi-paperclip me-1"></i>{{.Filename}} <small class="text-muted">({{.ContentType}}, {{filesize .Size}})</small></div>
|
||||||
<strong>Subject:</strong> {{if .log.subject}}{{.log.subject}}{{else}}N/A{{end}}<br>
|
<a href="{{.DataURI}}" target="_blank" rel="noopener" download="{{.Filename}}" class="btn btn-sm btn-outline-secondary"><i class="bi bi-download me-1"></i>Download</a>
|
||||||
<strong>Date:</strong> {{strftime "%Y-%m-%d %H:%M:%S" .log.created_at}}<br>
|
</div>
|
||||||
</div>
|
{{if .IsImage}}<img src="{{.DataURI}}" alt="{{.Filename}}" class="img-fluid mt-2 border rounded" style="max-height: 400px;">{{end}}
|
||||||
|
|
||||||
{{if .log.attachments}}
|
|
||||||
<div class="card mb-3">
|
|
||||||
<div class="card-header"><strong>Attachments:</strong></div>
|
|
||||||
<div class="card-body">
|
|
||||||
<ul class="list-group">
|
|
||||||
{{range .log.attachments}}
|
|
||||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
|
||||||
<div><i class="fas fa-paperclip"></i> {{.Filename}} <small class="text-muted">({{filesize .Size}})</small></div>
|
|
||||||
<div class="btn-group" role="group">
|
|
||||||
<a href="/pymta-manager/msg/attachment/{{.ID}}/download" class="btn btn-sm btn-outline-primary" target="_blank" title="Open in new tab"><i class="fas fa-external-link-alt"></i> View</a>
|
|
||||||
<a href="/pymta-manager/msg/attachment/{{.ID}}/download?download=true" class="btn btn-sm btn-outline-secondary" title="Download file"><i class="fas fa-download"></i> Download</a>
|
|
||||||
<form method="POST" action="/pymta-manager/msg/attachment/{{.ID}}/delete" style="display: inline;">
|
|
||||||
<button type="submit" class="btn btn-sm btn-outline-danger" title="Delete attachment" data-confirm="Are you sure you want to delete this attachment?"><i class="fas fa-trash-alt"></i> Delete</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
{{end}}
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
<div class="card">
|
{{if .log.legacy_attachments}}
|
||||||
<div class="card-header"><strong>Message Content:</strong></div>
|
<div class="card mb-3">
|
||||||
<div class="card-body"><pre style="white-space: pre-wrap; word-break: break-all;">{{.log.message_body}}</pre></div>
|
<div class="card-header"><strong>Saved attachment files</strong> <small class="text-muted">(from this sender/IP's "Store Full Message Content" setting)</small></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<ul class="list-group">
|
||||||
|
{{range .log.legacy_attachments}}
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
<div><i class="bi bi-paperclip me-1"></i>{{.Filename}} <small class="text-muted">({{filesize .Size}})</small></div>
|
||||||
|
<div class="btn-group" role="group">
|
||||||
|
<a href="/pymta-manager/msg/attachment/{{.ID}}/download" class="btn btn-sm btn-outline-primary" target="_blank" rel="noopener" title="Open in new tab"><i class="bi bi-box-arrow-up-right"></i> View</a>
|
||||||
|
<a href="/pymta-manager/msg/attachment/{{.ID}}/download?download=true" class="btn btn-sm btn-outline-secondary" title="Download file"><i class="bi bi-download"></i> Download</a>
|
||||||
|
<form method="POST" action="/pymta-manager/msg/attachment/{{.ID}}/delete" style="display: inline;" onsubmit="return confirm('Are you sure you want to delete this attachment?');">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{$.csrf_token}}">
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-danger" title="Delete attachment"><i class="bi bi-trash"></i> Delete</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
<div class="card mt-3">
|
<div class="card">
|
||||||
<div class="card-header"><strong>Message Headers:</strong></div>
|
<div class="card-header"><strong>Message Content:</strong></div>
|
||||||
<div class="card-body"><pre style="white-space: pre-wrap;">{{.log.email_headers}}</pre></div>
|
<div class="card-body">
|
||||||
|
{{if .log.has_content}}
|
||||||
|
{{if .log.html_body}}
|
||||||
|
<div class="p-2 border rounded bg-white text-dark">{{.log.html_body}}</div>
|
||||||
|
{{else if .log.plain_body}}
|
||||||
|
<pre style="white-space: pre-wrap; word-break: break-all;">{{.log.plain_body}}</pre>
|
||||||
|
{{else}}
|
||||||
|
<p class="text-muted mb-0">Message stored, but no readable body could be parsed out of it.</p>
|
||||||
|
{{end}}
|
||||||
|
{{else}}
|
||||||
|
<p class="text-muted mb-0"><i class="bi bi-shield-lock me-1"></i>Not stored, by design — the message content is only kept in this log when the sender/IP has "Store Full Message Content" enabled, or the message was quarantined as spam. Headers below are always kept.</p>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<a href="/pymta-manager/logs?type=emails" class="btn btn-secondary mt-3">Back to Logs</a>
|
<div class="card mt-3">
|
||||||
|
<div class="card-header"><strong>Message Headers:</strong></div>
|
||||||
|
<div class="card-body"><pre style="white-space: pre-wrap;">{{.log.email_headers}}</pre></div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -57,6 +57,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card mt-4">
|
||||||
|
<div class="card-header"><h5 class="mb-0"><i class="bi bi-sliders me-2"></i>Preferences</h5></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST" action="/webmail/account/preferences">
|
||||||
|
<label class="form-label">Group similar subjects in the message list</label>
|
||||||
|
<select class="form-select" name="group_messages">
|
||||||
|
<option value="false" {{if not .mailbox.GroupMessages}}selected{{end}}>No — show every message separately</option>
|
||||||
|
<option value="true" {{if .mailbox.GroupMessages}}selected{{end}}>Yes — collapse a run of same-subject messages into one expandable row</option>
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm mt-2"><i class="bi bi-check-lg me-1"></i>Save</button>
|
||||||
|
</form>
|
||||||
|
<hr>
|
||||||
|
<form method="POST" action="/webmail/account/rebuild-cache">
|
||||||
|
<label class="form-label">Refresh sender names & previews for existing mail</label>
|
||||||
|
<div class="form-text mb-2">Mail already in your folders keeps whatever sender name/preview it was stored with — this only changes going forward automatically. Use this once to bring older messages up to date.</div>
|
||||||
|
<button type="submit" class="btn btn-outline-secondary btn-sm"><i class="bi bi-arrow-repeat me-1"></i>Refresh now</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card mt-4">
|
<div class="card mt-4">
|
||||||
<div class="card-header"><h5 class="mb-0"><i class="bi bi-key-fill me-2"></i>Change Password</h5></div>
|
<div class="card-header"><h5 class="mb-0"><i class="bi bi-key-fill me-2"></i>Change Password</h5></div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
|||||||
@@ -8,34 +8,71 @@
|
|||||||
<link href="/webmail/static/vendor/bootstrap/css/bootstrap.min.css" rel="stylesheet">
|
<link href="/webmail/static/vendor/bootstrap/css/bootstrap.min.css" rel="stylesheet">
|
||||||
<link href="/webmail/static/vendor/bootstrap-icons/font/bootstrap-icons.css" rel="stylesheet">
|
<link href="/webmail/static/vendor/bootstrap-icons/font/bootstrap-icons.css" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
body { background-color: #1a1a1a; color: #e0e0e0; }
|
html, body { background-color: #1a1a1a; color: #e0e0e0; height: 100%; }
|
||||||
.card { background-color: #2d2d2d; border: 1px solid #404040; }
|
.card { background-color: #2d2d2d; border: 1px solid #404040; }
|
||||||
.table-dark { --bs-table-bg: #2d2d2d; --bs-table-border-color: #404040; }
|
|
||||||
.folder-link.active { background-color: #0d6efd; color: #fff !important; }
|
.folder-link.active { background-color: #0d6efd; color: #fff !important; }
|
||||||
.msg-unread { font-weight: 600; }
|
|
||||||
.msg-row { cursor: grab; }
|
.msg-row { cursor: grab; }
|
||||||
.msg-row.dragging { opacity: 0.4; }
|
.msg-row.dragging { opacity: 0.4; }
|
||||||
.folder-link.drop-hover { background-color: #0d6efd; color: #fff !important; outline: 2px dashed #6ea8fe; outline-offset: -2px; }
|
.folder-link.drop-hover { background-color: #0d6efd; color: #fff !important; outline: 2px dashed #6ea8fe; outline-offset: -2px; }
|
||||||
.folder-unread-badge { font-size: .7rem; }
|
.folder-unread-badge { font-size: .7rem; }
|
||||||
.msg-row-older { display: none; }
|
.msg-row-older { display: none; }
|
||||||
.msg-group-toggle { cursor: pointer; }
|
.msg-group-toggle { cursor: pointer; }
|
||||||
|
/* A search result can span multiple folders, and the bulk-action endpoint is
|
||||||
|
scoped to one folder path — rather than a bulk action silently no-oping on
|
||||||
|
every row from a different folder, selection/bulk actions are just not
|
||||||
|
offered while searching (per-row actions in the reading pane still work). */
|
||||||
|
.search-mode .msg-check, .search-mode #selectAllCheck, .search-mode .bulk-btn { display: none; }
|
||||||
|
|
||||||
|
/* Outlook-style three-pane shell: fixed-width sidebar + fixed-width list +
|
||||||
|
flexible reading pane, instead of a responsive 12-column grid — this is
|
||||||
|
deliberately a fixed desktop layout to match the reference, not a
|
||||||
|
mobile-first one. */
|
||||||
|
.mail-shell { display: flex; align-items: stretch; height: calc(100vh - 56px); overflow: hidden; }
|
||||||
|
.mail-sidebar { width: 230px; flex: 0 0 auto; overflow-y: auto; border-right: 1px solid #404040; padding: .75rem; }
|
||||||
|
.mail-list-pane { width: 380px; flex: 0 0 auto; overflow-y: auto; border-right: 1px solid #404040; display: flex; flex-direction: column; }
|
||||||
|
.mail-reading-pane { flex: 1 1 auto; overflow-y: auto; padding: 1.5rem; min-width: 0; }
|
||||||
|
.mail-toolbar { flex: 0 0 auto; padding: .5rem .75rem; border-bottom: 1px solid #404040; display: flex; align-items: center; gap: .35rem; flex-wrap: wrap; }
|
||||||
|
.mail-list-scroll { flex: 1 1 auto; overflow-y: auto; }
|
||||||
|
.mail-list-header { padding: .35rem .75rem; font-size: .75rem; text-transform: uppercase; color: #8a8a8a; display: flex; justify-content: space-between; }
|
||||||
|
|
||||||
|
.msg-item { display: flex; align-items: flex-start; gap: .6rem; padding: .55rem .75rem; border-bottom: 1px solid #333; cursor: pointer; }
|
||||||
|
.msg-item:hover { background-color: #262626; }
|
||||||
|
.msg-item.active { background-color: #0d3860; }
|
||||||
|
.msg-item.unread .msg-subject { font-weight: 700; color: #fff; }
|
||||||
|
.msg-item.unread .msg-from { font-weight: 700; color: #fff; }
|
||||||
|
.msg-check { margin-top: .35rem; flex: 0 0 auto; }
|
||||||
|
.msg-avatar { width: 34px; height: 34px; border-radius: 50%; background: #495057; color: #fff; display: flex; align-items: center; justify-content: center; font-size: .85rem; font-weight: 600; flex: 0 0 auto; }
|
||||||
|
.msg-item.unread .msg-avatar { background: #0d6efd; }
|
||||||
|
.msg-main { min-width: 0; flex: 1 1 auto; }
|
||||||
|
.msg-row1 { display: flex; justify-content: space-between; gap: .5rem; }
|
||||||
|
.msg-from { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.msg-date { flex: 0 0 auto; font-size: .75rem; color: #8a8a8a; }
|
||||||
|
.msg-row2 { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: .85rem; color: #adb5bd; }
|
||||||
|
.msg-subject { color: #e0e0e0; }
|
||||||
|
|
||||||
|
.msg-body-html { background-color: #fff; color: #000; border-radius: 6px; padding: 1rem; overflow-x: auto; }
|
||||||
|
.msg-body-text { white-space: pre-wrap; word-break: break-word; }
|
||||||
|
#readingPaneBody .pane-toolbar { border-bottom: 1px solid #404040; padding-bottom: .75rem; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{{template "csrf_script" .}}
|
{{template "csrf_script" .}}
|
||||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-4">
|
<nav class="navbar navbar-expand-lg navbar-dark bg-dark px-3" style="height: 56px;">
|
||||||
<div class="container-fluid">
|
<span class="navbar-brand mb-0 h1"><i class="bi bi-envelope-fill me-2"></i>Webmail <small class="text-muted">{{.mailbox.Email}}</small></span>
|
||||||
<span class="navbar-brand mb-0 h1"><i class="bi bi-inbox-fill me-2"></i>Webmail <small class="text-muted">{{.mailbox.Email}}</small></span>
|
<form method="get" action="/webmail/mail/search" class="mx-auto" style="width: 360px;">
|
||||||
<div class="navbar-nav flex-row gap-2 ms-auto">
|
<div class="input-group input-group-sm">
|
||||||
<a href="/webmail/mail/INBOX" class="btn btn-outline-light btn-sm"><i class="bi bi-envelope me-1"></i>Mail</a>
|
<span class="input-group-text bg-body-secondary border-0"><i class="bi bi-search"></i></span>
|
||||||
<button type="button" onclick="openCompose('/webmail/mail/compose')" class="btn btn-outline-light btn-sm"><i class="bi bi-pencil-square me-1"></i>Compose</button>
|
<input type="search" name="q" id="mailSearchInput" class="form-control" placeholder="Search all mail" value="{{.search_query}}">
|
||||||
<a href="/webmail/rules" class="btn btn-outline-light btn-sm"><i class="bi bi-funnel me-1"></i>Rules</a>
|
|
||||||
<a href="/webmail/certs" class="btn btn-outline-light btn-sm"><i class="bi bi-shield-lock me-1"></i>Certs</a>
|
|
||||||
<a href="/webmail/account" class="btn btn-outline-light btn-sm"><i class="bi bi-gear me-1"></i>Account</a>
|
|
||||||
<form method="post" action="/webmail/logout" class="d-inline">
|
|
||||||
<button type="submit" class="btn btn-outline-light btn-sm"><i class="bi bi-box-arrow-right me-1"></i>Sign out</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
|
</form>
|
||||||
|
<div class="navbar-nav flex-row gap-2 ms-auto">
|
||||||
|
<button type="button" onclick="openCompose('/webmail/mail/compose')" class="btn btn-primary btn-sm"><i class="bi bi-pencil-square me-1"></i>Compose</button>
|
||||||
|
<a href="/webmail/rules" class="btn btn-outline-light btn-sm" title="Rules"><i class="bi bi-funnel"></i></a>
|
||||||
|
<a href="/webmail/certs" class="btn btn-outline-light btn-sm" title="Certs"><i class="bi bi-shield-lock"></i></a>
|
||||||
|
<a href="/webmail/account" class="btn btn-outline-light btn-sm" title="Account"><i class="bi bi-gear"></i></a>
|
||||||
|
<form method="post" action="/webmail/logout" class="d-inline">
|
||||||
|
<button type="submit" class="btn btn-outline-light btn-sm" title="Sign out"><i class="bi bi-box-arrow-right"></i></button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@@ -53,117 +90,123 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="container-fluid pb-5">
|
<div class="mail-shell">
|
||||||
<div class="row">
|
<div class="mail-sidebar" id="folderSidebarCol">
|
||||||
<div class="col-lg-2 mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
<div class="card">
|
<span class="text-muted small text-uppercase">Folders</span>
|
||||||
<div class="card-body p-2">
|
<button type="button" class="btn btn-sm btn-outline-secondary border-0 py-0" id="sidebarCollapseBtn" title="Hide folder list"><i class="bi bi-chevron-bar-left"></i></button>
|
||||||
<form method="get" action="/webmail/mail/search" class="mb-2">
|
</div>
|
||||||
<div class="input-group input-group-sm">
|
<div class="list-group list-group-flush">
|
||||||
<input type="search" name="q" id="mailSearchInput" class="form-control" placeholder="Search all mail" value="{{.search_query}}">
|
{{$active := .active_folder}}
|
||||||
<button type="submit" class="btn btn-outline-light"><i class="bi bi-search"></i></button>
|
{{$unread := .unread_counts}}
|
||||||
</div>
|
{{$counts := .folder_counts}}
|
||||||
</form>
|
{{range .folders}}
|
||||||
<div class="list-group list-group-flush">
|
<div class="d-flex align-items-center folder-row">
|
||||||
{{$active := .active_folder}}
|
<a href="/webmail/mail/{{.}}" data-folder="{{.}}" class="list-group-item list-group-item-action bg-transparent text-white folder-link flex-grow-1 d-flex justify-content-between align-items-center {{if eq . $active}}active{{end}}">
|
||||||
{{$unread := .unread_counts}}
|
<span><i class="bi bi-folder2 me-1"></i>{{.}}</span>
|
||||||
{{range .folders}}
|
{{$n := index $unread .}}
|
||||||
<div class="d-flex align-items-center folder-row">
|
{{$total := index $counts .}}
|
||||||
<a href="/webmail/mail/{{.}}" data-folder="{{.}}" class="list-group-item list-group-item-action bg-transparent text-white folder-link flex-grow-1 d-flex justify-content-between align-items-center {{if eq . $active}}active{{end}}">
|
{{if $total}}
|
||||||
<span><i class="bi bi-folder2 me-1"></i>{{.}}</span>
|
<span class="badge {{if $n}}bg-primary{{else}}bg-secondary{{end}} rounded-pill folder-unread-badge" title="{{$total}} total{{if $n}}, {{$n}} unread{{end}}">{{$total}}{{if $n}} / <strong>{{$n}}</strong>{{end}}</span>
|
||||||
{{$n := index $unread .}}
|
{{end}}
|
||||||
{{if $n}}<span class="badge bg-primary rounded-pill folder-unread-badge">{{$n}}</span>{{end}}
|
</a>
|
||||||
</a>
|
{{if not (isStandardFolder .)}}
|
||||||
{{if not (isStandardFolder .)}}
|
<form method="post" action="/webmail/mail/folders/{{.}}/remove" class="d-inline">
|
||||||
<form method="post" action="/webmail/mail/folders/{{.}}/remove" class="d-inline">
|
<button type="submit" class="btn btn-sm btn-outline-danger border-0" title="Remove folder" data-confirm="Remove folder "{{.}}"? Any mail in it moves to INBOX."><i class="bi bi-x-lg"></i></button>
|
||||||
<button type="submit" class="btn btn-sm btn-outline-danger border-0" title="Remove folder" data-confirm="Remove folder "{{.}}"? Any mail in it moves to INBOX."><i class="bi bi-x-lg"></i></button>
|
</form>
|
||||||
</form>
|
{{end}}
|
||||||
{{end}}
|
</div>
|
||||||
</div>
|
{{end}}
|
||||||
{{end}}
|
</div>
|
||||||
</div>
|
<hr class="my-2">
|
||||||
<hr class="my-2">
|
<form method="post" action="/webmail/mail/folders/add" class="d-flex gap-1">
|
||||||
<form method="post" action="/webmail/mail/folders/add" class="d-flex gap-1">
|
<input type="text" class="form-control form-control-sm" name="name" placeholder="New folder" maxlength="60" required>
|
||||||
<input type="text" class="form-control form-control-sm" name="name" placeholder="New folder" maxlength="60" required>
|
<button type="submit" class="btn btn-sm btn-outline-primary" title="Create folder"><i class="bi bi-plus-lg"></i></button>
|
||||||
<button type="submit" class="btn btn-sm btn-outline-primary" title="Create folder"><i class="bi bi-plus-lg"></i></button>
|
</form>
|
||||||
</form>
|
</div>
|
||||||
</div>
|
|
||||||
|
<div class="mail-list-pane{{if .search_query}} search-mode{{end}}" id="messageListCol">
|
||||||
|
<div class="mail-toolbar">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary border-0 py-0 d-none" id="sidebarShowBtn" title="Show folder list"><i class="bi bi-chevron-bar-right"></i></button>
|
||||||
|
<input type="checkbox" class="form-check-input" id="selectAllCheck" title="Select all">
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
|
<button type="button" class="btn btn-outline-secondary bulk-btn" data-action="delete" disabled title="Delete"><i class="bi bi-trash"></i></button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary bulk-btn" data-action="read" disabled title="Mark as read"><i class="bi bi-envelope-open"></i></button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary bulk-btn" data-action="unread" disabled title="Mark as unread"><i class="bi bi-envelope"></i></button>
|
||||||
|
</div>
|
||||||
|
{{if not .search_query}}
|
||||||
|
<select class="form-select form-select-sm bulk-move-select" id="bulkMoveSelect" disabled style="width: auto;" title="Move selected to…">
|
||||||
|
<option value="">Move to…</option>
|
||||||
|
{{$folder := .active_folder}}
|
||||||
|
{{range .folders}}{{if ne . $folder}}<option value="{{.}}">{{.}}</option>{{end}}{{end}}
|
||||||
|
</select>
|
||||||
|
{{end}}
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary border-0" onclick="location.reload()" title="Refresh"><i class="bi bi-arrow-clockwise"></i></button>
|
||||||
|
<div class="ms-auto d-flex align-items-center gap-1">
|
||||||
|
{{if not .search_query}}
|
||||||
|
<a href="{{.sort_from_href}}" class="btn btn-sm btn-outline-secondary border-0 py-0" title="Sort by sender"><i class="bi bi-person{{if eq .sort_by "from"}}-fill{{end}}"></i>{{if eq .sort_by "from"}} <i class="bi bi-caret-{{if eq .sort_dir "asc"}}up{{else}}down{{end}}-fill"></i>{{end}}</a>
|
||||||
|
<a href="{{.sort_date_href}}" class="btn btn-sm btn-outline-secondary border-0 py-0" title="Sort by date"><i class="bi bi-calendar3{{if ne .sort_by "from"}}-fill{{end}}"></i>{{if ne .sort_by "from"}} <i class="bi bi-caret-{{if eq .sort_dir "asc"}}up{{else}}down{{end}}-fill"></i>{{end}}</a>
|
||||||
|
<a href="{{.unread_only_href}}" class="btn btn-sm border-0 py-0 {{if .unread_only}}btn-primary{{else}}btn-outline-secondary{{end}}" title="Unread only"><i class="bi bi-envelope-fill"></i></a>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mail-list-header">
|
||||||
<div class="col-lg-10 mb-4">
|
<span>{{if .search_query}}Search: “{{.search_query}}”{{else}}{{.active_folder}}{{end}}</span>
|
||||||
<div class="card">
|
<span>{{.total}} message{{if ne .total 1}}s{{end}}</span>
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
</div>
|
||||||
<h5 class="mb-0">
|
<div class="mail-list-scroll" id="mailListScroll">
|
||||||
{{if .search_query}}<i class="bi bi-search me-2"></i>Search results for “{{.search_query}}”
|
{{if .messages}}
|
||||||
{{else}}<i class="bi bi-folder2-open me-2"></i>{{.active_folder}}{{end}}
|
{{range .messages}}
|
||||||
</h5>
|
{{$rowHref := printf "/webmail/mail/%s/%d" .Folder .ID}}
|
||||||
<small class="text-muted">{{.total}} message{{if ne .total 1}}s{{end}}</small>
|
{{$paneHref := printf "/webmail/mail/%s/%d/pane" .Folder .ID}}
|
||||||
</div>
|
{{$isDraft := eq .Folder "Drafts"}}
|
||||||
<div class="card-body p-0">
|
{{if $isDraft}}{{$rowHref = printf "/webmail/mail/compose?draft=%d&folder=Drafts" .ID}}{{end}}
|
||||||
{{if .messages}}
|
{{$displayName := senderName .CachedFrom}}
|
||||||
<div class="table-responsive">
|
{{if eq .Folder "Sent"}}{{if .CachedTo}}{{$displayName = senderName .CachedTo}}{{else}}{{$displayName = "(no recipient)"}}{{end}}{{end}}
|
||||||
<table class="table table-dark table-hover mb-0">
|
<div class="msg-item {{if .Unread}}unread{{end}}{{if .Collapsed}} msg-row-older{{end}} msg-row" draggable="true" data-uid="{{.ID}}" data-folder="{{.Folder}}" data-href="{{$rowHref}}" data-pane-href="{{$paneHref}}" data-is-draft="{{$isDraft}}">
|
||||||
<thead>
|
<input type="checkbox" class="form-check-input msg-check" value="{{.ID}}" onclick="event.stopPropagation()">
|
||||||
<tr>
|
<div class="msg-avatar">{{initial $displayName}}</div>
|
||||||
{{if not .search_query}}<th>{{if eq .active_folder "Sent"}}To{{else}}From{{end}}</th>{{else}}<th>From / To</th>{{end}}
|
<div class="msg-main">
|
||||||
<th>Subject</th>
|
<div class="msg-row1">
|
||||||
{{if .search_query}}<th>Folder</th>{{end}}
|
<span class="msg-from" title="{{if eq .Folder "Sent"}}{{.CachedTo}}{{else}}{{.CachedFrom}}{{end}}">{{$displayName}}</span>
|
||||||
<th>Date</th>
|
<span class="msg-date">{{strftime "%Y-%m-%d %H:%M" .InternalDate}}</span>
|
||||||
<th></th>
|
</div>
|
||||||
</tr>
|
<div class="msg-row2">
|
||||||
</thead>
|
<span class="msg-subject">{{if .CachedSubject}}{{.CachedSubject}}{{else}}(no subject){{end}}</span>
|
||||||
<tbody>
|
{{if gt .GroupExtra 0}}<span class="badge bg-secondary msg-group-toggle" data-group-toggle="{{.ID}}">+{{.GroupExtra}} more</span>{{end}}
|
||||||
{{$folders := .folders}}
|
{{if .CachedPreview}} – {{.CachedPreview}}{{end}}
|
||||||
{{$showFolderCol := .search_query}}
|
</div>
|
||||||
{{range .messages}}
|
|
||||||
{{$rowHref := printf "/webmail/mail/%s/%d" .Folder .ID}}
|
|
||||||
{{if eq .Folder "Drafts"}}{{$rowHref = printf "/webmail/mail/compose?draft=%d&folder=Drafts" .ID}}{{end}}
|
|
||||||
<tr class="{{if .Unread}}msg-unread{{end}} msg-row{{if .Collapsed}} msg-row-older{{end}}" draggable="true" data-uid="{{.ID}}" data-folder="{{.Folder}}">
|
|
||||||
<td><a class="text-reset text-decoration-none" href="{{$rowHref}}">{{if eq .Folder "Sent"}}{{if .CachedTo}}{{.CachedTo}}{{else}}(no recipient){{end}}{{else}}{{.CachedFrom}}{{end}}</a></td>
|
|
||||||
<td>
|
|
||||||
<a class="text-reset text-decoration-none" href="{{$rowHref}}">{{if .CachedSubject}}{{.CachedSubject}}{{else}}<span class="text-muted">(no subject)</span>{{end}}</a>
|
|
||||||
{{if gt .GroupExtra 0}}<span class="badge bg-secondary msg-group-toggle" data-group-toggle="{{.ID}}">+{{.GroupExtra}} more</span>{{end}}
|
|
||||||
</td>
|
|
||||||
{{if $showFolderCol}}<td><small class="text-muted">{{.Folder}}</small></td>{{end}}
|
|
||||||
<td><small class="text-muted">{{strftime "%Y-%m-%d %H:%M" .InternalDate}}</small></td>
|
|
||||||
<td class="text-end">
|
|
||||||
<div class="btn-group btn-group-sm" role="group">
|
|
||||||
<form method="post" action="/webmail/mail/{{.Folder}}/{{.ID}}/move" class="d-inline-flex">
|
|
||||||
<select name="target_folder" class="form-select form-select-sm" style="width: auto;" onchange="this.form.submit()">
|
|
||||||
<option value="">Move to…</option>
|
|
||||||
{{$rowFolder := .Folder}}
|
|
||||||
{{range $folders}}{{if ne . $rowFolder}}<option value="{{.}}">{{.}}</option>{{end}}{{end}}
|
|
||||||
</select>
|
|
||||||
</form>
|
|
||||||
<form method="post" action="/webmail/mail/{{.Folder}}/{{.ID}}/delete" class="d-inline">
|
|
||||||
<button type="submit" class="btn btn-outline-danger btn-sm" title="{{if eq .Folder "Trash"}}Delete permanently{{else}}Move to Trash{{end}}" data-confirm="{{if eq .Folder "Trash"}}Permanently delete this message? This cannot be undone.{{else}}Move this message to Trash?{{end}}"><i class="bi bi-trash"></i></button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{{end}}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
{{if or .has_prev .has_next}}
|
|
||||||
<div class="d-flex justify-content-between p-3">
|
|
||||||
{{if .has_prev}}<a href="?page={{sub .page 1}}" class="btn btn-outline-secondary btn-sm">« Newer</a>{{else}}<span></span>{{end}}
|
|
||||||
{{if .has_next}}<a href="?page={{add .page 1}}" class="btn btn-outline-secondary btn-sm">Older »</a>{{end}}
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
{{else}}
|
|
||||||
<div class="text-center py-5">
|
|
||||||
<i class="bi bi-inbox text-muted" style="font-size: 3rem;"></i>
|
|
||||||
<h5 class="text-muted mt-3">No messages in {{.active_folder}}</h5>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{else}}
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<i class="bi bi-inbox text-muted" style="font-size: 3rem;"></i>
|
||||||
|
<h6 class="text-muted mt-3">No messages</h6>
|
||||||
</div>
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{if or .has_prev .has_next}}
|
||||||
|
<div class="d-flex justify-content-between p-2 border-top" style="border-color: #404040 !important;">
|
||||||
|
{{if .has_prev}}<a href="{{.prev_href}}" class="btn btn-outline-secondary btn-sm">« Newer</a>{{else}}<span></span>{{end}}
|
||||||
|
{{if .has_next}}<a href="{{.next_href}}" class="btn btn-outline-secondary btn-sm">Older »</a>{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mail-reading-pane" id="readingPaneBody">
|
||||||
|
<div class="text-center text-muted py-5">
|
||||||
|
<i class="bi bi-envelope-open" style="font-size: 3rem;"></i>
|
||||||
|
<p class="mt-3">Select a message to read</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<form method="post" id="bulkActionForm" class="d-none">
|
||||||
|
<input type="hidden" name="action" id="bulkActionField">
|
||||||
|
<input type="hidden" name="target_folder" id="bulkTargetFolderField">
|
||||||
|
</form>
|
||||||
|
|
||||||
<div class="modal fade" id="confirmationModal" tabindex="-1" aria-hidden="true">
|
<div class="modal fade" id="confirmationModal" tabindex="-1" aria-hidden="true">
|
||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
@@ -217,9 +260,9 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Drag a message row onto a folder in the sidebar to move it there — a
|
// Drag a message row onto a folder in the sidebar to move it there — a
|
||||||
// shortcut for the same "Move to..." dropdown every row already has. Each
|
// shortcut for the toolbar's "Move to..." control. Each row carries its OWN
|
||||||
// row carries its OWN folder (data-folder) rather than assuming the page's
|
// folder (data-folder) rather than assuming the page's active folder, since a
|
||||||
// active folder, since a search result can span multiple folders.
|
// search result can span multiple folders.
|
||||||
(function() {
|
(function() {
|
||||||
let draggedUID = null;
|
let draggedUID = null;
|
||||||
let draggedFolder = null;
|
let draggedFolder = null;
|
||||||
@@ -251,6 +294,7 @@
|
|||||||
if (!draggedUID || !targetFolder || targetFolder === draggedFolder) return;
|
if (!draggedUID || !targetFolder || targetFolder === draggedFolder) return;
|
||||||
const body = new URLSearchParams();
|
const body = new URLSearchParams();
|
||||||
body.set('target_folder', targetFolder);
|
body.set('target_folder', targetFolder);
|
||||||
|
body.set('csrf_token', window.__csrfToken || '');
|
||||||
await fetch(`/webmail/mail/${draggedFolder}/${draggedUID}/move`, {
|
await fetch(`/webmail/mail/${draggedFolder}/${draggedUID}/move`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
@@ -268,7 +312,7 @@
|
|||||||
badge.addEventListener('click', function(e) {
|
badge.addEventListener('click', function(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
let sib = badge.closest('tr').nextElementSibling;
|
let sib = badge.closest('.msg-item').nextElementSibling;
|
||||||
while (sib && sib.classList.contains('msg-row-older')) {
|
while (sib && sib.classList.contains('msg-row-older')) {
|
||||||
sib.style.display = '';
|
sib.style.display = '';
|
||||||
sib = sib.nextElementSibling;
|
sib = sib.nextElementSibling;
|
||||||
@@ -276,6 +320,123 @@
|
|||||||
badge.style.display = 'none';
|
badge.style.display = 'none';
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Folder sidebar collapse — a display preference remembered per-browser
|
||||||
|
// (localStorage), not server state; default is pinned open (nothing stored
|
||||||
|
// yet == not collapsed).
|
||||||
|
(function() {
|
||||||
|
const KEY = 'webmail_sidebar_collapsed';
|
||||||
|
const sidebarCol = document.getElementById('folderSidebarCol');
|
||||||
|
const showBtn = document.getElementById('sidebarShowBtn');
|
||||||
|
function apply(collapsed) {
|
||||||
|
sidebarCol.style.display = collapsed ? 'none' : '';
|
||||||
|
showBtn.classList.toggle('d-none', !collapsed);
|
||||||
|
}
|
||||||
|
apply(localStorage.getItem(KEY) === '1');
|
||||||
|
document.getElementById('sidebarCollapseBtn').addEventListener('click', function() {
|
||||||
|
localStorage.setItem(KEY, '1');
|
||||||
|
apply(true);
|
||||||
|
});
|
||||||
|
showBtn.addEventListener('click', function() {
|
||||||
|
localStorage.setItem(KEY, '0');
|
||||||
|
apply(false);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Reading pane: clicking a row loads the message via fetch instead of
|
||||||
|
// navigating away, mirroring the admin dashboard's message-log modal. Drafts
|
||||||
|
// still navigate to compose (there's nothing to "read"). The clicked row is
|
||||||
|
// marked read optimistically client-side — the pane fetch itself is what
|
||||||
|
// actually marks it read server-side (see webmailMessagePane).
|
||||||
|
(function() {
|
||||||
|
const paneBody = document.getElementById('readingPaneBody');
|
||||||
|
document.querySelectorAll('.msg-item').forEach(function(row) {
|
||||||
|
row.addEventListener('click', function(e) {
|
||||||
|
if (e.target.closest('.msg-group-toggle') || e.target.classList.contains('msg-check')) return;
|
||||||
|
if (row.dataset.isDraft === 'true') { window.location.href = row.dataset.href; return; }
|
||||||
|
document.querySelectorAll('.msg-item.active').forEach(function(r) { r.classList.remove('active'); });
|
||||||
|
row.classList.add('active');
|
||||||
|
row.classList.remove('unread');
|
||||||
|
paneBody.innerHTML = '<div class="text-center text-muted py-5"><div class="spinner-border" role="status"></div></div>';
|
||||||
|
fetch(row.dataset.paneHref)
|
||||||
|
.then(function(r) { if (!r.ok) throw new Error(r.status); return r.text(); })
|
||||||
|
.then(function(html) { paneBody.innerHTML = html; })
|
||||||
|
.catch(function() { paneBody.innerHTML = '<p class="text-danger">Failed to load the message.</p>'; });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Selection (checkboxes + select-all + Shift-click range) driving the bulk
|
||||||
|
// toolbar buttons — enabled only once something's actually selected.
|
||||||
|
(function() {
|
||||||
|
const checks = Array.from(document.querySelectorAll('.msg-check'));
|
||||||
|
const selectAll = document.getElementById('selectAllCheck');
|
||||||
|
const bulkBtns = document.querySelectorAll('.bulk-btn');
|
||||||
|
const moveSelect = document.getElementById('bulkMoveSelect');
|
||||||
|
let lastCheckedIndex = null;
|
||||||
|
|
||||||
|
function updateToolbar() {
|
||||||
|
const any = checks.some(function(c) { return c.checked; });
|
||||||
|
bulkBtns.forEach(function(b) { b.disabled = !any; });
|
||||||
|
if (moveSelect) moveSelect.disabled = !any;
|
||||||
|
selectAll.checked = checks.length > 0 && checks.every(function(c) { return c.checked; });
|
||||||
|
}
|
||||||
|
|
||||||
|
checks.forEach(function(cb, i) {
|
||||||
|
cb.addEventListener('click', function(e) {
|
||||||
|
if (e.shiftKey && lastCheckedIndex !== null) {
|
||||||
|
const [from, to] = [lastCheckedIndex, i].sort(function(a, b) { return a - b; });
|
||||||
|
for (let j = from; j <= to; j++) checks[j].checked = cb.checked;
|
||||||
|
}
|
||||||
|
lastCheckedIndex = i;
|
||||||
|
updateToolbar();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
selectAll.addEventListener('change', function() {
|
||||||
|
checks.forEach(function(c) { c.checked = selectAll.checked; });
|
||||||
|
updateToolbar();
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'a' && document.activeElement.tagName !== 'INPUT') {
|
||||||
|
e.preventDefault();
|
||||||
|
selectAll.checked = true;
|
||||||
|
checks.forEach(function(c) { c.checked = true; });
|
||||||
|
updateToolbar();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function selectedUIDs() { return checks.filter(function(c) { return c.checked; }).map(function(c) { return c.value; }); }
|
||||||
|
function submitBulk(action, targetFolder) {
|
||||||
|
const uids = selectedUIDs();
|
||||||
|
if (uids.length === 0) return;
|
||||||
|
const form = document.getElementById('bulkActionForm');
|
||||||
|
form.action = '/webmail/mail/{{.active_folder}}/bulk';
|
||||||
|
document.getElementById('bulkActionField').value = action;
|
||||||
|
document.getElementById('bulkTargetFolderField').value = targetFolder || '';
|
||||||
|
form.querySelectorAll('input[name="uid"]').forEach(function(el) { el.remove(); });
|
||||||
|
uids.forEach(function(uid) {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'hidden'; input.name = 'uid'; input.value = uid;
|
||||||
|
form.appendChild(input);
|
||||||
|
});
|
||||||
|
const csrf = document.createElement('input');
|
||||||
|
csrf.type = 'hidden'; csrf.name = 'csrf_token'; csrf.value = window.__csrfToken || '';
|
||||||
|
form.appendChild(csrf);
|
||||||
|
form.submit();
|
||||||
|
}
|
||||||
|
document.querySelectorAll('.bulk-btn').forEach(function(btn) {
|
||||||
|
btn.addEventListener('click', async function() {
|
||||||
|
const action = btn.dataset.action;
|
||||||
|
if (action === 'delete' && !(await showConfirmation('Move the selected message(s) to Trash?'))) return;
|
||||||
|
submitBulk(action);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (moveSelect) {
|
||||||
|
moveSelect.addEventListener('change', function() {
|
||||||
|
if (moveSelect.value) submitBulk('move', moveSelect.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
{{define "webmail_message_pane.html"}}
|
||||||
|
<div class="pane-toolbar d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
|
<button type="button" onclick="openCompose('/webmail/mail/compose?reply={{.uid}}&folder={{.active_folder}}')" class="btn btn-outline-primary" title="Reply"><i class="bi bi-reply"></i></button>
|
||||||
|
<button type="button" onclick="openCompose('/webmail/mail/compose?replyall={{.uid}}&folder={{.active_folder}}')" class="btn btn-outline-primary" title="Reply All"><i class="bi bi-reply-all"></i></button>
|
||||||
|
<button type="button" onclick="openCompose('/webmail/mail/compose?forward={{.uid}}&folder={{.active_folder}}')" class="btn btn-outline-primary" title="Forward"><i class="bi bi-arrow-right"></i></button>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<form method="post" action="/webmail/mail/{{.active_folder}}/{{.uid}}/move" class="d-flex align-items-center gap-1">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{.csrf_token}}">
|
||||||
|
<select name="target_folder" class="form-select form-select-sm" style="width: auto;">
|
||||||
|
<option value="">Move to…</option>
|
||||||
|
{{$folder := .active_folder}}
|
||||||
|
{{range .folders}}{{if ne . $folder}}<option value="{{.}}">{{.}}</option>{{end}}{{end}}
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="btn btn-outline-secondary btn-sm" title="Move"><i class="bi bi-folder-symlink"></i></button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/webmail/mail/{{.active_folder}}/{{.uid}}/delete" onsubmit="return confirm('{{if eq .active_folder "Trash"}}Permanently delete this message? This cannot be undone.{{else}}Move this message to Trash?{{end}}');">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{.csrf_token}}">
|
||||||
|
<button type="submit" class="btn btn-outline-danger btn-sm" title="{{if eq .active_folder "Trash"}}Delete Permanently{{else}}Move to Trash{{end}}"><i class="bi bi-trash"></i></button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h5 class="mb-2">{{if .parsed.Header.Subject}}{{.parsed.Header.Subject}}{{else}}<span class="text-muted">(no subject)</span>{{end}}</h5>
|
||||||
|
{{if or .smime.Signed .smime.Encrypted}}
|
||||||
|
<div class="mb-2">
|
||||||
|
{{if .smime.Encrypted}}
|
||||||
|
{{if .smime.Decrypted}}<span class="badge bg-success"><i class="bi bi-unlock-fill me-1"></i>Encrypted & decrypted</span>
|
||||||
|
{{else}}<span class="badge bg-danger" title="{{.smime.DecryptErr}}"><i class="bi bi-lock-fill me-1"></i>Encrypted — could not decrypt</span>{{end}}
|
||||||
|
{{end}}
|
||||||
|
{{if .smime.Signed}}
|
||||||
|
{{if .smime.SignatureOK}}<span class="badge bg-success" title="{{.smime.SignerEmail}}"><i class="bi bi-patch-check-fill me-1"></i>Signature verified{{if .smime.SignerEmail}} ({{.smime.SignerEmail}}){{end}}</span>
|
||||||
|
{{else}}<span class="badge bg-danger" title="{{.smime.SignatureErr}}"><i class="bi bi-exclamation-triangle-fill me-1"></i>Signature invalid</span>{{end}}
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{if .pgp.Encrypted}}
|
||||||
|
<div class="mb-2">
|
||||||
|
{{if .pgp.Decrypted}}<span class="badge bg-success"><i class="bi bi-unlock-fill me-1"></i>PGP encrypted & decrypted</span>
|
||||||
|
{{else if .pgp.NeedsUnlock}}<span class="badge bg-warning text-dark"><i class="bi bi-lock-fill me-1"></i>PGP encrypted — enter your passphrase to decrypt</span>
|
||||||
|
{{else}}<span class="badge bg-danger" title="{{.pgp.DecryptErr}}"><i class="bi bi-lock-fill me-1"></i>PGP encrypted — could not decrypt</span>{{end}}
|
||||||
|
</div>
|
||||||
|
{{if .pgp.NeedsUnlock}}
|
||||||
|
<form method="post" action="/webmail/pgp/unlock" class="row g-2 align-items-end mb-2">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{.csrf_token}}">
|
||||||
|
<input type="hidden" name="next" value="{{.message_url}}">
|
||||||
|
<div class="col-auto">
|
||||||
|
<select class="form-select form-select-sm" name="identity_id">
|
||||||
|
{{range .pgp.Identities}}<option value="{{.ID}}">{{if .Label}}{{.Label}}{{else}}Key{{end}} ({{.Fingerprint}})</option>{{end}}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<input type="password" class="form-control form-control-sm" name="passphrase" placeholder="Passphrase" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<button type="submit" class="btn btn-warning btn-sm">Unlock & Decrypt</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
<div class="small text-muted mb-3">
|
||||||
|
<div><strong>From:</strong> {{.parsed.Header.From}}</div>
|
||||||
|
<div><strong>To:</strong> {{.parsed.Header.To}}</div>
|
||||||
|
{{if .parsed.Header.Cc}}<div><strong>Cc:</strong> {{.parsed.Header.Cc}}</div>{{end}}
|
||||||
|
<div><strong>Date:</strong> {{.parsed.Header.Date}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if .html_body}}
|
||||||
|
<div class="msg-body-html">{{.html_body}}</div>
|
||||||
|
{{else if .parsed.TextBody}}
|
||||||
|
<div class="msg-body-text">{{.parsed.TextBody}}</div>
|
||||||
|
{{else}}
|
||||||
|
<p class="text-muted mb-0">(empty message body)</p>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .parsed.Attachments}}
|
||||||
|
<hr>
|
||||||
|
<h6><i class="bi bi-paperclip me-1"></i>Attachments</h6>
|
||||||
|
<div class="list-group">
|
||||||
|
{{$folder := .active_folder}}
|
||||||
|
{{$uid := .uid}}
|
||||||
|
{{range $i, $att := .parsed.Attachments}}
|
||||||
|
<a href="/webmail/mail/{{$folder}}/{{$uid}}/attachment/{{$i}}" class="list-group-item list-group-item-action bg-transparent text-white d-flex justify-content-between align-items-center">
|
||||||
|
<span><i class="bi bi-file-earmark me-2"></i>{{$att.Filename}}</span>
|
||||||
|
<i class="bi bi-download"></i>
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
package webui
|
package webui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"mailgoserver/internal/db"
|
"mailgoserver/internal/db"
|
||||||
|
"mailgoserver/internal/mailview"
|
||||||
)
|
)
|
||||||
|
|
||||||
// emailLogAccessible checks a scoped admin's domain assignment against the sender
|
// emailLogAccessible checks a scoped admin's domain assignment against the sender
|
||||||
@@ -20,7 +23,25 @@ func (a *App) emailLogAccessible(r *http.Request, mailFrom string) (bool, error)
|
|||||||
return isGlobal || names[emailDomain(mailFrom)], nil
|
return isGlobal || names[emailDomain(mailFrom)], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// viewMessageContent mirrors view_message.py's view_message_content().
|
// viewedAttachment is one attachment ready for the log viewer: decoded bytes encoded
|
||||||
|
// as a data: URI so no separate download route/disk read is needed, and a browser can
|
||||||
|
// render it as an inline image directly for the review case this is really for
|
||||||
|
// (a quarantined message an admin needs to actually inspect, images and all).
|
||||||
|
type viewedAttachment struct {
|
||||||
|
Filename string
|
||||||
|
ContentType string
|
||||||
|
Size int64 // int64 to match humanFileSize's signature (the "filesize" template func)
|
||||||
|
DataURI template.URL
|
||||||
|
IsImage bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// viewMessageContent mirrors view_message.py's view_message_content(). log.MessageBody
|
||||||
|
// holds the *entire* raw message when this log's content was eligible to be stored
|
||||||
|
// (see session.go's storeContent) — re-parsed here via mailview (the same parser
|
||||||
|
// webmail's own message view uses) so the real HTML body, inline images, and
|
||||||
|
// attachments all render, not just a plain-text approximation. Falls back to showing
|
||||||
|
// message_body as plain preformatted text if it doesn't parse as a MIME message (e.g.
|
||||||
|
// an older log row stored before this — plain-text-only — capture existed).
|
||||||
func (a *App) viewMessageContent(w http.ResponseWriter, r *http.Request) {
|
func (a *App) viewMessageContent(w http.ResponseWriter, r *http.Request) {
|
||||||
log, err := a.DB.GetEmailLogByID(pathID(r))
|
log, err := a.DB.GetEmailLogByID(pathID(r))
|
||||||
if err != nil || log == nil {
|
if err != nil || log == nil {
|
||||||
@@ -31,12 +52,45 @@ func (a *App) viewMessageContent(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
attachments, _ := a.DB.ListAttachmentsForEmail(log.ID)
|
|
||||||
|
// The old, file-on-disk attachment mechanism (still opt-in-gated the same way it
|
||||||
|
// always was) — kept as a fallback list for log rows predating the raw-message
|
||||||
|
// capture below, where this is the only place attachments exist at all.
|
||||||
|
legacyAttachments, _ := a.DB.ListAttachmentsForEmail(log.ID)
|
||||||
|
|
||||||
|
var htmlBody template.HTML
|
||||||
|
var plainBody string
|
||||||
|
var attachments []viewedAttachment
|
||||||
|
if log.MessageBody != "" {
|
||||||
|
if parsed, err := mailview.Parse([]byte(log.MessageBody)); err == nil {
|
||||||
|
if parsed.HTMLBody != "" {
|
||||||
|
htmlBody = template.HTML(htmlBodyPolicy.Sanitize(parsed.HTMLBody))
|
||||||
|
}
|
||||||
|
plainBody = parsed.TextBody
|
||||||
|
for _, att := range parsed.Attachments {
|
||||||
|
ct := att.ContentType
|
||||||
|
if ct == "" {
|
||||||
|
ct = "application/octet-stream"
|
||||||
|
}
|
||||||
|
attachments = append(attachments, viewedAttachment{
|
||||||
|
Filename: att.Filename, ContentType: ct, Size: int64(len(att.Data)),
|
||||||
|
DataURI: template.URL("data:" + ct + ";base64," + base64.StdEncoding.EncodeToString(att.Data)),
|
||||||
|
IsImage: strings.HasPrefix(ct, "image/"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Doesn't parse as MIME — treat the stored string as plain text as-is
|
||||||
|
// (the shape a pre-fix log row's message_body was always in).
|
||||||
|
plainBody = log.MessageBody
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
a.render(w, r, "view_message_content.html", M{"active": "logs", "log": M{
|
a.render(w, r, "view_message_content.html", M{"active": "logs", "log": M{
|
||||||
"id": log.ID, "mail_from": log.MailFrom, "to_address": log.ToAddress,
|
"id": log.ID, "mail_from": log.MailFrom, "to_address": log.ToAddress,
|
||||||
"cc_addresses": log.CcAddresses, "bcc_addresses": log.BccAddresses,
|
"cc_addresses": log.CcAddresses, "bcc_addresses": log.BccAddresses,
|
||||||
"subject": log.Subject, "created_at": log.CreatedAt, "message_body": log.MessageBody,
|
"subject": log.Subject, "created_at": log.CreatedAt,
|
||||||
"email_headers": log.EmailHeaders, "attachments": attachments,
|
"html_body": htmlBody, "plain_body": plainBody, "has_content": log.MessageBody != "",
|
||||||
|
"email_headers": log.EmailHeaders, "attachments": attachments, "legacy_attachments": legacyAttachments,
|
||||||
}})
|
}})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package webui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"mailgoserver/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 1x1 transparent PNG, base64-encoded — a minimal real image for the attachment part.
|
||||||
|
const testPNGBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
|
||||||
|
|
||||||
|
func buildTestMIMEMessageWithImage() string {
|
||||||
|
boundary := "testboundary123"
|
||||||
|
return strings.Join([]string{
|
||||||
|
"From: attacker@evil.example",
|
||||||
|
"To: victim@example.com",
|
||||||
|
"Subject: Free money",
|
||||||
|
"MIME-Version: 1.0",
|
||||||
|
"Content-Type: multipart/mixed; boundary=\"" + boundary + "\"",
|
||||||
|
"",
|
||||||
|
"--" + boundary,
|
||||||
|
`Content-Type: text/html; charset="UTF-8"`,
|
||||||
|
"",
|
||||||
|
"<p>Click <b>here</b> to claim your prize.</p>",
|
||||||
|
"",
|
||||||
|
"--" + boundary,
|
||||||
|
"Content-Type: image/png",
|
||||||
|
"Content-Transfer-Encoding: base64",
|
||||||
|
`Content-Disposition: attachment; filename="lure.png"`,
|
||||||
|
"",
|
||||||
|
testPNGBase64,
|
||||||
|
"",
|
||||||
|
"--" + boundary + "--",
|
||||||
|
}, "\r\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestViewMessageContentRendersHTMLAndAttachmentInlineForStoredContent confirms that
|
||||||
|
// when a log's message_body holds a full raw message (the new default for a
|
||||||
|
// quarantined/opted-in message — see session.go's storeContent), the "View Full
|
||||||
|
// Message" page actually renders the real HTML body and offers the attachment inline
|
||||||
|
// (as a data: URI, no separate file/route needed) — not just a plain-text dump, and
|
||||||
|
// not silently dropping the image the way the old text-only capture always did.
|
||||||
|
func TestViewMessageContentRendersHTMLAndAttachmentInlineForStoredContent(t *testing.T) {
|
||||||
|
app := newTestApp(t)
|
||||||
|
mux := app.Mux()
|
||||||
|
cookie := loginSession(t, app)
|
||||||
|
|
||||||
|
logID, err := app.DB.InsertEmailLog(db.EmailLog{
|
||||||
|
MessageID: "test-msg-id", Timestamp: time.Now(), PeerIP: "203.0.113.5",
|
||||||
|
MailFrom: "attacker@evil.example", ToAddress: "victim@example.com", Subject: "Free money",
|
||||||
|
EmailHeaders: "From: attacker@evil.example\nSubject: Free money",
|
||||||
|
MessageBody: buildTestMIMEMessageWithImage(),
|
||||||
|
Status: "failed",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, Prefix+"/msg/content/"+strconv.FormatInt(logID, 10), nil)
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
body := rec.Body.String()
|
||||||
|
|
||||||
|
if !strings.Contains(body, "Click") || !strings.Contains(body, "<b>here</b>") {
|
||||||
|
t.Errorf("expected the sanitized HTML body rendered, got:\n%s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "data:image/png;base64,") {
|
||||||
|
t.Error("expected the attachment rendered inline as a data: URI")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "lure.png") {
|
||||||
|
t.Error("expected the attachment's filename shown")
|
||||||
|
}
|
||||||
|
// This is fetched into a modal on the logs page, not navigated to directly — it
|
||||||
|
// must be a bare fragment, not a full page with the dashboard's own nav/sidebar.
|
||||||
|
if strings.Contains(body, "<!DOCTYPE") || strings.Contains(body, "Email Server Management") || strings.Contains(body, "sidebar_email") {
|
||||||
|
t.Errorf("expected a bare fragment with no dashboard chrome, got:\n%s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package webui
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"image/png"
|
"image/png"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -37,6 +38,45 @@ func (a *App) webmailDashboard(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// webmailSetGroupMessages toggles the "group similar subjects" folder-view preference
|
||||||
|
// (see renderFolderOrSearch) — off by default, per-mailbox, purely a display choice.
|
||||||
|
func (a *App) webmailSetGroupMessages(w http.ResponseWriter, r *http.Request) {
|
||||||
|
mbox := mailboxFromContext(r)
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
setFlash(w, "error", "Invalid form data")
|
||||||
|
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.DB.SetMailboxGroupMessages(mbox.ID, r.FormValue("group_messages") == "true"); err != nil {
|
||||||
|
a.Logger.Error("set group_messages for mailbox %d: %v", mbox.ID, err)
|
||||||
|
setFlash(w, "error", "Could not save preference")
|
||||||
|
} else {
|
||||||
|
setFlash(w, "success", "Preference saved")
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// webmailRebuildMessageCache re-derives cached_from/cached_to/cached_subject/
|
||||||
|
// cached_preview for every message already in this mailbox — see
|
||||||
|
// mailstore.RebuildMessageCache's doc comment for why this exists: those fields are
|
||||||
|
// only ever computed once, at delivery time, so mail stored before a caching fix (like
|
||||||
|
// showing a sender's display name instead of the bare address) or addition (like the
|
||||||
|
// preview snippet) landed keeps showing the old/blank value until something
|
||||||
|
// retroactively re-derives it.
|
||||||
|
func (a *App) webmailRebuildMessageCache(w http.ResponseWriter, r *http.Request) {
|
||||||
|
mbox := mailboxFromContext(r)
|
||||||
|
updated, skipped := a.Mailstore.RebuildMessageCache(mbox.ID)
|
||||||
|
if len(skipped) > 0 {
|
||||||
|
a.Logger.Error("rebuild message cache for mailbox %d: %d skipped: %v", mbox.ID, len(skipped), skipped)
|
||||||
|
}
|
||||||
|
msg := fmt.Sprintf("Refreshed %d message(s)", updated)
|
||||||
|
if len(skipped) > 0 {
|
||||||
|
msg += fmt.Sprintf(" — %d could not be read and were left as-is", len(skipped))
|
||||||
|
}
|
||||||
|
setFlash(w, "success", msg)
|
||||||
|
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
// webmailMFASetupRequiredPage is the isolated, no-navigation landing page
|
// webmailMFASetupRequiredPage is the isolated, no-navigation landing page
|
||||||
// requireMailboxAuth sends a mailbox owner to when enforce_mailbox_mfa applies and
|
// requireMailboxAuth sends a mailbox owner to when enforce_mailbox_mfa applies and
|
||||||
// they have no second factor yet — the only page (besides the totp/passkey setup
|
// they have no second factor yet — the only page (besides the totp/passkey setup
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package webui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"mailgoserver/internal/db"
|
||||||
|
"mailgoserver/internal/mailstore"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestWebmailComposeSendBouncesFailedRecipientToSenderInbox confirms that when one
|
||||||
|
// recipient in a multi-recipient send fails (here: an over-quota local mailbox, caught
|
||||||
|
// only at delivery time — RCPT-equivalent resolution succeeds), the sender still gets
|
||||||
|
// their flash "sent, but..." feedback AND a persistent bounce notification lands in
|
||||||
|
// their own INBOX, mirroring a real mail provider's delivery-failure notice.
|
||||||
|
func TestWebmailComposeSendBouncesFailedRecipientToSenderInbox(t *testing.T) {
|
||||||
|
app := newTestApp(t)
|
||||||
|
mux := app.Mux()
|
||||||
|
domains, _ := app.DB.ListDomains()
|
||||||
|
domainID := domains[0].ID
|
||||||
|
|
||||||
|
senderID := createTestMailboxWithPassword(t, app, "sender@example.com", domainID, "sender-password-1!")
|
||||||
|
|
||||||
|
// A second local mailbox with an effectively-zero quota, so StoreMessage always
|
||||||
|
// fails with ErrQuotaExceeded — a hermetic, deterministic delivery failure with no
|
||||||
|
// network dependency (unlike a relay-to-external-domain failure would be).
|
||||||
|
hash, err := db.HashPassword("full-password-1!")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
wrapped, nonce, err := app.Mailstore.WrapDEK(mailstore.GenerateDEK())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
fullID, err := app.DB.CreateMailbox("full@example.com", hash, domainID, 1, wrapped, nonce)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cookie := webmailLoginSession(t, app, senderID)
|
||||||
|
form := url.Values{
|
||||||
|
"to": {"full@example.com"}, "subject": {"Big attachment incoming"}, "body_html": {"body text"},
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/mail/compose", strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusFound {
|
||||||
|
t.Fatalf("compose send: status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
fullMsgs, err := app.DB.ListMessagesInFolder(fullID, "INBOX")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(fullMsgs) != 0 {
|
||||||
|
t.Fatalf("expected no message delivered to the over-quota mailbox, got %d", len(fullMsgs))
|
||||||
|
}
|
||||||
|
|
||||||
|
bounces, err := app.DB.ListMessagesInFolder(senderID, "INBOX")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(bounces) != 1 {
|
||||||
|
t.Fatalf("expected 1 bounce message in the sender's own INBOX, got %d", len(bounces))
|
||||||
|
}
|
||||||
|
if bounces[0].CachedSubject != "Undelivered Mail Returned to Sender" {
|
||||||
|
t.Errorf("bounce subject = %q", bounces[0].CachedSubject)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,6 +56,12 @@ func TestWebmailComposeSendLocalDelivery(t *testing.T) {
|
|||||||
if len(senderSent) != 1 {
|
if len(senderSent) != 1 {
|
||||||
t.Fatalf("expected 1 message in sender's Sent folder, got %d", len(senderSent))
|
t.Fatalf("expected 1 message in sender's Sent folder, got %d", len(senderSent))
|
||||||
}
|
}
|
||||||
|
if isUnread(senderSent[0].Flags) {
|
||||||
|
t.Error("expected the Sent copy to be marked read, not unread")
|
||||||
|
}
|
||||||
|
if !isUnread(recipientMsgs[0].Flags) {
|
||||||
|
t.Error("expected the recipient's INBOX copy to still be unread")
|
||||||
|
}
|
||||||
|
|
||||||
// Recipient can actually read it via the message view.
|
// Recipient can actually read it via the message view.
|
||||||
recipientCookie := webmailLoginSession(t, app, recipientID)
|
recipientCookie := webmailLoginSession(t, app, recipientID)
|
||||||
@@ -70,12 +76,17 @@ func TestWebmailComposeSendLocalDelivery(t *testing.T) {
|
|||||||
t.Error("expected the message body in the rendered view")
|
t.Error("expected the message body in the rendered view")
|
||||||
}
|
}
|
||||||
|
|
||||||
// It's also recorded in the admin email log for visibility.
|
// It's also recorded in the admin email log for visibility — but never with the
|
||||||
|
// real body content (privacy default; the Sent folder above already keeps the
|
||||||
|
// real, encrypted-at-rest copy).
|
||||||
logs, _ := app.DB.ListEmailLogsPage(0, 10)
|
logs, _ := app.DB.ListEmailLogsPage(0, 10)
|
||||||
found := false
|
found := false
|
||||||
for _, l := range logs {
|
for _, l := range logs {
|
||||||
if l.Subject == "Hello there" && l.MailFrom == "sender@example.com" {
|
if l.Subject == "Hello there" && l.MailFrom == "sender@example.com" {
|
||||||
found = true
|
found = true
|
||||||
|
if strings.Contains(l.MessageBody, "This is the message body.") {
|
||||||
|
t.Errorf("expected the real body not to be logged, got %q", l.MessageBody)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !found {
|
if !found {
|
||||||
|
|||||||
@@ -638,8 +638,14 @@ func (a *App) webmailComposeSend(w http.ResponseWriter, r *http.Request) {
|
|||||||
results = append(results, a.deliverWebmailComposeLocally(rcpt, localTypes[i], from, subject, signed, messageID))
|
results = append(results, a.deliverWebmailComposeLocally(rcpt, localTypes[i], from, subject, signed, messageID))
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := a.Mailstore.StoreMessage(mbox.ID, "Sent", []byte(signed), messageID, from, subject); err != nil {
|
if sentUID, err := a.Mailstore.StoreMessage(mbox.ID, "Sent", []byte(signed), messageID, from, subject); err != nil {
|
||||||
a.Logger.Error("store sent copy for mailbox %d: %v", mbox.ID, err)
|
a.Logger.Error("store sent copy for mailbox %d: %v", mbox.ID, err)
|
||||||
|
} else if err := a.DB.SetMessageFlags(mbox.ID, sentUID, `\Seen`); err != nil {
|
||||||
|
// Mail you just sent yourself was never "unread" to begin with — StoreMessage
|
||||||
|
// has no way to set initial flags, so this mirrors deliverWebmailComposeLocally's
|
||||||
|
// existing store-then-mark-read pattern rather than threading a flags param
|
||||||
|
// through StoreMessage for what only these two Sent/Drafts call sites need.
|
||||||
|
a.Logger.Error("mark sent copy %d read for mailbox %d: %v", sentUID, mbox.ID, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sending a draft removes it from Drafts, same as any real mail client.
|
// Sending a draft removes it from Drafts, same as any real mail client.
|
||||||
@@ -649,12 +655,13 @@ func (a *App) webmailComposeSend(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loggedBody := plainText
|
// Privacy default (matches the inbound SMTP path — see session.go's Data()): the
|
||||||
|
// admin-visible log never gets the body content, webmail-sent mail included. There's
|
||||||
|
// no per-mailbox "store content" opt-in for outbound webmail sends the way there is
|
||||||
|
// for inbound senders/IPs, and it would be redundant anyway — the sender's own Sent
|
||||||
|
// folder already keeps the real, encrypted-at-rest copy of what they sent.
|
||||||
|
loggedBody := "[content not stored by default — see the sender's Sent folder for the full message]"
|
||||||
if wantEncrypt {
|
if wantEncrypt {
|
||||||
// The whole point of checking "Encrypt" is that nobody but the recipient (and
|
|
||||||
// the sender's own Sent copy) can read it — logging the plaintext into the
|
|
||||||
// admin-visible email log would defeat that even though the wire content is
|
|
||||||
// genuinely encrypted.
|
|
||||||
loggedBody = "[PGP encrypted — plaintext not logged]"
|
loggedBody = "[PGP encrypted — plaintext not logged]"
|
||||||
}
|
}
|
||||||
if _, err := a.Relay.LogEmail(a.Cfg, a.requestIP(r), from, strings.Join(toAddrs, ", "), strings.Join(ccAddrs, ", "), strings.Join(bccAddrs, ", "),
|
if _, err := a.Relay.LogEmail(a.Cfg, a.requestIP(r), from, strings.Join(toAddrs, ", "), strings.Join(ccAddrs, ", "), strings.Join(bccAddrs, ", "),
|
||||||
@@ -664,6 +671,7 @@ func (a *App) webmailComposeSend(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
allSucceeded := len(results) > 0
|
allSucceeded := len(results) > 0
|
||||||
var failures []string
|
var failures []string
|
||||||
|
var failed []relay.Result
|
||||||
for _, res := range results {
|
for _, res := range results {
|
||||||
if res.Status != "success" {
|
if res.Status != "success" {
|
||||||
allSucceeded = false
|
allSucceeded = false
|
||||||
@@ -672,12 +680,20 @@ func (a *App) webmailComposeSend(w http.ResponseWriter, r *http.Request) {
|
|||||||
reason = res.ServerResponse
|
reason = res.ServerResponse
|
||||||
}
|
}
|
||||||
failures = append(failures, res.Recipient+": "+reason)
|
failures = append(failures, res.Recipient+": "+reason)
|
||||||
|
failed = append(failed, res)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if allSucceeded {
|
if allSucceeded {
|
||||||
setFlash(w, "success", "Message sent")
|
setFlash(w, "success", "Message sent")
|
||||||
} else {
|
} else {
|
||||||
setFlash(w, "error", "Sent, but delivery failed — "+strings.Join(failures, "; "))
|
setFlash(w, "error", "Sent, but delivery failed — "+strings.Join(failures, "; "))
|
||||||
|
// There's no separate "sending MTA" here to retry/bounce it the way a real
|
||||||
|
// inbound SMTP client would — the flash message above only exists for the
|
||||||
|
// moment right after clicking Send, so a persistent copy lands in the
|
||||||
|
// sender's own INBOX too, same as a real bounce from any other mail provider.
|
||||||
|
if err := a.Relay.SendBounce(from, subject, messageID, failed); err != nil {
|
||||||
|
a.Logger.Error("send bounce to %s: %v", from, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
http.Redirect(w, r, MailboxPrefix+"/mail/Sent", http.StatusFound)
|
http.Redirect(w, r, MailboxPrefix+"/mail/Sent", http.StatusFound)
|
||||||
}
|
}
|
||||||
@@ -745,6 +761,9 @@ func (a *App) webmailComposeSaveDraft(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Redirect(w, r, MailboxPrefix+"/mail/compose", http.StatusFound)
|
http.Redirect(w, r, MailboxPrefix+"/mail/compose", http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err := a.DB.SetMessageFlags(mbox.ID, newUID, `\Seen`); err != nil {
|
||||||
|
a.Logger.Error("mark draft %d read for mailbox %d: %v", newUID, mbox.ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
// Replace, don't accumulate: re-saving an open draft deletes the previous copy.
|
// Replace, don't accumulate: re-saving an open draft deletes the previous copy.
|
||||||
if draftIDStr := r.FormValue("draft_id"); draftIDStr != "" {
|
if draftIDStr := r.FormValue("draft_id"); draftIDStr != "" {
|
||||||
|
|||||||
+200
-19
@@ -1,8 +1,10 @@
|
|||||||
package webui
|
package webui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -96,6 +98,35 @@ type folderRow struct {
|
|||||||
Collapsed bool
|
Collapsed bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sortLink builds the href for a clickable "From"/"Date" column header: clicking an
|
||||||
|
// inactive column sorts by it descending; clicking the already-active column flips
|
||||||
|
// direction; unreadOnly (and folder/query, via the caller building this against the
|
||||||
|
// current URL) carries over so toggling sort never drops the unread filter.
|
||||||
|
func sortLink(col string, unreadOnly bool, activeSortBy, activeSortDir string) string {
|
||||||
|
v := url.Values{}
|
||||||
|
dir := "desc"
|
||||||
|
if activeSortBy == col {
|
||||||
|
if activeSortDir == "asc" {
|
||||||
|
dir = "desc"
|
||||||
|
} else {
|
||||||
|
dir = "asc"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if col != "" {
|
||||||
|
v.Set("sort", col)
|
||||||
|
}
|
||||||
|
if dir != "desc" {
|
||||||
|
v.Set("dir", dir)
|
||||||
|
}
|
||||||
|
if unreadOnly {
|
||||||
|
v.Set("unread", "1")
|
||||||
|
}
|
||||||
|
if encoded := v.Encode(); encoded != "" {
|
||||||
|
return "?" + encoded
|
||||||
|
}
|
||||||
|
return "?"
|
||||||
|
}
|
||||||
|
|
||||||
func isUnread(flags string) bool {
|
func isUnread(flags string) bool {
|
||||||
for _, f := range strings.Fields(flags) {
|
for _, f := range strings.Fields(flags) {
|
||||||
if f == `\Seen` {
|
if f == `\Seen` {
|
||||||
@@ -178,12 +209,19 @@ func (a *App) renderFolderOrSearch(w http.ResponseWriter, r *http.Request, folde
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
a.Logger.Error("count unread for mailbox %d: %v", mbox.ID, err)
|
a.Logger.Error("count unread for mailbox %d: %v", mbox.ID, err)
|
||||||
}
|
}
|
||||||
|
folderCounts, err := a.DB.CountMessagesByFolder(mbox.ID)
|
||||||
|
if err != nil {
|
||||||
|
a.Logger.Error("count messages by folder for mailbox %d: %v", mbox.ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
page := atoi(r.URL.Query().Get("page"))
|
page := atoi(r.URL.Query().Get("page"))
|
||||||
if page < 1 {
|
if page < 1 {
|
||||||
page = 1
|
page = 1
|
||||||
}
|
}
|
||||||
offset := (page - 1) * webmailPageSize
|
offset := (page - 1) * webmailPageSize
|
||||||
|
unreadOnly := r.URL.Query().Get("unread") == "1"
|
||||||
|
sortBy := r.URL.Query().Get("sort") // "" (id/date, default) or "from"
|
||||||
|
sortDir := r.URL.Query().Get("dir") // "" (desc, default) or "asc"
|
||||||
|
|
||||||
var total int
|
var total int
|
||||||
var rows []db.MailboxMessage
|
var rows []db.MailboxMessage
|
||||||
@@ -194,11 +232,11 @@ func (a *App) renderFolderOrSearch(w http.ResponseWriter, r *http.Request, folde
|
|||||||
}
|
}
|
||||||
rows, err = a.DB.SearchMessagesInFolder(mbox.ID, folder, query, offset, webmailPageSize)
|
rows, err = a.DB.SearchMessagesInFolder(mbox.ID, folder, query, offset, webmailPageSize)
|
||||||
} else {
|
} else {
|
||||||
total, err = a.DB.CountMessagesInFolder(mbox.ID, folder)
|
total, err = a.DB.CountMessagesInFolder(mbox.ID, folder, unreadOnly)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.Logger.Error("count messages in %s for mailbox %d: %v", folder, mbox.ID, err)
|
a.Logger.Error("count messages in %s for mailbox %d: %v", folder, mbox.ID, err)
|
||||||
}
|
}
|
||||||
rows, err = a.DB.ListMessagesInFolderPage(mbox.ID, folder, offset, webmailPageSize)
|
rows, err = a.DB.ListMessagesInFolderPage(mbox.ID, folder, unreadOnly, sortBy, sortDir, offset, webmailPageSize)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
setFlash(w, "error", "Error loading messages")
|
setFlash(w, "error", "Error loading messages")
|
||||||
@@ -209,36 +247,71 @@ func (a *App) renderFolderOrSearch(w http.ResponseWriter, r *http.Request, folde
|
|||||||
}
|
}
|
||||||
// Grouping a cross-folder search's results by subject would mix messages that
|
// Grouping a cross-folder search's results by subject would mix messages that
|
||||||
// happen to share a subject across unrelated folders — only group a real,
|
// happen to share a subject across unrelated folders — only group a real,
|
||||||
// single-folder, unfiltered listing.
|
// single-folder, unfiltered listing. Off by default (mbox.GroupMessages) — a
|
||||||
if query == "" && folder != "" {
|
// per-mailbox display preference, toggled from Account.
|
||||||
|
if query == "" && folder != "" && mbox.GroupMessages {
|
||||||
messages = groupConsecutiveBySubject(messages)
|
messages = groupConsecutiveBySubject(messages)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
unreadToggleV := url.Values{}
|
||||||
|
if !unreadOnly {
|
||||||
|
unreadToggleV.Set("unread", "1")
|
||||||
|
}
|
||||||
|
if sortBy != "" {
|
||||||
|
unreadToggleV.Set("sort", sortBy)
|
||||||
|
}
|
||||||
|
if sortDir != "" {
|
||||||
|
unreadToggleV.Set("dir", sortDir)
|
||||||
|
}
|
||||||
|
unreadOnlyHref := "?" + unreadToggleV.Encode()
|
||||||
|
|
||||||
|
pageHref := func(n int) string {
|
||||||
|
v := url.Values{}
|
||||||
|
v.Set("page", strconv.Itoa(n))
|
||||||
|
if unreadOnly {
|
||||||
|
v.Set("unread", "1")
|
||||||
|
}
|
||||||
|
if sortBy != "" {
|
||||||
|
v.Set("sort", sortBy)
|
||||||
|
}
|
||||||
|
if sortDir != "" {
|
||||||
|
v.Set("dir", sortDir)
|
||||||
|
}
|
||||||
|
return "?" + v.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
a.render(w, r, "webmail_folder.html", M{
|
a.render(w, r, "webmail_folder.html", M{
|
||||||
"mailbox": mbox, "folders": folders, "active_folder": folder,
|
"mailbox": mbox, "folders": folders, "active_folder": folder,
|
||||||
"messages": messages, "page": page, "total": total,
|
"messages": messages, "page": page, "total": total,
|
||||||
"has_next": offset+len(rows) < total, "has_prev": page > 1,
|
"has_next": offset+len(rows) < total, "has_prev": page > 1,
|
||||||
"search_query": query, "unread_counts": unreadCounts,
|
"search_query": query, "unread_counts": unreadCounts, "folder_counts": folderCounts,
|
||||||
"flashes": popFlashes(w, r),
|
"unread_only": unreadOnly, "sort_by": sortBy, "sort_dir": sortDir,
|
||||||
|
"sort_from_href": sortLink("from", unreadOnly, sortBy, sortDir),
|
||||||
|
"sort_date_href": sortLink("", unreadOnly, sortBy, sortDir),
|
||||||
|
"unread_only_href": unreadOnlyHref,
|
||||||
|
"prev_href": pageHref(page - 1),
|
||||||
|
"next_href": pageHref(page + 1),
|
||||||
|
"flashes": popFlashes(w, r),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// webmailMessageView decrypts, parses, and renders one message — and marks it read.
|
// loadMessageForView decrypts, parses, and marks one message read — the shared core
|
||||||
func (a *App) webmailMessageView(w http.ResponseWriter, r *http.Request) {
|
// behind both webmailMessageView (the full standalone page, for direct links/
|
||||||
mbox := mailboxFromContext(r)
|
// bookmarks) and webmailMessagePane (a bare fragment, AJAX-loaded into the folder
|
||||||
folder := r.PathValue("folder")
|
// view's Outlook-style reading pane) so the crypto/parse/mark-read logic exists in
|
||||||
uid := int64(atoi(r.PathValue("uid")))
|
// exactly one place. Redirects and returns ok=false itself on any failure, so callers
|
||||||
|
// just need to bail out when ok is false.
|
||||||
|
func (a *App) loadMessageForView(w http.ResponseWriter, r *http.Request, mbox *db.Mailbox, folder string, uid int64) (data M, ok bool) {
|
||||||
msgRow, ok := a.webmailMessageWithAccess(w, r, mbox.ID, folder, uid)
|
msgRow, ok := a.webmailMessageWithAccess(w, r, mbox.ID, folder, uid)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return nil, false
|
||||||
}
|
}
|
||||||
raw, err := a.Mailstore.FetchMessage(mbox.ID, uid)
|
raw, err := a.Mailstore.FetchMessage(mbox.ID, uid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.Logger.Error("fetch message %d for mailbox %d: %v", uid, mbox.ID, err)
|
a.Logger.Error("fetch message %d for mailbox %d: %v", uid, mbox.ID, err)
|
||||||
setFlash(w, "error", "Error loading message")
|
setFlash(w, "error", "Error loading message")
|
||||||
http.Redirect(w, r, MailboxPrefix+"/mail/"+folder, http.StatusFound)
|
http.Redirect(w, r, MailboxPrefix+"/mail/"+folder, http.StatusFound)
|
||||||
return
|
return nil, false
|
||||||
}
|
}
|
||||||
unwrapped, smimeStatus, pgpStatus := a.unwrapCrypto(r, mbox.ID, raw)
|
unwrapped, smimeStatus, pgpStatus := a.unwrapCrypto(r, mbox.ID, raw)
|
||||||
parsed, err := mailview.Parse(unwrapped)
|
parsed, err := mailview.Parse(unwrapped)
|
||||||
@@ -246,7 +319,7 @@ func (a *App) webmailMessageView(w http.ResponseWriter, r *http.Request) {
|
|||||||
a.Logger.Error("parse message %d for mailbox %d: %v", uid, mbox.ID, err)
|
a.Logger.Error("parse message %d for mailbox %d: %v", uid, mbox.ID, err)
|
||||||
setFlash(w, "error", "Error reading message")
|
setFlash(w, "error", "Error reading message")
|
||||||
http.Redirect(w, r, MailboxPrefix+"/mail/"+folder, http.StatusFound)
|
http.Redirect(w, r, MailboxPrefix+"/mail/"+folder, http.StatusFound)
|
||||||
return
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
if isUnread(msgRow.Flags) {
|
if isUnread(msgRow.Flags) {
|
||||||
@@ -262,12 +335,42 @@ func (a *App) webmailMessageView(w http.ResponseWriter, r *http.Request) {
|
|||||||
htmlBody = template.HTML(htmlBodyPolicy.Sanitize(parsed.HTMLBody))
|
htmlBody = template.HTML(htmlBodyPolicy.Sanitize(parsed.HTMLBody))
|
||||||
}
|
}
|
||||||
|
|
||||||
a.render(w, r, "webmail_message.html", M{
|
return M{
|
||||||
"mailbox": mbox, "folders": folders, "active_folder": folder,
|
"mailbox": mbox, "folders": folders, "active_folder": folder,
|
||||||
"uid": uid, "parsed": parsed, "html_body": htmlBody, "smime": smimeStatus, "pgp": pgpStatus,
|
"uid": uid, "parsed": parsed, "html_body": htmlBody, "smime": smimeStatus, "pgp": pgpStatus,
|
||||||
"message_url": MailboxPrefix + "/mail/" + folder + "/" + strconv.FormatInt(uid, 10),
|
"message_url": MailboxPrefix + "/mail/" + folder + "/" + strconv.FormatInt(uid, 10),
|
||||||
"flashes": popFlashes(w, r),
|
}, true
|
||||||
})
|
}
|
||||||
|
|
||||||
|
// webmailMessageView renders one message as its own full page — direct links/
|
||||||
|
// bookmarks still work even though the folder view's reading pane (webmailMessagePane)
|
||||||
|
// is how it's normally opened now.
|
||||||
|
func (a *App) webmailMessageView(w http.ResponseWriter, r *http.Request) {
|
||||||
|
mbox := mailboxFromContext(r)
|
||||||
|
folder := r.PathValue("folder")
|
||||||
|
uid := int64(atoi(r.PathValue("uid")))
|
||||||
|
|
||||||
|
data, ok := a.loadMessageForView(w, r, mbox, folder, uid)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data["flashes"] = popFlashes(w, r)
|
||||||
|
a.render(w, r, "webmail_message.html", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// webmailMessagePane is webmailMessageView's bare-fragment twin — AJAX-fetched into
|
||||||
|
// the folder view's reading pane (see webmail_folder.html) instead of navigating to a
|
||||||
|
// whole new page, mirroring the admin dashboard's message-log modal (view_message.go).
|
||||||
|
func (a *App) webmailMessagePane(w http.ResponseWriter, r *http.Request) {
|
||||||
|
mbox := mailboxFromContext(r)
|
||||||
|
folder := r.PathValue("folder")
|
||||||
|
uid := int64(atoi(r.PathValue("uid")))
|
||||||
|
|
||||||
|
data, ok := a.loadMessageForView(w, r, mbox, folder, uid)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.render(w, r, "webmail_message_pane.html", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// webmailMessageWithAccess loads a message and 404s if it doesn't exist, isn't in
|
// webmailMessageWithAccess loads a message and 404s if it doesn't exist, isn't in
|
||||||
@@ -275,9 +378,21 @@ func (a *App) webmailMessageView(w http.ResponseWriter, r *http.Request) {
|
|||||||
// *WithAccess helpers (mailboxWithAccess etc.): never trust the URL's folder segment
|
// *WithAccess helpers (mailboxWithAccess etc.): never trust the URL's folder segment
|
||||||
// as authorization, always re-check server-side.
|
// as authorization, always re-check server-side.
|
||||||
func (a *App) webmailMessageWithAccess(w http.ResponseWriter, r *http.Request, mailboxID int64, folder string, uid int64) (*db.MailboxMessage, bool) {
|
func (a *App) webmailMessageWithAccess(w http.ResponseWriter, r *http.Request, mailboxID int64, folder string, uid int64) (*db.MailboxMessage, bool) {
|
||||||
|
msg, ok := a.messageAccessible(mailboxID, folder, uid)
|
||||||
|
if !ok {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return msg, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// messageAccessible is webmailMessageWithAccess without the side effect of writing a
|
||||||
|
// 404 response — for webmailBulkAction, where one stale/mismatched uid among a batch
|
||||||
|
// selected from the page's own checkboxes should just be skipped, not abort (and
|
||||||
|
// double-write a response for) the whole request.
|
||||||
|
func (a *App) messageAccessible(mailboxID int64, folder string, uid int64) (*db.MailboxMessage, bool) {
|
||||||
msg, err := a.DB.GetMessageByUID(mailboxID, uid)
|
msg, err := a.DB.GetMessageByUID(mailboxID, uid)
|
||||||
if err != nil || msg == nil || msg.Folder != folder {
|
if err != nil || msg == nil || msg.Folder != folder {
|
||||||
http.NotFound(w, r)
|
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
return msg, true
|
return msg, true
|
||||||
@@ -334,6 +449,72 @@ func (a *App) webmailMessageMove(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Redirect(w, r, MailboxPrefix+"/mail/"+folder, http.StatusFound)
|
http.Redirect(w, r, MailboxPrefix+"/mail/"+folder, http.StatusFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// webmailBulkAction applies one action (delete/move/read/unread) to every uid selected
|
||||||
|
// via the folder view's checkboxes — the Outlook-style toolbar's bulk equivalent of
|
||||||
|
// webmailMessageDelete/webmailMessageMove/the auto-mark-read-on-open behavior, all
|
||||||
|
// through one endpoint rather than four near-identical ones. Every uid is
|
||||||
|
// independently re-checked against this mailbox+folder (webmailMessageWithAccess) —
|
||||||
|
// the folder path segment is never trusted as authorization by itself, same as the
|
||||||
|
// single-message actions.
|
||||||
|
func (a *App) webmailBulkAction(w http.ResponseWriter, r *http.Request) {
|
||||||
|
mbox := mailboxFromContext(r)
|
||||||
|
folder := r.PathValue("folder")
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
setFlash(w, "error", "Invalid form data")
|
||||||
|
http.Redirect(w, r, MailboxPrefix+"/mail/"+folder, http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
action := r.FormValue("action")
|
||||||
|
target := strings.TrimSpace(r.FormValue("target_folder"))
|
||||||
|
if action == "move" && target == "" {
|
||||||
|
setFlash(w, "error", "Choose a folder to move to")
|
||||||
|
http.Redirect(w, r, MailboxPrefix+"/mail/"+folder, http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
n := 0
|
||||||
|
for _, uidStr := range r.Form["uid"] {
|
||||||
|
uid := int64(atoi(uidStr))
|
||||||
|
if uid == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := a.messageAccessible(mbox.ID, folder, uid); !ok {
|
||||||
|
// A mismatched/stale uid here just means stale client state (the page's
|
||||||
|
// own checkboxes) — skip it, don't hard-fail the whole batch over one bad
|
||||||
|
// entry the way the single-message actions correctly do for a URL-level uid.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
switch action {
|
||||||
|
case "delete":
|
||||||
|
if folder == "Trash" {
|
||||||
|
err = a.Mailstore.DeleteMessage(mbox.ID, uid)
|
||||||
|
} else {
|
||||||
|
err = a.DB.MoveMessage(mbox.ID, uid, "Trash")
|
||||||
|
}
|
||||||
|
case "move":
|
||||||
|
err = a.DB.MoveMessage(mbox.ID, uid, target)
|
||||||
|
case "read":
|
||||||
|
err = a.DB.SetMessageFlags(mbox.ID, uid, `\Seen`)
|
||||||
|
case "unread":
|
||||||
|
err = a.DB.SetMessageFlags(mbox.ID, uid, "")
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
a.Logger.Error("bulk %s on message %d for mailbox %d: %v", action, uid, mbox.ID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
if n > 0 {
|
||||||
|
setFlash(w, "success", fmt.Sprintf("%d message(s) updated", n))
|
||||||
|
} else {
|
||||||
|
setFlash(w, "error", "No messages were selected")
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, MailboxPrefix+"/mail/"+folder, http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
// webmailAttachmentDownload re-decrypts and re-parses the whole message on every
|
// webmailAttachmentDownload re-decrypts and re-parses the whole message on every
|
||||||
// download — there's no separate on-disk attachment cache, and message sizes on a
|
// download — there's no separate on-disk attachment cache, and message sizes on a
|
||||||
// self-hosted mail server are small enough that this is simpler than building one.
|
// self-hosted mail server are small enough that this is simpler than building one.
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
package webui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestWebmailMessagePaneMarksReadAndReturnsFragment confirms the Outlook-style reading
|
||||||
|
// pane's fetch endpoint returns a bare fragment (no dashboard chrome) containing the
|
||||||
|
// message body, and marks the message read just like opening the full page does.
|
||||||
|
func TestWebmailMessagePaneMarksReadAndReturnsFragment(t *testing.T) {
|
||||||
|
app := newTestApp(t)
|
||||||
|
mux := app.Mux()
|
||||||
|
domains, _ := app.DB.ListDomains()
|
||||||
|
mailboxID := createTestMailboxWithPassword(t, app, "panetest@example.com", domains[0].ID, "panetest-password-1!")
|
||||||
|
cookie := webmailLoginSession(t, app, mailboxID)
|
||||||
|
|
||||||
|
uid := storeTestMessage(t, app, mailboxID, "INBOX", "a@example.com", "Pane test", "the body of the message")
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX/"+strconv.FormatInt(uid, 10)+"/pane", nil)
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
body := rec.Body.String()
|
||||||
|
if !strings.Contains(body, "the body of the message") {
|
||||||
|
t.Errorf("expected the message body in the fragment, got:\n%s", body)
|
||||||
|
}
|
||||||
|
if strings.Contains(body, "<!DOCTYPE") || strings.Contains(body, "navbar-brand") {
|
||||||
|
t.Errorf("expected a bare fragment with no page chrome, got:\n%s", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
msg, err := app.DB.GetMessageByUID(mailboxID, uid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if isUnread(msg.Flags) {
|
||||||
|
t.Error("expected the message marked read after loading it in the pane")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebmailBulkActionDeleteAndMarkRead confirms the folder view's bulk toolbar
|
||||||
|
// (multi-select checkboxes -> POST .../bulk) can delete-to-Trash and mark-read/unread
|
||||||
|
// several messages in one request.
|
||||||
|
func TestWebmailBulkActionDeleteAndMarkRead(t *testing.T) {
|
||||||
|
app := newTestApp(t)
|
||||||
|
mux := app.Mux()
|
||||||
|
domains, _ := app.DB.ListDomains()
|
||||||
|
mailboxID := createTestMailboxWithPassword(t, app, "bulktest@example.com", domains[0].ID, "bulktest-password-1!")
|
||||||
|
cookie := webmailLoginSession(t, app, mailboxID)
|
||||||
|
|
||||||
|
uid1 := storeTestMessage(t, app, mailboxID, "INBOX", "a@example.com", "One", "body1")
|
||||||
|
uid2 := storeTestMessage(t, app, mailboxID, "INBOX", "b@example.com", "Two", "body2")
|
||||||
|
uid3 := storeTestMessage(t, app, mailboxID, "INBOX", "c@example.com", "Three", "body3")
|
||||||
|
|
||||||
|
post := func(action string, uids ...int64) *httptest.ResponseRecorder {
|
||||||
|
form := url.Values{"action": {action}}
|
||||||
|
for _, u := range uids {
|
||||||
|
form.Add("uid", strconv.FormatInt(u, 10))
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/mail/INBOX/bulk", strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark uid1 and uid2 read in one bulk request.
|
||||||
|
if rec := post("read", uid1, uid2); rec.Code != http.StatusFound {
|
||||||
|
t.Fatalf("bulk read: status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
for _, uid := range []int64{uid1, uid2} {
|
||||||
|
msg, err := app.DB.GetMessageByUID(mailboxID, uid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if isUnread(msg.Flags) {
|
||||||
|
t.Errorf("expected message %d marked read", uid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
msg3, err := app.DB.GetMessageByUID(mailboxID, uid3)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !isUnread(msg3.Flags) {
|
||||||
|
t.Error("expected message 3 (not in the bulk request) to remain unread")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bulk-delete uid1 and uid3 (uid2 stays in INBOX).
|
||||||
|
if rec := post("delete", uid1, uid3); rec.Code != http.StatusFound {
|
||||||
|
t.Fatalf("bulk delete: status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
inbox, err := app.DB.ListMessagesInFolder(mailboxID, "INBOX")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(inbox) != 1 || inbox[0].ID != uid2 {
|
||||||
|
t.Fatalf("expected only message 2 left in INBOX, got %+v", inbox)
|
||||||
|
}
|
||||||
|
trash, err := app.DB.ListMessagesInFolder(mailboxID, "Trash")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(trash) != 2 {
|
||||||
|
t.Fatalf("expected 2 messages in Trash, got %d", len(trash))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebmailBulkActionSkipsUIDFromAnotherFolder confirms a uid that doesn't actually
|
||||||
|
// belong to the requested folder is silently skipped rather than aborting the whole
|
||||||
|
// batch or letting a stale/mismatched selection touch the wrong message.
|
||||||
|
func TestWebmailBulkActionSkipsUIDFromAnotherFolder(t *testing.T) {
|
||||||
|
app := newTestApp(t)
|
||||||
|
mux := app.Mux()
|
||||||
|
domains, _ := app.DB.ListDomains()
|
||||||
|
mailboxID := createTestMailboxWithPassword(t, app, "bulkskiptest@example.com", domains[0].ID, "bulkskiptest-password-1!")
|
||||||
|
cookie := webmailLoginSession(t, app, mailboxID)
|
||||||
|
|
||||||
|
inboxUID := storeTestMessage(t, app, mailboxID, "INBOX", "a@example.com", "In inbox", "body")
|
||||||
|
sentUID := storeTestMessage(t, app, mailboxID, "Sent", "b@example.com", "In sent", "body")
|
||||||
|
|
||||||
|
form := url.Values{"action": {"delete"}, "uid": {strconv.FormatInt(inboxUID, 10), strconv.FormatInt(sentUID, 10)}}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/mail/INBOX/bulk", strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusFound {
|
||||||
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
sent, err := app.DB.ListMessagesInFolder(mailboxID, "Sent")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(sent) != 1 {
|
||||||
|
t.Fatalf("expected the Sent message untouched (wrong folder for this bulk request), got %d left", len(sent))
|
||||||
|
}
|
||||||
|
trash, err := app.DB.ListMessagesInFolder(mailboxID, "Trash")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(trash) != 1 {
|
||||||
|
t.Fatalf("expected the INBOX message moved to Trash, got %d in Trash", len(trash))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -80,16 +80,17 @@ func TestWebmailFolderUnreadBadges(t *testing.T) {
|
|||||||
return rec.Body.String()
|
return rec.Body.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
if !strings.Contains(get(), `folder-unread-badge">1<`) {
|
if !strings.Contains(get(), `folder-unread-badge" title="1 total, 1 unread">1 / <strong>1</strong><`) {
|
||||||
t.Fatalf("expected an unread badge showing 1, got: %s", get())
|
t.Fatalf("expected a badge showing 1 total / 1 unread, got: %s", get())
|
||||||
}
|
}
|
||||||
|
|
||||||
viewReq := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX/"+strconv.FormatInt(uid, 10), nil)
|
viewReq := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX/"+strconv.FormatInt(uid, 10), nil)
|
||||||
viewReq.AddCookie(cookie)
|
viewReq.AddCookie(cookie)
|
||||||
mux.ServeHTTP(httptest.NewRecorder(), viewReq)
|
mux.ServeHTTP(httptest.NewRecorder(), viewReq)
|
||||||
|
|
||||||
if strings.Contains(get(), `folder-unread-badge">1<`) {
|
// Still 1 total message, but no longer unread — the "/ N unread" part should be gone.
|
||||||
t.Fatal("expected the unread badge gone after reading the message")
|
if !strings.Contains(get(), `folder-unread-badge" title="1 total">1<`) {
|
||||||
|
t.Fatalf("expected the badge to show just the total (1) with no unread suffix, got: %s", get())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,6 +102,9 @@ func TestWebmailFolderGroupsConsecutiveSameSubject(t *testing.T) {
|
|||||||
mux := app.Mux()
|
mux := app.Mux()
|
||||||
domains, _ := app.DB.ListDomains()
|
domains, _ := app.DB.ListDomains()
|
||||||
mailboxID := createTestMailboxWithPassword(t, app, "grouper@example.com", domains[0].ID, "grouper-password-1!")
|
mailboxID := createTestMailboxWithPassword(t, app, "grouper@example.com", domains[0].ID, "grouper-password-1!")
|
||||||
|
if err := app.DB.SetMailboxGroupMessages(mailboxID, true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
cookie := webmailLoginSession(t, app, mailboxID)
|
cookie := webmailLoginSession(t, app, mailboxID)
|
||||||
|
|
||||||
storeTestMessage(t, app, mailboxID, "INBOX", "a@example.com", "Project status", "1")
|
storeTestMessage(t, app, mailboxID, "INBOX", "a@example.com", "Project status", "1")
|
||||||
@@ -119,3 +123,211 @@ func TestWebmailFolderGroupsConsecutiveSameSubject(t *testing.T) {
|
|||||||
t.Fatal("expected the older grouped row hidden by default via msg-row-older")
|
t.Fatal("expected the older grouped row hidden by default via msg-row-older")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestWebmailFolderGroupingOffByDefault confirms grouping is off unless a mailbox
|
||||||
|
// owner explicitly enables it via Account > Preferences — same three messages as
|
||||||
|
// TestWebmailFolderGroupsConsecutiveSameSubject, but no toggle call this time.
|
||||||
|
func TestWebmailFolderGroupingOffByDefault(t *testing.T) {
|
||||||
|
app := newTestApp(t)
|
||||||
|
mux := app.Mux()
|
||||||
|
domains, _ := app.DB.ListDomains()
|
||||||
|
mailboxID := createTestMailboxWithPassword(t, app, "nogroup@example.com", domains[0].ID, "nogroup-password-1!")
|
||||||
|
cookie := webmailLoginSession(t, app, mailboxID)
|
||||||
|
|
||||||
|
storeTestMessage(t, app, mailboxID, "INBOX", "a@example.com", "Project status", "1")
|
||||||
|
storeTestMessage(t, app, mailboxID, "INBOX", "b@example.com", "Re: Project status", "2")
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX", nil)
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
body := rec.Body.String()
|
||||||
|
if strings.Contains(body, "+1 more") {
|
||||||
|
t.Fatal("expected no grouping by default")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enabling it via the Account > Preferences form flips the behavior live.
|
||||||
|
prefReq := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/account/preferences", strings.NewReader("group_messages=true"))
|
||||||
|
prefReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
prefReq.AddCookie(cookie)
|
||||||
|
prefRec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(prefRec, prefReq)
|
||||||
|
if prefRec.Code != http.StatusFound {
|
||||||
|
t.Fatalf("preferences save: status=%d body=%s", prefRec.Code, prefRec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
req2 := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX", nil)
|
||||||
|
req2.AddCookie(cookie)
|
||||||
|
rec2 := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec2, req2)
|
||||||
|
if !strings.Contains(rec2.Body.String(), "+1 more") {
|
||||||
|
t.Fatal("expected grouping enabled after saving the preference")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebmailFolderShowsSenderDisplayName confirms the folder list shows just the
|
||||||
|
// display name from a "Name <addr>" cached_from value, not the raw address string,
|
||||||
|
// while keeping the full address available via the row's title attribute.
|
||||||
|
func TestWebmailFolderShowsSenderDisplayName(t *testing.T) {
|
||||||
|
app := newTestApp(t)
|
||||||
|
mux := app.Mux()
|
||||||
|
domains, _ := app.DB.ListDomains()
|
||||||
|
mailboxID := createTestMailboxWithPassword(t, app, "namedisplay@example.com", domains[0].ID, "namedisplay-password-1!")
|
||||||
|
cookie := webmailLoginSession(t, app, mailboxID)
|
||||||
|
|
||||||
|
storeTestMessage(t, app, mailboxID, "INBOX", "Bob Marley <bob@example.com>", "One love", "body")
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX", nil)
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
body := rec.Body.String()
|
||||||
|
|
||||||
|
if !strings.Contains(body, `title="Bob Marley <bob@example.com>"`) {
|
||||||
|
t.Errorf("expected the full address in the title attribute, got:\n%s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, ">Bob Marley<") {
|
||||||
|
t.Errorf("expected just the display name shown in the row, got:\n%s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebmailFolderUnreadOnlyFilter confirms ?unread=1 hides read messages.
|
||||||
|
func TestWebmailFolderUnreadOnlyFilter(t *testing.T) {
|
||||||
|
app := newTestApp(t)
|
||||||
|
mux := app.Mux()
|
||||||
|
domains, _ := app.DB.ListDomains()
|
||||||
|
mailboxID := createTestMailboxWithPassword(t, app, "unreadfilter@example.com", domains[0].ID, "unreadfilter-password-1!")
|
||||||
|
cookie := webmailLoginSession(t, app, mailboxID)
|
||||||
|
|
||||||
|
storeTestMessage(t, app, mailboxID, "INBOX", "a@example.com", "Unread one", "body")
|
||||||
|
readUID := storeTestMessage(t, app, mailboxID, "INBOX", "b@example.com", "Already read", "body")
|
||||||
|
if err := app.DB.SetMessageFlags(mailboxID, readUID, `\Seen`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
get := func(path string) string {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
return rec.Body.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
all := get(MailboxPrefix + "/mail/INBOX")
|
||||||
|
if !strings.Contains(all, "Unread one") || !strings.Contains(all, "Already read") {
|
||||||
|
t.Fatalf("expected both messages without the filter, got:\n%s", all)
|
||||||
|
}
|
||||||
|
|
||||||
|
unreadOnly := get(MailboxPrefix + "/mail/INBOX?unread=1")
|
||||||
|
if !strings.Contains(unreadOnly, "Unread one") {
|
||||||
|
t.Error("expected the unread message still shown")
|
||||||
|
}
|
||||||
|
if strings.Contains(unreadOnly, "Already read") {
|
||||||
|
t.Errorf("expected the read message hidden with ?unread=1, got:\n%s", unreadOnly)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebmailFolderSortByFrom confirms ?sort=from&dir=asc orders the list by sender
|
||||||
|
// instead of the default received-order.
|
||||||
|
func TestWebmailFolderSortByFrom(t *testing.T) {
|
||||||
|
app := newTestApp(t)
|
||||||
|
mux := app.Mux()
|
||||||
|
domains, _ := app.DB.ListDomains()
|
||||||
|
mailboxID := createTestMailboxWithPassword(t, app, "sortfrom@example.com", domains[0].ID, "sortfrom-password-1!")
|
||||||
|
cookie := webmailLoginSession(t, app, mailboxID)
|
||||||
|
|
||||||
|
storeTestMessage(t, app, mailboxID, "INBOX", "zzz@example.com", "From Z", "body")
|
||||||
|
storeTestMessage(t, app, mailboxID, "INBOX", "aaa@example.com", "From A", "body")
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX?sort=from&dir=asc", nil)
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
body := rec.Body.String()
|
||||||
|
|
||||||
|
idxA := strings.Index(body, "From A")
|
||||||
|
idxZ := strings.Index(body, "From Z")
|
||||||
|
if idxA < 0 || idxZ < 0 || idxA > idxZ {
|
||||||
|
t.Fatalf("expected 'From A' (aaa@) before 'From Z' (zzz@) when sorted by sender ascending, got:\n%s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebmailFolderHasCollapsibleSidebarMarkup is a light smoke test for the
|
||||||
|
// collapsible-sidebar feature's markup/JS anchors — the actual show/hide behavior is
|
||||||
|
// client-side (localStorage-backed) and not exercisable from a Go test, but a missing
|
||||||
|
// element ID here would silently break the JS with no visible error.
|
||||||
|
func TestWebmailFolderHasCollapsibleSidebarMarkup(t *testing.T) {
|
||||||
|
app := newTestApp(t)
|
||||||
|
mux := app.Mux()
|
||||||
|
domains, _ := app.DB.ListDomains()
|
||||||
|
mailboxID := createTestMailboxWithPassword(t, app, "sidebartest@example.com", domains[0].ID, "sidebartest-password-1!")
|
||||||
|
cookie := webmailLoginSession(t, app, mailboxID)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX", nil)
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
body := rec.Body.String()
|
||||||
|
|
||||||
|
for _, id := range []string{`id="folderSidebarCol"`, `id="messageListCol"`, `id="sidebarCollapseBtn"`, `id="sidebarShowBtn"`, "webmail_sidebar_collapsed"} {
|
||||||
|
if !strings.Contains(body, id) {
|
||||||
|
t.Errorf("expected %q present in the rendered page", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebmailFolderShowsMessagePreview confirms the folder list shows a short preview
|
||||||
|
// snippet of the message body under the subject.
|
||||||
|
func TestWebmailFolderShowsMessagePreview(t *testing.T) {
|
||||||
|
app := newTestApp(t)
|
||||||
|
mux := app.Mux()
|
||||||
|
domains, _ := app.DB.ListDomains()
|
||||||
|
mailboxID := createTestMailboxWithPassword(t, app, "previewtest@example.com", domains[0].ID, "previewtest-password-1!")
|
||||||
|
cookie := webmailLoginSession(t, app, mailboxID)
|
||||||
|
|
||||||
|
storeTestMessage(t, app, mailboxID, "INBOX", "a@example.com", "Meeting notes", "Here is a summary of what we discussed today in the meeting.")
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX", nil)
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
body := rec.Body.String()
|
||||||
|
|
||||||
|
if !strings.Contains(body, "msg-row2") || !strings.Contains(body, "Here is a summary") {
|
||||||
|
t.Errorf("expected the message preview snippet rendered, got:\n%s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebmailRebuildMessageCache confirms the Account > Preferences "Refresh now"
|
||||||
|
// action re-derives an already-stored message's sender display name from its raw
|
||||||
|
// content, for mail that predates the fix that started caching it.
|
||||||
|
func TestWebmailRebuildMessageCache(t *testing.T) {
|
||||||
|
app := newTestApp(t)
|
||||||
|
mux := app.Mux()
|
||||||
|
domains, _ := app.DB.ListDomains()
|
||||||
|
mailboxID := createTestMailboxWithPassword(t, app, "rebuildtest@example.com", domains[0].ID, "rebuildtest-password-1!")
|
||||||
|
cookie := webmailLoginSession(t, app, mailboxID)
|
||||||
|
|
||||||
|
// Simulate a stale row: the raw content has the display name, but cached_from was
|
||||||
|
// stored as the bare address (what pre-fix code would have passed).
|
||||||
|
raw := "From: Bob Marley <bob@example.com>\r\nTo: rebuildtest@example.com\r\nSubject: One love\r\n\r\nHello there"
|
||||||
|
if _, err := app.Mailstore.StoreMessage(mailboxID, "INBOX", []byte(raw), "<one@example.com>", "bob@example.com", "One love"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/account/rebuild-cache", nil)
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusFound {
|
||||||
|
t.Fatalf("rebuild-cache: status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
folderReq := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX", nil)
|
||||||
|
folderReq.AddCookie(cookie)
|
||||||
|
folderRec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(folderRec, folderReq)
|
||||||
|
if !strings.Contains(folderRec.Body.String(), ">Bob Marley<") {
|
||||||
|
t.Errorf("expected the display name shown after rebuild, got:\n%s", folderRec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+11
-3
@@ -26,7 +26,8 @@ type App struct {
|
|||||||
DB *db.DB
|
DB *db.DB
|
||||||
DKIM *dkim.Manager
|
DKIM *dkim.Manager
|
||||||
Mailstore *mailstore.Store
|
Mailstore *mailstore.Store
|
||||||
ACME *acmecert.Manager
|
ACME *acmecert.Manager // DNS-01
|
||||||
|
ACMEHTTP *acmecert.Manager // HTTP-01
|
||||||
Relay *relay.Relay // used by the webmail client's compose/send (see webmail_compose.go)
|
Relay *relay.Relay // used by the webmail client's compose/send (see webmail_compose.go)
|
||||||
Cfg *ini.File
|
Cfg *ini.File
|
||||||
ConfigPath string
|
ConfigPath string
|
||||||
@@ -59,10 +60,10 @@ type App struct {
|
|||||||
// mailstore's master key is loaded in main.go and threaded in rather than resolved
|
// mailstore's master key is loaded in main.go and threaded in rather than resolved
|
||||||
// internally (both are file paths relative to the app's root working directory,
|
// internally (both are file paths relative to the app's root working directory,
|
||||||
// which this package doesn't otherwise know).
|
// which this package doesn't otherwise know).
|
||||||
func New(database *db.DB, dkimMgr *dkim.Manager, mstore *mailstore.Store, acmeMgr *acmecert.Manager, relayer *relay.Relay, cfg *ini.File, configPath string, logger *toolbox.Logger, smtpUp func() bool, appSecret []byte) (*App, error) {
|
func New(database *db.DB, dkimMgr *dkim.Manager, mstore *mailstore.Store, acmeMgr, acmeHTTPMgr *acmecert.Manager, relayer *relay.Relay, cfg *ini.File, configPath string, logger *toolbox.Logger, smtpUp func() bool, appSecret []byte) (*App, error) {
|
||||||
trustedProxies := parseTrustedProxies(cfg.Section("Server").Key("trusted_proxies").MustString(""), logger)
|
trustedProxies := parseTrustedProxies(cfg.Section("Server").Key("trusted_proxies").MustString(""), logger)
|
||||||
a := &App{
|
a := &App{
|
||||||
DB: database, DKIM: dkimMgr, Mailstore: mstore, ACME: acmeMgr, Relay: relayer, Cfg: cfg, ConfigPath: configPath, Logger: logger, SMTPUp: smtpUp,
|
DB: database, DKIM: dkimMgr, Mailstore: mstore, ACME: acmeMgr, ACMEHTTP: acmeHTTPMgr, Relay: relayer, Cfg: cfg, ConfigPath: configPath, Logger: logger, SMTPUp: smtpUp,
|
||||||
pgpKeys: newPGPKeyCache(), trustedProxies: trustedProxies, loginLimiter: newIPRateLimiter(20, time.Minute), appSecret: appSecret,
|
pgpKeys: newPGPKeyCache(), trustedProxies: trustedProxies, loginLimiter: newIPRateLimiter(20, time.Minute), appSecret: appSecret,
|
||||||
}
|
}
|
||||||
if err := a.loadTemplates(); err != nil {
|
if err := a.loadTemplates(); err != nil {
|
||||||
@@ -139,6 +140,8 @@ func (a *App) Mux() *http.ServeMux {
|
|||||||
webmailMux.HandleFunc("GET "+MailboxPrefix+"/account", a.webmailDashboard)
|
webmailMux.HandleFunc("GET "+MailboxPrefix+"/account", a.webmailDashboard)
|
||||||
webmailMux.HandleFunc("GET "+MailboxPrefix+"/mfa-setup", a.webmailMFASetupRequiredPage)
|
webmailMux.HandleFunc("GET "+MailboxPrefix+"/mfa-setup", a.webmailMFASetupRequiredPage)
|
||||||
webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/password", a.webmailChangePassword)
|
webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/password", a.webmailChangePassword)
|
||||||
|
webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/preferences", a.webmailSetGroupMessages)
|
||||||
|
webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/rebuild-cache", a.webmailRebuildMessageCache)
|
||||||
webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/totp/setup", a.webmailTOTPSetupBegin)
|
webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/totp/setup", a.webmailTOTPSetupBegin)
|
||||||
webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/totp/confirm", a.webmailTOTPSetupConfirm)
|
webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/totp/confirm", a.webmailTOTPSetupConfirm)
|
||||||
webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/totp/disable", a.webmailTOTPDisable)
|
webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/totp/disable", a.webmailTOTPDisable)
|
||||||
@@ -156,8 +159,10 @@ func (a *App) Mux() *http.ServeMux {
|
|||||||
webmailMux.HandleFunc("GET "+MailboxPrefix+"/mail/recipients", a.webmailRecipientSuggest)
|
webmailMux.HandleFunc("GET "+MailboxPrefix+"/mail/recipients", a.webmailRecipientSuggest)
|
||||||
webmailMux.HandleFunc("GET "+MailboxPrefix+"/mail/{folder}", a.webmailFolderView)
|
webmailMux.HandleFunc("GET "+MailboxPrefix+"/mail/{folder}", a.webmailFolderView)
|
||||||
webmailMux.HandleFunc("GET "+MailboxPrefix+"/mail/{folder}/{uid}", a.webmailMessageView)
|
webmailMux.HandleFunc("GET "+MailboxPrefix+"/mail/{folder}/{uid}", a.webmailMessageView)
|
||||||
|
webmailMux.HandleFunc("GET "+MailboxPrefix+"/mail/{folder}/{uid}/pane", a.webmailMessagePane)
|
||||||
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/{folder}/{uid}/delete", a.webmailMessageDelete)
|
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/{folder}/{uid}/delete", a.webmailMessageDelete)
|
||||||
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/{folder}/{uid}/move", a.webmailMessageMove)
|
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/{folder}/{uid}/move", a.webmailMessageMove)
|
||||||
|
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/{folder}/bulk", a.webmailBulkAction)
|
||||||
webmailMux.HandleFunc("GET "+MailboxPrefix+"/mail/{folder}/{uid}/attachment/{idx}", a.webmailAttachmentDownload)
|
webmailMux.HandleFunc("GET "+MailboxPrefix+"/mail/{folder}/{uid}/attachment/{idx}", a.webmailAttachmentDownload)
|
||||||
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/folders/add", a.webmailAddFolder)
|
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/folders/add", a.webmailAddFolder)
|
||||||
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/folders/{name}/remove", a.webmailDeleteFolder)
|
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/folders/{name}/remove", a.webmailDeleteFolder)
|
||||||
@@ -277,7 +282,10 @@ func (a *App) Mux() *http.ServeMux {
|
|||||||
mux.HandleFunc("GET "+Prefix+"/letsencrypt", a.requireGlobalAdmin(a.letsEncryptPage))
|
mux.HandleFunc("GET "+Prefix+"/letsencrypt", a.requireGlobalAdmin(a.letsEncryptPage))
|
||||||
mux.HandleFunc("POST "+Prefix+"/letsencrypt/save", a.requireGlobalAdmin(a.letsEncryptSave))
|
mux.HandleFunc("POST "+Prefix+"/letsencrypt/save", a.requireGlobalAdmin(a.letsEncryptSave))
|
||||||
mux.HandleFunc("POST "+Prefix+"/letsencrypt/obtain", a.requireGlobalAdmin(a.letsEncryptObtainNow))
|
mux.HandleFunc("POST "+Prefix+"/letsencrypt/obtain", a.requireGlobalAdmin(a.letsEncryptObtainNow))
|
||||||
|
mux.HandleFunc("POST "+Prefix+"/letsencrypt/http/save", a.requireGlobalAdmin(a.letsEncryptHTTPSave))
|
||||||
|
mux.HandleFunc("POST "+Prefix+"/letsencrypt/http/obtain", a.requireGlobalAdmin(a.letsEncryptHTTPObtainNow))
|
||||||
mux.HandleFunc("POST "+Prefix+"/api/letsencrypt/upload_gcloud_key", a.requireGlobalAdmin(a.uploadGCloudServiceAccount))
|
mux.HandleFunc("POST "+Prefix+"/api/letsencrypt/upload_gcloud_key", a.requireGlobalAdmin(a.uploadGCloudServiceAccount))
|
||||||
|
mux.HandleFunc("GET "+Prefix+"/api/letsencrypt/detect_ip", a.requireGlobalAdmin(a.detectWANIP))
|
||||||
|
|
||||||
mux.HandleFunc("GET "+Prefix+"/logs", a.logs)
|
mux.HandleFunc("GET "+Prefix+"/logs", a.logs)
|
||||||
|
|
||||||
|
|||||||
@@ -117,6 +117,9 @@ func newTestApp(t *testing.T) *App {
|
|||||||
tlsSec, _ := cfg.NewSection("TLS")
|
tlsSec, _ := cfg.NewSection("TLS")
|
||||||
tlsSec.NewKey("tls_cert_file", "ssl_certs/server.crt")
|
tlsSec.NewKey("tls_cert_file", "ssl_certs/server.crt")
|
||||||
tlsSec.NewKey("tls_key_file", "ssl_certs/server.key")
|
tlsSec.NewKey("tls_key_file", "ssl_certs/server.key")
|
||||||
|
tlsSec.NewKey("smtp_tls_cert", "custom")
|
||||||
|
tlsSec.NewKey("imap_tls_cert", "custom")
|
||||||
|
tlsSec.NewKey("web_https_cert", "custom")
|
||||||
dkimSec, _ := cfg.NewSection("DKIM")
|
dkimSec, _ := cfg.NewSection("DKIM")
|
||||||
dkimSec.NewKey("dkim_key_size", "2048")
|
dkimSec.NewKey("dkim_key_size", "2048")
|
||||||
dkimSec.NewKey("spf_server_ip", "192.168.1.1")
|
dkimSec.NewKey("spf_server_ip", "192.168.1.1")
|
||||||
@@ -129,13 +132,15 @@ func newTestApp(t *testing.T) *App {
|
|||||||
configPath := filepath.Join(dir, "settings.ini")
|
configPath := filepath.Join(dir, "settings.ini")
|
||||||
cfg.SaveTo(configPath)
|
cfg.SaveTo(configPath)
|
||||||
|
|
||||||
acmeMgr := acmecert.New(cfg, filepath.Join(dir, "server.crt"), filepath.Join(dir, "server.key"), filepath.Join(dir, "acme"), nil, toolbox.GetLogger("test"))
|
acmeMgr := acmecert.New(cfg, "LetsEncrypt", "dns-01", filepath.Join(dir, "server.crt"), filepath.Join(dir, "server.key"), filepath.Join(dir, "acme"), nil, toolbox.GetLogger("test"))
|
||||||
|
acmeHTTPMgr := acmecert.New(cfg, "LetsEncryptHTTP", "http-01", filepath.Join(dir, "server.crt"), filepath.Join(dir, "server.key"), filepath.Join(dir, "acme"), nil, toolbox.GetLogger("test"))
|
||||||
relayer := relay.New(database, cfg, toolbox.GetLogger("test"))
|
relayer := relay.New(database, cfg, toolbox.GetLogger("test"))
|
||||||
|
relayer.Mailstore = mstore
|
||||||
appSecret, err := LoadOrCreateAppSecret(filepath.Join(dir, "app_secret.key"))
|
appSecret, err := LoadOrCreateAppSecret(filepath.Join(dir, "app_secret.key"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("LoadOrCreateAppSecret: %v", err)
|
t.Fatalf("LoadOrCreateAppSecret: %v", err)
|
||||||
}
|
}
|
||||||
app, err := New(database, dkimMgr, mstore, acmeMgr, relayer, cfg, configPath, toolbox.GetLogger("test"), func() bool { return true }, appSecret)
|
app, err := New(database, dkimMgr, mstore, acmeMgr, acmeHTTPMgr, relayer, cfg, configPath, toolbox.GetLogger("test"), func() bool { return true }, appSecret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("New: %v", err)
|
t.Fatalf("New: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,25 +109,74 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
mstore := mailstore.New(database, masterKey, mailstoreBase)
|
mstore := mailstore.New(database, masterKey, mailstoreBase)
|
||||||
|
relayer.Mailstore = mstore
|
||||||
|
|
||||||
// Shared by both the SMTP and IMAP implicit-TLS listeners, so a single Reload()
|
// Three independent certificate "slots" — custom (self-signed by default, or your
|
||||||
// call (self-signed regeneration today; a Let's Encrypt renewal later) updates
|
// own uploaded cert/key), letsencrypt_dns, letsencrypt_http — each with its own
|
||||||
// both without restarting the process.
|
// file pair and CertReloader. [TLS]'s smtp_tls_cert/imap_tls_cert/web_https_cert
|
||||||
certFile := absPath(root, cfg.Section("TLS").Key("TLS_CERT_FILE").String())
|
// independently pick which slot each listener uses, e.g. an HTTP-01 cert for mail
|
||||||
keyFile := absPath(root, cfg.Section("TLS").Key("TLS_KEY_FILE").String())
|
// while a DNS-01 cert serves the dashboard. Every slot is seeded with a self-signed
|
||||||
if err := tlsutil.GenerateSelfSignedCert(certFile, keyFile); err != nil {
|
// cert if missing, so a reloader can always be constructed even before any ACME
|
||||||
logger.Error("generate TLS certificate: %v", err)
|
// manager has obtained anything yet.
|
||||||
os.Exit(1)
|
customCertFile := absPath(root, cfg.Section("TLS").Key("TLS_CERT_FILE").String())
|
||||||
|
customKeyFile := absPath(root, cfg.Section("TLS").Key("TLS_KEY_FILE").String())
|
||||||
|
dnsCertFile := absPath(root, "ssl_certs/letsencrypt_dns.crt")
|
||||||
|
dnsKeyFile := absPath(root, "ssl_certs/letsencrypt_dns.key")
|
||||||
|
httpCertFile := absPath(root, "ssl_certs/letsencrypt_http.crt")
|
||||||
|
httpKeyFile := absPath(root, "ssl_certs/letsencrypt_http.key")
|
||||||
|
for _, pair := range [][2]string{
|
||||||
|
{customCertFile, customKeyFile}, {dnsCertFile, dnsKeyFile}, {httpCertFile, httpKeyFile},
|
||||||
|
} {
|
||||||
|
if err := tlsutil.GenerateSelfSignedCert(pair[0], pair[1]); err != nil {
|
||||||
|
logger.Error("generate TLS certificate: %v", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
certReloader, err := tlsutil.NewCertReloader(certFile, keyFile)
|
customReloader, err := tlsutil.NewCertReloader(customCertFile, customKeyFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("load TLS certificate: %v", err)
|
logger.Error("load TLS certificate: %v", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
tlsConfig := tlsutil.NewReloadableTLSConfig(certReloader)
|
dnsReloader, err := tlsutil.NewCertReloader(dnsCertFile, dnsKeyFile)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("load TLS certificate: %v", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
httpReloader, err := tlsutil.NewCertReloader(httpCertFile, httpKeyFile)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("load TLS certificate: %v", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
certSlots := map[string]*tlsutil.CertReloader{
|
||||||
|
"custom": customReloader, "letsencrypt_dns": dnsReloader, "letsencrypt_http": httpReloader,
|
||||||
|
}
|
||||||
|
resolveCertSlot := func(key string) *tlsutil.CertReloader {
|
||||||
|
if r, ok := certSlots[cfg.Section("TLS").Key(key).MustString("custom")]; ok {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
return customReloader
|
||||||
|
}
|
||||||
|
smtpTLSConfig := tlsutil.NewReloadableTLSConfig(resolveCertSlot("smtp_tls_cert"))
|
||||||
|
imapTLSConfig := tlsutil.NewReloadableTLSConfig(resolveCertSlot("imap_tls_cert"))
|
||||||
|
webHTTPSConfig := tlsutil.NewReloadableTLSConfig(resolveCertSlot("web_https_cert"))
|
||||||
|
|
||||||
acmeDataDir := absPath(root, "server_data/acme")
|
acmeDataDir := absPath(root, "server_data/acme")
|
||||||
acmeMgr := acmecert.New(cfg, certFile, keyFile, acmeDataDir, certReloader, toolbox.GetLogger("acme"))
|
acmeMgr := acmecert.New(cfg, "LetsEncrypt", "dns-01", dnsCertFile, dnsKeyFile, acmeDataDir, dnsReloader, toolbox.GetLogger("acme"))
|
||||||
|
acmeHTTPMgr := acmecert.New(cfg, "LetsEncryptHTTP", "http-01", httpCertFile, httpKeyFile, acmeDataDir, httpReloader, toolbox.GetLogger("acme-http"))
|
||||||
|
|
||||||
|
// The HTTP-01 challenge responder is long-lived (unlike lego's own ephemeral
|
||||||
|
// per-obtain listener) so an operator behind NAT/a reverse proxy can verify their
|
||||||
|
// port-forwarding actually reaches this host before/without triggering a real,
|
||||||
|
// rate-limited ACME attempt — curling it should return 200 once this is up.
|
||||||
|
// Started once at boot if enabled; toggling [LetsEncryptHTTP] enabled needs a
|
||||||
|
// restart to start or stop this listener, same as the *_cert routing settings.
|
||||||
|
if cfg.Section("LetsEncryptHTTP").Key("enabled").MustBool(false) {
|
||||||
|
httpChallengeServer := acmecert.NewHTTP01Server()
|
||||||
|
httpPort := cfg.Section("Server").Key("HTTP_LETSENCRYPT_PORT").MustString("80")
|
||||||
|
httpChallengeServer.Start(fmt.Sprintf(":%s", httpPort), toolbox.GetLogger("acme-http"))
|
||||||
|
acmeHTTPMgr.HTTP01Server = httpChallengeServer
|
||||||
|
logger.Info("HTTP-01 challenge responder listening on :%s", httpPort)
|
||||||
|
}
|
||||||
|
|
||||||
backend := &smtpserver.Backend{
|
backend := &smtpserver.Backend{
|
||||||
DB: database, DKIM: dkimMgr, Relay: relayer, Cfg: cfg, Mailstore: mstore,
|
DB: database, DKIM: dkimMgr, Relay: relayer, Cfg: cfg, Mailstore: mstore,
|
||||||
@@ -147,7 +196,7 @@ func main() {
|
|||||||
// setting) — a bare ":port" address binds dual-stack on most systems,
|
// setting) — a bare ":port" address binds dual-stack on most systems,
|
||||||
// which would accept IPv6 connections the Python version never did.
|
// which would accept IPv6 connections the Python version never did.
|
||||||
plainServer := smtpserver.NewPlainServer(backend, fmt.Sprintf("0.0.0.0:%d", smtpPort), banner)
|
plainServer := smtpserver.NewPlainServer(backend, fmt.Sprintf("0.0.0.0:%d", smtpPort), banner)
|
||||||
tlsServer := smtpserver.NewTLSServer(backend, fmt.Sprintf("0.0.0.0:%d", smtpTLSPort), banner, tlsConfig)
|
tlsServer := smtpserver.NewTLSServer(backend, fmt.Sprintf("0.0.0.0:%d", smtpTLSPort), banner, smtpTLSConfig)
|
||||||
|
|
||||||
smtpRunning.Store(true)
|
smtpRunning.Store(true)
|
||||||
logger.Info("Plain SMTP listening on :%d, direct-TLS SMTP listening on :%d", smtpPort, smtpTLSPort)
|
logger.Info("Plain SMTP listening on :%d, direct-TLS SMTP listening on :%d", smtpPort, smtpTLSPort)
|
||||||
@@ -164,7 +213,7 @@ func main() {
|
|||||||
logger.Error("plain SMTP server: %v", err)
|
logger.Error("plain SMTP server: %v", err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
tlsListener, err := tls.Listen("tcp", tlsServer.Addr, tlsConfig)
|
tlsListener, err := tls.Listen("tcp", tlsServer.Addr, smtpTLSConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("TLS SMTP listen: %v", err)
|
logger.Error("TLS SMTP listen: %v", err)
|
||||||
return
|
return
|
||||||
@@ -179,7 +228,7 @@ func main() {
|
|||||||
imapTLSPort := cfg.Section("IMAP").Key("IMAP_TLS_PORT").MustInt(993)
|
imapTLSPort := cfg.Section("IMAP").Key("IMAP_TLS_PORT").MustInt(993)
|
||||||
|
|
||||||
plainServer := imapserver.NewPlainServer(imapBackend)
|
plainServer := imapserver.NewPlainServer(imapBackend)
|
||||||
tlsServer := imapserver.NewTLSServer(imapBackend, tlsConfig)
|
tlsServer := imapserver.NewTLSServer(imapBackend, imapTLSConfig)
|
||||||
|
|
||||||
logger.Info("Plain IMAP listening on :%d, direct-TLS IMAP listening on :%d", imapPort, imapTLSPort)
|
logger.Info("Plain IMAP listening on :%d, direct-TLS IMAP listening on :%d", imapPort, imapTLSPort)
|
||||||
|
|
||||||
@@ -195,7 +244,7 @@ func main() {
|
|||||||
logger.Error("plain IMAP server: %v", err)
|
logger.Error("plain IMAP server: %v", err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
imapTLSListener, err := tls.Listen("tcp", fmt.Sprintf("0.0.0.0:%d", imapTLSPort), tlsConfig)
|
imapTLSListener, err := tls.Listen("tcp", fmt.Sprintf("0.0.0.0:%d", imapTLSPort), imapTLSConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("TLS IMAP listen: %v", err)
|
logger.Error("TLS IMAP listen: %v", err)
|
||||||
return
|
return
|
||||||
@@ -207,25 +256,28 @@ func main() {
|
|||||||
|
|
||||||
// runCertRenewal is the first periodic/background job in this codebase — everything
|
// runCertRenewal is the first periodic/background job in this codebase — everything
|
||||||
// else is purely request-driven. Checks soon after boot (so enabling Let's Encrypt
|
// else is purely request-driven. Checks soon after boot (so enabling Let's Encrypt
|
||||||
// and restarting converges quickly) and every 12h thereafter. The first check each
|
// and restarting converges quickly) and every 12h thereafter. NeedsRenewal is a pure
|
||||||
// process run always attempts ObtainOrRenew regardless of NeedsRenewal's expiry
|
// disk-state check (it reads the cert file itself, including recognizing the
|
||||||
// check, since a fresh self-signed cert has ~1 year left and would otherwise never
|
// self-signed placeholder by its issuer) — deliberately not gated on any in-memory
|
||||||
// get replaced by the very first real certificate.
|
// "has this process attempted yet" flag, so restarting an already-working setup
|
||||||
|
// never triggers a redundant re-obtain of an already-valid real certificate.
|
||||||
runCertRenewal := func() {
|
runCertRenewal := func() {
|
||||||
time.Sleep(1 * time.Minute)
|
time.Sleep(1 * time.Minute)
|
||||||
checkAndRenew := func() {
|
checkAndRenewOne := func(mgr *acmecert.Manager) {
|
||||||
if !cfg.Section("LetsEncrypt").Key("enabled").MustBool(false) {
|
if !mgr.Enabled() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !acmeMgr.Status().LastAttempt.IsZero() {
|
if needs, err := mgr.NeedsRenewal(); err != nil || !needs {
|
||||||
if needs, err := acmeMgr.NeedsRenewal(); err != nil || !needs {
|
return
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if err := acmeMgr.ObtainOrRenew(context.Background()); err != nil {
|
if err := mgr.ObtainOrRenew(context.Background()); err != nil {
|
||||||
logger.Error("ACME obtain/renew: %v", err)
|
logger.Error("ACME obtain/renew (%s): %v", mgr.Section, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
checkAndRenew := func() {
|
||||||
|
checkAndRenewOne(acmeMgr)
|
||||||
|
checkAndRenewOne(acmeHTTPMgr)
|
||||||
|
}
|
||||||
checkAndRenew()
|
checkAndRenew()
|
||||||
ticker := time.NewTicker(12 * time.Hour)
|
ticker := time.NewTicker(12 * time.Hour)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
@@ -248,7 +300,7 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
app, err := webui.New(database, dkimMgr, mstore, acmeMgr, relayer, cfg, configPath, toolbox.GetLogger("web"), smtpRunning.Load, appSecret)
|
app, err := webui.New(database, dkimMgr, mstore, acmeMgr, acmeHTTPMgr, relayer, cfg, configPath, toolbox.GetLogger("web"), smtpRunning.Load, appSecret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("init web UI: %v", err)
|
logger.Error("init web UI: %v", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -280,7 +332,7 @@ func main() {
|
|||||||
|
|
||||||
httpsPort := cfg.Section("Server").Key("WEB_HTTPS_PORT").MustInt(5001)
|
httpsPort := cfg.Section("Server").Key("WEB_HTTPS_PORT").MustInt(5001)
|
||||||
httpsAddr := fmt.Sprintf("%s:%d", *host, httpsPort)
|
httpsAddr := fmt.Sprintf("%s:%d", *host, httpsPort)
|
||||||
httpsServer := &http.Server{Addr: httpsAddr, Handler: handler, TLSConfig: tlsConfig}
|
httpsServer := &http.Server{Addr: httpsAddr, Handler: handler, TLSConfig: webHTTPSConfig}
|
||||||
go func() {
|
go func() {
|
||||||
logger.Info("Web interface (HTTPS) starting at https://%s", httpsAddr)
|
logger.Info("Web interface (HTTPS) starting at https://%s", httpsAddr)
|
||||||
// Empty cert/key paths: TLSConfig.GetCertificate (backed by certReloader) supplies
|
// Empty cert/key paths: TLSConfig.GetCertificate (backed by certReloader) supplies
|
||||||
|
|||||||
Reference in New Issue
Block a user