initial
This commit is contained in:
@@ -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/<provider>/ 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_<KEY>` 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 <max>`, `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.<domain>` 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/<user>/` 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/<user>/` 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*
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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_<KEY> 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_<KEY>=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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
`
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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, ";")
|
||||
}
|
||||
@@ -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.<domain> 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
Reference in New Issue
Block a user