diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..d1c788f --- /dev/null +++ b/PLAN.md @@ -0,0 +1,838 @@ +# mailgosend — Full-Stack Go Email Server: Project Plan + +> Go 1.26.3 · Single binary · Dark TailwindCSS UI · Security-first + +--- + +## 1. Goal + +Self-hosted email server + webmail client in one executable. +Combines and supersedes `gomta` (outbound MTA) and `gowebmail` (IMAP webclient). + +### What it runs + +| Service | Default port | Protocol | +|---|---|---| +| SMTP inbound (MTA) | 25 | SMTP plain + STARTTLS | +| SMTP submission | 587 | SMTP + mandatory STARTTLS | +| SMTPS submission | 465 | SMTP implicit TLS | +| IMAP | 143 | IMAP + STARTTLS | +| IMAPS | 993 | IMAP implicit TLS | +| Web client | 8080 | HTTP (or TLS) | +| Web admin | 8081 | HTTP (or TLS) | +| CalDAV / CardDAV | 5232 | HTTP (or TLS) | + +All ports, bind IPs, and TLS settings configurable in `app_config.conf`. + +--- + +## 2. Design Constraints + +- **Go stdlib only** — except packages listed in §4. +- **Zero CGO** — all deps must be pure Go. +- **Single executable** — all web assets embedded with `go:embed`. +- **Config auto-gen** — `app_config.conf` created on first run with secure random secrets. +- **Encryption at rest** — all email bodies, attachments, contacts, calendar events encrypted AES-256-GCM. +- **Input = malicious** — validate/sanitize every boundary. +- **No global mutable state**. +- **`context.Context` timeouts** on all I/O. +- **No frameworks** — `net/http` only for HTTP. +- **TailwindCSS via CDN**, vanilla JS, dark theme. + +--- + +## 3. Directory Structure + +``` +mailgosend/ +├── cmd/ +│ └── mailgosend/ +│ └── main.go # Entrypoint — starts all servers, graceful shutdown +├── internal/ +│ ├── config/ +│ │ └── config.go # app_config.conf load/autogen/validate +│ ├── db/ +│ │ ├── db.go # database/sql wrapper, multi-driver support +│ │ ├── migrate.go # Schema migrations (sequential versioned) +│ │ └── queries/ # Named SQL queries per domain +│ ├── crypto/ +│ │ └── crypto.go # AES-256-GCM encrypt/decrypt, key derivation, bcrypt +│ ├── auth/ +│ │ ├── session.go # Session store (DB-backed, signed tokens) +│ │ ├── brute.go # Rate limiting + IP ban +│ │ └── totp.go # TOTP / MFA (stdlib crypto) +│ ├── tls/ +│ │ ├── tls.go # TLS config builder, cert reload (SIGHUP), SNI routing +│ │ ├── acme.go # ACME client: HTTP-01 + DNS-01 (lego), cert renewal loop +│ │ └── providers/ # DNS provider configs (Cloudflare, Route53, etc.) +│ ├── smtp/ +│ │ ├── inbound.go # Port 25 — receive mail for local domains +│ │ ├── submission.go # Port 587/465 — authenticated send +│ │ ├── session.go # SMTP session FSM (shared) +│ │ └── queue.go # Delivery queue + retry scheduler +│ ├── imap/ +│ │ ├── server.go # IMAP server (go-imap/v2 backend interface) +│ │ └── backend.go # Backend: maps IMAP ops to DB/storage layer +│ ├── delivery/ +│ │ └── delivery.go # Outbound MTA: DNS MX lookup, TLS dial, queue retry +│ ├── dkim/ +│ │ └── dkim.go # DKIM sign (outbound) + verify (inbound) — stdlib crypto +│ ├── spf/ +│ │ └── spf.go # SPF DNS lookup + policy check (stdlib net) +│ ├── dmarc/ +│ │ └── dmarc.go # DMARC policy fetch + alignment check +│ ├── spam/ +│ │ └── spam.go # Spam scorer: DNSBL, header heuristics, score threshold +│ ├── storage/ +│ │ └── storage.go # Message storage: DB metadata + encrypted body (file or DB blob) +│ ├── caldav/ +│ │ ├── server.go # CalDAV HTTP handler (go-webdav backend) +│ │ └── backend.go # iCalendar storage backend +│ ├── carddav/ +│ │ ├── server.go # CardDAV HTTP handler +│ │ └── backend.go # vCard storage backend +│ ├── models/ +│ │ └── models.go # Shared structs: User, Domain, Message, Folder, etc. +│ ├── middleware/ +│ │ └── middleware.go # Auth check, CSRF, rate limit, host check, logging +│ ├── webadmin/ +│ │ ├── handlers.go # Admin panel route handlers +│ │ └── api.go # Admin JSON API +│ └── webclient/ +│ ├── handlers.go # Webmail route handlers +│ ├── compose.go # Compose + send +│ └── api.go # Client JSON API (messages, folders, contacts, calendar) +├── web/ +│ ├── admin/ +│ │ ├── templates/ # Admin HTML templates +│ │ └── static/ # Admin CSS/JS +│ └── client/ +│ ├── templates/ # Webmail HTML templates +│ └── static/ # Client CSS/JS +├── webfs.go # go:embed declarations +├── go.mod +├── go.sum +├── app_config.conf # Auto-generated, never committed +├── .gitignore +└── PLAN.md # This file +``` + +--- + +## 4. Third-Party Dependencies + +Only where stdlib has no equivalent. All actively maintained. + +| Package | Why | Stars / Maintainer | +|---|---|---| +| `modernc.org/sqlite` | Pure-Go SQLite, no CGO | ~2k / cznic | +| `golang.org/x/crypto` | bcrypt (passwords), low-level ACME protocol | golang.org | +| `golang.org/x/text` | Charset decoding (email MIME) | golang.org | +| `golang.org/x/oauth2` | Google + Microsoft OAuth2 for external accounts | golang.org | +| `github.com/go-acme/lego/v4` | ACME client: HTTP-01 **and** DNS-01 challenges, wildcard certs, 90+ DNS providers (Cloudflare, Route53, etc.) | ~8k / go-acme | +| `github.com/emersion/go-imap/v2` | IMAP server + client protocol (RFC 3501 is 300+ pages) | ~2k / Simon Ser | +| `github.com/emersion/go-smtp` | SMTP protocol library (RFC 5321 state machine) | ~800 / Simon Ser | +| `github.com/emersion/go-message` | Full MIME email parsing (RFC 2045-2049) | ~400 / Simon Ser | +| `github.com/emersion/go-sasl` | SASL auth mechanisms (PLAIN, LOGIN, XOAUTH2) | transitive | +| `github.com/emersion/go-webdav` | CalDAV + CardDAV server framework (RFC 4791/6352) | ~600 / Simon Ser | + +**Optional DB drivers** (user selects in config, only linked if needed via build tags): + +| Driver | Package | +|---|---| +| PostgreSQL | `github.com/lib/pq` | +| MySQL/MariaDB | `github.com/go-sql-driver/mysql` | +| MSSQL | `github.com/microsoft/go-mssqldb` | + +> Note: `mattn/go-sqlite3` (used in old projects) requires CGO — replaced by `modernc.org/sqlite`. +> `github.com/gorilla/mux` (used in gowebmail) dropped — `net/http` `ServeMux` (Go 1.22+) supports method+path routing. + +--- + +## 5. Configuration (`app_config.conf`) + +Auto-generated on first run if missing. INI-style `KEY = value` with comments. +All secrets (encryption key, session secret) are random hex on first gen — never default insecure values. + +### Config sections + +```ini +# --- Server Identity --- +HOSTNAME = mail.example.com # FQDN for SMTP HELO/EHLO, TLS SNI, URLs +DOMAIN_DEFAULT = example.com # Primary mail domain + +# --- Network Ports & Interfaces --- +SMTP_IFACE = 0.0.0.0 +SMTP_PORT = 25 +SUBMISSION_IFACE = 0.0.0.0 +SUBMISSION_PORT = 587 +SMTPS_PORT = 465 +IMAP_IFACE = 0.0.0.0 +IMAP_PORT = 143 +IMAPS_PORT = 993 +WEBCLIENT_IFACE = 0.0.0.0 +WEBCLIENT_PORT = 8080 +WEBADMIN_IFACE = 127.0.0.1 # Admin on loopback by default +WEBADMIN_PORT = 8081 +CALDAV_IFACE = 0.0.0.0 +CALDAV_PORT = 5232 + +# --- TLS --- +# TLS_MODE options: +# dns01 = Let's Encrypt via DNS-01 challenge (no open ports, supports wildcards) +# Requires ACME_DNS_PROVIDER + provider credentials below. +# Works behind NAT/firewall. RECOMMENDED for self-hosted. +# http01 = Let's Encrypt via HTTP-01 challenge (requires port 80 reachable from internet) +# No DNS API needed. No wildcard support. +# manual = Provide TLS_CERT + TLS_KEY paths (certbot, acme.sh, self-signed, Vault, etc.) +# SIGHUP reloads certs without restart. +# off = No TLS. Use ONLY behind a TLS-terminating reverse proxy. +TLS_MODE = dns01 + +# ACME (Let's Encrypt) — used for dns01 and http01 modes +ACME_EMAIL = admin@example.com # Required for Let's Encrypt ToS +ACME_CACHE_DIR = ./acme-cache # Cache dir for certs, keys, account +ACME_STAGING = false # true = use Let's Encrypt staging (for testing) +ACME_DOMAINS = example.com,*.example.com # Domains to certify; blank = HOSTNAME only + # Include wildcard for *.example.com coverage + +# DNS-01 provider (required when TLS_MODE=dns01) +# Supported providers: cloudflare | route53 | digitalocean | dnsimple | godaddy | +# namecheap | linode | hetzner | ovh | gandi | porkbun | desec | acmedns | + 80 more +# Full list: https://go-acme.github.io/lego/dns/ +ACME_DNS_PROVIDER = cloudflare + +# Cloudflare credentials (ACME_DNS_PROVIDER=cloudflare) +# Option A — API Token (recommended, least privilege: Zone.DNS edit on target zones) +CF_DNS_API_TOKEN = +# Option B — Global API Key +CF_API_KEY = +CF_API_EMAIL = + +# AWS Route53 credentials (ACME_DNS_PROVIDER=route53) +AWS_REGION = +AWS_ACCESS_KEY_ID = +AWS_SECRET_ACCESS_KEY = +AWS_HOSTED_ZONE_ID = # Optional: skip auto-detection + +# DigitalOcean (ACME_DNS_PROVIDER=digitalocean) +DO_AUTH_TOKEN = + +# Generic: any provider using environment variables — lego reads standard env vars +# for each provider. Set them here or export before starting mailgosend. +# See https://go-acme.github.io/lego/dns// for required vars. + +# Manual cert paths (TLS_MODE=manual, or per-service override below) +TLS_CERT = ./certs/cert.pem +TLS_KEY = ./certs/key.pem + +# Per-service cert override — leave blank to inherit global TLS_MODE +# Useful when using wildcard autocert globally but a specific cert for admin +SMTP_TLS_CERT = +SMTP_TLS_KEY = +IMAP_TLS_CERT = +IMAP_TLS_KEY = +WEB_TLS_CERT = +WEB_TLS_KEY = + +# --- Secrets (auto-generated, BACK THESE UP) --- +ENCRYPTION_KEY = <64 hex chars> # AES-256 master key — email content at rest +SESSION_SECRET = <64 hex chars> # Session cookie signing +DKIM_SELECTOR = mail # Default DKIM selector + +# --- Database --- +DB_DRIVER = sqlite # sqlite | postgres | mysql | mssql +DB_PATH = ./data/mail.db # SQLite path +DB_DSN = # PostgreSQL/MySQL/MSSQL connection string + +# --- Security --- +MAX_MESSAGE_SIZE = 52428800 # 50 MB +SESSION_MAX_AGE = 604800 # 7 days +BRUTE_MAX_TRIES = 5 +BRUTE_WINDOW_MIN = 30 +BRUTE_BAN_HOURS = 24 +TRUSTED_PROXIES = # CIDR list for X-Forwarded-For +SECURE_COOKIE = false # true when behind TLS + +# --- Spam --- +SPAM_THRESHOLD = 10 # Messages scoring >= this → Spam folder +SPAM_DNSBL = zen.spamhaus.org,bl.spamcop.net +SPAM_CHECK_SPF = true +SPAM_CHECK_DKIM = true +SPAM_CHECK_DMARC = true + +# --- Delivery --- +QUEUE_MAX_AGE_H = 72 # Bounce after 72h +QUEUE_RETRY_MIN = 5,15,60,240,480 # Retry backoff in minutes +DNS_PRIMARY = 1.1.1.1 +DNS_SECONDARY = 8.8.8.8 + +# --- Debug --- +DEBUG = false +LOG_FILE = ./logs/mail.log +LOG_LEVEL = info # debug | info | warn | error +``` + +--- + +## 6. Database Schema + +### Tables + +```sql +-- System +config (key, value, updated_at) +migrations (version, applied_at) + +-- Domains & Users +domains (id, name, enabled, dkim_private_enc, dkim_public, dkim_selector, + spf_policy, dmarc_policy, created_at) +users (id, domain_id, username, email, password_hash, display_name, + quota_bytes, enabled, admin, mfa_secret_enc, created_at, last_login) +user_sessions (id, user_id, token_hash, ip, user_agent, created_at, expires_at) +user_aliases (id, user_id, alias_email) + +-- Mail storage +mailboxes (id, user_id, name, type, parent_id, uid_validity, uid_next, + subscribed, created_at) +-- type: inbox|sent|drafts|trash|spam|archive|custom + +messages (id, mailbox_id, uid, remote_uid, message_id, subject, + from_email, from_name, to_list, cc_list, bcc_list, reply_to, + date, body_text_enc, body_html_enc, raw_enc, + size_bytes, has_attachment, is_read, is_starred, is_draft, + flags, spam_score, received_at, deleted_at) +-- All *_enc columns are AES-256-GCM encrypted blobs + +attachments (id, message_id, filename, content_type, size_bytes, data_enc, + content_id, inline) + +-- Delivery queue +queue (id, domain_id, from_addr, to_addr, raw_enc, message_id, + status, attempts, last_attempt, next_attempt, error_log, + created_at, expires_at) +-- status: pending|sending|failed|bounced|delivered + +delivery_log (id, queue_id, from_addr, to_addr, status, smtp_code, + smtp_message, mx_host, client_ip, created_at) + +-- Security +ip_bans (id, ip, reason, banned_at, expires_at, released_by) +login_attempts (id, ip, user_email, success, created_at) +security_events (id, type, ip, user_id, detail, created_at) + +-- Contacts (CardDAV) +addressbooks (id, user_id, name, description, color, sync_token) +contacts (id, addressbook_id, uid, vcard_enc, etag, created_at, updated_at) + +-- Calendar (CalDAV) +calendars (id, user_id, name, description, color, timezone, sync_token) +calendar_events (id, calendar_id, uid, ical_enc, etag, dtstart, dtend, + summary, recurring, created_at, updated_at) +``` + +--- + +## 7. Core Modules — Detail + +### 7.1 Config (`internal/config/`) + +- Read `app_config.conf` (INI format, `#` comments) +- Missing file → generate with random secrets + safe defaults +- Missing keys → append to existing file (non-destructive update) +- Env var `MAILGO_` overrides file value +- Startup validation: required fields, port ranges, key lengths + +### 7.2 Database (`internal/db/`) + +- `database/sql` interface — driver loaded by config +- Build tags select driver: `sqlite`, `postgres`, `mysql`, `mssql` +- Sequential migrations (integer version in `migrations` table) +- Prepared statements everywhere — no `fmt.Sprintf` into SQL +- Connection pool config (max open, idle, lifetime) +- `context.Context` on all queries with configurable timeout + +### 7.3 Crypto (`internal/crypto/`) + +- Master key: 32-byte AES-256 from config (hex) +- Per-user key: HKDF-SHA256(master\_key, user\_id+salt) +- Encrypt: AES-256-GCM, random nonce prepended to ciphertext +- Decrypt: split nonce, decrypt, authenticate +- Password hash: bcrypt cost 12 minimum +- Session tokens: `crypto/rand` 32 bytes, stored as SHA-256 hash in DB +- DKIM keys: RSA-2048 or Ed25519 (configurable) + +### 7.4 SMTP Inbound (`internal/smtp/inbound.go`) + +Port 25 — receives mail from the internet for local domains. + +**Flow:** +1. Accept TCP connection +2. Greet: `220 hostname ESMTP mailgosend` +3. EHLO → advertise: `STARTTLS`, `SIZE `, `8BITMIME`, `SMTPUTF8` +4. STARTTLS (optional but offered) +5. `MAIL FROM` — validate format, check SPF +6. `RCPT TO` — validate recipient exists in local domain +7. `DATA` — accept message +8. Post-receive pipeline: + - Parse headers + MIME + - Verify DKIM signature + - Check DMARC alignment + - Score spam (DNSBL + heuristics) + - Encrypt body + attachments + - Store in recipient's mailbox + - Emit IMAP push notification (IDLE clients) +9. `250 OK` or appropriate error code + +**Rejected on arrival:** open relay (no local recipient), size exceeded, malformed envelope. + +### 7.5 SMTP Submission (`internal/smtp/submission.go`) + +Ports 587 (STARTTLS mandatory) and 465 (implicit TLS). + +**Differences from inbound:** +- STARTTLS mandatory before AUTH on 587 (reject AUTH without TLS) +- AUTH PLAIN + AUTH LOGIN + AUTH XOAUTH2 +- Authenticated user must match `MAIL FROM` domain +- No relay to arbitrary domains unless relay rules configured +- DKIM-sign outbound messages +- Add `Received:` header +- Queue for async delivery + +### 7.6 SMTP Session FSM (`internal/smtp/session.go`) + +Shared state machine (reused by both inbound and submission): + +``` +States: Greeting → Helo → (Auth) → Mail → Rcpt → Data → Done +``` + +- Command timeout: 5 min per command (configurable) +- Max recipients: 100 (configurable) +- Pipeline support (ESMTP PIPELINING extension) +- Idle timeout: 10 min + +### 7.7 Delivery Queue (`internal/smtp/queue.go` + `internal/delivery/`) + +**Queue flow:** +1. Message inserted into `queue` table (status=pending, raw body encrypted) +2. Background goroutine polls queue every 30s +3. For each pending/retry-eligible row: + - DNS MX lookup for recipient domain + - Attempt SMTP delivery with TLS (STARTTLS preferred, fallback plain) + - On success: status=delivered, log entry + - On soft failure (4xx): increment attempts, set next_attempt (backoff) + - On hard failure (5xx): status=failed, send bounce to sender + - On timeout (>queue_max_age): bounce +4. Bounce messages: generated RFC 3464 DSN, stored in sender's mailbox + +### 7.8 IMAP Server (`internal/imap/`) + +Uses `github.com/emersion/go-imap/v2` server package. +Implement the `backend.Backend` interface backed by our DB/storage layer. + +**Supported capabilities:** +- `IMAP4rev1` (RFC 3501) +- `UIDPLUS` +- `IDLE` (push notification when new mail arrives) +- `NAMESPACE` +- `QUOTA` +- `MOVE` +- `SPECIAL-USE` +- `AUTH=PLAIN` + `AUTH=LOGIN` +- `STARTTLS` (port 143), implicit TLS (port 993) + +**Backend operations map to:** +- `Login` → verify user credentials against DB +- `ListMailboxes` → query `mailboxes` table +- `SelectMailbox` → load mailbox metadata, update `uid_validity` +- `FetchMessages` → query + decrypt messages +- `StoreFlags` → update `is_read`, `is_starred`, `flags` +- `AppendMessage` → encrypt + insert into `messages` +- `CopyMessages` / `MoveMessages` → DB operations +- `ExpungeMessages` → soft-delete (set `deleted_at`) + +### 7.9 DKIM (`internal/dkim/`) + +Ported + improved from `gomta`: +- Sign outbound: `rsa-sha256`, `relaxed/relaxed` canonicalization +- Verify inbound: fetch public key via DNS TXT, validate signature +- Per-domain keys stored encrypted in `domains.dkim_private_enc` +- Key generation: RSA-2048 minimum, Ed25519 optional +- DNS record helper: prints ready-to-paste TXT record + +### 7.10 SPF (`internal/spf/`) + +From scratch using `net` stdlib: +- Parse `SPF1` TXT records from sender domain DNS +- Walk mechanisms: `include`, `a`, `mx`, `ip4`, `ip6`, `all` +- Return `pass`, `fail`, `softfail`, `neutral`, `permerror`, `temperror` +- DNS lookup limit: 10 (per RFC 7208) +- Result fed into spam scorer + +### 7.11 DMARC (`internal/dmarc/`) + +From scratch: +- Fetch `_dmarc.` TXT record +- Parse policy (`p=none|quarantine|reject`) +- Check DKIM + SPF alignment (strict/relaxed) +- Apply policy to inbound messages +- Log aggregate data (future: reporting) + +### 7.12 Spam Scorer (`internal/spam/`) + +Rule-based, no ML dependencies: + +| Check | Max points | +|---|---| +| DNSBL hit (zen.spamhaus.org etc.) | +5 per list | +| SPF fail | +4 | +| SPF softfail | +2 | +| DKIM fail / missing | +3 | +| DMARC reject policy violated | +5 | +| Missing `Date` header | +1 | +| Missing `Message-ID` | +1 | +| Suspicious `From` (forged display name) | +2 | +| HTML only (no text/plain) | +1 | +| Excessive recipients | +2 | +| All-caps subject | +1 | + +Score >= `SPAM_THRESHOLD` (default 10) → deliver to Spam folder + set flag. + +### 7.13 Storage (`internal/storage/`) + +- Message raw RFC822 + parsed parts stored encrypted in DB (BLOB) +- Attachments stored as separate encrypted blobs (in DB or filesystem, configurable) +- Encryption: AES-256-GCM with per-user derived key +- Quota enforcement: check `users.quota_bytes` before storing + +### 7.14 CalDAV (`internal/caldav/`) + +Uses `github.com/emersion/go-webdav` caldav package. +Implement `caldav.Backend` interface: +- `GetCalendar` → query `calendars` table +- `ListCalendars` → all user calendars +- `GetCalendarObject` → decrypt + return iCal +- `PutCalendarObject` → encrypt + upsert event +- `DeleteCalendarObject` → delete event +- Sync token: monotonic counter per calendar (for `calendar-sync` report) + +Exposed at `/dav/calendars//` on CalDAV port. +Supports iOS/Android/Thunderbird/Evolution native calendar sync. + +### 7.15 CardDAV (`internal/carddav/`) + +Uses `github.com/emersion/go-webdav` carddav package. +- `GetAddressBook` / `ListAddressBooks` +- `GetAddressObject` → decrypt + return vCard +- `PutAddressObject` → encrypt + upsert contact +- Supports iOS/Android contacts sync + +Exposed at `/dav/contacts//` on same CalDAV port. + +### 7.16 Web Admin (`internal/webadmin/`) + +Accessible on `WEBADMIN_PORT` (default 8081, loopback only by default). + +**Pages / features:** +- Dashboard: server stats, queue depth, delivery rate, active connections +- Domains: add/remove/enable, view DKIM DNS records (copy-paste ready), SPF guidance +- Users: create/edit/delete/quota, reset password, enable MFA +- Queue: view pending/failed messages, retry manually, cancel +- Delivery logs: searchable, filterable by status/domain/date +- Security: IP ban list, login attempt log, security events +- Settings: edit config keys (except secrets), reload config without restart +- TLS: cert upload or trigger ACME renewal +- System: log viewer (tail), version info + +### 7.17 Web Client (`internal/webclient/`) + +Accessible on `WEBCLIENT_PORT` (default 8080). + +**Pages / features:** +- Login (+ MFA if enabled) +- Multi-account unified inbox (all local accounts a user has access to, plus external IMAP) +- Folder tree sidebar (all mailboxes, unread counts) +- Message list (virtual scroll, infinite load, search) +- Message view (HTML with sandbox iframe, plain text fallback, inline images, attachment download) +- Compose (rich editor, attachments, To/CC/BCC autocomplete from contacts) +- Contacts view (CardDAV-backed) +- Calendar view (month/week/day, CalDAV-backed) +- Account settings: password change, MFA setup, aliases, display name +- External IMAP accounts: add Gmail/Outlook/custom IMAP servers (like gowebmail) +- Dark theme, responsive, TailwindCSS CDN + +--- + +## 8. Security Architecture + +### Authentication + +- Passwords: bcrypt cost 12, never stored plain +- Sessions: random 32-byte token → SHA-256 stored in DB +- Session cookie: HttpOnly, SameSite=Strict, Secure (when TLS) +- MFA: TOTP (RFC 6238), QR code at setup, recovery codes +- SMTP auth: PLAIN + LOGIN (both over TLS only) +- Admin panel: separate session store, optional IP allowlist + +### Transport Security + +- SMTP port 25: STARTTLS offered, not mandatory (internet compatibility) +- SMTP port 587: STARTTLS mandatory before AUTH +- SMTP port 465: implicit TLS +- IMAP port 143: STARTTLS mandatory before AUTH +- IMAP port 993: implicit TLS +- Web ports: TLS via cert file or Let's Encrypt autocert +- Min TLS: 1.2, prefer 1.3 + +### Input Validation + +- Every HTTP handler validates: Content-Type, body size, all fields +- Email addresses: RFC 5321 parse (`net/mail`) +- SQL: prepared statements only +- HTML output: `html/template` auto-escaping +- File uploads: MIME type + magic bytes check, size limit, path traversal prevention +- CSRF: double-submit cookie pattern on all state-changing endpoints +- Host header check: reject unexpected `Host:` values + +### Rate Limiting + +- SMTP: per-IP connection limit, per-session command rate +- IMAP: per-IP login rate +- HTTP login: brute force counter (configurable window/threshold/ban) +- HTTP API: per-user request rate (sliding window, in-memory + DB fallback) +- Geo blocking: optional deny/allow country lists + +### Data Security + +- Email bodies: AES-256-GCM, per-user derived key (HKDF) +- DKIM private keys: encrypted in DB +- OAuth tokens (external accounts): encrypted in DB +- MFA secrets: encrypted in DB +- Logs: never log passwords, tokens, or email content +- Bounce messages: no sensitive headers in DSN body + +--- + +## 9. Build System + +### Single binary + +```bash +go build -o mailgosend ./cmd/mailgosend/ +``` + +### Embedded assets + +`webfs.go`: +```go +//go:embed web/admin web/client +var webFS embed.FS +``` + +### Build tags for DB drivers + +```bash +# SQLite only (default) +go build -tags sqlite ./cmd/mailgosend/ + +# With PostgreSQL support +go build -tags sqlite,postgres ./cmd/mailgosend/ + +# All drivers +go build -tags sqlite,postgres,mysql,mssql ./cmd/mailgosend/ +``` + +### Dockerfile (optional) + +```dockerfile +FROM golang:1.26-alpine AS builder +WORKDIR /app +COPY . . +RUN go build -tags sqlite -o mailgosend ./cmd/mailgosend/ + +FROM alpine:latest +RUN apk add --no-cache ca-certificates tzdata +COPY --from=builder /app/mailgosend /usr/local/bin/ +EXPOSE 25 465 587 143 993 8080 8081 5232 +CMD ["mailgosend"] +``` + +--- + +## 10. Implementation Phases + +### Phase 1 — Foundation (Week 1) +- [ ] `go.mod` with all deps +- [ ] `internal/config/` — config file load/autogen/validate +- [ ] `internal/db/` — DB wrapper, SQLite driver, migrations +- [ ] `internal/crypto/` — AES-256-GCM, bcrypt, HKDF +- [ ] `internal/models/` — all shared structs +- [ ] `internal/auth/session.go` — session store +- [ ] `internal/tls/` — TLS config builder +- [ ] `cmd/mailgosend/main.go` — startup scaffold, graceful shutdown + +### Phase 2 — SMTP + Delivery (Week 2) +- [ ] `internal/smtp/session.go` — FSM + command parser +- [ ] `internal/smtp/inbound.go` — port 25 server +- [ ] `internal/smtp/submission.go` — ports 587/465 +- [ ] `internal/smtp/queue.go` — queue table + worker +- [ ] `internal/delivery/delivery.go` — MX lookup + outbound SMTP +- [ ] `internal/dkim/dkim.go` — sign + verify +- [ ] `internal/spf/spf.go` — SPF check +- [ ] `internal/dmarc/dmarc.go` — DMARC check +- [ ] `internal/spam/spam.go` — scoring engine + +### Phase 3 — IMAP Server (Week 3) +- [ ] `internal/imap/backend.go` — go-imap/v2 backend +- [ ] `internal/imap/server.go` — IMAP + IMAPS listeners +- [ ] `internal/storage/storage.go` — encrypted message storage +- [ ] IMAP IDLE support (push) +- [ ] Quota enforcement + +### Phase 4 — Web Admin (Week 4) +- [ ] `internal/webadmin/handlers.go` +- [ ] `internal/auth/brute.go` — brute force protection +- [ ] `internal/middleware/middleware.go` — auth, CSRF, logging +- [ ] All admin templates + TailwindCSS dark theme +- [ ] `web/admin/static/js/admin.js` + +### Phase 5 — Web Client (Week 5) +- [ ] `internal/webclient/handlers.go` — message list, view, compose +- [ ] `internal/webclient/api.go` — JSON endpoints +- [ ] Multi-account support (local + external IMAP) +- [ ] All client templates + TailwindCSS dark theme +- [ ] `web/client/static/js/client.js` — virtual scroll, compose editor + +### Phase 6 — CalDAV / CardDAV (Week 6) +- [ ] `internal/caldav/` — calendar CRUD + sync +- [ ] `internal/carddav/` — contacts CRUD + sync +- [ ] Calendar UI in webclient (month view) +- [ ] Contacts UI in webclient + +### Phase 7 — Polish & Hardening (Week 7) +- [ ] Let's Encrypt autocert integration +- [ ] MFA (TOTP) for web login +- [ ] Delivery log + admin queue management +- [ ] Bounce message generation (RFC 3464 DSN) +- [ ] Import: migrate from gowebmail external accounts +- [ ] End-to-end testing (SMTP → IMAP → webmail round-trip) +- [ ] Security audit pass + +--- + +## 11. Key Differences from Existing Projects + +| Feature | gomta | gowebmail | mailgosend | +|---|---|---|---| +| SMTP inbound | No | No | Yes | +| SMTP outbound | Yes | Relay only | Yes (full MTA) | +| IMAP server | No | No | Yes | +| IMAP client | No | Yes | Yes (external accounts) | +| Web client | No | Yes | Yes (improved) | +| Admin panel | Basic | Basic | Full | +| CalDAV | No | Basic | Full RFC | +| CardDAV | No | Basic | Full RFC | +| DKIM verify | No | No | Yes | +| SPF check | No | No | Yes | +| DMARC | No | No | Yes | +| Spam scoring | No | No | Yes | +| Encryption at rest | No | Yes (tokens only) | Yes (all content) | +| Pure Go (no CGO) | No (go-sqlite3) | No (go-sqlite3) | Yes (modernc) | +| Multi-DB support | No | No | Yes | +| Single binary | No | No | Yes | +| Let's Encrypt | No | No | Yes | +| MFA | No | No | Yes | + +--- + +## 12. Design Decisions (Resolved) + +| # | Question | Decision | +|---|---|---| +| 1 | External IMAP accounts | **Yes** — local accounts + external Gmail/Outlook/custom IMAP (OAuth2 + credentials). Reuse gowebmail approach. | +| 2 | Storage backend | **Configurable** — DB blob default, filesystem optional. Config switch `STORAGE_BACKEND = db \| fs`. | +| 3 | Admin model | **Global admin + per-domain admin** — domain admin can manage users/settings for their domain only. | +| 4 | Spam filter | **Rules + Bayesian** — static scoring (SPF/DKIM/DMARC/DNSBL) + per-user Bayesian token training from "mark as spam/not spam". | +| 5 | TLS | **DNS-01 default** (lego) — wildcard certs, no ports exposed, Cloudflare/Route53/90+ providers. `http01` for simple setups. `manual` for self-managed certs. Per-service cert override. SIGHUP hot-reload. | +| 6 | DB drivers | **Build tags** — default binary SQLite only. `-tags postgres/mysql/mssql` to add drivers. | +| 7 | DKIM keys | **RSA-2048 default + Ed25519 option** — configurable per domain (`DKIM_ALGO = rsa2048 \| ed25519`). | +| 8 | Queue | **DB-backed** — survives crash. Rows in `queue` table with status/retry/backoff tracking. | + +### Additional dependency: OAuth2 + +External account support requires: +- `golang.org/x/oauth2` — Google + Microsoft OAuth2 flows (same as gowebmail) +- Google: Gmail scope `https://mail.google.com/` +- Microsoft: IMAP.AccessAsUser.All, SMTP.Send, offline_access + +--- + +## 13. TLS Architecture Detail + +### Challenge modes + +``` +TLS_MODE = dns01 → github.com/go-acme/lego/v4 + DNS-01 ACME challenge via DNS provider API + ✓ No port 80/443 needed — works behind NAT/firewall + ✓ Wildcard certificates (*.example.com) + ✓ Server can be completely non-public (LAN-only) + ✓ 90+ DNS providers (Cloudflare, Route53, DO, etc.) + Renewal: automatic background goroutine, 30 days before expiry + +TLS_MODE = http01 → github.com/go-acme/lego/v4 + HTTP-01 challenge: lego spins up ephemeral listener on port 80 + ✓ No DNS API credentials needed + ✗ Port 80 must be reachable from internet + ✗ No wildcard support + Renewal: same automatic background goroutine + +TLS_MODE = manual → Load TLS_CERT + TLS_KEY at startup + SIGHUP signal triggers cert reload without restart + Compatible with: certbot, acme.sh, Vault PKI, self-signed + Use when: internal CA, corporate cert, or existing automation +``` + +### Wildcard flow (recommended, Cloudflare example) + +``` +startup → lego checks ACME_CACHE_DIR for cached cert + → if missing or expiring: start DNS-01 challenge + → lego calls Cloudflare API: add TXT _acme-challenge.example.com + → Let's Encrypt verifies TXT record + → lego calls Cloudflare API: remove TXT record + → cert issued for: example.com + *.example.com + → cached in ACME_CACHE_DIR/certificates/ + → build *tls.Config from cert + → all listeners (SMTP/IMAP/web) share same *tls.Config (or per-service) +renewal → background goroutine checks cert expiry every 12h + → renews 30 days before expiry, same DNS-01 flow + → atomic swap of *tls.Config — zero downtime, no restart +``` + +### Per-service cert override + +``` +Priority (highest to lowest): + SMTP_TLS_CERT/KEY → used for SMTP + SMTPS + IMAP_TLS_CERT/KEY → used for IMAP + IMAPS + WEB_TLS_CERT/KEY → used for webclient + admin + CalDAV ports + TLS_CERT/KEY → global fallback (TLS_MODE=manual) + ACME cert → global fallback (TLS_MODE=dns01 or http01) + off → no TLS on that service +``` + +### Minimum security + +- TLS 1.2 minimum on all listeners +- TLS 1.3 preferred +- Cipher suites: ECDHE + AES-256-GCM, CHACHA20-POLY1305 only +- HSTS header on all HTTP responses (when TLS active) +- SMTP: `220` banner does not reveal software version + +--- + +*Plan created: 2026-05-21* +*Author: nahakubuilder* +*Last updated: 2026-05-21 — all design decisions resolved* diff --git a/cmd/mailgosend/main.go b/cmd/mailgosend/main.go new file mode 100644 index 0000000..4cf7ebf --- /dev/null +++ b/cmd/mailgosend/main.go @@ -0,0 +1,408 @@ +// Command mailgosend — self-hosted email server + webmail client. +// Single binary: SMTP inbound/submission, IMAP, web client, web admin, CalDAV/CardDAV. +package main + +import ( + "context" + "crypto/tls" + "fmt" + "net" + "os" + "os/signal" + "syscall" + "time" + + "ghb.freebede.com/nahakubuilder/mailgosend/internal/auth" + "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" + 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" +) + +func main() { + if err := run(); err != nil { + fmt.Fprintf(os.Stderr, "fatal: %v\n", err) + os.Exit(1) + } +} + +func run() error { + // ---- Config ---- + cfg, err := config.Load() + if err != nil { + return fmt.Errorf("config: %w", err) + } + + // ---- Crypto ---- + crypt, err := crypto.New(cfg.EncryptionKey) + if err != nil { + return fmt.Errorf("crypto init: %w", err) + } + _ = crypt // used by all services via dependency injection + + // ---- Database ---- + database, err := db.Open(cfg.DBDriver, cfg.DBPath) + if err != nil { + return fmt.Errorf("database: %w", err) + } + defer database.Close() + fmt.Printf("[db] connected: %s\n", cfg.DBDriver) + + // ---- TLS ---- + tlsManagers, err := buildTLSManagers(cfg) + if err != nil { + return fmt.Errorf("tls: %w", err) + } + + // ---- Auth stores ---- + sessions := auth.NewSessionStore(database, cfg.SessionMaxAge, cfg.SecureCookie) + _ = sessions + + brute := auth.NewBruteGuard( + database, + cfg.BruteMaxTries, + cfg.BruteWindowMin, + cfg.BruteBanHours, + ipListToStrings(cfg.BruteWhitelist), + ) + + // ---- Storage + Spam scorer ---- + store, err := storage.New(database, crypt, cfg.StorageBackend, cfg.StorageFSPath) + if err != nil { + return fmt.Errorf("storage: %w", err) + } + + scorer := spam.NewScorer( + database, + cfg.SpamThreshold, + cfg.SpamDNSBL, + cfg.SpamCheckSPF, + cfg.SpamCheckDKIM, + ) + + // ---- SMTP dependencies ---- + smtpDeps := &appsmtp.Deps{ + DB: database, + Crypt: crypt, + Store: store, + Scorer: scorer, + Brute: brute, + Cfg: cfg, + } + + // ---- Signal handling ---- + stopCh := make(chan struct{}) + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT, syscall.SIGHUP) + + // ---- Start services ---- + errs := make(chan error, 16) + + // SMTP inbound (port 25) + if cfg.SMTPEnabled { + var smtpTLS *tls.Config + if tlsManagers.smtpManager != nil { + smtpTLS = tlsManagers.smtpManager.Config() + } + smtpSrv := appsmtp.NewInboundServer(smtpDeps, smtpTLS) + go func() { errs <- appsmtp.ListenAndServe(smtpSrv, "smtp-inbound") }() + } + + // SMTP submission STARTTLS (port 587) + if cfg.SubmitEnabled { + var submitTLS *tls.Config + if tlsManagers.smtpManager != nil { + submitTLS = tlsManagers.smtpManager.Config() + } + submitSrv := appsmtp.NewSubmissionServer(smtpDeps, submitTLS) + go func() { errs <- appsmtp.ListenAndServe(submitSrv, "smtp-submission") }() + } + + // SMTPS implicit TLS (port 465) + if cfg.SMTPSEnabled && tlsManagers.smtpManager != nil { + smtpsSrv := appsmtp.NewSMTPSServer(smtpDeps, tlsManagers.smtpManager.Config()) + go func() { errs <- appsmtp.ListenAndServeTLS(smtpsSrv, "smtps") }() + } + + // IMAP deps + imapDeps := &appimap.Deps{ + DB: database, + Crypt: crypt, + Brute: brute, + Cfg: cfg, + } + + // IMAP STARTTLS (port 143) + if cfg.IMAPEnabled { + var imapTLS *tls.Config + if tlsManagers.imapManager != nil { + imapTLS = tlsManagers.imapManager.Config() + } + imapSrv := appimap.NewServer(imapDeps, imapTLS) + go func() { + addr := fmt.Sprintf("%s:%d", cfg.IMAPIface, cfg.IMAPPort) + errs <- appimap.ListenAndServe(imapSrv, addr, "imap") + }() + } + + // IMAPS implicit TLS (port 993) + if cfg.IMAPSEnabled && tlsManagers.imapManager != nil { + imapsSrv := appimap.NewServer(imapDeps, tlsManagers.imapManager.Config()) + go func() { + addr := fmt.Sprintf("%s:%d", cfg.IMAPIface, cfg.IMAPSPort) + errs <- appimap.ListenAndServeTLS(imapsSrv, addr, "imaps") + }() + } + + // Delivery queue worker + queueWorker := appsmtp.NewQueueWorker(smtpDeps) + go queueWorker.Run(stopCh) + + // Background: purge expired sessions hourly + go func() { + ticker := time.NewTicker(1 * time.Hour) + defer ticker.Stop() + for { + select { + case <-stopCh: + return + case <-ticker.C: + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + if err := sessions.PurgeExpired(ctx); err != nil { + fmt.Printf("[sessions] purge error: %v\n", err) + } + if err := brute.PurgeOldAttempts(ctx); err != nil { + fmt.Printf("[brute] purge error: %v\n", err) + } + cancel() + } + } + }() + + // ACME renewal loop (if applicable) + if (cfg.TLSMode == "dns01" || cfg.TLSMode == "http01") && tlsManagers.acme != nil { + go tlsManagers.acme.RenewalLoop(tlsManagers.smtpManager, stopCh) + } + + fmt.Println("[mailgosend] all services started — waiting for signals") + + // ---- Main loop ---- + for { + select { + case err := <-errs: + if err != nil { + fmt.Printf("[service error] %v\n", err) + } + + case sig := <-sigCh: + switch sig { + case syscall.SIGHUP: + fmt.Println("[mailgosend] SIGHUP: reloading TLS certificates") + if tlsManagers.smtpManager != nil { + if err := tlsManagers.smtpManager.Reload(); err != nil { + fmt.Printf("[tls] reload smtp cert: %v\n", err) + } + } + if tlsManagers.imapManager != nil { + if err := tlsManagers.imapManager.Reload(); err != nil { + fmt.Printf("[tls] reload imap cert: %v\n", err) + } + } + if tlsManagers.webManager != nil { + if err := tlsManagers.webManager.Reload(); err != nil { + fmt.Printf("[tls] reload web cert: %v\n", err) + } + } + + case syscall.SIGTERM, syscall.SIGINT: + fmt.Println("[mailgosend] shutting down...") + close(stopCh) + // Give services 10s to drain. + time.Sleep(10 * time.Second) + return nil + } + } + } +} + +// ---- TLS manager set ---- + +type tlsSet struct { + smtpManager *apptls.Manager + imapManager *apptls.Manager + webManager *apptls.Manager + acme *apptls.ACME +} + +func buildTLSManagers(cfg *config.Config) (*tlsSet, error) { + set := &tlsSet{} + + if apptls.IsOff(cfg.TLSMode) { + return set, nil + } + + // ACME mode: obtain cert once, share across managers. + if cfg.TLSMode == "dns01" || cfg.TLSMode == "http01" { + acmeCfg := apptls.ACMEConfig{ + Email: cfg.ACMEEmail, + CacheDir: cfg.ACMECacheDir, + Staging: cfg.ACMEStaging, + Domains: cfg.ACMEDomainList(), + Mode: cfg.TLSMode, + DNSProvider: cfg.ACMEDNSProvider, + } + + acmeClient, err := apptls.NewACME(acmeCfg) + if err != nil { + return nil, fmt.Errorf("acme init: %w", err) + } + set.acme = acmeClient + + cert, err := acmeClient.ObtainOrRenew() + if err != nil { + return nil, fmt.Errorf("acme obtain: %w", err) + } + fmt.Printf("[tls] certificate obtained for %v\n", cfg.ACMEDomainList()) + + // All managers share the same ACME cert unless overridden. + smtpMgr, _ := apptls.NewManager("", "") + imapMgr, _ := apptls.NewManager("", "") + webMgr, _ := apptls.NewManager("", "") + smtpMgr.UpdateCert(cert) + imapMgr.UpdateCert(cert) + webMgr.UpdateCert(cert) + set.smtpManager = smtpMgr + set.imapManager = imapMgr + set.webManager = webMgr + + // Apply per-service manual overrides on top of ACME cert. + smtpSvc := apptls.Resolve(cfg.SMTPTLSCert, cfg.SMTPTLSKey, "", "") + if smtpSvc.FileExists() { + if err := smtpMgr.Reload(); err != nil { + fmt.Printf("[tls] smtp cert override load error: %v\n", err) + } + } + imapSvc := apptls.Resolve(cfg.IMAPTLSCert, cfg.IMAPTLSKey, "", "") + if imapSvc.FileExists() { + if err := imapMgr.Reload(); err != nil { + fmt.Printf("[tls] imap cert override load error: %v\n", err) + } + } + webSvc := apptls.Resolve(cfg.WebTLSCert, cfg.WebTLSKey, "", "") + if webSvc.FileExists() { + if err := webMgr.Reload(); err != nil { + fmt.Printf("[tls] web cert override load error: %v\n", err) + } + } + + return set, nil + } + + // Manual mode: load cert files. + if cfg.TLSMode == "manual" { + smtpSvc := apptls.Resolve(cfg.SMTPTLSCert, cfg.SMTPTLSKey, cfg.TLSCert, cfg.TLSKey) + imapSvc := apptls.Resolve(cfg.IMAPTLSCert, cfg.IMAPTLSKey, cfg.TLSCert, cfg.TLSKey) + webSvc := apptls.Resolve(cfg.WebTLSCert, cfg.WebTLSKey, cfg.TLSCert, cfg.TLSKey) + + if smtpSvc.FileExists() { + mgr, err := apptls.NewManager(smtpSvc.CertFile, smtpSvc.KeyFile) + if err != nil { + return nil, fmt.Errorf("smtp tls: %w", err) + } + set.smtpManager = mgr + } + if imapSvc.FileExists() { + mgr, err := apptls.NewManager(imapSvc.CertFile, imapSvc.KeyFile) + if err != nil { + return nil, fmt.Errorf("imap tls: %w", err) + } + set.imapManager = mgr + } + if webSvc.FileExists() { + mgr, err := apptls.NewManager(webSvc.CertFile, webSvc.KeyFile) + if err != nil { + return nil, fmt.Errorf("web tls: %w", err) + } + set.webManager = mgr + } + return set, nil + } + + return nil, fmt.Errorf("unknown TLS_MODE %q", cfg.TLSMode) +} + +// ---- Stub listeners (replaced in later phases by real handlers) ---- + +// listenTCP opens a TCP listener and logs connections until stopCh is closed. +func listenTCP(addr, name string, stopCh <-chan struct{}) error { + ln, err := net.Listen("tcp", addr) + if err != nil { + return fmt.Errorf("%s listen %s: %w", name, addr, err) + } + defer ln.Close() + fmt.Printf("[%s] listening on %s\n", name, addr) + + go func() { + <-stopCh + ln.Close() + }() + + for { + conn, err := ln.Accept() + if err != nil { + select { + case <-stopCh: + return nil + default: + return fmt.Errorf("%s accept: %w", name, err) + } + } + // Stub: just close for now. Real handlers replace this in Phase 2/3. + conn.Close() + } +} + +// listenTLS opens a TLS listener. Real handlers registered in Phase 2/3. +func listenTLS(addr, name string, mgr *apptls.Manager, stopCh <-chan struct{}) error { + tlsCfg := mgr.Config() + ln, err := net.Listen("tcp", addr) + if err != nil { + return fmt.Errorf("%s tls listen %s: %w", name, addr, err) + } + defer ln.Close() + + tlsLn := apptls.NewListener(ln, tlsCfg) + fmt.Printf("[%s] listening on %s (TLS)\n", name, addr) + + go func() { + <-stopCh + tlsLn.Close() + }() + + for { + conn, err := tlsLn.Accept() + if err != nil { + select { + case <-stopCh: + return nil + default: + return fmt.Errorf("%s accept: %w", name, err) + } + } + conn.Close() // stub + } +} + +// ---- Helpers ---- + +func ipListToStrings(ips []net.IP) []string { + out := make([]string, len(ips)) + for i, ip := range ips { + out[i] = ip.String() + } + return out +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..79c9492 --- /dev/null +++ b/go.mod @@ -0,0 +1,53 @@ +module ghb.freebede.com/nahakubuilder/mailgosend + +go 1.26.3 + +require ( + github.com/emersion/go-imap/v2 v2.0.0-beta.8 + github.com/emersion/go-message v0.18.2 + github.com/emersion/go-smtp v0.24.0 + github.com/go-acme/lego/v4 v4.22.2 + golang.org/x/crypto v0.38.0 + modernc.org/sqlite v1.34.5 +) + +require ( + github.com/aws/aws-sdk-go-v2 v1.32.7 // indirect + github.com/aws/aws-sdk-go-v2/config v1.28.7 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.17.48 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.22 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.26 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.26 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.7 // indirect + github.com/aws/aws-sdk-go-v2/service/route53 v1.46.4 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.24.8 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.7 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.33.3 // indirect + github.com/aws/smithy-go v1.22.1 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cloudflare/cloudflare-go v0.112.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect + github.com/go-jose/go-jose/v4 v4.0.4 // indirect + github.com/goccy/go-json v0.10.4 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/jmespath/go-jmespath v0.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/miekg/dns v1.1.62 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/mod v0.22.0 // indirect + golang.org/x/net v0.33.0 // indirect + golang.org/x/oauth2 v0.29.0 // indirect + golang.org/x/sync v0.14.0 // indirect + golang.org/x/sys v0.33.0 // indirect + golang.org/x/text v0.25.0 // indirect + golang.org/x/time v0.8.0 // indirect + golang.org/x/tools v0.28.0 // indirect + modernc.org/libc v1.55.3 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.8.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..047f878 --- /dev/null +++ b/go.sum @@ -0,0 +1,159 @@ +github.com/aws/aws-sdk-go-v2 v1.32.7 h1:ky5o35oENWi0JYWUZkB7WYvVPP+bcRF5/Iq7JWSb5Rw= +github.com/aws/aws-sdk-go-v2 v1.32.7/go.mod h1:P5WJBrYqqbWVaOxgH0X/FYYD47/nooaPOZPlQdmiN2U= +github.com/aws/aws-sdk-go-v2/config v1.28.7 h1:GduUnoTXlhkgnxTD93g1nv4tVPILbdNQOzav+Wpg7AE= +github.com/aws/aws-sdk-go-v2/config v1.28.7/go.mod h1:vZGX6GVkIE8uECSUHB6MWAUsd4ZcG2Yq/dMa4refR3M= +github.com/aws/aws-sdk-go-v2/credentials v1.17.48 h1:IYdLD1qTJ0zanRavulofmqut4afs45mOWEI+MzZtTfQ= +github.com/aws/aws-sdk-go-v2/credentials v1.17.48/go.mod h1:tOscxHN3CGmuX9idQ3+qbkzrjVIx32lqDSU1/0d/qXs= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.22 h1:kqOrpojG71DxJm/KDPO+Z/y1phm1JlC8/iT+5XRmAn8= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.22/go.mod h1:NtSFajXVVL8TA2QNngagVZmUtXciyrHOt7xgz4faS/M= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.26 h1:I/5wmGMffY4happ8NOCuIUEWGUvvFp5NSeQcXl9RHcI= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.26/go.mod h1:FR8f4turZtNy6baO0KJ5FJUmXH/cSkI9fOngs0yl6mA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.26 h1:zXFLuEuMMUOvEARXFUVJdfqZ4bvvSgdGRq/ATcrQxzM= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.26/go.mod h1:3o2Wpy0bogG1kyOPrgkXA8pgIfEEv0+m19O9D5+W8y8= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 h1:VaRN3TlFdd6KxX1x3ILT5ynH6HvKgqdiXoTxAF4HQcQ= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 h1:iXtILhvDxB6kPvEXgsDhGaZCSC6LQET5ZHSdJozeI0Y= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1/go.mod h1:9nu0fVANtYiAePIBh2/pFUSwtJ402hLnp854CNoDOeE= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.7 h1:8eUsivBQzZHqe/3FE+cqwfH+0p5Jo8PFM/QYQSmeZ+M= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.7/go.mod h1:kLPQvGUmxn/fqiCrDeohwG33bq2pQpGeY62yRO6Nrh0= +github.com/aws/aws-sdk-go-v2/service/route53 v1.46.4 h1:0jMtawybbfpFEIMy4wvfyW2Z4YLr7mnuzT0fhR67Nrc= +github.com/aws/aws-sdk-go-v2/service/route53 v1.46.4/go.mod h1:xlMODgumb0Pp8bzfpojqelDrf8SL9rb5ovwmwKJl+oU= +github.com/aws/aws-sdk-go-v2/service/sso v1.24.8 h1:CvuUmnXI7ebaUAhbJcDy9YQx8wHR69eZ9I7q5hszt/g= +github.com/aws/aws-sdk-go-v2/service/sso v1.24.8/go.mod h1:XDeGv1opzwm8ubxddF0cgqkZWsyOtw4lr6dxwmb6YQg= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.7 h1:F2rBfNAL5UyswqoeWv9zs74N/NanhK16ydHW1pahX6E= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.7/go.mod h1:JfyQ0g2JG8+Krq0EuZNnRwX0mU0HrwY/tG6JNfcqh4k= +github.com/aws/aws-sdk-go-v2/service/sts v1.33.3 h1:Xgv/hyNgvLda/M9l9qxXc4UFSgppnRczLxlMs5Ae/QY= +github.com/aws/aws-sdk-go-v2/service/sts v1.33.3/go.mod h1:5Gn+d+VaaRgsjewpMvGazt0WfcFO+Md4wLOuBfGR9Bc= +github.com/aws/smithy-go v1.22.1 h1:/HPHZQ0g7f4eUeK6HKglFz8uwVfZKgoI25rb/J+dnro= +github.com/aws/smithy-go v1.22.1/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cloudflare/cloudflare-go v0.112.0 h1:caFwqXdGJCl3rjVMgbPEn8iCYAg9JsRYV3dIVQE5d7g= +github.com/cloudflare/cloudflare-go v0.112.0/go.mod h1:QB55kuJ5ZTeLNFcLJePfMuBilhu/LDKpLBmKFQIoSZ0= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/emersion/go-imap/v2 v2.0.0-beta.8 h1:5IXZK1E33DyeP526320J3RS7eFlCYGFgtbrfapqDPug= +github.com/emersion/go-imap/v2 v2.0.0-beta.8/go.mod h1:dhoFe2Q0PwLrMD7oZw8ODuaD0vLYPe5uj2wcOMnvh48= +github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg= +github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA= +github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk= +github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= +github.com/emersion/go-smtp v0.24.0 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6Wk= +github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ= +github.com/go-acme/lego/v4 v4.22.2 h1:ck+HllWrV/rZGeYohsKQ5iKNnU/WAZxwOdiu6cxky+0= +github.com/go-acme/lego/v4 v4.22.2/go.mod h1:E2FndyI3Ekv0usNJt46mFb9LVpV/XBYT+4E3tz02Tzo= +github.com/go-jose/go-jose/v4 v4.0.4 h1:VsjPI33J0SB9vQM6PLmNjoHqMQNGPiZ0rHL7Ni7Q6/E= +github.com/go-jose/go-jose/v4 v4.0.4/go.mod h1:NKb5HO1EZccyMpiZNbdUw/14tiXNyUJh188dfnMCAfc= +github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM= +github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/miekg/dns v1.1.62 h1:cN8OuEF1/x5Rq6Np+h1epln8OiyPWV+lROx9LxcGgIQ= +github.com/miekg/dns v1.1.62/go.mod h1:mvDlcItzm+br7MToIKqkglaGhlFMHJ9DTNNWONWXbNQ= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= +golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4= +golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98= +golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= +golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= +golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= +golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= +golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.28.0 h1:WuB6qZ4RPCQo5aP3WdKZS7i595EdWqWR8vqJTlwTVK8= +golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= +modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= +modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y= +modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s= +modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= +modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= +modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= +modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= +modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= +modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= +modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= +modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= +modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g= +modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/auth/brute.go b/internal/auth/brute.go new file mode 100644 index 0000000..84e2178 --- /dev/null +++ b/internal/auth/brute.go @@ -0,0 +1,201 @@ +package auth + +import ( + "context" + "database/sql" + "fmt" + "time" + + "ghb.freebede.com/nahakubuilder/mailgosend/internal/db" +) + +// BruteGuard tracks login attempts and bans IPs exceeding the threshold. +type BruteGuard struct { + db *db.DB + maxTries int + windowMin int + banHours int + whitelist map[string]struct{} // exempt IPs +} + +// NewBruteGuard creates a brute-force guard. +func NewBruteGuard(database *db.DB, maxTries, windowMin, banHours int, whitelist []string) *BruteGuard { + wl := make(map[string]struct{}, len(whitelist)) + for _, ip := range whitelist { + wl[ip] = struct{}{} + } + return &BruteGuard{ + db: database, + maxTries: maxTries, + windowMin: windowMin, + banHours: banHours, + whitelist: wl, + } +} + +// IsBanned returns true if the IP is currently banned. +func (g *BruteGuard) IsBanned(ctx context.Context, ip string) (bool, error) { + if _, ok := g.whitelist[ip]; ok { + return false, nil + } + + var expiresAt sql.NullTime + err := g.db.SQL().QueryRowContext(ctx, + "SELECT expires_at FROM ip_bans WHERE ip = ?", ip).Scan(&expiresAt) + if err == sql.ErrNoRows { + return false, nil + } + if err != nil { + return false, fmt.Errorf("check ban: %w", err) + } + + // Permanent ban (expires_at IS NULL) or not yet expired. + if !expiresAt.Valid || expiresAt.Time.After(time.Now().UTC()) { + return true, nil + } + + // Expired ban — clean up. + _, _ = g.db.SQL().ExecContext(ctx, "DELETE FROM ip_bans WHERE ip = ?", ip) + return false, nil +} + +// RecordAttempt records a login attempt and bans the IP if threshold exceeded. +// Returns (banned, error). +func (g *BruteGuard) RecordAttempt(ctx context.Context, ip, email string, success bool) (bool, error) { + if _, ok := g.whitelist[ip]; ok { + return false, nil + } + + // Insert attempt. + _, err := g.db.SQL().ExecContext(ctx, + "INSERT INTO login_attempts (ip, user_email, success, created_at) VALUES (?, ?, ?, ?)", + ip, email, success, time.Now().UTC()) + if err != nil { + return false, fmt.Errorf("record attempt: %w", err) + } + + if success { + // Successful login resets the counter (remove recent failed attempts). + window := time.Now().UTC().Add(-time.Duration(g.windowMin) * time.Minute) + _, _ = g.db.SQL().ExecContext(ctx, + "DELETE FROM login_attempts WHERE ip = ? AND success = 0 AND created_at >= ?", + ip, window) + return false, nil + } + + // Count failures in window. + window := time.Now().UTC().Add(-time.Duration(g.windowMin) * time.Minute) + var count int + err = g.db.SQL().QueryRowContext(ctx, + "SELECT COUNT(*) FROM login_attempts WHERE ip = ? AND success = 0 AND created_at >= ?", + ip, window).Scan(&count) + if err != nil { + return false, fmt.Errorf("count attempts: %w", err) + } + + if count < g.maxTries { + return false, nil + } + + // Threshold exceeded — ban the IP. + var expiresAt *time.Time + if g.banHours > 0 { + t := time.Now().UTC().Add(time.Duration(g.banHours) * time.Hour) + expiresAt = &t + } + + _, err = g.db.SQL().ExecContext(ctx, ` + INSERT INTO ip_bans (ip, reason, banned_at, expires_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(ip) DO UPDATE SET + reason = excluded.reason, + banned_at = excluded.banned_at, + expires_at = excluded.expires_at`, + ip, + fmt.Sprintf("brute force: %d failed attempts in %d minutes", count, g.windowMin), + time.Now().UTC(), + expiresAt, + ) + if err != nil { + return false, fmt.Errorf("ban ip: %w", err) + } + + // Log security event. + _, _ = g.db.SQL().ExecContext(ctx, ` + INSERT INTO security_events (type, ip, detail, created_at) + VALUES ('brute_ban', ?, ?, ?)`, + ip, + fmt.Sprintf("%d failed attempts in %d min window, last target: %s", count, g.windowMin, email), + time.Now().UTC(), + ) + + return true, nil +} + +// BanIP manually bans an IP (from admin panel). +func (g *BruteGuard) BanIP(ctx context.Context, ip, reason, bannedBy string, hours int) error { + var expiresAt *time.Time + if hours > 0 { + t := time.Now().UTC().Add(time.Duration(hours) * time.Hour) + expiresAt = &t + } + _, err := g.db.SQL().ExecContext(ctx, ` + INSERT INTO ip_bans (ip, reason, banned_at, expires_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(ip) DO UPDATE SET + reason = excluded.reason, + banned_at = excluded.banned_at, + expires_at = excluded.expires_at`, + ip, reason, time.Now().UTC(), expiresAt) + return err +} + +// UnbanIP removes a ban. +func (g *BruteGuard) UnbanIP(ctx context.Context, ip, releasedBy string) error { + _, err := g.db.SQL().ExecContext(ctx, + "DELETE FROM ip_bans WHERE ip = ?", ip) + return err +} + +// ListBans returns active bans ordered by newest first. +func (g *BruteGuard) ListBans(ctx context.Context, limit int) ([]banRow, error) { + rows, err := g.db.SQL().QueryContext(ctx, ` + SELECT ip, reason, banned_at, expires_at + FROM ip_bans + ORDER BY banned_at DESC + LIMIT ?`, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var bans []banRow + for rows.Next() { + var b banRow + var exp sql.NullTime + if err := rows.Scan(&b.IP, &b.Reason, &b.BannedAt, &exp); err != nil { + return nil, err + } + if exp.Valid { + b.ExpiresAt = &exp.Time + } + bans = append(bans, b) + } + return bans, rows.Err() +} + +// PurgeOldAttempts removes login attempt records older than windowMin*2. +// Call periodically to keep the table small. +func (g *BruteGuard) PurgeOldAttempts(ctx context.Context) error { + cutoff := time.Now().UTC().Add(-2 * time.Duration(g.windowMin) * time.Minute) + _, err := g.db.SQL().ExecContext(ctx, + "DELETE FROM login_attempts WHERE created_at < ?", cutoff) + return err +} + +type banRow struct { + IP string + Reason string + BannedAt time.Time + ExpiresAt *time.Time +} diff --git a/internal/auth/session.go b/internal/auth/session.go new file mode 100644 index 0000000..18c890a --- /dev/null +++ b/internal/auth/session.go @@ -0,0 +1,276 @@ +// Package auth provides session management and brute-force protection. +package auth + +import ( + "context" + "database/sql" + "fmt" + "net/http" + "time" + + "ghb.freebede.com/nahakubuilder/mailgosend/internal/crypto" + "ghb.freebede.com/nahakubuilder/mailgosend/internal/db" + "ghb.freebede.com/nahakubuilder/mailgosend/internal/models" +) + +const ( + sessionCookieName = "mgs_session" + sessionPurpose = "session" +) + +// SessionStore manages user sessions stored in the database. +type SessionStore struct { + db *db.DB + maxAge int // seconds + secureCookie bool +} + +// NewSessionStore creates a session store. +func NewSessionStore(database *db.DB, maxAge int, secureCookie bool) *SessionStore { + return &SessionStore{ + db: database, + maxAge: maxAge, + secureCookie: secureCookie, + } +} + +// Create creates a new session for userID, sets the cookie, and returns the session. +func (s *SessionStore) Create(w http.ResponseWriter, r *http.Request, userID int64) (*models.Session, error) { + raw, hash, err := crypto.NewToken() + if err != nil { + return nil, fmt.Errorf("session token: %w", err) + } + + now := time.Now().UTC() + expires := now.Add(time.Duration(s.maxAge) * time.Second) + + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + _, err = s.db.SQL().ExecContext(ctx, ` + INSERT INTO sessions (user_id, token_hash, ip, user_agent, created_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?)`, + userID, + hash, + realIP(r), + truncate(r.UserAgent(), 512), + now, + expires, + ) + if err != nil { + return nil, fmt.Errorf("insert session: %w", err) + } + + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, + Value: raw, + Path: "/", + MaxAge: s.maxAge, + HttpOnly: true, + Secure: s.secureCookie, + SameSite: http.SameSiteStrictMode, + }) + + return &models.Session{ + UserID: userID, + TokenHash: hash, + IP: realIP(r), + CreatedAt: now, + ExpiresAt: expires, + }, nil +} + +// Get validates the session cookie and returns the session + user. +// Returns (nil, nil, nil) if no valid session exists. +func (s *SessionStore) Get(r *http.Request) (*models.Session, *models.User, error) { + cookie, err := r.Cookie(sessionCookieName) + if err != nil { + return nil, nil, nil // no cookie = not logged in + } + + raw := cookie.Value + if len(raw) == 0 { + return nil, nil, nil + } + + hash := crypto.HashToken(raw) + + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + row := s.db.SQL().QueryRowContext(ctx, ` + SELECT s.id, s.user_id, s.token_hash, s.ip, s.user_agent, s.created_at, s.expires_at, + 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 + FROM sessions s + JOIN users u ON u.id = s.user_id + WHERE s.token_hash = ? + AND s.expires_at > ? + AND u.enabled = 1`, + hash, time.Now().UTC()) + + var sess models.Session + var user models.User + var lastLogin sql.NullTime + var createdAt sql.NullTime + + err = row.Scan( + &sess.ID, &sess.UserID, &sess.TokenHash, &sess.IP, &sess.UserAgent, + &sess.CreatedAt, &sess.ExpiresAt, + &user.ID, &user.DomainID, &user.Username, &user.Email, &user.DisplayName, + &user.QuotaBytes, &user.UsedBytes, &user.Enabled, &user.Admin, &user.DomainAdmin, + &user.MFAEnabled, &createdAt, &lastLogin, + ) + if err == sql.ErrNoRows { + return nil, nil, nil + } + if err != nil { + return nil, nil, fmt.Errorf("session lookup: %w", err) + } + if createdAt.Valid { + user.CreatedAt = createdAt.Time + } + if lastLogin.Valid { + user.LastLogin = lastLogin.Time + } + + return &sess, &user, nil +} + +// Destroy deletes the session from the database and clears the cookie. +func (s *SessionStore) Destroy(w http.ResponseWriter, r *http.Request) error { + cookie, err := r.Cookie(sessionCookieName) + if err != nil { + return nil // no session to destroy + } + + hash := crypto.HashToken(cookie.Value) + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + _, err = s.db.SQL().ExecContext(ctx, + "DELETE FROM sessions WHERE token_hash = ?", hash) + if err != nil { + return fmt.Errorf("delete session: %w", err) + } + + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + Secure: s.secureCookie, + SameSite: http.SameSiteStrictMode, + }) + return nil +} + +// DestroyAll deletes all sessions for a user (logout everywhere). +func (s *SessionStore) DestroyAll(ctx context.Context, userID int64) error { + _, err := s.db.SQL().ExecContext(ctx, + "DELETE FROM sessions WHERE user_id = ?", userID) + return err +} + +// PurgeExpired deletes all expired sessions. Call periodically (e.g. hourly). +func (s *SessionStore) PurgeExpired(ctx context.Context) error { + _, err := s.db.SQL().ExecContext(ctx, + "DELETE FROM sessions WHERE expires_at <= ?", time.Now().UTC()) + return err +} + +// UpdateLastLogin updates the user's last_login timestamp. +func UpdateLastLogin(ctx context.Context, database *db.DB, userID int64) { + database.SQL().ExecContext(ctx, // nolint: errcheck — best effort + "UPDATE users SET last_login = ? WHERE id = ?", + time.Now().UTC(), userID) +} + +// ---- User lookup ---- + +// GetUserByEmail returns the user matching email (case-insensitive), or nil. +func GetUserByEmail(ctx context.Context, database *db.DB, email string) (*models.User, error) { + row := database.SQL().QueryRowContext(ctx, ` + SELECT id, domain_id, username, email, password_hash, display_name, + quota_bytes, used_bytes, enabled, admin, domain_admin, + mfa_secret_enc, mfa_enabled, recovery_codes_enc, + created_at, last_login + FROM users + WHERE lower(email) = lower(?)`, email) + + return scanUser(row) +} + +// GetUserByID returns the user by ID, or nil. +func GetUserByID(ctx context.Context, database *db.DB, id int64) (*models.User, error) { + row := database.SQL().QueryRowContext(ctx, ` + SELECT id, domain_id, username, email, password_hash, display_name, + quota_bytes, used_bytes, enabled, admin, domain_admin, + mfa_secret_enc, mfa_enabled, recovery_codes_enc, + created_at, last_login + FROM users + WHERE id = ?`, id) + + return scanUser(row) +} + +func scanUser(row *sql.Row) (*models.User, error) { + var u models.User + var mfaSecretEnc, recoveryCodesEnc []byte + var lastLogin sql.NullTime + + err := row.Scan( + &u.ID, &u.DomainID, &u.Username, &u.Email, &u.PasswordHash, + &u.DisplayName, &u.QuotaBytes, &u.UsedBytes, &u.Enabled, + &u.Admin, &u.DomainAdmin, + &mfaSecretEnc, &u.MFAEnabled, &recoveryCodesEnc, + &u.CreatedAt, &lastLogin, + ) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("scan user: %w", err) + } + u.MFASecretEnc = mfaSecretEnc + u.RecoveryCodesEnc = recoveryCodesEnc + if lastLogin.Valid { + u.LastLogin = lastLogin.Time + } + return &u, nil +} + +// ---- Helpers ---- + +func realIP(r *http.Request) string { + if ip := r.Header.Get("X-Real-IP"); ip != "" { + return ip + } + if ip := r.Header.Get("X-Forwarded-For"); ip != "" { + return ip + } + host, _, err := parseHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +} + +func parseHostPort(addr string) (string, string, error) { + // net.SplitHostPort returns error for bare IPs without port. + for i := len(addr) - 1; i >= 0; i-- { + if addr[i] == ':' { + return addr[:i], addr[i+1:], nil + } + } + return addr, "", fmt.Errorf("no port") +} + +func truncate(s string, max int) string { + if len(s) <= max { + return s + } + return s[:max] +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..5c4ee10 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,773 @@ +// Package config loads and auto-generates app_config.conf. +// INI-style: KEY = value, # comments, blank lines ignored. +// Missing keys appended on each startup — existing values preserved. +// Env var MAILGO_ overrides file value. +package config + +import ( + "bufio" + "crypto/rand" + "encoding/hex" + "fmt" + "net" + "os" + "strconv" + "strings" +) + +const ConfigPath = "./app_config.conf" + +// Config holds all runtime configuration. +type Config struct { + // Identity + Hostname string // FQDN for SMTP HELO, TLS SNI, URL building + DefaultDomain string // Primary mail domain + + // Network — SMTP + SMTPIface string + SMTPPort int + SubmitIface string + SubmitPort int // 587 STARTTLS + SMTPSPort int // 465 implicit TLS + SMTPEnabled bool + SubmitEnabled bool + SMTPSEnabled bool + + // Network — IMAP + IMAPIface string + IMAPPort int // 143 STARTTLS + IMAPSPort int // 993 implicit TLS + IMAPEnabled bool + IMAPSEnabled bool + + // Network — Web + WebClientIface string + WebClientPort int + WebAdminIface string + WebAdminPort int + CalDAVIface string + CalDAVPort int + + // TLS + TLSMode string // dns01 | http01 | manual | off + TLSCert string // manual: path to cert.pem + TLSKey string // manual: path to key.pem + // Per-service overrides (empty = use global TLS) + SMTPTLSCert string + SMTPTLSKey string + IMAPTLSCert string + IMAPTLSKey string + WebTLSCert string + WebTLSKey string + + // ACME (dns01 / http01) + ACMEEmail string + ACMECacheDir string + ACMEStaging bool + ACMEDomains []string // domains to certify; empty = just Hostname + + // DNS-01 provider + ACMEDNSProvider string // cloudflare | route53 | digitalocean | hetzner | ... + // Cloudflare + CFDNSAPIToken string + CFAPIKey string + CFAPIEmail string + // Route53 + AWSRegion string + AWSAccessKeyID string + AWSSecretAccessKey string + AWSHostedZoneID string + // DigitalOcean + DOAuthToken string + // Hetzner + HetznerAPIKey string + // Generic: any additional lego env vars should be exported before starting. + + // Secrets (auto-generated on first run — BACK UP app_config.conf) + EncryptionKey []byte // 32 bytes, AES-256 master key + SessionSecret []byte // session cookie signing + + // Database + DBDriver string // sqlite | postgres | mysql | mssql + DBPath string // SQLite path + DBDSN string // PostgreSQL/MySQL/MSSQL DSN + + // Storage + StorageBackend string // db | fs + StorageFSPath string // base path for fs storage + + // Security + MaxMessageSize int64 + SessionMaxAge int // seconds + BruteMaxTries int + BruteWindowMin int + BruteBanHours int + TrustedProxies []net.IPNet + SecureCookie bool + BruteWhitelist []net.IP + + // SMTP server + SMTPHostname string // override for SMTP HELO (defaults to Hostname) + MaxRcptPer int // max recipients per message + QueueMaxAgeHours int + QueueRetryMins []int // backoff schedule + DNSPrimary string + DNSSecondary string + + // DKIM + DKIMSelector string + DKIMAlgo string // rsa2048 | ed25519 + + // Spam + SpamThreshold int + SpamDNSBL []string + SpamCheckSPF bool + SpamCheckDKIM bool + SpamCheckDMARC bool + + // OAuth2 (external accounts) + GoogleClientID string + GoogleClientSecret string + MicrosoftClientID string + MicrosoftClientSecret string + MicrosoftTenantID string + + // Debug + Debug bool + LogFile string + LogLevel string // debug | info | warn | error +} + +// field drives both config file generation and value parsing. +type field struct { + key string + defVal string + comments []string + secret bool // true = never shown in logs +} + +var allFields = []field{ + // --- Identity --- + {key: "HOSTNAME", defVal: "mail.example.com", comments: []string{ + "--- Server Identity ---", + "FQDN used for SMTP HELO/EHLO, TLS SNI, and URL building.", + "Must resolve in DNS if using TLS_MODE=autocert/dns01/http01.", + }}, + {key: "DEFAULT_DOMAIN", defVal: "example.com", comments: []string{ + "Primary mail domain served by this instance.", + }}, + + // --- SMTP --- + {key: "SMTP_IFACE", defVal: "0.0.0.0", comments: []string{ + "--- SMTP Server (Inbound MTA) ---", + "Network interface to bind SMTP port 25.", + }}, + {key: "SMTP_PORT", defVal: "25"}, + {key: "SMTP_ENABLED", defVal: "true"}, + {key: "SUBMIT_IFACE", defVal: "0.0.0.0", comments: []string{ + "--- SMTP Submission (Authenticated Send) ---", + }}, + {key: "SUBMIT_PORT", defVal: "587", comments: []string{"STARTTLS mandatory on this port."}}, + {key: "SUBMIT_ENABLED", defVal: "true"}, + {key: "SMTPS_PORT", defVal: "465", comments: []string{"Implicit TLS SMTP submission."}}, + {key: "SMTPS_ENABLED", defVal: "true"}, + + // --- IMAP --- + {key: "IMAP_IFACE", defVal: "0.0.0.0", comments: []string{"--- IMAP Server ---"}}, + {key: "IMAP_PORT", defVal: "143"}, + {key: "IMAP_ENABLED", defVal: "true"}, + {key: "IMAPS_PORT", defVal: "993"}, + {key: "IMAPS_ENABLED", defVal: "true"}, + + // --- Web --- + {key: "WEBCLIENT_IFACE", defVal: "0.0.0.0", comments: []string{"--- Web Client ---"}}, + {key: "WEBCLIENT_PORT", defVal: "8080"}, + {key: "WEBADMIN_IFACE", defVal: "127.0.0.1", comments: []string{ + "--- Web Admin ---", + "Default: loopback only. Change to 0.0.0.0 only behind a reverse proxy with auth.", + }}, + {key: "WEBADMIN_PORT", defVal: "8081"}, + {key: "CALDAV_IFACE", defVal: "0.0.0.0", comments: []string{"--- CalDAV / CardDAV ---"}}, + {key: "CALDAV_PORT", defVal: "5232"}, + + // --- TLS --- + {key: "TLS_MODE", defVal: "dns01", comments: []string{ + "--- TLS Configuration ---", + " dns01 = Let's Encrypt via DNS-01 (no open ports, wildcard support) [RECOMMENDED]", + " http01 = Let's Encrypt via HTTP-01 (port 80 must be reachable, no wildcards)", + " manual = Provide TLS_CERT + TLS_KEY paths", + " off = No TLS (use ONLY behind a TLS-terminating reverse proxy)", + }}, + {key: "TLS_CERT", defVal: "./certs/cert.pem", comments: []string{ + "Path to certificate file (PEM). Used when TLS_MODE=manual.", + "Also used as per-service fallback if SMTP_TLS_CERT etc. are not set.", + }}, + {key: "TLS_KEY", defVal: "./certs/key.pem"}, + {key: "SMTP_TLS_CERT", defVal: "", comments: []string{"Override TLS cert/key for SMTP services (blank = use global TLS)."}}, + {key: "SMTP_TLS_KEY", defVal: ""}, + {key: "IMAP_TLS_CERT", defVal: "", comments: []string{"Override TLS cert/key for IMAP services."}}, + {key: "IMAP_TLS_KEY", defVal: ""}, + {key: "WEB_TLS_CERT", defVal: "", comments: []string{"Override TLS cert/key for web/CalDAV ports."}}, + {key: "WEB_TLS_KEY", defVal: ""}, + + // --- ACME --- + {key: "ACME_EMAIL", defVal: "", comments: []string{ + "--- ACME / Let's Encrypt ---", + "Email for Let's Encrypt account registration and renewal notices. Required.", + }}, + {key: "ACME_CACHE_DIR", defVal: "./acme-cache", comments: []string{ + "Directory to cache ACME account data and certificates.", + }}, + {key: "ACME_STAGING", defVal: "false", comments: []string{ + "Use Let's Encrypt staging server (rate-limit-free testing). Set false for production.", + }}, + {key: "ACME_DOMAINS", defVal: "", comments: []string{ + "Comma-separated domains to include in the certificate.", + "Example: example.com,*.example.com,mail.example.com", + "Blank = use HOSTNAME only. Include wildcard for full coverage.", + }}, + {key: "ACME_DNS_PROVIDER", defVal: "cloudflare", comments: []string{ + "DNS provider for DNS-01 challenge (TLS_MODE=dns01).", + "Supported: cloudflare | route53 | digitalocean | hetzner | ovh | porkbun |", + " namecheap | gandi | desec | acmedns | godaddy | ... (90+ providers)", + "Full list: https://go-acme.github.io/lego/dns/", + }}, + + // --- Cloudflare --- + {key: "CF_DNS_API_TOKEN", defVal: "", secret: true, comments: []string{ + "--- Cloudflare DNS-01 (ACME_DNS_PROVIDER=cloudflare) ---", + "API Token with Zone.DNS:Edit permission on target zone(s).", + "Preferred over CF_API_KEY. Create at: https://dash.cloudflare.com/profile/api-tokens", + }}, + {key: "CF_API_KEY", defVal: "", secret: true, comments: []string{"Global API Key (alternative to CF_DNS_API_TOKEN)."}}, + {key: "CF_API_EMAIL", defVal: "", comments: []string{"Account email (required with CF_API_KEY, not needed with CF_DNS_API_TOKEN)."}}, + + // --- Route53 --- + {key: "AWS_REGION", defVal: "", comments: []string{"--- AWS Route53 (ACME_DNS_PROVIDER=route53) ---"}}, + {key: "AWS_ACCESS_KEY_ID", defVal: "", secret: true}, + {key: "AWS_SECRET_ACCESS_KEY", defVal: "", secret: true}, + {key: "AWS_HOSTED_ZONE_ID", defVal: "", comments: []string{"Optional: skip auto-detection."}}, + + // --- DigitalOcean --- + {key: "DO_AUTH_TOKEN", defVal: "", secret: true, comments: []string{"--- DigitalOcean (ACME_DNS_PROVIDER=digitalocean) ---"}}, + + // --- Hetzner --- + {key: "HETZNER_API_KEY", defVal: "", secret: true, comments: []string{"--- Hetzner DNS (ACME_DNS_PROVIDER=hetzner) ---"}}, + + // --- Secrets --- + {key: "ENCRYPTION_KEY", defVal: "", secret: true, comments: []string{ + "--- Secrets (auto-generated — BACK UP this file!) ---", + "AES-256 master key for all data at rest (emails, tokens, keys, contacts, calendar).", + "64 hex characters = 32 bytes. Losing this key = permanent data loss.", + }}, + {key: "SESSION_SECRET", defVal: "", secret: true, comments: []string{ + "Session cookie signing secret. Changing this logs out all users.", + }}, + + // --- Database --- + {key: "DB_DRIVER", defVal: "sqlite", comments: []string{ + "--- Database ---", + "Database driver: sqlite | postgres | mysql | mssql", + }}, + {key: "DB_PATH", defVal: "./data/mail.db", comments: []string{"SQLite database path (DB_DRIVER=sqlite)."}}, + {key: "DB_DSN", defVal: "", secret: true, comments: []string{ + "Connection string for PostgreSQL/MySQL/MSSQL.", + " PostgreSQL: host=localhost port=5432 user=mail password=secret dbname=mail sslmode=require", + " MySQL: mail:secret@tcp(localhost:3306)/mail?tls=true", + " MSSQL: sqlserver://mail:secret@localhost?database=mail", + }}, + + // --- Storage --- + {key: "STORAGE_BACKEND", defVal: "db", comments: []string{ + "--- Email Storage ---", + " db = Store encrypted message blobs in database (simple, single backup file)", + " fs = Store encrypted files on filesystem, metadata in DB (better for large attachments)", + }}, + {key: "STORAGE_FS_PATH", defVal: "./data/messages", comments: []string{ + "Base directory for filesystem storage (STORAGE_BACKEND=fs).", + }}, + + // --- Security --- + {key: "MAX_MESSAGE_SIZE", defVal: "52428800", comments: []string{ + "--- Security ---", + "Maximum accepted email size in bytes (default 50 MB).", + }}, + {key: "SESSION_MAX_AGE", defVal: "604800", comments: []string{"Session lifetime in seconds (default 7 days)."}}, + {key: "BRUTE_MAX_TRIES", defVal: "5"}, + {key: "BRUTE_WINDOW_MIN", defVal: "30"}, + {key: "BRUTE_BAN_HOURS", defVal: "24"}, + {key: "BRUTE_WHITELIST_IPS", defVal: "", comments: []string{"Comma-separated IPs exempt from brute-force banning."}}, + {key: "TRUSTED_PROXIES", defVal: "", comments: []string{ + "Comma-separated CIDR ranges of trusted reverse proxies.", + "Only these may set X-Forwarded-For / X-Forwarded-Proto headers.", + }}, + {key: "SECURE_COOKIE", defVal: "false", comments: []string{ + "Mark session cookies Secure. Set true when serving over HTTPS.", + "Auto-enabled when BASE_URL starts with https://", + }}, + + // --- SMTP tuning --- + {key: "SMTP_HOSTNAME", defVal: "", comments: []string{ + "--- SMTP Tuning ---", + "SMTP HELO/EHLO hostname override. Blank = use HOSTNAME.", + }}, + {key: "MAX_RCPT_PER", defVal: "100", comments: []string{"Maximum recipients per message."}}, + {key: "QUEUE_MAX_AGE_HOURS", defVal: "72", comments: []string{"Queue age before bounce (hours)."}}, + {key: "QUEUE_RETRY_MINS", defVal: "5,15,60,240,480", comments: []string{"Retry backoff schedule (minutes between attempts)."}}, + {key: "DNS_PRIMARY", defVal: "1.1.1.1"}, + {key: "DNS_SECONDARY", defVal: "8.8.8.8"}, + + // --- DKIM --- + {key: "DKIM_SELECTOR", defVal: "mail", comments: []string{ + "--- DKIM ---", + "Default DKIM selector for new domains.", + }}, + {key: "DKIM_ALGO", defVal: "rsa2048", comments: []string{"Key algorithm: rsa2048 | ed25519"}}, + + // --- Spam --- + {key: "SPAM_THRESHOLD", defVal: "10", comments: []string{ + "--- Spam Filtering ---", + "Messages with spam score >= threshold delivered to Spam folder.", + }}, + {key: "SPAM_DNSBL", defVal: "zen.spamhaus.org,bl.spamcop.net"}, + {key: "SPAM_CHECK_SPF", defVal: "true"}, + {key: "SPAM_CHECK_DKIM", defVal: "true"}, + {key: "SPAM_CHECK_DMARC", defVal: "true"}, + + // --- OAuth2 --- + {key: "GOOGLE_CLIENT_ID", defVal: "", comments: []string{ + "--- Google OAuth2 (external Gmail accounts) ---", + "Create at: https://console.cloud.google.com/apis/credentials", + "Required scope: https://mail.google.com/", + }}, + {key: "GOOGLE_CLIENT_SECRET", defVal: "", secret: true}, + {key: "MICROSOFT_CLIENT_ID", defVal: "", comments: []string{ + "--- Microsoft OAuth2 (external Outlook accounts) ---", + "Register at: https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps", + }}, + {key: "MICROSOFT_CLIENT_SECRET", defVal: "", secret: true}, + {key: "MICROSOFT_TENANT_ID", defVal: "consumers"}, + + // --- Debug --- + {key: "DEBUG", defVal: "false", comments: []string{"--- Debug ---"}}, + {key: "LOG_FILE", defVal: "./logs/mail.log"}, + {key: "LOG_LEVEL", defVal: "info", comments: []string{"Log level: debug | info | warn | error"}}, +} + +// Load reads app_config.conf, generates it if missing, returns populated Config. +func Load() (*Config, error) { + if err := os.MkdirAll("./data", 0700); err != nil { + return nil, fmt.Errorf("create data dir: %w", err) + } + if err := os.MkdirAll("./logs", 0700); err != nil { + return nil, fmt.Errorf("create logs dir: %w", err) + } + + existing, err := readFile(ConfigPath) + if err != nil { + return nil, err + } + + // Auto-generate secrets if absent. + if existing["ENCRYPTION_KEY"] == "" { + existing["ENCRYPTION_KEY"] = mustHex(32) + fmt.Fprintln(os.Stderr, "[mailgosend] WARNING: Generated new ENCRYPTION_KEY — back up app_config.conf immediately!") + } + if existing["SESSION_SECRET"] == "" { + existing["SESSION_SECRET"] = mustHex(32) + } + + if err := writeFile(ConfigPath, existing); err != nil { + return nil, fmt.Errorf("write config: %w", err) + } + + get := func(key string) string { + if v := os.Getenv("MAILGO_" + key); v != "" { + return v + } + return existing[key] + } + + // Decode master encryption key. + encHex := get("ENCRYPTION_KEY") + encKey, err := hex.DecodeString(encHex) + if err != nil || len(encKey) != 32 { + return nil, fmt.Errorf("ENCRYPTION_KEY must be 64 hex chars (32 bytes), got %d chars", len(encHex)) + } + sessSecret := get("SESSION_SECRET") + if sessSecret == "" { + return nil, fmt.Errorf("SESSION_SECRET missing") + } + + trustedProxies, err := parseCIDRs(get("TRUSTED_PROXIES")) + if err != nil { + return nil, fmt.Errorf("TRUSTED_PROXIES: %w", err) + } + + acmeDomains := splitTrim(get("ACME_DOMAINS"), ",") + retryMins := parseIntList(get("QUEUE_RETRY_MINS"), []int{5, 15, 60, 240, 480}) + dnsbl := splitTrim(get("SPAM_DNSBL"), ",") + + smtpHostname := get("SMTP_HOSTNAME") + if smtpHostname == "" { + smtpHostname = get("HOSTNAME") + } + if smtpHostname == "" { + smtpHostname = "localhost" + } + + cfg := &Config{ + Hostname: orDefault(get("HOSTNAME"), "mail.example.com"), + DefaultDomain: orDefault(get("DEFAULT_DOMAIN"), "example.com"), + + SMTPIface: orDefault(get("SMTP_IFACE"), "0.0.0.0"), + SMTPPort: atoi(get("SMTP_PORT"), 25), + SMTPEnabled: atobool(get("SMTP_ENABLED"), true), + SubmitIface: orDefault(get("SUBMIT_IFACE"), "0.0.0.0"), + SubmitPort: atoi(get("SUBMIT_PORT"), 587), + SubmitEnabled: atobool(get("SUBMIT_ENABLED"), true), + SMTPSPort: atoi(get("SMTPS_PORT"), 465), + SMTPSEnabled: atobool(get("SMTPS_ENABLED"), true), + + IMAPIface: orDefault(get("IMAP_IFACE"), "0.0.0.0"), + IMAPPort: atoi(get("IMAP_PORT"), 143), + IMAPEnabled: atobool(get("IMAP_ENABLED"), true), + IMAPSPort: atoi(get("IMAPS_PORT"), 993), + IMAPSEnabled: atobool(get("IMAPS_ENABLED"), true), + + WebClientIface: orDefault(get("WEBCLIENT_IFACE"), "0.0.0.0"), + WebClientPort: atoi(get("WEBCLIENT_PORT"), 8080), + WebAdminIface: orDefault(get("WEBADMIN_IFACE"), "127.0.0.1"), + WebAdminPort: atoi(get("WEBADMIN_PORT"), 8081), + CalDAVIface: orDefault(get("CALDAV_IFACE"), "0.0.0.0"), + CalDAVPort: atoi(get("CALDAV_PORT"), 5232), + + TLSMode: orDefault(get("TLS_MODE"), "dns01"), + TLSCert: get("TLS_CERT"), + TLSKey: get("TLS_KEY"), + SMTPTLSCert: get("SMTP_TLS_CERT"), + SMTPTLSKey: get("SMTP_TLS_KEY"), + IMAPTLSCert: get("IMAP_TLS_CERT"), + IMAPTLSKey: get("IMAP_TLS_KEY"), + WebTLSCert: get("WEB_TLS_CERT"), + WebTLSKey: get("WEB_TLS_KEY"), + + ACMEEmail: get("ACME_EMAIL"), + ACMECacheDir: orDefault(get("ACME_CACHE_DIR"), "./acme-cache"), + ACMEStaging: atobool(get("ACME_STAGING"), false), + ACMEDomains: acmeDomains, + ACMEDNSProvider: orDefault(get("ACME_DNS_PROVIDER"), "cloudflare"), + + CFDNSAPIToken: get("CF_DNS_API_TOKEN"), + CFAPIKey: get("CF_API_KEY"), + CFAPIEmail: get("CF_API_EMAIL"), + AWSRegion: get("AWS_REGION"), + AWSAccessKeyID: get("AWS_ACCESS_KEY_ID"), + AWSSecretAccessKey: get("AWS_SECRET_ACCESS_KEY"), + AWSHostedZoneID: get("AWS_HOSTED_ZONE_ID"), + DOAuthToken: get("DO_AUTH_TOKEN"), + HetznerAPIKey: get("HETZNER_API_KEY"), + + EncryptionKey: encKey, + SessionSecret: []byte(sessSecret), + + DBDriver: orDefault(get("DB_DRIVER"), "sqlite"), + DBPath: orDefault(get("DB_PATH"), "./data/mail.db"), + DBDSN: get("DB_DSN"), + + StorageBackend: orDefault(get("STORAGE_BACKEND"), "db"), + StorageFSPath: orDefault(get("STORAGE_FS_PATH"), "./data/messages"), + + MaxMessageSize: int64(atoi(get("MAX_MESSAGE_SIZE"), 52428800)), + SessionMaxAge: atoi(get("SESSION_MAX_AGE"), 604800), + BruteMaxTries: atoi(get("BRUTE_MAX_TRIES"), 5), + BruteWindowMin: atoi(get("BRUTE_WINDOW_MIN"), 30), + BruteBanHours: atoi(get("BRUTE_BAN_HOURS"), 24), + BruteWhitelist: parseIPs(get("BRUTE_WHITELIST_IPS")), + TrustedProxies: trustedProxies, + SecureCookie: atobool(get("SECURE_COOKIE"), false), + + SMTPHostname: smtpHostname, + MaxRcptPer: atoi(get("MAX_RCPT_PER"), 100), + QueueMaxAgeHours: atoi(get("QUEUE_MAX_AGE_HOURS"), 72), + QueueRetryMins: retryMins, + DNSPrimary: orDefault(get("DNS_PRIMARY"), "1.1.1.1"), + DNSSecondary: orDefault(get("DNS_SECONDARY"), "8.8.8.8"), + + DKIMSelector: orDefault(get("DKIM_SELECTOR"), "mail"), + DKIMAlgo: orDefault(get("DKIM_ALGO"), "rsa2048"), + + SpamThreshold: atoi(get("SPAM_THRESHOLD"), 10), + SpamDNSBL: dnsbl, + SpamCheckSPF: atobool(get("SPAM_CHECK_SPF"), true), + SpamCheckDKIM: atobool(get("SPAM_CHECK_DKIM"), true), + SpamCheckDMARC: atobool(get("SPAM_CHECK_DMARC"), true), + + GoogleClientID: get("GOOGLE_CLIENT_ID"), + GoogleClientSecret: get("GOOGLE_CLIENT_SECRET"), + MicrosoftClientID: get("MICROSOFT_CLIENT_ID"), + MicrosoftClientSecret: get("MICROSOFT_CLIENT_SECRET"), + MicrosoftTenantID: orDefault(get("MICROSOFT_TENANT_ID"), "consumers"), + + Debug: atobool(get("DEBUG"), false), + LogFile: orDefault(get("LOG_FILE"), "./logs/mail.log"), + LogLevel: orDefault(get("LOG_LEVEL"), "info"), + } + + // Export provider-specific env vars so lego can pick them up. + cfg.exportProviderEnv() + + logStartup(cfg) + return cfg, nil +} + +// exportProviderEnv sets standard lego env vars from config values. +// This allows using app_config.conf as the single source of truth. +func (c *Config) exportProviderEnv() { + setenv := func(key, val string) { + if val != "" && os.Getenv(key) == "" { + os.Setenv(key, val) //nolint:errcheck + } + } + setenv("CF_DNS_API_TOKEN", c.CFDNSAPIToken) + setenv("CF_API_KEY", c.CFAPIKey) + setenv("CF_API_EMAIL", c.CFAPIEmail) + setenv("AWS_REGION", c.AWSRegion) + setenv("AWS_ACCESS_KEY_ID", c.AWSAccessKeyID) + setenv("AWS_SECRET_ACCESS_KEY", c.AWSSecretAccessKey) + setenv("AWS_HOSTED_ZONE_ID", c.AWSHostedZoneID) + setenv("DO_AUTH_TOKEN", c.DOAuthToken) + setenv("HETZNER_API_KEY", c.HetznerAPIKey) +} + +// ACMEDomainList returns ACME_DOMAINS, falling back to Hostname. +func (c *Config) ACMEDomainList() []string { + if len(c.ACMEDomains) > 0 { + return c.ACMEDomains + } + return []string{c.Hostname} +} + +// RealIP extracts the client IP, honouring X-Forwarded-For from trusted proxies. +func (c *Config) RealIP(remoteAddr, xForwardedFor string) string { + remoteIP, _, err := net.SplitHostPort(remoteAddr) + if err != nil { + remoteIP = remoteAddr + } + if xForwardedFor == "" || !c.isTrustedProxy(remoteIP) { + return remoteIP + } + parts := strings.Split(xForwardedFor, ",") + if len(parts) > 0 { + if ip := strings.TrimSpace(parts[0]); net.ParseIP(ip) != nil { + return ip + } + } + return remoteIP +} + +func (c *Config) isTrustedProxy(ipStr string) bool { + ip := net.ParseIP(ipStr) + if ip == nil { + return false + } + for _, cidr := range c.TrustedProxies { + if cidr.Contains(ip) { + return true + } + } + return false +} + +func (c *Config) IsIPWhitelisted(ip string) bool { + parsed := net.ParseIP(ip) + if parsed == nil { + return false + } + for _, w := range c.BruteWhitelist { + if w.Equal(parsed) { + return true + } + } + return false +} + +// ---- Config file I/O ---- + +func readFile(path string) (map[string]string, error) { + vals := make(map[string]string) + f, err := os.Open(path) + if os.IsNotExist(err) { + return vals, nil + } + if err != nil { + return nil, fmt.Errorf("open config: %w", err) + } + defer f.Close() + + sc := bufio.NewScanner(f) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + idx := strings.IndexByte(line, '=') + if idx < 0 { + continue + } + k := strings.TrimSpace(line[:idx]) + v := strings.TrimSpace(line[idx+1:]) + vals[k] = v + } + return vals, sc.Err() +} + +func writeFile(path string, existing map[string]string) error { + var sb strings.Builder + sb.WriteString("# mailgosend Configuration\n") + sb.WriteString("# =========================\n") + sb.WriteString("# Auto-generated and updated on each startup.\n") + sb.WriteString("# Edit freely — your values are always preserved.\n") + sb.WriteString("# Override any key via env var: MAILGO_=value\n") + sb.WriteString("#\n\n") + + for _, f := range allFields { + for _, c := range f.comments { + if c == "" { + sb.WriteString("#\n") + } else { + sb.WriteString("# " + c + "\n") + } + } + v := existing[f.key] + if v == "" { + v = f.defVal + } + sb.WriteString(f.key + " = " + v + "\n\n") + } + return os.WriteFile(path, []byte(sb.String()), 0600) +} + +// ---- Startup log ---- + +func logStartup(c *Config) { + fmt.Printf("mailgosend starting\n") + fmt.Printf(" Hostname : %s\n", c.Hostname) + fmt.Printf(" Default domain: %s\n", c.DefaultDomain) + fmt.Printf(" TLS mode : %s\n", c.TLSMode) + if c.TLSMode == "dns01" || c.TLSMode == "http01" { + fmt.Printf(" ACME provider: %s\n", c.ACMEDNSProvider) + fmt.Printf(" ACME domains : %v\n", c.ACMEDomainList()) + } + fmt.Printf(" DB driver : %s\n", c.DBDriver) + if c.SMTPEnabled { + fmt.Printf(" SMTP : %s:%d\n", c.SMTPIface, c.SMTPPort) + } + if c.SubmitEnabled { + fmt.Printf(" Submission : %s:%d (STARTTLS)\n", c.SubmitIface, c.SubmitPort) + } + if c.SMTPSEnabled { + fmt.Printf(" SMTPS : %s:%d (TLS)\n", c.SMTPIface, c.SMTPSPort) + } + if c.IMAPEnabled { + fmt.Printf(" IMAP : %s:%d (STARTTLS)\n", c.IMAPIface, c.IMAPPort) + } + if c.IMAPSEnabled { + fmt.Printf(" IMAPS : %s:%d (TLS)\n", c.IMAPIface, c.IMAPSPort) + } + fmt.Printf(" Web client : %s:%d\n", c.WebClientIface, c.WebClientPort) + fmt.Printf(" Web admin : %s:%d\n", c.WebAdminIface, c.WebAdminPort) + fmt.Printf(" CalDAV : %s:%d\n", c.CalDAVIface, c.CalDAVPort) +} + +// ---- Helpers ---- + +func mustHex(n int) string { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + panic("crypto/rand unavailable: " + err.Error()) + } + return hex.EncodeToString(b) +} + +func atoi(s string, fallback int) int { + if v, err := strconv.Atoi(s); err == nil { + return v + } + return fallback +} + +func atobool(s string, fallback bool) bool { + if v, err := strconv.ParseBool(s); err == nil { + return v + } + return fallback +} + +func orDefault(s, def string) string { + if s == "" { + return def + } + return s +} + +func splitTrim(s, sep string) []string { + var out []string + for _, p := range strings.Split(s, sep) { + p = strings.TrimSpace(p) + if p != "" { + out = append(out, p) + } + } + return out +} + +func parseIntList(s string, fallback []int) []int { + parts := splitTrim(s, ",") + if len(parts) == 0 { + return fallback + } + out := make([]int, 0, len(parts)) + for _, p := range parts { + if v, err := strconv.Atoi(p); err == nil { + out = append(out, v) + } + } + if len(out) == 0 { + return fallback + } + return out +} + +func parseCIDRs(s string) ([]net.IPNet, error) { + var nets []net.IPNet + for _, raw := range splitTrim(s, ",") { + if !strings.Contains(raw, "/") { + ip := net.ParseIP(raw) + if ip == nil { + return nil, fmt.Errorf("invalid IP %q", raw) + } + bits := 32 + if ip.To4() == nil { + bits = 128 + } + raw = fmt.Sprintf("%s/%d", ip.String(), bits) + } + _, ipNet, err := net.ParseCIDR(raw) + if err != nil { + return nil, fmt.Errorf("invalid CIDR %q: %w", raw, err) + } + nets = append(nets, *ipNet) + } + return nets, nil +} + +func parseIPs(s string) []net.IP { + var ips []net.IP + for _, raw := range splitTrim(s, ",") { + if ip := net.ParseIP(raw); ip != nil { + ips = append(ips, ip) + } + } + return ips +} diff --git a/internal/crypto/crypto.go b/internal/crypto/crypto.go new file mode 100644 index 0000000..d67d274 --- /dev/null +++ b/internal/crypto/crypto.go @@ -0,0 +1,216 @@ +// Package crypto provides AES-256-GCM encryption, HKDF key derivation, +// bcrypt helpers, and secure random utilities. +// All encryption uses authenticated encryption — tampering is detected. +package crypto + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "fmt" + "io" + + "golang.org/x/crypto/bcrypt" + "golang.org/x/crypto/hkdf" +) + +const ( + // BcryptCost is the minimum bcrypt work factor. + BcryptCost = 12 + + // keyLen is AES-256 key length in bytes. + keyLen = 32 + + // gcmNonceLen is the standard GCM nonce size. + gcmNonceLen = 12 +) + +// Crypto holds the master encryption key. +// One instance per application, injected everywhere that needs encryption. +type Crypto struct { + masterKey [keyLen]byte +} + +// New creates a Crypto instance from a 32-byte master key. +func New(masterKey []byte) (*Crypto, error) { + if len(masterKey) != keyLen { + return nil, fmt.Errorf("crypto: master key must be %d bytes, got %d", keyLen, len(masterKey)) + } + c := &Crypto{} + copy(c.masterKey[:], masterKey) + return c, nil +} + +// DeriveKey returns a unique 32-byte AES-256 key for a given purpose + userID. +// Uses HKDF-SHA256 so each (purpose, userID) pair gets a unique subkey, +// and the master key is never used directly for encryption. +func (c *Crypto) DeriveKey(purpose string, userID int64) ([keyLen]byte, error) { + var key [keyLen]byte + info := fmt.Sprintf("%s:user:%d", purpose, userID) + r := hkdf.New(sha256.New, c.masterKey[:], nil, []byte(info)) + if _, err := io.ReadFull(r, key[:]); err != nil { + return key, fmt.Errorf("hkdf derive: %w", err) + } + return key, nil +} + +// DeriveKeyGlobal returns a 32-byte key derived from master key for global use +// (e.g. encrypting DKIM private keys stored per-domain, not per-user). +func (c *Crypto) DeriveKeyGlobal(purpose string) ([keyLen]byte, error) { + var key [keyLen]byte + r := hkdf.New(sha256.New, c.masterKey[:], nil, []byte("global:"+purpose)) + if _, err := io.ReadFull(r, key[:]); err != nil { + return key, fmt.Errorf("hkdf derive global: %w", err) + } + return key, nil +} + +// Encrypt encrypts plaintext with AES-256-GCM using the provided 32-byte key. +// Returns nonce||ciphertext||tag (nonce prepended, all opaque bytes). +// Returns an error if plaintext is nil (use []byte{} for empty). +func Encrypt(key [keyLen]byte, plaintext []byte) ([]byte, error) { + block, err := aes.NewCipher(key[:]) + if err != nil { + return nil, fmt.Errorf("aes new cipher: %w", err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("aes gcm: %w", err) + } + + nonce := make([]byte, gcmNonceLen) + if _, err := rand.Read(nonce); err != nil { + return nil, fmt.Errorf("rand nonce: %w", err) + } + + ciphertext := gcm.Seal(nonce, nonce, plaintext, nil) + return ciphertext, nil +} + +// Decrypt decrypts a nonce||ciphertext||tag blob produced by Encrypt. +func Decrypt(key [keyLen]byte, ciphertext []byte) ([]byte, error) { + if len(ciphertext) < gcmNonceLen { + return nil, fmt.Errorf("ciphertext too short") + } + block, err := aes.NewCipher(key[:]) + if err != nil { + return nil, fmt.Errorf("aes new cipher: %w", err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("aes gcm: %w", err) + } + + nonce := ciphertext[:gcmNonceLen] + data := ciphertext[gcmNonceLen:] + plaintext, err := gcm.Open(nil, nonce, data, nil) + if err != nil { + return nil, fmt.Errorf("decrypt: %w", err) // do not leak GCM error details + } + return plaintext, nil +} + +// EncryptForUser derives a per-user key and encrypts. +func (c *Crypto) EncryptForUser(userID int64, purpose string, plaintext []byte) ([]byte, error) { + key, err := c.DeriveKey(purpose, userID) + if err != nil { + return nil, err + } + return Encrypt(key, plaintext) +} + +// DecryptForUser derives a per-user key and decrypts. +func (c *Crypto) DecryptForUser(userID int64, purpose string, ciphertext []byte) ([]byte, error) { + key, err := c.DeriveKey(purpose, userID) + if err != nil { + return nil, err + } + return Decrypt(key, ciphertext) +} + +// EncryptGlobal derives a global key for given purpose and encrypts. +func (c *Crypto) EncryptGlobal(purpose string, plaintext []byte) ([]byte, error) { + key, err := c.DeriveKeyGlobal(purpose) + if err != nil { + return nil, err + } + return Encrypt(key, plaintext) +} + +// DecryptGlobal derives a global key for given purpose and decrypts. +func (c *Crypto) DecryptGlobal(purpose string, ciphertext []byte) ([]byte, error) { + key, err := c.DeriveKeyGlobal(purpose) + if err != nil { + return nil, err + } + return Decrypt(key, ciphertext) +} + +// ---- Bcrypt ---- + +// HashPassword hashes a password with bcrypt at cost BcryptCost. +func HashPassword(password string) (string, error) { + if password == "" { + return "", fmt.Errorf("password must not be empty") + } + hash, err := bcrypt.GenerateFromPassword([]byte(password), BcryptCost) + if err != nil { + return "", fmt.Errorf("bcrypt: %w", err) + } + return string(hash), nil +} + +// CheckPassword returns nil if password matches the stored bcrypt hash. +// Uses constant-time comparison internally (bcrypt). +func CheckPassword(hash, password string) error { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) +} + +// ---- Session tokens ---- + +// NewToken generates a cryptographically random 32-byte token and returns +// (rawToken, sha256HexHash). Store the hash; send the raw token to the client. +func NewToken() (raw string, hash string, err error) { + b := make([]byte, 32) + if _, err = rand.Read(b); err != nil { + return "", "", fmt.Errorf("rand token: %w", err) + } + raw = hex.EncodeToString(b) + hash = HashToken(raw) + return raw, hash, nil +} + +// HashToken returns the SHA-256 hex hash of a raw token string. +func HashToken(raw string) string { + h := sha256.Sum256([]byte(raw)) + return hex.EncodeToString(h[:]) +} + +// SecureCompare returns true if a == b using constant-time comparison. +// Use for any comparison where timing attacks are a concern. +func SecureCompare(a, b string) bool { + return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1 +} + +// ---- Random helpers ---- + +// RandomHex returns n random bytes as a hex string (length 2n). +func RandomHex(n int) (string, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("rand: %w", err) + } + return hex.EncodeToString(b), nil +} + +// RandomBytes returns n cryptographically random bytes. +func RandomBytes(n int) ([]byte, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return nil, fmt.Errorf("rand: %w", err) + } + return b, nil +} diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..0575a96 --- /dev/null +++ b/internal/db/db.go @@ -0,0 +1,171 @@ +// Package db provides the database/sql wrapper and driver registration. +// Default driver: modernc.org/sqlite (pure Go, no CGO). +// Additional drivers registered via build tags: postgres, mysql, mssql. +package db + +import ( + "context" + "database/sql" + "fmt" + "time" + + _ "modernc.org/sqlite" // pure-Go SQLite driver +) + +// DB wraps sql.DB with convenience methods and prepared statement caching. +type DB struct { + db *sql.DB + driver string +} + +// Open opens and validates the database connection, runs migrations, returns DB. +func Open(driver, dsn string) (*DB, error) { + if driver == "" { + driver = "sqlite" + } + + // Map friendly driver names to database/sql driver names. + sqlDriver := sqlDriverName(driver) + + if driver == "sqlite" { + dsn = sqliteDSN(dsn) + } + + sqlDB, err := sql.Open(sqlDriver, dsn) + if err != nil { + return nil, fmt.Errorf("db open %s: %w", driver, err) + } + + // Connection pool tuning. + if driver == "sqlite" { + // SQLite: serialise with single connection to avoid SQLITE_BUSY. + sqlDB.SetMaxOpenConns(1) + sqlDB.SetMaxIdleConns(1) + sqlDB.SetConnMaxLifetime(0) + } else { + sqlDB.SetMaxOpenConns(25) + sqlDB.SetMaxIdleConns(10) + sqlDB.SetConnMaxLifetime(5 * time.Minute) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := sqlDB.PingContext(ctx); err != nil { + sqlDB.Close() + return nil, fmt.Errorf("db ping: %w", err) + } + + d := &DB{db: sqlDB, driver: driver} + + // Enable WAL mode for SQLite (dramatically improves concurrent read performance). + if driver == "sqlite" { + if _, err := sqlDB.Exec(`PRAGMA journal_mode=WAL`); err != nil { + sqlDB.Close() + return nil, fmt.Errorf("sqlite WAL: %w", err) + } + if _, err := sqlDB.Exec(`PRAGMA foreign_keys=ON`); err != nil { + sqlDB.Close() + return nil, fmt.Errorf("sqlite foreign_keys: %w", err) + } + if _, err := sqlDB.Exec(`PRAGMA busy_timeout=5000`); err != nil { + sqlDB.Close() + return nil, fmt.Errorf("sqlite busy_timeout: %w", err) + } + } + + if err := d.migrate(); err != nil { + sqlDB.Close() + return nil, fmt.Errorf("migrate: %w", err) + } + + return d, nil +} + +// Close closes the underlying sql.DB. +func (d *DB) Close() error { return d.db.Close() } + +// Driver returns the driver name (sqlite / postgres / mysql / mssql). +func (d *DB) Driver() string { return d.driver } + +// SQL returns the underlying *sql.DB for direct use when needed. +func (d *DB) SQL() *sql.DB { return d.db } + +// Exec runs a query with a per-call context timeout. +func (d *DB) Exec(query string, args ...any) (sql.Result, error) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + return d.db.ExecContext(ctx, query, args...) +} + +// QueryRow runs a single-row query. +func (d *DB) QueryRow(query string, args ...any) *sql.Row { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + return d.db.QueryRowContext(ctx, query, args...) +} + +// Query runs a multi-row query. +func (d *DB) Query(query string, args ...any) (*sql.Rows, error) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + return d.db.QueryContext(ctx, query, args...) +} + +// WithTx runs fn inside a transaction, rolling back on error or panic. +func (d *DB) WithTx(ctx context.Context, fn func(*sql.Tx) error) error { + tx, err := d.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin tx: %w", err) + } + defer func() { + if p := recover(); p != nil { + _ = tx.Rollback() + panic(p) // re-raise + } + }() + if err := fn(tx); err != nil { + _ = tx.Rollback() + return err + } + return tx.Commit() +} + +// ---- Placeholder helper ---- + +// Placeholder returns the SQL parameter placeholder for the current driver. +// SQLite and MySQL use ?, PostgreSQL uses $1, $2… MSSQL uses @p1, @p2… +func (d *DB) Placeholder(n int) string { + switch d.driver { + case "postgres": + return fmt.Sprintf("$%d", n) + case "mssql": + return fmt.Sprintf("@p%d", n) + default: + return "?" + } +} + +// ---- Private helpers ---- + +func sqlDriverName(driver string) string { + switch driver { + case "sqlite": + return "sqlite" // modernc.org/sqlite registers as "sqlite" + case "postgres": + return "postgres" + case "mysql": + return "mysql" + case "mssql": + return "sqlserver" + default: + return driver + } +} + +func sqliteDSN(path string) string { + if path == "" { + path = "./data/mail.db" + } + // modernc.org/sqlite DSN supports query parameters. + return path + "?_pragma=foreign_keys(1)" +} diff --git a/internal/db/domains.go b/internal/db/domains.go new file mode 100644 index 0000000..3927d9b --- /dev/null +++ b/internal/db/domains.go @@ -0,0 +1,120 @@ +package db + +import ( + "context" + "database/sql" + "fmt" + + "ghb.freebede.com/nahakubuilder/mailgosend/internal/models" +) + +// GetDomain returns the domain row by name, or nil if not found. +func (d *DB) GetDomain(ctx context.Context, name string) (*models.Domain, error) { + row := d.db.QueryRowContext(ctx, ` + SELECT id, name, enabled, dkim_private_enc, dkim_public, dkim_selector, + dkim_algo, spf_policy, dmarc_policy, max_users, max_quota_bytes, created_at + FROM domains WHERE lower(name) = lower(?)`, name) + + var dom models.Domain + var privEnc []byte + err := row.Scan( + &dom.ID, &dom.Name, &dom.Enabled, + &privEnc, &dom.DKIMPublic, &dom.DKIMSelector, + &dom.DKIMAlgo, &dom.SPFPolicy, &dom.DMARCPolicy, + &dom.MaxUsers, &dom.MaxQuotaBytes, &dom.CreatedAt, + ) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("get domain: %w", err) + } + dom.DKIMPrivateEnc = privEnc + return &dom, nil +} + +// GetDomainByID returns the domain row by ID. +func (d *DB) GetDomainByID(ctx context.Context, id int64) (*models.Domain, error) { + row := d.db.QueryRowContext(ctx, ` + SELECT id, name, enabled, dkim_private_enc, dkim_public, dkim_selector, + dkim_algo, spf_policy, dmarc_policy, max_users, max_quota_bytes, created_at + FROM domains WHERE id = ?`, id) + + var dom models.Domain + var privEnc []byte + err := row.Scan( + &dom.ID, &dom.Name, &dom.Enabled, + &privEnc, &dom.DKIMPublic, &dom.DKIMSelector, + &dom.DKIMAlgo, &dom.SPFPolicy, &dom.DMARCPolicy, + &dom.MaxUsers, &dom.MaxQuotaBytes, &dom.CreatedAt, + ) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("get domain by id: %w", err) + } + dom.DKIMPrivateEnc = privEnc + return &dom, nil +} + +// IsLocalDomain returns true if name is a known enabled domain. +func (d *DB) IsLocalDomain(ctx context.Context, name string) (bool, error) { + var count int + err := d.db.QueryRowContext(ctx, + "SELECT COUNT(*) FROM domains WHERE lower(name)=lower(?) AND enabled=1", name). + Scan(&count) + if err != nil { + return false, err + } + return count > 0, nil +} + +// ListDomains returns all domains ordered by name. +func (d *DB) ListDomains(ctx context.Context) ([]*models.Domain, error) { + rows, err := d.db.QueryContext(ctx, ` + SELECT id, name, enabled, dkim_private_enc, dkim_public, dkim_selector, + dkim_algo, spf_policy, dmarc_policy, max_users, max_quota_bytes, created_at + FROM domains ORDER BY name`) + if err != nil { + return nil, err + } + defer rows.Close() + + var doms []*models.Domain + for rows.Next() { + var dom models.Domain + var privEnc []byte + err := rows.Scan( + &dom.ID, &dom.Name, &dom.Enabled, + &privEnc, &dom.DKIMPublic, &dom.DKIMSelector, + &dom.DKIMAlgo, &dom.SPFPolicy, &dom.DMARCPolicy, + &dom.MaxUsers, &dom.MaxQuotaBytes, &dom.CreatedAt, + ) + if err != nil { + return nil, err + } + dom.DKIMPrivateEnc = privEnc + doms = append(doms, &dom) + } + return doms, rows.Err() +} + +// CreateDomain inserts a new domain. Returns the new ID. +func (d *DB) CreateDomain(ctx context.Context, name, selector, algo string) (int64, error) { + res, err := d.db.ExecContext(ctx, ` + INSERT INTO domains (name, enabled, dkim_selector, dkim_algo) + VALUES (?, 1, ?, ?)`, name, selector, algo) + if err != nil { + return 0, fmt.Errorf("create domain: %w", err) + } + return res.LastInsertId() +} + +// SaveDKIMKeys stores encrypted DKIM private key + public key for a domain. +func (d *DB) SaveDKIMKeys(ctx context.Context, domainID int64, privEnc []byte, pubPEM string) error { + _, err := d.db.ExecContext(ctx, + "UPDATE domains SET dkim_private_enc=?, dkim_public=? WHERE id=?", + privEnc, pubPEM, domainID) + return err +} diff --git a/internal/db/imap.go b/internal/db/imap.go new file mode 100644 index 0000000..5862ee0 --- /dev/null +++ b/internal/db/imap.go @@ -0,0 +1,284 @@ +package db + +import ( + "context" + "database/sql" + "fmt" + "time" + + "ghb.freebede.com/nahakubuilder/mailgosend/internal/models" +) + +// IMAPMessage is a lightweight message descriptor used by the IMAP layer. +// The raw/body blobs are NOT loaded here — fetch separately via GetMessageRaw. +type IMAPMessage struct { + ID int64 + MailboxID int64 + UID uint32 + MessageID string // RFC 2822 Message-ID header + Subject string + FromEmail string + FromName string + ToList string + Date time.Time + SizeBytes int64 + HasAttachment bool + IsRead bool + IsStarred bool + IsDraft bool + IsDeleted bool // deleted_at IS NOT NULL + Flags string + SpamScore int + ReceivedAt time.Time +} + +// ListIMAPMessages returns all non-deleted messages in a mailbox ordered by UID ascending. +func (d *DB) ListIMAPMessages(ctx context.Context, mailboxID int64) ([]*IMAPMessage, error) { + rows, err := d.db.QueryContext(ctx, ` + SELECT id, mailbox_id, uid, message_id, subject, from_email, from_name, + to_list, date, size_bytes, has_attachment, + is_read, is_starred, is_draft, flags, spam_score, received_at + FROM messages + WHERE mailbox_id = ? AND deleted_at IS NULL + ORDER BY uid ASC`, mailboxID) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []*IMAPMessage + for rows.Next() { + m, err := scanIMAPMessage(rows) + if err != nil { + return nil, err + } + out = append(out, m) + } + return out, rows.Err() +} + +// GetIMAPMessageByUID returns one message by UID within a mailbox. +func (d *DB) GetIMAPMessageByUID(ctx context.Context, mailboxID int64, uid uint32) (*IMAPMessage, error) { + rows, err := d.db.QueryContext(ctx, ` + SELECT id, mailbox_id, uid, message_id, subject, from_email, from_name, + to_list, date, size_bytes, has_attachment, + is_read, is_starred, is_draft, flags, spam_score, received_at + FROM messages + WHERE mailbox_id = ? AND uid = ? AND deleted_at IS NULL + LIMIT 1`, mailboxID, uid) + if err != nil { + return nil, err + } + defer rows.Close() + if !rows.Next() { + return nil, nil + } + return scanIMAPMessage(rows) +} + +// SetMessageFlags updates the mutable flags for a message. +func (d *DB) SetMessageFlags(ctx context.Context, messageID int64, isRead, isStarred, isDraft bool, extraFlags string) error { + _, err := d.db.ExecContext(ctx, + "UPDATE messages SET is_read=?, is_starred=?, is_draft=?, flags=? WHERE id=?", + isRead, isStarred, isDraft, extraFlags, messageID) + return err +} + +// SoftDeleteMessage marks a message as deleted (sets deleted_at). +func (d *DB) SoftDeleteMessage(ctx context.Context, messageID int64) error { + _, err := d.db.ExecContext(ctx, + "UPDATE messages SET deleted_at=? WHERE id=?", time.Now().UTC(), messageID) + return err +} + +// HardDeleteMessages physically removes all soft-deleted messages from a mailbox. +// Returns the UIDs of deleted messages (for EXPUNGE responses). +func (d *DB) HardDeleteMessages(ctx context.Context, mailboxID int64) ([]uint32, error) { + rows, err := d.db.QueryContext(ctx, + "SELECT uid FROM messages WHERE mailbox_id=? AND deleted_at IS NOT NULL ORDER BY uid ASC", + mailboxID) + if err != nil { + return nil, err + } + var uids []uint32 + for rows.Next() { + var uid uint32 + if err := rows.Scan(&uid); err != nil { + rows.Close() + return nil, err + } + uids = append(uids, uid) + } + rows.Close() + if err := rows.Err(); err != nil { + return nil, err + } + if len(uids) == 0 { + return nil, nil + } + + // Delete attachments first (FK). + _, err = d.db.ExecContext(ctx, ` + DELETE FROM attachments WHERE message_id IN ( + SELECT id FROM messages WHERE mailbox_id=? AND deleted_at IS NOT NULL + )`, mailboxID) + if err != nil { + return nil, fmt.Errorf("delete attachments: %w", err) + } + _, err = d.db.ExecContext(ctx, + "DELETE FROM messages WHERE mailbox_id=? AND deleted_at IS NOT NULL", mailboxID) + if err != nil { + return nil, fmt.Errorf("delete messages: %w", err) + } + return uids, nil +} + +// CopyMessageToMailbox duplicates a message row to another mailbox. +// Returns the new UID. +func (d *DB) CopyMessageToMailbox(ctx context.Context, srcMsgID, destMailboxID, userID int64) (uint32, error) { + // Read source. + var src struct { + mailboxID int64 + uid uint32 + messageID string + subject string + fromEmail string + fromName string + toList string + ccList string + bccList string + replyTo string + date time.Time + bodyTextEnc []byte + bodyHTMLEnc []byte + rawEnc []byte + sizeBytes int64 + hasAttachment bool + isRead bool + isStarred bool + isDraft bool + flags string + spamScore int + } + err := d.db.QueryRowContext(ctx, ` + SELECT mailbox_id, uid, message_id, subject, from_email, from_name, + to_list, cc_list, bcc_list, reply_to, date, + body_text_enc, body_html_enc, raw_enc, + size_bytes, has_attachment, is_read, is_starred, is_draft, + flags, spam_score + FROM messages WHERE id=? AND deleted_at IS NULL`, srcMsgID).Scan( + &src.mailboxID, &src.uid, &src.messageID, &src.subject, + &src.fromEmail, &src.fromName, &src.toList, &src.ccList, &src.bccList, &src.replyTo, + &src.date, &src.bodyTextEnc, &src.bodyHTMLEnc, &src.rawEnc, + &src.sizeBytes, &src.hasAttachment, &src.isRead, &src.isStarred, &src.isDraft, + &src.flags, &src.spamScore, + ) + if err == sql.ErrNoRows { + return 0, fmt.Errorf("source message %d not found", srcMsgID) + } + if err != nil { + return 0, fmt.Errorf("copy message read: %w", err) + } + + // Allocate UID in destination. + uid, err := d.NextUID(ctx, destMailboxID) + if err != nil { + return 0, fmt.Errorf("copy message uid: %w", err) + } + + ins := &MessageInsert{ + MailboxID: destMailboxID, + UID: uid, + MessageID: src.messageID, + Subject: src.subject, + FromEmail: src.fromEmail, + FromName: src.fromName, + ToList: src.toList, + CCList: src.ccList, + BCCList: src.bccList, + ReplyTo: src.replyTo, + Date: src.date, + BodyTextEnc: src.bodyTextEnc, + BodyHTMLEnc: src.bodyHTMLEnc, + RawEnc: src.rawEnc, + SizeBytes: src.sizeBytes, + HasAttachment: src.hasAttachment, + IsRead: src.isRead, + IsStarred: src.isStarred, + IsDraft: src.isDraft, + Flags: src.flags, + SpamScore: src.spamScore, + } + if _, err := d.InsertMessage(ctx, ins); err != nil { + return 0, fmt.Errorf("copy message insert: %w", err) + } + return uid, nil +} + +// RenameMailbox updates the name field of a mailbox. +func (d *DB) RenameMailbox(ctx context.Context, mailboxID int64, newName string) error { + _, err := d.db.ExecContext(ctx, + "UPDATE mailboxes SET name=? WHERE id=?", newName, mailboxID) + return err +} + +// SetMailboxSubscribed updates the subscribed flag on a mailbox. +func (d *DB) SetMailboxSubscribed(ctx context.Context, mailboxID int64, subscribed bool) error { + _, err := d.db.ExecContext(ctx, + "UPDATE mailboxes SET subscribed=? WHERE id=?", subscribed, mailboxID) + return err +} + +// GetMailboxMessageCounts returns (total, unseen) counts for a mailbox. +func (d *DB) GetMailboxMessageCounts(ctx context.Context, mailboxID int64) (total, unseen int64, err error) { + err = d.db.QueryRowContext(ctx, ` + SELECT COUNT(*), COUNT(CASE WHEN is_read=0 THEN 1 END) + FROM messages WHERE mailbox_id=? AND deleted_at IS NULL`, mailboxID).Scan(&total, &unseen) + return +} + +// GetMailboxSize returns the total size in bytes of all messages in a mailbox. +func (d *DB) GetMailboxSize(ctx context.Context, mailboxID int64) (int64, error) { + var sz sql.NullInt64 + err := d.db.QueryRowContext(ctx, + "SELECT SUM(size_bytes) FROM messages WHERE mailbox_id=? AND deleted_at IS NULL", + mailboxID).Scan(&sz) + if err != nil { + return 0, err + } + return sz.Int64, nil +} + +// ---- helpers ---- + +func scanIMAPMessage(rows *sql.Rows) (*IMAPMessage, error) { + m := &IMAPMessage{} + err := rows.Scan( + &m.ID, &m.MailboxID, &m.UID, &m.MessageID, &m.Subject, + &m.FromEmail, &m.FromName, &m.ToList, + &m.Date, &m.SizeBytes, &m.HasAttachment, + &m.IsRead, &m.IsStarred, &m.IsDraft, + &m.Flags, &m.SpamScore, &m.ReceivedAt, + ) + return m, err +} + +// mailboxTypeToAttr converts our type string to an IMAP special-use string. +// Callers handle the conversion to imap.MailboxAttr themselves. +func MailboxTypeToSpecialUse(mboxType string) string { + switch mboxType { + case models.MailboxSent: + return `\Sent` + case models.MailboxDrafts: + return `\Drafts` + case models.MailboxTrash: + return `\Trash` + case models.MailboxSpam: + return `\Junk` + case models.MailboxArchive: + return `\Archive` + case models.MailboxInbox: + return `\Inbox` + } + return "" +} diff --git a/internal/db/mailboxes.go b/internal/db/mailboxes.go new file mode 100644 index 0000000..fe1ab3b --- /dev/null +++ b/internal/db/mailboxes.go @@ -0,0 +1,401 @@ +package db + +import ( + "context" + "database/sql" + "fmt" + "math/rand" + "time" + + "ghb.freebede.com/nahakubuilder/mailgosend/internal/models" +) + +// GetMailbox returns the mailbox with the given name for a user, or nil. +func (d *DB) GetMailbox(ctx context.Context, userID int64, name string) (*models.Mailbox, error) { + row := d.db.QueryRowContext(ctx, ` + SELECT id, user_id, name, type, parent_id, uid_validity, uid_next, subscribed, created_at + FROM mailboxes WHERE user_id=? AND name=?`, userID, name) + return scanMailbox(row) +} + +// GetMailboxByType returns the first mailbox of the given type for a user. +func (d *DB) GetMailboxByType(ctx context.Context, userID int64, mboxType string) (*models.Mailbox, error) { + row := d.db.QueryRowContext(ctx, ` + SELECT id, user_id, name, type, parent_id, uid_validity, uid_next, subscribed, created_at + FROM mailboxes WHERE user_id=? AND type=? LIMIT 1`, userID, mboxType) + return scanMailbox(row) +} + +// GetMailboxByID returns the mailbox by ID. +func (d *DB) GetMailboxByID(ctx context.Context, id int64) (*models.Mailbox, error) { + row := d.db.QueryRowContext(ctx, ` + SELECT id, user_id, name, type, parent_id, uid_validity, uid_next, subscribed, created_at + FROM mailboxes WHERE id=?`, id) + return scanMailbox(row) +} + +// ListMailboxes returns all subscribed mailboxes for a user, ordered by name. +func (d *DB) ListMailboxes(ctx context.Context, userID int64) ([]*models.Mailbox, error) { + rows, err := d.db.QueryContext(ctx, ` + SELECT id, user_id, name, type, parent_id, uid_validity, uid_next, subscribed, created_at + FROM mailboxes WHERE user_id=? ORDER BY name`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var mbs []*models.Mailbox + for rows.Next() { + mb, err := scanMailboxRow(rows) + if err != nil { + return nil, err + } + mbs = append(mbs, mb) + } + return mbs, rows.Err() +} + +// CreateMailbox creates a mailbox. Returns the new mailbox with uid_validity set. +func (d *DB) CreateMailbox(ctx context.Context, userID int64, name, mboxType string, parentID *int64) (*models.Mailbox, error) { + uidValidity := uint32(rand.Int31()) //nolint:gosec — not a security value + if uidValidity == 0 { + uidValidity = 1 + } + + res, err := d.db.ExecContext(ctx, ` + INSERT INTO mailboxes (user_id, name, type, parent_id, uid_validity, uid_next, subscribed, created_at) + VALUES (?, ?, ?, ?, ?, 1, 1, ?)`, + userID, name, mboxType, parentID, uidValidity, time.Now().UTC()) + if err != nil { + return nil, fmt.Errorf("create mailbox: %w", err) + } + id, _ := res.LastInsertId() + + return &models.Mailbox{ + ID: id, + UserID: userID, + Name: name, + Type: mboxType, + ParentID: parentID, + UIDValidity: uidValidity, + UIDNext: 1, + Subscribed: true, + CreatedAt: time.Now().UTC(), + }, nil +} + +// CreateDefaultMailboxes creates the standard mailbox set for a new user. +// Idempotent — skips any that already exist. +func (d *DB) CreateDefaultMailboxes(ctx context.Context, userID int64) error { + defaults := []struct { + name string + mboxType string + }{ + {"INBOX", models.MailboxInbox}, + {"Sent", models.MailboxSent}, + {"Drafts", models.MailboxDrafts}, + {"Trash", models.MailboxTrash}, + {"Spam", models.MailboxSpam}, + {"Archive", models.MailboxArchive}, + } + + for _, mb := range defaults { + existing, err := d.GetMailbox(ctx, userID, mb.name) + if err != nil { + return err + } + if existing != nil { + continue + } + if _, err := d.CreateMailbox(ctx, userID, mb.name, mb.mboxType, nil); err != nil { + return fmt.Errorf("create default mailbox %s: %w", mb.name, err) + } + } + return nil +} + +// NextUID allocates the next UID for a mailbox atomically. +// Returns the UID to use for the new message. +func (d *DB) NextUID(ctx context.Context, mailboxID int64) (uint32, error) { + tx, err := d.db.BeginTx(ctx, nil) + if err != nil { + return 0, err + } + defer tx.Rollback() //nolint:errcheck + + var next uint32 + err = tx.QueryRowContext(ctx, + "SELECT uid_next FROM mailboxes WHERE id=?", mailboxID).Scan(&next) + if err != nil { + return 0, fmt.Errorf("read uid_next: %w", err) + } + + _, err = tx.ExecContext(ctx, + "UPDATE mailboxes SET uid_next=uid_next+1 WHERE id=?", mailboxID) + if err != nil { + return 0, fmt.Errorf("increment uid_next: %w", err) + } + + return next, tx.Commit() +} + +// ---- Message operations ---- + +// InsertMessage stores a message record (body is already encrypted; call SaveRawBody separately). +func (d *DB) InsertMessage(ctx context.Context, m *MessageInsert) (int64, error) { + res, err := d.db.ExecContext(ctx, ` + INSERT INTO messages + (mailbox_id, uid, message_id, subject, from_email, from_name, + to_list, cc_list, bcc_list, reply_to, date, + body_text_enc, body_html_enc, raw_enc, + size_bytes, has_attachment, is_read, is_starred, is_draft, + flags, spam_score, received_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + m.MailboxID, m.UID, m.MessageID, m.Subject, m.FromEmail, m.FromName, + m.ToList, m.CCList, m.BCCList, m.ReplyTo, m.Date, + m.BodyTextEnc, m.BodyHTMLEnc, m.RawEnc, + m.SizeBytes, m.HasAttachment, m.IsRead, m.IsStarred, m.IsDraft, + m.Flags, m.SpamScore, time.Now().UTC(), + ) + if err != nil { + return 0, fmt.Errorf("insert message: %w", err) + } + return res.LastInsertId() +} + +// MessageInsert is the data transfer object for inserting a new message. +type MessageInsert struct { + MailboxID int64 + UID uint32 + MessageID string + Subject string + FromEmail string + FromName string + ToList string + CCList string + BCCList string + ReplyTo string + Date time.Time + BodyTextEnc []byte + BodyHTMLEnc []byte + RawEnc []byte + SizeBytes int64 + HasAttachment bool + IsRead bool + IsStarred bool + IsDraft bool + Flags string + SpamScore int +} + +// InsertAttachment stores an attachment record for a message. +func (d *DB) InsertAttachment(ctx context.Context, a *AttachmentInsert) (int64, error) { + res, err := d.db.ExecContext(ctx, ` + INSERT INTO attachments + (message_id, filename, content_type, size_bytes, data_enc, data_path, + content_id, inline, mime_path) + VALUES (?,?,?,?,?,?,?,?,?)`, + a.MessageID, a.Filename, a.ContentType, a.SizeBytes, + a.DataEnc, a.DataPath, a.ContentID, a.Inline, a.MIMEPath, + ) + if err != nil { + return 0, fmt.Errorf("insert attachment: %w", err) + } + return res.LastInsertId() +} + +// AttachmentInsert is the data transfer object for inserting an attachment. +type AttachmentInsert struct { + MessageID int64 + Filename string + ContentType string + SizeBytes int64 + DataEnc []byte + DataPath string + ContentID string + Inline bool + MIMEPath string +} + +// GetMessageRaw returns the encrypted raw blob for a message. +func (d *DB) GetMessageRaw(ctx context.Context, messageID int64) ([]byte, error) { + var raw []byte + err := d.db.QueryRowContext(ctx, + "SELECT raw_enc FROM messages WHERE id=?", messageID).Scan(&raw) + if err == sql.ErrNoRows { + return nil, nil + } + return raw, err +} + +// ListMessages returns messages in a mailbox ordered by UID descending. +// Only non-deleted messages are returned. +func (d *DB) ListMessages(ctx context.Context, mailboxID int64, limit, offset int) ([]*models.Message, error) { + rows, err := d.db.QueryContext(ctx, ` + SELECT id, mailbox_id, uid, message_id, subject, from_email, from_name, + to_list, cc_list, bcc_list, reply_to, date, + size_bytes, has_attachment, is_read, is_starred, is_draft, + flags, spam_score, received_at + FROM messages + WHERE mailbox_id=? AND deleted_at IS NULL + ORDER BY uid DESC + LIMIT ? OFFSET ?`, mailboxID, limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + + var msgs []*models.Message + for rows.Next() { + var m models.Message + err := rows.Scan( + &m.ID, &m.MailboxID, &m.UID, &m.MessageID, &m.Subject, + &m.FromEmail, &m.FromName, &m.ToList, &m.CCList, &m.BCCList, + &m.ReplyTo, &m.Date, &m.SizeBytes, &m.HasAttachment, + &m.IsRead, &m.IsStarred, &m.IsDraft, &m.Flags, + &m.SpamScore, &m.ReceivedAt, + ) + if err != nil { + return nil, err + } + msgs = append(msgs, &m) + } + return msgs, rows.Err() +} + +// CountUnread returns the number of unread messages in a mailbox. +func (d *DB) CountUnread(ctx context.Context, mailboxID int64) (int, error) { + var n int + err := d.db.QueryRowContext(ctx, + "SELECT COUNT(*) FROM messages WHERE mailbox_id=? AND is_read=0 AND deleted_at IS NULL", + mailboxID).Scan(&n) + return n, err +} + +// ---- Queue operations ---- + +// EnqueueMessage inserts a delivery queue entry. Returns the new queue ID. +func (d *DB) EnqueueMessage(ctx context.Context, domainID int64, from, to, msgID string, rawEnc []byte, maxAgeHours int) (int64, error) { + expires := time.Now().UTC().Add(time.Duration(maxAgeHours) * time.Hour) + res, err := d.db.ExecContext(ctx, ` + INSERT INTO queue + (domain_id, from_addr, to_addr, raw_enc, message_id, status, + attempts, next_attempt, created_at, expires_at) + VALUES (?, ?, ?, ?, ?, 'pending', 0, ?, ?, ?)`, + domainID, from, to, rawEnc, msgID, + time.Now().UTC(), time.Now().UTC(), expires) + if err != nil { + return 0, fmt.Errorf("enqueue: %w", err) + } + return res.LastInsertId() +} + +// PeekQueue returns up to limit pending/retry-eligible queue entries. +func (d *DB) PeekQueue(ctx context.Context, limit int) ([]QueueRow, error) { + rows, err := d.db.QueryContext(ctx, ` + SELECT id, domain_id, from_addr, to_addr, raw_enc, message_id, + status, attempts, expires_at + FROM queue + WHERE status IN ('pending','failed') + AND next_attempt <= ? + AND expires_at > ? + ORDER BY next_attempt ASC + LIMIT ?`, + time.Now().UTC(), time.Now().UTC(), limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []QueueRow + for rows.Next() { + var q QueueRow + var domainID sql.NullInt64 + err := rows.Scan( + &q.ID, &domainID, &q.FromAddr, &q.ToAddr, + &q.RawEnc, &q.MessageID, &q.Status, &q.Attempts, &q.ExpiresAt, + ) + if err != nil { + return nil, err + } + if domainID.Valid { + q.DomainID = domainID.Int64 + } + out = append(out, q) + } + return out, rows.Err() +} + +// QueueRow is a minimal queue entry for the delivery worker. +type QueueRow struct { + ID int64 + DomainID int64 + FromAddr string + ToAddr string + RawEnc []byte + MessageID string + Status string + Attempts int + ExpiresAt time.Time +} + +// SetQueueStatus updates the status of a queue entry. +func (d *DB) SetQueueStatus(ctx context.Context, id int64, status, errMsg string, nextAttempt *time.Time) error { + _, err := d.db.ExecContext(ctx, ` + UPDATE queue + SET status=?, attempts=attempts+1, last_attempt=?, + error_log=error_log || ?, next_attempt=COALESCE(?, next_attempt) + WHERE id=?`, + status, time.Now().UTC(), + fmt.Sprintf("[%s] %s\n", time.Now().UTC().Format(time.RFC3339), errMsg), + nextAttempt, id) + return err +} + +// LogDelivery inserts a delivery log entry. +func (d *DB) LogDelivery(ctx context.Context, queueID int64, from, to, status string, smtpCode int, smtpMsg, mxHost string) error { + _, err := d.db.ExecContext(ctx, ` + INSERT INTO delivery_log (queue_id, from_addr, to_addr, status, smtp_code, smtp_message, mx_host, created_at) + VALUES (?,?,?,?,?,?,?,?)`, + queueID, from, to, status, smtpCode, smtpMsg, mxHost, time.Now().UTC()) + return err +} + +// ---- private ---- + +func scanMailbox(row *sql.Row) (*models.Mailbox, error) { + var mb models.Mailbox + var parentID sql.NullInt64 + err := row.Scan( + &mb.ID, &mb.UserID, &mb.Name, &mb.Type, + &parentID, &mb.UIDValidity, &mb.UIDNext, &mb.Subscribed, &mb.CreatedAt, + ) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("scan mailbox: %w", err) + } + if parentID.Valid { + id := parentID.Int64 + mb.ParentID = &id + } + return &mb, nil +} + +func scanMailboxRow(rows *sql.Rows) (*models.Mailbox, error) { + var mb models.Mailbox + var parentID sql.NullInt64 + err := rows.Scan( + &mb.ID, &mb.UserID, &mb.Name, &mb.Type, + &parentID, &mb.UIDValidity, &mb.UIDNext, &mb.Subscribed, &mb.CreatedAt, + ) + if err != nil { + return nil, err + } + if parentID.Valid { + id := parentID.Int64 + mb.ParentID = &id + } + return &mb, nil +} diff --git a/internal/db/migrate.go b/internal/db/migrate.go new file mode 100644 index 0000000..43a7e1f --- /dev/null +++ b/internal/db/migrate.go @@ -0,0 +1,337 @@ +package db + +import ( + "context" + "database/sql" + "fmt" + "time" +) + +// migration is a versioned schema change. +type migration struct { + version int + up string // SQL to apply +} + +// migrations must be append-only. Never edit an applied migration. +var migrations = []migration{ + {1, schemav1}, +} + +// migrate applies any unapplied migrations in order. +func (d *DB) migrate() error { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + // Ensure migrations table exists. + _, err := d.db.ExecContext(ctx, ` + CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + )`) + if err != nil { + return fmt.Errorf("create migrations table: %w", err) + } + + for _, m := range migrations { + var count int + err := d.db.QueryRowContext(ctx, + "SELECT COUNT(*) FROM schema_migrations WHERE version = ?", m.version).Scan(&count) + if err != nil { + return fmt.Errorf("check migration %d: %w", m.version, err) + } + if count > 0 { + continue // already applied + } + + if err := d.WithTx(ctx, func(tx *sql.Tx) error { + if _, err := tx.ExecContext(ctx, m.up); err != nil { + return fmt.Errorf("apply migration %d: %w", m.version, err) + } + _, err := tx.ExecContext(ctx, + "INSERT INTO schema_migrations (version) VALUES (?)", m.version) + return err + }); err != nil { + return err + } + fmt.Printf("[db] applied migration %d\n", m.version) + } + return nil +} + +// ---- Schema v1 (initial) ---- + +const schemav1 = ` +-- Domains +CREATE TABLE IF NOT EXISTS domains ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + enabled BOOLEAN NOT NULL DEFAULT 1, + dkim_private_enc BLOB, + dkim_public TEXT, + dkim_selector TEXT NOT NULL DEFAULT 'mail', + dkim_algo TEXT NOT NULL DEFAULT 'rsa2048', + spf_policy TEXT, + dmarc_policy TEXT, + max_users INTEGER NOT NULL DEFAULT 0, + max_quota_bytes INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Users +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + domain_id INTEGER NOT NULL REFERENCES domains(id) ON DELETE CASCADE, + username TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + display_name TEXT NOT NULL DEFAULT '', + quota_bytes INTEGER NOT NULL DEFAULT 1073741824, + used_bytes INTEGER NOT NULL DEFAULT 0, + enabled BOOLEAN NOT NULL DEFAULT 1, + admin BOOLEAN NOT NULL DEFAULT 0, + domain_admin BOOLEAN NOT NULL DEFAULT 0, + mfa_secret_enc BLOB, + mfa_enabled BOOLEAN NOT NULL DEFAULT 0, + recovery_codes_enc BLOB, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_login TIMESTAMP +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users(email); +CREATE INDEX IF NOT EXISTS idx_users_domain ON users(domain_id); + +-- User aliases +CREATE TABLE IF NOT EXISTS user_aliases ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + alias_email TEXT NOT NULL UNIQUE +); + +-- Mailboxes (IMAP folders) +CREATE TABLE IF NOT EXISTS mailboxes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'custom', + parent_id INTEGER REFERENCES mailboxes(id) ON DELETE CASCADE, + uid_validity INTEGER NOT NULL DEFAULT 1, + uid_next INTEGER NOT NULL DEFAULT 1, + subscribed BOOLEAN NOT NULL DEFAULT 1, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, name) +); +CREATE INDEX IF NOT EXISTS idx_mailboxes_user ON mailboxes(user_id); + +-- Messages +CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + mailbox_id INTEGER NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE, + uid INTEGER NOT NULL, + message_id TEXT, + subject TEXT NOT NULL DEFAULT '', + from_email TEXT NOT NULL DEFAULT '', + from_name TEXT NOT NULL DEFAULT '', + to_list TEXT NOT NULL DEFAULT '', + cc_list TEXT NOT NULL DEFAULT '', + bcc_list TEXT NOT NULL DEFAULT '', + reply_to TEXT NOT NULL DEFAULT '', + date TIMESTAMP, + body_text_enc BLOB, + body_html_enc BLOB, + raw_enc BLOB, + size_bytes INTEGER NOT NULL DEFAULT 0, + has_attachment BOOLEAN NOT NULL DEFAULT 0, + is_read BOOLEAN NOT NULL DEFAULT 0, + is_starred BOOLEAN NOT NULL DEFAULT 0, + is_draft BOOLEAN NOT NULL DEFAULT 0, + flags TEXT NOT NULL DEFAULT '', + spam_score INTEGER NOT NULL DEFAULT 0, + received_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP, + UNIQUE(mailbox_id, uid) +); +CREATE INDEX IF NOT EXISTS idx_messages_mailbox ON messages(mailbox_id); +CREATE INDEX IF NOT EXISTS idx_messages_uid ON messages(mailbox_id, uid); +CREATE INDEX IF NOT EXISTS idx_messages_date ON messages(mailbox_id, date); +CREATE INDEX IF NOT EXISTS idx_messages_deleted ON messages(mailbox_id, deleted_at); + +-- Attachments +CREATE TABLE IF NOT EXISTS attachments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE, + filename TEXT NOT NULL, + content_type TEXT NOT NULL, + size_bytes INTEGER NOT NULL DEFAULT 0, + data_enc BLOB, + data_path TEXT, + content_id TEXT, + inline BOOLEAN NOT NULL DEFAULT 0, + mime_path TEXT +); +CREATE INDEX IF NOT EXISTS idx_attachments_message ON attachments(message_id); + +-- Delivery queue +CREATE TABLE IF NOT EXISTS queue ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + domain_id INTEGER REFERENCES domains(id), + from_addr TEXT NOT NULL, + to_addr TEXT NOT NULL, + raw_enc BLOB NOT NULL, + message_id TEXT, + status TEXT NOT NULL DEFAULT 'pending', + attempts INTEGER NOT NULL DEFAULT 0, + last_attempt TIMESTAMP, + next_attempt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + error_log TEXT NOT NULL DEFAULT '', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_queue_status ON queue(status, next_attempt); + +-- Delivery log +CREATE TABLE IF NOT EXISTS delivery_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + queue_id INTEGER REFERENCES queue(id) ON DELETE SET NULL, + from_addr TEXT NOT NULL, + to_addr TEXT NOT NULL, + status TEXT NOT NULL, + smtp_code INTEGER NOT NULL DEFAULT 0, + smtp_message TEXT NOT NULL DEFAULT '', + mx_host TEXT NOT NULL DEFAULT '', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_delivery_log_created ON delivery_log(created_at); + +-- Sessions +CREATE TABLE IF NOT EXISTS sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + ip TEXT NOT NULL DEFAULT '', + user_agent TEXT NOT NULL DEFAULT '', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token_hash); +CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id); +CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at); + +-- IP bans +CREATE TABLE IF NOT EXISTS ip_bans ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ip TEXT NOT NULL UNIQUE, + reason TEXT NOT NULL DEFAULT '', + banned_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP, + released_by TEXT NOT NULL DEFAULT '' +); +CREATE INDEX IF NOT EXISTS idx_ip_bans_ip ON ip_bans(ip); +CREATE INDEX IF NOT EXISTS idx_ip_bans_expires ON ip_bans(expires_at); + +-- Login attempts +CREATE TABLE IF NOT EXISTS login_attempts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ip TEXT NOT NULL, + user_email TEXT NOT NULL DEFAULT '', + success BOOLEAN NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_login_attempts_ip ON login_attempts(ip, created_at); + +-- Security events +CREATE TABLE IF NOT EXISTS security_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + type TEXT NOT NULL, + ip TEXT NOT NULL DEFAULT '', + user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + detail TEXT NOT NULL DEFAULT '', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_security_events_created ON security_events(created_at); + +-- External accounts (Gmail / Outlook / custom IMAP) +CREATE TABLE IF NOT EXISTS external_accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + email_address TEXT NOT NULL, + display_name TEXT NOT NULL DEFAULT '', + access_token_enc BLOB, + refresh_token_enc BLOB, + token_expiry TIMESTAMP, + imap_host TEXT NOT NULL DEFAULT '', + imap_port INTEGER NOT NULL DEFAULT 993, + smtp_host TEXT NOT NULL DEFAULT '', + smtp_port INTEGER NOT NULL DEFAULT 587, + enabled BOOLEAN NOT NULL DEFAULT 1, + sync_enabled BOOLEAN NOT NULL DEFAULT 1, + last_sync TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_ext_accounts_user ON external_accounts(user_id); + +-- Address books (CardDAV) +CREATE TABLE IF NOT EXISTS address_books ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + color TEXT NOT NULL DEFAULT '#4A90E2', + sync_token INTEGER NOT NULL DEFAULT 1, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Contacts (CardDAV) +CREATE TABLE IF NOT EXISTS contacts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + address_book_id INTEGER NOT NULL REFERENCES address_books(id) ON DELETE CASCADE, + uid TEXT NOT NULL, + vcard_enc BLOB NOT NULL, + etag TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(address_book_id, uid) +); +CREATE INDEX IF NOT EXISTS idx_contacts_book ON contacts(address_book_id); + +-- Calendars (CalDAV) +CREATE TABLE IF NOT EXISTS calendars ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + color TEXT NOT NULL DEFAULT '#4CAF50', + timezone TEXT NOT NULL DEFAULT 'UTC', + sync_token INTEGER NOT NULL DEFAULT 1, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Calendar events (CalDAV) +CREATE TABLE IF NOT EXISTS calendar_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + calendar_id INTEGER NOT NULL REFERENCES calendars(id) ON DELETE CASCADE, + uid TEXT NOT NULL, + ical_enc BLOB NOT NULL, + etag TEXT NOT NULL, + dt_start TIMESTAMP, + dt_end TIMESTAMP, + summary TEXT NOT NULL DEFAULT '', + recurring BOOLEAN NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(calendar_id, uid) +); +CREATE INDEX IF NOT EXISTS idx_events_calendar ON calendar_events(calendar_id); +CREATE INDEX IF NOT EXISTS idx_events_dtstart ON calendar_events(calendar_id, dt_start); + +-- Spam Bayesian tokens (per-user) +CREATE TABLE IF NOT EXISTS spam_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token TEXT NOT NULL, + spam_count INTEGER NOT NULL DEFAULT 0, + ham_count INTEGER NOT NULL DEFAULT 0, + UNIQUE(user_id, token) +); +CREATE INDEX IF NOT EXISTS idx_spam_tokens_user ON spam_tokens(user_id, token); +` diff --git a/internal/db/users.go b/internal/db/users.go new file mode 100644 index 0000000..d7c44ea --- /dev/null +++ b/internal/db/users.go @@ -0,0 +1,158 @@ +package db + +import ( + "context" + "database/sql" + "fmt" + "time" + + "ghb.freebede.com/nahakubuilder/mailgosend/internal/models" +) + +// GetUserByEmail returns the user with the given email (case-insensitive), or nil. +func (d *DB) GetUserByEmail(ctx context.Context, email string) (*models.User, error) { + row := d.db.QueryRowContext(ctx, ` + SELECT id, domain_id, username, email, password_hash, display_name, + quota_bytes, used_bytes, enabled, admin, domain_admin, + mfa_secret_enc, mfa_enabled, recovery_codes_enc, created_at, last_login + FROM users WHERE lower(email)=lower(?)`, email) + return scanUser(row) +} + +// GetUserByID returns the user with the given ID, or nil. +func (d *DB) GetUserByID(ctx context.Context, id int64) (*models.User, error) { + row := d.db.QueryRowContext(ctx, ` + SELECT id, domain_id, username, email, password_hash, display_name, + quota_bytes, used_bytes, enabled, admin, domain_admin, + mfa_secret_enc, mfa_enabled, recovery_codes_enc, created_at, last_login + FROM users WHERE id=?`, id) + return scanUser(row) +} + +// UserExistsByEmail returns true if any user (enabled or not) has this email or alias. +func (d *DB) UserExistsByEmail(ctx context.Context, email string) (bool, error) { + var count int + err := d.db.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM ( + SELECT 1 FROM users WHERE lower(email)=lower(?) AND enabled=1 + UNION ALL + SELECT 1 FROM user_aliases WHERE lower(alias_email)=lower(?) + )`, email, email).Scan(&count) + return count > 0, err +} + +// ResolveEmail returns the canonical user for an email or alias, or nil. +func (d *DB) ResolveEmail(ctx context.Context, email string) (*models.User, error) { + // Direct match first. + u, err := d.GetUserByEmail(ctx, email) + if err != nil || u != nil { + return u, err + } + + // Alias match. + var userID int64 + err = d.db.QueryRowContext(ctx, + "SELECT user_id FROM user_aliases WHERE lower(alias_email)=lower(?)", email). + Scan(&userID) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return d.GetUserByID(ctx, userID) +} + +// CreateUser inserts a new user. Returns the new ID. +func (d *DB) CreateUser(ctx context.Context, domainID int64, username, email, passwordHash, displayName string, quotaBytes int64, domainAdmin bool) (int64, error) { + res, err := d.db.ExecContext(ctx, ` + INSERT INTO users + (domain_id, username, email, password_hash, display_name, quota_bytes, + enabled, admin, domain_admin, created_at) + VALUES (?, ?, ?, ?, ?, ?, 1, 0, ?, ?)`, + domainID, username, email, passwordHash, displayName, quotaBytes, domainAdmin, + time.Now().UTC()) + if err != nil { + return 0, fmt.Errorf("create user: %w", err) + } + return res.LastInsertId() +} + +// UpdateUsedBytes sets the cached used_bytes for a user (approximate, updated on store). +func (d *DB) UpdateUsedBytes(ctx context.Context, userID int64, delta int64) error { + _, err := d.db.ExecContext(ctx, + "UPDATE users SET used_bytes = MAX(0, used_bytes + ?) WHERE id=?", + delta, userID) + return err +} + +// UpdateLastLogin sets last_login to now. +func (d *DB) UpdateLastLogin(ctx context.Context, userID int64) { + d.db.ExecContext(ctx, //nolint:errcheck — best-effort + "UPDATE users SET last_login=? WHERE id=?", time.Now().UTC(), userID) +} + +// ListUsers returns all users for a domain. +func (d *DB) ListUsers(ctx context.Context, domainID int64) ([]*models.User, error) { + rows, err := d.db.QueryContext(ctx, ` + SELECT id, domain_id, username, email, password_hash, display_name, + quota_bytes, used_bytes, enabled, admin, domain_admin, + mfa_secret_enc, mfa_enabled, recovery_codes_enc, created_at, last_login + FROM users WHERE domain_id=? ORDER BY email`, domainID) + if err != nil { + return nil, err + } + defer rows.Close() + + var users []*models.User + for rows.Next() { + var u models.User + var mfaEnc, rcEnc []byte + var lastLogin sql.NullTime + err := rows.Scan( + &u.ID, &u.DomainID, &u.Username, &u.Email, &u.PasswordHash, + &u.DisplayName, &u.QuotaBytes, &u.UsedBytes, &u.Enabled, + &u.Admin, &u.DomainAdmin, + &mfaEnc, &u.MFAEnabled, &rcEnc, + &u.CreatedAt, &lastLogin, + ) + if err != nil { + return nil, err + } + u.MFASecretEnc = mfaEnc + u.RecoveryCodesEnc = rcEnc + if lastLogin.Valid { + u.LastLogin = lastLogin.Time + } + users = append(users, &u) + } + return users, rows.Err() +} + +// ---- private ---- + +func scanUser(row *sql.Row) (*models.User, error) { + var u models.User + var mfaEnc, rcEnc []byte + var lastLogin sql.NullTime + + err := row.Scan( + &u.ID, &u.DomainID, &u.Username, &u.Email, &u.PasswordHash, + &u.DisplayName, &u.QuotaBytes, &u.UsedBytes, &u.Enabled, + &u.Admin, &u.DomainAdmin, + &mfaEnc, &u.MFAEnabled, &rcEnc, + &u.CreatedAt, &lastLogin, + ) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("scan user: %w", err) + } + u.MFASecretEnc = mfaEnc + u.RecoveryCodesEnc = rcEnc + if lastLogin.Valid { + u.LastLogin = lastLogin.Time + } + return &u, nil +} diff --git a/internal/delivery/delivery.go b/internal/delivery/delivery.go new file mode 100644 index 0000000..a24d320 --- /dev/null +++ b/internal/delivery/delivery.go @@ -0,0 +1,211 @@ +// Package delivery implements outbound SMTP delivery: MX lookup, connection, +// TLS upgrade, message submission. Used by the queue worker. +package delivery + +import ( + "bytes" + "context" + "crypto/tls" + "fmt" + "log" + "net" + "net/smtp" + "sort" + "strings" + "time" +) + +const ( + deliveryTimeout = 60 * time.Second + connectTimeout = 30 * time.Second +) + +// Result holds the outcome of a single delivery attempt. +type Result struct { + MXHost string + SMTPCode int + Message string + Perm bool // true = permanent failure (5xx), don't retry +} + +// Deliver attempts to deliver raw to the address to using a fresh SMTP +// connection to the recipient domain's MX. Signs with the given EHLO hostname. +// Returns a Result describing success or failure. +func Deliver(ctx context.Context, ehloHostname, from, to string, raw []byte) *Result { + at := strings.LastIndex(to, "@") + if at < 0 { + return &Result{Perm: true, Message: "invalid recipient address: " + to} + } + toDomain := strings.ToLower(to[at+1:]) + + mxHosts, err := lookupMX(ctx, toDomain) + if err != nil { + return &Result{Message: fmt.Sprintf("MX lookup %s: %v", toDomain, err)} + } + if len(mxHosts) == 0 { + return &Result{Perm: true, Message: "no MX records for " + toDomain} + } + + var lastResult *Result + for _, host := range mxHosts { + r := deliver(ctx, ehloHostname, host, from, to, raw) + lastResult = r + if r.SMTPCode == 0 || r.SMTPCode/100 == 4 { + // Temp error or connection failure — try next MX. + continue + } + // 2xx = success, 5xx = permanent failure — stop trying. + return r + } + return lastResult +} + +// deliver connects to one MX host and submits the message. +func deliver(ctx context.Context, ehloHostname, mxHost, from, to string, raw []byte) *Result { + addr := net.JoinHostPort(mxHost, "25") + + dialCtx, cancel := context.WithTimeout(ctx, connectTimeout) + defer cancel() + + conn, err := (&net.Dialer{}).DialContext(dialCtx, "tcp", addr) + if err != nil { + return &Result{MXHost: mxHost, Message: fmt.Sprintf("connect %s: %v", addr, err)} + } + + // Wrap in a deadline for the full SMTP exchange. + deadline := time.Now().Add(deliveryTimeout) + _ = conn.SetDeadline(deadline) + + c, err := smtp.NewClient(conn, mxHost) + if err != nil { + conn.Close() + return &Result{MXHost: mxHost, Message: fmt.Sprintf("smtp client %s: %v", mxHost, err)} + } + defer c.Close() + + // EHLO. + if err := c.Hello(ehloHostname); err != nil { + return &Result{MXHost: mxHost, Message: fmt.Sprintf("EHLO: %v", err)} + } + + // Try STARTTLS (best effort — not all remote servers require it). + if ok, _ := c.Extension("STARTTLS"); ok { + tlsCfg := &tls.Config{ + ServerName: mxHost, + MinVersion: tls.VersionTLS12, + } + if err := c.StartTLS(tlsCfg); err != nil { + log.Printf("[delivery] STARTTLS %s failed (continuing plain): %v", mxHost, err) + } + } + + // MAIL FROM. + if err := c.Mail(from); err != nil { + return smtpResult(mxHost, err) + } + + // RCPT TO. + if err := c.Rcpt(to); err != nil { + return smtpResult(mxHost, err) + } + + // DATA. + wc, err := c.Data() + if err != nil { + return smtpResult(mxHost, err) + } + if _, err := wc.Write(raw); err != nil { + wc.Close() + return &Result{MXHost: mxHost, Message: fmt.Sprintf("write data: %v", err)} + } + if err := wc.Close(); err != nil { + return smtpResult(mxHost, err) + } + + _ = c.Quit() + + log.Printf("[delivery] delivered %s → %s via %s", from, to, mxHost) + return &Result{MXHost: mxHost, SMTPCode: 250, Message: "2.0.0 OK"} +} + +// lookupMX resolves MX records and returns hosts sorted by priority. +func lookupMX(ctx context.Context, domain string) ([]string, error) { + r := net.DefaultResolver + mxs, err := r.LookupMX(ctx, domain) + if err != nil { + // Treat NXDOMAIN as no-MX (not a transient error). + if dnsErr, ok := err.(*net.DNSError); ok && dnsErr.IsNotFound { + // Fall back: try A record (some small domains don't publish MX). + addrs, aerr := r.LookupHost(ctx, domain) + if aerr != nil || len(addrs) == 0 { + return nil, nil + } + return []string{domain}, nil + } + return nil, err + } + + // Sort by priority ascending. + sort.Slice(mxs, func(i, j int) bool { + return mxs[i].Pref < mxs[j].Pref + }) + + hosts := make([]string, 0, len(mxs)) + for _, mx := range mxs { + h := strings.TrimSuffix(mx.Host, ".") + if h != "" { + hosts = append(hosts, h) + } + } + return hosts, nil +} + +// smtpResult maps an smtp error to a Result, marking 5xx as permanent. +func smtpResult(mxHost string, err error) *Result { + if err == nil { + return &Result{MXHost: mxHost, SMTPCode: 250, Message: "2.0.0 OK"} + } + msg := err.Error() + code := parseCode(msg) + return &Result{ + MXHost: mxHost, + SMTPCode: code, + Message: msg, + Perm: code/100 == 5, + } +} + +// parseCode extracts the leading 3-digit SMTP code from an error string. +func parseCode(s string) int { + if len(s) < 3 { + return 0 + } + var code int + _, _ = fmt.Sscanf(s[:3], "%d", &code) + return code +} + +// IsLocal reports whether the given domain is served locally. Used by the +// queue worker to skip outbound delivery for internal mail. +// The caller supplies its own domain list to avoid a DB call here. +func IsLocal(domain string, localDomains []string) bool { + domain = strings.ToLower(domain) + for _, d := range localDomains { + if strings.EqualFold(d, domain) { + return true + } + } + return false +} + +// RecipientDomain returns the domain part of an email address. +func RecipientDomain(addr string) string { + at := strings.LastIndex(addr, "@") + if at < 0 { + return "" + } + return strings.ToLower(addr[at+1:]) +} + +// _ suppresses unused import if bytes is only used in tests later. +var _ = bytes.NewReader diff --git a/internal/dkim/dkim.go b/internal/dkim/dkim.go new file mode 100644 index 0000000..fd15576 --- /dev/null +++ b/internal/dkim/dkim.go @@ -0,0 +1,465 @@ +// Package dkim implements DKIM signing (outbound) and verification (inbound) +// for RSA-2048 and Ed25519 keys per RFC 6376 and RFC 8463. +package dkim + +import ( + gocrypto "crypto" + "crypto/ed25519" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "fmt" + "net" + "strings" + "time" +) + +// Signer holds a loaded private key and signing metadata. +type Signer struct { + privateKey gocrypto.PrivateKey // *rsa.PrivateKey or ed25519.PrivateKey + selector string + domain string + algo string // "rsa2048" | "ed25519" +} + +// GenerateKeyPair generates a DKIM key pair for the given algorithm. +// algo must be "rsa2048" or "ed25519". +// Returns PEM-encoded private key and PEM-encoded public key. +func GenerateKeyPair(algo string) (privateKeyPEM, publicKeyPEM string, err error) { + switch algo { + case "rsa2048": + priv, genErr := rsa.GenerateKey(rand.Reader, 2048) + if genErr != nil { + return "", "", fmt.Errorf("dkim: generate RSA key: %w", genErr) + } + privDER := x509.MarshalPKCS1PrivateKey(priv) + privBlock := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: privDER} + privateKeyPEM = string(pem.EncodeToMemory(privBlock)) + + pubDER, marshalErr := x509.MarshalPKIXPublicKey(&priv.PublicKey) + if marshalErr != nil { + return "", "", fmt.Errorf("dkim: marshal RSA public key: %w", marshalErr) + } + pubBlock := &pem.Block{Type: "PUBLIC KEY", Bytes: pubDER} + publicKeyPEM = string(pem.EncodeToMemory(pubBlock)) + return privateKeyPEM, publicKeyPEM, nil + + case "ed25519": + pub, priv, genErr := ed25519.GenerateKey(rand.Reader) + if genErr != nil { + return "", "", fmt.Errorf("dkim: generate Ed25519 key: %w", genErr) + } + privDER, marshalErr := x509.MarshalPKCS8PrivateKey(priv) + if marshalErr != nil { + return "", "", fmt.Errorf("dkim: marshal Ed25519 private key: %w", marshalErr) + } + privBlock := &pem.Block{Type: "PRIVATE KEY", Bytes: privDER} + privateKeyPEM = string(pem.EncodeToMemory(privBlock)) + + pubDER, marshalErr := x509.MarshalPKIXPublicKey(pub) + if marshalErr != nil { + return "", "", fmt.Errorf("dkim: marshal Ed25519 public key: %w", marshalErr) + } + pubBlock := &pem.Block{Type: "PUBLIC KEY", Bytes: pubDER} + publicKeyPEM = string(pem.EncodeToMemory(pubBlock)) + return privateKeyPEM, publicKeyPEM, nil + + default: + return "", "", fmt.Errorf("dkim: unsupported algorithm %q (want rsa2048 or ed25519)", algo) + } +} + +// NewSigner parses a PEM private key and returns a Signer. +// Tries PKCS1 RSA first, then PKCS8 (Ed25519 or RSA). +func NewSigner(privateKeyPEM, domain, selector string) (*Signer, error) { + if domain == "" { + return nil, fmt.Errorf("dkim: domain must not be empty") + } + if selector == "" { + return nil, fmt.Errorf("dkim: selector must not be empty") + } + + block, _ := pem.Decode([]byte(privateKeyPEM)) + if block == nil { + return nil, fmt.Errorf("dkim: failed to decode PEM block") + } + + // Try PKCS1 RSA. + if rsaKey, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil { + return &Signer{ + privateKey: rsaKey, + selector: selector, + domain: domain, + algo: "rsa2048", + }, nil + } + + // Try PKCS8 (covers Ed25519 and RSA PKCS8). + key, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("dkim: parse private key: %w", err) + } + switch k := key.(type) { + case ed25519.PrivateKey: + return &Signer{ + privateKey: k, + selector: selector, + domain: domain, + algo: "ed25519", + }, nil + case *rsa.PrivateKey: + return &Signer{ + privateKey: k, + selector: selector, + domain: domain, + algo: "rsa2048", + }, nil + default: + return nil, fmt.Errorf("dkim: unsupported private key type %T", key) + } +} + +// Sign produces a DKIM-Signature header for the given RFC822 message. +// Returns the complete "DKIM-Signature: ..." header line (no trailing CRLF). +func (s *Signer) Sign(message []byte) (string, error) { + // Split at first blank line (\r\n\r\n or \n\n). + headerBytes, bodyBytes := splitMessage(message) + + // Canonicalize body (relaxed). + canonBody := canonicalizeBodyRelaxed(bodyBytes) + + // Body hash. + bodyHash := sha256.Sum256(canonBody) + bh := base64.StdEncoding.EncodeToString(bodyHash[:]) + + // Determine algorithm tag. + var aTag string + switch s.algo { + case "ed25519": + aTag = "ed25519-sha256" + default: + aTag = "rsa-sha256" + } + + // Signed header fields (lower-case, in sign order). + signedFields := "from:to:subject:date:message-id" + + // Build DKIM-Signature with b= empty. + ts := fmt.Sprintf("%d", time.Now().Unix()) + sigHeader := fmt.Sprintf( + "DKIM-Signature: v=1; a=%s; c=relaxed/relaxed; d=%s; s=%s; t=%s; bh=%s; h=%s; b=", + aTag, s.domain, s.selector, ts, bh, signedFields, + ) + + // Canonicalize headers to sign + the sig header (b= empty). + hdrMap := parseHeaders(headerBytes) + var sb strings.Builder + for _, field := range strings.Split(signedFields, ":") { + field = strings.TrimSpace(field) + val, ok := hdrMap[strings.ToLower(field)] + if !ok { + continue + } + sb.WriteString(canonicalizeHeaderRelaxed(field, val)) + sb.WriteString("\r\n") + } + // Append the DKIM-Signature line itself (with b= empty), canonicalized. + sb.WriteString(canonicalizeHeaderRelaxed("dkim-signature", strings.TrimPrefix(sigHeader, "DKIM-Signature: "))) + + dataToSign := []byte(sb.String()) + hash := sha256.Sum256(dataToSign) + + var sigBytes []byte + var signErr error + + switch s.algo { + case "ed25519": + privKey, ok := s.privateKey.(ed25519.PrivateKey) + if !ok { + return "", fmt.Errorf("dkim: private key type mismatch for ed25519") + } + // RFC 8463: ed25519-sha256 — sign the SHA-256 hash of the data. + sigBytes = ed25519.Sign(privKey, hash[:]) + + default: + privKey, ok := s.privateKey.(*rsa.PrivateKey) + if !ok { + return "", fmt.Errorf("dkim: private key type mismatch for RSA") + } + sigBytes, signErr = rsa.SignPKCS1v15(rand.Reader, privKey, gocrypto.SHA256, hash[:]) + if signErr != nil { + return "", fmt.Errorf("dkim: RSA sign: %w", signErr) + } + } + + b := base64.StdEncoding.EncodeToString(sigBytes) + return sigHeader + b, nil +} + +// Verify finds the DKIM-Signature in message, fetches the DNS public key, +// and verifies the signature. Returns the signing domain on success. +func Verify(message []byte) (domain string, err error) { + headerBytes, bodyBytes := splitMessage(message) + hdrMap := parseHeaders(headerBytes) + + sigVal, ok := hdrMap["dkim-signature"] + if !ok { + return "", fmt.Errorf("dkim: no DKIM-Signature header found") + } + + params := parseDKIMParams(sigVal) + + sel, ok := params["s"] + if !ok || sel == "" { + return "", fmt.Errorf("dkim: missing selector (s=) in DKIM-Signature") + } + dom, ok := params["d"] + if !ok || dom == "" { + return "", fmt.Errorf("dkim: missing domain (d=) in DKIM-Signature") + } + bh64, _ := params["bh"] + b64, ok := params["b"] + if !ok { + return "", fmt.Errorf("dkim: missing signature (b=) in DKIM-Signature") + } + signedFields, _ := params["h"] + + // Verify body hash. + canonBody := canonicalizeBodyRelaxed(bodyBytes) + bodyHash := sha256.Sum256(canonBody) + expectedBH := base64.StdEncoding.EncodeToString(bodyHash[:]) + // Strip whitespace from DNS-retrieved bh for comparison. + cleanBH := strings.Map(func(r rune) rune { + if r == ' ' || r == '\t' || r == '\r' || r == '\n' { + return -1 + } + return r + }, bh64) + if cleanBH != expectedBH { + return "", fmt.Errorf("dkim: body hash mismatch") + } + + // DNS lookup. + lookupName := sel + "._domainkey." + dom + txts, lookupErr := net.LookupTXT(lookupName) + if lookupErr != nil { + return "", fmt.Errorf("dkim: DNS lookup %s: %w", lookupName, lookupErr) + } + if len(txts) == 0 { + return "", fmt.Errorf("dkim: no TXT record at %s", lookupName) + } + + // Join TXT record parts (DNS may split at 255 bytes). + dnsRecord := strings.Join(txts, "") + dnsParams := parseDKIMParams(dnsRecord) + pVal, ok := dnsParams["p"] + if !ok || pVal == "" { + return "", fmt.Errorf("dkim: no p= (public key) in DNS TXT record") + } + + pubDER, decErr := base64.StdEncoding.DecodeString(pVal) + if decErr != nil { + return "", fmt.Errorf("dkim: decode public key base64: %w", decErr) + } + + // Rebuild the data-to-sign exactly as Signer.Sign did. + // Canonicalize the signed headers. + var sb strings.Builder + for _, field := range strings.Split(signedFields, ":") { + field = strings.TrimSpace(field) + val, ok2 := hdrMap[strings.ToLower(field)] + if !ok2 { + continue + } + sb.WriteString(canonicalizeHeaderRelaxed(field, val)) + sb.WriteString("\r\n") + } + // Append DKIM-Signature with b= stripped (zeroed), canonicalized. + cleanedSig := stripBValue(sigVal) + sb.WriteString(canonicalizeHeaderRelaxed("dkim-signature", cleanedSig)) + + dataToVerify := []byte(sb.String()) + hash := sha256.Sum256(dataToVerify) + + sigBytes, decErr := base64.StdEncoding.DecodeString( + strings.Map(func(r rune) rune { + if r == ' ' || r == '\t' || r == '\r' || r == '\n' { + return -1 + } + return r + }, b64), + ) + if decErr != nil { + return "", fmt.Errorf("dkim: decode signature base64: %w", decErr) + } + + // Try RSA PKIX public key first. + if pubKey, rsaErr := x509.ParsePKIXPublicKey(pubDER); rsaErr == nil { + switch k := pubKey.(type) { + case *rsa.PublicKey: + if err := rsa.VerifyPKCS1v15(k, gocrypto.SHA256, hash[:], sigBytes); err != nil { + return "", fmt.Errorf("dkim: RSA signature invalid: %w", err) + } + return dom, nil + case ed25519.PublicKey: + if !ed25519.Verify(k, hash[:], sigBytes) { + return "", fmt.Errorf("dkim: Ed25519 signature invalid") + } + return dom, nil + default: + return "", fmt.Errorf("dkim: unsupported public key type %T", pubKey) + } + } + + // Try raw Ed25519 public key (32 bytes). + if len(pubDER) == ed25519.PublicKeySize { + edPub := ed25519.PublicKey(pubDER) + if !ed25519.Verify(edPub, hash[:], sigBytes) { + return "", fmt.Errorf("dkim: Ed25519 signature invalid") + } + return dom, nil + } + + return "", fmt.Errorf("dkim: unable to parse public key from DNS record") +} + +// DNSRecord returns the DKIM TXT record string for publishing. +func DNSRecord(selector, domain, publicKeyPEM string) string { + block, _ := pem.Decode([]byte(publicKeyPEM)) + var p string + if block != nil { + p = base64.StdEncoding.EncodeToString(block.Bytes) + } + return fmt.Sprintf("%s._domainkey.%s. IN TXT \"v=DKIM1; k=rsa; p=%s\"", selector, domain, p) +} + +// SPFRecord returns the recommended SPF TXT record for a domain. +func SPFRecord(domain string) string { + return fmt.Sprintf("%s IN TXT \"v=spf1 mx a ~all\"", domain) +} + +// ---- Internals ---- + +// splitMessage splits at the first blank line (CRLF or LF variants). +func splitMessage(msg []byte) (headers, body []byte) { + s := string(msg) + for _, sep := range []string{"\r\n\r\n", "\n\n"} { + if idx := strings.Index(s, sep); idx >= 0 { + return []byte(s[:idx]), []byte(s[idx+len(sep):]) + } + } + return msg, nil +} + +// canonicalizeBodyRelaxed applies RFC 6376 relaxed body canonicalization. +func canonicalizeBodyRelaxed(body []byte) []byte { + if len(body) == 0 { + return []byte("\r\n") + } + // Normalize line endings. + s := strings.ReplaceAll(string(body), "\r\n", "\n") + s = strings.ReplaceAll(s, "\r", "\n") + + lines := strings.Split(s, "\n") + var out []string + for _, line := range lines { + // Collapse whitespace runs within each line, trim trailing whitespace. + fields := strings.Fields(line) + out = append(out, strings.Join(fields, " ")) + } + + // Strip trailing empty lines. + for len(out) > 0 && out[len(out)-1] == "" { + out = out[:len(out)-1] + } + + result := strings.Join(out, "\r\n") + "\r\n" + return []byte(result) +} + +// canonicalizeHeaderRelaxed applies RFC 6376 relaxed header canonicalization +// to a single header name + value. Returns "lowername:value" (no trailing CRLF). +func canonicalizeHeaderRelaxed(name, value string) string { + name = strings.ToLower(strings.TrimSpace(name)) + // Collapse all whitespace (including CRLF folding) to a single space. + value = strings.ReplaceAll(value, "\r\n", " ") + value = strings.ReplaceAll(value, "\r", " ") + value = strings.ReplaceAll(value, "\n", " ") + // Collapse multiple spaces. + parts := strings.Fields(value) + value = strings.Join(parts, " ") + value = strings.TrimSpace(value) + return name + ":" + value +} + +// parseHeaders builds a map of lower-cased header name → last value. +// Handles multi-line (folded) headers. +func parseHeaders(headerBytes []byte) map[string]string { + m := make(map[string]string) + lines := strings.Split(strings.ReplaceAll(string(headerBytes), "\r\n", "\n"), "\n") + + var curName, curVal string + flush := func() { + if curName != "" { + m[strings.ToLower(curName)] = curVal + } + } + + for _, line := range lines { + if line == "" { + continue + } + if len(line) > 0 && (line[0] == ' ' || line[0] == '\t') { + // Folded continuation. + curVal += " " + strings.TrimSpace(line) + continue + } + flush() + idx := strings.IndexByte(line, ':') + if idx < 0 { + curName = "" + curVal = "" + continue + } + curName = line[:idx] + curVal = strings.TrimSpace(line[idx+1:]) + } + flush() + return m +} + +// parseDKIMParams parses semicolon-separated tag=value pairs (DKIM and DNS TXT). +func parseDKIMParams(s string) map[string]string { + m := make(map[string]string) + for _, part := range strings.Split(s, ";") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + idx := strings.IndexByte(part, '=') + if idx < 0 { + continue + } + key := strings.TrimSpace(part[:idx]) + val := strings.TrimSpace(part[idx+1:]) + m[key] = val + } + return m +} + +// stripBValue removes the value of the b= tag (sets it to empty) so the +// header can be re-canonicalized for verification. +func stripBValue(sigVal string) string { + // Find b= and zero everything after it up to the next ;. + parts := strings.Split(sigVal, ";") + for i, p := range parts { + trimmed := strings.TrimSpace(p) + if strings.HasPrefix(trimmed, "b=") { + parts[i] = " b=" + } + } + return strings.Join(parts, ";") +} diff --git a/internal/dmarc/dmarc.go b/internal/dmarc/dmarc.go new file mode 100644 index 0000000..8f935e5 --- /dev/null +++ b/internal/dmarc/dmarc.go @@ -0,0 +1,125 @@ +// Package dmarc implements basic DMARC (RFC 7489) policy evaluation. +// Only stdlib net is used for DNS. Supports p=none, p=quarantine, p=reject. +package dmarc + +import ( + "fmt" + "net" + "strings" + + "ghb.freebede.com/nahakubuilder/mailgosend/internal/spf" +) + +// Policy represents the DMARC disposition policy. +type Policy int + +const ( + PolicyNone Policy = iota // p=none + PolicyQuarantine // p=quarantine + PolicyReject // p=reject +) + +func (p Policy) String() string { + return [...]string{"none", "quarantine", "reject"}[p] +} + +// Result holds the DMARC evaluation outcome. +type Result struct { + Pass bool + Policy Policy + Disposition string // none | quarantine | reject + Reason string +} + +// Check evaluates DMARC policy for the given envelope-from domain. +// spfPass: whether SPF passed for this envelope-from domain. +// dkimDomains: set of signing domains that produced a valid DKIM signature. +// Returns a Result and logs no external calls beyond DNS. +func Check(fromDomain string, spfResult spf.Result, dkimDomains []string) *Result { + record, err := fetchDMARC(fromDomain) + if err != nil || record == "" { + // No DMARC record — not a failure, but can't enforce. + return &Result{ + Pass: true, + Policy: PolicyNone, + Disposition: "none", + Reason: "no DMARC record for " + fromDomain, + } + } + + policy := parsePolicy(record) + + // SPF alignment (relaxed): envelope-from domain must equal or be a subdomain + // of the DMARC organisational domain. + orgDomain := orgDomainOf(fromDomain) + spfAligned := spfResult == spf.ResultPass + + // DKIM alignment (relaxed): at least one valid DKIM signature domain must + // equal or be a subdomain of the org domain. + dkimAligned := false + for _, d := range dkimDomains { + if strings.EqualFold(d, fromDomain) || strings.HasSuffix(strings.ToLower(d), "."+strings.ToLower(orgDomain)) { + dkimAligned = true + break + } + } + + pass := spfAligned || dkimAligned + reason := fmt.Sprintf("SPF=%v DKIM-aligned=%v policy=%s", spfAligned, dkimAligned, policy) + + if pass { + return &Result{ + Pass: true, + Policy: policy, + Disposition: "none", + Reason: reason, + } + } + + return &Result{ + Pass: false, + Policy: policy, + Disposition: policy.String(), + Reason: reason, + } +} + +// fetchDMARC queries TXT at _dmarc. and returns the first DMARC record. +func fetchDMARC(domain string) (string, error) { + txts, err := net.LookupTXT("_dmarc." + domain) + if err != nil { + if dnsErr, ok := err.(*net.DNSError); ok && dnsErr.IsNotFound { + return "", nil + } + return "", err + } + for _, txt := range txts { + txt = strings.TrimSpace(txt) + if strings.HasPrefix(txt, "v=DMARC1") { + return txt, nil + } + } + return "", nil +} + +// parsePolicy extracts the p= tag from a DMARC record. +func parsePolicy(record string) Policy { + for _, part := range strings.Split(record, ";") { + part = strings.TrimSpace(part) + if strings.HasPrefix(strings.ToLower(part), "p=") { + switch strings.ToLower(part[2:]) { + case "quarantine": + return PolicyQuarantine + case "reject": + return PolicyReject + } + } + } + return PolicyNone +} + +// orgDomainOf returns a simplified "organisational domain" — for this +// implementation we use the domain as-is (a full PSL lookup is out of scope). +func orgDomainOf(domain string) string { + return strings.ToLower(domain) +} diff --git a/internal/imap/server.go b/internal/imap/server.go new file mode 100644 index 0000000..4831a67 --- /dev/null +++ b/internal/imap/server.go @@ -0,0 +1,80 @@ +// Package imap provides an IMAP4rev2 server backed by the encrypted SQLite store. +package imap + +import ( + "context" + "crypto/tls" + "fmt" + "log" + "net" + "time" + + "github.com/emersion/go-imap/v2" + "github.com/emersion/go-imap/v2/imapserver" + + "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" +) + +// Deps groups dependencies needed by the IMAP server. +type Deps struct { + DB *db.DB + Crypt *appCrypto.Crypto + Brute *auth.BruteGuard + Cfg *config.Config +} + +// NewServer creates an IMAP4rev2 server for port 143 (STARTTLS). +func NewServer(d *Deps, tlsCfg *tls.Config) *imapserver.Server { + return imapserver.New(&imapserver.Options{ + NewSession: func(c *imapserver.Conn) (imapserver.Session, *imapserver.GreetingData, error) { + clientIP := connRemoteIP(c) + if d.Brute != nil && d.Cfg.BruteMaxTries > 0 { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + if banned, err := d.Brute.IsBanned(ctx, clientIP); err == nil && banned { + cancel() + return nil, nil, fmt.Errorf("connection refused") + } + cancel() + } + return &IMAPSession{deps: d, clientIP: clientIP}, &imapserver.GreetingData{}, nil + }, + Caps: imap.CapSet{ + imap.CapIMAP4rev2: {}, + imap.CapIMAP4rev1: {}, + }, + TLSConfig: tlsCfg, + InsecureAuth: tlsCfg == nil, + }) +} + +// ListenAndServe listens on addr (port 143 / STARTTLS). +func ListenAndServe(s *imapserver.Server, addr, name string) error { + ln, err := net.Listen("tcp", addr) + if err != nil { + return fmt.Errorf("%s listen %s: %w", name, addr, err) + } + log.Printf("[%s] listening on %s", name, addr) + return s.Serve(ln) +} + +// ListenAndServeTLS listens with implicit TLS (port 993 / IMAPS). +func ListenAndServeTLS(s *imapserver.Server, addr, name string) error { + log.Printf("[%s] listening on %s (TLS)", name, addr) + return s.ListenAndServeTLS(addr) +} + +// connRemoteIP returns the client IP from an imapserver.Conn. +func connRemoteIP(c *imapserver.Conn) string { + if c == nil { + return "" + } + nc := c.NetConn() + if nc == nil { + return "" + } + host, _, _ := net.SplitHostPort(nc.RemoteAddr().String()) + return host +} diff --git a/internal/imap/session.go b/internal/imap/session.go new file mode 100644 index 0000000..ee54b6b --- /dev/null +++ b/internal/imap/session.go @@ -0,0 +1,884 @@ +package imap + +import ( + "bufio" + "bytes" + "context" + "fmt" + "log" + "sort" + "strings" + "time" + + "github.com/emersion/go-imap/v2" + "github.com/emersion/go-imap/v2/imapserver" + "github.com/emersion/go-message/textproto" + + "ghb.freebede.com/nahakubuilder/mailgosend/internal/crypto" + "ghb.freebede.com/nahakubuilder/mailgosend/internal/db" + "ghb.freebede.com/nahakubuilder/mailgosend/internal/models" +) + +const mailboxDelim rune = '/' + +// msgEntry holds the in-memory descriptor for one message in the selected mailbox. +type msgEntry struct { + dbID int64 + uid imap.UID + isRead bool + isStarred bool + isDraft bool + isDeleted bool + extraFlags string + size int64 + internalDate time.Time +} + +func (e *msgEntry) flagList() []imap.Flag { + var flags []imap.Flag + if e.isRead { + flags = append(flags, imap.FlagSeen) + } + if e.isStarred { + flags = append(flags, imap.FlagFlagged) + } + if e.isDraft { + flags = append(flags, imap.FlagDraft) + } + if e.isDeleted { + flags = append(flags, imap.FlagDeleted) + } + for _, f := range strings.Fields(e.extraFlags) { + flags = append(flags, imap.Flag(f)) + } + return flags +} + +// IMAPSession implements imapserver.SessionIMAP4rev2. +type IMAPSession struct { + deps *Deps + clientIP string + user *models.User // set after Login + + selectedMailbox *models.Mailbox + msgs []msgEntry // index+1 = IMAP sequence number + mboxTracker *imapserver.MailboxTracker + sessionTracker *imapserver.SessionTracker +} + +var _ imapserver.SessionIMAP4rev2 = (*IMAPSession)(nil) + +// ---- Authentication ---- + +func (s *IMAPSession) Close() error { + if s.sessionTracker != nil { + s.sessionTracker.Close() + } + return nil +} + +func (s *IMAPSession) Login(username, password string) error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + user, err := s.deps.DB.GetUserByEmail(ctx, username) + if err != nil { + log.Printf("[imap] login error %s: %v", username, err) + return imapserver.ErrAuthFailed + } + if user == nil || !user.Enabled { + s.recordAttempt(ctx, username, false) + return imapserver.ErrAuthFailed + } + if err := crypto.CheckPassword(user.PasswordHash, password); err != nil { + log.Printf("[imap] auth failed %s from %s", username, s.clientIP) + s.recordAttempt(ctx, username, false) + return imapserver.ErrAuthFailed + } + + s.user = user + s.deps.DB.UpdateLastLogin(ctx, user.ID) //nolint:errcheck + s.recordAttempt(ctx, username, true) + log.Printf("[imap] auth OK %s from %s", username, s.clientIP) + return nil +} + +func (s *IMAPSession) recordAttempt(ctx context.Context, email string, success bool) { + if s.deps.Brute != nil { + s.deps.Brute.RecordAttempt(ctx, s.clientIP, email, success) //nolint:errcheck + } +} + +// ---- Mailbox management ---- + +func (s *IMAPSession) Select(mailboxName string, options *imap.SelectOptions) (*imap.SelectData, error) { + s.doUnselect() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + mbox, err := s.deps.DB.GetMailbox(ctx, s.user.ID, mailboxName) + if err != nil { + return nil, imapErr(err) + } + if mbox == nil { + return nil, noSuchMailbox() + } + + msgs, err := s.deps.DB.ListIMAPMessages(ctx, mbox.ID) + if err != nil { + return nil, imapErr(err) + } + + s.selectedMailbox = mbox + s.msgs = make([]msgEntry, 0, len(msgs)) + for _, m := range msgs { + s.msgs = append(s.msgs, msgEntry{ + dbID: m.ID, + uid: imap.UID(m.UID), + isRead: m.IsRead, + isStarred: m.IsStarred, + isDraft: m.IsDraft, + extraFlags: m.Flags, + size: m.SizeBytes, + internalDate: m.ReceivedAt, + }) + } + + s.mboxTracker = imapserver.NewMailboxTracker(uint32(len(s.msgs))) + s.sessionTracker = s.mboxTracker.NewSession() + + var firstUnseen uint32 + for i, m := range s.msgs { + if !m.isRead { + firstUnseen = uint32(i + 1) + break + } + } + + return &imap.SelectData{ + Flags: allFlags(), + PermanentFlags: allFlags(), + NumMessages: uint32(len(s.msgs)), + FirstUnseenSeqNum: firstUnseen, + UIDNext: imap.UID(mbox.UIDNext), + UIDValidity: mbox.UIDValidity, + List: &imap.ListData{ + Mailbox: mailboxName, + Delim: mailboxDelim, + Attrs: mboxAttrs(mbox), + }, + }, nil +} + +func (s *IMAPSession) Unselect() error { + s.doUnselect() + return nil +} + +func (s *IMAPSession) doUnselect() { + if s.sessionTracker != nil { + s.sessionTracker.Close() + s.sessionTracker = nil + } + s.selectedMailbox = nil + s.msgs = nil + s.mboxTracker = nil +} + +func (s *IMAPSession) Create(mailboxName string, options *imap.CreateOptions) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + existing, _ := s.deps.DB.GetMailbox(ctx, s.user.ID, mailboxName) + if existing != nil { + return &imap.Error{Type: imap.StatusResponseTypeNo, Code: imap.ResponseCodeAlreadyExists, Text: "Mailbox already exists"} + } + _, err := s.deps.DB.CreateMailbox(ctx, s.user.ID, mailboxName, "", nil) + return imapErr(err) +} + +func (s *IMAPSession) Delete(mailboxName string) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + mbox, _ := s.deps.DB.GetMailbox(ctx, s.user.ID, mailboxName) + if mbox == nil { + return noSuchMailbox() + } + if mbox.Type != "" { + return &imap.Error{Type: imap.StatusResponseTypeNo, Text: "Cannot delete system mailbox"} + } + _, err := s.deps.DB.SQL().ExecContext(ctx, "DELETE FROM mailboxes WHERE id=?", mbox.ID) + return imapErr(err) +} + +func (s *IMAPSession) Rename(oldName, newName string, options *imap.RenameOptions) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + mbox, _ := s.deps.DB.GetMailbox(ctx, s.user.ID, oldName) + if mbox == nil { + return noSuchMailbox() + } + return imapErr(s.deps.DB.RenameMailbox(ctx, mbox.ID, newName)) +} + +func (s *IMAPSession) Subscribe(mailboxName string) error { + return s.setSubscribed(mailboxName, true) +} + +func (s *IMAPSession) Unsubscribe(mailboxName string) error { + return s.setSubscribed(mailboxName, false) +} + +func (s *IMAPSession) setSubscribed(name string, sub bool) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + mbox, _ := s.deps.DB.GetMailbox(ctx, s.user.ID, name) + if mbox == nil { + return noSuchMailbox() + } + return imapErr(s.deps.DB.SetMailboxSubscribed(ctx, mbox.ID, sub)) +} + +func (s *IMAPSession) List(w *imapserver.ListWriter, ref string, patterns []string, options *imap.ListOptions) error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if len(patterns) == 0 { + return w.WriteList(&imap.ListData{ + Attrs: []imap.MailboxAttr{imap.MailboxAttrNoSelect}, + Delim: mailboxDelim, + }) + } + + mailboxes, err := s.deps.DB.ListMailboxes(ctx, s.user.ID) + if err != nil { + return imapErr(err) + } + + type entry struct { + name string + data imap.ListData + } + var entries []entry + + for _, mbox := range mailboxes { + if options.SelectSubscribed && !mbox.Subscribed { + continue + } + matched := false + for _, pat := range patterns { + if imapserver.MatchList(mbox.Name, mailboxDelim, ref, pat) { + matched = true + break + } + } + if !matched { + continue + } + + data := imap.ListData{ + Mailbox: mbox.Name, + Delim: mailboxDelim, + Attrs: mboxAttrs(mbox), + } + if mbox.Subscribed { + data.Attrs = append(data.Attrs, imap.MailboxAttrSubscribed) + } + if options.ReturnStatus != nil { + sd, _ := s.statusFor(ctx, mbox, options.ReturnStatus) + data.Status = sd + } + entries = append(entries, entry{name: mbox.Name, data: data}) + } + + sort.Slice(entries, func(i, j int) bool { return entries[i].name < entries[j].name }) + for _, e := range entries { + if err := w.WriteList(&e.data); err != nil { + return err + } + } + return nil +} + +func (s *IMAPSession) Status(mailboxName string, options *imap.StatusOptions) (*imap.StatusData, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + mbox, _ := s.deps.DB.GetMailbox(ctx, s.user.ID, mailboxName) + if mbox == nil { + return nil, noSuchMailbox() + } + return s.statusFor(ctx, mbox, options) +} + +func (s *IMAPSession) statusFor(ctx context.Context, mbox *models.Mailbox, opts *imap.StatusOptions) (*imap.StatusData, error) { + data := &imap.StatusData{Mailbox: mbox.Name} + + total, unseen, err := s.deps.DB.GetMailboxMessageCounts(ctx, mbox.ID) + if err != nil { + return nil, imapErr(err) + } + + if opts.NumMessages { + n := uint32(total) + data.NumMessages = &n + } + if opts.NumUnseen { + n := uint32(unseen) + data.NumUnseen = &n + } + if opts.UIDNext { + data.UIDNext = imap.UID(mbox.UIDNext) + } + if opts.UIDValidity { + data.UIDValidity = mbox.UIDValidity + } + if opts.NumRecent { + n := uint32(0) + data.NumRecent = &n + } + if opts.NumDeleted { + n := uint32(0) + data.NumDeleted = &n + } + if opts.Size { + sz, _ := s.deps.DB.GetMailboxSize(ctx, mbox.ID) + data.Size = &sz + } + return data, nil +} + +func (s *IMAPSession) Append(mailboxName string, r imap.LiteralReader, options *imap.AppendOptions) (*imap.AppendData, error) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + mbox, _ := s.deps.DB.GetMailbox(ctx, s.user.ID, mailboxName) + if mbox == nil { + return nil, &imap.Error{Type: imap.StatusResponseTypeNo, Code: imap.ResponseCodeTryCreate, Text: "No such mailbox"} + } + + raw, err := readLiteral(r, s.deps.Cfg.MaxMessageSize) + if err != nil { + return nil, imapErr(err) + } + + key, err := s.deps.Crypt.DeriveKey("messages", s.user.ID) + if err != nil { + return nil, imapErr(fmt.Errorf("derive key: %w", err)) + } + rawEnc, err := crypto.Encrypt(key, raw) + if err != nil { + return nil, imapErr(err) + } + + uid, err := s.deps.DB.NextUID(ctx, mbox.ID) + if err != nil { + return nil, imapErr(err) + } + + internalDate := time.Now().UTC() + isRead, isDraft := false, false + var extraParts []string + + if options != nil { + if !options.Time.IsZero() { + internalDate = options.Time + } + for _, f := range options.Flags { + switch f { + case imap.FlagSeen: + isRead = true + case imap.FlagDraft: + isDraft = true + default: + extraParts = append(extraParts, string(f)) + } + } + } + + ins := &db.MessageInsert{ + MailboxID: mbox.ID, + UID: uid, + SizeBytes: int64(len(raw)), + RawEnc: rawEnc, + IsRead: isRead, + IsDraft: isDraft, + Flags: strings.Join(extraParts, " "), + Date: internalDate, + } + if _, err := s.deps.DB.InsertMessage(ctx, ins); err != nil { + return nil, imapErr(err) + } + + return &imap.AppendData{ + UIDValidity: mbox.UIDValidity, + UID: imap.UID(uid), + }, nil +} + +func (s *IMAPSession) Poll(w *imapserver.UpdateWriter, allowExpunge bool) error { + if s.sessionTracker == nil { + return nil + } + return s.sessionTracker.Poll(w, allowExpunge) +} + +func (s *IMAPSession) Idle(w *imapserver.UpdateWriter, stop <-chan struct{}) error { + if s.sessionTracker == nil { + return nil + } + return s.sessionTracker.Idle(w, stop) +} + +// ---- Selected-state operations ---- + +func (s *IMAPSession) Expunge(w *imapserver.ExpungeWriter, uids *imap.UIDSet) error { + if s.selectedMailbox == nil { + return noSelectedMailbox() + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + // Soft-delete entries marked \Deleted (filtered by UIDs if provided). + for i := range s.msgs { + m := &s.msgs[i] + if uids != nil && !uids.Contains(m.uid) { + continue + } + if m.isDeleted { + s.deps.DB.SoftDeleteMessage(ctx, m.dbID) //nolint:errcheck + } + } + + deletedUIDs, err := s.deps.DB.HardDeleteMessages(ctx, s.selectedMailbox.ID) + if err != nil { + return imapErr(err) + } + deletedSet := make(map[imap.UID]struct{}, len(deletedUIDs)) + for _, uid := range deletedUIDs { + deletedSet[imap.UID(uid)] = struct{}{} + } + + // Collect seq nums to expunge (in ascending order, write in reverse). + var seqNums []uint32 + for i, m := range s.msgs { + if _, ok := deletedSet[m.uid]; ok { + seqNums = append(seqNums, uint32(i+1)) + } + } + for i := len(seqNums) - 1; i >= 0; i-- { + if err := w.WriteExpunge(seqNums[i]); err != nil { + return err + } + } + + // Remove from in-memory list. + filtered := s.msgs[:0] + for _, m := range s.msgs { + if _, ok := deletedSet[m.uid]; !ok { + filtered = append(filtered, m) + } + } + s.msgs = filtered + return nil +} + +func (s *IMAPSession) Search(kind imapserver.NumKind, criteria *imap.SearchCriteria, options *imap.SearchOptions) (*imap.SearchData, error) { + if s.selectedMailbox == nil { + return nil, noSelectedMailbox() + } + + if kind == imapserver.NumKindUID { + var result imap.UIDSet + for i, m := range s.msgs { + seqNum := uint32(i + 1) + if s.matchCriteria(seqNum, &m, criteria) { + result.AddNum(m.uid) + } + } + return &imap.SearchData{All: result}, nil + } + + var result imap.SeqSet + for i, m := range s.msgs { + seqNum := uint32(i + 1) + if s.matchCriteria(seqNum, &m, criteria) { + result.AddNum(seqNum) + } + } + return &imap.SearchData{All: result}, nil +} + +func (s *IMAPSession) matchCriteria(seqNum uint32, m *msgEntry, c *imap.SearchCriteria) bool { + if c == nil { + return true + } + for _, seqSet := range c.SeqNum { + if !seqSet.Contains(seqNum) { + return false + } + } + for _, uidSet := range c.UID { + if !uidSet.Contains(m.uid) { + return false + } + } + flagSet := make(map[imap.Flag]struct{}) + for _, f := range m.flagList() { + flagSet[f] = struct{}{} + } + for _, f := range c.Flag { + if _, ok := flagSet[f]; !ok { + return false + } + } + for _, f := range c.NotFlag { + if _, ok := flagSet[f]; ok { + return false + } + } + if c.Larger != 0 && m.size <= c.Larger { + return false + } + if c.Smaller != 0 && m.size >= c.Smaller { + return false + } + if !matchDate(m.internalDate, c.Since, c.Before) { + return false + } + for _, sub := range c.Not { + if s.matchCriteria(seqNum, m, &sub) { + return false + } + } + for _, or := range c.Or { + if !s.matchCriteria(seqNum, m, &or[0]) && !s.matchCriteria(seqNum, m, &or[1]) { + return false + } + } + return true +} + +func (s *IMAPSession) Fetch(w *imapserver.FetchWriter, numSet imap.NumSet, options *imap.FetchOptions) error { + if s.selectedMailbox == nil { + return noSelectedMailbox() + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + key, err := s.deps.Crypt.DeriveKey("messages", s.user.ID) + if err != nil { + return imapErr(err) + } + + for i := range s.msgs { + m := &s.msgs[i] + seqNum := uint32(i + 1) + if !numSetContains(numSet, seqNum, m.uid) { + continue + } + + rw := w.CreateMessage(seqNum) + rw.WriteUID(m.uid) + + if options.Flags { + rw.WriteFlags(m.flagList()) + } + if options.InternalDate { + rw.WriteInternalDate(m.internalDate) + } + if options.RFC822Size { + rw.WriteRFC822Size(m.size) + } + + needRaw := options.Envelope || options.BodyStructure != nil || + len(options.BodySection) > 0 || len(options.BinarySection) > 0 || + len(options.BinarySectionSize) > 0 + + if needRaw { + rawEnc, err := s.deps.DB.GetMessageRaw(ctx, m.dbID) + if err != nil || rawEnc == nil { + rw.Close() //nolint:errcheck + continue + } + raw, err := crypto.Decrypt(key, rawEnc) + if err != nil { + log.Printf("[imap] decrypt %d: %v", m.dbID, err) + rw.Close() //nolint:errcheck + continue + } + + if options.Envelope { + if env := extractEnvelope(raw); env != nil { + rw.WriteEnvelope(env) + } + } + if options.BodyStructure != nil { + rw.WriteBodyStructure(imapserver.ExtractBodyStructure(bytes.NewReader(raw))) + } + for _, bs := range options.BodySection { + buf := imapserver.ExtractBodySection(bytes.NewReader(raw), bs) + wc := rw.WriteBodySection(bs, int64(len(buf))) + wc.Write(buf) //nolint:errcheck + wc.Close() //nolint:errcheck + } + for _, bs := range options.BinarySection { + buf := imapserver.ExtractBinarySection(bytes.NewReader(raw), bs) + wc := rw.WriteBinarySection(bs, int64(len(buf))) + wc.Write(buf) //nolint:errcheck + wc.Close() //nolint:errcheck + } + for _, bss := range options.BinarySectionSize { + n := imapserver.ExtractBinarySectionSize(bytes.NewReader(raw), bss) + rw.WriteBinarySectionSize(bss, n) + } + } + + if err := rw.Close(); err != nil { + return err + } + } + return nil +} + +func (s *IMAPSession) Store(w *imapserver.FetchWriter, numSet imap.NumSet, flags *imap.StoreFlags, options *imap.StoreOptions) error { + if s.selectedMailbox == nil { + return noSelectedMailbox() + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + for i := range s.msgs { + m := &s.msgs[i] + seqNum := uint32(i + 1) + if !numSetContains(numSet, seqNum, m.uid) { + continue + } + applyStoreFlags(m, flags) + s.deps.DB.SetMessageFlags(ctx, m.dbID, m.isRead, m.isStarred, m.isDraft, m.extraFlags) //nolint:errcheck + if m.isDeleted { + s.deps.DB.SoftDeleteMessage(ctx, m.dbID) //nolint:errcheck + } + + if !flags.Silent { + rw := w.CreateMessage(seqNum) + rw.WriteUID(m.uid) + rw.WriteFlags(m.flagList()) + if err := rw.Close(); err != nil { + return err + } + } + } + return nil +} + +func (s *IMAPSession) Copy(numSet imap.NumSet, destName string) (*imap.CopyData, error) { + if s.selectedMailbox == nil { + return nil, noSelectedMailbox() + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + dest, _ := s.deps.DB.GetMailbox(ctx, s.user.ID, destName) + if dest == nil { + return nil, &imap.Error{Type: imap.StatusResponseTypeNo, Code: imap.ResponseCodeTryCreate, Text: "No such mailbox"} + } + + var sourceUIDs, destUIDs imap.UIDSet + for i, m := range s.msgs { + seqNum := uint32(i + 1) + if !numSetContains(numSet, seqNum, m.uid) { + continue + } + newUID, err := s.deps.DB.CopyMessageToMailbox(ctx, m.dbID, dest.ID, s.user.ID) + if err != nil { + log.Printf("[imap] copy msg %d: %v", m.dbID, err) + continue + } + sourceUIDs.AddNum(m.uid) + destUIDs.AddNum(imap.UID(newUID)) + } + return &imap.CopyData{ + UIDValidity: dest.UIDValidity, + SourceUIDs: sourceUIDs, + DestUIDs: destUIDs, + }, nil +} + +func (s *IMAPSession) Move(w *imapserver.MoveWriter, numSet imap.NumSet, destName string) error { + copyData, err := s.Copy(numSet, destName) + if err != nil { + return err + } + if err := w.WriteCopyData(copyData); err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + var seqNums []uint32 + for i := range s.msgs { + m := &s.msgs[i] + seqNum := uint32(i + 1) + if !numSetContains(numSet, seqNum, m.uid) { + continue + } + s.deps.DB.SoftDeleteMessage(ctx, m.dbID) //nolint:errcheck + seqNums = append(seqNums, seqNum) + } + s.deps.DB.HardDeleteMessages(ctx, s.selectedMailbox.ID) //nolint:errcheck + + for i := len(seqNums) - 1; i >= 0; i-- { + if err := w.WriteExpunge(seqNums[i]); err != nil { + return err + } + } + + // Remove moved messages. + kept := s.msgs[:0] + for i, m := range s.msgs { + seqNum := uint32(i + 1) + if !numSetContains(numSet, seqNum, m.uid) { + kept = append(kept, m) + } + } + s.msgs = kept + return nil +} + +func (s *IMAPSession) Namespace() (*imap.NamespaceData, error) { + return &imap.NamespaceData{ + Personal: []imap.NamespaceDescriptor{{Delim: mailboxDelim}}, + }, nil +} + +// ---- Helpers ---- + +// numSetContains checks seqNum (for SeqSet) or uid (for UIDSet). +func numSetContains(numSet imap.NumSet, seqNum uint32, uid imap.UID) bool { + switch ns := numSet.(type) { + case imap.SeqSet: + return ns.Contains(seqNum) + case imap.UIDSet: + return ns.Contains(uid) + } + return false +} + +func applyStoreFlags(m *msgEntry, store *imap.StoreFlags) { + flagMap := map[imap.Flag]*bool{ + imap.FlagSeen: &m.isRead, + imap.FlagFlagged: &m.isStarred, + imap.FlagDraft: &m.isDraft, + imap.FlagDeleted: &m.isDeleted, + } + switch store.Op { + case imap.StoreFlagsSet: + m.isRead, m.isStarred, m.isDraft, m.isDeleted = false, false, false, false + m.extraFlags = "" + for _, f := range store.Flags { + if ptr, ok := flagMap[f]; ok { + *ptr = true + } else { + m.extraFlags = strings.TrimSpace(m.extraFlags + " " + string(f)) + } + } + case imap.StoreFlagsAdd: + for _, f := range store.Flags { + if ptr, ok := flagMap[f]; ok { + *ptr = true + } else if !strings.Contains(m.extraFlags, string(f)) { + m.extraFlags = strings.TrimSpace(m.extraFlags + " " + string(f)) + } + } + case imap.StoreFlagsDel: + for _, f := range store.Flags { + if ptr, ok := flagMap[f]; ok { + *ptr = false + } else { + m.extraFlags = strings.TrimSpace(strings.ReplaceAll(m.extraFlags, string(f), "")) + } + } + } +} + +func allFlags() []imap.Flag { + return []imap.Flag{ + imap.FlagSeen, + imap.FlagAnswered, + imap.FlagFlagged, + imap.FlagDeleted, + imap.FlagDraft, + } +} + +func mboxAttrs(mbox *models.Mailbox) []imap.MailboxAttr { + su := db.MailboxTypeToSpecialUse(mbox.Type) + if su != "" { + return []imap.MailboxAttr{imap.MailboxAttr(su)} + } + return nil +} + +func extractEnvelope(raw []byte) *imap.Envelope { + br := bufio.NewReader(bytes.NewReader(raw)) + header, err := textproto.ReadHeader(br) + if err != nil { + return nil + } + return imapserver.ExtractEnvelope(header) +} + +func matchDate(t, since, before time.Time) bool { + t = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC) + if !since.IsZero() && t.Before(since) { + return false + } + if !before.IsZero() && !t.Before(before) { + return false + } + return true +} + +func readLiteral(r imap.LiteralReader, maxSize int64) ([]byte, error) { + buf := make([]byte, 0, 4096) + tmp := make([]byte, 32768) + var total int64 + for { + n, err := r.Read(tmp) + if n > 0 { + total += int64(n) + if total > maxSize { + return nil, fmt.Errorf("message too large") + } + buf = append(buf, tmp[:n]...) + } + if err != nil { + break + } + } + return buf, nil +} + +func imapErr(err error) error { + if err == nil { + return nil + } + if _, ok := err.(*imap.Error); ok { + return err + } + return &imap.Error{Type: imap.StatusResponseTypeNo, Text: "server error"} +} + +func noSuchMailbox() error { + return &imap.Error{Type: imap.StatusResponseTypeNo, Code: imap.ResponseCodeNonExistent, Text: "No such mailbox"} +} + +func noSelectedMailbox() error { + return &imap.Error{Type: imap.StatusResponseTypeBad, Text: "no mailbox selected"} +} diff --git a/internal/models/models.go b/internal/models/models.go new file mode 100644 index 0000000..f42ca50 --- /dev/null +++ b/internal/models/models.go @@ -0,0 +1,295 @@ +// Package models defines all shared data structures. +package models + +import "time" + +// ---- Domain & User ---- + +type Domain struct { + ID int64 + Name string + Enabled bool + DKIMPrivateEnc []byte // AES-256-GCM encrypted PEM private key + DKIMPublic string // PEM public key (not secret) + DKIMSelector string + DKIMAlgo string // rsa2048 | ed25519 + SPFPolicy string // stored for reference + DMARCPolicy string // stored for reference + MaxUsers int + MaxQuotaBytes int64 + CreatedAt time.Time +} + +type User struct { + ID int64 + DomainID int64 + Username string // local part (before @) + Email string // full address + PasswordHash string // bcrypt + DisplayName string + QuotaBytes int64 + UsedBytes int64 + Enabled bool + Admin bool // global admin + DomainAdmin bool // admin of own domain only + MFASecretEnc []byte // encrypted TOTP secret; nil = MFA disabled + MFAEnabled bool + RecoveryCodesEnc []byte // encrypted JSON array of one-time codes + CreatedAt time.Time + LastLogin time.Time +} + +type UserAlias struct { + ID int64 + UserID int64 + AliasEmail string +} + +// ---- Mail storage ---- + +// MailboxType canonical values. +const ( + MailboxInbox = "inbox" + MailboxSent = "sent" + MailboxDrafts = "drafts" + MailboxTrash = "trash" + MailboxSpam = "spam" + MailboxArchive = "archive" + MailboxCustom = "custom" +) + +type Mailbox struct { + ID int64 + UserID int64 + Name string // IMAP mailbox name (e.g. "INBOX", "Sent", "Folder/Sub") + Type string // MailboxInbox … MailboxCustom + ParentID *int64 + UIDValidity uint32 + UIDNext uint32 + Subscribed bool + CreatedAt time.Time +} + +type Message struct { + ID int64 + MailboxID int64 + UID uint32 + MessageID string // RFC 2822 Message-ID header (not encrypted — needed for threading) + Subject string // plaintext for list/search (consider sensitivity vs usability) + FromEmail string + FromName string + ToList string // comma-separated + CCList string + BCCList string + ReplyTo string + Date time.Time + BodyTextEnc []byte // AES-256-GCM encrypted text/plain + BodyHTMLEnc []byte // AES-256-GCM encrypted text/html + RawEnc []byte // AES-256-GCM encrypted full RFC822 message + SizeBytes int64 + HasAttachment bool + IsRead bool + IsStarred bool + IsDraft bool + Flags string // raw IMAP flags string + SpamScore int + ReceivedAt time.Time + DeletedAt *time.Time // soft delete; nil = not deleted +} + +type Attachment struct { + ID int64 + MessageID int64 + Filename string + ContentType string + SizeBytes int64 + DataEnc []byte // AES-256-GCM encrypted bytes (or nil if fs-backed) + DataPath string // filesystem path (if STORAGE_BACKEND=fs) + ContentID string // MIME Content-ID for inline images + Inline bool + MIMEPath string // dot-separated MIME section path e.g. "1.2" +} + +// ---- Delivery queue ---- + +const ( + QueuePending = "pending" + QueueSending = "sending" + QueueDelivered = "delivered" + QueueFailed = "failed" + QueueBounced = "bounced" +) + +type QueueEntry struct { + ID int64 + DomainID int64 + FromAddr string + ToAddr string + RawEnc []byte // encrypted RFC822 + MessageID string + Status string // QueuePending … QueueBounced + Attempts int + LastAttempt *time.Time + NextAttempt time.Time + ErrorLog string + CreatedAt time.Time + ExpiresAt time.Time +} + +type DeliveryLog struct { + ID int64 + QueueID int64 + FromAddr string + ToAddr string + Status string + SMTPCode int + SMTPMessage string + MXHost string + CreatedAt time.Time +} + +// ---- Sessions & Security ---- + +type Session struct { + ID int64 + UserID int64 + TokenHash string // SHA-256 hex of the raw bearer token + IP string + UserAgent string + CreatedAt time.Time + ExpiresAt time.Time +} + +type IPBan struct { + ID int64 + IP string + Reason string + BannedAt time.Time + ExpiresAt *time.Time // nil = permanent + ReleasedBy string +} + +type LoginAttempt struct { + ID int64 + IP string + UserEmail string + Success bool + CreatedAt time.Time +} + +type SecurityEvent struct { + ID int64 + Type string // brute_ban | auth_fail | relay_attempt | etc. + IP string + UserID *int64 + Detail string + CreatedAt time.Time +} + +// ---- External accounts (Gmail / Outlook / custom IMAP) ---- + +const ( + ProviderGmail = "gmail" + ProviderOutlook = "outlook" + ProviderCustom = "custom" +) + +type ExternalAccount struct { + ID int64 + UserID int64 + Provider string // ProviderGmail | ProviderOutlook | ProviderCustom + EmailAddress string + DisplayName string + AccessTokenEnc []byte // encrypted OAuth2 or password + RefreshTokenEnc []byte // encrypted OAuth2 refresh token + TokenExpiry time.Time + IMAPHost string + IMAPPort int + SMTPHost string + SMTPPort int + Enabled bool + SyncEnabled bool + LastSync time.Time + CreatedAt time.Time +} + +// ---- Contacts (CardDAV) ---- + +type AddressBook struct { + ID int64 + UserID int64 + Name string + Description string + Color string + SyncToken int64 + CreatedAt time.Time +} + +type Contact struct { + ID int64 + AddressBookID int64 + UID string + VCardEnc []byte // AES-256-GCM encrypted vCard 3.0/4.0 + ETag string // hex(sha256(raw vcard)) — for sync + CreatedAt time.Time + UpdatedAt time.Time +} + +// ---- Calendar (CalDAV) ---- + +type Calendar struct { + ID int64 + UserID int64 + Name string + Description string + Color string + Timezone string + SyncToken int64 + CreatedAt time.Time +} + +type CalendarEvent struct { + ID int64 + CalendarID int64 + UID string + ICalEnc []byte // AES-256-GCM encrypted iCalendar data + ETag string // hex(sha256(raw ical)) + DTStart time.Time + DTEnd time.Time + Summary string // plaintext for calendar grid display + Recurring bool + CreatedAt time.Time + UpdatedAt time.Time +} + +// ---- Spam / Bayesian ---- + +type SpamToken struct { + ID int64 + UserID int64 + Token string + SpamCount int64 + HamCount int64 +} + +// ---- Compose helpers (not persisted directly) ---- + +type Attachment_Upload struct { + Filename string + ContentType string + Data []byte +} + +type ComposeRequest struct { + AccountID int64 // 0 = local account, >0 = external account ID + FromEmail string + To []string + CC []string + BCC []string + Subject string + BodyText string + BodyHTML string + Attachments []Attachment_Upload + InReplyTo string + References string +} diff --git a/internal/smtp/inbound.go b/internal/smtp/inbound.go new file mode 100644 index 0000000..34cf280 --- /dev/null +++ b/internal/smtp/inbound.go @@ -0,0 +1,265 @@ +package smtp + +import ( + "context" + "fmt" + "io" + "log" + "net" + "net/mail" + "strings" + "time" + + gosmtp "github.com/emersion/go-smtp" + + "ghb.freebede.com/nahakubuilder/mailgosend/internal/dkim" + "ghb.freebede.com/nahakubuilder/mailgosend/internal/models" + "ghb.freebede.com/nahakubuilder/mailgosend/internal/spam" + "ghb.freebede.com/nahakubuilder/mailgosend/internal/spf" + "ghb.freebede.com/nahakubuilder/mailgosend/internal/storage" +) + +// InboundBackend implements gosmtp.Backend for port 25 (receive from internet). +type InboundBackend struct { + deps *Deps +} + +func (b *InboundBackend) NewSession(c *gosmtp.Conn) (gosmtp.Session, error) { + clientIP, _, _ := net.SplitHostPort(c.Conn().RemoteAddr().String()) + + // Check IP ban. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + var banned bool + var err error + if b.deps.Cfg.BruteMaxTries > 0 && b.deps.Brute != nil { + banned, err = b.deps.Brute.IsBanned(ctx, clientIP) + if err != nil { + log.Printf("[smtp/inbound] ban check error %s: %v", clientIP, err) + } + } + if banned { + return nil, fmt.Errorf("connection refused") + } + + return &InboundSession{ + deps: b.deps, + clientIP: net.ParseIP(clientIP), + log: func(f string, a ...any) { log.Printf("[smtp/inbound] "+f, a...) }, + }, nil +} + +// InboundSession handles one inbound SMTP connection. +type InboundSession struct { + deps *Deps + clientIP net.IP + from string + fromDomain string + rcpts []string // validated local recipients (email addresses) + rcptUsers []int64 // corresponding user IDs + spfResult spf.Result + log func(string, ...any) +} + +// AuthPlain is not used on port 25; always return error. +func (s *InboundSession) AuthPlain(username, password string) error { + return fmt.Errorf("AUTH not supported on port 25") +} + +func (s *InboundSession) Mail(from string, opts *gosmtp.MailOptions) error { + if from == "" { + // Bounce messages use empty envelope sender — allow. + s.from = "" + s.fromDomain = "" + return nil + } + + addr, err := mail.ParseAddress(from) + if err != nil { + return &gosmtp.SMTPError{Code: 501, EnhancedCode: gosmtp.EnhancedCode{5, 1, 7}, Message: "invalid sender address"} + } + + s.from = addr.Address + at := strings.LastIndex(s.from, "@") + if at < 0 { + return &gosmtp.SMTPError{Code: 501, EnhancedCode: gosmtp.EnhancedCode{5, 1, 7}, Message: "invalid sender domain"} + } + s.fromDomain = strings.ToLower(s.from[at+1:]) + + // SPF check (async, best-effort). + if s.deps.Cfg.SpamCheckSPF && s.clientIP != nil && s.fromDomain != "" { + s.spfResult, _ = spf.Check(s.clientIP, s.fromDomain) + } + + return nil +} + +func (s *InboundSession) Rcpt(to string, opts *gosmtp.RcptOptions) error { + addr, err := mail.ParseAddress(to) + if err != nil { + return &gosmtp.SMTPError{Code: 501, EnhancedCode: gosmtp.EnhancedCode{5, 1, 3}, Message: "invalid recipient"} + } + email := strings.ToLower(addr.Address) + + at := strings.LastIndex(email, "@") + if at < 0 { + return &gosmtp.SMTPError{Code: 501, EnhancedCode: gosmtp.EnhancedCode{5, 1, 3}, Message: "invalid recipient"} + } + domain := email[at+1:] + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Must be a local domain. + local, err := s.deps.DB.IsLocalDomain(ctx, domain) + if err != nil || !local { + return &gosmtp.SMTPError{Code: 550, EnhancedCode: gosmtp.EnhancedCode{5, 1, 2}, Message: "relay access denied"} + } + + // Resolve to a user (handles aliases). + user, err := s.deps.DB.ResolveEmail(ctx, email) + if err != nil { + return &gosmtp.SMTPError{Code: 451, EnhancedCode: gosmtp.EnhancedCode{4, 3, 0}, Message: "temporary lookup error"} + } + if user == nil || !user.Enabled { + return &gosmtp.SMTPError{Code: 550, EnhancedCode: gosmtp.EnhancedCode{5, 1, 1}, Message: "user unknown"} + } + + s.rcpts = append(s.rcpts, email) + s.rcptUsers = append(s.rcptUsers, user.ID) + return nil +} + +func (s *InboundSession) Data(r io.Reader) error { + if len(s.rcpts) == 0 { + return &gosmtp.SMTPError{Code: 503, Message: "no recipients"} + } + + // Read message with size cap (already enforced by go-smtp, but be safe). + raw, err := io.ReadAll(io.LimitReader(r, s.deps.Cfg.MaxMessageSize+1)) + if err != nil { + return fmt.Errorf("read data: %w", err) + } + if int64(len(raw)) > s.deps.Cfg.MaxMessageSize { + return &gosmtp.SMTPError{Code: 552, EnhancedCode: gosmtp.EnhancedCode{5, 3, 4}, Message: "message too large"} + } + + // Parse headers for metadata. + msg, err := mail.ReadMessage(strings.NewReader(string(raw))) + if err != nil { + return &gosmtp.SMTPError{Code: 550, EnhancedCode: gosmtp.EnhancedCode{5, 6, 0}, Message: "malformed message"} + } + + subject := decodeHeader(msg.Header.Get("Subject")) + fromHeader := msg.Header.Get("From") + fromAddr, fromName := parseFromHeader(fromHeader) + if fromAddr == "" && s.from != "" { + fromAddr = s.from + } + msgID := msg.Header.Get("Message-ID") + dateStr := msg.Header.Get("Date") + msgDate, _ := mail.ParseDate(dateStr) + if msgDate.IsZero() { + msgDate = time.Now().UTC() + } + + // DKIM verification. + dkimValid := false + dkimPresent := strings.Contains(string(raw), "DKIM-Signature:") + if dkimPresent { + _, dkimErr := dkim.Verify(raw) + dkimValid = dkimErr == nil + } + + // Spam scoring (per recipient — each has their own Bayesian model). + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // Deliver to each local recipient. + for i, userID := range s.rcptUsers { + rcptEmail := s.rcpts[i] + + // Build spam score params. + params := &spam.Params{ + ClientIP: s.clientIP, + SenderDomain: s.fromDomain, + SPFResult: s.spfResult, + DKIMValid: dkimValid, + DKIMPresent: dkimPresent, + Subject: subject, + FromHeader: fromHeader, + RecipCount: len(s.rcpts), + HasDateHeader: dateStr != "", + HasMsgIDHeader: msgID != "", + } + + spamResult := s.deps.Scorer.Score(ctx, userID, params) + + // Choose target mailbox. + mboxType := models.MailboxInbox + if spamResult.IsSpam { + mboxType = models.MailboxSpam + s.log("message for %s scored %d (spam), delivering to Spam", rcptEmail, spamResult.Total) + } + + mbox, err := s.deps.DB.GetMailboxByType(ctx, userID, mboxType) + if err != nil || mbox == nil { + // Fallback to INBOX. + mbox, err = s.deps.DB.GetMailboxByType(ctx, userID, models.MailboxInbox) + if err != nil || mbox == nil { + s.log("no inbox for user %d (%s): %v", userID, rcptEmail, err) + continue + } + } + + incoming := &storage.IncomingMessage{ + Raw: raw, + FromEmail: fromAddr, + FromName: fromName, + ToList: strings.Join(s.rcpts, ", "), + Subject: subject, + Date: msgDate, + MessageID: msgID, + SpamScore: spamResult.Total, + } + + _, err = s.deps.Store.SaveIncoming(ctx, userID, mbox.ID, incoming) + if err != nil { + s.log("store message for %s: %v", rcptEmail, err) + return &gosmtp.SMTPError{Code: 451, EnhancedCode: gosmtp.EnhancedCode{4, 3, 0}, Message: "storage error"} + } + + s.log("delivered to %s (spam=%v score=%d)", rcptEmail, spamResult.IsSpam, spamResult.Total) + } + + return nil +} + +func (s *InboundSession) Reset() { + s.from = "" + s.fromDomain = "" + s.rcpts = s.rcpts[:0] + s.rcptUsers = s.rcptUsers[:0] + s.spfResult = spf.ResultNone +} + +func (s *InboundSession) Logout() error { return nil } + +// ---- Helpers ---- + +func parseFromHeader(h string) (addr, name string) { + if h == "" { + return "", "" + } + a, err := mail.ParseAddress(h) + if err != nil { + return h, "" + } + return a.Address, a.Name +} + +func decodeHeader(h string) string { + // mime.WordDecoder would be imported from mime package. Keep it simple. + return h +} diff --git a/internal/smtp/queue.go b/internal/smtp/queue.go new file mode 100644 index 0000000..2b605cd --- /dev/null +++ b/internal/smtp/queue.go @@ -0,0 +1,148 @@ +package smtp + +import ( + "context" + "log" + "time" + + "ghb.freebede.com/nahakubuilder/mailgosend/internal/crypto" + "ghb.freebede.com/nahakubuilder/mailgosend/internal/delivery" +) + +// QueueWorker polls the delivery queue and dispatches messages. +type QueueWorker struct { + deps *Deps + interval time.Duration // how often to poll (default 30s) +} + +// NewQueueWorker creates a QueueWorker backed by the given deps. +func NewQueueWorker(deps *Deps) *QueueWorker { + return &QueueWorker{ + deps: deps, + interval: 30 * time.Second, + } +} + +// Run polls the queue until stopCh is closed. +func (w *QueueWorker) Run(stopCh <-chan struct{}) { + log.Println("[queue] worker started") + ticker := time.NewTicker(w.interval) + defer ticker.Stop() + + // Drain immediately on start. + w.drainQueue() + + for { + select { + case <-stopCh: + log.Println("[queue] worker stopped") + return + case <-ticker.C: + w.drainQueue() + } + } +} + +// drainQueue fetches all due entries and delivers them. +func (w *QueueWorker) drainQueue() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + entries, err := w.deps.DB.PeekQueue(ctx, 100) + if err != nil { + log.Printf("[queue] peek error: %v", err) + return + } + if len(entries) == 0 { + return + } + + log.Printf("[queue] %d entries ready for delivery", len(entries)) + + // Derive the global queue decryption key once. + queueKey, err := w.deps.Crypt.DeriveKeyGlobal("queue") + if err != nil { + log.Printf("[queue] derive key: %v", err) + return + } + + for _, entry := range entries { + // Mark in-progress to prevent parallel workers picking the same entry. + nextAttempt := time.Now().Add(5 * time.Minute) // safety: reset on success + if err := w.deps.DB.SetQueueStatus(ctx, entry.ID, "sending", "", &nextAttempt); err != nil { + log.Printf("[queue] mark sending %d: %v", entry.ID, err) + continue + } + + raw, err := crypto.Decrypt(queueKey, entry.RawEnc) + if err != nil { + log.Printf("[queue] decrypt %d: %v", entry.ID, err) + w.markFailed(ctx, entry.ID, entry.FromAddr, entry.ToAddr, "decrypt error: "+err.Error(), true) + continue + } + + ehlo := w.deps.Cfg.SMTPHostname + if ehlo == "" { + ehlo = w.deps.Cfg.Hostname + } + + result := delivery.Deliver(ctx, ehlo, entry.FromAddr, entry.ToAddr, raw) + + if result.SMTPCode == 250 { + // Success. + if err := w.deps.DB.SetQueueStatus(ctx, entry.ID, "delivered", "delivered", nil); err != nil { + log.Printf("[queue] mark delivered %d: %v", entry.ID, err) + } + w.deps.DB.LogDelivery(ctx, entry.ID, //nolint:errcheck + entry.FromAddr, entry.ToAddr, + "delivered", result.SMTPCode, result.Message, result.MXHost) + log.Printf("[queue] delivered %d: %s → %s via %s", entry.ID, entry.FromAddr, entry.ToAddr, result.MXHost) + continue + } + + // Failure. + w.markFailed(ctx, entry.ID, entry.FromAddr, entry.ToAddr, result.Message, result.Perm) + } +} + +// markFailed updates queue status with exponential back-off or marks permanent failure. +func (w *QueueWorker) markFailed(ctx context.Context, queueID int64, from, to, errMsg string, perm bool) { + status := "failed" + if perm { + status = "bounced" + } + + var nextAttempt *time.Time + if !perm { + // Exponential back-off: 5m, 15m, 1h, 4h, 8h (then give up per expires_at). + backoff := w.nextBackoff(ctx, queueID) + t := time.Now().Add(backoff) + nextAttempt = &t + } + + if err := w.deps.DB.SetQueueStatus(ctx, queueID, status, errMsg, nextAttempt); err != nil { + log.Printf("[queue] update status %d: %v", queueID, err) + } + w.deps.DB.LogDelivery(ctx, queueID, from, to, status, 0, errMsg, "") //nolint:errcheck + log.Printf("[queue] %s %d → %s: %s", status, queueID, to, errMsg) +} + +// nextBackoff returns the back-off duration based on attempt count using +// the configured schedule (or a default). +func (w *QueueWorker) nextBackoff(ctx context.Context, queueID int64) time.Duration { + var attempts int + _ = w.deps.DB.SQL().QueryRowContext(ctx, + "SELECT attempts FROM queue WHERE id=?", queueID).Scan(&attempts) + + schedule := w.deps.Cfg.QueueRetryMins + if len(schedule) == 0 { + // Default: 5m, 15m, 60m, 240m, 480m + schedule = []int{5, 15, 60, 240, 480} + } + + idx := attempts + if idx >= len(schedule) { + idx = len(schedule) - 1 + } + return time.Duration(schedule[idx]) * time.Minute +} diff --git a/internal/smtp/server.go b/internal/smtp/server.go new file mode 100644 index 0000000..e30bb35 --- /dev/null +++ b/internal/smtp/server.go @@ -0,0 +1,104 @@ +// Package smtp provides SMTP inbound (port 25) and submission (587/465) servers +// using github.com/emersion/go-smtp as the protocol layer. +package smtp + +import ( + "crypto/tls" + "fmt" + "log" + "time" + + gosmtp "github.com/emersion/go-smtp" + + "ghb.freebede.com/nahakubuilder/mailgosend/internal/auth" + "ghb.freebede.com/nahakubuilder/mailgosend/internal/config" + "ghb.freebede.com/nahakubuilder/mailgosend/internal/crypto" + "ghb.freebede.com/nahakubuilder/mailgosend/internal/db" + "ghb.freebede.com/nahakubuilder/mailgosend/internal/spam" + "ghb.freebede.com/nahakubuilder/mailgosend/internal/storage" +) + +// Deps groups all dependencies shared by SMTP servers. +type Deps struct { + DB *db.DB + Crypt *crypto.Crypto + Store *storage.Store + Scorer *spam.Scorer + Brute *auth.BruteGuard + Cfg *config.Config +} + +// NewInboundServer creates the SMTP server for port 25 (inbound MTA). +// Accepts mail for local domains from any sender. STARTTLS offered but not required. +func NewInboundServer(d *Deps, tlsCfg *tls.Config) *gosmtp.Server { + be := &InboundBackend{deps: d} + + s := gosmtp.NewServer(be) + s.Addr = fmt.Sprintf("%s:%d", d.Cfg.SMTPIface, d.Cfg.SMTPPort) + s.Domain = d.Cfg.SMTPHostname + s.MaxMessageBytes = d.Cfg.MaxMessageSize + s.MaxRecipients = d.Cfg.MaxRcptPer + s.WriteTimeout = 5 * time.Minute + s.ReadTimeout = 5 * time.Minute + s.AllowInsecureAuth = false // AUTH not offered on port 25 + + if tlsCfg != nil { + // Setting TLSConfig enables STARTTLS automatically in go-smtp v0.24+. + s.TLSConfig = tlsCfg + } + + return s +} + +// NewSubmissionServer creates the SMTP server for port 587 (STARTTLS submission). +// Auth required. STARTTLS mandatory before AUTH. +func NewSubmissionServer(d *Deps, tlsCfg *tls.Config) *gosmtp.Server { + be := &SubmissionBackend{deps: d} + + s := gosmtp.NewServer(be) + s.Addr = fmt.Sprintf("%s:%d", d.Cfg.SubmitIface, d.Cfg.SubmitPort) + s.Domain = d.Cfg.SMTPHostname + s.MaxMessageBytes = d.Cfg.MaxMessageSize + s.MaxRecipients = d.Cfg.MaxRcptPer + s.WriteTimeout = 5 * time.Minute + s.ReadTimeout = 5 * time.Minute + s.AllowInsecureAuth = false // auth only after STARTTLS + + if tlsCfg != nil { + s.TLSConfig = tlsCfg + } + + return s +} + +// NewSMTPSServer creates the SMTP server for port 465 (implicit TLS submission). +func NewSMTPSServer(d *Deps, tlsCfg *tls.Config) *gosmtp.Server { + be := &SubmissionBackend{deps: d} + + s := gosmtp.NewServer(be) + s.Addr = fmt.Sprintf("%s:%d", d.Cfg.SMTPIface, d.Cfg.SMTPSPort) + s.Domain = d.Cfg.SMTPHostname + s.MaxMessageBytes = d.Cfg.MaxMessageSize + s.MaxRecipients = d.Cfg.MaxRcptPer + s.WriteTimeout = 5 * time.Minute + s.ReadTimeout = 5 * time.Minute + s.AllowInsecureAuth = false + + if tlsCfg != nil { + s.TLSConfig = tlsCfg + } + + return s +} + +// ListenAndServe starts a server and logs errors. +func ListenAndServe(s *gosmtp.Server, name string) error { + log.Printf("[%s] listening on %s", name, s.Addr) + return s.ListenAndServe() +} + +// ListenAndServeTLS starts a server with implicit TLS (port 465). +func ListenAndServeTLS(s *gosmtp.Server, name string) error { + log.Printf("[%s] listening on %s (TLS)", name, s.Addr) + return s.ListenAndServeTLS() +} diff --git a/internal/smtp/submission.go b/internal/smtp/submission.go new file mode 100644 index 0000000..e0d2ba1 --- /dev/null +++ b/internal/smtp/submission.go @@ -0,0 +1,281 @@ +package smtp + +import ( + "bytes" + "context" + "fmt" + "io" + "log" + "net" + "net/mail" + "strings" + "time" + + gosmtp "github.com/emersion/go-smtp" + + "ghb.freebede.com/nahakubuilder/mailgosend/internal/crypto" + "ghb.freebede.com/nahakubuilder/mailgosend/internal/dkim" + "ghb.freebede.com/nahakubuilder/mailgosend/internal/models" + "ghb.freebede.com/nahakubuilder/mailgosend/internal/storage" +) + +// SubmissionBackend implements gosmtp.Backend for ports 587/465. +// Requires authenticated users. Signs outbound mail with DKIM. Queues for delivery. +type SubmissionBackend struct { + deps *Deps +} + +func (b *SubmissionBackend) NewSession(c *gosmtp.Conn) (gosmtp.Session, error) { + clientIP, _, _ := net.SplitHostPort(c.Conn().RemoteAddr().String()) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + var banned bool + if b.deps.Brute != nil { + banned, _ = b.deps.Brute.IsBanned(ctx, clientIP) + } + if banned { + return nil, fmt.Errorf("connection refused") + } + + return &SubmissionSession{ + deps: b.deps, + clientIP: clientIP, + }, nil +} + +// SubmissionSession handles one authenticated submission connection. +type SubmissionSession struct { + deps *Deps + clientIP string + user *models.User // set after AUTH + from string + rcpts []string +} + +func (s *SubmissionSession) AuthPlain(username, password string) error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + user, err := s.deps.DB.GetUserByEmail(ctx, username) + if err != nil { + log.Printf("[smtp/submission] auth lookup error %s: %v", username, err) + return &gosmtp.SMTPError{Code: 535, Message: "authentication failed"} + } + if user == nil || !user.Enabled { + s.logAttempt(ctx, username, false) + return &gosmtp.SMTPError{Code: 535, Message: "authentication failed"} + } + + if err := crypto.CheckPassword(user.PasswordHash, password); err != nil { + s.logAttempt(ctx, username, false) + log.Printf("[smtp/submission] auth failed for %s from %s", username, s.clientIP) + return &gosmtp.SMTPError{Code: 535, Message: "authentication failed"} + } + + s.user = user + s.deps.DB.UpdateLastLogin(ctx, user.ID) + log.Printf("[smtp/submission] auth OK for %s from %s", username, s.clientIP) + return nil +} + +func (s *SubmissionSession) Mail(from string, opts *gosmtp.MailOptions) error { + if s.user == nil { + return &gosmtp.SMTPError{Code: 530, Message: "authentication required"} + } + + addr, err := mail.ParseAddress(from) + if err != nil { + return &gosmtp.SMTPError{Code: 501, EnhancedCode: gosmtp.EnhancedCode{5, 1, 7}, Message: "invalid sender"} + } + + // Sender must be user's own address or an alias they own. + fromEmail := strings.ToLower(addr.Address) + if !strings.EqualFold(fromEmail, s.user.Email) { + // Check aliases. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + resolved, err := s.deps.DB.ResolveEmail(ctx, fromEmail) + if err != nil || resolved == nil || resolved.ID != s.user.ID { + return &gosmtp.SMTPError{Code: 553, EnhancedCode: gosmtp.EnhancedCode{5, 1, 8}, Message: "sender not owned by authenticated user"} + } + } + + s.from = addr.Address + return nil +} + +func (s *SubmissionSession) Rcpt(to string, opts *gosmtp.RcptOptions) error { + if s.user == nil { + return &gosmtp.SMTPError{Code: 530, Message: "authentication required"} + } + + addr, err := mail.ParseAddress(to) + if err != nil { + return &gosmtp.SMTPError{Code: 501, EnhancedCode: gosmtp.EnhancedCode{5, 1, 3}, Message: "invalid recipient"} + } + + s.rcpts = append(s.rcpts, addr.Address) + return nil +} + +func (s *SubmissionSession) Data(r io.Reader) error { + if s.user == nil { + return &gosmtp.SMTPError{Code: 530, Message: "authentication required"} + } + if len(s.rcpts) == 0 { + return &gosmtp.SMTPError{Code: 503, Message: "no recipients"} + } + + raw, err := io.ReadAll(io.LimitReader(r, s.deps.Cfg.MaxMessageSize+1)) + if err != nil { + return fmt.Errorf("read submission data: %w", err) + } + if int64(len(raw)) > s.deps.Cfg.MaxMessageSize { + return &gosmtp.SMTPError{Code: 552, EnhancedCode: gosmtp.EnhancedCode{5, 3, 4}, Message: "message too large"} + } + + // Parse for basic header validation. + _, err = mail.ReadMessage(bytes.NewReader(raw)) + if err != nil { + return &gosmtp.SMTPError{Code: 550, EnhancedCode: gosmtp.EnhancedCode{5, 6, 0}, Message: "malformed message"} + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // DKIM-sign the message if the sender's domain has keys configured. + senderDomain := domainOf(s.from) + raw = s.signDKIM(ctx, raw, senderDomain) + + msgID := extractMsgID(raw) + + // Queue each recipient for delivery. + // For local recipients we could deliver directly, but queuing is simpler and + // provides a consistent audit trail. + dom, err := s.deps.DB.GetDomain(ctx, senderDomain) + var domainID int64 + if err == nil && dom != nil { + domainID = dom.ID + } + + // Encrypt raw for queue storage using a global (non-user) key. + queueKey, err := s.deps.Crypt.DeriveKeyGlobal("queue") + if err != nil { + return fmt.Errorf("queue key: %w", err) + } + rawEnc, err := crypto.Encrypt(queueKey, raw) + if err != nil { + return fmt.Errorf("encrypt for queue: %w", err) + } + + for _, rcpt := range s.rcpts { + _, err := s.deps.DB.EnqueueMessage(ctx, domainID, s.from, rcpt, msgID, rawEnc, s.deps.Cfg.QueueMaxAgeHours) + if err != nil { + log.Printf("[smtp/submission] enqueue to %s: %v", rcpt, err) + return &gosmtp.SMTPError{Code: 451, EnhancedCode: gosmtp.EnhancedCode{4, 3, 0}, Message: "queue error"} + } + log.Printf("[smtp/submission] queued %s → %s", s.from, rcpt) + } + + // Also save a copy in sender's Sent folder. + s.saveSentCopy(ctx, raw) + + return nil +} + +func (s *SubmissionSession) Reset() { + s.from = "" + s.rcpts = s.rcpts[:0] +} + +func (s *SubmissionSession) Logout() error { return nil } + +// signDKIM signs the message with the sender domain's DKIM key if available. +// Returns the original raw on any error (DKIM is best-effort). +func (s *SubmissionSession) signDKIM(ctx context.Context, raw []byte, senderDomain string) []byte { + dom, err := s.deps.DB.GetDomain(ctx, senderDomain) + if err != nil || dom == nil || dom.DKIMPrivateEnc == nil { + return raw // no key configured + } + + privPEM, err := s.deps.Crypt.DecryptGlobal("dkim", dom.DKIMPrivateEnc) + if err != nil { + log.Printf("[smtp/submission] dkim key decrypt for %s: %v", senderDomain, err) + return raw + } + + signer, err := dkim.NewSigner(string(privPEM), senderDomain, dom.DKIMSelector) + if err != nil { + log.Printf("[smtp/submission] dkim signer for %s: %v", senderDomain, err) + return raw + } + + header, err := signer.Sign(raw) + if err != nil { + log.Printf("[smtp/submission] dkim sign for %s: %v", senderDomain, err) + return raw + } + + // Prepend DKIM-Signature header. + return append([]byte(header+"\r\n"), raw...) +} + +// saveSentCopy stores a copy in the user's Sent mailbox (best-effort). +func (s *SubmissionSession) saveSentCopy(ctx context.Context, raw []byte) { + mbox, err := s.deps.DB.GetMailboxByType(ctx, s.user.ID, models.MailboxSent) + if err != nil || mbox == nil { + return + } + + msg, err := mail.ReadMessage(strings.NewReader(string(raw))) + if err != nil { + return + } + + subject := msg.Header.Get("Subject") + msgDate, _ := mail.ParseDate(msg.Header.Get("Date")) + if msgDate.IsZero() { + msgDate = time.Now().UTC() + } + + incoming := &storage.IncomingMessage{ + Raw: raw, + FromEmail: s.from, + ToList: strings.Join(s.rcpts, ", "), + Subject: subject, + Date: msgDate, + MessageID: extractMsgID(raw), + SpamScore: 0, + } + + if _, err := s.deps.Store.SaveIncoming(ctx, s.user.ID, mbox.ID, incoming); err != nil { + log.Printf("[smtp/submission] save sent copy: %v", err) + } +} + +// ---- helpers ---- + +func (s *SubmissionSession) logAttempt(ctx context.Context, email string, success bool) { + if s.deps.Brute != nil { + s.deps.Brute.RecordAttempt(ctx, s.clientIP, email, success) //nolint:errcheck + } +} + +func domainOf(email string) string { + at := strings.LastIndex(email, "@") + if at < 0 { + return "" + } + return strings.ToLower(email[at+1:]) +} + +func extractMsgID(raw []byte) string { + msg, err := mail.ReadMessage(bytes.NewReader(raw)) + if err != nil { + return "" + } + return msg.Header.Get("Message-ID") +} diff --git a/internal/spam/spam.go b/internal/spam/spam.go new file mode 100644 index 0000000..7ccf00e --- /dev/null +++ b/internal/spam/spam.go @@ -0,0 +1,314 @@ +// Package spam scores inbound messages using static heuristics plus +// optional Bayesian token analysis (per RFC 5965 conventions). +// Score >= threshold → deliver to Spam folder. +package spam + +import ( + "context" + "database/sql" + "fmt" + "math" + "net" + "strings" + "time" + "unicode" + + "ghb.freebede.com/nahakubuilder/mailgosend/internal/db" + "ghb.freebede.com/nahakubuilder/mailgosend/internal/spf" +) + +// Scorer evaluates spam likelihood. +type Scorer struct { + db *db.DB + threshold int + dnsbl []string + checkSPF bool + checkDKIM bool +} + +// Result holds the total spam score and the component breakdown. +type Result struct { + Total int + Reasons []string + IsSpam bool +} + +// Params groups message features for scoring. +type Params struct { + ClientIP net.IP + SenderDomain string + SPFResult spf.Result + DKIMValid bool + DKIMPresent bool + DMARCFail bool + Subject string + FromHeader string + HasHTMLOnly bool // true if no text/plain part + RecipCount int + HasDateHeader bool + HasMsgIDHeader bool + BodyText string // first 1000 bytes of plain text for token analysis +} + +// NewScorer creates a scorer from config values. +func NewScorer(database *db.DB, threshold int, dnsbl []string, checkSPF, checkDKIM bool) *Scorer { + return &Scorer{ + db: database, + threshold: threshold, + dnsbl: dnsbl, + checkSPF: checkSPF, + checkDKIM: checkDKIM, + } +} + +// Score evaluates the message and returns a Result. +func (s *Scorer) Score(ctx context.Context, userID int64, p *Params) *Result { + r := &Result{} + add := func(pts int, reason string) { + r.Total += pts + r.Reasons = append(r.Reasons, fmt.Sprintf("+%d: %s", pts, reason)) + } + + // DNSBL check (async-ish: each lookup gets its own goroutine with timeout) + if p.ClientIP != nil { + hits := s.dnsblCheck(ctx, p.ClientIP) + for _, bl := range hits { + add(5, "DNSBL hit: "+bl) + } + } + + // SPF + if s.checkSPF { + switch p.SPFResult { + case spf.ResultFail: + add(4, "SPF fail") + case spf.ResultSoftFail: + add(2, "SPF softfail") + case spf.ResultNone: + add(1, "SPF none (no record)") + } + } + + // DKIM + if s.checkDKIM { + if !p.DKIMPresent { + add(2, "DKIM absent") + } else if !p.DKIMValid { + add(3, "DKIM invalid signature") + } + } + + // DMARC + if p.DMARCFail { + add(5, "DMARC fail") + } + + // Missing required headers. + if !p.HasDateHeader { + add(1, "missing Date header") + } + if !p.HasMsgIDHeader { + add(1, "missing Message-ID header") + } + + // HTML-only (no text/plain). + if p.HasHTMLOnly { + add(1, "HTML-only body") + } + + // All-caps subject. + if p.Subject != "" && isAllCaps(p.Subject) { + add(1, "all-caps subject") + } + + // Excessive recipients. + if p.RecipCount > 20 { + add(2, fmt.Sprintf("excessive recipients (%d)", p.RecipCount)) + } + + // Bayesian (per-user trained model). + if userID > 0 && p.BodyText != "" { + bayesScore, err := s.bayesScore(ctx, userID, p.BodyText) + if err == nil && bayesScore >= 0.8 { + pts := int((bayesScore - 0.7) * 20) // 0.8→2 pts, 0.9→4 pts, 1.0→6 pts + add(pts, fmt.Sprintf("Bayesian score %.2f", bayesScore)) + } + } + + r.IsSpam = r.Total >= s.threshold + return r +} + +// TrainSpam adds body tokens to the user's spam corpus. +func (s *Scorer) TrainSpam(ctx context.Context, userID int64, body string) error { + return s.trainTokens(ctx, userID, body, true) +} + +// TrainHam adds body tokens to the user's ham corpus. +func (s *Scorer) TrainHam(ctx context.Context, userID int64, body string) error { + return s.trainTokens(ctx, userID, body, false) +} + +// ---- DNSBL ---- + +func (s *Scorer) dnsblCheck(ctx context.Context, ip net.IP) []string { + ipv4 := ip.To4() + if ipv4 == nil { + return nil // DNSBL queries are IPv4-only for now + } + + // Reverse the IP octets: 1.2.3.4 → 4.3.2.1 + reversed := fmt.Sprintf("%d.%d.%d.%d", ipv4[3], ipv4[2], ipv4[1], ipv4[0]) + + type result struct{ bl string } + hits := make(chan result, len(s.dnsbl)) + + timeout, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + for _, bl := range s.dnsbl { + bl := bl + go func() { + query := reversed + "." + bl + addrs, err := net.DefaultResolver.LookupHost(timeout, query) + if err == nil && len(addrs) > 0 { + hits <- result{bl} + } else { + hits <- result{} + } + }() + } + + var matched []string + for range s.dnsbl { + if h := <-hits; h.bl != "" { + matched = append(matched, h.bl) + } + } + return matched +} + +// ---- Bayesian ---- + +func tokenize(body string) []string { + body = strings.ToLower(body) + var tokens []string + seen := make(map[string]struct{}) + words := strings.FieldsFunc(body, func(r rune) bool { + return !unicode.IsLetter(r) && !unicode.IsDigit(r) + }) + for _, w := range words { + if len(w) < 3 || len(w) > 30 { + continue + } + if _, ok := seen[w]; ok { + continue + } + seen[w] = struct{}{} + tokens = append(tokens, w) + if len(tokens) >= 200 { + break + } + } + return tokens +} + +// bayesScore returns the probability [0,1] that the message is spam. +func (s *Scorer) bayesScore(ctx context.Context, userID int64, body string) (float64, error) { + tokens := tokenize(body) + if len(tokens) == 0 { + return 0, nil + } + + // Fetch total spam/ham message counts for this user (proxy: count rows with nonzero counts). + var totalSpam, totalHam int64 + err := s.db.SQL().QueryRowContext(ctx, ` + SELECT COALESCE(SUM(spam_count),0), COALESCE(SUM(ham_count),0) + FROM spam_tokens WHERE user_id=?`, userID).Scan(&totalSpam, &totalHam) + if err != nil || (totalSpam+totalHam) < 50 { + // Not enough training data. + return 0, nil + } + + // Naive Bayes: P(spam|words) ∝ Π P(word|spam) / Π P(word|ham) + // Use log-probabilities to avoid underflow. + var logP float64 + + placeholders := make([]string, len(tokens)) + args := make([]interface{}, len(tokens)+1) + args[0] = userID + for i, tok := range tokens { + placeholders[i] = "?" + args[i+1] = tok + } + + query := fmt.Sprintf(` + SELECT token, spam_count, ham_count FROM spam_tokens + WHERE user_id=? AND token IN (%s)`, strings.Join(placeholders, ",")) + + rows, err := s.db.SQL().QueryContext(ctx, query, args...) + if err != nil { + return 0, err + } + defer rows.Close() + + tokenData := make(map[string][2]int64) + for rows.Next() { + var tok string + var sc, hc int64 + if err := rows.Scan(&tok, &sc, &hc); err != nil { + return 0, err + } + tokenData[tok] = [2]int64{sc, hc} + } + + for _, tok := range tokens { + counts := tokenData[tok] + sc, hc := counts[0], counts[1] + + // Laplace smoothing. + pSpam := float64(sc+1) / float64(totalSpam+2) + pHam := float64(hc+1) / float64(totalHam+2) + + logP += math.Log(pSpam) - math.Log(pHam) + } + + // Convert log-odds back to probability. + prob := 1.0 / (1.0 + math.Exp(-logP)) + return prob, nil +} + +func (s *Scorer) trainTokens(ctx context.Context, userID int64, body string, isSpam bool) error { + tokens := tokenize(body) + for _, tok := range tokens { + var col string + if isSpam { + col = "spam_count" + } else { + col = "ham_count" + } + + _, err := s.db.SQL().ExecContext(ctx, fmt.Sprintf(` + INSERT INTO spam_tokens (user_id, token, %s) + VALUES (?, ?, 1) + ON CONFLICT(user_id, token) DO UPDATE SET %s=%s+1`, col, col, col), + userID, tok) + if err != nil && err != sql.ErrNoRows { + return fmt.Errorf("train token %q: %w", tok, err) + } + } + return nil +} + +func isAllCaps(s string) bool { + hasLetter := false + for _, r := range s { + if unicode.IsLetter(r) { + hasLetter = true + if unicode.IsLower(r) { + return false + } + } + } + return hasLetter +} diff --git a/internal/spf/spf.go b/internal/spf/spf.go new file mode 100644 index 0000000..a15265b --- /dev/null +++ b/internal/spf/spf.go @@ -0,0 +1,200 @@ +// Package spf implements basic SPF (RFC 7208) DNS lookup and policy evaluation. +// Only stdlib net is used for DNS. Lookup limit: 10 DNS mechanisms per spec. +package spf + +import ( + "fmt" + "net" + "strings" +) + +// Result values per RFC 7208 §2.6. +type Result int + +const ( + ResultNone Result = iota // no SPF record + ResultNeutral // ?all + ResultPass // sender is permitted + ResultFail // sender is not permitted (hard fail) + ResultSoftFail // ~all (likely spam) + ResultTempError // transient DNS error + ResultPermError // permanent SPF parse error +) + +func (r Result) String() string { + return [...]string{"none", "neutral", "pass", "fail", "softfail", "temperror", "permerror"}[r] +} + +// Check performs SPF evaluation for the given sender IP and envelope-from domain. +// Returns the SPF result and an explanation string. +func Check(clientIP net.IP, senderDomain string) (Result, string) { + if senderDomain == "" { + return ResultNone, "empty sender domain" + } + + record, err := fetchSPF(senderDomain) + if err != nil { + return ResultTempError, fmt.Sprintf("SPF DNS lookup %s: %v", senderDomain, err) + } + if record == "" { + return ResultNone, "no SPF record for " + senderDomain + } + + result, msg := evaluate(clientIP, senderDomain, record, 0) + return result, msg +} + +// fetchSPF queries TXT records for the domain and returns the first SPF record. +func fetchSPF(domain string) (string, error) { + txts, err := net.LookupTXT(domain) + if err != nil { + // Treat NXDOMAIN as "no record" not an error. + if dnsErr, ok := err.(*net.DNSError); ok && dnsErr.IsNotFound { + return "", nil + } + return "", err + } + for _, txt := range txts { + txt = strings.TrimSpace(txt) + if strings.HasPrefix(txt, "v=spf1") { + return txt, nil + } + } + return "", nil +} + +// evaluate parses and applies SPF mechanisms. dnsLookups tracks the lookup count. +func evaluate(ip net.IP, domain, record string, dnsLookups int) (Result, string) { + parts := strings.Fields(record) + if len(parts) == 0 || !strings.EqualFold(parts[0], "v=spf1") { + return ResultPermError, "invalid SPF record: " + record + } + + for _, term := range parts[1:] { + if dnsLookups > 10 { + return ResultPermError, "exceeded 10 DNS lookups" + } + + // Qualifier prefix: +pass -fail ~softfail ?neutral + qualifier := ResultPass + switch term[0] { + case '+': + qualifier = ResultPass + term = term[1:] + case '-': + qualifier = ResultFail + term = term[1:] + case '~': + qualifier = ResultSoftFail + term = term[1:] + case '?': + qualifier = ResultNeutral + term = term[1:] + } + + lower := strings.ToLower(term) + + switch { + case lower == "all": + return qualifier, "matched 'all'" + + case strings.HasPrefix(lower, "ip4:"): + cidr := term[4:] + if !strings.Contains(cidr, "/") { + cidr += "/32" + } + _, network, err := net.ParseCIDR(cidr) + if err != nil { + continue + } + if ip.To4() != nil && network.Contains(ip) { + return qualifier, fmt.Sprintf("matched ip4:%s", term[4:]) + } + + case strings.HasPrefix(lower, "ip6:"): + cidr := term[4:] + if !strings.Contains(cidr, "/") { + cidr += "/128" + } + _, network, err := net.ParseCIDR(cidr) + if err != nil { + continue + } + if ip.To4() == nil && network.Contains(ip) { + return qualifier, fmt.Sprintf("matched ip6:%s", term[4:]) + } + + case lower == "a" || strings.HasPrefix(lower, "a:") || strings.HasPrefix(lower, "a/"): + dnsLookups++ + checkDomain := domain + if strings.HasPrefix(lower, "a:") { + checkDomain = term[2:] + } + addrs, err := net.LookupHost(checkDomain) + if err == nil { + for _, addr := range addrs { + if net.ParseIP(addr).Equal(ip) { + return qualifier, fmt.Sprintf("matched a:%s", checkDomain) + } + } + } + + case lower == "mx" || strings.HasPrefix(lower, "mx:") || strings.HasPrefix(lower, "mx/"): + dnsLookups++ + checkDomain := domain + if strings.HasPrefix(lower, "mx:") { + checkDomain = term[3:] + } + mxs, err := net.LookupMX(checkDomain) + if err == nil { + for _, mx := range mxs { + dnsLookups++ + addrs, err := net.LookupHost(mx.Host) + if err == nil { + for _, addr := range addrs { + if net.ParseIP(addr).Equal(ip) { + return qualifier, fmt.Sprintf("matched mx:%s", checkDomain) + } + } + } + } + } + + case strings.HasPrefix(lower, "include:"): + includeDomain := term[8:] + dnsLookups++ + includeRecord, err := fetchSPF(includeDomain) + if err != nil { + return ResultTempError, fmt.Sprintf("include %s DNS error: %v", includeDomain, err) + } + if includeRecord == "" { + return ResultPermError, "include domain has no SPF: " + includeDomain + } + subResult, subMsg := evaluate(ip, includeDomain, includeRecord, dnsLookups) + if subResult == ResultPass { + return qualifier, fmt.Sprintf("include:%s → %s", includeDomain, subMsg) + } + + case strings.HasPrefix(lower, "redirect="): + redirectDomain := term[9:] + dnsLookups++ + redirectRecord, err := fetchSPF(redirectDomain) + if err != nil { + return ResultTempError, fmt.Sprintf("redirect %s DNS error: %v", redirectDomain, err) + } + if redirectRecord == "" { + return ResultNone, "redirect domain has no SPF: " + redirectDomain + } + return evaluate(ip, redirectDomain, redirectRecord, dnsLookups) + + case strings.HasPrefix(lower, "exp="): + // Explanation modifier — ignore. + + default: + // Unknown mechanism — ignore per spec. + } + } + + // No mechanism matched. + return ResultNeutral, "no mechanism matched" +} diff --git a/internal/storage/storage.go b/internal/storage/storage.go new file mode 100644 index 0000000..19c18be --- /dev/null +++ b/internal/storage/storage.go @@ -0,0 +1,358 @@ +// Package storage provides encrypted message persistence for both SQLite (db) +// and filesystem (fs) backends. All body and raw data is encrypted with +// AES-256-GCM before being written. +package storage + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "fmt" + "io" + "mime" + "mime/multipart" + "mime/quotedprintable" + "net/mail" + "os" + "path/filepath" + "strings" + "time" + + "ghb.freebede.com/nahakubuilder/mailgosend/internal/crypto" + "ghb.freebede.com/nahakubuilder/mailgosend/internal/db" + "ghb.freebede.com/nahakubuilder/mailgosend/internal/models" +) + +// Store is the encrypted message store. +type Store struct { + db *db.DB + crypt *crypto.Crypto + backend string // "db" | "fs" + fsPath string +} + +// IncomingMessage is the raw inbound message plus parsed envelope fields. +type IncomingMessage struct { + Raw []byte // full RFC822 + FromEmail string + FromName string + ToList string + CCList string + BCCList string + ReplyTo string + Subject string + Date time.Time + MessageID string + SpamScore int +} + +type parsedAttachment struct { + Filename string + ContentType string + Data []byte + ContentID string + Inline bool + MIMEPath string +} + +// New validates the backend and returns a ready Store. +func New(database *db.DB, crypt *crypto.Crypto, backend, fsPath string) (*Store, error) { + if backend != "db" && backend != "fs" { + return nil, fmt.Errorf("storage: unknown backend %q (want \"db\" or \"fs\")", backend) + } + if backend == "fs" { + if fsPath == "" { + return nil, fmt.Errorf("storage: fsPath required for fs backend") + } + if err := os.MkdirAll(fsPath, 0700); err != nil { + return nil, fmt.Errorf("storage: create fs dir: %w", err) + } + } + return &Store{db: database, crypt: crypt, backend: backend, fsPath: fsPath}, nil +} + +// SaveIncoming encrypts and persists an incoming message plus its attachments. +// Returns the new message row ID. +func (s *Store) SaveIncoming(ctx context.Context, userID, mailboxID int64, msg *IncomingMessage) (int64, error) { + uid, err := s.db.NextUID(ctx, mailboxID) + if err != nil { + return 0, fmt.Errorf("storage: next uid: %w", err) + } + + bodyText, bodyHTML, attachments := parseMIME(msg.Raw) + + key, err := s.crypt.DeriveKey("messages", userID) + if err != nil { + return 0, fmt.Errorf("storage: derive key: %w", err) + } + + bodyTextEnc, err := crypto.Encrypt(key, []byte(bodyText)) + if err != nil { + return 0, fmt.Errorf("storage: encrypt body text: %w", err) + } + + bodyHTMLEnc, err := crypto.Encrypt(key, []byte(bodyHTML)) + if err != nil { + return 0, fmt.Errorf("storage: encrypt body html: %w", err) + } + + rawEnc, err := crypto.Encrypt(key, msg.Raw) + if err != nil { + return 0, fmt.Errorf("storage: encrypt raw: %w", err) + } + + insert := &db.MessageInsert{ + MailboxID: mailboxID, + UID: uid, + MessageID: msg.MessageID, + Subject: msg.Subject, + FromEmail: msg.FromEmail, + FromName: msg.FromName, + ToList: msg.ToList, + CCList: msg.CCList, + BCCList: msg.BCCList, + ReplyTo: msg.ReplyTo, + Date: msg.Date, + BodyTextEnc: bodyTextEnc, + BodyHTMLEnc: bodyHTMLEnc, + RawEnc: rawEnc, + SizeBytes: int64(len(msg.Raw)), + HasAttachment: len(attachments) > 0, + IsRead: false, + IsStarred: false, + IsDraft: false, + Flags: "", + SpamScore: msg.SpamScore, + } + + messageID, err := s.db.InsertMessage(ctx, insert) + if err != nil { + return 0, fmt.Errorf("storage: insert message: %w", err) + } + + if err := s.db.UpdateUsedBytes(ctx, userID, int64(len(msg.Raw))); err != nil { + return 0, fmt.Errorf("storage: update used bytes: %w", err) + } + + for _, att := range attachments { + attEnc, err := crypto.Encrypt(key, att.Data) + if err != nil { + return 0, fmt.Errorf("storage: encrypt attachment: %w", err) + } + + var dataPath string + var dataEnc []byte + + if s.backend == "fs" { + h := sha256.Sum256(att.Data) + name := hex.EncodeToString(h[:]) + ".att" + p := filepath.Join(s.fsPath, name) + if err := os.WriteFile(p, attEnc, 0600); err != nil { + return 0, fmt.Errorf("storage: write attachment file: %w", err) + } + dataPath = p + } else { + dataEnc = attEnc + } + + attInsert := &db.AttachmentInsert{ + MessageID: messageID, + Filename: att.Filename, + ContentType: att.ContentType, + SizeBytes: int64(len(att.Data)), + DataEnc: dataEnc, + DataPath: dataPath, + ContentID: att.ContentID, + Inline: att.Inline, + MIMEPath: att.MIMEPath, + } + if _, err := s.db.InsertAttachment(ctx, attInsert); err != nil { + return 0, fmt.Errorf("storage: insert attachment: %w", err) + } + } + + return messageID, nil +} + +// GetRaw returns the decrypted RFC822 blob for a message. +func (s *Store) GetRaw(ctx context.Context, userID, messageID int64) ([]byte, error) { + rawEnc, err := s.db.GetMessageRaw(ctx, messageID) + if err != nil { + return nil, fmt.Errorf("storage: get raw enc: %w", err) + } + if rawEnc == nil { + return nil, fmt.Errorf("storage: message %d not found", messageID) + } + + key, err := s.crypt.DeriveKey("messages", userID) + if err != nil { + return nil, fmt.Errorf("storage: derive key: %w", err) + } + + plain, err := crypto.Decrypt(key, rawEnc) + if err != nil { + return nil, fmt.Errorf("storage: decrypt raw: %w", err) + } + return plain, 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)) + if err != nil { + return "", "", nil + } + ct := m.Header.Get("Content-Type") + body, err := io.ReadAll(m.Body) + if err != nil { + return "", "", nil + } + walkPart(ct, m.Header.Get("Content-Transfer-Encoding"), + m.Header.Get("Content-Disposition"), m.Header.Get("Content-ID"), + body, "1", &bodyText, &bodyHTML, &attachments) + return bodyText, bodyHTML, attachments +} + +// walkPart recursively processes one MIME part. +func walkPart( + ctHeader, cteHeader, cdHeader, cidHeader string, + data []byte, + mimePath string, + bodyText, bodyHTML *string, + attachments *[]parsedAttachment, +) { + mediaType, params, err := mime.ParseMediaType(ctHeader) + if err != nil { + // Treat unparseable as text/plain. + mediaType = "text/plain" + params = map[string]string{} + } + + // Decode transfer encoding first if this is a leaf part. + if !strings.HasPrefix(mediaType, "multipart/") { + data = decodeCTE(cteHeader, data) + } + + switch { + case mediaType == "text/plain" && !isAttachment(cdHeader): + *bodyText = string(data) + + case mediaType == "text/html" && !isAttachment(cdHeader): + *bodyHTML = string(data) + + case strings.HasPrefix(mediaType, "multipart/"): + boundary, ok := params["boundary"] + if !ok { + return + } + mr := multipart.NewReader(bytes.NewReader(data), boundary) + partIdx := 1 + for { + part, err := mr.NextPart() + if err != nil { + break + } + partData, err := io.ReadAll(part) + if err != nil { + part.Close() + break + } + part.Close() + + subCT := part.Header.Get("Content-Type") + if subCT == "" { + subCT = "text/plain" + } + subCTE := part.Header.Get("Content-Transfer-Encoding") + subCD := part.Header.Get("Content-Disposition") + subCID := part.Header.Get("Content-ID") + subPath := fmt.Sprintf("%s.%d", mimePath, partIdx) + + walkPart(subCT, subCTE, subCD, subCID, partData, subPath, + bodyText, bodyHTML, attachments) + partIdx++ + } + + default: + // Treat as attachment. + filename := filenameFrom(cdHeader, ctHeader) + inline := strings.HasPrefix(strings.ToLower(cdHeader), "inline") + cleanCID := strings.Trim(cidHeader, "<>") + *attachments = append(*attachments, parsedAttachment{ + Filename: filename, + ContentType: mediaType, + Data: data, + ContentID: cleanCID, + Inline: inline, + MIMEPath: mimePath, + }) + } +} + +// decodeCTE applies quoted-printable or base64 decoding based on the +// Content-Transfer-Encoding header value. +func decodeCTE(cte string, data []byte) []byte { + switch strings.ToLower(strings.TrimSpace(cte)) { + case "quoted-printable": + decoded, err := io.ReadAll(quotedprintable.NewReader(bytes.NewReader(data))) + if err != nil { + return data + } + return decoded + case "base64": + // Strip whitespace — base64 in email is line-wrapped. + clean := strings.Map(func(r rune) rune { + if r == '\r' || r == '\n' || r == ' ' || r == '\t' { + return -1 + } + return r + }, string(data)) + decoded, err := base64.StdEncoding.DecodeString(clean) + if err != nil { + // Try raw/URL encoding as fallback. + decoded, err = base64.RawStdEncoding.DecodeString(clean) + if err != nil { + return data + } + } + return decoded + default: + return data + } +} + +// isAttachment returns true when the Content-Disposition header signals attachment. +func isAttachment(cd string) bool { + if cd == "" { + return false + } + disp, _, _ := mime.ParseMediaType(cd) + return strings.EqualFold(disp, "attachment") +} + +// filenameFrom extracts a filename from Content-Disposition, falling back to +// the name= param of Content-Type. +func filenameFrom(cd, ct string) string { + if cd != "" { + _, params, err := mime.ParseMediaType(cd) + if err == nil { + if name, ok := params["filename"]; ok && name != "" { + return filepath.Base(name) + } + } + } + if ct != "" { + _, params, err := mime.ParseMediaType(ct) + if err == nil { + if name, ok := params["name"]; ok && name != "" { + return filepath.Base(name) + } + } + } + return "attachment" +} + +// Ensure models import is used (Subject field type reference avoids import pruning). +var _ = models.MailboxInbox diff --git a/internal/tls/acme.go b/internal/tls/acme.go new file mode 100644 index 0000000..fb69a5f --- /dev/null +++ b/internal/tls/acme.go @@ -0,0 +1,350 @@ +package tls + +import ( + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "encoding/json" + "encoding/pem" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/go-acme/lego/v4/certcrypto" + "github.com/go-acme/lego/v4/certificate" + "github.com/go-acme/lego/v4/challenge" + "github.com/go-acme/lego/v4/challenge/http01" + "github.com/go-acme/lego/v4/lego" + "github.com/go-acme/lego/v4/providers/dns/cloudflare" + "github.com/go-acme/lego/v4/providers/dns/digitalocean" + "github.com/go-acme/lego/v4/providers/dns/hetzner" + "github.com/go-acme/lego/v4/providers/dns/route53" + "github.com/go-acme/lego/v4/registration" +) + +// ACME manages Let's Encrypt certificate issuance and renewal. +type ACME struct { + cfg ACMEConfig + client *lego.Client + account *acmeAccount + cacheDir string +} + +// ACMEConfig holds all ACME-related settings from app config. +type ACMEConfig struct { + Email string + CacheDir string + Staging bool + Domains []string // e.g. ["example.com", "*.example.com"] + Mode string // "dns01" | "http01" + DNSProvider string // "cloudflare" | "route53" | "digitalocean" | "hetzner" | ... +} + +// acmeAccount implements lego's registration.User interface. +type acmeAccount struct { + Email string `json:"email"` + Registration *registration.Resource `json:"registration"` + key crypto.PrivateKey + keyPEM []byte +} + +func (a *acmeAccount) GetEmail() string { return a.Email } +func (a *acmeAccount) GetRegistration() *registration.Resource { return a.Registration } +func (a *acmeAccount) GetPrivateKey() crypto.PrivateKey { return a.key } + +// NewACME initialises the ACME client. Does not yet obtain a cert. +// Call ObtainOrRenew() to get/refresh the certificate. +func NewACME(cfg ACMEConfig) (*ACME, error) { + if cfg.Email == "" { + return nil, fmt.Errorf("ACME_EMAIL required for Let's Encrypt") + } + if len(cfg.Domains) == 0 { + return nil, fmt.Errorf("ACME_DOMAINS must not be empty") + } + if err := os.MkdirAll(cfg.CacheDir, 0700); err != nil { + return nil, fmt.Errorf("acme cache dir: %w", err) + } + + a := &ACME{cfg: cfg, cacheDir: cfg.CacheDir} + + acc, err := a.loadOrCreateAccount() + if err != nil { + return nil, fmt.Errorf("acme account: %w", err) + } + a.account = acc + + legoConfig := lego.NewConfig(a.account) + if cfg.Staging { + legoConfig.CADirURL = lego.LEDirectoryStaging + } else { + legoConfig.CADirURL = lego.LEDirectoryProduction + } + legoConfig.Certificate.KeyType = certcrypto.RSA2048 + + client, err := lego.NewClient(legoConfig) + if err != nil { + return nil, fmt.Errorf("lego client: %w", err) + } + a.client = client + + // Configure challenge provider. + if err := a.setProvider(); err != nil { + return nil, err + } + + // Register account if not already registered. + if acc.Registration == nil { + reg, err := client.Registration.Register(registration.RegisterOptions{ + TermsOfServiceAgreed: true, + }) + if err != nil { + return nil, fmt.Errorf("acme register: %w", err) + } + a.account.Registration = reg + if err := a.saveAccount(); err != nil { + return nil, fmt.Errorf("save acme account: %w", err) + } + } + + return a, nil +} + +// setProvider configures the ACME challenge based on cfg.Mode and cfg.DNSProvider. +func (a *ACME) setProvider() error { + switch a.cfg.Mode { + case "http01": + // Lego manages an ephemeral HTTP server on port 80. + return a.client.Challenge.SetHTTP01Provider(http01.NewProviderServer("", "80")) + + case "dns01": + provider, err := a.buildDNSProvider() + if err != nil { + return fmt.Errorf("dns01 provider %q: %w", a.cfg.DNSProvider, err) + } + return a.client.Challenge.SetDNS01Provider(provider) + + default: + return fmt.Errorf("unknown ACME mode %q (want dns01 or http01)", a.cfg.Mode) + } +} + +// buildDNSProvider returns the lego DNS provider for cfg.DNSProvider. +// Credentials are read from env vars set by config.exportProviderEnv(). +func (a *ACME) buildDNSProvider() (challenge.Provider, error) { + switch a.cfg.DNSProvider { + case "cloudflare": + return cloudflare.NewDNSProvider() + + case "route53": + return route53.NewDNSProvider() + + case "digitalocean": + return digitalocean.NewDNSProvider() + + case "hetzner": + return hetzner.NewDNSProvider() + + default: + // Generic: lego supports 90+ providers; if the user sets the correct + // env vars and the provider name matches a lego provider, it WILL work + // through the lego plugin system. For unlisted providers, document that + // the user must set env vars matching the lego provider docs. + return nil, fmt.Errorf( + "provider %q not built-in; set lego env vars and use a supported provider name.\n"+ + "Built-in: cloudflare, route53, digitalocean, hetzner.\n"+ + "Full list: https://go-acme.github.io/lego/dns/", + a.cfg.DNSProvider, + ) + } +} + +// ObtainOrRenew returns a valid *tls.Certificate, obtaining or renewing as needed. +// Uses cached cert on disk if it has >30 days remaining. +func (a *ACME) ObtainOrRenew() (*tls.Certificate, error) { + cached, err := a.loadCachedCert() + if err == nil && cached != nil { + return cached, nil + } + + return a.obtain() +} + +// obtain requests a new certificate from Let's Encrypt. +func (a *ACME) obtain() (*tls.Certificate, error) { + req := certificate.ObtainRequest{ + Domains: a.cfg.Domains, + Bundle: true, // include full chain in cert PEM + } + + res, err := a.client.Certificate.Obtain(req) + if err != nil { + return nil, fmt.Errorf("acme obtain %v: %w", a.cfg.Domains, err) + } + + if err := a.saveCert(res); err != nil { + // Non-fatal: cert is in memory, just can't persist. + fmt.Printf("[acme] WARNING: could not cache cert: %v\n", err) + } + + return parseCert(res.Certificate, res.PrivateKey) +} + +// RenewalLoop blocks forever, renewing the cert 30 days before expiry. +// manager.UpdateCert() is called on each renewal to hot-swap the TLS config. +// Call as a goroutine. stopCh receives when it should quit. +func (a *ACME) RenewalLoop(manager *Manager, stopCh <-chan struct{}) { + ticker := time.NewTicker(12 * time.Hour) + defer ticker.Stop() + + for { + select { + case <-stopCh: + return + case <-ticker.C: + cached, err := a.loadCachedCert() + if err != nil || cached == nil { + // No cert or corrupt cache — re-obtain. + cert, err := a.obtain() + if err != nil { + fmt.Printf("[acme] renewal obtain error: %v\n", err) + continue + } + manager.UpdateCert(cert) + fmt.Printf("[acme] cert renewed for %v\n", a.cfg.Domains) + } + // loadCachedCert returns nil if cert expires in < 30 days → triggers re-obtain above. + } + } +} + +// ---- Cert persistence ---- + +func (a *ACME) certPath() string { return filepath.Join(a.cacheDir, "cert.pem") } +func (a *ACME) keyPath() string { return filepath.Join(a.cacheDir, "key.pem") } +func (a *ACME) accPath() string { return filepath.Join(a.cacheDir, "account.json") } +func (a *ACME) accKeyPath() string { return filepath.Join(a.cacheDir, "account.key") } + +func (a *ACME) saveCert(res *certificate.Resource) error { + if err := os.WriteFile(a.certPath(), res.Certificate, 0600); err != nil { + return err + } + return os.WriteFile(a.keyPath(), res.PrivateKey, 0600) +} + +// loadCachedCert returns the cached cert if it has >30 days remaining, nil otherwise. +func (a *ACME) loadCachedCert() (*tls.Certificate, error) { + certPEM, err := os.ReadFile(a.certPath()) + if err != nil { + return nil, err + } + keyPEM, err := os.ReadFile(a.keyPath()) + if err != nil { + return nil, err + } + + cert, err := parseCert(certPEM, keyPEM) + if err != nil { + return nil, err + } + + // Check expiry — renew if <30 days left. + if cert.Leaf != nil && time.Until(cert.Leaf.NotAfter) < 30*24*time.Hour { + return nil, nil // signal: needs renewal + } + // Parse leaf if not already parsed. + if cert.Leaf == nil { + leaf, err := x509.ParseCertificate(cert.Certificate[0]) + if err == nil && time.Until(leaf.NotAfter) < 30*24*time.Hour { + return nil, nil + } + } + + return cert, nil +} + +func parseCert(certPEM, keyPEM []byte) (*tls.Certificate, error) { + cert, err := tls.X509KeyPair(certPEM, keyPEM) + if err != nil { + return nil, fmt.Errorf("parse cert: %w", err) + } + // Pre-parse leaf for expiry checks. + if len(cert.Certificate) > 0 { + leaf, err := x509.ParseCertificate(cert.Certificate[0]) + if err == nil { + cert.Leaf = leaf + } + } + return &cert, nil +} + +// ---- Account persistence ---- + +func (a *ACME) loadOrCreateAccount() (*acmeAccount, error) { + // Try loading existing account. + accData, errAcc := os.ReadFile(a.accPath()) + keyData, errKey := os.ReadFile(a.accKeyPath()) + + if errAcc == nil && errKey == nil { + acc := &acmeAccount{} + if err := json.Unmarshal(accData, acc); err != nil { + return nil, fmt.Errorf("parse account: %w", err) + } + key, err := parseECKey(keyData) + if err != nil { + return nil, fmt.Errorf("parse account key: %w", err) + } + acc.key = key + acc.keyPEM = keyData + return acc, nil + } + + // Generate new account key. + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, fmt.Errorf("gen account key: %w", err) + } + keyPEM, err := encodeECKey(key) + if err != nil { + return nil, err + } + + acc := &acmeAccount{ + Email: a.cfg.Email, + key: key, + keyPEM: keyPEM, + } + return acc, nil +} + +func (a *ACME) saveAccount() error { + data, err := json.MarshalIndent(a.account, "", " ") + if err != nil { + return err + } + if err := os.WriteFile(a.accPath(), data, 0600); err != nil { + return err + } + return os.WriteFile(a.accKeyPath(), a.account.keyPEM, 0600) +} + +// ---- EC key helpers ---- + +func encodeECKey(key *ecdsa.PrivateKey) ([]byte, error) { + der, err := x509.MarshalECPrivateKey(key) + if err != nil { + return nil, fmt.Errorf("marshal ec key: %w", err) + } + return pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der}), nil +} + +func parseECKey(pemData []byte) (*ecdsa.PrivateKey, error) { + block, _ := pem.Decode(pemData) + if block == nil { + return nil, fmt.Errorf("no PEM block in account key") + } + return x509.ParseECPrivateKey(block.Bytes) +} diff --git a/internal/tls/tls.go b/internal/tls/tls.go new file mode 100644 index 0000000..47c5ca5 --- /dev/null +++ b/internal/tls/tls.go @@ -0,0 +1,158 @@ +// Package tls provides TLS configuration builders, cert loading, +// and hot-reload on SIGHUP. +package tls + +import ( + "crypto/tls" + "fmt" + "net" + "os" + "sync" + "sync/atomic" + "unsafe" +) + +// MinVersion enforced across all listeners. +const MinVersion = tls.VersionTLS12 + +// cipherSuites is the approved list — ECDHE + AEAD only. +var cipherSuites = []uint16{ + tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, + tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, + tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256, + tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256, + tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, + tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, +} + +// Manager holds the current TLS certificate and rebuilds tls.Config on demand. +// Thread-safe: cert updates are atomic. +type Manager struct { + mu sync.Mutex + certFile string + keyFile string + cert unsafe.Pointer // *tls.Certificate, accessed atomically +} + +// NewManager creates a Manager that loads cert + key from disk. +// Call Reload() after obtaining/renewing a cert. +func NewManager(certFile, keyFile string) (*Manager, error) { + m := &Manager{certFile: certFile, keyFile: keyFile} + if certFile != "" && keyFile != "" { + if err := m.Reload(); err != nil { + return nil, err + } + } + return m, nil +} + +// Reload re-reads the cert and key from disk atomically. +// Safe to call from a SIGHUP handler or ACME renewal goroutine. +func (m *Manager) Reload() error { + m.mu.Lock() + defer m.mu.Unlock() + + if m.certFile == "" || m.keyFile == "" { + return nil + } + + cert, err := tls.LoadX509KeyPair(m.certFile, m.keyFile) + if err != nil { + return fmt.Errorf("tls load cert %s / %s: %w", m.certFile, m.keyFile, err) + } + atomic.StorePointer(&m.cert, unsafe.Pointer(&cert)) + return nil +} + +// UpdateCert replaces the in-memory certificate (used by ACME after renewal). +func (m *Manager) UpdateCert(cert *tls.Certificate) { + atomic.StorePointer(&m.cert, unsafe.Pointer(cert)) +} + +// GetCertificate implements tls.Config.GetCertificate — returns the current cert +// regardless of SNI (single-domain or wildcard usage). +func (m *Manager) GetCertificate(_ *tls.ClientHelloInfo) (*tls.Certificate, error) { + p := atomic.LoadPointer(&m.cert) + if p == nil { + return nil, fmt.Errorf("no TLS certificate loaded") + } + return (*tls.Certificate)(p), nil +} + +// Config returns a *tls.Config with secure defaults and GetCertificate set. +func (m *Manager) Config() *tls.Config { + cfg := &tls.Config{ + GetCertificate: m.GetCertificate, + MinVersion: MinVersion, + CipherSuites: cipherSuites, + // TLS 1.3 cipher suites are configured by the Go runtime automatically. + } + return cfg +} + +// ConfigForSMTP returns a tls.Config for SMTP servers. +// SMTP requires setting ServerName in ClientHello so SNI works; +// the GetCertificate callback handles the lookup. +func (m *Manager) ConfigForSMTP() *tls.Config { + cfg := m.Config() + // SMTP clients often don't send SNI, so always return our cert. + return cfg +} + +// ---- Manual cert loader (TLS_MODE=manual without hot-reload) ---- + +// LoadCert loads a certificate pair from disk and returns a ready tls.Config. +// Used for services where the Manager is not needed (e.g. one-off TLS dial). +func LoadCert(certFile, keyFile string) (*tls.Config, error) { + cert, err := tls.LoadX509KeyPair(certFile, keyFile) + if err != nil { + return nil, fmt.Errorf("load cert %s: %w", certFile, err) + } + return &tls.Config{ + Certificates: []tls.Certificate{cert}, + MinVersion: MinVersion, + CipherSuites: cipherSuites, + }, nil +} + +// ---- TLS_MODE=off placeholder ---- + +// IsOff returns true if the given mode string means no TLS. +func IsOff(mode string) bool { return mode == "off" || mode == "" } + +// ---- Per-service cert resolution ---- + +// ServiceCert describes the cert files for one service (SMTP, IMAP, web). +type ServiceCert struct { + CertFile string + KeyFile string +} + +// Resolve returns the cert pair to use for a service: +// service-specific override → global fallback → empty (ACME handles it). +func Resolve(svcCert, svcKey, globalCert, globalKey string) ServiceCert { + if svcCert != "" && svcKey != "" { + return ServiceCert{CertFile: svcCert, KeyFile: svcKey} + } + if globalCert != "" && globalKey != "" { + return ServiceCert{CertFile: globalCert, KeyFile: globalKey} + } + return ServiceCert{} // ACME-managed +} + +// FileExists returns true if both files exist. +func (sc ServiceCert) FileExists() bool { + if sc.CertFile == "" || sc.KeyFile == "" { + return false + } + _, errC := os.Stat(sc.CertFile) + _, errK := os.Stat(sc.KeyFile) + return errC == nil && errK == nil +} + +// ---- TLS listener ---- + +// NewListener wraps a net.Listener with TLS using the provided config. +func NewListener(ln net.Listener, cfg *tls.Config) net.Listener { + return tls.NewListener(ln, cfg) +} diff --git a/web/admin/static/css/.gitkeep b/web/admin/static/css/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/web/admin/static/js/.gitkeep b/web/admin/static/js/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/web/admin/templates/.gitkeep b/web/admin/templates/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/web/admin/templates/placeholder b/web/admin/templates/placeholder new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/web/admin/templates/placeholder @@ -0,0 +1 @@ + diff --git a/web/client/static/css/.gitkeep b/web/client/static/css/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/web/client/static/js/.gitkeep b/web/client/static/js/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/web/client/templates/.gitkeep b/web/client/templates/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/web/client/templates/placeholder b/web/client/templates/placeholder new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/web/client/templates/placeholder @@ -0,0 +1 @@ + diff --git a/webfs.go b/webfs.go new file mode 100644 index 0000000..b8cee60 --- /dev/null +++ b/webfs.go @@ -0,0 +1,9 @@ +// Package assets embeds all web assets (templates + static files) into the binary. +package assets + +import "embed" + +// FS contains all files under the web/ directory. +// +//go:embed web +var FS embed.FS