build-base
This commit is contained in:
@@ -0,0 +1,279 @@
|
||||
# mailgosend
|
||||
|
||||
Self-hosted email server and webmail client in a single Go binary.
|
||||
|
||||
## What it does
|
||||
|
||||
- **SMTP** — receives inbound mail (port 25), accepts authenticated submission (587 STARTTLS, 465 TLS)
|
||||
- **IMAP** — serves mail to desktop clients (143 STARTTLS, 993 TLS)
|
||||
- **Webmail** — full browser client: read, compose, reply, forward, trash, search (port 8080)
|
||||
- **Admin panel** — manage domains, users, queue, IP bans, security events (port 8081, localhost-only by default)
|
||||
- **CalDAV** — calendar sync compatible with Apple Calendar, Thunderbird, DAVx5 (port 8080 at `/caldav/`)
|
||||
- **CardDAV** — contacts sync compatible with Apple Contacts, Thunderbird, DAVx5 (port 8080 at `/carddav/`)
|
||||
- **DKIM** — signs outbound mail, verifies inbound signatures
|
||||
- **SPF / DMARC** — validates inbound mail policy
|
||||
- **Spam filtering** — DNSBL checks + header heuristics with configurable score threshold
|
||||
- **Encryption at rest** — all email bodies, attachments, contacts, and calendar events encrypted AES-256-GCM
|
||||
- **TOTP / MFA** — per-user two-factor authentication with backup recovery codes
|
||||
- **Auto TLS** — ACME (Let's Encrypt) via DNS-01 or HTTP-01; or bring your own certs
|
||||
- **Multi-domain** — serve multiple mail domains from one instance
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- Go 1.26.3+
|
||||
- Linux / macOS (Windows: SMTP port 25 typically blocked)
|
||||
- Ports 25, 587, 465 open inbound for mail reception
|
||||
- A real FQDN with MX, A/AAAA, and SPF DNS records
|
||||
|
||||
---
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
git clone https://ghb.freebede.com/nahakubuilder/mailgosend
|
||||
cd mailgosend
|
||||
go build -o mailgosend ./cmd/mailgosend/
|
||||
```
|
||||
|
||||
Produces a single ~35 MB binary with all web assets embedded.
|
||||
|
||||
---
|
||||
|
||||
## First run
|
||||
|
||||
```bash
|
||||
./mailgosend
|
||||
```
|
||||
|
||||
On first run with no `app_config.conf` present, the binary:
|
||||
|
||||
1. Generates `app_config.conf` with secure random `ENCRYPTION_KEY` and `SESSION_SECRET`
|
||||
2. Creates `./data/mail.db` (SQLite) and applies the schema
|
||||
3. Starts all configured services and prints listening addresses
|
||||
|
||||
**Back up `app_config.conf` immediately** — the encryption key is required to read stored mail. Losing it means losing access to all stored messages, contacts, and calendar data.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
All settings live in `app_config.conf` (INI-style `KEY = VALUE`). The file is auto-generated with commented defaults on first run. The most important settings:
|
||||
|
||||
### Identity
|
||||
|
||||
| Key | Default | Description |
|
||||
|---|---|---|
|
||||
| `HOSTNAME` | `mail.example.com` | Server FQDN — used in SMTP HELO and TLS SNI |
|
||||
| `DEFAULT_DOMAIN` | `example.com` | Primary mail domain |
|
||||
|
||||
### Ports
|
||||
|
||||
| Key | Default | Description |
|
||||
|---|---|---|
|
||||
| `SMTP_PORT` | `25` | Inbound SMTP |
|
||||
| `SUBMIT_PORT` | `587` | SMTP submission (STARTTLS) |
|
||||
| `SMTPS_PORT` | `465` | SMTP submission (implicit TLS) |
|
||||
| `IMAP_PORT` | `143` | IMAP (STARTTLS) |
|
||||
| `IMAPS_PORT` | `993` | IMAP (implicit TLS) |
|
||||
| `WEBCLIENT_PORT` | `8080` | Webmail + CalDAV + CardDAV |
|
||||
| `WEBADMIN_PORT` | `8081` | Admin panel (binds `127.0.0.1` by default) |
|
||||
|
||||
Disable any service by setting its `_ENABLED = false` or `_PORT = 0`.
|
||||
|
||||
### TLS
|
||||
|
||||
| Key | Default | Description |
|
||||
|---|---|---|
|
||||
| `TLS_MODE` | `dns01` | `dns01` \| `http01` \| `file` \| `off` |
|
||||
| `ACME_EMAIL` | _(empty)_ | Required for ACME modes |
|
||||
| `ACME_DNS_PROVIDER` | `cloudflare` | `cloudflare` \| `route53` \| `digitalocean` \| `hetzner` |
|
||||
| `TLS_CERT` / `TLS_KEY` | `./certs/` | Certificate paths for `TLS_MODE=file` |
|
||||
|
||||
For `dns01` (recommended for wildcard certs), set the provider credentials:
|
||||
|
||||
```ini
|
||||
TLS_MODE = dns01
|
||||
ACME_EMAIL = admin@example.com
|
||||
ACME_DNS_PROVIDER = cloudflare
|
||||
CF_DNS_API_TOKEN = your-cloudflare-api-token
|
||||
```
|
||||
|
||||
For manual / existing certs:
|
||||
|
||||
```ini
|
||||
TLS_MODE = file
|
||||
TLS_CERT = /etc/letsencrypt/live/mail.example.com/fullchain.pem
|
||||
TLS_KEY = /etc/letsencrypt/live/mail.example.com/privkey.pem
|
||||
```
|
||||
|
||||
### Storage
|
||||
|
||||
| Key | Default | Description |
|
||||
|---|---|---|
|
||||
| `DB_DRIVER` | `sqlite` | `sqlite` (embedded, no deps) |
|
||||
| `DB_PATH` | `./data/mail.db` | SQLite database file |
|
||||
| `STORAGE_BACKEND` | `db` | `db` (blobs in SQLite) \| `fs` (files on disk) |
|
||||
| `STORAGE_FS_PATH` | `./data/messages` | Directory for `fs` backend |
|
||||
| `MAX_MESSAGE_SIZE` | `52428800` | Max message size in bytes (50 MB) |
|
||||
|
||||
### Security
|
||||
|
||||
| Key | Default | Description |
|
||||
|---|---|---|
|
||||
| `BRUTE_MAX_TRIES` | `5` | Failed attempts before IP ban |
|
||||
| `BRUTE_WINDOW_MIN` | `30` | Rolling window for attempt counting (minutes) |
|
||||
| `BRUTE_BAN_HOURS` | `24` | How long a banned IP stays banned |
|
||||
| `SECURE_COOKIE` | `false` | Set `true` when serving over HTTPS (marks cookies `Secure`) |
|
||||
| `SESSION_MAX_AGE` | `604800` | Session lifetime in seconds (7 days) |
|
||||
|
||||
### Spam
|
||||
|
||||
| Key | Default | Description |
|
||||
|---|---|---|
|
||||
| `SPAM_THRESHOLD` | `10` | Score at which mail is marked spam |
|
||||
| `SPAM_DNSBL` | `zen.spamhaus.org,...` | Comma-separated DNSBL hosts |
|
||||
| `SPAM_CHECK_SPF` | `true` | Validate SPF on inbound |
|
||||
| `SPAM_CHECK_DKIM` | `true` | Validate DKIM on inbound |
|
||||
| `SPAM_CHECK_DMARC` | `true` | Validate DMARC on inbound |
|
||||
|
||||
### Queue / Delivery
|
||||
|
||||
| Key | Default | Description |
|
||||
|---|---|---|
|
||||
| `QUEUE_MAX_AGE_HOURS` | `72` | Hours before undeliverable mail bounces |
|
||||
| `QUEUE_RETRY_MINS` | `5,15,60,240,480` | Retry backoff schedule (minutes) |
|
||||
| `DNS_PRIMARY` | `1.1.1.1` | Primary resolver for MX lookups |
|
||||
|
||||
---
|
||||
|
||||
## DNS records (required)
|
||||
|
||||
Replace `example.com` and `203.0.113.1` with your domain and server IP.
|
||||
|
||||
```
|
||||
; MX
|
||||
example.com. MX 10 mail.example.com.
|
||||
|
||||
; A record for the mail host
|
||||
mail.example.com. A 203.0.113.1
|
||||
|
||||
; SPF — only this server sends mail for example.com
|
||||
example.com. TXT "v=spf1 mx ~all"
|
||||
|
||||
; DMARC
|
||||
_dmarc.example.com. TXT "v=DMARC1; p=quarantine; rua=mailto:postmaster@example.com"
|
||||
|
||||
; PTR (set at your hosting provider — must match HOSTNAME)
|
||||
1.113.0.203.in-addr.arpa. PTR mail.example.com.
|
||||
```
|
||||
|
||||
DKIM keys are generated per-domain from the admin panel. After generating, copy the displayed TXT record into DNS.
|
||||
|
||||
---
|
||||
|
||||
## Initial setup workflow
|
||||
|
||||
1. Start the binary, open the admin panel at `http://127.0.0.1:8081/admin/`
|
||||
2. On first run there is no admin account. Create one:
|
||||
```bash
|
||||
# The binary exposes no separate CLI; create the first admin via the
|
||||
# setup endpoint that appears when zero users exist, or insert directly:
|
||||
./mailgosend --create-admin admin@example.com
|
||||
```
|
||||
*(If no `--create-admin` flag exists yet, use the DB directly or the admin panel bootstrap page.)*
|
||||
3. Add your domain: Admin → Domains → New Domain
|
||||
4. Generate DKIM keys: Domain detail page → Generate DKIM
|
||||
5. Copy the DKIM TXT record into your DNS provider
|
||||
6. Create users: Admin → Users → New User
|
||||
7. Point your mail client at the server (IMAP + SMTP credentials = email + password)
|
||||
|
||||
---
|
||||
|
||||
## Connecting a mail client
|
||||
|
||||
| Setting | Value |
|
||||
|---|---|
|
||||
| IMAP server | `mail.example.com` |
|
||||
| IMAP port | `993` (TLS) or `143` (STARTTLS) |
|
||||
| SMTP server | `mail.example.com` |
|
||||
| SMTP port | `465` (TLS) or `587` (STARTTLS) |
|
||||
| Username | Full email address (`user@example.com`) |
|
||||
| Password | Account password |
|
||||
| Authentication | Normal password |
|
||||
|
||||
---
|
||||
|
||||
## CalDAV / CardDAV
|
||||
|
||||
Clients discover the service via well-known URLs:
|
||||
|
||||
| URL | Service |
|
||||
|---|---|
|
||||
| `https://mail.example.com/.well-known/caldav` | Calendar discovery |
|
||||
| `https://mail.example.com/.well-known/carddav` | Contacts discovery |
|
||||
|
||||
Authentication: HTTP Basic Auth (email + password). Compatible with Apple Calendar, Apple Contacts, Thunderbird (with TbSync), and DAVx5 on Android.
|
||||
|
||||
---
|
||||
|
||||
## Two-factor authentication (TOTP)
|
||||
|
||||
Users enable MFA from the webmail Settings page. The flow:
|
||||
|
||||
1. Settings → Set Up Two-Factor Auth → scan QR code with authenticator app
|
||||
2. Enter the 6-digit code to confirm → MFA enabled
|
||||
3. 10 single-use recovery codes are generated and encrypted; displayed once at enrollment time
|
||||
|
||||
On next login, users enter password then a 6-digit TOTP code (or 8-char recovery code).
|
||||
|
||||
Admin accounts support the same MFA flow via the admin panel login.
|
||||
|
||||
---
|
||||
|
||||
## Reloading TLS certificates
|
||||
|
||||
Send `SIGHUP` to reload certificates without downtime:
|
||||
|
||||
```bash
|
||||
kill -HUP $(pidof mailgosend)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Graceful shutdown
|
||||
|
||||
`SIGTERM` or `SIGINT` (Ctrl+C) triggers graceful shutdown: HTTP servers drain active connections (10-second deadline), queue worker stops, database closes.
|
||||
|
||||
---
|
||||
|
||||
## Security model
|
||||
|
||||
- **Sessions**: raw token in `HttpOnly` cookie; SHA-256 hash stored in DB. Cookie theft without DB access yields nothing.
|
||||
- **Passwords**: bcrypt (cost 12).
|
||||
- **Encryption**: AES-256-GCM with HKDF-derived per-user per-purpose keys. Master key in `app_config.conf`.
|
||||
- **CSRF**: stateless HMAC-SHA256 token bound to session + clock hour. No DB storage.
|
||||
- **Brute force**: failed attempts tracked per IP; configurable lockout threshold and duration.
|
||||
- **Rate limiting**: token-bucket per IP — 60 req/min (webmail), 10 req/min (admin).
|
||||
- **Security headers**: CSP, HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy on all HTTP responses.
|
||||
- **HTML emails**: rendered in `<iframe sandbox="allow-same-origin">` — scripts cannot execute.
|
||||
- **Admin panel**: binds `127.0.0.1` by default; not exposed to the internet.
|
||||
|
||||
---
|
||||
|
||||
## Logging
|
||||
|
||||
Logs go to stdout by default. Set `LOG_FILE = ./logs/mail.log` to write to a file. Rotate with `logrotate` + `SIGHUP`.
|
||||
|
||||
---
|
||||
|
||||
## Upgrading
|
||||
|
||||
Replace the binary and restart. Schema migrations run automatically on startup; they are sequential and non-destructive (only `ADD COLUMN` and `CREATE TABLE IF NOT EXISTS`).
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
See [LICENSE](LICENSE).
|
||||
|
||||
+100
-3
@@ -7,20 +7,27 @@ import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
assets "ghb.freebede.com/nahakubuilder/mailgosend"
|
||||
"ghb.freebede.com/nahakubuilder/mailgosend/internal/auth"
|
||||
"ghb.freebede.com/nahakubuilder/mailgosend/internal/caldav"
|
||||
"ghb.freebede.com/nahakubuilder/mailgosend/internal/carddav"
|
||||
"ghb.freebede.com/nahakubuilder/mailgosend/internal/config"
|
||||
"ghb.freebede.com/nahakubuilder/mailgosend/internal/crypto"
|
||||
"ghb.freebede.com/nahakubuilder/mailgosend/internal/db"
|
||||
appimap "ghb.freebede.com/nahakubuilder/mailgosend/internal/imap"
|
||||
"ghb.freebede.com/nahakubuilder/mailgosend/internal/middleware"
|
||||
appsmtp "ghb.freebede.com/nahakubuilder/mailgosend/internal/smtp"
|
||||
"ghb.freebede.com/nahakubuilder/mailgosend/internal/spam"
|
||||
"ghb.freebede.com/nahakubuilder/mailgosend/internal/storage"
|
||||
apptls "ghb.freebede.com/nahakubuilder/mailgosend/internal/tls"
|
||||
"ghb.freebede.com/nahakubuilder/mailgosend/internal/webadmin"
|
||||
"ghb.freebede.com/nahakubuilder/mailgosend/internal/webclient"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -37,6 +44,11 @@ func run() error {
|
||||
return fmt.Errorf("config: %w", err)
|
||||
}
|
||||
|
||||
// ---- Validate config ----
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return fmt.Errorf("config validation: %w", err)
|
||||
}
|
||||
|
||||
// ---- Crypto ----
|
||||
crypt, err := crypto.New(cfg.EncryptionKey)
|
||||
if err != nil {
|
||||
@@ -60,7 +72,6 @@ func run() error {
|
||||
|
||||
// ---- Auth stores ----
|
||||
sessions := auth.NewSessionStore(database, cfg.SessionMaxAge, cfg.SecureCookie)
|
||||
_ = sessions
|
||||
|
||||
brute := auth.NewBruteGuard(
|
||||
database,
|
||||
@@ -162,6 +173,86 @@ func run() error {
|
||||
queueWorker := appsmtp.NewQueueWorker(smtpDeps)
|
||||
go queueWorker.Run(stopCh)
|
||||
|
||||
// httpServers collects all HTTP servers for graceful shutdown.
|
||||
var httpServers []*http.Server
|
||||
|
||||
// Shared rate limiters: admin login is very strict (10 req/min, burst 3),
|
||||
// webmail general is more relaxed (60 req/min, burst 10).
|
||||
adminRL := middleware.NewRateLimiter(10, 3)
|
||||
clientRL := middleware.NewRateLimiter(60, 10)
|
||||
|
||||
// ---- Web admin panel ----
|
||||
if cfg.WebAdminPort > 0 {
|
||||
adminDeps := &webadmin.Deps{
|
||||
DB: database,
|
||||
Crypt: crypt,
|
||||
Sessions: sessions,
|
||||
Brute: brute,
|
||||
Cfg: cfg,
|
||||
FS: assets.AdminFS(),
|
||||
}
|
||||
adminSrv := webadmin.New(adminDeps)
|
||||
adminHandler := middleware.SecureHeaders(adminRL.Middleware(adminSrv.Handler()))
|
||||
adminHTTP := &http.Server{
|
||||
Addr: fmt.Sprintf("%s:%d", cfg.WebAdminIface, cfg.WebAdminPort),
|
||||
Handler: adminHandler,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 60 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
}
|
||||
httpServers = append(httpServers, adminHTTP)
|
||||
go func() {
|
||||
fmt.Printf("[admin] listening on %s\n", adminHTTP.Addr)
|
||||
if err := adminHTTP.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
errs <- fmt.Errorf("admin http: %w", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// ---- Web client + CalDAV + CardDAV (combined on WebClientPort) ----
|
||||
if cfg.WebClientPort > 0 {
|
||||
clientDeps := &webclient.Deps{
|
||||
DB: database,
|
||||
Crypt: crypt,
|
||||
Sessions: sessions,
|
||||
Brute: brute,
|
||||
Store: store,
|
||||
Cfg: cfg,
|
||||
FS: assets.ClientFS(),
|
||||
}
|
||||
clientSrv := webclient.New(clientDeps)
|
||||
|
||||
davDeps := &caldav.Deps{DB: database, Crypt: crypt}
|
||||
caldavSrv := caldav.New(davDeps)
|
||||
|
||||
cardDeps := &carddav.Deps{DB: database, Crypt: crypt}
|
||||
carddavSrv := carddav.New(cardDeps)
|
||||
|
||||
// Top-level mux: DAV paths take priority; webclient is catch-all.
|
||||
topMux := http.NewServeMux()
|
||||
topMux.Handle("/caldav/", caldavSrv)
|
||||
topMux.Handle("/.well-known/caldav", caldavSrv)
|
||||
topMux.Handle("/carddav/", carddavSrv)
|
||||
topMux.Handle("/.well-known/carddav", carddavSrv)
|
||||
topMux.Handle("/", clientSrv.Handler())
|
||||
|
||||
clientHandler := middleware.SecureHeaders(clientRL.Middleware(topMux))
|
||||
clientHTTP := &http.Server{
|
||||
Addr: fmt.Sprintf("%s:%d", cfg.WebClientIface, cfg.WebClientPort),
|
||||
Handler: clientHandler,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 60 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
}
|
||||
httpServers = append(httpServers, clientHTTP)
|
||||
go func() {
|
||||
fmt.Printf("[webmail+dav] listening on %s\n", clientHTTP.Addr)
|
||||
if err := clientHTTP.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
errs <- fmt.Errorf("webmail http: %w", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Background: purge expired sessions hourly
|
||||
go func() {
|
||||
ticker := time.NewTicker(1 * time.Hour)
|
||||
@@ -221,8 +312,14 @@ func run() error {
|
||||
case syscall.SIGTERM, syscall.SIGINT:
|
||||
fmt.Println("[mailgosend] shutting down...")
|
||||
close(stopCh)
|
||||
// Give services 10s to drain.
|
||||
time.Sleep(10 * time.Second)
|
||||
// Gracefully shut down HTTP servers with a 10-second deadline.
|
||||
shutCtx, shutCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer shutCancel()
|
||||
for _, srv := range httpServers {
|
||||
if err := srv.Shutdown(shutCtx); err != nil {
|
||||
fmt.Printf("[http] shutdown error: %v\n", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,730 @@
|
||||
// Package caldav implements a CalDAV server (RFC 4791 over WebDAV RFC 4918).
|
||||
// Authentication: HTTP Basic Auth against the user DB.
|
||||
// Events are stored as AES-256-GCM encrypted iCalendar blobs.
|
||||
package caldav
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
appCrypto "ghb.freebede.com/nahakubuilder/mailgosend/internal/crypto"
|
||||
"ghb.freebede.com/nahakubuilder/mailgosend/internal/db"
|
||||
"ghb.freebede.com/nahakubuilder/mailgosend/internal/models"
|
||||
)
|
||||
|
||||
const (
|
||||
nsDAV = "DAV:"
|
||||
nsCalDAV = "urn:ietf:params:xml:ns:caldav"
|
||||
nsCS = "http://calendarserver.org/ns/"
|
||||
)
|
||||
|
||||
// Deps holds CalDAV server dependencies.
|
||||
type Deps struct {
|
||||
DB *db.DB
|
||||
Crypt *appCrypto.Crypto
|
||||
}
|
||||
|
||||
// Server is the CalDAV HTTP handler.
|
||||
type Server struct {
|
||||
deps *Deps
|
||||
mux *http.ServeMux
|
||||
}
|
||||
|
||||
// New creates a CalDAV server and registers handlers on the given mux prefix.
|
||||
func New(deps *Deps) *Server {
|
||||
s := &Server{deps: deps, mux: http.NewServeMux()}
|
||||
s.setup()
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
s.mux.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func (s *Server) setup() {
|
||||
// Well-known discovery redirects.
|
||||
s.mux.HandleFunc("/.well-known/caldav", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/caldav/", http.StatusMovedPermanently)
|
||||
})
|
||||
|
||||
// All CalDAV routes use a catch-all; we route internally by path/method.
|
||||
s.mux.HandleFunc("/caldav/", s.withAuth(s.route))
|
||||
}
|
||||
|
||||
// withAuth wraps a handler requiring HTTP Basic Auth.
|
||||
func (s *Server) withAuth(next func(http.ResponseWriter, *http.Request, *models.User)) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := s.authenticate(r)
|
||||
if err != nil || user == nil {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="mailgosend CalDAV", charset="UTF-8"`)
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if !user.Enabled {
|
||||
http.Error(w, "Account disabled", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
next(w, r, user)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) authenticate(r *http.Request) (*models.User, error) {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(authHeader, "Basic ") {
|
||||
return nil, nil
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(authHeader[6:])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("basic auth decode: %w", err)
|
||||
}
|
||||
parts := strings.SplitN(string(decoded), ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return nil, fmt.Errorf("basic auth format")
|
||||
}
|
||||
email := strings.TrimSpace(parts[0])
|
||||
password := parts[1]
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
user, err := s.deps.DB.GetUserByEmail(ctx, email)
|
||||
if err != nil || user == nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := appCrypto.CheckPassword(user.PasswordHash, password); err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// route dispatches CalDAV requests by path structure and HTTP method.
|
||||
// Path patterns under /caldav/:
|
||||
// /caldav/ → root (discovery)
|
||||
// /caldav/p/{userID} → principal
|
||||
// /caldav/{userID}/ → calendar home
|
||||
// /caldav/{userID}/{calID}/ → calendar collection
|
||||
// /caldav/{userID}/{calID}/{uid}.ics → event resource
|
||||
func (s *Server) route(w http.ResponseWriter, r *http.Request, user *models.User) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/caldav")
|
||||
path = strings.TrimSuffix(path, "/")
|
||||
segments := splitPath(path)
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
switch len(segments) {
|
||||
case 0:
|
||||
// /caldav/ — root
|
||||
s.handleOptions(w, r, "caldav")
|
||||
if r.Method == "PROPFIND" {
|
||||
s.propfindRoot(w, r, user)
|
||||
}
|
||||
|
||||
case 1:
|
||||
if segments[0] == "p" || strings.HasPrefix(segments[0], "p") {
|
||||
// /caldav/p — principal without user ID (redirect to user principal)
|
||||
http.Redirect(w, r, fmt.Sprintf("/caldav/p/%d", user.ID), http.StatusMovedPermanently)
|
||||
return
|
||||
}
|
||||
// /caldav/{userID}/ — calendar home
|
||||
ownerID, err := strconv.ParseInt(segments[0], 10, 64)
|
||||
if err != nil || ownerID != user.ID {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case "OPTIONS":
|
||||
s.handleOptions(w, r, "collection")
|
||||
case "PROPFIND":
|
||||
s.propfindHome(w, r, user)
|
||||
case "MKCOL":
|
||||
s.mkcalendarHome(w, r, user)
|
||||
default:
|
||||
w.Header().Set("Allow", davAllow)
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
|
||||
case 2:
|
||||
// /caldav/p/{userID} — principal
|
||||
if segments[0] == "p" {
|
||||
switch r.Method {
|
||||
case "OPTIONS":
|
||||
s.handleOptions(w, r, "principal")
|
||||
case "PROPFIND":
|
||||
s.propfindPrincipal(w, r, user)
|
||||
default:
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
return
|
||||
}
|
||||
// /caldav/{userID}/{calID}/ — calendar collection
|
||||
ownerID, _ := strconv.ParseInt(segments[0], 10, 64)
|
||||
if ownerID != user.ID {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
calID, err := strconv.ParseInt(segments[1], 10, 64)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
cal, err := s.deps.DB.GetCalendarByID(r.Context(), calID)
|
||||
if err != nil || cal == nil || cal.UserID != user.ID {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case "OPTIONS":
|
||||
s.handleOptions(w, r, "calendar")
|
||||
case "PROPFIND":
|
||||
s.propfindCalendar(w, r, user, cal)
|
||||
case "REPORT":
|
||||
s.reportCalendar(w, r, user, cal)
|
||||
case "DELETE":
|
||||
s.deps.DB.DeleteCalendar(r.Context(), calID) //nolint:errcheck
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
w.Header().Set("Allow", davAllow)
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
|
||||
case 3:
|
||||
// /caldav/{userID}/{calID}/{uid}.ics — event resource
|
||||
ownerID, _ := strconv.ParseInt(segments[0], 10, 64)
|
||||
if ownerID != user.ID {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
calID, err := strconv.ParseInt(segments[1], 10, 64)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
cal, err := s.deps.DB.GetCalendarByID(r.Context(), calID)
|
||||
if err != nil || cal == nil || cal.UserID != user.ID {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
uid := strings.TrimSuffix(segments[2], ".ics")
|
||||
switch r.Method {
|
||||
case "GET", "HEAD":
|
||||
s.getEvent(w, r, user, cal, uid)
|
||||
case "PUT":
|
||||
s.putEvent(w, r, user, cal, uid)
|
||||
case "DELETE":
|
||||
s.deleteEvent(w, r, user, cal, uid)
|
||||
case "OPTIONS":
|
||||
s.handleOptions(w, r, "event")
|
||||
default:
|
||||
w.Header().Set("Allow", davAllow)
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
const davAllow = "OPTIONS, GET, PUT, DELETE, PROPFIND, MKCOL, REPORT"
|
||||
|
||||
func (s *Server) handleOptions(w http.ResponseWriter, r *http.Request, resType string) {
|
||||
w.Header().Set("DAV", "1, 2, 3, calendar-access")
|
||||
w.Header().Set("Allow", davAllow)
|
||||
w.Header().Set("Ms-Author-Via", "DAV")
|
||||
if r.Method == "OPTIONS" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- PROPFIND handlers ----
|
||||
|
||||
func (s *Server) propfindRoot(w http.ResponseWriter, r *http.Request, user *models.User) {
|
||||
depth := r.Header.Get("Depth")
|
||||
if depth == "" {
|
||||
depth = "0"
|
||||
}
|
||||
|
||||
responses := []davResponse{
|
||||
{
|
||||
Href: "/caldav/",
|
||||
Props: []davProp{
|
||||
{Name: "resourcetype", NS: nsDAV, Value: "<D:collection/>"},
|
||||
{Name: "displayname", NS: nsDAV, Value: "CalDAV"},
|
||||
{Name: "current-user-principal", NS: nsDAV,
|
||||
Value: fmt.Sprintf("<D:href>/caldav/p/%d</D:href>", user.ID)},
|
||||
},
|
||||
},
|
||||
}
|
||||
writeMultiStatus(w, responses)
|
||||
}
|
||||
|
||||
func (s *Server) propfindPrincipal(w http.ResponseWriter, r *http.Request, user *models.User) {
|
||||
responses := []davResponse{
|
||||
{
|
||||
Href: fmt.Sprintf("/caldav/p/%d", user.ID),
|
||||
Props: []davProp{
|
||||
{Name: "resourcetype", NS: nsDAV, Value: "<D:principal/>"},
|
||||
{Name: "displayname", NS: nsDAV, Value: xmlEscape(user.DisplayName)},
|
||||
{Name: "current-user-principal", NS: nsDAV,
|
||||
Value: fmt.Sprintf("<D:href>/caldav/p/%d</D:href>", user.ID)},
|
||||
{Name: "calendar-home-set", NS: nsCalDAV,
|
||||
Value: fmt.Sprintf("<D:href>/caldav/%d/</D:href>", user.ID)},
|
||||
},
|
||||
},
|
||||
}
|
||||
writeMultiStatus(w, responses)
|
||||
}
|
||||
|
||||
func (s *Server) propfindHome(w http.ResponseWriter, r *http.Request, user *models.User) {
|
||||
depth := r.Header.Get("Depth")
|
||||
|
||||
cals, err := s.deps.DB.ListCalendars(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
log.Printf("[caldav] list calendars: %v", err)
|
||||
}
|
||||
|
||||
responses := []davResponse{
|
||||
{
|
||||
Href: fmt.Sprintf("/caldav/%d/", user.ID),
|
||||
Props: []davProp{
|
||||
{Name: "resourcetype", NS: nsDAV, Value: "<D:collection/>"},
|
||||
{Name: "displayname", NS: nsDAV, Value: "Calendars"},
|
||||
{Name: "current-user-principal", NS: nsDAV,
|
||||
Value: fmt.Sprintf("<D:href>/caldav/p/%d</D:href>", user.ID)},
|
||||
{Name: "calendar-home-set", NS: nsCalDAV,
|
||||
Value: fmt.Sprintf("<D:href>/caldav/%d/</D:href>", user.ID)},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if depth != "0" {
|
||||
for _, cal := range cals {
|
||||
responses = append(responses, calendarResponse(user.ID, cal))
|
||||
}
|
||||
}
|
||||
writeMultiStatus(w, responses)
|
||||
}
|
||||
|
||||
func (s *Server) propfindCalendar(w http.ResponseWriter, r *http.Request, user *models.User, cal *models.Calendar) {
|
||||
depth := r.Header.Get("Depth")
|
||||
|
||||
responses := []davResponse{calendarResponse(user.ID, cal)}
|
||||
|
||||
if depth != "0" {
|
||||
events, err := s.deps.DB.ListCalendarEvents(r.Context(), cal.ID)
|
||||
if err != nil {
|
||||
log.Printf("[caldav] list events: %v", err)
|
||||
}
|
||||
for _, ev := range events {
|
||||
responses = append(responses, eventResponse(user.ID, cal.ID, ev))
|
||||
}
|
||||
}
|
||||
writeMultiStatus(w, responses)
|
||||
}
|
||||
|
||||
func calendarResponse(userID int64, cal *models.Calendar) davResponse {
|
||||
ctag := strconv.FormatInt(cal.SyncToken, 10)
|
||||
syncToken := fmt.Sprintf("https://example.com/ns/sync/%d", cal.SyncToken)
|
||||
return davResponse{
|
||||
Href: fmt.Sprintf("/caldav/%d/%d/", userID, cal.ID),
|
||||
Props: []davProp{
|
||||
{Name: "resourcetype", NS: nsDAV,
|
||||
Value: `<D:collection/><C:calendar xmlns:C="` + nsCalDAV + `"/>`},
|
||||
{Name: "displayname", NS: nsDAV, Value: xmlEscape(cal.Name)},
|
||||
{Name: "calendar-description", NS: nsCalDAV, Value: xmlEscape(cal.Description)},
|
||||
{Name: "calendar-color", NS: "http://apple.com/ns/ical/", Value: xmlEscape(cal.Color)},
|
||||
{Name: "supported-calendar-component-set", NS: nsCalDAV,
|
||||
Value: `<C:comp xmlns:C="` + nsCalDAV + `" name="VEVENT"/><C:comp xmlns:C="` + nsCalDAV + `" name="VTODO"/>`},
|
||||
{Name: "getctag", NS: nsCS, Value: ctag},
|
||||
{Name: "sync-token", NS: nsDAV, Value: xmlEscape(syncToken)},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func eventResponse(userID, calID int64, ev *models.CalendarEvent) davResponse {
|
||||
return davResponse{
|
||||
Href: fmt.Sprintf("/caldav/%d/%d/%s.ics", userID, calID, ev.UID),
|
||||
Props: []davProp{
|
||||
{Name: "resourcetype", NS: nsDAV, Value: ""},
|
||||
{Name: "getetag", NS: nsDAV, Value: `"` + ev.ETag + `"`},
|
||||
{Name: "getcontenttype", NS: nsDAV, Value: "text/calendar; charset=utf-8"},
|
||||
{Name: "getlastmodified", NS: nsDAV, Value: ev.UpdatedAt.UTC().Format(http.TimeFormat)},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ---- REPORT ----
|
||||
|
||||
func (s *Server) reportCalendar(w http.ResponseWriter, r *http.Request, user *models.User, cal *models.Calendar) {
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 256*1024))
|
||||
if err != nil {
|
||||
http.Error(w, "read error", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
XMLName xml.Name `xml:""`
|
||||
SyncToken string `xml:"sync-token"`
|
||||
Hrefs []string `xml:"href"`
|
||||
}
|
||||
_ = xml.Unmarshal(body, &req)
|
||||
|
||||
localName := ""
|
||||
if req.XMLName.Local != "" {
|
||||
localName = req.XMLName.Local
|
||||
}
|
||||
|
||||
switch localName {
|
||||
case "sync-collection":
|
||||
s.syncCollection(w, r, user, cal, req.SyncToken)
|
||||
case "calendar-multiget":
|
||||
s.calendarMultiget(w, r, user, cal, req.Hrefs)
|
||||
default:
|
||||
// calendar-query or unknown: return all events.
|
||||
s.propfindCalendar(w, r, user, cal)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) syncCollection(w http.ResponseWriter, r *http.Request, user *models.User, cal *models.Calendar, clientToken string) {
|
||||
// Minimal sync: if token matches current, return 0 changes; else return all.
|
||||
curToken := fmt.Sprintf("https://example.com/ns/sync/%d", cal.SyncToken)
|
||||
if clientToken == curToken {
|
||||
// No changes since last sync.
|
||||
writeMultiStatus(w, []davResponse{})
|
||||
return
|
||||
}
|
||||
// Full sync: return all events.
|
||||
events, err := s.deps.DB.ListCalendarEvents(r.Context(), cal.ID)
|
||||
if err != nil {
|
||||
log.Printf("[caldav] sync events: %v", err)
|
||||
}
|
||||
var responses []davResponse
|
||||
for _, ev := range events {
|
||||
responses = append(responses, eventResponse(user.ID, cal.ID, ev))
|
||||
}
|
||||
writeMultiStatus(w, responses)
|
||||
}
|
||||
|
||||
func (s *Server) calendarMultiget(w http.ResponseWriter, r *http.Request, user *models.User, cal *models.Calendar, hrefs []string) {
|
||||
var responses []davResponse
|
||||
for _, href := range hrefs {
|
||||
// Extract UID from href like /caldav/{uid}/{calid}/{uid}.ics
|
||||
parts := splitPath(strings.TrimPrefix(href, "/caldav"))
|
||||
if len(parts) < 3 {
|
||||
continue
|
||||
}
|
||||
uid := strings.TrimSuffix(parts[len(parts)-1], ".ics")
|
||||
ev, err := s.deps.DB.GetCalendarEvent(r.Context(), cal.ID, uid)
|
||||
if err != nil || ev == nil {
|
||||
responses = append(responses, davResponse{
|
||||
Href: href,
|
||||
Status: "HTTP/1.1 404 Not Found",
|
||||
})
|
||||
continue
|
||||
}
|
||||
// Return event with ical data.
|
||||
raw, err := s.decryptICal(user.ID, ev.ICalEnc)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
res := eventResponse(user.ID, cal.ID, ev)
|
||||
res.Props = append(res.Props, davProp{
|
||||
Name: "calendar-data", NS: nsCalDAV, Value: xmlEscape(string(raw)), CData: true,
|
||||
})
|
||||
responses = append(responses, res)
|
||||
}
|
||||
writeMultiStatus(w, responses)
|
||||
}
|
||||
|
||||
// ---- Event GET/PUT/DELETE ----
|
||||
|
||||
func (s *Server) getEvent(w http.ResponseWriter, r *http.Request, user *models.User, cal *models.Calendar, uid string) {
|
||||
ev, err := s.deps.DB.GetCalendarEvent(r.Context(), cal.ID, uid)
|
||||
if err != nil || ev == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Conditional GET.
|
||||
if match := r.Header.Get("If-None-Match"); match != "" {
|
||||
if match == `"`+ev.ETag+`"` {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
raw, err := s.decryptICal(user.ID, ev.ICalEnc)
|
||||
if err != nil {
|
||||
http.Error(w, "decrypt error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/calendar; charset=utf-8")
|
||||
w.Header().Set("ETag", `"`+ev.ETag+`"`)
|
||||
w.Header().Set("Last-Modified", ev.UpdatedAt.UTC().Format(http.TimeFormat))
|
||||
if r.Method == "HEAD" {
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(raw)))
|
||||
return
|
||||
}
|
||||
w.Write(raw) //nolint:errcheck
|
||||
}
|
||||
|
||||
func (s *Server) putEvent(w http.ResponseWriter, r *http.Request, user *models.User, cal *models.Calendar, uid string) {
|
||||
if r.ContentLength > 1024*1024 {
|
||||
http.Error(w, "Request too large", http.StatusRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
|
||||
raw, err := io.ReadAll(io.LimitReader(r.Body, 1024*1024))
|
||||
if err != nil || len(raw) == 0 {
|
||||
http.Error(w, "read error", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate it's iCalendar data.
|
||||
rawStr := string(raw)
|
||||
if !strings.Contains(rawStr, "BEGIN:VCALENDAR") {
|
||||
http.Error(w, "Invalid iCalendar data", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Conditional check: If-Match must match existing ETag.
|
||||
existing, err := s.deps.DB.GetCalendarEvent(r.Context(), cal.ID, uid)
|
||||
if err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if ifMatch := r.Header.Get("If-Match"); ifMatch != "" && ifMatch != "*" {
|
||||
if existing == nil || `"`+existing.ETag+`"` != ifMatch {
|
||||
http.Error(w, "Precondition Failed", http.StatusPreconditionFailed)
|
||||
return
|
||||
}
|
||||
}
|
||||
if r.Header.Get("If-None-Match") == "*" && existing != nil {
|
||||
http.Error(w, "Precondition Failed", http.StatusPreconditionFailed)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse minimal fields from iCalendar for DB index.
|
||||
dtStart, dtEnd, summary, recurring := parseICal(rawStr)
|
||||
|
||||
// Encrypt and store.
|
||||
icalEnc, err := s.encryptICal(user.ID, raw)
|
||||
if err != nil {
|
||||
http.Error(w, "encrypt error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
etag := sha256Hex(raw)
|
||||
if err := s.deps.DB.UpsertCalendarEvent(r.Context(), cal.ID, uid, etag, icalEnc, dtStart, dtEnd, summary, recurring); err != nil {
|
||||
log.Printf("[caldav] upsert event: %v", err)
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if _, err := s.deps.DB.BumpCalendarSyncToken(r.Context(), cal.ID); err != nil {
|
||||
log.Printf("[caldav] bump token: %v", err)
|
||||
}
|
||||
|
||||
w.Header().Set("ETag", `"`+etag+`"`)
|
||||
if existing == nil {
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
} else {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) deleteEvent(w http.ResponseWriter, r *http.Request, user *models.User, cal *models.Calendar, uid string) {
|
||||
ev, err := s.deps.DB.GetCalendarEvent(r.Context(), cal.ID, uid)
|
||||
if err != nil || ev == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if ifMatch := r.Header.Get("If-Match"); ifMatch != "" && ifMatch != "*" {
|
||||
if `"`+ev.ETag+`"` != ifMatch {
|
||||
http.Error(w, "Precondition Failed", http.StatusPreconditionFailed)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := s.deps.DB.DeleteCalendarEvent(r.Context(), cal.ID, uid); err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if _, err := s.deps.DB.BumpCalendarSyncToken(r.Context(), cal.ID); err != nil {
|
||||
log.Printf("[caldav] bump token delete: %v", err)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) mkcalendarHome(w http.ResponseWriter, r *http.Request, user *models.User) {
|
||||
// MKCOL on calendar home: ensure default calendar exists.
|
||||
if _, err := s.deps.DB.EnsureDefaultCalendar(r.Context(), user.ID); err != nil {
|
||||
http.Error(w, "error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}
|
||||
|
||||
// ---- Encryption helpers ----
|
||||
|
||||
func (s *Server) encryptICal(userID int64, plain []byte) ([]byte, error) {
|
||||
key, err := s.deps.Crypt.DeriveKey("ical", userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return appCrypto.Encrypt(key, plain)
|
||||
}
|
||||
|
||||
func (s *Server) decryptICal(userID int64, enc []byte) ([]byte, error) {
|
||||
key, err := s.deps.Crypt.DeriveKey("ical", userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return appCrypto.Decrypt(key, enc)
|
||||
}
|
||||
|
||||
// ---- iCalendar parsing (minimal, no third-party lib) ----
|
||||
|
||||
// parseICal extracts DTSTART, DTEND, SUMMARY, RRULE presence from raw iCal text.
|
||||
func parseICal(raw string) (dtStart, dtEnd time.Time, summary string, recurring bool) {
|
||||
for _, line := range strings.Split(raw, "\n") {
|
||||
line = strings.TrimRight(line, "\r")
|
||||
k, v, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// Strip parameters from key (e.g., DTSTART;TZID=UTC → DTSTART)
|
||||
k = strings.SplitN(k, ";", 2)[0]
|
||||
switch strings.ToUpper(k) {
|
||||
case "DTSTART":
|
||||
dtStart = parseICalTime(v)
|
||||
case "DTEND":
|
||||
dtEnd = parseICalTime(v)
|
||||
case "SUMMARY":
|
||||
summary = icalUnfold(v)
|
||||
case "RRULE":
|
||||
recurring = true
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func parseICalTime(s string) time.Time {
|
||||
s = strings.TrimSuffix(s, "Z")
|
||||
formats := []string{"20060102T150405", "20060102"}
|
||||
for _, f := range formats {
|
||||
if t, err := time.Parse(f, s); err == nil {
|
||||
return t.UTC()
|
||||
}
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
func icalUnfold(s string) string {
|
||||
return strings.ReplaceAll(s, `\n`, "\n")
|
||||
}
|
||||
|
||||
// ---- XML multi-status response ----
|
||||
|
||||
type davProp struct {
|
||||
Name string
|
||||
NS string
|
||||
Value string
|
||||
CData bool // wrap Value in CDATA
|
||||
}
|
||||
|
||||
type davResponse struct {
|
||||
Href string
|
||||
Status string // if empty, use 200 OK
|
||||
Props []davProp
|
||||
}
|
||||
|
||||
func writeMultiStatus(w http.ResponseWriter, responses []davResponse) {
|
||||
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
|
||||
w.WriteHeader(http.StatusMultiStatus)
|
||||
|
||||
fmt.Fprintf(w, `<?xml version="1.0" encoding="UTF-8"?>`+"\n")
|
||||
fmt.Fprintf(w, `<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/" xmlns:ICAL="http://apple.com/ns/ical/">`+"\n")
|
||||
|
||||
for _, resp := range responses {
|
||||
fmt.Fprintf(w, " <D:response>\n")
|
||||
fmt.Fprintf(w, " <D:href>%s</D:href>\n", xmlEscape(resp.Href))
|
||||
|
||||
if resp.Status != "" {
|
||||
fmt.Fprintf(w, " <D:status>%s</D:status>\n", xmlEscape(resp.Status))
|
||||
} else if len(resp.Props) > 0 {
|
||||
fmt.Fprintf(w, " <D:propstat>\n <D:prop>\n")
|
||||
for _, p := range resp.Props {
|
||||
writeDAVProp(w, p)
|
||||
}
|
||||
fmt.Fprintf(w, " </D:prop>\n <D:status>HTTP/1.1 200 OK</D:status>\n </D:propstat>\n")
|
||||
} else {
|
||||
fmt.Fprintf(w, " <D:status>HTTP/1.1 200 OK</D:status>\n")
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, " </D:response>\n")
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "</D:multistatus>\n")
|
||||
}
|
||||
|
||||
func writeDAVProp(w http.ResponseWriter, p davProp) {
|
||||
ns := ""
|
||||
switch p.NS {
|
||||
case nsDAV:
|
||||
ns = "D"
|
||||
case nsCalDAV:
|
||||
ns = "C"
|
||||
case nsCS:
|
||||
ns = "CS"
|
||||
case "http://apple.com/ns/ical/":
|
||||
ns = "ICAL"
|
||||
default:
|
||||
ns = "D"
|
||||
}
|
||||
|
||||
if p.Value == "" {
|
||||
fmt.Fprintf(w, " <%s:%s/>\n", ns, p.Name)
|
||||
return
|
||||
}
|
||||
if p.CData {
|
||||
fmt.Fprintf(w, " <%s:%s><![CDATA[%s]]></%s:%s>\n", ns, p.Name, p.Value, ns, p.Name)
|
||||
} else {
|
||||
fmt.Fprintf(w, " <%s:%s>%s</%s:%s>\n", ns, p.Name, p.Value, ns, p.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
func splitPath(path string) []string {
|
||||
var out []string
|
||||
for _, s := range strings.Split(strings.Trim(path, "/"), "/") {
|
||||
if s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func xmlEscape(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
s = strings.ReplaceAll(s, `"`, """)
|
||||
return s
|
||||
}
|
||||
|
||||
func sha256Hex(data []byte) string {
|
||||
h := sha256.Sum256(data)
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
@@ -0,0 +1,614 @@
|
||||
// Package carddav implements a CardDAV server (RFC 6352 over WebDAV RFC 4918).
|
||||
// Authentication: HTTP Basic Auth against the user DB.
|
||||
// Contacts are stored as AES-256-GCM encrypted vCard blobs.
|
||||
package carddav
|
||||
|
||||
import (
|
||||
"context"
|
||||
gocrypto "crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
appCrypto "ghb.freebede.com/nahakubuilder/mailgosend/internal/crypto"
|
||||
"ghb.freebede.com/nahakubuilder/mailgosend/internal/db"
|
||||
"ghb.freebede.com/nahakubuilder/mailgosend/internal/models"
|
||||
)
|
||||
|
||||
const (
|
||||
nsDAV = "DAV:"
|
||||
nsCardDAV = "urn:ietf:params:xml:ns:carddav"
|
||||
nsCS = "http://calendarserver.org/ns/"
|
||||
)
|
||||
|
||||
// Deps holds CardDAV server dependencies.
|
||||
type Deps struct {
|
||||
DB *db.DB
|
||||
Crypt *appCrypto.Crypto
|
||||
}
|
||||
|
||||
// Server is the CardDAV HTTP handler.
|
||||
type Server struct {
|
||||
deps *Deps
|
||||
mux *http.ServeMux
|
||||
}
|
||||
|
||||
// New creates a CardDAV server.
|
||||
func New(deps *Deps) *Server {
|
||||
s := &Server{deps: deps, mux: http.NewServeMux()}
|
||||
s.setup()
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
s.mux.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func (s *Server) setup() {
|
||||
s.mux.HandleFunc("/.well-known/carddav", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/carddav/", http.StatusMovedPermanently)
|
||||
})
|
||||
s.mux.HandleFunc("/carddav/", s.withAuth(s.route))
|
||||
}
|
||||
|
||||
func (s *Server) withAuth(next func(http.ResponseWriter, *http.Request, *models.User)) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := s.authenticate(r)
|
||||
if err != nil || user == nil {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="mailgosend CardDAV", charset="UTF-8"`)
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if !user.Enabled {
|
||||
http.Error(w, "Account disabled", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
next(w, r, user)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) authenticate(r *http.Request) (*models.User, error) {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(authHeader, "Basic ") {
|
||||
return nil, nil
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(authHeader[6:])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("basic auth decode: %w", err)
|
||||
}
|
||||
parts := strings.SplitN(string(decoded), ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return nil, fmt.Errorf("basic auth format")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
user, err := s.deps.DB.GetUserByEmail(ctx, strings.TrimSpace(parts[0]))
|
||||
if err != nil || user == nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := appCrypto.CheckPassword(user.PasswordHash, parts[1]); err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// route dispatches CardDAV requests.
|
||||
// Paths under /carddav/:
|
||||
// /carddav/ → root
|
||||
// /carddav/p/{userID} → principal
|
||||
// /carddav/{userID}/ → address book home
|
||||
// /carddav/{userID}/{abID}/ → address book collection
|
||||
// /carddav/{userID}/{abID}/{uid}.vcf → contact resource
|
||||
func (s *Server) route(w http.ResponseWriter, r *http.Request, user *models.User) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/carddav")
|
||||
path = strings.TrimSuffix(path, "/")
|
||||
segments := splitPath(path)
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
const allow = "OPTIONS, GET, PUT, DELETE, PROPFIND, MKCOL, REPORT"
|
||||
|
||||
switch len(segments) {
|
||||
case 0:
|
||||
s.setDavHeaders(w)
|
||||
if r.Method == "OPTIONS" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
if r.Method == "PROPFIND" {
|
||||
s.propfindRoot(w, r, user)
|
||||
return
|
||||
}
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
|
||||
case 1:
|
||||
if segments[0] == "p" {
|
||||
http.Redirect(w, r, fmt.Sprintf("/carddav/p/%d", user.ID), http.StatusMovedPermanently)
|
||||
return
|
||||
}
|
||||
ownerID, err := strconv.ParseInt(segments[0], 10, 64)
|
||||
if err != nil || ownerID != user.ID {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
s.setDavHeaders(w)
|
||||
switch r.Method {
|
||||
case "OPTIONS":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
case "PROPFIND":
|
||||
s.propfindHome(w, r, user)
|
||||
default:
|
||||
w.Header().Set("Allow", allow)
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
|
||||
case 2:
|
||||
if segments[0] == "p" {
|
||||
s.setDavHeaders(w)
|
||||
if r.Method == "PROPFIND" {
|
||||
s.propfindPrincipal(w, r, user)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
ownerID, _ := strconv.ParseInt(segments[0], 10, 64)
|
||||
if ownerID != user.ID {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
abID, err := strconv.ParseInt(segments[1], 10, 64)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
ab, err := s.deps.DB.GetAddressBookByID(r.Context(), abID)
|
||||
if err != nil || ab == nil || ab.UserID != user.ID {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
s.setDavHeaders(w)
|
||||
switch r.Method {
|
||||
case "OPTIONS":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
case "PROPFIND":
|
||||
s.propfindAddressBook(w, r, user, ab)
|
||||
case "REPORT":
|
||||
s.reportAddressBook(w, r, user, ab)
|
||||
case "DELETE":
|
||||
s.deps.DB.DeleteAddressBook(r.Context(), abID) //nolint:errcheck
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
w.Header().Set("Allow", allow)
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
|
||||
case 3:
|
||||
ownerID, _ := strconv.ParseInt(segments[0], 10, 64)
|
||||
if ownerID != user.ID {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
abID, err := strconv.ParseInt(segments[1], 10, 64)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
ab, err := s.deps.DB.GetAddressBookByID(r.Context(), abID)
|
||||
if err != nil || ab == nil || ab.UserID != user.ID {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
uid := strings.TrimSuffix(segments[2], ".vcf")
|
||||
switch r.Method {
|
||||
case "GET", "HEAD":
|
||||
s.getContact(w, r, user, ab, uid)
|
||||
case "PUT":
|
||||
s.putContact(w, r, user, ab, uid)
|
||||
case "DELETE":
|
||||
s.deleteContact(w, r, user, ab, uid)
|
||||
case "OPTIONS":
|
||||
s.setDavHeaders(w)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
default:
|
||||
w.Header().Set("Allow", allow)
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) setDavHeaders(w http.ResponseWriter) {
|
||||
w.Header().Set("DAV", "1, 2, 3, addressbook")
|
||||
w.Header().Set("Allow", "OPTIONS, GET, PUT, DELETE, PROPFIND, MKCOL, REPORT")
|
||||
w.Header().Set("Ms-Author-Via", "DAV")
|
||||
}
|
||||
|
||||
// ---- PROPFIND handlers ----
|
||||
|
||||
func (s *Server) propfindRoot(w http.ResponseWriter, r *http.Request, user *models.User) {
|
||||
writeMultiStatus(w, []davResponse{
|
||||
{
|
||||
Href: "/carddav/",
|
||||
Props: []davProp{
|
||||
{Name: "resourcetype", NS: nsDAV, Value: "<D:collection/>"},
|
||||
{Name: "displayname", NS: nsDAV, Value: "CardDAV"},
|
||||
{Name: "current-user-principal", NS: nsDAV,
|
||||
Value: fmt.Sprintf("<D:href>/carddav/p/%d</D:href>", user.ID)},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) propfindPrincipal(w http.ResponseWriter, r *http.Request, user *models.User) {
|
||||
writeMultiStatus(w, []davResponse{
|
||||
{
|
||||
Href: fmt.Sprintf("/carddav/p/%d", user.ID),
|
||||
Props: []davProp{
|
||||
{Name: "resourcetype", NS: nsDAV, Value: "<D:principal/>"},
|
||||
{Name: "displayname", NS: nsDAV, Value: xmlEscape(user.DisplayName)},
|
||||
{Name: "current-user-principal", NS: nsDAV,
|
||||
Value: fmt.Sprintf("<D:href>/carddav/p/%d</D:href>", user.ID)},
|
||||
{Name: "addressbook-home-set", NS: nsCardDAV,
|
||||
Value: fmt.Sprintf("<D:href>/carddav/%d/</D:href>", user.ID)},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) propfindHome(w http.ResponseWriter, r *http.Request, user *models.User) {
|
||||
depth := r.Header.Get("Depth")
|
||||
abs, err := s.deps.DB.ListAddressBooks(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
log.Printf("[carddav] list address books: %v", err)
|
||||
}
|
||||
|
||||
responses := []davResponse{
|
||||
{
|
||||
Href: fmt.Sprintf("/carddav/%d/", user.ID),
|
||||
Props: []davProp{
|
||||
{Name: "resourcetype", NS: nsDAV, Value: "<D:collection/>"},
|
||||
{Name: "displayname", NS: nsDAV, Value: "Address Books"},
|
||||
{Name: "addressbook-home-set", NS: nsCardDAV,
|
||||
Value: fmt.Sprintf("<D:href>/carddav/%d/</D:href>", user.ID)},
|
||||
},
|
||||
},
|
||||
}
|
||||
if depth != "0" {
|
||||
for _, ab := range abs {
|
||||
responses = append(responses, addressBookResponse(user.ID, ab))
|
||||
}
|
||||
}
|
||||
writeMultiStatus(w, responses)
|
||||
}
|
||||
|
||||
func (s *Server) propfindAddressBook(w http.ResponseWriter, r *http.Request, user *models.User, ab *models.AddressBook) {
|
||||
depth := r.Header.Get("Depth")
|
||||
responses := []davResponse{addressBookResponse(user.ID, ab)}
|
||||
if depth != "0" {
|
||||
contacts, err := s.deps.DB.ListContacts(r.Context(), ab.ID)
|
||||
if err != nil {
|
||||
log.Printf("[carddav] list contacts: %v", err)
|
||||
}
|
||||
for _, c := range contacts {
|
||||
responses = append(responses, contactResponse(user.ID, ab.ID, c))
|
||||
}
|
||||
}
|
||||
writeMultiStatus(w, responses)
|
||||
}
|
||||
|
||||
func addressBookResponse(userID int64, ab *models.AddressBook) davResponse {
|
||||
ctag := strconv.FormatInt(ab.SyncToken, 10)
|
||||
return davResponse{
|
||||
Href: fmt.Sprintf("/carddav/%d/%d/", userID, ab.ID),
|
||||
Props: []davProp{
|
||||
{Name: "resourcetype", NS: nsDAV,
|
||||
Value: `<D:collection/><CARD:addressbook xmlns:CARD="` + nsCardDAV + `"/>`},
|
||||
{Name: "displayname", NS: nsDAV, Value: xmlEscape(ab.Name)},
|
||||
{Name: "addressbook-description", NS: nsCardDAV, Value: xmlEscape(ab.Description)},
|
||||
{Name: "getctag", NS: nsCS, Value: ctag},
|
||||
{Name: "sync-token", NS: nsDAV,
|
||||
Value: fmt.Sprintf("https://example.com/ns/sync/%d", ab.SyncToken)},
|
||||
{Name: "supported-address-data", NS: nsCardDAV,
|
||||
Value: `<CARD:address-data-type xmlns:CARD="` + nsCardDAV + `" content-type="text/vcard" version="3.0"/>`},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func contactResponse(userID, abID int64, c *models.Contact) davResponse {
|
||||
return davResponse{
|
||||
Href: fmt.Sprintf("/carddav/%d/%d/%s.vcf", userID, abID, c.UID),
|
||||
Props: []davProp{
|
||||
{Name: "resourcetype", NS: nsDAV, Value: ""},
|
||||
{Name: "getetag", NS: nsDAV, Value: `"` + c.ETag + `"`},
|
||||
{Name: "getcontenttype", NS: nsDAV, Value: "text/vcard; charset=utf-8"},
|
||||
{Name: "getlastmodified", NS: nsDAV, Value: c.UpdatedAt.UTC().Format(http.TimeFormat)},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ---- REPORT ----
|
||||
|
||||
func (s *Server) reportAddressBook(w http.ResponseWriter, r *http.Request, user *models.User, ab *models.AddressBook) {
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 256*1024))
|
||||
if err != nil {
|
||||
http.Error(w, "read error", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
XMLName xml.Name `xml:""`
|
||||
SyncToken string `xml:"sync-token"`
|
||||
Hrefs []string `xml:"href"`
|
||||
}
|
||||
_ = xml.Unmarshal(body, &req)
|
||||
|
||||
switch req.XMLName.Local {
|
||||
case "sync-collection":
|
||||
curToken := fmt.Sprintf("https://example.com/ns/sync/%d", ab.SyncToken)
|
||||
if req.SyncToken == curToken {
|
||||
writeMultiStatus(w, []davResponse{})
|
||||
return
|
||||
}
|
||||
contacts, err := s.deps.DB.ListContacts(r.Context(), ab.ID)
|
||||
if err != nil {
|
||||
log.Printf("[carddav] sync contacts: %v", err)
|
||||
}
|
||||
var responses []davResponse
|
||||
for _, c := range contacts {
|
||||
responses = append(responses, contactResponse(user.ID, ab.ID, c))
|
||||
}
|
||||
writeMultiStatus(w, responses)
|
||||
|
||||
case "addressbook-multiget":
|
||||
var responses []davResponse
|
||||
for _, href := range req.Hrefs {
|
||||
parts := splitPath(strings.TrimPrefix(href, "/carddav"))
|
||||
if len(parts) < 3 {
|
||||
continue
|
||||
}
|
||||
uid := strings.TrimSuffix(parts[len(parts)-1], ".vcf")
|
||||
c, err := s.deps.DB.GetContact(r.Context(), ab.ID, uid)
|
||||
if err != nil || c == nil {
|
||||
responses = append(responses, davResponse{
|
||||
Href: href,
|
||||
Status: "HTTP/1.1 404 Not Found",
|
||||
})
|
||||
continue
|
||||
}
|
||||
raw, err := s.decryptVCard(user.ID, c.VCardEnc)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
res := contactResponse(user.ID, ab.ID, c)
|
||||
res.Props = append(res.Props, davProp{
|
||||
Name: "address-data",
|
||||
NS: nsCardDAV,
|
||||
Value: xmlEscape(string(raw)),
|
||||
CData: true,
|
||||
})
|
||||
responses = append(responses, res)
|
||||
}
|
||||
writeMultiStatus(w, responses)
|
||||
|
||||
default:
|
||||
s.propfindAddressBook(w, r, user, ab)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Contact GET/PUT/DELETE ----
|
||||
|
||||
func (s *Server) getContact(w http.ResponseWriter, r *http.Request, user *models.User, ab *models.AddressBook, uid string) {
|
||||
c, err := s.deps.DB.GetContact(r.Context(), ab.ID, uid)
|
||||
if err != nil || c == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if match := r.Header.Get("If-None-Match"); match == `"`+c.ETag+`"` {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
|
||||
raw, err := s.decryptVCard(user.ID, c.VCardEnc)
|
||||
if err != nil {
|
||||
http.Error(w, "decrypt error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/vcard; charset=utf-8")
|
||||
w.Header().Set("ETag", `"`+c.ETag+`"`)
|
||||
w.Header().Set("Last-Modified", c.UpdatedAt.UTC().Format(http.TimeFormat))
|
||||
if r.Method == "HEAD" {
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(raw)))
|
||||
return
|
||||
}
|
||||
w.Write(raw) //nolint:errcheck
|
||||
}
|
||||
|
||||
func (s *Server) putContact(w http.ResponseWriter, r *http.Request, user *models.User, ab *models.AddressBook, uid string) {
|
||||
if r.ContentLength > 512*1024 {
|
||||
http.Error(w, "Request too large", http.StatusRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(r.Body, 512*1024))
|
||||
if err != nil || len(raw) == 0 {
|
||||
http.Error(w, "read error", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !strings.Contains(string(raw), "BEGIN:VCARD") {
|
||||
http.Error(w, "Invalid vCard data", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
existing, err := s.deps.DB.GetContact(r.Context(), ab.ID, uid)
|
||||
if err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if ifMatch := r.Header.Get("If-Match"); ifMatch != "" && ifMatch != "*" {
|
||||
if existing == nil || `"`+existing.ETag+`"` != ifMatch {
|
||||
http.Error(w, "Precondition Failed", http.StatusPreconditionFailed)
|
||||
return
|
||||
}
|
||||
}
|
||||
if r.Header.Get("If-None-Match") == "*" && existing != nil {
|
||||
http.Error(w, "Precondition Failed", http.StatusPreconditionFailed)
|
||||
return
|
||||
}
|
||||
|
||||
vcardEnc, err := s.encryptVCard(user.ID, raw)
|
||||
if err != nil {
|
||||
http.Error(w, "encrypt error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
etag := sha256Hex(raw)
|
||||
if err := s.deps.DB.UpsertContact(r.Context(), ab.ID, uid, etag, vcardEnc); err != nil {
|
||||
log.Printf("[carddav] upsert contact: %v", err)
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if _, err := s.deps.DB.BumpAddressBookSyncToken(r.Context(), ab.ID); err != nil {
|
||||
log.Printf("[carddav] bump token: %v", err)
|
||||
}
|
||||
w.Header().Set("ETag", `"`+etag+`"`)
|
||||
if existing == nil {
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
} else {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) deleteContact(w http.ResponseWriter, r *http.Request, user *models.User, ab *models.AddressBook, uid string) {
|
||||
c, err := s.deps.DB.GetContact(r.Context(), ab.ID, uid)
|
||||
if err != nil || c == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if ifMatch := r.Header.Get("If-Match"); ifMatch != "" && ifMatch != "*" {
|
||||
if `"`+c.ETag+`"` != ifMatch {
|
||||
http.Error(w, "Precondition Failed", http.StatusPreconditionFailed)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := s.deps.DB.DeleteContact(r.Context(), ab.ID, uid); err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if _, err := s.deps.DB.BumpAddressBookSyncToken(r.Context(), ab.ID); err != nil {
|
||||
log.Printf("[carddav] bump token delete: %v", err)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ---- Encryption ----
|
||||
|
||||
func (s *Server) encryptVCard(userID int64, plain []byte) ([]byte, error) {
|
||||
key, err := s.deps.Crypt.DeriveKey("vcard", userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return appCrypto.Encrypt(key, plain)
|
||||
}
|
||||
|
||||
func (s *Server) decryptVCard(userID int64, enc []byte) ([]byte, error) {
|
||||
key, err := s.deps.Crypt.DeriveKey("vcard", userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return appCrypto.Decrypt(key, enc)
|
||||
}
|
||||
|
||||
// ---- XML helpers (shared pattern with caldav) ----
|
||||
|
||||
type davProp struct {
|
||||
Name string
|
||||
NS string
|
||||
Value string
|
||||
CData bool
|
||||
}
|
||||
|
||||
type davResponse struct {
|
||||
Href string
|
||||
Status string
|
||||
Props []davProp
|
||||
}
|
||||
|
||||
func writeMultiStatus(w http.ResponseWriter, responses []davResponse) {
|
||||
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
|
||||
w.WriteHeader(http.StatusMultiStatus)
|
||||
|
||||
fmt.Fprintf(w, `<?xml version="1.0" encoding="UTF-8"?>`+"\n")
|
||||
fmt.Fprintf(w, `<D:multistatus xmlns:D="DAV:" xmlns:CARD="urn:ietf:params:xml:ns:carddav" xmlns:CS="http://calendarserver.org/ns/">`+"\n")
|
||||
|
||||
for _, resp := range responses {
|
||||
fmt.Fprintf(w, " <D:response>\n")
|
||||
fmt.Fprintf(w, " <D:href>%s</D:href>\n", xmlEscape(resp.Href))
|
||||
if resp.Status != "" {
|
||||
fmt.Fprintf(w, " <D:status>%s</D:status>\n", xmlEscape(resp.Status))
|
||||
} else if len(resp.Props) > 0 {
|
||||
fmt.Fprintf(w, " <D:propstat>\n <D:prop>\n")
|
||||
for _, p := range resp.Props {
|
||||
writeDAVProp(w, p)
|
||||
}
|
||||
fmt.Fprintf(w, " </D:prop>\n <D:status>HTTP/1.1 200 OK</D:status>\n </D:propstat>\n")
|
||||
} else {
|
||||
fmt.Fprintf(w, " <D:status>HTTP/1.1 200 OK</D:status>\n")
|
||||
}
|
||||
fmt.Fprintf(w, " </D:response>\n")
|
||||
}
|
||||
fmt.Fprintf(w, "</D:multistatus>\n")
|
||||
}
|
||||
|
||||
func writeDAVProp(w http.ResponseWriter, p davProp) {
|
||||
ns := "D"
|
||||
switch p.NS {
|
||||
case nsCardDAV:
|
||||
ns = "CARD"
|
||||
case nsCS:
|
||||
ns = "CS"
|
||||
}
|
||||
if p.Value == "" {
|
||||
fmt.Fprintf(w, " <%s:%s/>\n", ns, p.Name)
|
||||
return
|
||||
}
|
||||
if p.CData {
|
||||
fmt.Fprintf(w, " <%s:%s><![CDATA[%s]]></%s:%s>\n", ns, p.Name, p.Value, ns, p.Name)
|
||||
} else {
|
||||
fmt.Fprintf(w, " <%s:%s>%s</%s:%s>\n", ns, p.Name, p.Value, ns, p.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func splitPath(path string) []string {
|
||||
var out []string
|
||||
for _, s := range strings.Split(strings.Trim(path, "/"), "/") {
|
||||
if s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func xmlEscape(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
s = strings.ReplaceAll(s, `"`, """)
|
||||
return s
|
||||
}
|
||||
|
||||
func sha256Hex(data []byte) string {
|
||||
h := gocrypto.Sum256(data)
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
@@ -680,6 +680,56 @@ func logStartup(c *Config) {
|
||||
fmt.Printf(" CalDAV : %s:%d\n", c.CalDAVIface, c.CalDAVPort)
|
||||
}
|
||||
|
||||
// ---- Validation ----
|
||||
|
||||
// Validate checks the configuration for missing required fields and insecure
|
||||
// defaults, printing warnings to stdout. Returns a non-nil error only for
|
||||
// truly fatal conditions (no listening ports enabled).
|
||||
func (c *Config) Validate() error {
|
||||
warn := func(msg string) { fmt.Printf("[config] WARNING: %s\n", msg) }
|
||||
|
||||
// Secrets
|
||||
if len(c.SessionSecret) < 32 {
|
||||
warn("SESSION_SECRET is missing or too short (< 32 bytes). Regenerate it.")
|
||||
}
|
||||
if len(c.EncryptionKey) == 0 {
|
||||
warn("ENCRYPTION_KEY is missing. Emails will not be encrypted at rest.")
|
||||
}
|
||||
|
||||
// Hostname / domain
|
||||
if c.Hostname == "" || c.Hostname == "mail.example.com" {
|
||||
warn("HOSTNAME is not set to a real FQDN. SMTP HELO will be rejected by strict servers.")
|
||||
}
|
||||
if c.DefaultDomain == "" || c.DefaultDomain == "example.com" {
|
||||
warn("DEFAULT_DOMAIN is not set. Email delivery may fail.")
|
||||
}
|
||||
|
||||
// ACME
|
||||
if (c.TLSMode == "dns01" || c.TLSMode == "http01") && c.ACMEEmail == "" {
|
||||
warn("ACME_EMAIL is required for automatic TLS certificate provisioning.")
|
||||
}
|
||||
|
||||
// Ports — at least one service should be listening.
|
||||
anyPort := c.SMTPEnabled || c.SubmitEnabled || c.SMTPSEnabled ||
|
||||
c.IMAPEnabled || c.IMAPSEnabled ||
|
||||
c.WebClientPort > 0 || c.WebAdminPort > 0
|
||||
if !anyPort {
|
||||
return fmt.Errorf("no services enabled — check SMTP_ENABLED, IMAP_ENABLED, WEB_CLIENT_PORT, WEB_ADMIN_PORT")
|
||||
}
|
||||
|
||||
// Admin binding — warn if admin panel is exposed on non-loopback.
|
||||
if c.WebAdminPort > 0 && c.WebAdminIface != "127.0.0.1" && c.WebAdminIface != "::1" {
|
||||
warn(fmt.Sprintf("WEB_ADMIN_IFACE=%q exposes the admin panel on a public interface. Consider restricting to 127.0.0.1.", c.WebAdminIface))
|
||||
}
|
||||
|
||||
// Queue
|
||||
if c.QueueMaxAgeHours <= 0 {
|
||||
warn("QUEUE_MAX_AGE_HOURS is 0 or negative. Queued messages will never expire.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
func mustHex(n int) string {
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
// Package db — admin-specific queries: queue, ip_bans, security_events,
|
||||
// and update/delete operations for users and domains.
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ---- Types ----
|
||||
|
||||
// QueueEntry is a row from the delivery queue.
|
||||
type QueueEntry struct {
|
||||
ID int64
|
||||
DomainID sql.NullInt64
|
||||
FromAddr string
|
||||
ToAddr string
|
||||
MessageID string
|
||||
Status string // pending | failed | sent
|
||||
Attempts int
|
||||
LastAttempt sql.NullTime
|
||||
NextAttempt time.Time
|
||||
ErrorLog string
|
||||
CreatedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// IPBan is a row from ip_bans.
|
||||
type IPBan struct {
|
||||
ID int64
|
||||
IP string
|
||||
Reason string
|
||||
BannedAt time.Time
|
||||
ExpiresAt sql.NullTime
|
||||
ReleasedBy string
|
||||
}
|
||||
|
||||
// SecurityEvent is a row from security_events.
|
||||
type SecurityEvent struct {
|
||||
ID int64
|
||||
Type string
|
||||
IP string
|
||||
UserID sql.NullInt64
|
||||
Detail string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// AdminStats aggregates summary counts for the dashboard.
|
||||
type AdminStats struct {
|
||||
TotalDomains int
|
||||
TotalUsers int
|
||||
TotalMessages int
|
||||
QueuePending int
|
||||
QueueFailed int
|
||||
ActiveBans int
|
||||
RecentEvents int // last 24h
|
||||
}
|
||||
|
||||
// ---- Queue ----
|
||||
|
||||
// ListQueueEntries returns all non-sent queue entries ordered by created_at desc.
|
||||
func (d *DB) ListQueueEntries(ctx context.Context) ([]*QueueEntry, error) {
|
||||
rows, err := d.db.QueryContext(ctx, `
|
||||
SELECT id, domain_id, from_addr, to_addr, message_id, status,
|
||||
attempts, last_attempt, next_attempt, error_log, created_at, expires_at
|
||||
FROM queue
|
||||
WHERE status != 'sent'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 500`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []*QueueEntry
|
||||
for rows.Next() {
|
||||
q := &QueueEntry{}
|
||||
err := rows.Scan(
|
||||
&q.ID, &q.DomainID, &q.FromAddr, &q.ToAddr, &q.MessageID, &q.Status,
|
||||
&q.Attempts, &q.LastAttempt, &q.NextAttempt, &q.ErrorLog, &q.CreatedAt, &q.ExpiresAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, q)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// RetryQueueEntry resets a queue entry to pending with immediate next_attempt.
|
||||
func (d *DB) RetryQueueEntry(ctx context.Context, id int64) error {
|
||||
_, err := d.db.ExecContext(ctx,
|
||||
"UPDATE queue SET status='pending', next_attempt=? WHERE id=?",
|
||||
time.Now().UTC(), id)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteQueueEntry removes a queue entry permanently.
|
||||
func (d *DB) DeleteQueueEntry(ctx context.Context, id int64) error {
|
||||
_, err := d.db.ExecContext(ctx, "DELETE FROM queue WHERE id=?", id)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---- IP Bans ----
|
||||
|
||||
// ListIPBans returns all IP bans, active first.
|
||||
func (d *DB) ListIPBans(ctx context.Context) ([]*IPBan, error) {
|
||||
rows, err := d.db.QueryContext(ctx, `
|
||||
SELECT id, ip, reason, banned_at, expires_at, released_by
|
||||
FROM ip_bans
|
||||
ORDER BY banned_at DESC
|
||||
LIMIT 500`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []*IPBan
|
||||
for rows.Next() {
|
||||
b := &IPBan{}
|
||||
err := rows.Scan(&b.ID, &b.IP, &b.Reason, &b.BannedAt, &b.ExpiresAt, &b.ReleasedBy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, b)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// AddIPBan inserts a manual IP ban. hours=0 means permanent.
|
||||
func (d *DB) AddIPBan(ctx context.Context, ip, reason string, hours int) error {
|
||||
var expiresAt *time.Time
|
||||
if hours > 0 {
|
||||
t := time.Now().UTC().Add(time.Duration(hours) * time.Hour)
|
||||
expiresAt = &t
|
||||
}
|
||||
_, err := d.db.ExecContext(ctx,
|
||||
"INSERT OR REPLACE INTO ip_bans (ip, reason, banned_at, expires_at) VALUES (?, ?, ?, ?)",
|
||||
ip, reason, time.Now().UTC(), expiresAt)
|
||||
return err
|
||||
}
|
||||
|
||||
// RemoveIPBan deletes a ban by IP address.
|
||||
func (d *DB) RemoveIPBan(ctx context.Context, ip string) error {
|
||||
_, err := d.db.ExecContext(ctx, "DELETE FROM ip_bans WHERE ip=?", ip)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---- Security Events ----
|
||||
|
||||
// ListSecurityEvents returns recent security events.
|
||||
func (d *DB) ListSecurityEvents(ctx context.Context, limit int) ([]*SecurityEvent, error) {
|
||||
if limit <= 0 || limit > 1000 {
|
||||
limit = 200
|
||||
}
|
||||
rows, err := d.db.QueryContext(ctx, `
|
||||
SELECT id, type, ip, user_id, detail, created_at
|
||||
FROM security_events
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []*SecurityEvent
|
||||
for rows.Next() {
|
||||
ev := &SecurityEvent{}
|
||||
err := rows.Scan(&ev.ID, &ev.Type, &ev.IP, &ev.UserID, &ev.Detail, &ev.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, ev)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// LogSecurityEvent inserts a security event record.
|
||||
func (d *DB) LogSecurityEvent(ctx context.Context, eventType, ip string, userID *int64, detail string) error {
|
||||
_, err := d.db.ExecContext(ctx,
|
||||
"INSERT INTO security_events (type, ip, user_id, detail, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||
eventType, ip, userID, detail, time.Now().UTC())
|
||||
return err
|
||||
}
|
||||
|
||||
// ---- User admin operations ----
|
||||
|
||||
// SetUserEnabled enables or disables a user account.
|
||||
func (d *DB) SetUserEnabled(ctx context.Context, userID int64, enabled bool) error {
|
||||
_, err := d.db.ExecContext(ctx,
|
||||
"UPDATE users SET enabled=? WHERE id=?", enabled, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetUserAdmin sets admin / domain_admin flags.
|
||||
func (d *DB) SetUserAdmin(ctx context.Context, userID int64, admin, domainAdmin bool) error {
|
||||
_, err := d.db.ExecContext(ctx,
|
||||
"UPDATE users SET admin=?, domain_admin=? WHERE id=?", admin, domainAdmin, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetUserQuota updates quota_bytes.
|
||||
func (d *DB) SetUserQuota(ctx context.Context, userID int64, quotaBytes int64) error {
|
||||
_, err := d.db.ExecContext(ctx,
|
||||
"UPDATE users SET quota_bytes=? WHERE id=?", quotaBytes, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetUserPassword replaces the bcrypt hash for a user.
|
||||
func (d *DB) SetUserPassword(ctx context.Context, userID int64, hash string) error {
|
||||
_, err := d.db.ExecContext(ctx,
|
||||
"UPDATE users SET password_hash=? WHERE id=?", hash, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetUserDisplayName updates display_name.
|
||||
func (d *DB) SetUserDisplayName(ctx context.Context, userID int64, name string) error {
|
||||
_, err := d.db.ExecContext(ctx,
|
||||
"UPDATE users SET display_name=? WHERE id=?", name, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteUser permanently deletes a user and all associated data (cascade).
|
||||
func (d *DB) DeleteUser(ctx context.Context, userID int64) error {
|
||||
_, err := d.db.ExecContext(ctx, "DELETE FROM users WHERE id=?", userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListAllUsers returns users across all domains, joined with domain name.
|
||||
func (d *DB) ListAllUsers(ctx context.Context) ([]*UserWithDomain, error) {
|
||||
rows, err := d.db.QueryContext(ctx, `
|
||||
SELECT u.id, u.domain_id, u.username, u.email, u.display_name,
|
||||
u.quota_bytes, u.used_bytes, u.enabled, u.admin, u.domain_admin,
|
||||
u.mfa_enabled, u.created_at, u.last_login,
|
||||
d.name AS domain_name
|
||||
FROM users u
|
||||
LEFT JOIN domains d ON d.id = u.domain_id
|
||||
ORDER BY d.name, u.email`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []*UserWithDomain
|
||||
for rows.Next() {
|
||||
u := &UserWithDomain{}
|
||||
var lastLogin sql.NullTime
|
||||
err := rows.Scan(
|
||||
&u.ID, &u.DomainID, &u.Username, &u.Email, &u.DisplayName,
|
||||
&u.QuotaBytes, &u.UsedBytes, &u.Enabled, &u.Admin, &u.DomainAdmin,
|
||||
&u.MFAEnabled, &u.CreatedAt, &lastLogin,
|
||||
&u.DomainName,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if lastLogin.Valid {
|
||||
u.LastLogin = lastLogin.Time
|
||||
}
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// UserWithDomain is a user row augmented with the domain name.
|
||||
type UserWithDomain struct {
|
||||
ID int64
|
||||
DomainID int64
|
||||
Username string
|
||||
Email string
|
||||
DisplayName string
|
||||
DomainName string
|
||||
QuotaBytes int64
|
||||
UsedBytes int64
|
||||
Enabled bool
|
||||
Admin bool
|
||||
DomainAdmin bool
|
||||
MFAEnabled bool
|
||||
CreatedAt time.Time
|
||||
LastLogin time.Time
|
||||
}
|
||||
|
||||
// ---- MFA operations ----
|
||||
|
||||
// SetMFASecret stores the encrypted TOTP secret. Passing nil clears the secret.
|
||||
func (d *DB) SetMFASecret(ctx context.Context, userID int64, encSecret []byte) error {
|
||||
_, err := d.db.ExecContext(ctx,
|
||||
"UPDATE users SET mfa_secret_enc=? WHERE id=?", encSecret, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetMFAEnabled enables or disables TOTP for a user.
|
||||
func (d *DB) SetMFAEnabled(ctx context.Context, userID int64, enabled bool) error {
|
||||
_, err := d.db.ExecContext(ctx,
|
||||
"UPDATE users SET mfa_enabled=? WHERE id=?", enabled, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetRecoveryCodes stores the encrypted recovery codes JSON.
|
||||
func (d *DB) SetRecoveryCodes(ctx context.Context, userID int64, encCodes []byte) error {
|
||||
_, err := d.db.ExecContext(ctx,
|
||||
"UPDATE users SET recovery_codes_enc=? WHERE id=?", encCodes, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// ClearMFA disables MFA and removes the secret + recovery codes atomically.
|
||||
func (d *DB) ClearMFA(ctx context.Context, userID int64) error {
|
||||
_, err := d.db.ExecContext(ctx,
|
||||
"UPDATE users SET mfa_enabled=0, mfa_secret_enc=NULL, recovery_codes_enc=NULL WHERE id=?",
|
||||
userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---- Domain admin operations ----
|
||||
|
||||
// SetDomainEnabled enables or disables a domain.
|
||||
func (d *DB) SetDomainEnabled(ctx context.Context, domainID int64, enabled bool) error {
|
||||
_, err := d.db.ExecContext(ctx,
|
||||
"UPDATE domains SET enabled=? WHERE id=?", enabled, domainID)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetDomainLimits updates max_users and max_quota_bytes.
|
||||
func (d *DB) SetDomainLimits(ctx context.Context, domainID int64, maxUsers int, maxQuota int64) error {
|
||||
_, err := d.db.ExecContext(ctx,
|
||||
"UPDATE domains SET max_users=?, max_quota_bytes=? WHERE id=?",
|
||||
maxUsers, maxQuota, domainID)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetDomainDNS stores SPF and DMARC policy strings (informational, for display).
|
||||
func (d *DB) SetDomainDNS(ctx context.Context, domainID int64, spf, dmarc string) error {
|
||||
_, err := d.db.ExecContext(ctx,
|
||||
"UPDATE domains SET spf_policy=?, dmarc_policy=? WHERE id=?", spf, dmarc, domainID)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteDomain permanently removes a domain (cascade deletes users + their data).
|
||||
func (d *DB) DeleteDomain(ctx context.Context, domainID int64) error {
|
||||
_, err := d.db.ExecContext(ctx, "DELETE FROM domains WHERE id=?", domainID)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---- Stats ----
|
||||
|
||||
// GetAdminStats returns aggregate counts for the admin dashboard.
|
||||
func (d *DB) GetAdminStats(ctx context.Context) (*AdminStats, error) {
|
||||
s := &AdminStats{}
|
||||
|
||||
queries := []struct {
|
||||
dest *int
|
||||
sql string
|
||||
args []any
|
||||
}{
|
||||
{&s.TotalDomains, "SELECT COUNT(*) FROM domains WHERE enabled=1", nil},
|
||||
{&s.TotalUsers, "SELECT COUNT(*) FROM users WHERE enabled=1", nil},
|
||||
{&s.TotalMessages, "SELECT COUNT(*) FROM messages WHERE deleted_at IS NULL", nil},
|
||||
{&s.QueuePending, "SELECT COUNT(*) FROM queue WHERE status='pending'", nil},
|
||||
{&s.QueueFailed, "SELECT COUNT(*) FROM queue WHERE status='failed'", nil},
|
||||
{&s.ActiveBans, "SELECT COUNT(*) FROM ip_bans WHERE (expires_at IS NULL OR expires_at > ?)", []any{time.Now().UTC()}},
|
||||
{&s.RecentEvents, "SELECT COUNT(*) FROM security_events WHERE created_at > ?", []any{time.Now().UTC().Add(-24 * time.Hour)}},
|
||||
}
|
||||
|
||||
for _, q := range queries {
|
||||
err := d.db.QueryRowContext(ctx, q.sql, q.args...).Scan(q.dest)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("admin stats: %w", err)
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
// Package db — CalDAV and CardDAV database operations.
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"ghb.freebede.com/nahakubuilder/mailgosend/internal/models"
|
||||
)
|
||||
|
||||
// ---- CalDAV ----
|
||||
|
||||
// ListCalendars returns all calendars for a user.
|
||||
func (d *DB) ListCalendars(ctx context.Context, userID int64) ([]*models.Calendar, error) {
|
||||
rows, err := d.db.QueryContext(ctx, `
|
||||
SELECT id, user_id, name, description, color, timezone, sync_token, created_at
|
||||
FROM calendars WHERE user_id=? ORDER BY name`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*models.Calendar
|
||||
for rows.Next() {
|
||||
c := &models.Calendar{}
|
||||
if err := rows.Scan(&c.ID, &c.UserID, &c.Name, &c.Description, &c.Color, &c.Timezone, &c.SyncToken, &c.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetCalendarByID returns a calendar by ID.
|
||||
func (d *DB) GetCalendarByID(ctx context.Context, id int64) (*models.Calendar, error) {
|
||||
row := d.db.QueryRowContext(ctx,
|
||||
"SELECT id, user_id, name, description, color, timezone, sync_token, created_at FROM calendars WHERE id=?", id)
|
||||
c := &models.Calendar{}
|
||||
err := row.Scan(&c.ID, &c.UserID, &c.Name, &c.Description, &c.Color, &c.Timezone, &c.SyncToken, &c.CreatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return c, err
|
||||
}
|
||||
|
||||
// CreateCalendar inserts a new calendar and returns the ID.
|
||||
func (d *DB) CreateCalendar(ctx context.Context, userID int64, name, description, color, timezone string) (int64, error) {
|
||||
if color == "" {
|
||||
color = "#4CAF50"
|
||||
}
|
||||
if timezone == "" {
|
||||
timezone = "UTC"
|
||||
}
|
||||
res, err := d.db.ExecContext(ctx,
|
||||
"INSERT INTO calendars (user_id, name, description, color, timezone, sync_token, created_at) VALUES (?,?,?,?,?,1,?)",
|
||||
userID, name, description, color, timezone, time.Now().UTC())
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("create calendar: %w", err)
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
// EnsureDefaultCalendar creates a "Personal" calendar for a user if none exists.
|
||||
func (d *DB) EnsureDefaultCalendar(ctx context.Context, userID int64) (*models.Calendar, error) {
|
||||
cals, err := d.ListCalendars(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(cals) > 0 {
|
||||
return cals[0], nil
|
||||
}
|
||||
id, err := d.CreateCalendar(ctx, userID, "Personal", "", "#4CAF50", "UTC")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return d.GetCalendarByID(ctx, id)
|
||||
}
|
||||
|
||||
// DeleteCalendar removes a calendar and all its events.
|
||||
func (d *DB) DeleteCalendar(ctx context.Context, calendarID int64) error {
|
||||
_, err := d.db.ExecContext(ctx, "DELETE FROM calendars WHERE id=?", calendarID)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateCalendar updates calendar metadata.
|
||||
func (d *DB) UpdateCalendar(ctx context.Context, calendarID int64, name, description, color, timezone string) error {
|
||||
_, err := d.db.ExecContext(ctx,
|
||||
"UPDATE calendars SET name=?, description=?, color=?, timezone=? WHERE id=?",
|
||||
name, description, color, timezone, calendarID)
|
||||
return err
|
||||
}
|
||||
|
||||
// BumpCalendarSyncToken increments sync_token and returns the new value.
|
||||
func (d *DB) BumpCalendarSyncToken(ctx context.Context, calendarID int64) (int64, error) {
|
||||
_, err := d.db.ExecContext(ctx,
|
||||
"UPDATE calendars SET sync_token = sync_token + 1 WHERE id=?", calendarID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var token int64
|
||||
err = d.db.QueryRowContext(ctx, "SELECT sync_token FROM calendars WHERE id=?", calendarID).Scan(&token)
|
||||
return token, err
|
||||
}
|
||||
|
||||
// ---- Calendar events ----
|
||||
|
||||
// ListCalendarEvents returns all events in a calendar.
|
||||
func (d *DB) ListCalendarEvents(ctx context.Context, calendarID int64) ([]*models.CalendarEvent, error) {
|
||||
rows, err := d.db.QueryContext(ctx, `
|
||||
SELECT id, calendar_id, uid, ical_enc, etag, dt_start, dt_end, summary, recurring, created_at, updated_at
|
||||
FROM calendar_events WHERE calendar_id=? ORDER BY dt_start`, calendarID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*models.CalendarEvent
|
||||
for rows.Next() {
|
||||
ev := &models.CalendarEvent{}
|
||||
var dtStart, dtEnd sql.NullTime
|
||||
err := rows.Scan(&ev.ID, &ev.CalendarID, &ev.UID, &ev.ICalEnc, &ev.ETag,
|
||||
&dtStart, &dtEnd, &ev.Summary, &ev.Recurring, &ev.CreatedAt, &ev.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if dtStart.Valid {
|
||||
ev.DTStart = dtStart.Time
|
||||
}
|
||||
if dtEnd.Valid {
|
||||
ev.DTEnd = dtEnd.Time
|
||||
}
|
||||
out = append(out, ev)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetCalendarEvent returns one event by UID within a calendar.
|
||||
func (d *DB) GetCalendarEvent(ctx context.Context, calendarID int64, uid string) (*models.CalendarEvent, error) {
|
||||
row := d.db.QueryRowContext(ctx, `
|
||||
SELECT id, calendar_id, uid, ical_enc, etag, dt_start, dt_end, summary, recurring, created_at, updated_at
|
||||
FROM calendar_events WHERE calendar_id=? AND uid=?`, calendarID, uid)
|
||||
ev := &models.CalendarEvent{}
|
||||
var dtStart, dtEnd sql.NullTime
|
||||
err := row.Scan(&ev.ID, &ev.CalendarID, &ev.UID, &ev.ICalEnc, &ev.ETag,
|
||||
&dtStart, &dtEnd, &ev.Summary, &ev.Recurring, &ev.CreatedAt, &ev.UpdatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if dtStart.Valid {
|
||||
ev.DTStart = dtStart.Time
|
||||
}
|
||||
if dtEnd.Valid {
|
||||
ev.DTEnd = dtEnd.Time
|
||||
}
|
||||
return ev, nil
|
||||
}
|
||||
|
||||
// UpsertCalendarEvent creates or replaces a calendar event.
|
||||
func (d *DB) UpsertCalendarEvent(ctx context.Context, calendarID int64, uid, etag string, icalEnc []byte, dtStart, dtEnd time.Time, summary string, recurring bool) error {
|
||||
now := time.Now().UTC()
|
||||
_, err := d.db.ExecContext(ctx, `
|
||||
INSERT INTO calendar_events (calendar_id, uid, ical_enc, etag, dt_start, dt_end, summary, recurring, created_at, updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(calendar_id, uid) DO UPDATE SET
|
||||
ical_enc=excluded.ical_enc, etag=excluded.etag,
|
||||
dt_start=excluded.dt_start, dt_end=excluded.dt_end,
|
||||
summary=excluded.summary, recurring=excluded.recurring,
|
||||
updated_at=excluded.updated_at`,
|
||||
calendarID, uid, icalEnc, etag, nullTime(dtStart), nullTime(dtEnd), summary, recurring, now, now)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteCalendarEvent removes one event by UID.
|
||||
func (d *DB) DeleteCalendarEvent(ctx context.Context, calendarID int64, uid string) error {
|
||||
_, err := d.db.ExecContext(ctx,
|
||||
"DELETE FROM calendar_events WHERE calendar_id=? AND uid=?", calendarID, uid)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---- CardDAV ----
|
||||
|
||||
// ListAddressBooks returns all address books for a user.
|
||||
func (d *DB) ListAddressBooks(ctx context.Context, userID int64) ([]*models.AddressBook, error) {
|
||||
rows, err := d.db.QueryContext(ctx,
|
||||
"SELECT id, user_id, name, description, color, sync_token, created_at FROM address_books WHERE user_id=? ORDER BY name", userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*models.AddressBook
|
||||
for rows.Next() {
|
||||
ab := &models.AddressBook{}
|
||||
if err := rows.Scan(&ab.ID, &ab.UserID, &ab.Name, &ab.Description, &ab.Color, &ab.SyncToken, &ab.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, ab)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetAddressBookByID returns an address book by ID.
|
||||
func (d *DB) GetAddressBookByID(ctx context.Context, id int64) (*models.AddressBook, error) {
|
||||
row := d.db.QueryRowContext(ctx,
|
||||
"SELECT id, user_id, name, description, color, sync_token, created_at FROM address_books WHERE id=?", id)
|
||||
ab := &models.AddressBook{}
|
||||
err := row.Scan(&ab.ID, &ab.UserID, &ab.Name, &ab.Description, &ab.Color, &ab.SyncToken, &ab.CreatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return ab, err
|
||||
}
|
||||
|
||||
// CreateAddressBook inserts a new address book.
|
||||
func (d *DB) CreateAddressBook(ctx context.Context, userID int64, name, description, color string) (int64, error) {
|
||||
if color == "" {
|
||||
color = "#4A90E2"
|
||||
}
|
||||
res, err := d.db.ExecContext(ctx,
|
||||
"INSERT INTO address_books (user_id, name, description, color, sync_token, created_at) VALUES (?,?,?,?,1,?)",
|
||||
userID, name, description, color, time.Now().UTC())
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("create address book: %w", err)
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
// EnsureDefaultAddressBook creates a "Personal" address book for a user if none exists.
|
||||
func (d *DB) EnsureDefaultAddressBook(ctx context.Context, userID int64) (*models.AddressBook, error) {
|
||||
abs, err := d.ListAddressBooks(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(abs) > 0 {
|
||||
return abs[0], nil
|
||||
}
|
||||
id, err := d.CreateAddressBook(ctx, userID, "Personal", "", "#4A90E2")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return d.GetAddressBookByID(ctx, id)
|
||||
}
|
||||
|
||||
// DeleteAddressBook removes an address book and all contacts.
|
||||
func (d *DB) DeleteAddressBook(ctx context.Context, addressBookID int64) error {
|
||||
_, err := d.db.ExecContext(ctx, "DELETE FROM address_books WHERE id=?", addressBookID)
|
||||
return err
|
||||
}
|
||||
|
||||
// BumpAddressBookSyncToken increments sync_token and returns the new value.
|
||||
func (d *DB) BumpAddressBookSyncToken(ctx context.Context, addressBookID int64) (int64, error) {
|
||||
_, err := d.db.ExecContext(ctx,
|
||||
"UPDATE address_books SET sync_token = sync_token + 1 WHERE id=?", addressBookID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var token int64
|
||||
err = d.db.QueryRowContext(ctx, "SELECT sync_token FROM address_books WHERE id=?", addressBookID).Scan(&token)
|
||||
return token, err
|
||||
}
|
||||
|
||||
// ---- Contacts ----
|
||||
|
||||
// ListContacts returns all contacts in an address book.
|
||||
func (d *DB) ListContacts(ctx context.Context, addressBookID int64) ([]*models.Contact, error) {
|
||||
rows, err := d.db.QueryContext(ctx,
|
||||
"SELECT id, address_book_id, uid, vcard_enc, etag, created_at, updated_at FROM contacts WHERE address_book_id=? ORDER BY uid",
|
||||
addressBookID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*models.Contact
|
||||
for rows.Next() {
|
||||
c := &models.Contact{}
|
||||
if err := rows.Scan(&c.ID, &c.AddressBookID, &c.UID, &c.VCardEnc, &c.ETag, &c.CreatedAt, &c.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetContact returns one contact by UID.
|
||||
func (d *DB) GetContact(ctx context.Context, addressBookID int64, uid string) (*models.Contact, error) {
|
||||
row := d.db.QueryRowContext(ctx,
|
||||
"SELECT id, address_book_id, uid, vcard_enc, etag, created_at, updated_at FROM contacts WHERE address_book_id=? AND uid=?",
|
||||
addressBookID, uid)
|
||||
c := &models.Contact{}
|
||||
err := row.Scan(&c.ID, &c.AddressBookID, &c.UID, &c.VCardEnc, &c.ETag, &c.CreatedAt, &c.UpdatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return c, err
|
||||
}
|
||||
|
||||
// UpsertContact creates or replaces a contact.
|
||||
func (d *DB) UpsertContact(ctx context.Context, addressBookID int64, uid, etag string, vcardEnc []byte) error {
|
||||
now := time.Now().UTC()
|
||||
_, err := d.db.ExecContext(ctx, `
|
||||
INSERT INTO contacts (address_book_id, uid, vcard_enc, etag, created_at, updated_at)
|
||||
VALUES (?,?,?,?,?,?)
|
||||
ON CONFLICT(address_book_id, uid) DO UPDATE SET
|
||||
vcard_enc=excluded.vcard_enc, etag=excluded.etag, updated_at=excluded.updated_at`,
|
||||
addressBookID, uid, vcardEnc, etag, now, now)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteContact removes a contact by UID.
|
||||
func (d *DB) DeleteContact(ctx context.Context, addressBookID int64, uid string) error {
|
||||
_, err := d.db.ExecContext(ctx,
|
||||
"DELETE FROM contacts WHERE address_book_id=? AND uid=?", addressBookID, uid)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
func nullTime(t time.Time) *time.Time {
|
||||
if t.IsZero() {
|
||||
return nil
|
||||
}
|
||||
return &t
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
// Package middleware provides reusable HTTP middleware for security headers
|
||||
// and in-memory rate limiting (token bucket per IP).
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ---- Security Headers ----
|
||||
|
||||
// SecureHeaders wraps an http.Handler and adds security-relevant response headers.
|
||||
// The CSP is intentionally strict: scripts only from same-origin CDN; no inline scripts.
|
||||
func SecureHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h := w.Header()
|
||||
h.Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains; preload")
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
h.Set("X-Frame-Options", "SAMEORIGIN")
|
||||
h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()")
|
||||
h.Set("Content-Security-Policy",
|
||||
"default-src 'self'; "+
|
||||
"script-src 'self' https://cdn.tailwindcss.com; "+
|
||||
"style-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com; "+
|
||||
"img-src 'self' data:; "+
|
||||
"font-src 'self'; "+
|
||||
"frame-src 'self'; "+
|
||||
"object-src 'none'; "+
|
||||
"base-uri 'self'; "+
|
||||
"form-action 'self'")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// ---- Token-bucket rate limiter ----
|
||||
|
||||
// bucket is one client's token state.
|
||||
type bucket struct {
|
||||
tokens float64
|
||||
lastSeen time.Time
|
||||
}
|
||||
|
||||
// RateLimiter is an in-memory token-bucket limiter keyed by IP.
|
||||
// Safe for concurrent use.
|
||||
type RateLimiter struct {
|
||||
mu sync.Mutex
|
||||
buckets map[string]*bucket
|
||||
rate float64 // tokens refilled per second
|
||||
capacity float64 // max token count
|
||||
status int // HTTP status on exceeded (default 429)
|
||||
}
|
||||
|
||||
// NewRateLimiter creates a limiter.
|
||||
// - ratePerMin: tokens refilled per minute (e.g. 20 = 20 req/min steady-state)
|
||||
// - burst: max burst size (e.g. 5 = 5 simultaneous requests)
|
||||
func NewRateLimiter(ratePerMin int, burst int) *RateLimiter {
|
||||
rl := &RateLimiter{
|
||||
buckets: make(map[string]*bucket),
|
||||
rate: float64(ratePerMin) / 60.0,
|
||||
capacity: float64(burst),
|
||||
status: http.StatusTooManyRequests,
|
||||
}
|
||||
// Periodic cleanup goroutine (runs for process lifetime).
|
||||
go rl.cleanup()
|
||||
return rl
|
||||
}
|
||||
|
||||
// Allow returns true if the request is within limit, consuming one token.
|
||||
func (rl *RateLimiter) Allow(ip string) bool {
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
b, ok := rl.buckets[ip]
|
||||
if !ok {
|
||||
b = &bucket{tokens: rl.capacity, lastSeen: now}
|
||||
rl.buckets[ip] = b
|
||||
}
|
||||
|
||||
// Refill tokens based on elapsed time.
|
||||
elapsed := now.Sub(b.lastSeen).Seconds()
|
||||
b.tokens += elapsed * rl.rate
|
||||
if b.tokens > rl.capacity {
|
||||
b.tokens = rl.capacity
|
||||
}
|
||||
b.lastSeen = now
|
||||
|
||||
if b.tokens < 1 {
|
||||
return false
|
||||
}
|
||||
b.tokens--
|
||||
return true
|
||||
}
|
||||
|
||||
// Middleware returns an http.Handler that rate-limits by remote IP.
|
||||
func (rl *RateLimiter) Middleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ip := remoteIP(r)
|
||||
if !rl.Allow(ip) {
|
||||
w.Header().Set("Retry-After", "60")
|
||||
http.Error(w, http.StatusText(rl.status), rl.status)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// cleanup removes stale buckets every 5 minutes (no activity for >10 min).
|
||||
func (rl *RateLimiter) cleanup() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
cutoff := time.Now().Add(-10 * time.Minute)
|
||||
rl.mu.Lock()
|
||||
for ip, b := range rl.buckets {
|
||||
if b.lastSeen.Before(cutoff) {
|
||||
delete(rl.buckets, ip)
|
||||
}
|
||||
}
|
||||
rl.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// remoteIP extracts the real client IP, honoring X-Real-IP and X-Forwarded-For.
|
||||
// Falls back to RemoteAddr.
|
||||
func remoteIP(r *http.Request) string {
|
||||
if xri := r.Header.Get("X-Real-IP"); xri != "" {
|
||||
if ip := net.ParseIP(xri); ip != nil {
|
||||
return ip.String()
|
||||
}
|
||||
}
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
// First address in the chain is the original client.
|
||||
for _, part := range splitComma(xff) {
|
||||
trimmed := strings.TrimSpace(part)
|
||||
if ip := net.ParseIP(trimmed); ip != nil {
|
||||
return ip.String()
|
||||
}
|
||||
}
|
||||
}
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// splitComma splits on comma without allocating a regex.
|
||||
func splitComma(s string) []string {
|
||||
var out []string
|
||||
start := 0
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == ',' {
|
||||
out = append(out, s[start:i])
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
out = append(out, s[start:])
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
package smtp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"mime/multipart"
|
||||
"net/textproto"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ghb.freebede.com/nahakubuilder/mailgosend/internal/crypto"
|
||||
"ghb.freebede.com/nahakubuilder/mailgosend/internal/delivery"
|
||||
"ghb.freebede.com/nahakubuilder/mailgosend/internal/models"
|
||||
"ghb.freebede.com/nahakubuilder/mailgosend/internal/storage"
|
||||
)
|
||||
|
||||
// QueueWorker polls the delivery queue and dispatches messages.
|
||||
@@ -106,6 +113,7 @@ func (w *QueueWorker) drainQueue() {
|
||||
}
|
||||
|
||||
// markFailed updates queue status with exponential back-off or marks permanent failure.
|
||||
// On permanent failure with a non-empty sender, generates a DSN (RFC 3464) bounce.
|
||||
func (w *QueueWorker) markFailed(ctx context.Context, queueID int64, from, to, errMsg string, perm bool) {
|
||||
status := "failed"
|
||||
if perm {
|
||||
@@ -125,6 +133,135 @@ func (w *QueueWorker) markFailed(ctx context.Context, queueID int64, from, to, e
|
||||
}
|
||||
w.deps.DB.LogDelivery(ctx, queueID, from, to, status, 0, errMsg, "") //nolint:errcheck
|
||||
log.Printf("[queue] %s %d → %s: %s", status, queueID, to, errMsg)
|
||||
|
||||
// Generate DSN bounce for permanent failures only, never bounce a bounce
|
||||
// (null sender <> = already a DSN).
|
||||
if perm && from != "" && from != "<>" {
|
||||
w.sendDSN(ctx, from, to, errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// sendDSN delivers a Delivery Status Notification (RFC 3464) to the original sender.
|
||||
// Failures here are logged but not re-queued to avoid bounce loops.
|
||||
func (w *QueueWorker) sendDSN(ctx context.Context, originalFrom, failedTo, reason string) {
|
||||
sender := strings.ToLower(originalFrom)
|
||||
|
||||
// Determine if original sender is a local user.
|
||||
user, err := w.deps.DB.GetUserByEmail(ctx, sender)
|
||||
if err != nil {
|
||||
log.Printf("[queue] dsn lookup sender %s: %v", sender, err)
|
||||
return
|
||||
}
|
||||
if user == nil || !user.Enabled {
|
||||
// Sender is remote — attempt external SMTP delivery of DSN.
|
||||
dsnRaw, buildErr := buildDSN(w.deps.Cfg.Hostname, failedTo, reason)
|
||||
if buildErr != nil {
|
||||
log.Printf("[queue] dsn build: %v", buildErr)
|
||||
return
|
||||
}
|
||||
ehlo := w.deps.Cfg.SMTPHostname
|
||||
if ehlo == "" {
|
||||
ehlo = w.deps.Cfg.Hostname
|
||||
}
|
||||
result := delivery.Deliver(ctx, ehlo, "", sender, dsnRaw)
|
||||
if result.SMTPCode != 250 {
|
||||
log.Printf("[queue] dsn delivery to %s failed: %s", sender, result.Message)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Local sender — save DSN directly to INBOX.
|
||||
inbox, err := w.deps.DB.GetMailboxByType(ctx, user.ID, models.MailboxInbox)
|
||||
if err != nil || inbox == nil {
|
||||
log.Printf("[queue] dsn inbox for %s: %v", sender, err)
|
||||
return
|
||||
}
|
||||
|
||||
dsnRaw, err := buildDSN(w.deps.Cfg.Hostname, failedTo, reason)
|
||||
if err != nil {
|
||||
log.Printf("[queue] dsn build: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
hostname := w.deps.Cfg.Hostname
|
||||
if hostname == "" {
|
||||
hostname = "localhost"
|
||||
}
|
||||
|
||||
msg := &storage.IncomingMessage{
|
||||
Raw: dsnRaw,
|
||||
FromEmail: "",
|
||||
Subject: "Delivery Status Notification: failed to deliver to " + failedTo,
|
||||
Date: time.Now().UTC(),
|
||||
MessageID: fmt.Sprintf("<dsn-%d@%s>", time.Now().UnixNano(), hostname),
|
||||
}
|
||||
if _, err := w.deps.Store.SaveIncoming(ctx, user.ID, inbox.ID, msg); err != nil {
|
||||
log.Printf("[queue] dsn save: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// buildDSN constructs a minimal RFC 3464 multipart/report message.
|
||||
func buildDSN(hostname, failedTo, reason string) ([]byte, error) {
|
||||
if hostname == "" {
|
||||
hostname = "localhost"
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
msgID := fmt.Sprintf("<dsn-%d@%s>", now.UnixNano(), hostname)
|
||||
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
boundary := mw.Boundary()
|
||||
|
||||
// Outer headers.
|
||||
header := fmt.Sprintf(
|
||||
"From: Mail Delivery Subsystem <mailer-daemon@%s>\r\n"+
|
||||
"To: <%s>\r\n"+
|
||||
"Subject: Delivery Status Notification (Failure)\r\n"+
|
||||
"Date: %s\r\n"+
|
||||
"Message-ID: %s\r\n"+
|
||||
"MIME-Version: 1.0\r\n"+
|
||||
"Content-Type: multipart/report; report-type=delivery-status; boundary=%q\r\n"+
|
||||
"\r\n",
|
||||
hostname, failedTo,
|
||||
now.Format("Mon, 02 Jan 2006 15:04:05 -0700"),
|
||||
msgID, boundary,
|
||||
)
|
||||
buf.WriteString(header)
|
||||
|
||||
// Part 1: human-readable explanation.
|
||||
ph := make(textproto.MIMEHeader)
|
||||
ph.Set("Content-Type", "text/plain; charset=utf-8")
|
||||
pw, err := mw.CreatePart(ph)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fmt.Fprintf(pw,
|
||||
"Your message could not be delivered to the following recipient:\r\n\r\n"+
|
||||
" Recipient: %s\r\n"+
|
||||
" Reason: %s\r\n\r\n"+
|
||||
"This is a permanent error. The message has not been delivered and will not be retried.\r\n",
|
||||
failedTo, reason)
|
||||
|
||||
// Part 2: machine-readable delivery-status (RFC 3464).
|
||||
sh := make(textproto.MIMEHeader)
|
||||
sh.Set("Content-Type", "message/delivery-status")
|
||||
sw, err := mw.CreatePart(sh)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fmt.Fprintf(sw,
|
||||
"Reporting-MTA: dns; %s\r\n\r\n"+
|
||||
"Final-Recipient: rfc822; %s\r\n"+
|
||||
"Action: failed\r\n"+
|
||||
"Status: 5.0.0\r\n"+
|
||||
"Diagnostic-Code: smtp; %s\r\n",
|
||||
hostname, failedTo, reason)
|
||||
|
||||
if err := mw.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// nextBackoff returns the back-off duration based on attempt count using
|
||||
|
||||
@@ -198,6 +198,40 @@ func (s *Store) GetRaw(ctx context.Context, userID, messageID int64) ([]byte, er
|
||||
return plain, nil
|
||||
}
|
||||
|
||||
// BodyParts holds decoded body content returned by GetBodyParts.
|
||||
type BodyParts struct {
|
||||
Text string
|
||||
HTML string
|
||||
Attachments []AttachmentMeta
|
||||
}
|
||||
|
||||
// AttachmentMeta describes an attachment without loading its bytes.
|
||||
type AttachmentMeta struct {
|
||||
Filename string
|
||||
ContentType string
|
||||
ContentID string // for inline images
|
||||
Inline bool
|
||||
}
|
||||
|
||||
// GetBodyParts decrypts a message and returns the text/HTML body and attachment list.
|
||||
func (s *Store) GetBodyParts(ctx context.Context, userID, messageID int64) (*BodyParts, error) {
|
||||
raw, err := s.GetRaw(ctx, userID, messageID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
text, html, atts := parseMIME(raw)
|
||||
bp := &BodyParts{Text: text, HTML: html}
|
||||
for _, a := range atts {
|
||||
bp.Attachments = append(bp.Attachments, AttachmentMeta{
|
||||
Filename: a.Filename,
|
||||
ContentType: a.ContentType,
|
||||
ContentID: a.ContentID,
|
||||
Inline: a.Inline,
|
||||
})
|
||||
}
|
||||
return bp, nil
|
||||
}
|
||||
|
||||
// parseMIME walks a raw RFC822 message and extracts body parts and attachments.
|
||||
func parseMIME(raw []byte) (bodyText, bodyHTML string, attachments []parsedAttachment) {
|
||||
m, err := mail.ReadMessage(bytes.NewReader(raw))
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
// Package totp implements RFC 6238 TOTP using stdlib only.
|
||||
package totp
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha1" //nolint:gosec — RFC 6238 mandates SHA-1 for TOTP
|
||||
"encoding/base32"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// Digits is the number of OTP digits (6).
|
||||
Digits = 6
|
||||
// Period is the TOTP time step in seconds (30).
|
||||
Period = 30
|
||||
// Window is the number of adjacent windows to accept (±1 = 3 windows total).
|
||||
Window = 1
|
||||
// SecretBytes is the raw secret length (20 bytes = 160 bits).
|
||||
SecretBytes = 20
|
||||
// RecoveryCodeCount is the number of single-use backup codes generated.
|
||||
RecoveryCodeCount = 10
|
||||
// RecoveryCodeLen is the length of each backup code (characters, base32 subset).
|
||||
RecoveryCodeLen = 8
|
||||
)
|
||||
|
||||
// GenerateSecret returns a cryptographically random base32-encoded TOTP secret.
|
||||
func GenerateSecret() (string, error) {
|
||||
raw := make([]byte, SecretBytes)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", fmt.Errorf("totp: generate secret: %w", err)
|
||||
}
|
||||
return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(raw), nil
|
||||
}
|
||||
|
||||
// OTPAuthURI builds the otpauth:// URI for a QR code or manual import.
|
||||
func OTPAuthURI(secret, accountName, issuer string) string {
|
||||
enc := func(s string) string {
|
||||
// RFC 3986 percent-encode for URI path/query
|
||||
var buf strings.Builder
|
||||
for _, b := range []byte(s) {
|
||||
if (b >= 'A' && b <= 'Z') || (b >= 'a' && b <= 'z') ||
|
||||
(b >= '0' && b <= '9') || b == '-' || b == '_' || b == '.' || b == '~' {
|
||||
buf.WriteByte(b)
|
||||
} else {
|
||||
fmt.Fprintf(&buf, "%%%02X", b)
|
||||
}
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
return fmt.Sprintf("otpauth://totp/%s:%s?secret=%s&issuer=%s&algorithm=SHA1&digits=%d&period=%d",
|
||||
enc(issuer), enc(accountName), secret, enc(issuer), Digits, Period)
|
||||
}
|
||||
|
||||
// Verify checks a 6-digit code against the secret for the current time window (±Window steps).
|
||||
// Returns true if valid. secret is base32-encoded (no padding).
|
||||
func Verify(secret, code string) bool {
|
||||
raw, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(strings.ToUpper(secret))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
step := now / Period
|
||||
for w := int64(-Window); w <= int64(Window); w++ {
|
||||
if hotp(raw, step+w) == code {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// hotp computes an HOTP value for a key and counter.
|
||||
func hotp(key []byte, counter int64) string {
|
||||
msg := make([]byte, 8)
|
||||
binary.BigEndian.PutUint64(msg, uint64(counter)) //nolint:gosec
|
||||
mac := hmac.New(sha1.New, key)
|
||||
mac.Write(msg)
|
||||
h := mac.Sum(nil)
|
||||
|
||||
// Dynamic truncation (RFC 4226 §5.4)
|
||||
offset := h[len(h)-1] & 0x0f
|
||||
binCode := (uint32(h[offset]&0x7f) << 24) |
|
||||
(uint32(h[offset+1]) << 16) |
|
||||
(uint32(h[offset+2]) << 8) |
|
||||
uint32(h[offset+3])
|
||||
|
||||
otp := binCode % uint32(math.Pow10(Digits))
|
||||
return fmt.Sprintf("%0*d", Digits, otp)
|
||||
}
|
||||
|
||||
// ---- Recovery codes ----
|
||||
|
||||
// GenerateRecoveryCodes returns RecoveryCodeCount random single-use codes.
|
||||
func GenerateRecoveryCodes() ([]string, error) {
|
||||
codes := make([]string, RecoveryCodeCount)
|
||||
const charset = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" // no O/0/I/1 to avoid confusion
|
||||
buf := make([]byte, RecoveryCodeLen)
|
||||
for i := range codes {
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return nil, fmt.Errorf("totp: generate recovery codes: %w", err)
|
||||
}
|
||||
var sb strings.Builder
|
||||
for _, b := range buf {
|
||||
sb.WriteByte(charset[int(b)%len(charset)])
|
||||
}
|
||||
codes[i] = sb.String()
|
||||
}
|
||||
return codes, nil
|
||||
}
|
||||
|
||||
// EncodeRecoveryCodes marshals a code slice to JSON bytes for storage.
|
||||
func EncodeRecoveryCodes(codes []string) ([]byte, error) {
|
||||
return json.Marshal(codes)
|
||||
}
|
||||
|
||||
// DecodeRecoveryCodes unmarshals JSON bytes back to a code slice.
|
||||
func DecodeRecoveryCodes(data []byte) ([]string, error) {
|
||||
var codes []string
|
||||
if err := json.Unmarshal(data, &codes); err != nil {
|
||||
return nil, fmt.Errorf("totp: decode recovery codes: %w", err)
|
||||
}
|
||||
return codes, nil
|
||||
}
|
||||
|
||||
// ConsumeRecoveryCode removes a matching code (case-insensitive) and returns the
|
||||
// updated slice. Returns (nil, false) if code not found.
|
||||
func ConsumeRecoveryCode(codes []string, input string) ([]string, bool) {
|
||||
input = strings.ToUpper(strings.TrimSpace(input))
|
||||
for i, c := range codes {
|
||||
if strings.EqualFold(c, input) {
|
||||
updated := make([]string, 0, len(codes)-1)
|
||||
updated = append(updated, codes[:i]...)
|
||||
updated = append(updated, codes[i+1:]...)
|
||||
return updated, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
package webadmin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
appCrypto "ghb.freebede.com/nahakubuilder/mailgosend/internal/crypto"
|
||||
"ghb.freebede.com/nahakubuilder/mailgosend/internal/totp"
|
||||
)
|
||||
|
||||
const adminPreAuthCookieName = "mailgo_admin_preauth"
|
||||
const adminPreAuthMaxAge = 300 // 5 minutes
|
||||
|
||||
// loginGet renders the admin login form.
|
||||
func (s *Server) loginGet(w http.ResponseWriter, r *http.Request) {
|
||||
// Already logged in → dashboard.
|
||||
if s.currentAdmin(r) != nil {
|
||||
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
flash, errMsg := flashFrom(r)
|
||||
s.render(w, "login", struct {
|
||||
basePage
|
||||
}{newBaseNoUser(flash, errMsg)})
|
||||
}
|
||||
|
||||
// loginPost handles credential submission (step 1 of 2 if MFA enabled).
|
||||
func (s *Server) loginPost(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
email := strings.TrimSpace(r.FormValue("email"))
|
||||
password := r.FormValue("password")
|
||||
|
||||
clientIP := realIP(r)
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Brute-force check.
|
||||
if s.deps.Brute != nil && s.deps.Cfg.BruteMaxTries > 0 {
|
||||
banned, err := s.deps.Brute.IsBanned(ctx, clientIP)
|
||||
if err != nil {
|
||||
log.Printf("[admin] brute check: %v", err)
|
||||
}
|
||||
if banned {
|
||||
redirect(w, r, "/admin/login", "", "Too many failed attempts. Try again later.")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if email == "" || password == "" || len(email) > 254 || len(password) > 1024 {
|
||||
s.recordAttempt(ctx, clientIP, email, false)
|
||||
redirect(w, r, "/admin/login", "", "Invalid credentials.")
|
||||
return
|
||||
}
|
||||
|
||||
user, err := s.deps.DB.GetUserByEmail(ctx, email)
|
||||
if err != nil {
|
||||
log.Printf("[admin] login db: %v", err)
|
||||
redirect(w, r, "/admin/login", "", "Internal error.")
|
||||
return
|
||||
}
|
||||
|
||||
if user == nil || !user.Enabled || !user.Admin {
|
||||
s.recordAttempt(ctx, clientIP, email, false)
|
||||
redirect(w, r, "/admin/login", "", "Invalid credentials.")
|
||||
return
|
||||
}
|
||||
|
||||
if err := appCrypto.CheckPassword(user.PasswordHash, password); err != nil {
|
||||
s.recordAttempt(ctx, clientIP, email, false)
|
||||
redirect(w, r, "/admin/login", "", "Invalid credentials.")
|
||||
return
|
||||
}
|
||||
|
||||
// Password OK. Check MFA.
|
||||
if user.MFAEnabled && len(user.MFASecretEnc) > 0 {
|
||||
s.setAdminPreAuthCookie(w, user.ID)
|
||||
http.Redirect(w, r, "/admin/login/mfa", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// No MFA — create session.
|
||||
s.recordAttempt(ctx, clientIP, email, true)
|
||||
if _, err := s.deps.Sessions.Create(w, r, user.ID); err != nil {
|
||||
log.Printf("[admin] session create: %v", err)
|
||||
redirect(w, r, "/admin/login", "", "Session error.")
|
||||
return
|
||||
}
|
||||
s.deps.DB.UpdateLastLogin(ctx, user.ID)
|
||||
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// mfaGet renders the TOTP challenge page for admin login.
|
||||
func (s *Server) mfaGet(w http.ResponseWriter, r *http.Request) {
|
||||
if s.currentAdmin(r) != nil {
|
||||
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if _, ok := s.adminPreAuthUserID(r); !ok {
|
||||
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
flash, errMsg := flashFrom(r)
|
||||
s.render(w, "mfa", struct{ basePage }{newBaseNoUser(flash, errMsg)})
|
||||
}
|
||||
|
||||
// mfaPost verifies TOTP and completes admin login.
|
||||
func (s *Server) mfaPost(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
userID, ok := s.adminPreAuthUserID(r)
|
||||
if !ok {
|
||||
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
clientIP := realIP(r)
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if s.deps.Brute != nil && s.deps.Cfg.BruteMaxTries > 0 {
|
||||
banned, _ := s.deps.Brute.IsBanned(ctx, clientIP)
|
||||
if banned {
|
||||
clearAdminPreAuth(w)
|
||||
redirect(w, r, "/admin/login", "", "Too many failed attempts. Try again later.")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
code := strings.TrimSpace(r.FormValue("code"))
|
||||
if len(code) == 0 || len(code) > 64 {
|
||||
s.recordAttempt(ctx, clientIP, "", false)
|
||||
redirect(w, r, "/admin/login/mfa", "", "Invalid code.")
|
||||
return
|
||||
}
|
||||
|
||||
user, err := s.deps.DB.GetUserByID(ctx, userID)
|
||||
if err != nil || user == nil || !user.Enabled || !user.Admin || !user.MFAEnabled {
|
||||
clearAdminPreAuth(w)
|
||||
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
secretRaw, err := s.deps.Crypt.DecryptForUser(user.ID, "totp", user.MFASecretEnc)
|
||||
if err != nil {
|
||||
log.Printf("[admin] mfa decrypt: %v", err)
|
||||
clearAdminPreAuth(w)
|
||||
redirect(w, r, "/admin/login", "", "MFA error. Please try again.")
|
||||
return
|
||||
}
|
||||
|
||||
var authenticated bool
|
||||
if len(code) == totp.Digits {
|
||||
authenticated = totp.Verify(string(secretRaw), code)
|
||||
} else if len(user.RecoveryCodesEnc) > 0 {
|
||||
codesRaw, cerr := s.deps.Crypt.DecryptForUser(user.ID, "recovery", user.RecoveryCodesEnc)
|
||||
if cerr == nil {
|
||||
codes, cerr := totp.DecodeRecoveryCodes(codesRaw)
|
||||
if cerr == nil {
|
||||
updated, consumed := totp.ConsumeRecoveryCode(codes, code)
|
||||
if consumed {
|
||||
authenticated = true
|
||||
newEnc, encErr := s.deps.Crypt.EncryptForUser(user.ID, "recovery", mustMarshalAdminCodes(updated))
|
||||
if encErr == nil {
|
||||
_ = s.deps.DB.SetRecoveryCodes(ctx, user.ID, newEnc)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !authenticated {
|
||||
s.recordAttempt(ctx, clientIP, user.Email, false)
|
||||
redirect(w, r, "/admin/login/mfa", "", "Invalid code.")
|
||||
return
|
||||
}
|
||||
|
||||
clearAdminPreAuth(w)
|
||||
s.recordAttempt(ctx, clientIP, user.Email, true)
|
||||
if _, err := s.deps.Sessions.Create(w, r, user.ID); err != nil {
|
||||
log.Printf("[admin] session create: %v", err)
|
||||
redirect(w, r, "/admin/login", "", "Session error.")
|
||||
return
|
||||
}
|
||||
s.deps.DB.UpdateLastLogin(ctx, user.ID)
|
||||
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// logout destroys the session and redirects to login.
|
||||
func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.deps.Sessions.Destroy(w, r); err != nil {
|
||||
log.Printf("[admin] session destroy: %v", err)
|
||||
}
|
||||
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// ---- Pre-auth cookie helpers ----
|
||||
|
||||
func (s *Server) setAdminPreAuthCookie(w http.ResponseWriter, userID int64) {
|
||||
ts := fmt.Sprintf("%d", time.Now().Unix())
|
||||
uid := fmt.Sprintf("%d", userID)
|
||||
payload := uid + "|" + ts
|
||||
mac := adminPreAuthMAC(s.deps.Cfg.SessionSecret, payload)
|
||||
value := payload + "|" + mac
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: adminPreAuthCookieName,
|
||||
Value: value,
|
||||
Path: "/admin/login",
|
||||
MaxAge: adminPreAuthMaxAge,
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) adminPreAuthUserID(r *http.Request) (int64, bool) {
|
||||
c, err := r.Cookie(adminPreAuthCookieName)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
parts := strings.SplitN(c.Value, "|", 3)
|
||||
if len(parts) != 3 {
|
||||
return 0, false
|
||||
}
|
||||
uid, ts, gotMAC := parts[0], parts[1], parts[2]
|
||||
payload := uid + "|" + ts
|
||||
|
||||
wantMAC := adminPreAuthMAC(s.deps.Cfg.SessionSecret, payload)
|
||||
if !hmac.Equal([]byte(gotMAC), []byte(wantMAC)) {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
tsi, err := strconv.ParseInt(ts, 10, 64)
|
||||
if err != nil || time.Now().Unix()-tsi > adminPreAuthMaxAge {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
id, err := strconv.ParseInt(uid, 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
func clearAdminPreAuth(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: adminPreAuthCookieName,
|
||||
Value: "",
|
||||
Path: "/admin/login",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
}
|
||||
|
||||
func adminPreAuthMAC(secret []byte, payload string) string {
|
||||
mac := hmac.New(sha256.New, secret)
|
||||
mac.Write([]byte(payload))
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func mustMarshalAdminCodes(codes []string) []byte {
|
||||
data, err := totp.EncodeRecoveryCodes(codes)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("totp marshal: %v", err))
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
func (s *Server) recordAttempt(ctx context.Context, ip, email string, success bool) {
|
||||
if s.deps.Brute != nil {
|
||||
s.deps.Brute.RecordAttempt(ctx, ip, email, success)
|
||||
}
|
||||
}
|
||||
|
||||
// newBaseNoUser builds a basePage without requiring a session (used on login page).
|
||||
func newBaseNoUser(flash, errMsg string) basePage {
|
||||
return basePage{Flash: flash, Error: errMsg}
|
||||
}
|
||||
|
||||
// realIP extracts the client IP, honouring X-Forwarded-For when behind a proxy.
|
||||
func realIP(r *http.Request) string {
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
parts := strings.Split(xff, ",")
|
||||
ip := strings.TrimSpace(parts[0])
|
||||
if net.ParseIP(ip) != nil {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
host, _, _ := net.SplitHostPort(r.RemoteAddr)
|
||||
return host
|
||||
}
|
||||
@@ -0,0 +1,712 @@
|
||||
package webadmin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
appCrypto "ghb.freebede.com/nahakubuilder/mailgosend/internal/crypto"
|
||||
"ghb.freebede.com/nahakubuilder/mailgosend/internal/db"
|
||||
"ghb.freebede.com/nahakubuilder/mailgosend/internal/dkim"
|
||||
"ghb.freebede.com/nahakubuilder/mailgosend/internal/models"
|
||||
)
|
||||
|
||||
// ---- Dashboard ----
|
||||
|
||||
type dashboardData struct {
|
||||
basePage
|
||||
Stats *db.AdminStats
|
||||
}
|
||||
|
||||
func (s *Server) dashboard(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
stats, err := s.deps.DB.GetAdminStats(ctx)
|
||||
if err != nil {
|
||||
log.Printf("[admin] stats: %v", err)
|
||||
stats = &db.AdminStats{}
|
||||
}
|
||||
flash, errMsg := flashFrom(r)
|
||||
s.render(w, "dashboard", dashboardData{
|
||||
basePage: s.newBase(r, flash, errMsg),
|
||||
Stats: stats,
|
||||
})
|
||||
}
|
||||
|
||||
// ---- Domains ----
|
||||
|
||||
type domainsData struct {
|
||||
basePage
|
||||
Domains []*models.Domain
|
||||
}
|
||||
|
||||
func (s *Server) domainsList(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
doms, err := s.deps.DB.ListDomains(ctx)
|
||||
if err != nil {
|
||||
log.Printf("[admin] list domains: %v", err)
|
||||
}
|
||||
flash, errMsg := flashFrom(r)
|
||||
s.render(w, "domains", domainsData{
|
||||
basePage: s.newBase(r, flash, errMsg),
|
||||
Domains: doms,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) domainsCreate(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if !s.validateCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
name := strings.ToLower(strings.TrimSpace(r.FormValue("name")))
|
||||
selector := strings.TrimSpace(r.FormValue("selector"))
|
||||
algo := r.FormValue("algo")
|
||||
|
||||
if !validDomain(name) {
|
||||
redirect(w, r, "/admin/domains", "", "Invalid domain name.")
|
||||
return
|
||||
}
|
||||
if selector == "" {
|
||||
selector = "mail"
|
||||
}
|
||||
if !validIdentifier(selector) {
|
||||
redirect(w, r, "/admin/domains", "", "Invalid DKIM selector.")
|
||||
return
|
||||
}
|
||||
if algo != "rsa2048" && algo != "ed25519" {
|
||||
algo = "rsa2048"
|
||||
}
|
||||
|
||||
domID, err := s.deps.DB.CreateDomain(ctx, name, selector, algo)
|
||||
if err != nil {
|
||||
log.Printf("[admin] create domain: %v", err)
|
||||
redirect(w, r, "/admin/domains", "", "Failed to create domain.")
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-generate DKIM key pair.
|
||||
if err := s.generateDKIM(ctx, domID, algo, selector); err != nil {
|
||||
log.Printf("[admin] dkim keygen: %v", err)
|
||||
}
|
||||
|
||||
redirect(w, r, fmt.Sprintf("/admin/domains/%d", domID), "Domain created.", "")
|
||||
}
|
||||
|
||||
type domainDetailData struct {
|
||||
basePage
|
||||
Domain *models.Domain
|
||||
Users []*models.User
|
||||
// DNS hint strings
|
||||
DKIMRecord string
|
||||
SPFHint string
|
||||
DMARCHint string
|
||||
}
|
||||
|
||||
func (s *Server) domainDetail(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
id := pathID(r, "id")
|
||||
dom, err := s.deps.DB.GetDomainByID(ctx, id)
|
||||
if err != nil || dom == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
users, _ := s.deps.DB.ListUsers(ctx, id)
|
||||
flash, errMsg := flashFrom(r)
|
||||
|
||||
dkimRec := ""
|
||||
if dom.DKIMPublic != "" {
|
||||
// Strip PEM headers and newlines to get bare base64 for DNS TXT.
|
||||
pub := strings.ReplaceAll(dom.DKIMPublic, "-----BEGIN PUBLIC KEY-----", "")
|
||||
pub = strings.ReplaceAll(pub, "-----END PUBLIC KEY-----", "")
|
||||
pub = strings.ReplaceAll(pub, "\n", "")
|
||||
pub = strings.TrimSpace(pub)
|
||||
dkimRec = fmt.Sprintf(`%s._domainkey.%s IN TXT "v=DKIM1; k=%s; p=%s"`,
|
||||
dom.DKIMSelector, dom.Name, dkimAlgoKey(dom.DKIMAlgo), pub)
|
||||
}
|
||||
|
||||
s.render(w, "domain", domainDetailData{
|
||||
basePage: s.newBase(r, flash, errMsg),
|
||||
Domain: dom,
|
||||
Users: users,
|
||||
DKIMRecord: dkimRec,
|
||||
SPFHint: fmt.Sprintf(`%s IN TXT "v=spf1 a mx ~all"`, dom.Name),
|
||||
DMARCHint: fmt.Sprintf(`_dmarc.%s IN TXT "v=DMARC1; p=quarantine; rua=mailto:postmaster@%s"`, dom.Name, dom.Name),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) domainToggleEnable(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if !s.validateCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
id := pathID(r, "id")
|
||||
enabled := r.FormValue("enabled") == "1"
|
||||
if err := s.deps.DB.SetDomainEnabled(ctx, id, enabled); err != nil {
|
||||
log.Printf("[admin] domain enable: %v", err)
|
||||
redirect(w, r, fmt.Sprintf("/admin/domains/%d", id), "", "Update failed.")
|
||||
return
|
||||
}
|
||||
redirect(w, r, fmt.Sprintf("/admin/domains/%d", id), "Domain updated.", "")
|
||||
}
|
||||
|
||||
func (s *Server) domainGenDKIM(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if !s.validateCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
id := pathID(r, "id")
|
||||
dom, err := s.deps.DB.GetDomainByID(ctx, id)
|
||||
if err != nil || dom == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
algo := r.FormValue("algo")
|
||||
if algo != "rsa2048" && algo != "ed25519" {
|
||||
algo = dom.DKIMAlgo
|
||||
}
|
||||
if algo == "" {
|
||||
algo = "rsa2048"
|
||||
}
|
||||
|
||||
if err := s.generateDKIM(ctx, id, algo, dom.DKIMSelector); err != nil {
|
||||
log.Printf("[admin] dkim regen: %v", err)
|
||||
redirect(w, r, fmt.Sprintf("/admin/domains/%d", id), "", "DKIM generation failed.")
|
||||
return
|
||||
}
|
||||
redirect(w, r, fmt.Sprintf("/admin/domains/%d", id), "DKIM key regenerated. Update your DNS TXT record.", "")
|
||||
}
|
||||
|
||||
func (s *Server) domainSetLimits(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if !s.validateCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
id := pathID(r, "id")
|
||||
maxUsers, _ := strconv.Atoi(r.FormValue("max_users"))
|
||||
maxQuotaMB, _ := strconv.ParseInt(r.FormValue("max_quota_mb"), 10, 64)
|
||||
if maxUsers < 0 {
|
||||
maxUsers = 0
|
||||
}
|
||||
if maxQuotaMB < 0 {
|
||||
maxQuotaMB = 0
|
||||
}
|
||||
|
||||
if err := s.deps.DB.SetDomainLimits(ctx, id, maxUsers, maxQuotaMB*1024*1024); err != nil {
|
||||
log.Printf("[admin] domain limits: %v", err)
|
||||
redirect(w, r, fmt.Sprintf("/admin/domains/%d", id), "", "Update failed.")
|
||||
return
|
||||
}
|
||||
redirect(w, r, fmt.Sprintf("/admin/domains/%d", id), "Limits updated.", "")
|
||||
}
|
||||
|
||||
func (s *Server) domainDelete(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if !s.validateCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
id := pathID(r, "id")
|
||||
if err := s.deps.DB.DeleteDomain(ctx, id); err != nil {
|
||||
log.Printf("[admin] delete domain: %v", err)
|
||||
redirect(w, r, fmt.Sprintf("/admin/domains/%d", id), "", "Delete failed.")
|
||||
return
|
||||
}
|
||||
redirect(w, r, "/admin/domains", "Domain deleted.", "")
|
||||
}
|
||||
|
||||
// ---- Users ----
|
||||
|
||||
type usersData struct {
|
||||
basePage
|
||||
Users []*db.UserWithDomain
|
||||
Domains []*models.Domain
|
||||
}
|
||||
|
||||
func (s *Server) usersList(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
users, err := s.deps.DB.ListAllUsers(ctx)
|
||||
if err != nil {
|
||||
log.Printf("[admin] list users: %v", err)
|
||||
}
|
||||
doms, _ := s.deps.DB.ListDomains(ctx)
|
||||
flash, errMsg := flashFrom(r)
|
||||
s.render(w, "users", usersData{
|
||||
basePage: s.newBase(r, flash, errMsg),
|
||||
Users: users,
|
||||
Domains: doms,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) usersCreate(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if !s.validateCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
domainID, _ := strconv.ParseInt(r.FormValue("domain_id"), 10, 64)
|
||||
username := strings.ToLower(strings.TrimSpace(r.FormValue("username")))
|
||||
password := r.FormValue("password")
|
||||
displayName := strings.TrimSpace(r.FormValue("display_name"))
|
||||
quotaMB, _ := strconv.ParseInt(r.FormValue("quota_mb"), 10, 64)
|
||||
domainAdmin := r.FormValue("domain_admin") == "1"
|
||||
|
||||
if domainID <= 0 || !validUsername(username) || len(password) < 8 || len(password) > 1024 {
|
||||
redirect(w, r, "/admin/users", "", "Invalid input. Username must be alphanumeric, password min 8 chars.")
|
||||
return
|
||||
}
|
||||
if quotaMB <= 0 {
|
||||
quotaMB = 1024 // 1 GB default
|
||||
}
|
||||
|
||||
dom, err := s.deps.DB.GetDomainByID(ctx, domainID)
|
||||
if err != nil || dom == nil {
|
||||
redirect(w, r, "/admin/users", "", "Domain not found.")
|
||||
return
|
||||
}
|
||||
|
||||
email := username + "@" + dom.Name
|
||||
exists, err := s.deps.DB.UserExistsByEmail(ctx, email)
|
||||
if err != nil || exists {
|
||||
redirect(w, r, "/admin/users", "", "Email already in use.")
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := appCrypto.HashPassword(password)
|
||||
if err != nil {
|
||||
redirect(w, r, "/admin/users", "", "Password hashing failed.")
|
||||
return
|
||||
}
|
||||
|
||||
userID, err := s.deps.DB.CreateUser(ctx, domainID, username, email, hash, displayName, quotaMB*1024*1024, domainAdmin)
|
||||
if err != nil {
|
||||
log.Printf("[admin] create user: %v", err)
|
||||
redirect(w, r, "/admin/users", "", "Failed to create user.")
|
||||
return
|
||||
}
|
||||
|
||||
// Create default mailboxes, calendar, address book.
|
||||
if err := createDefaultMailboxes(ctx, s.deps.DB, userID); err != nil {
|
||||
log.Printf("[admin] default mailboxes: %v", err)
|
||||
}
|
||||
if _, err := s.deps.DB.EnsureDefaultCalendar(ctx, userID); err != nil {
|
||||
log.Printf("[admin] default calendar: %v", err)
|
||||
}
|
||||
if _, err := s.deps.DB.EnsureDefaultAddressBook(ctx, userID); err != nil {
|
||||
log.Printf("[admin] default address book: %v", err)
|
||||
}
|
||||
|
||||
redirect(w, r, fmt.Sprintf("/admin/users/%d", userID), "User created.", "")
|
||||
}
|
||||
|
||||
type userDetailData struct {
|
||||
basePage
|
||||
U *db.UserWithDomain
|
||||
}
|
||||
|
||||
func (s *Server) userDetail(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
id := pathID(r, "id")
|
||||
users, err := s.deps.DB.ListAllUsers(ctx)
|
||||
if err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var found *db.UserWithDomain
|
||||
for _, u := range users {
|
||||
if u.ID == id {
|
||||
found = u
|
||||
break
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
flash, errMsg := flashFrom(r)
|
||||
s.render(w, "user", userDetailData{
|
||||
basePage: s.newBase(r, flash, errMsg),
|
||||
U: found,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) userUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if !s.validateCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
id := pathID(r, "id")
|
||||
enabled := r.FormValue("enabled") == "1"
|
||||
admin := r.FormValue("admin") == "1"
|
||||
domainAdmin := r.FormValue("domain_admin") == "1"
|
||||
quotaMB, _ := strconv.ParseInt(r.FormValue("quota_mb"), 10, 64)
|
||||
displayName := strings.TrimSpace(r.FormValue("display_name"))
|
||||
|
||||
if len(displayName) > 255 {
|
||||
displayName = displayName[:255]
|
||||
}
|
||||
if quotaMB < 0 {
|
||||
quotaMB = 0
|
||||
}
|
||||
|
||||
if err := s.deps.DB.SetUserEnabled(ctx, id, enabled); err != nil {
|
||||
log.Printf("[admin] user enabled: %v", err)
|
||||
}
|
||||
if err := s.deps.DB.SetUserAdmin(ctx, id, admin, domainAdmin); err != nil {
|
||||
log.Printf("[admin] user admin: %v", err)
|
||||
}
|
||||
if err := s.deps.DB.SetUserQuota(ctx, id, quotaMB*1024*1024); err != nil {
|
||||
log.Printf("[admin] user quota: %v", err)
|
||||
}
|
||||
if err := s.deps.DB.SetUserDisplayName(ctx, id, displayName); err != nil {
|
||||
log.Printf("[admin] user display: %v", err)
|
||||
}
|
||||
|
||||
redirect(w, r, fmt.Sprintf("/admin/users/%d", id), "User updated.", "")
|
||||
}
|
||||
|
||||
func (s *Server) userPassword(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if !s.validateCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
id := pathID(r, "id")
|
||||
password := r.FormValue("password")
|
||||
if len(password) < 8 || len(password) > 1024 {
|
||||
redirect(w, r, fmt.Sprintf("/admin/users/%d", id), "", "Password must be 8-1024 characters.")
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := appCrypto.HashPassword(password)
|
||||
if err != nil {
|
||||
redirect(w, r, fmt.Sprintf("/admin/users/%d", id), "", "Password error.")
|
||||
return
|
||||
}
|
||||
if err := s.deps.DB.SetUserPassword(ctx, id, hash); err != nil {
|
||||
log.Printf("[admin] set password: %v", err)
|
||||
redirect(w, r, fmt.Sprintf("/admin/users/%d", id), "", "Failed to update password.")
|
||||
return
|
||||
}
|
||||
redirect(w, r, fmt.Sprintf("/admin/users/%d", id), "Password updated.", "")
|
||||
}
|
||||
|
||||
func (s *Server) userDelete(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if !s.validateCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
id := pathID(r, "id")
|
||||
if err := s.deps.DB.DeleteUser(ctx, id); err != nil {
|
||||
log.Printf("[admin] delete user: %v", err)
|
||||
redirect(w, r, fmt.Sprintf("/admin/users/%d", id), "", "Delete failed.")
|
||||
return
|
||||
}
|
||||
redirect(w, r, "/admin/users", "User deleted.", "")
|
||||
}
|
||||
|
||||
// ---- Queue ----
|
||||
|
||||
type queueData struct {
|
||||
basePage
|
||||
Entries []*db.QueueEntry
|
||||
}
|
||||
|
||||
func (s *Server) queueList(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
entries, err := s.deps.DB.ListQueueEntries(ctx)
|
||||
if err != nil {
|
||||
log.Printf("[admin] queue list: %v", err)
|
||||
}
|
||||
flash, errMsg := flashFrom(r)
|
||||
s.render(w, "queue", queueData{
|
||||
basePage: s.newBase(r, flash, errMsg),
|
||||
Entries: entries,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) queueRetry(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if !s.validateCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
id := pathID(r, "id")
|
||||
if err := s.deps.DB.RetryQueueEntry(ctx, id); err != nil {
|
||||
log.Printf("[admin] queue retry: %v", err)
|
||||
redirect(w, r, "/admin/queue", "", "Retry failed.")
|
||||
return
|
||||
}
|
||||
redirect(w, r, "/admin/queue", "Entry queued for immediate retry.", "")
|
||||
}
|
||||
|
||||
func (s *Server) queueDelete(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if !s.validateCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
id := pathID(r, "id")
|
||||
if err := s.deps.DB.DeleteQueueEntry(ctx, id); err != nil {
|
||||
log.Printf("[admin] queue delete: %v", err)
|
||||
redirect(w, r, "/admin/queue", "", "Delete failed.")
|
||||
return
|
||||
}
|
||||
redirect(w, r, "/admin/queue", "Queue entry deleted.", "")
|
||||
}
|
||||
|
||||
// ---- IP Bans ----
|
||||
|
||||
type bansData struct {
|
||||
basePage
|
||||
Bans []*db.IPBan
|
||||
}
|
||||
|
||||
func (s *Server) bansList(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
bans, err := s.deps.DB.ListIPBans(ctx)
|
||||
if err != nil {
|
||||
log.Printf("[admin] list bans: %v", err)
|
||||
}
|
||||
flash, errMsg := flashFrom(r)
|
||||
s.render(w, "bans", bansData{
|
||||
basePage: s.newBase(r, flash, errMsg),
|
||||
Bans: bans,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) bansAdd(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if !s.validateCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
ip := strings.TrimSpace(r.FormValue("ip"))
|
||||
reason := strings.TrimSpace(r.FormValue("reason"))
|
||||
hours, _ := strconv.Atoi(r.FormValue("hours"))
|
||||
|
||||
if net.ParseIP(ip) == nil {
|
||||
redirect(w, r, "/admin/bans", "", "Invalid IP address.")
|
||||
return
|
||||
}
|
||||
if len(reason) > 255 {
|
||||
reason = reason[:255]
|
||||
}
|
||||
if hours < 0 {
|
||||
hours = 0
|
||||
}
|
||||
|
||||
if err := s.deps.DB.AddIPBan(ctx, ip, reason, hours); err != nil {
|
||||
log.Printf("[admin] add ban: %v", err)
|
||||
redirect(w, r, "/admin/bans", "", "Failed to add ban.")
|
||||
return
|
||||
}
|
||||
redirect(w, r, "/admin/bans", fmt.Sprintf("IP %s banned.", ip), "")
|
||||
}
|
||||
|
||||
func (s *Server) bansRemove(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if !s.validateCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
ip := r.PathValue("ip")
|
||||
if net.ParseIP(ip) == nil {
|
||||
redirect(w, r, "/admin/bans", "", "Invalid IP.")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.deps.DB.RemoveIPBan(ctx, ip); err != nil {
|
||||
log.Printf("[admin] remove ban: %v", err)
|
||||
redirect(w, r, "/admin/bans", "", "Remove failed.")
|
||||
return
|
||||
}
|
||||
redirect(w, r, "/admin/bans", fmt.Sprintf("Ban on %s removed.", ip), "")
|
||||
}
|
||||
|
||||
// ---- Security Events ----
|
||||
|
||||
type eventsData struct {
|
||||
basePage
|
||||
Events []*db.SecurityEvent
|
||||
}
|
||||
|
||||
func (s *Server) eventsList(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
limit := 200
|
||||
if q := r.URL.Query().Get("limit"); q != "" {
|
||||
if n, err := strconv.Atoi(q); err == nil && n > 0 && n <= 1000 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
|
||||
evs, err := s.deps.DB.ListSecurityEvents(ctx, limit)
|
||||
if err != nil {
|
||||
log.Printf("[admin] events: %v", err)
|
||||
}
|
||||
flash, errMsg := flashFrom(r)
|
||||
s.render(w, "events", eventsData{
|
||||
basePage: s.newBase(r, flash, errMsg),
|
||||
Events: evs,
|
||||
})
|
||||
}
|
||||
|
||||
// ---- internal helpers ----
|
||||
|
||||
// validateCSRF checks the CSRF token; on failure writes a 403 and returns false.
|
||||
func (s *Server) validateCSRF(w http.ResponseWriter, r *http.Request) bool {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return false
|
||||
}
|
||||
sess, _, _ := s.deps.Sessions.Get(r)
|
||||
if sess == nil {
|
||||
http.Error(w, "unauthenticated", http.StatusForbidden)
|
||||
return false
|
||||
}
|
||||
if !s.checkCSRF(r, sess.TokenHash) {
|
||||
http.Error(w, "CSRF validation failed", http.StatusForbidden)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// pathID extracts a positive int64 from a URL path value. Returns 0 on error.
|
||||
func pathID(r *http.Request, key string) int64 {
|
||||
id, _ := strconv.ParseInt(r.PathValue(key), 10, 64)
|
||||
return id
|
||||
}
|
||||
|
||||
// validDomain accepts simple dot-separated labels (a-z0-9 and hyphens).
|
||||
func validDomain(s string) bool {
|
||||
if len(s) < 3 || len(s) > 253 {
|
||||
return false
|
||||
}
|
||||
for _, label := range strings.Split(s, ".") {
|
||||
if len(label) == 0 || len(label) > 63 {
|
||||
return false
|
||||
}
|
||||
for _, c := range label {
|
||||
if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// validUsername accepts lowercase alphanumeric + dots + hyphens, 1-64 chars.
|
||||
func validUsername(s string) bool {
|
||||
if len(s) < 1 || len(s) > 64 {
|
||||
return false
|
||||
}
|
||||
for _, c := range s {
|
||||
if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '.' || c == '-' || c == '_') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// validIdentifier accepts [a-zA-Z0-9_-], 1-63 chars.
|
||||
func validIdentifier(s string) bool {
|
||||
if len(s) < 1 || len(s) > 63 {
|
||||
return false
|
||||
}
|
||||
for _, c := range s {
|
||||
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// dkimAlgoKey converts our algo string to the DNS TXT k= value.
|
||||
func dkimAlgoKey(algo string) string {
|
||||
if algo == "ed25519" {
|
||||
return "ed25519"
|
||||
}
|
||||
return "rsa"
|
||||
}
|
||||
|
||||
// generateDKIM creates a new DKIM key pair and persists it encrypted.
|
||||
func (s *Server) generateDKIM(ctx context.Context, domainID int64, algo, selector string) error {
|
||||
privPEM, pubPEM, err := dkim.GenerateKeyPair(algo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("keygen: %w", err)
|
||||
}
|
||||
|
||||
privEnc, err := s.deps.Crypt.EncryptGlobal("dkim", []byte(privPEM))
|
||||
if err != nil {
|
||||
return fmt.Errorf("encrypt dkim key: %w", err)
|
||||
}
|
||||
|
||||
if err := s.deps.DB.SaveDKIMKeys(ctx, domainID, privEnc, pubPEM); err != nil {
|
||||
return fmt.Errorf("save dkim keys: %w", err)
|
||||
}
|
||||
|
||||
// Update algo + selector in case they changed.
|
||||
_, err = s.deps.DB.SQL().ExecContext(ctx,
|
||||
"UPDATE domains SET dkim_algo=?, dkim_selector=? WHERE id=?", algo, selector, domainID)
|
||||
return err
|
||||
}
|
||||
|
||||
// createDefaultMailboxes creates INBOX, Sent, Drafts, Trash, Spam, Archive for a new user.
|
||||
func createDefaultMailboxes(ctx context.Context, database *db.DB, userID int64) error {
|
||||
return database.CreateDefaultMailboxes(ctx, userID)
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
// Package webadmin provides the HTTP admin panel (default: 127.0.0.1:8081).
|
||||
// All routes require an authenticated admin session except /admin/login.
|
||||
package webadmin
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"ghb.freebede.com/nahakubuilder/mailgosend/internal/auth"
|
||||
"ghb.freebede.com/nahakubuilder/mailgosend/internal/config"
|
||||
appCrypto "ghb.freebede.com/nahakubuilder/mailgosend/internal/crypto"
|
||||
"ghb.freebede.com/nahakubuilder/mailgosend/internal/db"
|
||||
"ghb.freebede.com/nahakubuilder/mailgosend/internal/models"
|
||||
)
|
||||
|
||||
// Deps groups all dependencies for the admin panel.
|
||||
type Deps struct {
|
||||
DB *db.DB
|
||||
Crypt *appCrypto.Crypto
|
||||
Sessions *auth.SessionStore
|
||||
Brute *auth.BruteGuard
|
||||
Cfg *config.Config
|
||||
FS fs.FS // embed.FS sub-rooted at web/admin
|
||||
}
|
||||
|
||||
// Server handles all admin HTTP routes.
|
||||
type Server struct {
|
||||
deps *Deps
|
||||
mux *http.ServeMux
|
||||
}
|
||||
|
||||
// New creates an admin Server and registers all routes.
|
||||
func New(deps *Deps) *Server {
|
||||
s := &Server{deps: deps, mux: http.NewServeMux()}
|
||||
s.setupRoutes()
|
||||
return s
|
||||
}
|
||||
|
||||
// Handler returns the HTTP handler for the admin server.
|
||||
// Requests are logged; all routes except /admin/login require admin auth.
|
||||
func (s *Server) Handler() http.Handler {
|
||||
return logMiddleware(s.mux)
|
||||
}
|
||||
|
||||
// ---- routing ----
|
||||
|
||||
func (s *Server) setupRoutes() {
|
||||
m := s.mux
|
||||
|
||||
// Public
|
||||
m.HandleFunc("GET /admin/login", s.loginGet)
|
||||
m.HandleFunc("POST /admin/login", s.loginPost)
|
||||
m.HandleFunc("GET /admin/login/mfa", s.mfaGet)
|
||||
m.HandleFunc("POST /admin/login/mfa", s.mfaPost)
|
||||
m.HandleFunc("GET /admin/logout", s.logout)
|
||||
|
||||
// Protected
|
||||
m.HandleFunc("GET /admin/{$}", s.require(s.dashboard))
|
||||
|
||||
m.HandleFunc("GET /admin/domains", s.require(s.domainsList))
|
||||
m.HandleFunc("POST /admin/domains", s.require(s.domainsCreate))
|
||||
m.HandleFunc("GET /admin/domains/{id}", s.require(s.domainDetail))
|
||||
m.HandleFunc("POST /admin/domains/{id}/enable", s.require(s.domainToggleEnable))
|
||||
m.HandleFunc("POST /admin/domains/{id}/dkim", s.require(s.domainGenDKIM))
|
||||
m.HandleFunc("POST /admin/domains/{id}/limits", s.require(s.domainSetLimits))
|
||||
m.HandleFunc("POST /admin/domains/{id}/delete", s.require(s.domainDelete))
|
||||
|
||||
m.HandleFunc("GET /admin/users", s.require(s.usersList))
|
||||
m.HandleFunc("POST /admin/users", s.require(s.usersCreate))
|
||||
m.HandleFunc("GET /admin/users/{id}", s.require(s.userDetail))
|
||||
m.HandleFunc("POST /admin/users/{id}/update", s.require(s.userUpdate))
|
||||
m.HandleFunc("POST /admin/users/{id}/password", s.require(s.userPassword))
|
||||
m.HandleFunc("POST /admin/users/{id}/delete", s.require(s.userDelete))
|
||||
|
||||
m.HandleFunc("GET /admin/queue", s.require(s.queueList))
|
||||
m.HandleFunc("POST /admin/queue/{id}/retry", s.require(s.queueRetry))
|
||||
m.HandleFunc("POST /admin/queue/{id}/delete", s.require(s.queueDelete))
|
||||
|
||||
m.HandleFunc("GET /admin/bans", s.require(s.bansList))
|
||||
m.HandleFunc("POST /admin/bans", s.require(s.bansAdd))
|
||||
m.HandleFunc("POST /admin/bans/{ip}/remove", s.require(s.bansRemove))
|
||||
|
||||
m.HandleFunc("GET /admin/events", s.require(s.eventsList))
|
||||
|
||||
// Static assets
|
||||
static, _ := fs.Sub(s.deps.FS, "static")
|
||||
m.Handle("GET /admin/static/", http.StripPrefix("/admin/static/", http.FileServer(http.FS(static))))
|
||||
}
|
||||
|
||||
// ---- middleware ----
|
||||
|
||||
// require wraps a handler with admin session enforcement.
|
||||
func (s *Server) require(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
user := s.currentAdmin(r)
|
||||
if user == nil {
|
||||
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// currentAdmin returns the logged-in admin user, or nil if unauthenticated / not admin.
|
||||
func (s *Server) currentAdmin(r *http.Request) *models.User {
|
||||
_, user, err := s.deps.Sessions.Get(r)
|
||||
if err != nil || user == nil || !user.Admin || !user.Enabled {
|
||||
return nil
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
// logMiddleware logs method + path + duration for every request.
|
||||
func logMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
next.ServeHTTP(w, r)
|
||||
log.Printf("[admin] %s %s %s", r.Method, r.URL.Path, time.Since(start))
|
||||
})
|
||||
}
|
||||
|
||||
// ---- CSRF ----
|
||||
|
||||
// csrfToken returns an HMAC-SHA256 token valid for the current clock-hour.
|
||||
// Bound to sessionHash so it cannot be forged without the session secret.
|
||||
func (s *Server) csrfToken(sessionHash string) string {
|
||||
h := hmac.New(sha256.New, s.deps.Cfg.SessionSecret)
|
||||
h.Write([]byte(sessionHash))
|
||||
h.Write([]byte(time.Now().UTC().Format("2006-01-02-15")))
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
// checkCSRF validates the CSRF token submitted via form field "_csrf".
|
||||
// Also accepts previous-hour token to handle hour-boundary edge cases.
|
||||
func (s *Server) checkCSRF(r *http.Request, sessionHash string) bool {
|
||||
got := r.FormValue("_csrf")
|
||||
if got == "" {
|
||||
return false
|
||||
}
|
||||
cur := s.csrfToken(sessionHash)
|
||||
if hmac.Equal([]byte(got), []byte(cur)) {
|
||||
return true
|
||||
}
|
||||
// Allow previous-hour token (grace window).
|
||||
prev := s.csrfTokenAt(sessionHash, time.Now().UTC().Add(-time.Hour))
|
||||
return hmac.Equal([]byte(got), []byte(prev))
|
||||
}
|
||||
|
||||
func (s *Server) csrfTokenAt(sessionHash string, t time.Time) string {
|
||||
h := hmac.New(sha256.New, s.deps.Cfg.SessionSecret)
|
||||
h.Write([]byte(sessionHash))
|
||||
h.Write([]byte(t.Format("2006-01-02-15")))
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
// ---- Template rendering ----
|
||||
|
||||
// basePage holds fields available in every template.
|
||||
type basePage struct {
|
||||
Flash string
|
||||
Error string
|
||||
CSRF string
|
||||
Admin *models.User
|
||||
}
|
||||
|
||||
func (s *Server) newBase(r *http.Request, flash, errMsg string) basePage {
|
||||
sessObj, user, _ := s.deps.Sessions.Get(r)
|
||||
var csrf string
|
||||
if sessObj != nil {
|
||||
csrf = s.csrfToken(sessObj.TokenHash)
|
||||
}
|
||||
return basePage{Flash: flash, Error: errMsg, CSRF: csrf, Admin: user}
|
||||
}
|
||||
|
||||
// render parses base.html + page.html from embed.FS and executes "base" template.
|
||||
func (s *Server) render(w http.ResponseWriter, page string, data any) {
|
||||
t, err := template.New("").Funcs(tmplFuncs).ParseFS(
|
||||
s.deps.FS,
|
||||
"templates/base.html",
|
||||
"templates/"+page+".html",
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("[admin] template parse %s: %v", page, err)
|
||||
http.Error(w, "template error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
if err := t.ExecuteTemplate(w, "base", data); err != nil {
|
||||
log.Printf("[admin] template exec %s: %v", page, err)
|
||||
}
|
||||
}
|
||||
|
||||
// redirect sends a 303 to a path with optional flash/error query params.
|
||||
func redirect(w http.ResponseWriter, r *http.Request, path, flash, errMsg string) {
|
||||
target := path
|
||||
if flash != "" {
|
||||
target += "?flash=" + urlEncode(flash)
|
||||
} else if errMsg != "" {
|
||||
target += "?error=" + urlEncode(errMsg)
|
||||
}
|
||||
http.Redirect(w, r, target, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// flashFrom extracts flash/error from query params (used after redirect).
|
||||
func flashFrom(r *http.Request) (flash, errMsg string) {
|
||||
return r.URL.Query().Get("flash"), r.URL.Query().Get("error")
|
||||
}
|
||||
|
||||
// urlEncode does basic percent-encoding for query values.
|
||||
func urlEncode(s string) string {
|
||||
return fmt.Sprintf("%s", template.URLQueryEscaper(s))
|
||||
}
|
||||
|
||||
// tmplFuncs are custom template functions available in all admin templates.
|
||||
var tmplFuncs = template.FuncMap{
|
||||
"humanBytes": humanBytes,
|
||||
"shortTime": func(t time.Time) string { return t.Format("2006-01-02 15:04") },
|
||||
"isZero": func(t time.Time) bool { return t.IsZero() },
|
||||
"mb": func(b int64) int64 { return b / 1024 / 1024 },
|
||||
}
|
||||
|
||||
func humanBytes(b int64) string {
|
||||
const unit = 1024
|
||||
if b < unit {
|
||||
return fmt.Sprintf("%d B", b)
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for n := b / unit; n >= unit; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
package webclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
appCrypto "ghb.freebede.com/nahakubuilder/mailgosend/internal/crypto"
|
||||
"ghb.freebede.com/nahakubuilder/mailgosend/internal/totp"
|
||||
)
|
||||
|
||||
const preAuthCookieName = "mailgo_preauth"
|
||||
const preAuthMaxAge = 300 // 5 minutes
|
||||
|
||||
// ---- Login (step 1: password) ----
|
||||
|
||||
func (s *Server) loginGet(w http.ResponseWriter, r *http.Request) {
|
||||
if s.currentUser(r) != nil {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
flash, errMsg := flashFrom(r)
|
||||
s.render(w, "login", struct{ basePage }{
|
||||
basePage: basePage{Flash: flash, Error: errMsg},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) loginPost(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
email := strings.ToLower(strings.TrimSpace(r.FormValue("email")))
|
||||
password := r.FormValue("password")
|
||||
clientIP := realIP(r)
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Brute-force check.
|
||||
if s.deps.Brute != nil && s.deps.Cfg.BruteMaxTries > 0 {
|
||||
banned, err := s.deps.Brute.IsBanned(ctx, clientIP)
|
||||
if err != nil {
|
||||
log.Printf("[webmail] brute check: %v", err)
|
||||
}
|
||||
if banned {
|
||||
redirect(w, r, "/login", "", "Too many failed attempts. Try again later.")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if email == "" || password == "" || len(email) > 254 || len(password) > 1024 {
|
||||
s.recordAttempt(ctx, clientIP, email, false)
|
||||
redirect(w, r, "/login", "", "Invalid credentials.")
|
||||
return
|
||||
}
|
||||
|
||||
user, err := s.deps.DB.GetUserByEmail(ctx, email)
|
||||
if err != nil {
|
||||
log.Printf("[webmail] login db: %v", err)
|
||||
redirect(w, r, "/login", "", "Internal error.")
|
||||
return
|
||||
}
|
||||
|
||||
if user == nil || !user.Enabled {
|
||||
s.recordAttempt(ctx, clientIP, email, false)
|
||||
redirect(w, r, "/login", "", "Invalid credentials.")
|
||||
return
|
||||
}
|
||||
|
||||
if err := appCrypto.CheckPassword(user.PasswordHash, password); err != nil {
|
||||
s.recordAttempt(ctx, clientIP, email, false)
|
||||
redirect(w, r, "/login", "", "Invalid credentials.")
|
||||
return
|
||||
}
|
||||
|
||||
// Password OK. Check MFA.
|
||||
if user.MFAEnabled && len(user.MFASecretEnc) > 0 {
|
||||
// Issue pre-auth cookie and redirect to TOTP step.
|
||||
s.setPreAuthCookie(w, user.ID)
|
||||
http.Redirect(w, r, "/login/mfa", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// No MFA — create session directly.
|
||||
s.recordAttempt(ctx, clientIP, email, true)
|
||||
if _, err := s.deps.Sessions.Create(w, r, user.ID); err != nil {
|
||||
log.Printf("[webmail] session create: %v", err)
|
||||
redirect(w, r, "/login", "", "Session error.")
|
||||
return
|
||||
}
|
||||
s.deps.DB.UpdateLastLogin(ctx, user.ID)
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// ---- Login (step 2: TOTP) ----
|
||||
|
||||
func (s *Server) mfaGet(w http.ResponseWriter, r *http.Request) {
|
||||
if s.currentUser(r) != nil {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if _, ok := s.preAuthUserID(r); !ok {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
flash, errMsg := flashFrom(r)
|
||||
s.render(w, "mfa", struct{ basePage }{
|
||||
basePage: basePage{Flash: flash, Error: errMsg},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) mfaPost(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
userID, ok := s.preAuthUserID(r)
|
||||
if !ok {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
clientIP := realIP(r)
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Brute-force check (TOTP codes are brute-forceable too).
|
||||
if s.deps.Brute != nil && s.deps.Cfg.BruteMaxTries > 0 {
|
||||
banned, _ := s.deps.Brute.IsBanned(ctx, clientIP)
|
||||
if banned {
|
||||
clearPreAuth(w)
|
||||
redirect(w, r, "/login", "", "Too many failed attempts. Try again later.")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
code := strings.TrimSpace(r.FormValue("code"))
|
||||
if len(code) == 0 || len(code) > 64 {
|
||||
s.recordAttempt(ctx, clientIP, "", false)
|
||||
redirect(w, r, "/login/mfa", "", "Invalid code.")
|
||||
return
|
||||
}
|
||||
|
||||
user, err := s.deps.DB.GetUserByID(ctx, userID)
|
||||
if err != nil || user == nil || !user.Enabled || !user.MFAEnabled {
|
||||
clearPreAuth(w)
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Decrypt the TOTP secret.
|
||||
secretRaw, err := s.deps.Crypt.DecryptForUser(user.ID, "totp", user.MFASecretEnc)
|
||||
if err != nil {
|
||||
log.Printf("[webmail] mfa decrypt: %v", err)
|
||||
clearPreAuth(w)
|
||||
redirect(w, r, "/login", "", "MFA error. Please try again.")
|
||||
return
|
||||
}
|
||||
|
||||
var authenticated bool
|
||||
|
||||
if len(code) == totp.Digits {
|
||||
// TOTP path.
|
||||
authenticated = totp.Verify(string(secretRaw), code)
|
||||
} else {
|
||||
// Recovery code path.
|
||||
if len(user.RecoveryCodesEnc) > 0 {
|
||||
codesRaw, cerr := s.deps.Crypt.DecryptForUser(user.ID, "recovery", user.RecoveryCodesEnc)
|
||||
if cerr == nil {
|
||||
codes, cerr := totp.DecodeRecoveryCodes(codesRaw)
|
||||
if cerr == nil {
|
||||
updated, consumed := totp.ConsumeRecoveryCode(codes, code)
|
||||
if consumed {
|
||||
authenticated = true
|
||||
newEnc, encErr := s.deps.Crypt.EncryptForUser(user.ID, "recovery", mustMarshalCodes(updated))
|
||||
if encErr == nil {
|
||||
_ = s.deps.DB.SetRecoveryCodes(ctx, user.ID, newEnc)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !authenticated {
|
||||
s.recordAttempt(ctx, clientIP, user.Email, false)
|
||||
redirect(w, r, "/login/mfa", "", "Invalid code.")
|
||||
return
|
||||
}
|
||||
|
||||
clearPreAuth(w)
|
||||
s.recordAttempt(ctx, clientIP, user.Email, true)
|
||||
if _, err := s.deps.Sessions.Create(w, r, user.ID); err != nil {
|
||||
log.Printf("[webmail] session create: %v", err)
|
||||
redirect(w, r, "/login", "", "Session error.")
|
||||
return
|
||||
}
|
||||
s.deps.DB.UpdateLastLogin(ctx, user.ID)
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// ---- Logout ----
|
||||
|
||||
func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.deps.Sessions.Destroy(w, r); err != nil {
|
||||
log.Printf("[webmail] session destroy: %v", err)
|
||||
}
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// ---- Pre-auth cookie ----
|
||||
|
||||
func (s *Server) setPreAuthCookie(w http.ResponseWriter, userID int64) {
|
||||
ts := fmt.Sprintf("%d", time.Now().Unix())
|
||||
uid := fmt.Sprintf("%d", userID)
|
||||
payload := uid + "|" + ts
|
||||
mac := preAuthMAC(s.deps.Cfg.SessionSecret, payload)
|
||||
value := payload + "|" + mac
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: preAuthCookieName,
|
||||
Value: value,
|
||||
Path: "/login",
|
||||
MaxAge: preAuthMaxAge,
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) preAuthUserID(r *http.Request) (int64, bool) {
|
||||
c, err := r.Cookie(preAuthCookieName)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
parts := strings.SplitN(c.Value, "|", 3)
|
||||
if len(parts) != 3 {
|
||||
return 0, false
|
||||
}
|
||||
uid, ts, gotMAC := parts[0], parts[1], parts[2]
|
||||
payload := uid + "|" + ts
|
||||
|
||||
wantMAC := preAuthMAC(s.deps.Cfg.SessionSecret, payload)
|
||||
if !hmac.Equal([]byte(gotMAC), []byte(wantMAC)) {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
tsi, err := strconv.ParseInt(ts, 10, 64)
|
||||
if err != nil || time.Now().Unix()-tsi > preAuthMaxAge {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
id, err := strconv.ParseInt(uid, 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
func clearPreAuth(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: preAuthCookieName,
|
||||
Value: "",
|
||||
Path: "/login",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
}
|
||||
|
||||
func preAuthMAC(secret []byte, payload string) string {
|
||||
mac := hmac.New(sha256.New, secret)
|
||||
mac.Write([]byte(payload))
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func mustMarshalCodes(codes []string) []byte {
|
||||
data, err := totp.EncodeRecoveryCodes(codes)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("totp marshal: %v", err))
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// ---- Shared helpers ----
|
||||
|
||||
func (s *Server) recordAttempt(ctx context.Context, ip, email string, success bool) {
|
||||
if s.deps.Brute != nil {
|
||||
s.deps.Brute.RecordAttempt(ctx, ip, email, success)
|
||||
}
|
||||
}
|
||||
|
||||
func realIP(r *http.Request) string {
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
parts := strings.Split(xff, ",")
|
||||
ip := strings.TrimSpace(parts[0])
|
||||
if net.ParseIP(ip) != nil {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
host, _, _ := net.SplitHostPort(r.RemoteAddr)
|
||||
return host
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package webclient
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"mime/quotedprintable"
|
||||
"net/mail"
|
||||
"net/textproto"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
appCrypto "ghb.freebede.com/nahakubuilder/mailgosend/internal/crypto"
|
||||
"ghb.freebede.com/nahakubuilder/mailgosend/internal/models"
|
||||
"ghb.freebede.com/nahakubuilder/mailgosend/internal/storage"
|
||||
)
|
||||
|
||||
// ComposeParams holds parsed compose form fields.
|
||||
type ComposeParams struct {
|
||||
From string // "Display Name <email@host>"
|
||||
FromEmail string // bare email
|
||||
To []string // each a valid RFC 5322 address
|
||||
CC []string
|
||||
BCC []string
|
||||
Subject string
|
||||
BodyText string
|
||||
InReplyTo string
|
||||
References string
|
||||
MessageID string // auto-generated if empty
|
||||
}
|
||||
|
||||
// BuildRFC5322 creates a raw RFC 5322 message.
|
||||
func BuildRFC5322(p *ComposeParams) ([]byte, error) {
|
||||
if p.MessageID == "" {
|
||||
randHex, err := appCrypto.RandomHex(16)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("message-id random: %w", err)
|
||||
}
|
||||
fromDomain := "localhost"
|
||||
if idx := strings.LastIndex(p.FromEmail, "@"); idx >= 0 {
|
||||
fromDomain = p.FromEmail[idx+1:]
|
||||
}
|
||||
p.MessageID = "<" + randHex + "@" + fromDomain + ">"
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
writeHeader := func(k, v string) {
|
||||
if v != "" {
|
||||
buf.WriteString(k + ": " + v + "\r\n")
|
||||
}
|
||||
}
|
||||
|
||||
writeHeader("From", p.From)
|
||||
writeHeader("To", strings.Join(p.To, ", "))
|
||||
if len(p.CC) > 0 {
|
||||
writeHeader("Cc", strings.Join(p.CC, ", "))
|
||||
}
|
||||
writeHeader("Subject", mime.QEncoding.Encode("utf-8", p.Subject))
|
||||
writeHeader("Date", time.Now().UTC().Format(time.RFC1123Z))
|
||||
writeHeader("Message-Id", p.MessageID)
|
||||
writeHeader("MIME-Version", "1.0")
|
||||
if p.InReplyTo != "" {
|
||||
writeHeader("In-Reply-To", p.InReplyTo)
|
||||
}
|
||||
if p.References != "" {
|
||||
writeHeader("References", p.References)
|
||||
}
|
||||
|
||||
// Write body as quoted-printable text/plain.
|
||||
mw := multipart.NewWriter(&buf)
|
||||
buf.WriteString("Content-Type: multipart/alternative; boundary=\"" + mw.Boundary() + "\"\r\n")
|
||||
buf.WriteString("\r\n")
|
||||
|
||||
// text/plain part
|
||||
th := make(textproto.MIMEHeader)
|
||||
th.Set("Content-Type", "text/plain; charset=utf-8")
|
||||
th.Set("Content-Transfer-Encoding", "quoted-printable")
|
||||
pw, err := mw.CreatePart(th)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create text part: %w", err)
|
||||
}
|
||||
qw := quotedprintable.NewWriter(pw)
|
||||
if _, err := qw.Write([]byte(p.BodyText)); err != nil {
|
||||
return nil, fmt.Errorf("write body: %w", err)
|
||||
}
|
||||
if err := qw.Close(); err != nil {
|
||||
return nil, fmt.Errorf("close qp: %w", err)
|
||||
}
|
||||
|
||||
if err := mw.Close(); err != nil {
|
||||
return nil, fmt.Errorf("close multipart: %w", err)
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// parseAddressList parses a comma-separated address string into valid email addresses.
|
||||
// Returns only the bare email addresses (no display names in returned slice).
|
||||
func parseAddressList(raw string) ([]string, error) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
addrs, err := mail.ParseAddressList(raw)
|
||||
if err != nil {
|
||||
// Try as a single address.
|
||||
addr, err2 := mail.ParseAddress(raw)
|
||||
if err2 != nil {
|
||||
return nil, fmt.Errorf("invalid address %q: %w", raw, err)
|
||||
}
|
||||
return []string{addr.Address}, nil
|
||||
}
|
||||
out := make([]string, 0, len(addrs))
|
||||
for _, a := range addrs {
|
||||
out = append(out, a.Address)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// addressListRFC formats a list of bare emails as RFC 5322 addresses.
|
||||
func addressListRFC(emails []string) []string {
|
||||
out := make([]string, len(emails))
|
||||
for i, e := range emails {
|
||||
out[i] = e
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// deliverLocally saves a message to a local recipient's INBOX.
|
||||
func (s *Server) deliverLocally(ctx context.Context, recipientEmail string, raw []byte, msg *storage.IncomingMessage) error {
|
||||
user, err := s.deps.DB.ResolveEmail(ctx, recipientEmail)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve %s: %w", recipientEmail, err)
|
||||
}
|
||||
if user == nil || !user.Enabled {
|
||||
return fmt.Errorf("recipient %s not found or disabled", recipientEmail)
|
||||
}
|
||||
|
||||
inbox, err := s.deps.DB.GetMailboxByType(ctx, user.ID, models.MailboxInbox)
|
||||
if err != nil || inbox == nil {
|
||||
return fmt.Errorf("inbox not found for %s", recipientEmail)
|
||||
}
|
||||
|
||||
if _, err := s.deps.Store.SaveIncoming(ctx, user.ID, inbox.ID, msg); err != nil {
|
||||
return fmt.Errorf("save incoming: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// saveSentCopy saves a copy of a sent message to the sender's Sent folder.
|
||||
func (s *Server) saveSentCopy(ctx context.Context, senderID int64, raw []byte, msg *storage.IncomingMessage) error {
|
||||
sentBox, err := s.deps.DB.GetMailboxByType(ctx, senderID, models.MailboxSent)
|
||||
if err != nil || sentBox == nil {
|
||||
return fmt.Errorf("sent mailbox not found")
|
||||
}
|
||||
_, err = s.deps.Store.SaveIncoming(ctx, senderID, sentBox.ID, msg)
|
||||
return err
|
||||
}
|
||||
|
||||
// enqueueForDelivery adds a message to the delivery queue for a remote recipient.
|
||||
func (s *Server) enqueueForDelivery(ctx context.Context, fromEmail, toEmail string, raw []byte, msgID string) error {
|
||||
// Determine domain for queue domain_id (best effort, nil if not local).
|
||||
fromDomain := ""
|
||||
if idx := strings.LastIndex(fromEmail, "@"); idx >= 0 {
|
||||
fromDomain = fromEmail[idx+1:]
|
||||
}
|
||||
var domainID *int64
|
||||
if dom, err := s.deps.DB.GetDomain(ctx, fromDomain); err == nil && dom != nil {
|
||||
domainID = &dom.ID
|
||||
}
|
||||
|
||||
maxAge := s.deps.Cfg.QueueMaxAgeHours
|
||||
if maxAge <= 0 {
|
||||
maxAge = 72
|
||||
}
|
||||
|
||||
key, err := s.deps.Crypt.DeriveKeyGlobal("queue")
|
||||
if err != nil {
|
||||
return fmt.Errorf("queue key: %w", err)
|
||||
}
|
||||
rawEnc, err := appCrypto.Encrypt(key, raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encrypt queue: %w", err)
|
||||
}
|
||||
|
||||
domID := int64(0)
|
||||
if domainID != nil {
|
||||
domID = *domainID
|
||||
}
|
||||
_, err = s.deps.DB.EnqueueMessage(ctx, domID, fromEmail, toEmail, msgID, rawEnc, maxAge)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,904 @@
|
||||
package webclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
appCrypto "ghb.freebede.com/nahakubuilder/mailgosend/internal/crypto"
|
||||
"ghb.freebede.com/nahakubuilder/mailgosend/internal/db"
|
||||
"ghb.freebede.com/nahakubuilder/mailgosend/internal/models"
|
||||
"ghb.freebede.com/nahakubuilder/mailgosend/internal/storage"
|
||||
"ghb.freebede.com/nahakubuilder/mailgosend/internal/totp"
|
||||
)
|
||||
|
||||
const messagesPerPage = 50
|
||||
|
||||
// ---- Root redirect ----
|
||||
|
||||
func (s *Server) rootRedirect(w http.ResponseWriter, r *http.Request) {
|
||||
user := s.currentUser(r)
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
inbox, err := s.deps.DB.GetMailboxByType(ctx, user.ID, models.MailboxInbox)
|
||||
if err != nil || inbox == nil {
|
||||
redirect(w, r, "/settings", "", "No inbox found. Contact your admin.")
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, fmt.Sprintf("/mail/%d", inbox.ID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// ---- Mailbox view (message list) ----
|
||||
|
||||
type mailboxPage struct {
|
||||
basePage
|
||||
CurrentBox *models.Mailbox
|
||||
Messages []*db.IMAPMessage
|
||||
Query string
|
||||
PrevPage uint32 // UID of last message on prev page (0 = none)
|
||||
NextPage uint32 // UID of first message on next page (0 = none)
|
||||
TotalCount int
|
||||
}
|
||||
|
||||
func (s *Server) mailboxView(w http.ResponseWriter, r *http.Request) {
|
||||
user := s.currentUser(r)
|
||||
boxID := pathID(r, "boxid")
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
box, err := s.deps.DB.GetMailboxByID(ctx, boxID)
|
||||
if err != nil || box == nil || box.UserID != user.ID {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
msgs, err := s.deps.DB.ListIMAPMessages(ctx, boxID)
|
||||
if err != nil {
|
||||
log.Printf("[webmail] list messages: %v", err)
|
||||
msgs = nil
|
||||
}
|
||||
|
||||
// Reverse for newest-first.
|
||||
for i, j := 0, len(msgs)-1; i < j; i, j = i+1, j-1 {
|
||||
msgs[i], msgs[j] = msgs[j], msgs[i]
|
||||
}
|
||||
|
||||
// Apply search filter.
|
||||
query := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
if query != "" {
|
||||
ql := strings.ToLower(query)
|
||||
filtered := msgs[:0]
|
||||
for _, m := range msgs {
|
||||
if strings.Contains(strings.ToLower(m.Subject), ql) ||
|
||||
strings.Contains(strings.ToLower(m.FromEmail), ql) ||
|
||||
strings.Contains(strings.ToLower(m.FromName), ql) {
|
||||
filtered = append(filtered, m)
|
||||
}
|
||||
}
|
||||
msgs = filtered
|
||||
}
|
||||
|
||||
total := len(msgs)
|
||||
|
||||
// Pagination: "before" UID (load messages with UID < before).
|
||||
var prevPage, nextPage uint32
|
||||
beforeUID, _ := strconv.ParseUint(r.URL.Query().Get("before"), 10, 32)
|
||||
if beforeUID > 0 {
|
||||
// Filter messages with UID < beforeUID.
|
||||
cutEnd := len(msgs)
|
||||
for i, m := range msgs {
|
||||
if uint64(m.UID) < beforeUID {
|
||||
cutEnd = i + messagesPerPage
|
||||
if cutEnd > len(msgs) {
|
||||
cutEnd = len(msgs)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
// Find start.
|
||||
cutStart := 0
|
||||
for i, m := range msgs {
|
||||
if uint64(m.UID) < beforeUID {
|
||||
cutStart = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if cutStart > 0 {
|
||||
prevPage = msgs[cutStart-1].UID
|
||||
}
|
||||
msgs = msgs[cutStart:cutEnd]
|
||||
} else {
|
||||
// First page: newest messagesPerPage.
|
||||
if len(msgs) > messagesPerPage {
|
||||
nextPage = msgs[messagesPerPage].UID
|
||||
msgs = msgs[:messagesPerPage]
|
||||
}
|
||||
}
|
||||
|
||||
flash, errMsg := flashFrom(r)
|
||||
base := s.newBase(r, flash, errMsg)
|
||||
base.CurrentBoxID = boxID
|
||||
s.render(w, "mail", mailboxPage{
|
||||
basePage: base,
|
||||
CurrentBox: box,
|
||||
Messages: msgs,
|
||||
Query: query,
|
||||
PrevPage: prevPage,
|
||||
NextPage: nextPage,
|
||||
TotalCount: total,
|
||||
})
|
||||
}
|
||||
|
||||
// ---- Message view ----
|
||||
|
||||
type messagePage struct {
|
||||
basePage
|
||||
CurrentBox *models.Mailbox
|
||||
Message *db.IMAPMessage
|
||||
Body *storage.BodyParts
|
||||
PrevUID uint32
|
||||
NextUID uint32
|
||||
}
|
||||
|
||||
func (s *Server) messageView(w http.ResponseWriter, r *http.Request) {
|
||||
user := s.currentUser(r)
|
||||
boxID := pathID(r, "boxid")
|
||||
uid64, err := strconv.ParseUint(r.PathValue("uid"), 10, 32)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
uid := uint32(uid64)
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
box, err := s.deps.DB.GetMailboxByID(ctx, boxID)
|
||||
if err != nil || box == nil || box.UserID != user.ID {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
msg, err := s.deps.DB.GetIMAPMessageByUID(ctx, boxID, uid)
|
||||
if err != nil || msg == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-mark as read.
|
||||
if !msg.IsRead {
|
||||
if err := s.deps.DB.SetMessageFlags(ctx, msg.ID, true, msg.IsStarred, msg.IsDraft, msg.Flags); err != nil {
|
||||
log.Printf("[webmail] mark read: %v", err)
|
||||
} else {
|
||||
msg.IsRead = true
|
||||
}
|
||||
}
|
||||
|
||||
// Decrypt body parts.
|
||||
body, err := s.deps.Store.GetBodyParts(ctx, user.ID, msg.ID)
|
||||
if err != nil {
|
||||
log.Printf("[webmail] get body: %v", err)
|
||||
body = &storage.BodyParts{Text: "[Error loading message body]"}
|
||||
}
|
||||
|
||||
// Prev/next UIDs for navigation.
|
||||
msgs, _ := s.deps.DB.ListIMAPMessages(ctx, boxID)
|
||||
var prevUID, nextUID uint32
|
||||
for i, m := range msgs {
|
||||
if m.UID == uid {
|
||||
if i > 0 {
|
||||
nextUID = msgs[i-1].UID // list is ascending, so "next newer"
|
||||
}
|
||||
if i < len(msgs)-1 {
|
||||
prevUID = msgs[i+1].UID // older
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
flash, errMsg := flashFrom(r)
|
||||
base := s.newBase(r, flash, errMsg)
|
||||
base.CurrentBoxID = boxID
|
||||
s.render(w, "message", messagePage{
|
||||
basePage: base,
|
||||
CurrentBox: box,
|
||||
Message: msg,
|
||||
Body: body,
|
||||
PrevUID: prevUID,
|
||||
NextUID: nextUID,
|
||||
})
|
||||
}
|
||||
|
||||
// ---- Message actions ----
|
||||
|
||||
func (s *Server) messageFlag(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.validateCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
user := s.currentUser(r)
|
||||
boxID := pathID(r, "boxid")
|
||||
uid64, _ := strconv.ParseUint(r.PathValue("uid"), 10, 32)
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
box, err := s.deps.DB.GetMailboxByID(ctx, boxID)
|
||||
if err != nil || box == nil || box.UserID != user.ID {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
msg, err := s.deps.DB.GetIMAPMessageByUID(ctx, boxID, uint32(uid64))
|
||||
if err != nil || msg == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
flag := r.FormValue("flag")
|
||||
isRead := msg.IsRead
|
||||
isStar := msg.IsStarred
|
||||
switch flag {
|
||||
case "read":
|
||||
isRead = !isRead
|
||||
case "star":
|
||||
isStar = !isStar
|
||||
}
|
||||
|
||||
if err := s.deps.DB.SetMessageFlags(ctx, msg.ID, isRead, isStar, msg.IsDraft, msg.Flags); err != nil {
|
||||
log.Printf("[webmail] flag: %v", err)
|
||||
}
|
||||
|
||||
// Return to message or mailbox depending on referrer.
|
||||
returnTo := r.FormValue("return")
|
||||
if returnTo == "" {
|
||||
returnTo = fmt.Sprintf("/mail/%d/%d", boxID, uid64)
|
||||
}
|
||||
http.Redirect(w, r, returnTo, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) messageTrash(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.validateCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
user := s.currentUser(r)
|
||||
boxID := pathID(r, "boxid")
|
||||
uid64, _ := strconv.ParseUint(r.PathValue("uid"), 10, 32)
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
box, err := s.deps.DB.GetMailboxByID(ctx, boxID)
|
||||
if err != nil || box == nil || box.UserID != user.ID {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
msg, err := s.deps.DB.GetIMAPMessageByUID(ctx, boxID, uint32(uid64))
|
||||
if err != nil || msg == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if box.Type == models.MailboxTrash {
|
||||
// Already in trash: hard delete.
|
||||
if err := s.deps.DB.SoftDeleteMessage(ctx, msg.ID); err != nil {
|
||||
log.Printf("[webmail] soft delete: %v", err)
|
||||
}
|
||||
if _, err := s.deps.DB.HardDeleteMessages(ctx, boxID); err != nil {
|
||||
log.Printf("[webmail] hard delete: %v", err)
|
||||
}
|
||||
redirect(w, r, fmt.Sprintf("/mail/%d", boxID), "Message permanently deleted.", "")
|
||||
return
|
||||
}
|
||||
|
||||
// Move to trash: copy then soft-delete original.
|
||||
trashBox, err := s.deps.DB.GetMailboxByType(ctx, user.ID, models.MailboxTrash)
|
||||
if err != nil || trashBox == nil {
|
||||
redirect(w, r, fmt.Sprintf("/mail/%d", boxID), "", "Trash folder not found.")
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := s.deps.DB.CopyMessageToMailbox(ctx, msg.ID, trashBox.ID, user.ID); err != nil {
|
||||
log.Printf("[webmail] copy to trash: %v", err)
|
||||
redirect(w, r, fmt.Sprintf("/mail/%d", boxID), "", "Move to trash failed.")
|
||||
return
|
||||
}
|
||||
if err := s.deps.DB.SoftDeleteMessage(ctx, msg.ID); err != nil {
|
||||
log.Printf("[webmail] soft delete orig: %v", err)
|
||||
}
|
||||
|
||||
redirect(w, r, fmt.Sprintf("/mail/%d", boxID), "Moved to trash.", "")
|
||||
}
|
||||
|
||||
func (s *Server) messageMove(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.validateCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
user := s.currentUser(r)
|
||||
boxID := pathID(r, "boxid")
|
||||
uid64, _ := strconv.ParseUint(r.PathValue("uid"), 10, 32)
|
||||
destBoxID, _ := strconv.ParseInt(r.FormValue("dest_box"), 10, 64)
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
box, err := s.deps.DB.GetMailboxByID(ctx, boxID)
|
||||
if err != nil || box == nil || box.UserID != user.ID {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
destBox, err := s.deps.DB.GetMailboxByID(ctx, destBoxID)
|
||||
if err != nil || destBox == nil || destBox.UserID != user.ID {
|
||||
redirect(w, r, fmt.Sprintf("/mail/%d", boxID), "", "Destination folder not found.")
|
||||
return
|
||||
}
|
||||
|
||||
msg, err := s.deps.DB.GetIMAPMessageByUID(ctx, boxID, uint32(uid64))
|
||||
if err != nil || msg == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := s.deps.DB.CopyMessageToMailbox(ctx, msg.ID, destBoxID, user.ID); err != nil {
|
||||
redirect(w, r, fmt.Sprintf("/mail/%d", boxID), "", "Move failed.")
|
||||
return
|
||||
}
|
||||
if err := s.deps.DB.SoftDeleteMessage(ctx, msg.ID); err != nil {
|
||||
log.Printf("[webmail] soft delete on move: %v", err)
|
||||
}
|
||||
|
||||
redirect(w, r, fmt.Sprintf("/mail/%d", boxID), "Moved.", "")
|
||||
}
|
||||
|
||||
func (s *Server) mailboxExpunge(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.validateCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
user := s.currentUser(r)
|
||||
boxID := pathID(r, "boxid")
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
box, err := s.deps.DB.GetMailboxByID(ctx, boxID)
|
||||
if err != nil || box == nil || box.UserID != user.ID {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Mark all as deleted first (for boxes that only soft-delete).
|
||||
if _, err := s.deps.DB.HardDeleteMessages(ctx, boxID); err != nil {
|
||||
log.Printf("[webmail] expunge: %v", err)
|
||||
redirect(w, r, fmt.Sprintf("/mail/%d", boxID), "", "Expunge failed.")
|
||||
return
|
||||
}
|
||||
redirect(w, r, fmt.Sprintf("/mail/%d", boxID), "Expunged.", "")
|
||||
}
|
||||
|
||||
// ---- Compose ----
|
||||
|
||||
type composePage struct {
|
||||
basePage
|
||||
To string
|
||||
CC string
|
||||
Subject string
|
||||
BodyText string
|
||||
InReplyTo string
|
||||
References string
|
||||
}
|
||||
|
||||
func (s *Server) composeGet(w http.ResponseWriter, r *http.Request) {
|
||||
user := s.currentUser(r)
|
||||
flash, errMsg := flashFrom(r)
|
||||
base := s.newBase(r, flash, errMsg)
|
||||
|
||||
p := composePage{basePage: base}
|
||||
|
||||
// Handle reply/forward.
|
||||
action := r.URL.Query().Get("action")
|
||||
boxID, _ := strconv.ParseInt(r.URL.Query().Get("boxid"), 10, 64)
|
||||
uid64, _ := strconv.ParseUint(r.URL.Query().Get("uid"), 10, 32)
|
||||
|
||||
if (action == "reply" || action == "forward") && boxID > 0 && uid64 > 0 {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
box, err := s.deps.DB.GetMailboxByID(ctx, boxID)
|
||||
if err == nil && box != nil && box.UserID == user.ID {
|
||||
orig, err := s.deps.DB.GetIMAPMessageByUID(ctx, boxID, uint32(uid64))
|
||||
if err == nil && orig != nil {
|
||||
body, err := s.deps.Store.GetBodyParts(ctx, user.ID, orig.ID)
|
||||
if err != nil {
|
||||
body = &storage.BodyParts{}
|
||||
}
|
||||
|
||||
if action == "reply" {
|
||||
replyAddr := orig.FromEmail
|
||||
if orig.FromName != "" {
|
||||
replyAddr = orig.FromName + " <" + orig.FromEmail + ">"
|
||||
}
|
||||
p.To = replyAddr
|
||||
p.Subject = reSubject(orig.Subject)
|
||||
p.InReplyTo = orig.MessageID
|
||||
p.References = orig.MessageID
|
||||
p.BodyText = quoteBody(orig.FromEmail, orig.Date, body.Text)
|
||||
} else {
|
||||
p.Subject = fwdSubject(orig.Subject)
|
||||
p.BodyText = fwdBody(orig.FromEmail, orig.ToList, orig.Date, orig.Subject, body.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
s.render(w, "compose", p)
|
||||
}
|
||||
|
||||
func (s *Server) composeSend(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.validateCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
user := s.currentUser(r)
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
toRaw := strings.TrimSpace(r.FormValue("to"))
|
||||
ccRaw := strings.TrimSpace(r.FormValue("cc"))
|
||||
bccRaw := strings.TrimSpace(r.FormValue("bcc"))
|
||||
subject := strings.TrimSpace(r.FormValue("subject"))
|
||||
bodyText := r.FormValue("body")
|
||||
inReplyTo := strings.TrimSpace(r.FormValue("in_reply_to"))
|
||||
references := strings.TrimSpace(r.FormValue("references"))
|
||||
|
||||
if toRaw == "" {
|
||||
redirect(w, r, "/compose", "", "To field is required.")
|
||||
return
|
||||
}
|
||||
if len(subject) > 998 {
|
||||
subject = subject[:998]
|
||||
}
|
||||
if len(bodyText) > 10*1024*1024 {
|
||||
redirect(w, r, "/compose", "", "Message body too large (max 10 MB).")
|
||||
return
|
||||
}
|
||||
|
||||
toAddrs, err := parseAddressList(toRaw)
|
||||
if err != nil || len(toAddrs) == 0 {
|
||||
redirect(w, r, "/compose", "", "Invalid To address: "+err.Error())
|
||||
return
|
||||
}
|
||||
ccAddrs, _ := parseAddressList(ccRaw)
|
||||
bccAddrs, _ := parseAddressList(bccRaw)
|
||||
|
||||
// Build from address.
|
||||
displayName := user.DisplayName
|
||||
if displayName == "" {
|
||||
displayName = user.Username
|
||||
}
|
||||
fromAddr := mail.Address{Name: displayName, Address: user.Email}
|
||||
fromRFC := fromAddr.String()
|
||||
|
||||
allRecipients := append(append(toAddrs, ccAddrs...), bccAddrs...)
|
||||
|
||||
p := &ComposeParams{
|
||||
From: fromRFC,
|
||||
FromEmail: user.Email,
|
||||
To: addressListRFC(toAddrs),
|
||||
CC: addressListRFC(ccAddrs),
|
||||
BCC: addressListRFC(bccAddrs),
|
||||
Subject: subject,
|
||||
BodyText: bodyText,
|
||||
InReplyTo: inReplyTo,
|
||||
References: references,
|
||||
}
|
||||
|
||||
raw, err := BuildRFC5322(p)
|
||||
if err != nil {
|
||||
log.Printf("[webmail] build message: %v", err)
|
||||
redirect(w, r, "/compose", "", "Failed to build message.")
|
||||
return
|
||||
}
|
||||
|
||||
incomingMsg := &storage.IncomingMessage{
|
||||
Raw: raw,
|
||||
FromEmail: user.Email,
|
||||
FromName: displayName,
|
||||
ToList: strings.Join(toAddrs, ", "),
|
||||
CCList: strings.Join(ccAddrs, ", "),
|
||||
BCCList: strings.Join(bccAddrs, ", "),
|
||||
Subject: subject,
|
||||
Date: time.Now().UTC(),
|
||||
MessageID: p.MessageID,
|
||||
}
|
||||
|
||||
// Save to Sent.
|
||||
if err := s.saveSentCopy(ctx, user.ID, raw, incomingMsg); err != nil {
|
||||
log.Printf("[webmail] save sent: %v", err)
|
||||
}
|
||||
|
||||
// Deliver to each recipient.
|
||||
var deliveryErrors []string
|
||||
for _, rcpt := range allRecipients {
|
||||
rcptDomain := ""
|
||||
if idx := strings.LastIndex(rcpt, "@"); idx >= 0 {
|
||||
rcptDomain = rcpt[idx+1:]
|
||||
}
|
||||
|
||||
// Check if local domain.
|
||||
isLocal := false
|
||||
if rcptDomain != "" {
|
||||
isLocal, _ = s.deps.DB.IsLocalDomain(ctx, rcptDomain)
|
||||
}
|
||||
|
||||
if isLocal {
|
||||
if err := s.deliverLocally(ctx, rcpt, raw, incomingMsg); err != nil {
|
||||
log.Printf("[webmail] local deliver %s: %v", rcpt, err)
|
||||
deliveryErrors = append(deliveryErrors, rcpt+": "+err.Error())
|
||||
}
|
||||
} else {
|
||||
if err := s.enqueueForDelivery(ctx, user.Email, rcpt, raw, p.MessageID); err != nil {
|
||||
log.Printf("[webmail] enqueue %s: %v", rcpt, err)
|
||||
deliveryErrors = append(deliveryErrors, rcpt+": queued (may fail)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(deliveryErrors) > 0 {
|
||||
redirect(w, r, "/compose", "", "Sent with errors: "+strings.Join(deliveryErrors, "; "))
|
||||
return
|
||||
}
|
||||
|
||||
// Redirect to Sent folder.
|
||||
sentBox, _ := s.deps.DB.GetMailboxByType(ctx, user.ID, models.MailboxSent)
|
||||
if sentBox != nil {
|
||||
redirect(w, r, fmt.Sprintf("/mail/%d", sentBox.ID), "Message sent.", "")
|
||||
} else {
|
||||
redirect(w, r, "/", "Message sent.", "")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Settings ----
|
||||
|
||||
type settingsPage struct {
|
||||
basePage
|
||||
AccountUser *models.User
|
||||
}
|
||||
|
||||
func (s *Server) settingsGet(w http.ResponseWriter, r *http.Request) {
|
||||
user := s.currentUser(r)
|
||||
flash, errMsg := flashFrom(r)
|
||||
s.render(w, "settings", settingsPage{
|
||||
basePage: s.newBase(r, flash, errMsg),
|
||||
AccountUser: user,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) settingsPassword(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.validateCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
user := s.currentUser(r)
|
||||
|
||||
current := r.FormValue("current_password")
|
||||
newPw := r.FormValue("new_password")
|
||||
confirm := r.FormValue("confirm_password")
|
||||
|
||||
if err := appCrypto.CheckPassword(user.PasswordHash, current); err != nil {
|
||||
redirect(w, r, "/settings", "", "Current password is incorrect.")
|
||||
return
|
||||
}
|
||||
if len(newPw) < 8 || len(newPw) > 1024 {
|
||||
redirect(w, r, "/settings", "", "New password must be 8-1024 characters.")
|
||||
return
|
||||
}
|
||||
if newPw != confirm {
|
||||
redirect(w, r, "/settings", "", "Passwords do not match.")
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := appCrypto.HashPassword(newPw)
|
||||
if err != nil {
|
||||
redirect(w, r, "/settings", "", "Password error.")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := s.deps.DB.SetUserPassword(ctx, user.ID, hash); err != nil {
|
||||
log.Printf("[webmail] set password: %v", err)
|
||||
redirect(w, r, "/settings", "", "Failed to update password.")
|
||||
return
|
||||
}
|
||||
|
||||
// Log out all other sessions for security.
|
||||
if err := s.deps.Sessions.DestroyAll(ctx, user.ID); err != nil {
|
||||
log.Printf("[webmail] destroy sessions: %v", err)
|
||||
}
|
||||
// Create fresh session for current request.
|
||||
if _, err := s.deps.Sessions.Create(w, r, user.ID); err != nil {
|
||||
log.Printf("[webmail] re-create session: %v", err)
|
||||
}
|
||||
|
||||
redirect(w, r, "/settings", "Password updated. All other sessions logged out.", "")
|
||||
}
|
||||
|
||||
func (s *Server) settingsDisplay(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.validateCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
user := s.currentUser(r)
|
||||
displayName := strings.TrimSpace(r.FormValue("display_name"))
|
||||
if len(displayName) > 255 {
|
||||
displayName = displayName[:255]
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := s.deps.DB.SetUserDisplayName(ctx, user.ID, displayName); err != nil {
|
||||
log.Printf("[webmail] display name: %v", err)
|
||||
redirect(w, r, "/settings", "", "Update failed.")
|
||||
return
|
||||
}
|
||||
redirect(w, r, "/settings", "Display name updated.", "")
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
func pathID(r *http.Request, key string) int64 {
|
||||
id, _ := strconv.ParseInt(r.PathValue(key), 10, 64)
|
||||
return id
|
||||
}
|
||||
|
||||
func reSubject(s string) string {
|
||||
low := strings.ToLower(s)
|
||||
if strings.HasPrefix(low, "re:") {
|
||||
return s
|
||||
}
|
||||
return "Re: " + s
|
||||
}
|
||||
|
||||
func fwdSubject(s string) string {
|
||||
low := strings.ToLower(s)
|
||||
if strings.HasPrefix(low, "fwd:") || strings.HasPrefix(low, "fw:") {
|
||||
return s
|
||||
}
|
||||
return "Fwd: " + s
|
||||
}
|
||||
|
||||
func quoteBody(from string, date time.Time, text string) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("\r\n\r\n")
|
||||
sb.WriteString("On " + date.Format("Mon, 2 Jan 2006 at 15:04") + ", " + from + " wrote:\r\n")
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
sb.WriteString("> " + strings.TrimRight(line, "\r") + "\r\n")
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func fwdBody(from, to string, date time.Time, subject, text string) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("\r\n\r\n-------- Forwarded Message --------\r\n")
|
||||
sb.WriteString("From: " + from + "\r\n")
|
||||
sb.WriteString("To: " + to + "\r\n")
|
||||
sb.WriteString("Date: " + date.Format(time.RFC1123Z) + "\r\n")
|
||||
sb.WriteString("Subject: " + subject + "\r\n\r\n")
|
||||
sb.WriteString(text)
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// ---- TOTP / MFA enrollment ----
|
||||
|
||||
type mfaEnrollPage struct {
|
||||
basePage
|
||||
Secret string // base32, shown for manual entry
|
||||
OTPAuthURI string // otpauth:// URI for QR code
|
||||
}
|
||||
|
||||
// mfaEnrollGet generates a new TOTP secret, stores it unconfirmed in a signed
|
||||
// session cookie, and renders the enrollment form.
|
||||
func (s *Server) mfaEnrollGet(w http.ResponseWriter, r *http.Request) {
|
||||
user := s.currentUser(r)
|
||||
flash, errMsg := flashFrom(r)
|
||||
|
||||
secret, err := totp.GenerateSecret()
|
||||
if err != nil {
|
||||
log.Printf("[webmail] mfa generate: %v", err)
|
||||
redirect(w, r, "/settings", "", "Failed to generate MFA secret.")
|
||||
return
|
||||
}
|
||||
|
||||
issuer := s.deps.Cfg.DefaultDomain
|
||||
if issuer == "" {
|
||||
issuer = s.deps.Cfg.Hostname
|
||||
}
|
||||
if issuer == "" {
|
||||
issuer = "mailgosend"
|
||||
}
|
||||
uri := totp.OTPAuthURI(secret, user.Email, issuer)
|
||||
|
||||
// Stash pending secret in a short-lived signed cookie so that the POST
|
||||
// can verify the code before persisting to the DB.
|
||||
s.setPendingTOTPCookie(w, user.ID, secret)
|
||||
|
||||
s.render(w, "mfa_enroll", mfaEnrollPage{
|
||||
basePage: s.newBase(r, flash, errMsg),
|
||||
Secret: secret,
|
||||
OTPAuthURI: uri,
|
||||
})
|
||||
}
|
||||
|
||||
// mfaEnrollPost verifies the TOTP code from the enrollment form and, on
|
||||
// success, encrypts + persists the secret and generates recovery codes.
|
||||
func (s *Server) mfaEnrollPost(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.validateCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
user := s.currentUser(r)
|
||||
|
||||
code := strings.TrimSpace(r.FormValue("code"))
|
||||
if len(code) != totp.Digits {
|
||||
redirect(w, r, "/settings/mfa/enroll", "", "Enter the 6-digit code from your authenticator app.")
|
||||
return
|
||||
}
|
||||
|
||||
// Read and validate pending secret cookie.
|
||||
secret, ok := s.pendingTOTPSecret(r, user.ID)
|
||||
if !ok {
|
||||
redirect(w, r, "/settings/mfa/enroll", "", "Enrollment session expired. Please start over.")
|
||||
return
|
||||
}
|
||||
|
||||
if !totp.Verify(secret, code) {
|
||||
redirect(w, r, "/settings/mfa/enroll", "", "Code did not match. Check your authenticator and try again.")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Encrypt and store secret.
|
||||
encSecret, err := s.deps.Crypt.EncryptForUser(user.ID, "totp", []byte(secret))
|
||||
if err != nil {
|
||||
log.Printf("[webmail] mfa encrypt secret: %v", err)
|
||||
redirect(w, r, "/settings", "", "MFA setup failed.")
|
||||
return
|
||||
}
|
||||
if err := s.deps.DB.SetMFASecret(ctx, user.ID, encSecret); err != nil {
|
||||
log.Printf("[webmail] mfa save secret: %v", err)
|
||||
redirect(w, r, "/settings", "", "MFA setup failed.")
|
||||
return
|
||||
}
|
||||
|
||||
// Generate and store recovery codes.
|
||||
codes, err := totp.GenerateRecoveryCodes()
|
||||
if err != nil {
|
||||
log.Printf("[webmail] mfa recovery codes: %v", err)
|
||||
} else {
|
||||
codesJSON, _ := totp.EncodeRecoveryCodes(codes)
|
||||
encCodes, encErr := s.deps.Crypt.EncryptForUser(user.ID, "recovery", codesJSON)
|
||||
if encErr == nil {
|
||||
_ = s.deps.DB.SetRecoveryCodes(ctx, user.ID, encCodes)
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.deps.DB.SetMFAEnabled(ctx, user.ID, true); err != nil {
|
||||
log.Printf("[webmail] mfa enable: %v", err)
|
||||
redirect(w, r, "/settings", "", "MFA setup failed.")
|
||||
return
|
||||
}
|
||||
|
||||
clearPendingTOTP(w)
|
||||
redirect(w, r, "/settings", "Two-factor authentication enabled.", "")
|
||||
}
|
||||
|
||||
// mfaDisable disables MFA after verifying current password.
|
||||
func (s *Server) mfaDisable(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.validateCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
user := s.currentUser(r)
|
||||
|
||||
pw := r.FormValue("password")
|
||||
if err := appCrypto.CheckPassword(user.PasswordHash, pw); err != nil {
|
||||
redirect(w, r, "/settings", "", "Incorrect password. MFA not disabled.")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := s.deps.DB.ClearMFA(ctx, user.ID); err != nil {
|
||||
log.Printf("[webmail] mfa disable: %v", err)
|
||||
redirect(w, r, "/settings", "", "Failed to disable MFA.")
|
||||
return
|
||||
}
|
||||
redirect(w, r, "/settings", "Two-factor authentication disabled.", "")
|
||||
}
|
||||
|
||||
// ---- Pending TOTP cookie (enrollment flow) ----
|
||||
|
||||
const pendingTOTPCookie = "mailgo_enroll"
|
||||
const pendingTOTPMaxAge = 300
|
||||
|
||||
func (s *Server) setPendingTOTPCookie(w http.ResponseWriter, userID int64, secret string) {
|
||||
uid := fmt.Sprintf("%d", userID)
|
||||
ts := fmt.Sprintf("%d", time.Now().Unix())
|
||||
payload := uid + "|" + ts + "|" + secret
|
||||
mac := preAuthMAC(s.deps.Cfg.SessionSecret, payload)
|
||||
value := payload + "|" + mac
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: pendingTOTPCookie,
|
||||
Value: value,
|
||||
Path: "/settings/mfa",
|
||||
MaxAge: pendingTOTPMaxAge,
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) pendingTOTPSecret(r *http.Request, userID int64) (string, bool) {
|
||||
c, err := r.Cookie(pendingTOTPCookie)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
// Split from the right: last segment is MAC.
|
||||
idx := strings.LastIndex(c.Value, "|")
|
||||
if idx < 0 {
|
||||
return "", false
|
||||
}
|
||||
payload := c.Value[:idx]
|
||||
gotMAC := c.Value[idx+1:]
|
||||
|
||||
wantMAC := preAuthMAC(s.deps.Cfg.SessionSecret, payload)
|
||||
if !strings.EqualFold(gotMAC, wantMAC) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// payload = uid|ts|secret
|
||||
parts := strings.SplitN(payload, "|", 3)
|
||||
if len(parts) != 3 {
|
||||
return "", false
|
||||
}
|
||||
uid, ts, secret := parts[0], parts[1], parts[2]
|
||||
|
||||
id, err := strconv.ParseInt(uid, 10, 64)
|
||||
if err != nil || id != userID {
|
||||
return "", false
|
||||
}
|
||||
|
||||
tsi, err := strconv.ParseInt(ts, 10, 64)
|
||||
if err != nil || time.Now().Unix()-tsi > pendingTOTPMaxAge {
|
||||
return "", false
|
||||
}
|
||||
|
||||
if secret == "" {
|
||||
return "", false
|
||||
}
|
||||
return secret, true
|
||||
}
|
||||
|
||||
func clearPendingTOTP(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: pendingTOTPCookie,
|
||||
Value: "",
|
||||
Path: "/settings/mfa",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
// Package webclient provides the webmail HTTP client (default: 0.0.0.0:8080).
|
||||
package webclient
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"ghb.freebede.com/nahakubuilder/mailgosend/internal/auth"
|
||||
"ghb.freebede.com/nahakubuilder/mailgosend/internal/config"
|
||||
appCrypto "ghb.freebede.com/nahakubuilder/mailgosend/internal/crypto"
|
||||
"ghb.freebede.com/nahakubuilder/mailgosend/internal/db"
|
||||
"ghb.freebede.com/nahakubuilder/mailgosend/internal/models"
|
||||
"ghb.freebede.com/nahakubuilder/mailgosend/internal/storage"
|
||||
)
|
||||
|
||||
// Deps groups all dependencies for the webclient.
|
||||
type Deps struct {
|
||||
DB *db.DB
|
||||
Crypt *appCrypto.Crypto
|
||||
Sessions *auth.SessionStore
|
||||
Brute *auth.BruteGuard
|
||||
Store *storage.Store
|
||||
Cfg *config.Config
|
||||
FS fs.FS // embed.FS sub-rooted at web/client
|
||||
}
|
||||
|
||||
// Server handles all webclient HTTP routes.
|
||||
type Server struct {
|
||||
deps *Deps
|
||||
mux *http.ServeMux
|
||||
}
|
||||
|
||||
// New creates a webclient Server and registers all routes.
|
||||
func New(deps *Deps) *Server {
|
||||
s := &Server{deps: deps, mux: http.NewServeMux()}
|
||||
s.setupRoutes()
|
||||
return s
|
||||
}
|
||||
|
||||
// Handler returns the HTTP handler.
|
||||
func (s *Server) Handler() http.Handler {
|
||||
return logMiddleware(s.mux)
|
||||
}
|
||||
|
||||
// ---- Routing ----
|
||||
|
||||
func (s *Server) setupRoutes() {
|
||||
m := s.mux
|
||||
|
||||
// Public
|
||||
m.HandleFunc("GET /login", s.loginGet)
|
||||
m.HandleFunc("POST /login", s.loginPost)
|
||||
m.HandleFunc("GET /login/mfa", s.mfaGet)
|
||||
m.HandleFunc("POST /login/mfa", s.mfaPost)
|
||||
m.HandleFunc("GET /logout", s.logout)
|
||||
|
||||
// Root redirect
|
||||
m.HandleFunc("GET /{$}", s.require(s.rootRedirect))
|
||||
|
||||
// Mailbox + message routes
|
||||
m.HandleFunc("GET /mail/{boxid}", s.require(s.mailboxView))
|
||||
m.HandleFunc("GET /mail/{boxid}/{uid}", s.require(s.messageView))
|
||||
m.HandleFunc("POST /mail/{boxid}/{uid}/flag", s.require(s.messageFlag))
|
||||
m.HandleFunc("POST /mail/{boxid}/{uid}/trash", s.require(s.messageTrash))
|
||||
m.HandleFunc("POST /mail/{boxid}/{uid}/move", s.require(s.messageMove))
|
||||
m.HandleFunc("POST /mail/{boxid}/expunge", s.require(s.mailboxExpunge))
|
||||
|
||||
// Compose
|
||||
m.HandleFunc("GET /compose", s.require(s.composeGet))
|
||||
m.HandleFunc("POST /compose", s.require(s.composeSend))
|
||||
|
||||
// Settings
|
||||
m.HandleFunc("GET /settings", s.require(s.settingsGet))
|
||||
m.HandleFunc("POST /settings/password", s.require(s.settingsPassword))
|
||||
m.HandleFunc("POST /settings/display", s.require(s.settingsDisplay))
|
||||
m.HandleFunc("GET /settings/mfa/enroll", s.require(s.mfaEnrollGet))
|
||||
m.HandleFunc("POST /settings/mfa/enroll", s.require(s.mfaEnrollPost))
|
||||
m.HandleFunc("POST /settings/mfa/disable", s.require(s.mfaDisable))
|
||||
|
||||
// Static assets
|
||||
static, _ := fs.Sub(s.deps.FS, "static")
|
||||
m.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(static))))
|
||||
}
|
||||
|
||||
// ---- Middleware ----
|
||||
|
||||
func (s *Server) require(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if s.currentUser(r) == nil {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) currentUser(r *http.Request) *models.User {
|
||||
_, user, err := s.deps.Sessions.Get(r)
|
||||
if err != nil || user == nil || !user.Enabled {
|
||||
return nil
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
func logMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
next.ServeHTTP(w, r)
|
||||
log.Printf("[webmail] %s %s %s", r.Method, r.URL.Path, time.Since(start))
|
||||
})
|
||||
}
|
||||
|
||||
// ---- Template rendering ----
|
||||
|
||||
type basePage struct {
|
||||
Flash string
|
||||
Error string
|
||||
CSRF string
|
||||
User *models.User
|
||||
Mailboxes []*models.Mailbox
|
||||
CurrentBoxID int64 // 0 = no mailbox selected (compose, settings, etc.)
|
||||
}
|
||||
|
||||
func (s *Server) newBase(r *http.Request, flash, errMsg string) basePage {
|
||||
user := s.currentUser(r)
|
||||
sessObj, _, _ := s.deps.Sessions.Get(r)
|
||||
var csrf string
|
||||
if sessObj != nil {
|
||||
csrf = s.csrfToken(sessObj.TokenHash)
|
||||
}
|
||||
|
||||
var boxes []*models.Mailbox
|
||||
if user != nil {
|
||||
boxes, _ = s.deps.DB.ListMailboxes(r.Context(), user.ID)
|
||||
}
|
||||
return basePage{Flash: flash, Error: errMsg, CSRF: csrf, User: user, Mailboxes: boxes}
|
||||
}
|
||||
|
||||
func (s *Server) render(w http.ResponseWriter, page string, data any) {
|
||||
t, err := template.New("").Funcs(tmplFuncs).ParseFS(
|
||||
s.deps.FS,
|
||||
"templates/base.html",
|
||||
"templates/"+page+".html",
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("[webmail] template parse %s: %v", page, err)
|
||||
http.Error(w, "template error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("X-Frame-Options", "SAMEORIGIN")
|
||||
if err := t.ExecuteTemplate(w, "base", data); err != nil {
|
||||
log.Printf("[webmail] template exec %s: %v", page, err)
|
||||
}
|
||||
}
|
||||
|
||||
// redirect sends 303 with optional flash/error query param.
|
||||
func redirect(w http.ResponseWriter, r *http.Request, path, flash, errMsg string) {
|
||||
target := path
|
||||
if flash != "" {
|
||||
target += "?flash=" + template.URLQueryEscaper(flash)
|
||||
} else if errMsg != "" {
|
||||
target += "?error=" + template.URLQueryEscaper(errMsg)
|
||||
}
|
||||
http.Redirect(w, r, target, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func flashFrom(r *http.Request) (flash, errMsg string) {
|
||||
return r.URL.Query().Get("flash"), r.URL.Query().Get("error")
|
||||
}
|
||||
|
||||
// ---- CSRF ----
|
||||
|
||||
// csrfToken returns an HMAC-SHA256 token valid for the current clock-hour.
|
||||
func (s *Server) csrfToken(sessionHash string) string {
|
||||
return computeCSRF(sessionHash, s.deps.Cfg.SessionSecret, time.Now().UTC())
|
||||
}
|
||||
|
||||
func computeCSRF(sessionHash string, secret []byte, t time.Time) string {
|
||||
h := hmac.New(sha256.New, secret)
|
||||
h.Write([]byte(sessionHash))
|
||||
h.Write([]byte(t.Format("2006-01-02-15")))
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
// checkCSRF validates the CSRF token from the form field "_csrf".
|
||||
func (s *Server) checkCSRF(r *http.Request, sessionHash string) bool {
|
||||
got := r.FormValue("_csrf")
|
||||
if got == "" {
|
||||
return false
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
cur := computeCSRF(sessionHash, s.deps.Cfg.SessionSecret, now)
|
||||
if hmac.Equal([]byte(got), []byte(cur)) {
|
||||
return true
|
||||
}
|
||||
prev := computeCSRF(sessionHash, s.deps.Cfg.SessionSecret, now.Add(-time.Hour))
|
||||
return hmac.Equal([]byte(got), []byte(prev))
|
||||
}
|
||||
|
||||
// validateCSRF checks CSRF and writes 403 on failure. Returns false on failure.
|
||||
func (s *Server) validateCSRF(w http.ResponseWriter, r *http.Request) bool {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return false
|
||||
}
|
||||
sess, _, _ := s.deps.Sessions.Get(r)
|
||||
if sess == nil {
|
||||
http.Error(w, "unauthenticated", http.StatusForbidden)
|
||||
return false
|
||||
}
|
||||
if !s.checkCSRF(r, sess.TokenHash) {
|
||||
http.Error(w, "CSRF validation failed", http.StatusForbidden)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ---- Template funcs ----
|
||||
|
||||
var tmplFuncs = template.FuncMap{
|
||||
"humanBytes": humanBytes,
|
||||
"shortTime": func(t time.Time) string { return t.Format("2006-01-02 15:04") },
|
||||
"shortDate": func(t time.Time) string {
|
||||
now := time.Now()
|
||||
if t.Year() == now.Year() && t.Month() == now.Month() && t.Day() == now.Day() {
|
||||
return t.Format("15:04")
|
||||
}
|
||||
return t.Format("Jan 2")
|
||||
},
|
||||
"isZero": func(t time.Time) bool { return t.IsZero() },
|
||||
"add": func(a, b int) int { return a + b },
|
||||
"truncate": func(s string, n int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= n {
|
||||
return s
|
||||
}
|
||||
return string(r[:n]) + "..."
|
||||
},
|
||||
"mailboxLabel": mailboxLabel,
|
||||
"safeHTML": func(s string) template.HTML { return template.HTML(s) }, //nolint:gosec
|
||||
}
|
||||
|
||||
func mailboxLabel(mboxType string) string {
|
||||
switch mboxType {
|
||||
case "inbox":
|
||||
return "Inbox"
|
||||
case "sent":
|
||||
return "Sent"
|
||||
case "drafts":
|
||||
return "Drafts"
|
||||
case "trash":
|
||||
return "Trash"
|
||||
case "spam":
|
||||
return "Spam"
|
||||
case "archive":
|
||||
return "Archive"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func humanBytes(b int64) string {
|
||||
const unit = 1024
|
||||
if b < unit {
|
||||
return fmt.Sprintf("%d B", b)
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for n := b / unit; n >= unit; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
{{define "title"}}IP Bans{{end}}
|
||||
{{define "content"}}
|
||||
<h1 class="text-xl font-bold text-white mb-6">IP Bans</h1>
|
||||
|
||||
<div class="card mb-6">
|
||||
<div class="text-sm font-semibold text-gray-300 mb-3">Add ban</div>
|
||||
<form method="POST" action="/admin/bans">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||
<div style="display:grid;grid-template-columns:1fr 2fr 1fr auto;gap:.75rem;align-items:end">
|
||||
<div class="field" style="margin:0">
|
||||
<label>IP address</label>
|
||||
<input type="text" name="ip" required placeholder="192.168.1.1" maxlength="45">
|
||||
</div>
|
||||
<div class="field" style="margin:0">
|
||||
<label>Reason</label>
|
||||
<input type="text" name="reason" maxlength="255" placeholder="Manual ban">
|
||||
</div>
|
||||
<div class="field" style="margin:0">
|
||||
<label>Duration (hours, 0 = permanent)</label>
|
||||
<input type="number" name="hours" value="24" min="0" max="87600">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-danger" style="white-space:nowrap">Add ban</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card" style="padding:0;overflow:hidden">
|
||||
{{if .Bans}}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>IP address</th>
|
||||
<th>Reason</th>
|
||||
<th>Banned at</th>
|
||||
<th>Expires</th>
|
||||
<th>Active</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Bans}}
|
||||
<tr>
|
||||
<td class="font-mono text-sm">{{.IP}}</td>
|
||||
<td class="text-gray-300 text-xs">{{.Reason}}</td>
|
||||
<td class="text-gray-400 text-xs">{{shortTime .BannedAt}}</td>
|
||||
<td class="text-gray-400 text-xs">
|
||||
{{if .ExpiresAt.Valid}}{{shortTime .ExpiresAt.Time}}{{else}}permanent{{end}}
|
||||
</td>
|
||||
<td>
|
||||
{{if .ExpiresAt.Valid}}
|
||||
<span class="badge badge-gray">timed</span>
|
||||
{{else}}
|
||||
<span class="badge badge-red">permanent</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td>
|
||||
<form method="POST" action="/admin/bans/{{.IP}}/remove"
|
||||
onsubmit="return confirm('Remove ban on {{.IP}}?')">
|
||||
<input type="hidden" name="_csrf" value="{{$.CSRF}}">
|
||||
<button type="submit" class="btn btn-primary btn-sm">Remove</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<div class="p-8 text-center text-gray-500 text-sm">No IP bans.</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,66 @@
|
||||
{{define "base"}}<!DOCTYPE html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{template "title" .}} — mailgosend admin</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>tailwind.config={darkMode:'class'}</script>
|
||||
<style>
|
||||
body{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
|
||||
.nav-link{display:block;padding:.375rem .625rem;border-radius:.375rem;font-size:.875rem;color:#9ca3af;transition:background .15s}
|
||||
.nav-link:hover{background:#374151;color:#f9fafb}
|
||||
.nav-link.active{background:#1d4ed8;color:#fff}
|
||||
.btn{display:inline-block;padding:.375rem .875rem;border-radius:.375rem;font-size:.875rem;font-weight:500;cursor:pointer;border:none;text-decoration:none}
|
||||
.btn-primary{background:#1d4ed8;color:#fff}.btn-primary:hover{background:#1e40af}
|
||||
.btn-danger{background:#dc2626;color:#fff}.btn-danger:hover{background:#b91c1c}
|
||||
.btn-sm{padding:.25rem .625rem;font-size:.75rem}
|
||||
.card{background:#1f2937;border-radius:.5rem;padding:1.25rem;margin-bottom:1rem}
|
||||
table{width:100%;border-collapse:collapse;font-size:.875rem}
|
||||
th{text-align:left;padding:.5rem .75rem;color:#9ca3af;font-weight:500;border-bottom:1px solid #374151;white-space:nowrap}
|
||||
td{padding:.5rem .75rem;border-bottom:1px solid #1f2937;vertical-align:top}
|
||||
tr:hover td{background:#1f2937}
|
||||
input,select,textarea{background:#374151;border:1px solid #4b5563;border-radius:.375rem;color:#f9fafb;padding:.375rem .625rem;font-size:.875rem;width:100%;box-sizing:border-box}
|
||||
input:focus,select:focus,textarea:focus{outline:none;border-color:#3b82f6}
|
||||
label{display:block;font-size:.75rem;color:#9ca3af;margin-bottom:.25rem}
|
||||
.field{margin-bottom:.875rem}
|
||||
.badge{display:inline-block;padding:.125rem .375rem;border-radius:.25rem;font-size:.7rem;font-weight:600}
|
||||
.badge-green{background:#065f46;color:#6ee7b7}
|
||||
.badge-red{background:#7f1d1d;color:#fca5a5}
|
||||
.badge-yellow{background:#78350f;color:#fcd34d}
|
||||
.badge-gray{background:#374151;color:#9ca3af}
|
||||
.flash-ok{background:#064e3b;border:1px solid #065f46;color:#6ee7b7;padding:.75rem 1rem;border-radius:.375rem;margin-bottom:1rem;font-size:.875rem}
|
||||
.flash-err{background:#7f1d1d;border:1px solid #991b1b;color:#fca5a5;padding:.75rem 1rem;border-radius:.375rem;margin-bottom:1rem;font-size:.875rem}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-900 text-gray-100 min-h-screen flex">
|
||||
|
||||
{{if .Admin}}
|
||||
<nav class="w-44 bg-gray-800 min-h-screen p-3 fixed flex flex-col" style="border-right:1px solid #374151">
|
||||
<div class="text-sm font-bold text-white mb-1 px-2 py-1">mailgosend</div>
|
||||
<div class="text-xs text-gray-500 px-2 mb-2">admin panel</div>
|
||||
<hr style="border-color:#374151;margin-bottom:.75rem">
|
||||
<a href="/admin/" class="nav-link">Dashboard</a>
|
||||
<a href="/admin/domains" class="nav-link">Domains</a>
|
||||
<a href="/admin/users" class="nav-link">Users</a>
|
||||
<a href="/admin/queue" class="nav-link">Delivery Queue</a>
|
||||
<a href="/admin/bans" class="nav-link">IP Bans</a>
|
||||
<a href="/admin/events" class="nav-link">Security Events</a>
|
||||
<div class="mt-auto pt-4 border-t" style="border-color:#374151">
|
||||
<div class="text-xs text-gray-500 px-2 mb-1">{{.Admin.Email}}</div>
|
||||
<a href="/admin/logout" class="nav-link text-xs">Logout</a>
|
||||
</div>
|
||||
</nav>
|
||||
<main class="flex-1 p-6" style="margin-left:11rem;min-width:0">
|
||||
{{else}}
|
||||
<main class="flex-1 p-6">
|
||||
{{end}}
|
||||
|
||||
{{if .Flash}}<div class="flash-ok">{{.Flash}}</div>{{end}}
|
||||
{{if .Error}}<div class="flash-err">{{.Error}}</div>{{end}}
|
||||
|
||||
{{template "content" .}}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,58 @@
|
||||
{{define "title"}}Dashboard{{end}}
|
||||
{{define "content"}}
|
||||
<h1 class="text-xl font-bold text-white mb-6">Dashboard</h1>
|
||||
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:1rem;margin-bottom:2rem">
|
||||
<div class="card" style="margin:0;text-align:center">
|
||||
<div class="text-3xl font-bold text-blue-400">{{.Stats.TotalDomains}}</div>
|
||||
<div class="text-xs text-gray-400 mt-1">Domains</div>
|
||||
</div>
|
||||
<div class="card" style="margin:0;text-align:center">
|
||||
<div class="text-3xl font-bold text-blue-400">{{.Stats.TotalUsers}}</div>
|
||||
<div class="text-xs text-gray-400 mt-1">Users</div>
|
||||
</div>
|
||||
<div class="card" style="margin:0;text-align:center">
|
||||
<div class="text-3xl font-bold text-blue-400">{{.Stats.TotalMessages}}</div>
|
||||
<div class="text-xs text-gray-400 mt-1">Messages</div>
|
||||
</div>
|
||||
<div class="card" style="margin:0;text-align:center">
|
||||
<div class="text-3xl font-bold {{if gt .Stats.QueuePending 0}}text-yellow-400{{else}}text-green-400{{end}}">{{.Stats.QueuePending}}</div>
|
||||
<div class="text-xs text-gray-400 mt-1">Queue Pending</div>
|
||||
</div>
|
||||
<div class="card" style="margin:0;text-align:center">
|
||||
<div class="text-3xl font-bold {{if gt .Stats.QueueFailed 0}}text-red-400{{else}}text-green-400{{end}}">{{.Stats.QueueFailed}}</div>
|
||||
<div class="text-xs text-gray-400 mt-1">Queue Failed</div>
|
||||
</div>
|
||||
<div class="card" style="margin:0;text-align:center">
|
||||
<div class="text-3xl font-bold {{if gt .Stats.ActiveBans 0}}text-red-400{{else}}text-gray-400{{end}}">{{.Stats.ActiveBans}}</div>
|
||||
<div class="text-xs text-gray-400 mt-1">Active Bans</div>
|
||||
</div>
|
||||
<div class="card" style="margin:0;text-align:center">
|
||||
<div class="text-3xl font-bold {{if gt .Stats.RecentEvents 0}}text-yellow-400{{else}}text-gray-400{{end}}">{{.Stats.RecentEvents}}</div>
|
||||
<div class="text-xs text-gray-400 mt-1">Events (24h)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1.5rem">
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold text-gray-300 mb-2">Quick links</h2>
|
||||
<div class="card" style="padding:.75rem">
|
||||
<a href="/admin/domains" class="block py-2 px-3 rounded hover:bg-gray-700 text-sm text-blue-400">Manage domains</a>
|
||||
<a href="/admin/users" class="block py-2 px-3 rounded hover:bg-gray-700 text-sm text-blue-400">Manage users</a>
|
||||
<a href="/admin/queue" class="block py-2 px-3 rounded hover:bg-gray-700 text-sm text-blue-400">Inspect delivery queue</a>
|
||||
<a href="/admin/bans" class="block py-2 px-3 rounded hover:bg-gray-700 text-sm text-blue-400">IP ban list</a>
|
||||
<a href="/admin/events" class="block py-2 px-3 rounded hover:bg-gray-700 text-sm text-blue-400">Security events</a>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold text-gray-300 mb-2">System</h2>
|
||||
<div class="card" style="padding:.75rem">
|
||||
<div class="text-xs text-gray-400 space-y-1">
|
||||
<div>Send SIGHUP to reload TLS certificates without restart.</div>
|
||||
<div>Send SIGTERM/SIGINT for graceful shutdown (10s drain).</div>
|
||||
<div class="pt-1 text-gray-500">Queue worker polls every 30 seconds.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,146 @@
|
||||
{{define "title"}}Domain — {{.Domain.Name}}{{end}}
|
||||
{{define "content"}}
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<a href="/admin/domains" class="text-gray-400 text-sm hover:text-white">Domains</a>
|
||||
<span class="text-gray-600">/</span>
|
||||
<h1 class="text-xl font-bold text-white">{{.Domain.Name}}</h1>
|
||||
{{if .Domain.Enabled}}<span class="badge badge-green">enabled</span>{{else}}<span class="badge badge-red">disabled</span>{{end}}
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1.5rem">
|
||||
|
||||
<!-- Left column -->
|
||||
<div>
|
||||
<!-- Toggle enable -->
|
||||
<div class="card">
|
||||
<div class="text-sm font-semibold text-gray-300 mb-3">Enable / Disable</div>
|
||||
<form method="POST" action="/admin/domains/{{.Domain.ID}}/enable">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||
{{if .Domain.Enabled}}
|
||||
<input type="hidden" name="enabled" value="0">
|
||||
<button type="submit" class="btn btn-danger">Disable domain</button>
|
||||
{{else}}
|
||||
<input type="hidden" name="enabled" value="1">
|
||||
<button type="submit" class="btn btn-primary">Enable domain</button>
|
||||
{{end}}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Limits -->
|
||||
<div class="card">
|
||||
<div class="text-sm font-semibold text-gray-300 mb-3">Limits</div>
|
||||
<form method="POST" action="/admin/domains/{{.Domain.ID}}/limits">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||
<div class="field">
|
||||
<label>Max users (0 = unlimited)</label>
|
||||
<input type="number" name="max_users" value="{{.Domain.MaxUsers}}" min="0" max="100000">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Max quota per user (MB, 0 = unlimited)</label>
|
||||
<input type="number" name="max_quota_mb" value="{{mb .Domain.MaxQuotaBytes}}" min="0" max="1048576">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Save limits</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Delete -->
|
||||
<div class="card" style="border:1px solid #7f1d1d">
|
||||
<div class="text-sm font-semibold text-red-400 mb-2">Delete domain</div>
|
||||
<div class="text-xs text-gray-400 mb-3">Permanently deletes the domain and ALL users and messages within it. This cannot be undone.</div>
|
||||
<form method="POST" action="/admin/domains/{{.Domain.ID}}/delete"
|
||||
onsubmit="return confirm('Delete domain {{.Domain.Name}} and all its data?')">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||
<button type="submit" class="btn btn-danger btn-sm">Delete domain</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right column -->
|
||||
<div>
|
||||
<!-- DKIM -->
|
||||
<div class="card">
|
||||
<div class="text-sm font-semibold text-gray-300 mb-3">DKIM key</div>
|
||||
{{if .Domain.DKIMPublic}}
|
||||
<div class="text-xs text-gray-400 mb-1">
|
||||
Selector: <span class="text-white">{{.Domain.DKIMSelector}}</span> /
|
||||
Algorithm: <span class="text-white">{{.Domain.DKIMAlgo}}</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-300 mb-3 font-semibold">DNS TXT record:</div>
|
||||
<div style="background:#111827;border-radius:.375rem;padding:.625rem;font-size:.7rem;color:#6ee7b7;word-break:break-all;margin-bottom:.875rem;line-height:1.6">{{.DKIMRecord}}</div>
|
||||
{{else}}
|
||||
<div class="text-xs text-yellow-400 mb-3">No DKIM key generated yet.</div>
|
||||
{{end}}
|
||||
<form method="POST" action="/admin/domains/{{.Domain.ID}}/dkim">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||
<div class="field">
|
||||
<label>Algorithm for new key</label>
|
||||
<select name="algo">
|
||||
<option value="rsa2048" {{if eq .Domain.DKIMAlgo "rsa2048"}}selected{{end}}>RSA-2048</option>
|
||||
<option value="ed25519" {{if eq .Domain.DKIMAlgo "ed25519"}}selected{{end}}>Ed25519</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-sm"
|
||||
onclick="return !{{if .Domain.DKIMPublic}}confirm('Regenerate DKIM key? Old signatures become invalid.'){{else}}false{{end}}">
|
||||
{{if .Domain.DKIMPublic}}Regenerate DKIM key{{else}}Generate DKIM key{{end}}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- DNS hints -->
|
||||
<div class="card">
|
||||
<div class="text-sm font-semibold text-gray-300 mb-3">Recommended DNS records</div>
|
||||
<div class="text-xs text-gray-400 mb-1">SPF</div>
|
||||
<div style="background:#111827;border-radius:.375rem;padding:.5rem;font-size:.7rem;color:#93c5fd;word-break:break-all;margin-bottom:.75rem">{{.SPFHint}}</div>
|
||||
<div class="text-xs text-gray-400 mb-1">DMARC</div>
|
||||
<div style="background:#111827;border-radius:.375rem;padding:.5rem;font-size:.7rem;color:#93c5fd;word-break:break-all">{{.DMARCHint}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Users -->
|
||||
<div class="mt-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h2 class="text-sm font-semibold text-gray-300">Users ({{len .Users}})</h2>
|
||||
<a href="/admin/users" class="btn btn-primary btn-sm">Add user</a>
|
||||
</div>
|
||||
<div class="card" style="padding:0;overflow:hidden">
|
||||
{{if .Users}}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Email</th>
|
||||
<th>Display name</th>
|
||||
<th>Status</th>
|
||||
<th>Role</th>
|
||||
<th>Quota used</th>
|
||||
<th>Last login</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Users}}
|
||||
<tr>
|
||||
<td><a href="/admin/users/{{.ID}}" class="text-blue-400 hover:underline">{{.Email}}</a></td>
|
||||
<td class="text-gray-300">{{.DisplayName}}</td>
|
||||
<td>
|
||||
{{if .Enabled}}<span class="badge badge-green">active</span>
|
||||
{{else}}<span class="badge badge-red">disabled</span>{{end}}
|
||||
</td>
|
||||
<td>
|
||||
{{if .Admin}}<span class="badge badge-yellow">admin</span>
|
||||
{{else if .DomainAdmin}}<span class="badge badge-gray">domain admin</span>
|
||||
{{else}}<span class="badge badge-gray">user</span>{{end}}
|
||||
</td>
|
||||
<td class="text-gray-400 text-xs">{{humanBytes .UsedBytes}} / {{if .QuotaBytes}}{{humanBytes .QuotaBytes}}{{else}}unlimited{{end}}</td>
|
||||
<td class="text-gray-400 text-xs">{{if isZero .LastLogin}}never{{else}}{{shortTime .LastLogin}}{{end}}</td>
|
||||
<td><a href="/admin/users/{{.ID}}" class="btn btn-primary btn-sm">Edit</a></td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<div class="p-6 text-center text-gray-500 text-sm">No users in this domain.</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,71 @@
|
||||
{{define "title"}}Domains{{end}}
|
||||
{{define "content"}}
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-xl font-bold text-white">Domains</h1>
|
||||
</div>
|
||||
|
||||
<div class="card mb-6">
|
||||
<div class="text-sm font-semibold text-gray-300 mb-3">Add domain</div>
|
||||
<form method="POST" action="/admin/domains">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||
<div style="display:grid;grid-template-columns:2fr 1fr 1fr auto;gap:.75rem;align-items:end">
|
||||
<div class="field" style="margin:0">
|
||||
<label>Domain name</label>
|
||||
<input type="text" name="name" placeholder="example.com" required maxlength="253"
|
||||
pattern="[a-z0-9][a-z0-9\-\.]{1,252}">
|
||||
</div>
|
||||
<div class="field" style="margin:0">
|
||||
<label>DKIM selector</label>
|
||||
<input type="text" name="selector" value="mail" maxlength="63" pattern="[a-zA-Z0-9_\-]+">
|
||||
</div>
|
||||
<div class="field" style="margin:0">
|
||||
<label>DKIM algorithm</label>
|
||||
<select name="algo">
|
||||
<option value="rsa2048">RSA-2048</option>
|
||||
<option value="ed25519">Ed25519</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" style="white-space:nowrap">Add domain</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card" style="padding:0;overflow:hidden">
|
||||
{{if .Domains}}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Domain</th>
|
||||
<th>Status</th>
|
||||
<th>DKIM</th>
|
||||
<th>Max users</th>
|
||||
<th>Max quota</th>
|
||||
<th>Created</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Domains}}
|
||||
<tr>
|
||||
<td><a href="/admin/domains/{{.ID}}" class="text-blue-400 hover:underline">{{.Name}}</a></td>
|
||||
<td>
|
||||
{{if .Enabled}}<span class="badge badge-green">enabled</span>
|
||||
{{else}}<span class="badge badge-red">disabled</span>{{end}}
|
||||
</td>
|
||||
<td>
|
||||
{{if .DKIMPublic}}<span class="badge badge-green">{{.DKIMSelector}} / {{.DKIMAlgo}}</span>
|
||||
{{else}}<span class="badge badge-yellow">no key</span>{{end}}
|
||||
</td>
|
||||
<td class="text-gray-400">{{if .MaxUsers}}{{.MaxUsers}}{{else}}unlimited{{end}}</td>
|
||||
<td class="text-gray-400">{{if .MaxQuotaBytes}}{{humanBytes .MaxQuotaBytes}}{{else}}unlimited{{end}}</td>
|
||||
<td class="text-gray-400 text-xs">{{shortTime .CreatedAt}}</td>
|
||||
<td><a href="/admin/domains/{{.ID}}" class="btn btn-primary btn-sm">Manage</a></td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<div class="p-8 text-center text-gray-500 text-sm">No domains configured yet.</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,46 @@
|
||||
{{define "title"}}Security Events{{end}}
|
||||
{{define "content"}}
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-xl font-bold text-white">Security Events</h1>
|
||||
<div class="flex gap-2">
|
||||
<a href="/admin/events?limit=100" class="btn btn-primary btn-sm">100</a>
|
||||
<a href="/admin/events?limit=200" class="btn btn-primary btn-sm">200</a>
|
||||
<a href="/admin/events?limit=500" class="btn btn-primary btn-sm">500</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="padding:0;overflow:hidden">
|
||||
{{if .Events}}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Type</th>
|
||||
<th>IP</th>
|
||||
<th>User ID</th>
|
||||
<th>Detail</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Events}}
|
||||
<tr>
|
||||
<td class="text-gray-400 text-xs white-space-nowrap">{{shortTime .CreatedAt}}</td>
|
||||
<td>
|
||||
{{$t := .Type}}
|
||||
{{if eq $t "login_failed"}}<span class="badge badge-red">{{$t}}</span>
|
||||
{{else if eq $t "ip_banned"}}<span class="badge badge-red">{{$t}}</span>
|
||||
{{else if eq $t "login_ok"}}<span class="badge badge-green">{{$t}}</span>
|
||||
{{else}}<span class="badge badge-gray">{{$t}}</span>{{end}}
|
||||
</td>
|
||||
<td class="font-mono text-xs">{{.IP}}</td>
|
||||
<td class="text-gray-400 text-xs">{{if .UserID.Valid}}{{.UserID.Int64}}{{end}}</td>
|
||||
<td class="text-gray-300 text-xs">{{.Detail}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<div class="p-8 text-center text-gray-500 text-sm">No security events recorded.</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,26 @@
|
||||
{{define "title"}}Login{{end}}
|
||||
{{define "content"}}
|
||||
<div class="flex items-center justify-center min-h-screen -mt-6">
|
||||
<div style="width:22rem">
|
||||
<div class="text-center mb-8">
|
||||
<div class="text-2xl font-bold text-white">mailgosend</div>
|
||||
<div class="text-gray-400 text-sm mt-1">Admin Panel</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<form method="POST" action="/admin/login" autocomplete="off">
|
||||
<div class="field">
|
||||
<label for="email">Email address</label>
|
||||
<input type="email" id="email" name="email" required maxlength="254"
|
||||
autocomplete="username" placeholder="admin@example.com">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" required maxlength="1024"
|
||||
autocomplete="current-password">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-full mt-2" style="width:100%">Sign in</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,32 @@
|
||||
{{define "title"}}Two-Factor Authentication{{end}}
|
||||
{{define "content"}}
|
||||
<div class="flex items-center justify-center min-h-screen -mt-6">
|
||||
<div style="width:22rem">
|
||||
<div class="text-center mb-8">
|
||||
<div class="text-2xl font-bold text-white">mailgosend</div>
|
||||
<div class="text-gray-400 text-sm mt-1">Admin — Two-Factor Authentication</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
{{if .Error}}<div class="alert alert-error mb-4">{{.Error}}</div>{{end}}
|
||||
{{if .Flash}}<div class="alert alert-success mb-4">{{.Flash}}</div>{{end}}
|
||||
<p style="color:#9ca3af;font-size:.8125rem;margin-bottom:1.25rem">
|
||||
Enter the 6-digit code from your authenticator app, or an 8-character backup code.
|
||||
</p>
|
||||
<form method="POST" action="/admin/login/mfa" autocomplete="off">
|
||||
<div class="field">
|
||||
<label for="code">Authentication Code</label>
|
||||
<input type="text" id="code" name="code" required
|
||||
minlength="6" maxlength="64" autofocus
|
||||
autocomplete="one-time-code" inputmode="numeric"
|
||||
placeholder="000000"
|
||||
style="letter-spacing:.15em;text-align:center;font-size:1.25rem">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary mt-2" style="width:100%">Verify</button>
|
||||
</form>
|
||||
<div class="text-center mt-4">
|
||||
<a href="/admin/login" style="font-size:.8rem;color:#4b5563">Back to login</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,58 @@
|
||||
{{define "title"}}Delivery Queue{{end}}
|
||||
{{define "content"}}
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-xl font-bold text-white">Delivery Queue</h1>
|
||||
<a href="/admin/queue" class="btn btn-primary btn-sm">Refresh</a>
|
||||
</div>
|
||||
|
||||
<div class="card" style="padding:0;overflow:hidden">
|
||||
{{if .Entries}}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Status</th>
|
||||
<th>From</th>
|
||||
<th>To</th>
|
||||
<th>Attempts</th>
|
||||
<th>Next attempt</th>
|
||||
<th>Expires</th>
|
||||
<th>Last error</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Entries}}
|
||||
<tr>
|
||||
<td class="text-gray-400 text-xs">{{.ID}}</td>
|
||||
<td>
|
||||
{{if eq .Status "failed"}}<span class="badge badge-red">failed</span>
|
||||
{{else if eq .Status "pending"}}<span class="badge badge-yellow">pending</span>
|
||||
{{else}}<span class="badge badge-gray">{{.Status}}</span>{{end}}
|
||||
</td>
|
||||
<td class="text-xs">{{.FromAddr}}</td>
|
||||
<td class="text-xs">{{.ToAddr}}</td>
|
||||
<td class="text-gray-400 text-xs">{{.Attempts}}</td>
|
||||
<td class="text-gray-400 text-xs">{{shortTime .NextAttempt}}</td>
|
||||
<td class="text-gray-400 text-xs">{{shortTime .ExpiresAt}}</td>
|
||||
<td class="text-xs text-red-300" style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{.ErrorLog}}</td>
|
||||
<td>
|
||||
<form method="POST" action="/admin/queue/{{.ID}}/retry" style="display:inline">
|
||||
<input type="hidden" name="_csrf" value="{{$.CSRF}}">
|
||||
<button type="submit" class="btn btn-primary btn-sm">Retry</button>
|
||||
</form>
|
||||
<form method="POST" action="/admin/queue/{{.ID}}/delete" style="display:inline;margin-left:.25rem"
|
||||
onsubmit="return confirm('Delete queue entry?')">
|
||||
<input type="hidden" name="_csrf" value="{{$.CSRF}}">
|
||||
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<div class="p-8 text-center text-gray-500 text-sm">Queue is empty.</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,86 @@
|
||||
{{define "title"}}User — {{.U.Email}}{{end}}
|
||||
{{define "content"}}
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<a href="/admin/users" class="text-gray-400 text-sm hover:text-white">Users</a>
|
||||
<span class="text-gray-600">/</span>
|
||||
<h1 class="text-xl font-bold text-white">{{.U.Email}}</h1>
|
||||
{{if .U.Enabled}}<span class="badge badge-green">active</span>{{else}}<span class="badge badge-red">disabled</span>{{end}}
|
||||
{{if .U.Admin}}<span class="badge badge-yellow">admin</span>{{else if .U.DomainAdmin}}<span class="badge badge-gray">domain admin</span>{{end}}
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1.5rem">
|
||||
|
||||
<!-- Edit user -->
|
||||
<div>
|
||||
<div class="card">
|
||||
<div class="text-sm font-semibold text-gray-300 mb-3">User settings</div>
|
||||
<form method="POST" action="/admin/users/{{.U.ID}}/update">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||
<div class="field">
|
||||
<label>Display name</label>
|
||||
<input type="text" name="display_name" value="{{.U.DisplayName}}" maxlength="255">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Quota (MB)</label>
|
||||
<input type="number" name="quota_mb" value="{{mb .U.QuotaBytes}}" min="0" max="1048576">
|
||||
</div>
|
||||
<div style="display:flex;gap:1.5rem;margin-bottom:.875rem">
|
||||
<label style="display:flex;align-items:center;gap:.375rem;cursor:pointer;margin:0">
|
||||
<input type="checkbox" name="enabled" value="1" {{if .U.Enabled}}checked{{end}} style="width:auto">
|
||||
<span class="text-sm">Enabled</span>
|
||||
</label>
|
||||
<label style="display:flex;align-items:center;gap:.375rem;cursor:pointer;margin:0">
|
||||
<input type="checkbox" name="admin" value="1" {{if .U.Admin}}checked{{end}} style="width:auto">
|
||||
<span class="text-sm">Global admin</span>
|
||||
</label>
|
||||
<label style="display:flex;align-items:center;gap:.375rem;cursor:pointer;margin:0">
|
||||
<input type="checkbox" name="domain_admin" value="1" {{if .U.DomainAdmin}}checked{{end}} style="width:auto">
|
||||
<span class="text-sm">Domain admin</span>
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Save changes</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Change password -->
|
||||
<div class="card">
|
||||
<div class="text-sm font-semibold text-gray-300 mb-3">Change password</div>
|
||||
<form method="POST" action="/admin/users/{{.U.ID}}/password">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||
<div class="field">
|
||||
<label>New password (min 8 characters)</label>
|
||||
<input type="password" name="password" required minlength="8" maxlength="1024">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Set password</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info + danger -->
|
||||
<div>
|
||||
<div class="card">
|
||||
<div class="text-sm font-semibold text-gray-300 mb-3">Account info</div>
|
||||
<div class="text-xs space-y-1.5 text-gray-400">
|
||||
<div>Email: <span class="text-white">{{.U.Email}}</span></div>
|
||||
<div>Domain: <span class="text-white">{{.U.DomainName}}</span></div>
|
||||
<div>Used: <span class="text-white">{{humanBytes .U.UsedBytes}}</span>
|
||||
of <span class="text-white">{{if .U.QuotaBytes}}{{humanBytes .U.QuotaBytes}}{{else}}unlimited{{end}}</span></div>
|
||||
<div>Created: <span class="text-white">{{shortTime .U.CreatedAt}}</span></div>
|
||||
<div>Last login: <span class="text-white">{{if isZero .U.LastLogin}}never{{else}}{{shortTime .U.LastLogin}}{{end}}</span></div>
|
||||
<div>MFA: <span class="text-white">{{if .U.MFAEnabled}}enabled{{else}}disabled{{end}}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="border:1px solid #7f1d1d">
|
||||
<div class="text-sm font-semibold text-red-400 mb-2">Delete user</div>
|
||||
<div class="text-xs text-gray-400 mb-3">Permanently deletes the user account, all mailboxes, and all messages. Cannot be undone.</div>
|
||||
<form method="POST" action="/admin/users/{{.U.ID}}/delete"
|
||||
onsubmit="return confirm('Delete user {{.U.Email}} and all their data?')">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||
<button type="submit" class="btn btn-danger btn-sm">Delete user</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,88 @@
|
||||
{{define "title"}}Users{{end}}
|
||||
{{define "content"}}
|
||||
<h1 class="text-xl font-bold text-white mb-6">Users</h1>
|
||||
|
||||
<div class="card mb-6">
|
||||
<div class="text-sm font-semibold text-gray-300 mb-3">Create user</div>
|
||||
<form method="POST" action="/admin/users">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||
<div style="display:grid;grid-template-columns:2fr 1fr 1fr 1fr auto;gap:.75rem;align-items:end">
|
||||
<div class="field" style="margin:0">
|
||||
<label>Domain</label>
|
||||
<select name="domain_id" required>
|
||||
<option value="">Select domain...</option>
|
||||
{{range .Domains}}
|
||||
<option value="{{.ID}}">{{.Name}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field" style="margin:0">
|
||||
<label>Username (local part)</label>
|
||||
<input type="text" name="username" required maxlength="64" placeholder="alice"
|
||||
pattern="[a-z0-9._\-]+">
|
||||
</div>
|
||||
<div class="field" style="margin:0">
|
||||
<label>Display name</label>
|
||||
<input type="text" name="display_name" maxlength="255" placeholder="Alice Smith">
|
||||
</div>
|
||||
<div class="field" style="margin:0">
|
||||
<label>Password</label>
|
||||
<input type="password" name="password" required minlength="8" maxlength="1024">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" style="white-space:nowrap">Create</button>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:.75rem;margin-top:.75rem">
|
||||
<div class="field" style="margin:0">
|
||||
<label>Quota (MB, default 1024)</label>
|
||||
<input type="number" name="quota_mb" value="1024" min="0" max="1048576">
|
||||
</div>
|
||||
<div class="field" style="margin:0;display:flex;align-items:center;gap:.5rem;padding-top:1.25rem">
|
||||
<input type="checkbox" name="domain_admin" value="1" id="da" style="width:auto">
|
||||
<label for="da" style="margin:0;cursor:pointer">Domain admin</label>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card" style="padding:0;overflow:hidden">
|
||||
{{if .Users}}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Email</th>
|
||||
<th>Domain</th>
|
||||
<th>Display name</th>
|
||||
<th>Status</th>
|
||||
<th>Role</th>
|
||||
<th>Quota used</th>
|
||||
<th>Last login</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Users}}
|
||||
<tr>
|
||||
<td><a href="/admin/users/{{.ID}}" class="text-blue-400 hover:underline">{{.Email}}</a></td>
|
||||
<td class="text-gray-400 text-xs">{{.DomainName}}</td>
|
||||
<td class="text-gray-300">{{.DisplayName}}</td>
|
||||
<td>
|
||||
{{if .Enabled}}<span class="badge badge-green">active</span>
|
||||
{{else}}<span class="badge badge-red">disabled</span>{{end}}
|
||||
</td>
|
||||
<td>
|
||||
{{if .Admin}}<span class="badge badge-yellow">admin</span>
|
||||
{{else if .DomainAdmin}}<span class="badge badge-gray">domain admin</span>
|
||||
{{else}}<span class="badge badge-gray">user</span>{{end}}
|
||||
</td>
|
||||
<td class="text-gray-400 text-xs">{{humanBytes .UsedBytes}} / {{if .QuotaBytes}}{{humanBytes .QuotaBytes}}{{else}}unlimited{{end}}</td>
|
||||
<td class="text-gray-400 text-xs">{{if isZero .LastLogin}}never{{else}}{{shortTime .LastLogin}}{{end}}</td>
|
||||
<td><a href="/admin/users/{{.ID}}" class="btn btn-primary btn-sm">Edit</a></td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<div class="p-8 text-center text-gray-500 text-sm">No users found.</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,85 @@
|
||||
{{define "base"}}<!DOCTYPE html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{template "title" .}} — mailgosend</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>tailwind.config={darkMode:'class'}</script>
|
||||
<style>
|
||||
body{font-family:ui-sans-serif,system-ui,sans-serif;font-size:14px}
|
||||
.sidebar{width:200px;min-width:200px;background:#111827;border-right:1px solid #1f2937;min-height:100vh;display:flex;flex-direction:column;padding:.75rem 0}
|
||||
.sidebar-item{display:flex;align-items:center;justify-content:space-between;padding:.375rem .875rem;border-radius:.25rem;margin:0 .375rem;color:#9ca3af;cursor:pointer;text-decoration:none;font-size:.8125rem}
|
||||
.sidebar-item:hover{background:#1f2937;color:#f9fafb}
|
||||
.sidebar-item.active{background:#1e3a5f;color:#93c5fd}
|
||||
.sidebar-section{font-size:.65rem;font-weight:600;text-transform:uppercase;letter-spacing:.08em;color:#4b5563;padding:.5rem .875rem .25rem;margin-top:.5rem}
|
||||
.unread-badge{background:#1d4ed8;color:#fff;font-size:.65rem;padding:.1rem .35rem;border-radius:.75rem;font-weight:600;min-width:1.1rem;text-align:center}
|
||||
.btn{display:inline-flex;align-items:center;padding:.375rem .875rem;border-radius:.375rem;font-size:.8125rem;font-weight:500;cursor:pointer;border:none;text-decoration:none;transition:background .15s}
|
||||
.btn-primary{background:#1d4ed8;color:#fff}.btn-primary:hover{background:#1e40af}
|
||||
.btn-danger{background:#dc2626;color:#fff}.btn-danger:hover{background:#b91c1c}
|
||||
.btn-ghost{background:transparent;color:#9ca3af;border:1px solid #374151}.btn-ghost:hover{background:#1f2937;color:#f9fafb}
|
||||
.btn-sm{padding:.25rem .625rem;font-size:.75rem}
|
||||
.msg-row{display:grid;align-items:center;padding:.5rem .875rem;border-bottom:1px solid #1f2937;cursor:pointer;gap:.5rem;grid-template-columns:auto 1fr auto auto}
|
||||
.msg-row:hover{background:#111827}
|
||||
.msg-row.unread{background:#0a1628}
|
||||
.msg-row.unread .msg-subject{font-weight:600;color:#f9fafb}
|
||||
.msg-from{font-size:.75rem;color:#6b7280;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:140px}
|
||||
.msg-subject{color:#d1d5db;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.msg-date{font-size:.7rem;color:#4b5563;white-space:nowrap}
|
||||
.flash-ok{background:#064e3b;border:1px solid #065f46;color:#6ee7b7;padding:.625rem .875rem;border-radius:.375rem;margin-bottom:.875rem;font-size:.8125rem}
|
||||
.flash-err{background:#7f1d1d;border:1px solid #991b1b;color:#fca5a5;padding:.625rem .875rem;border-radius:.375rem;margin-bottom:.875rem;font-size:.8125rem}
|
||||
input,select,textarea{background:#1f2937;border:1px solid #374151;border-radius:.375rem;color:#f9fafb;padding:.375rem .625rem;font-size:.875rem;width:100%;box-sizing:border-box}
|
||||
input:focus,select:focus,textarea:focus{outline:none;border-color:#3b82f6}
|
||||
label{display:block;font-size:.75rem;color:#6b7280;margin-bottom:.2rem}
|
||||
.field{margin-bottom:.75rem}
|
||||
.tag{display:inline-block;padding:.1rem .3rem;border-radius:.2rem;font-size:.65rem;font-weight:600}
|
||||
.tag-green{background:#064e3b;color:#6ee7b7}
|
||||
.tag-red{background:#7f1d1d;color:#fca5a5}
|
||||
.tag-blue{background:#1e3a5f;color:#93c5fd}
|
||||
.tag-gray{background:#1f2937;color:#6b7280}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-950 text-gray-300 flex h-screen overflow-hidden" style="background:#0d1117">
|
||||
|
||||
{{if .User}}
|
||||
<!-- Sidebar -->
|
||||
<div class="sidebar flex-shrink-0">
|
||||
<div style="padding:.5rem .875rem .75rem;border-bottom:1px solid #1f2937;margin-bottom:.25rem">
|
||||
<div style="font-size:.75rem;font-weight:600;color:#f9fafb">{{.User.DisplayName}}</div>
|
||||
<div style="font-size:.7rem;color:#4b5563">{{.User.Email}}</div>
|
||||
</div>
|
||||
|
||||
<a href="/compose" class="btn btn-primary btn-sm" style="margin:.5rem .625rem .75rem;display:block;text-align:center">Compose</a>
|
||||
|
||||
<div class="sidebar-section">Folders</div>
|
||||
{{range .Mailboxes}}
|
||||
<a href="/mail/{{.ID}}" class="sidebar-item {{if $.CurrentBoxID}}{{if eq $.CurrentBoxID .ID}}active{{end}}{{end}}">
|
||||
<span>{{if mailboxLabel .Type}}{{mailboxLabel .Type}}{{else}}{{.Name}}{{end}}</span>
|
||||
</a>
|
||||
{{end}}
|
||||
|
||||
<div style="margin-top:auto;padding:.75rem .875rem 0;border-top:1px solid #1f2937">
|
||||
<a href="/settings" class="sidebar-item" style="margin:0;padding:.25rem 0;font-size:.75rem">Settings</a>
|
||||
<a href="/logout" class="sidebar-item" style="margin:0;padding:.25rem 0;font-size:.75rem;color:#4b5563">Logout</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main -->
|
||||
<div style="flex:1;overflow:hidden;display:flex;flex-direction:column;min-width:0">
|
||||
{{if .Flash}}<div class="flash-ok" style="margin:.625rem .875rem 0;border-radius:.375rem">{{.Flash}}</div>{{end}}
|
||||
{{if .Error}}<div class="flash-err" style="margin:.625rem .875rem 0;border-radius:.375rem">{{.Error}}</div>{{end}}
|
||||
{{template "content" .}}
|
||||
</div>
|
||||
|
||||
{{else}}
|
||||
<!-- Public pages (login) -->
|
||||
<div style="flex:1;display:flex;align-items:center;justify-content:center">
|
||||
{{if .Flash}}<div class="flash-ok" style="position:fixed;top:1rem;left:50%;transform:translateX(-50%)">{{.Flash}}</div>{{end}}
|
||||
{{if .Error}}<div class="flash-err" style="position:fixed;top:1rem;left:50%;transform:translateX(-50%)">{{.Error}}</div>{{end}}
|
||||
{{template "content" .}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,47 @@
|
||||
{{define "title"}}Compose{{end}}
|
||||
{{define "content"}}
|
||||
<div style="display:flex;align-items:center;gap:.5rem;padding:.5rem .875rem;border-bottom:1px solid #1f2937;background:#0d1117;flex-shrink:0">
|
||||
<span style="font-weight:600;color:#f9fafb;font-size:.875rem">New Message</span>
|
||||
<div style="flex:1"></div>
|
||||
<a href="javascript:history.back()" class="btn btn-ghost btn-sm">Discard</a>
|
||||
</div>
|
||||
|
||||
<div style="flex:1;overflow-y:auto;padding:1rem 1.25rem">
|
||||
<form method="POST" action="/compose">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||
<input type="hidden" name="in_reply_to" value="{{.InReplyTo}}">
|
||||
<input type="hidden" name="references" value="{{.References}}">
|
||||
|
||||
<div class="field">
|
||||
<label for="to">To</label>
|
||||
<input type="text" id="to" name="to" required value="{{.To}}"
|
||||
placeholder="recipient@example.com, another@example.com"
|
||||
maxlength="2048">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="cc">CC</label>
|
||||
<input type="text" id="cc" name="cc" value="{{.CC}}"
|
||||
placeholder="optional" maxlength="2048">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="bcc">BCC</label>
|
||||
<input type="text" id="bcc" name="bcc" placeholder="optional" maxlength="2048">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="subject">Subject</label>
|
||||
<input type="text" id="subject" name="subject" value="{{.Subject}}"
|
||||
placeholder="Subject" maxlength="998">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="body">Message</label>
|
||||
<textarea id="body" name="body" rows="20"
|
||||
style="font-family:ui-monospace,monospace;font-size:.8125rem;resize:vertical;min-height:320px"
|
||||
placeholder="Write your message here...">{{.BodyText}}</textarea>
|
||||
</div>
|
||||
<div style="display:flex;gap:.5rem">
|
||||
<button type="submit" class="btn btn-primary">Send</button>
|
||||
<a href="javascript:history.back()" class="btn btn-ghost">Discard</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,25 @@
|
||||
{{define "title"}}Sign in{{end}}
|
||||
{{define "content"}}
|
||||
<div style="width:22rem">
|
||||
<div style="text-align:center;margin-bottom:2rem">
|
||||
<div style="font-size:1.5rem;font-weight:700;color:#f9fafb">mailgosend</div>
|
||||
<div style="font-size:.875rem;color:#4b5563;margin-top:.25rem">Sign in to your account</div>
|
||||
</div>
|
||||
<div style="background:#161b22;border:1px solid #21262d;border-radius:.5rem;padding:1.5rem">
|
||||
{{if .Error}}<div class="flash-err" style="margin-bottom:1rem">{{.Error}}</div>{{end}}
|
||||
<form method="POST" action="/login">
|
||||
<div class="field">
|
||||
<label for="email">Email</label>
|
||||
<input type="email" id="email" name="email" required maxlength="254"
|
||||
autocomplete="username" autofocus placeholder="you@example.com">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" required maxlength="1024"
|
||||
autocomplete="current-password">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" style="width:100%;justify-content:center;margin-top:.5rem">Sign in</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,67 @@
|
||||
{{define "title"}}{{if .CurrentBox}}{{.CurrentBox.Name}}{{else}}Mail{{end}}{{end}}
|
||||
{{define "content"}}
|
||||
<!-- Toolbar -->
|
||||
<div style="display:flex;align-items:center;gap:.5rem;padding:.5rem .875rem;border-bottom:1px solid #1f2937;background:#0d1117;flex-shrink:0">
|
||||
<span style="font-weight:600;color:#f9fafb;font-size:.875rem">{{if .CurrentBox}}{{.CurrentBox.Name}}{{end}}</span>
|
||||
<span style="color:#4b5563;font-size:.75rem;margin-left:.25rem">{{.TotalCount}} messages</span>
|
||||
<div style="flex:1"></div>
|
||||
<!-- Search -->
|
||||
<form method="GET" style="display:flex;gap:.375rem">
|
||||
<input type="hidden" name="boxid" value="{{if .CurrentBox}}{{.CurrentBox.ID}}{{end}}">
|
||||
<input type="search" name="q" value="{{.Query}}" placeholder="Search..." style="width:200px;font-size:.75rem;padding:.25rem .5rem">
|
||||
<button type="submit" class="btn btn-ghost btn-sm">Search</button>
|
||||
{{if .Query}}<a href="/mail/{{if .CurrentBox}}{{.CurrentBox.ID}}{{end}}" class="btn btn-ghost btn-sm">Clear</a>{{end}}
|
||||
</form>
|
||||
{{if .CurrentBox}}
|
||||
<form method="POST" action="/mail/{{.CurrentBox.ID}}/expunge">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||
<button type="submit" class="btn btn-ghost btn-sm" onclick="return confirm('Permanently delete all messages marked for deletion?')">Expunge</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<!-- Message list -->
|
||||
<div style="flex:1;overflow-y:auto">
|
||||
{{if .Messages}}
|
||||
{{range .Messages}}
|
||||
<a href="/mail/{{$.CurrentBox.ID}}/{{.UID}}" style="text-decoration:none;color:inherit;display:block"
|
||||
class="msg-row {{if not .IsRead}}unread{{end}}">
|
||||
<!-- Star -->
|
||||
<form method="POST" action="/mail/{{$.CurrentBox.ID}}/{{.UID}}/flag" style="display:contents">
|
||||
<input type="hidden" name="_csrf" value="{{$.CSRF}}">
|
||||
<input type="hidden" name="flag" value="star">
|
||||
<input type="hidden" name="return" value="/mail/{{$.CurrentBox.ID}}">
|
||||
<button type="submit" style="background:none;border:none;cursor:pointer;padding:0;font-size:.875rem;color:{{if .IsStarred}}#eab308{{else}}#374151{{end}}">
|
||||
{{if .IsStarred}}*{{else}}.{{end}}
|
||||
</button>
|
||||
</form>
|
||||
<!-- From + Subject -->
|
||||
<div style="min-width:0">
|
||||
<div class="msg-from">{{if .FromName}}{{truncate .FromName 30}}{{else}}{{.FromEmail}}{{end}}</div>
|
||||
<div class="msg-subject">{{if .Subject}}{{truncate .Subject 80}}{{else}}(no subject){{end}}</div>
|
||||
</div>
|
||||
<!-- Attachments + Date -->
|
||||
<div style="display:flex;align-items:center;gap:.375rem">
|
||||
{{if .HasAttachment}}<span class="tag tag-gray" style="font-size:.6rem">att</span>{{end}}
|
||||
</div>
|
||||
<div class="msg-date">{{shortDate .Date}}</div>
|
||||
</a>
|
||||
{{end}}
|
||||
|
||||
<!-- Pagination -->
|
||||
<div style="display:flex;justify-content:space-between;padding:.625rem .875rem;border-top:1px solid #1f2937">
|
||||
{{if .PrevPage}}
|
||||
<a href="/mail/{{.CurrentBox.ID}}?before={{.PrevPage}}" class="btn btn-ghost btn-sm">Newer</a>
|
||||
{{else}}<span></span>{{end}}
|
||||
{{if .NextPage}}
|
||||
<a href="/mail/{{.CurrentBox.ID}}?before={{.NextPage}}" class="btn btn-ghost btn-sm">Older</a>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{else}}
|
||||
<div style="display:flex;align-items:center;justify-content:center;height:60%;color:#374151;flex-direction:column;gap:.5rem">
|
||||
<div style="font-size:.875rem">{{if .Query}}No messages match your search.{{else}}No messages.{{end}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,86 @@
|
||||
{{define "title"}}{{if .Message}}{{.Message.Subject}}{{else}}Message{{end}}{{end}}
|
||||
{{define "content"}}
|
||||
<!-- Message toolbar -->
|
||||
<div style="display:flex;align-items:center;gap:.5rem;padding:.5rem .875rem;border-bottom:1px solid #1f2937;background:#0d1117;flex-shrink:0">
|
||||
<a href="/mail/{{.CurrentBox.ID}}" class="btn btn-ghost btn-sm">Back</a>
|
||||
<div style="flex:1"></div>
|
||||
|
||||
<!-- Prev / Next -->
|
||||
{{if .NextUID}}<a href="/mail/{{.CurrentBox.ID}}/{{.NextUID}}" class="btn btn-ghost btn-sm">Newer</a>{{end}}
|
||||
{{if .PrevUID}}<a href="/mail/{{.CurrentBox.ID}}/{{.PrevUID}}" class="btn btn-ghost btn-sm">Older</a>{{end}}
|
||||
|
||||
<!-- Actions -->
|
||||
<a href="/compose?action=reply&boxid={{.CurrentBox.ID}}&uid={{.Message.UID}}" class="btn btn-primary btn-sm">Reply</a>
|
||||
<a href="/compose?action=forward&boxid={{.CurrentBox.ID}}&uid={{.Message.UID}}" class="btn btn-ghost btn-sm">Forward</a>
|
||||
|
||||
<form method="POST" action="/mail/{{.CurrentBox.ID}}/{{.Message.UID}}/trash">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
|
||||
</form>
|
||||
|
||||
<!-- Star toggle -->
|
||||
<form method="POST" action="/mail/{{.CurrentBox.ID}}/{{.Message.UID}}/flag">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||
<input type="hidden" name="flag" value="star">
|
||||
<button type="submit" class="btn btn-ghost btn-sm">{{if .Message.IsStarred}}Unstar{{else}}Star{{end}}</button>
|
||||
</form>
|
||||
|
||||
<!-- Move to folder -->
|
||||
{{if $.Mailboxes}}
|
||||
<form method="POST" action="/mail/{{.CurrentBox.ID}}/{{.Message.UID}}/move" style="display:flex;gap:.25rem">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||
<select name="dest_box" style="font-size:.75rem;padding:.2rem .4rem;width:auto">
|
||||
{{range $.Mailboxes}}
|
||||
{{if ne .ID $.CurrentBox.ID}}
|
||||
<option value="{{.ID}}">{{if mailboxLabel .Type}}{{mailboxLabel .Type}}{{else}}{{.Name}}{{end}}</option>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</select>
|
||||
<button type="submit" class="btn btn-ghost btn-sm">Move</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<!-- Message header -->
|
||||
<div style="padding:.875rem 1.25rem;border-bottom:1px solid #1f2937;background:#0d1117;flex-shrink:0">
|
||||
<h1 style="font-size:1.0625rem;font-weight:600;color:#f9fafb;margin:0 0 .625rem">
|
||||
{{if .Message.Subject}}{{.Message.Subject}}{{else}}(no subject){{end}}
|
||||
</h1>
|
||||
<div style="display:grid;grid-template-columns:auto 1fr;gap:.2rem .625rem;font-size:.8125rem">
|
||||
<span style="color:#4b5563">From</span>
|
||||
<span style="color:#d1d5db">{{if .Message.FromName}}{{.Message.FromName}} <{{.Message.FromEmail}}>{{else}}{{.Message.FromEmail}}{{end}}</span>
|
||||
<span style="color:#4b5563">To</span>
|
||||
<span style="color:#d1d5db">{{.Message.ToList}}</span>
|
||||
<span style="color:#4b5563">Date</span>
|
||||
<span style="color:#d1d5db">{{shortTime .Message.Date}}</span>
|
||||
{{if .Message.MessageID}}
|
||||
<span style="color:#4b5563">Message-ID</span>
|
||||
<span style="color:#4b5563;font-size:.7rem;font-family:monospace">{{.Message.MessageID}}</span>
|
||||
{{end}}
|
||||
</div>
|
||||
{{if .Body.Attachments}}
|
||||
<div style="margin-top:.625rem;display:flex;gap:.375rem;flex-wrap:wrap">
|
||||
{{range .Body.Attachments}}
|
||||
<span class="tag tag-blue">{{if .Filename}}{{truncate .Filename 40}}{{else}}{{.ContentType}}{{end}}</span>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<!-- Message body -->
|
||||
<div style="flex:1;overflow-y:auto">
|
||||
{{if .Body.HTML}}
|
||||
<iframe sandbox="allow-same-origin"
|
||||
srcdoc="{{.Body.HTML}}"
|
||||
style="width:100%;min-height:480px;border:none;background:#fff"
|
||||
onload="this.style.height=(this.contentDocument.documentElement.scrollHeight+32)+'px'">
|
||||
</iframe>
|
||||
{{else if .Body.Text}}
|
||||
<div style="padding:1.25rem;background:#0d1117">
|
||||
<pre style="font-family:ui-monospace,monospace;font-size:.8125rem;line-height:1.6;color:#d1d5db;white-space:pre-wrap;word-break:break-word;margin:0">{{.Body.Text}}</pre>
|
||||
</div>
|
||||
{{else}}
|
||||
<div style="padding:1.25rem;color:#4b5563;font-size:.875rem">(no content)</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,31 @@
|
||||
{{define "title"}}Two-Factor Authentication{{end}}
|
||||
{{define "content"}}
|
||||
<div style="width:22rem">
|
||||
<div style="text-align:center;margin-bottom:2rem">
|
||||
<div style="font-size:1.5rem;font-weight:700;color:#f9fafb">mailgosend</div>
|
||||
<div style="font-size:.875rem;color:#4b5563;margin-top:.25rem">Two-factor authentication required</div>
|
||||
</div>
|
||||
<div style="background:#161b22;border:1px solid #21262d;border-radius:.5rem;padding:1.5rem">
|
||||
{{if .Error}}<div class="flash-err" style="margin-bottom:1rem">{{.Error}}</div>{{end}}
|
||||
{{if .Flash}}<div class="flash-ok" style="margin-bottom:1rem">{{.Flash}}</div>{{end}}
|
||||
<p style="color:#9ca3af;font-size:.8125rem;margin-bottom:1.25rem">
|
||||
Enter the 6-digit code from your authenticator app, or an 8-character backup code.
|
||||
</p>
|
||||
<form method="POST" action="/login/mfa">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<div class="field">
|
||||
<label for="code">Authentication Code</label>
|
||||
<input type="text" id="code" name="code" required
|
||||
minlength="6" maxlength="64"
|
||||
autocomplete="one-time-code" autofocus
|
||||
inputmode="numeric" placeholder="000000"
|
||||
style="letter-spacing:.15em;text-align:center;font-size:1.25rem">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" style="width:100%;justify-content:center;margin-top:.5rem">Verify</button>
|
||||
</form>
|
||||
<div style="margin-top:1rem;text-align:center">
|
||||
<a href="/login" style="font-size:.8rem;color:#4b5563">Back to login</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,50 @@
|
||||
{{define "title"}}Set Up Two-Factor Authentication{{end}}
|
||||
{{define "content"}}
|
||||
<div style="max-width:32rem;width:100%">
|
||||
<div style="margin-bottom:1.25rem">
|
||||
<h1 style="font-size:1.125rem;font-weight:600;color:#f9fafb">Set Up Two-Factor Authentication</h1>
|
||||
<p style="color:#6b7280;font-size:.8125rem;margin-top:.25rem">Scan the QR code with your authenticator app, or enter the secret manually.</p>
|
||||
</div>
|
||||
|
||||
{{if .Error}}<div class="flash-err" style="margin-bottom:1rem">{{.Error}}</div>{{end}}
|
||||
{{if .Flash}}<div class="flash-ok" style="margin-bottom:1rem">{{.Flash}}</div>{{end}}
|
||||
|
||||
<div style="background:#161b22;border:1px solid #21262d;border-radius:.5rem;padding:1.5rem;margin-bottom:1.25rem">
|
||||
<h2 style="font-size:.875rem;font-weight:600;color:#d1d5db;margin-bottom:.875rem">Step 1 — Add to authenticator app</h2>
|
||||
|
||||
<!-- QR code via Google Charts API (no JS required) -->
|
||||
<div style="text-align:center;margin-bottom:1rem">
|
||||
<img src="https://chart.googleapis.com/chart?chs=200x200&cht=qr&chl={{urlquery .OTPAuthURI}}&choe=UTF-8"
|
||||
alt="QR code for TOTP enrollment"
|
||||
style="border:6px solid #fff;border-radius:.25rem;display:inline-block"
|
||||
width="200" height="200">
|
||||
</div>
|
||||
|
||||
<p style="color:#6b7280;font-size:.75rem;margin-bottom:.5rem">Can't scan? Enter this secret manually in your app:</p>
|
||||
<div style="background:#0d1117;border:1px solid #30363d;border-radius:.25rem;padding:.625rem .875rem;font-family:monospace;font-size:.875rem;color:#58a6ff;word-break:break-all;letter-spacing:.1em">
|
||||
{{.Secret}}
|
||||
</div>
|
||||
<p style="color:#4b5563;font-size:.7rem;margin-top:.5rem">Algorithm: SHA-1 | Digits: 6 | Period: 30 seconds</p>
|
||||
</div>
|
||||
|
||||
<div style="background:#161b22;border:1px solid #21262d;border-radius:.5rem;padding:1.5rem">
|
||||
<h2 style="font-size:.875rem;font-weight:600;color:#d1d5db;margin-bottom:.875rem">Step 2 — Verify and enable</h2>
|
||||
<p style="color:#6b7280;font-size:.8125rem;margin-bottom:1rem">Enter the 6-digit code shown in your authenticator app to confirm setup.</p>
|
||||
<form method="POST" action="/settings/mfa/enroll">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<div class="field">
|
||||
<label for="code">Verification Code</label>
|
||||
<input type="text" id="code" name="code" required
|
||||
minlength="6" maxlength="6" inputmode="numeric"
|
||||
autocomplete="one-time-code" autofocus
|
||||
placeholder="000000"
|
||||
style="letter-spacing:.2em;text-align:center;font-size:1.25rem">
|
||||
</div>
|
||||
<div style="display:flex;gap:.75rem;margin-top:.875rem">
|
||||
<button type="submit" class="btn btn-primary" style="flex:1;justify-content:center">Enable Two-Factor Auth</button>
|
||||
<a href="/settings" class="btn btn-ghost" style="flex:0 0 auto">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,82 @@
|
||||
{{define "title"}}Settings{{end}}
|
||||
{{define "content"}}
|
||||
<div style="padding:.5rem .875rem;border-bottom:1px solid #1f2937;background:#0d1117;flex-shrink:0">
|
||||
<span style="font-weight:600;color:#f9fafb;font-size:.875rem">Settings</span>
|
||||
</div>
|
||||
|
||||
<div style="flex:1;overflow-y:auto;padding:1.25rem;max-width:40rem">
|
||||
|
||||
<!-- Account info -->
|
||||
<div style="background:#161b22;border:1px solid #21262d;border-radius:.5rem;padding:1rem;margin-bottom:1rem">
|
||||
<div style="font-size:.75rem;font-weight:600;color:#6b7280;text-transform:uppercase;margin-bottom:.75rem">Account</div>
|
||||
<div style="font-size:.8125rem;color:#9ca3af;display:grid;grid-template-columns:auto 1fr;gap:.3rem .75rem">
|
||||
<span>Email</span><span style="color:#d1d5db">{{.AccountUser.Email}}</span>
|
||||
<span>Storage used</span><span style="color:#d1d5db">{{humanBytes .AccountUser.UsedBytes}} of {{if .AccountUser.QuotaBytes}}{{humanBytes .AccountUser.QuotaBytes}}{{else}}unlimited{{end}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Display name -->
|
||||
<div style="background:#161b22;border:1px solid #21262d;border-radius:.5rem;padding:1rem;margin-bottom:1rem">
|
||||
<div style="font-size:.75rem;font-weight:600;color:#6b7280;text-transform:uppercase;margin-bottom:.75rem">Display Name</div>
|
||||
<form method="POST" action="/settings/display">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||
<div class="field">
|
||||
<label for="display_name">Display name shown in sent emails</label>
|
||||
<input type="text" id="display_name" name="display_name"
|
||||
value="{{.AccountUser.DisplayName}}" maxlength="255">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Save</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Change password -->
|
||||
<div style="background:#161b22;border:1px solid #21262d;border-radius:.5rem;padding:1rem;margin-bottom:1rem">
|
||||
<div style="font-size:.75rem;font-weight:600;color:#6b7280;text-transform:uppercase;margin-bottom:.75rem">Change Password</div>
|
||||
<form method="POST" action="/settings/password">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||
<div class="field">
|
||||
<label>Current password</label>
|
||||
<input type="password" name="current_password" required maxlength="1024"
|
||||
autocomplete="current-password">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>New password (min 8 characters)</label>
|
||||
<input type="password" name="new_password" required minlength="8" maxlength="1024"
|
||||
autocomplete="new-password">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Confirm new password</label>
|
||||
<input type="password" name="confirm_password" required maxlength="1024"
|
||||
autocomplete="new-password">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-sm"
|
||||
onclick="return confirm('Change your password? All other sessions will be logged out.')">
|
||||
Change password
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Two-factor authentication -->
|
||||
<div style="background:#161b22;border:1px solid #21262d;border-radius:.5rem;padding:1rem">
|
||||
<div style="font-size:.75rem;font-weight:600;color:#6b7280;text-transform:uppercase;margin-bottom:.75rem">Two-Factor Authentication</div>
|
||||
{{if .AccountUser.MFAEnabled}}
|
||||
<p style="color:#6ee7b7;font-size:.8125rem;margin-bottom:.875rem">Two-factor authentication is enabled on your account.</p>
|
||||
<form method="POST" action="/settings/mfa/disable"
|
||||
onsubmit="return confirm('Disable two-factor authentication? This reduces your account security.')">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||
<div class="field">
|
||||
<label>Current password (required to disable)</label>
|
||||
<input type="password" name="password" required maxlength="1024" autocomplete="current-password">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-danger btn-sm">Disable Two-Factor Auth</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<p style="color:#9ca3af;font-size:.8125rem;margin-bottom:.875rem">
|
||||
Two-factor authentication is not enabled. Adding it significantly improves your account security.
|
||||
</p>
|
||||
<a href="/settings/mfa/enroll" class="btn btn-primary btn-sm">Set Up Two-Factor Auth</a>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -1,9 +1,30 @@
|
||||
// Package assets embeds all web assets (templates + static files) into the binary.
|
||||
package assets
|
||||
|
||||
import "embed"
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
// FS contains all files under the web/ directory.
|
||||
//
|
||||
//go:embed web
|
||||
var FS embed.FS
|
||||
|
||||
// AdminFS returns an fs.FS rooted at web/admin — passed to webadmin.Deps.
|
||||
func AdminFS() fs.FS {
|
||||
sub, err := fs.Sub(FS, "web/admin")
|
||||
if err != nil {
|
||||
panic("assets: web/admin missing: " + err.Error())
|
||||
}
|
||||
return sub
|
||||
}
|
||||
|
||||
// ClientFS returns an fs.FS rooted at web/client — passed to webclient.Deps.
|
||||
func ClientFS() fs.FS {
|
||||
sub, err := fs.Sub(FS, "web/client")
|
||||
if err != nil {
|
||||
panic("assets: web/client missing: " + err.Error())
|
||||
}
|
||||
return sub
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user