mirror of
https://github.com/ghostersk/gowebmail.git
synced 2026-04-17 16:46:01 +01:00
Compare commits
6 Commits
latest
...
68c81ebaed
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68c81ebaed | ||
|
|
f122d29282 | ||
|
|
d6e987f66c | ||
|
|
ef85246806 | ||
|
|
948e111cc6 | ||
|
|
ac43075d62 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -4,3 +4,5 @@ data/*.db-shm
|
|||||||
data/*db-wal
|
data/*db-wal
|
||||||
data/gowebmail.conf
|
data/gowebmail.conf
|
||||||
data/*.txt
|
data/*.txt
|
||||||
|
gowebmail-devplan.md
|
||||||
|
testrun/
|
||||||
243
README.md
243
README.md
@@ -1,110 +1,215 @@
|
|||||||
# GoWebMail
|
# GoWebMail
|
||||||
|
|
||||||
A self-hosted, encrypted web email client written entirely in Go. Supports Gmail and Outlook via OAuth2, plus any standard IMAP/SMTP provider.
|
A self-hosted, multi-user, encrypted web email client written entirely in Go. Supports Gmail and Outlook via OAuth2, plus any standard IMAP/SMTP provider (Fastmail, ProtonMail Bridge, iCloud, etc.).
|
||||||
|
|
||||||
# Notes:
|
> **Notes:**
|
||||||
- work still in progress ( gmail and hotmail email not tested yet, just prepared the app for it)
|
> - Work still in progress (Gmail and Outlook OAuth2 not yet fully tested in production)
|
||||||
- AI is involved in making this work, as I do not have the skill and time to do it on my own
|
> - AI-assisted development — suggestions and contributions very welcome!
|
||||||
- looking for any advice and suggestions to improve it!
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
|
### Email
|
||||||
- **Unified inbox** — view emails from all connected accounts in one stream
|
- **Unified inbox** — view emails from all connected accounts in one stream
|
||||||
- **Gmail & Outlook OAuth2** — modern, token-based auth (no storing raw passwords for these providers)
|
- **Gmail & Outlook OAuth2** — modern token-based auth (no raw passwords stored for these providers)
|
||||||
- **IMAP/SMTP** — connect any provider (ProtonMail Bridge, Fastmail, iCloud, etc.)
|
- **IMAP/SMTP** — connect any standard provider with username/password credentials
|
||||||
- **AES-256-GCM encryption** — all email content encrypted at rest in SQLite
|
- **Auto-detect mail settings** — MX lookup + common port patterns to pre-fill IMAP/SMTP config
|
||||||
|
- **Send / Reply / Forward / Draft** — full compose workflow with floating draggable compose window
|
||||||
|
- **Attachments** — view inline images, download individual files or all at once
|
||||||
|
- **Forward as attachment** — attach original `.eml` as `message/rfc822`
|
||||||
|
- **Folder navigation** — per-account folder/label browsing with right-click context menu
|
||||||
|
- **Full-text search** — across all accounts and folders locally (no server-side search required)
|
||||||
|
- **Message filtering** — unread only, starred, has attachment, from/to filters
|
||||||
|
- **Bulk operations** — multi-select with Ctrl+click / Shift+range; bulk mark read/delete
|
||||||
|
- **Drag-and-drop** — move messages to folders; attach files in compose
|
||||||
|
- **Starred messages** — virtual folder across all accounts
|
||||||
|
- **EML download** — download raw message as `.eml`
|
||||||
|
- **Raw headers view** — fetches full RFC 822 headers from IMAP on demand
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- **AES-256-GCM encryption** — all email content, credentials and OAuth tokens encrypted at rest in SQLite (field-level, not whole-DB encryption)
|
||||||
- **bcrypt password hashing** — GoWebMail account passwords hashed with cost=12
|
- **bcrypt password hashing** — GoWebMail account passwords hashed with cost=12
|
||||||
- **Send / Reply / Forward** — full compose workflow
|
- **TOTP MFA** — custom implementation, no external library; ±60s window for clock skew tolerance
|
||||||
- **Folder navigation** — per-account folder/label browsing
|
- **Brute-force IP blocking** — auto-blocks IPs after configurable failed login attempts (default: 5 attempts in 30 min → 12h ban); permanent blocks supported
|
||||||
- **Full-text search** — across all accounts locally
|
- **Geo-blocking** — deny or allow-only access by country via ip-api.com (no API key needed); 24h in-memory cache
|
||||||
- **Dark-themed web UI** — clean, keyboard-shortcut-friendly interface
|
- **Per-user IP access rules** — each user configures their own IP allow-list or brute-force bypass list independently of global rules
|
||||||
<img width="1213" height="848" alt="image" src="https://github.com/user-attachments/assets/955eda04-e358-4779-80e7-0a9b299ac110" />
|
- **Security alert emails** — notifies the targeted user when their account is brute-forced; supports STARTTLS, implicit TLS, and plain relay
|
||||||
<img width="1261" height="921" alt="image" src="https://github.com/user-attachments/assets/40ee58e8-6c4b-45c3-974d-98cc8ccc45a5" />
|
- **DNS rebinding protection** — `HostCheckMiddleware` rejects requests with unexpected `Host` headers
|
||||||
<img width="1153" height="907" alt="image" src="https://github.com/user-attachments/assets/ebc92335-f6b7-46ed-b9a2-84512f70e1b2" />
|
- **Security headers** — CSP, X-Frame-Options, Referrer-Policy, Permissions-Policy, X-XSS-Protection on all responses
|
||||||
<img width="551" height="669" alt="image" src="https://github.com/user-attachments/assets/412585c0-434a-4177-ab04-7db69da9d08a" />
|
- **Sandboxed HTML email rendering** — emails rendered in CSP-sandboxed `<iframe>`; external links require confirmation before opening
|
||||||
|
- **Remote image blocking** — images blocked by default with per-sender whitelist
|
||||||
|
- **Styled HTTP error pages** — 403/404/405 served as themed pages matching the app (not plain browser defaults)
|
||||||
|
|
||||||
|
### Admin Panel (`/admin`)
|
||||||
|
- **User management** — create, edit (role, active status, password reset), delete users
|
||||||
|
- **Audit log** — paginated, filterable event log for all security-relevant actions
|
||||||
|
- **Security dashboard** — live blocked IPs table with attacker country, login attempt history per IP, manual block/unblock controls
|
||||||
|
- **App settings** — all runtime configuration editable from the UI; changes are written back to `gowebmail.conf`
|
||||||
|
- **MFA disable** — admin can disable MFA for any locked-out user
|
||||||
|
- **Password reset** — admin can reset any user's password from the web UI
|
||||||
|
|
||||||
|
### User Settings
|
||||||
|
- **Profile** — change username and email address (password confirmation required)
|
||||||
|
- **Password change** — change own password
|
||||||
|
- **TOTP MFA setup** — enable/disable via QR code scan
|
||||||
|
- **Sync interval** — per-user background sync frequency
|
||||||
|
- **Compose popup mode** — toggle floating window vs. browser popup window
|
||||||
|
- **Per-user IP rules** — three modes: `disabled` (global rules apply), `brute_skip` (listed IPs bypass lockout counter), `allow_only` (only listed IPs may log in to this account)
|
||||||
|
|
||||||
|
### UI
|
||||||
|
- **Dark-themed SPA** — clean, responsive vanilla-JS single-page app; no JavaScript framework
|
||||||
|
- **OS / browser notifications** — permission requested once; slide-in toast + OS push notification on new mail
|
||||||
|
- **Folder context menu** — right-click: sync, enable/disable sync, hide, empty trash/spam, move contents, delete
|
||||||
|
- **Compose window** — draggable floating window or browser popup; tag-input for To/CC/BCC; auto-saves draft every 60s
|
||||||
|
|
||||||
|
<img width="1213" height="848" alt="Inbox view" src="https://github.com/user-attachments/assets/955eda04-e358-4779-80e7-0a9b299ac110" />
|
||||||
|
<img width="1261" height="921" alt="Compose" src="https://github.com/user-attachments/assets/40ee58e8-6c4b-45c3-974d-98cc8ccc45a5" />
|
||||||
|
<img width="1153" height="907" alt="Admin Security panel" src="https://github.com/user-attachments/assets/ebc92335-f6b7-46ed-b9a2-84512f70e1b2" />
|
||||||
|
<img width="551" height="669" alt="Settings" src="https://github.com/user-attachments/assets/412585c0-434a-4177-ab04-7db69da9d08a" />
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
### Option 1: Build executable
|
### Option 1: Build executable
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Clone / copy the project
|
|
||||||
git clone https://github.com/ghostersk/gowebmail && cd gowebmail
|
git clone https://github.com/ghostersk/gowebmail && cd gowebmail
|
||||||
go build -o gowebmail ./cmd/server
|
go build -o gowebmail ./cmd/server
|
||||||
# if you want smaller exe ( strip down debuginformation):
|
# Smaller binary (strip debug info):
|
||||||
go build -ldflags="-s -w" -o gowebmail ./cmd/server
|
go build -ldflags="-s -w" -o gowebmail ./cmd/server
|
||||||
./gowebmail
|
./gowebmail
|
||||||
```
|
```
|
||||||
|
|
||||||
Visit http://localhost:8080, default login admin/admin, register an account, then connect your email.
|
Visit `http://localhost:8080`. Default login: `admin` / `admin`.
|
||||||
|
|
||||||
### Option 2: Run directly
|
### Option 2: Run directly
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/ghostersk/gowebmail && cd gowebmail
|
git clone https://github.com/ghostersk/gowebmail && cd gowebmail
|
||||||
go run ./cmd/server/main.go
|
go run ./cmd/server/main.go
|
||||||
# check ./data/gowebmail.conf what gets generated on first run if not exists, update as needed.
|
# Check ./data/gowebmail.conf on first run — update as needed, then restart.
|
||||||
# then restart the app
|
|
||||||
```
|
```
|
||||||
### Reset Admin password, MFA
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Admin CLI Commands
|
||||||
|
|
||||||
|
All commands open the database directly without starting the HTTP server. They require the same environment variables or `data/gowebmail.conf` as the server.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# List all admins with MFA status
|
# List all admin accounts with MFA status
|
||||||
./gowebmail --list-admin
|
./gowebmail --list-admin
|
||||||
|
|
||||||
# USERNAME EMAIL MFA
|
# USERNAME EMAIL MFA
|
||||||
# -------- ----- ---
|
# -------- ----- ---
|
||||||
# admin admin@example.com ON
|
# admin admin@example.com ON
|
||||||
|
|
||||||
# Reset an admin's password (min 8 chars)
|
# Reset an admin's password (minimum 8 characters)
|
||||||
./gowebmail --pw admin "NewSecurePass123"
|
./gowebmail --pw admin "NewSecurePass123"
|
||||||
|
|
||||||
# Disable MFA so a locked-out admin can log in again
|
# Disable MFA for a locked-out admin
|
||||||
./gowebmail --mfa-off admin
|
./gowebmail --mfa-off admin
|
||||||
|
|
||||||
|
# List all currently blocked IPs
|
||||||
|
# Shows: IP, username attempted, attempt count, blocked-at, expiry, time remaining
|
||||||
|
./gowebmail --blocklist
|
||||||
|
|
||||||
|
# IP USERNAME USED TRIES BLOCKED AT EXPIRES REMAINING
|
||||||
|
# -- ------------- ----- ---------- ------- ---------
|
||||||
|
# 1.2.3.4 bob 7 2026-03-08 14:22:01 2026-03-09 02:22:01 11h 34m
|
||||||
|
# 5.6.7.8 admin 12 2026-03-07 09:10:00 permanent ∞ (manual unblock)
|
||||||
|
|
||||||
|
# Remove a block immediately
|
||||||
|
./gowebmail --unblock 1.2.3.4
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **Note:** `--list-admin`, `--pw`, and `--mfa-off` only work on admin accounts. Regular user management is done through the web UI at `/admin`. `--blocklist` and `--unblock` are particularly useful if you have locked yourself out.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
On first run, `data/gowebmail.conf` is auto-generated with all defaults and inline comments. All keys can also be set via environment variables. The Admin → Settings UI can edit and save most values at runtime, writing changes back to `gowebmail.conf`.
|
||||||
|
|
||||||
|
### Core
|
||||||
|
|
||||||
|
| Key | Default | Description |
|
||||||
|
|-----|---------|-------------|
|
||||||
|
| `HOSTNAME` | `localhost` | Public hostname for BaseURL construction and Host header validation |
|
||||||
|
| `LISTEN_ADDR` | `:8080` | Bind address |
|
||||||
|
| `SECURE_COOKIE` | `false` | Set `true` when running behind HTTPS |
|
||||||
|
| `TRUSTED_PROXIES` | _(blank)_ | Comma-separated IPs/CIDRs allowed to set `X-Forwarded-For` |
|
||||||
|
| `ENCRYPTION_KEY` | _(auto-generated)_ | AES-256 key — **back this up immediately**; losing it makes the DB unreadable |
|
||||||
|
| `SESSION_MAX_AGE` | `604800` | Session lifetime in seconds (default: 7 days) |
|
||||||
|
|
||||||
|
### Brute Force Protection
|
||||||
|
|
||||||
|
| Key | Default | Description |
|
||||||
|
|-----|---------|-------------|
|
||||||
|
| `BRUTE_ENABLED` | `true` | Enable automatic IP blocking on failed logins |
|
||||||
|
| `BRUTE_MAX_ATTEMPTS` | `5` | Failed login attempts before ban triggers |
|
||||||
|
| `BRUTE_WINDOW_MINUTES` | `30` | Rolling window in minutes for counting failures |
|
||||||
|
| `BRUTE_BAN_HOURS` | `12` | Ban duration in hours; `0` = permanent block (manual unblock required) |
|
||||||
|
| `BRUTE_WHITELIST_IPS` | _(blank)_ | Comma-separated IPs never blocked — **add your own IP here** |
|
||||||
|
|
||||||
|
### Geo Blocking
|
||||||
|
|
||||||
|
| Key | Default | Description |
|
||||||
|
|-----|---------|-------------|
|
||||||
|
| `GEO_BLOCK_COUNTRIES` | _(blank)_ | ISO country codes to deny outright (e.g. `CN,RU,KP`). Evaluated first — takes priority over allow list. |
|
||||||
|
| `GEO_ALLOW_COUNTRIES` | _(blank)_ | ISO country codes to allow exclusively (e.g. `SK,CZ,DE`). All other countries are denied. |
|
||||||
|
|
||||||
|
Geo lookups use [ip-api.com](http://ip-api.com) (free tier, no API key, ~45 req/min limit). Results are cached in-memory for 24 hours. Private/loopback IPs always bypass geo checks.
|
||||||
|
|
||||||
|
### Security Notification Emails
|
||||||
|
|
||||||
|
| Key | Default | Description |
|
||||||
|
|-----|---------|-------------|
|
||||||
|
| `NOTIFY_ENABLED` | `true` | Send alert email to user when a brute-force attack targets their account |
|
||||||
|
| `NOTIFY_SMTP_HOST` | _(blank)_ | SMTP hostname for sending alert emails |
|
||||||
|
| `NOTIFY_SMTP_PORT` | `587` | `465` = implicit TLS · `587` = STARTTLS · `25` = plain relay (no auth) |
|
||||||
|
| `NOTIFY_FROM` | _(blank)_ | Sender address (e.g. `security@yourdomain.com`) |
|
||||||
|
| `NOTIFY_USER` | _(blank)_ | SMTP auth username — leave blank for unauthenticated relay |
|
||||||
|
| `NOTIFY_PASS` | _(blank)_ | SMTP auth password — leave blank for unauthenticated relay |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Setting up OAuth2
|
## Setting up OAuth2
|
||||||
|
|
||||||
### Gmail
|
### Gmail
|
||||||
|
|
||||||
1. Go to [Google Cloud Console](https://console.cloud.google.com/) → New project
|
1. Go to [Google Cloud Console](https://console.cloud.google.com/) → New project
|
||||||
2. Enable **Gmail API**
|
2. Enable **Gmail API**
|
||||||
3. Create **OAuth 2.0 Client ID** (Web application)
|
3. Create **OAuth 2.0 Client ID** (Web application type)
|
||||||
4. Add Authorized redirect URI: `http://localhost:8080/auth/gmail/callback`
|
4. Add Authorized redirect URI: `<BASE_URL>/auth/gmail/callback`
|
||||||
5. Set env vars: `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`
|
5. Add scope `https://mail.google.com/` (required for full IMAP access)
|
||||||
|
6. Add test users while the app is in "Testing" mode
|
||||||
> **Important:** In the Google Cloud Console, add the scope `https://mail.google.com/` to allow IMAP access. You'll also need to add test users while in "Testing" mode.
|
7. Set in config: `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`
|
||||||
|
|
||||||
### Outlook / Microsoft 365
|
### Outlook / Microsoft 365
|
||||||
|
|
||||||
1. Go to [Azure portal](https://portal.azure.com/) → App registrations → New registration
|
1. Go to [Azure portal](https://portal.azure.com/) → App registrations → New registration
|
||||||
2. Set redirect URI: `http://localhost:8080/auth/outlook/callback`
|
2. Set redirect URI: `<BASE_URL>/auth/outlook/callback`
|
||||||
3. Under API permissions, add:
|
3. Under API permissions add:
|
||||||
- `https://outlook.office.com/IMAP.AccessAsUser.All`
|
- `https://outlook.office.com/IMAP.AccessAsUser.All`
|
||||||
- `https://outlook.office.com/SMTP.Send`
|
- `https://outlook.office.com/SMTP.Send`
|
||||||
- `offline_access`, `openid`, `profile`, `email`
|
- `offline_access`, `openid`, `profile`, `email`
|
||||||
4. Create a Client secret
|
4. Create a Client secret under Certificates & secrets
|
||||||
5. Set env vars: `MICROSOFT_CLIENT_ID`, `MICROSOFT_CLIENT_SECRET`, `MICROSOFT_TENANT_ID`
|
5. Set in config: `MICROSOFT_CLIENT_ID`, `MICROSOFT_CLIENT_SECRET`, `MICROSOFT_TENANT_ID`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Security Notes
|
## Security Notes
|
||||||
|
|
||||||
- **ENCRYPTION_KEY** is critical — back it up. Without it, the encrypted SQLite database is unreadable.
|
- **`ENCRYPTION_KEY` is critical** — back it up. Without it the encrypted SQLite database is permanently unreadable.
|
||||||
- Email content (subject, from, to, body) is encrypted at rest using AES-256-GCM.
|
- Email content (subject, from, to, body), IMAP/SMTP credentials, and OAuth tokens are all encrypted at rest with AES-256-GCM at the field level.
|
||||||
- OAuth2 tokens are stored encrypted in the database.
|
- GoWebMail user passwords are bcrypt hashed (cost=12). Session tokens are 32-byte `crypto/rand` hex strings.
|
||||||
- Passwords for GoWebMail accounts are bcrypt hashed (cost=12).
|
- All HTTP responses include security headers (CSP, X-Frame-Options, Referrer-Policy, etc.).
|
||||||
- All HTTP responses include security headers (CSP, X-Frame-Options, etc.).
|
- HTML emails render in a CSP-sandboxed `<iframe>` — external links trigger a confirmation dialog before opening in a new tab.
|
||||||
- In production, run behind HTTPS (nginx/Caddy) and set `SECURE_COOKIE=true`.
|
- In production, run behind a reverse proxy with HTTPS (nginx / Caddy) and set `SECURE_COOKIE=true`.
|
||||||
|
- Add your own IP to `BRUTE_WHITELIST_IPS` to avoid ever locking yourself out. If it does happen, use `./gowebmail --unblock <ip>` — no server restart needed.
|
||||||
|
|
||||||
## Dependencies
|
---
|
||||||
|
|
||||||
```
|
|
||||||
github.com/emersion/go-imap IMAP client
|
|
||||||
github.com/emersion/go-smtp SMTP client
|
|
||||||
github.com/emersion/go-message MIME parsing
|
|
||||||
github.com/gorilla/mux HTTP routing
|
|
||||||
github.com/mattn/go-sqlite3 SQLite driver (CGO)
|
|
||||||
golang.org/x/crypto bcrypt
|
|
||||||
golang.org/x/oauth2 OAuth2 + Google/Microsoft endpoints
|
|
||||||
```
|
|
||||||
|
|
||||||
## Building for Production
|
## Building for Production
|
||||||
|
|
||||||
@@ -112,7 +217,41 @@ golang.org/x/oauth2 OAuth2 + Google/Microsoft endpoints
|
|||||||
CGO_ENABLED=1 GOOS=linux go build -ldflags="-s -w" -o gowebmail ./cmd/server
|
CGO_ENABLED=1 GOOS=linux go build -ldflags="-s -w" -o gowebmail ./cmd/server
|
||||||
```
|
```
|
||||||
|
|
||||||
CGO is required by `go-sqlite3`. Cross-compilation requires a C cross-compiler.
|
CGO is required by `mattn/go-sqlite3`. Cross-compilation for other platforms requires a C cross-compiler (or use `zig cc` as a drop-in).
|
||||||
|
|
||||||
|
### Docker (example)
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
FROM golang:1.22-alpine AS builder
|
||||||
|
RUN apk add gcc musl-dev sqlite-dev
|
||||||
|
WORKDIR /app
|
||||||
|
COPY . .
|
||||||
|
RUN CGO_ENABLED=1 go build -ldflags="-s -w" -o gowebmail ./cmd/server
|
||||||
|
|
||||||
|
FROM alpine:latest
|
||||||
|
RUN apk add --no-cache sqlite-libs ca-certificates
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=builder /app/gowebmail .
|
||||||
|
EXPOSE 8080
|
||||||
|
CMD ["./gowebmail"]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
| Package | Purpose |
|
||||||
|
|---------|---------|
|
||||||
|
| `github.com/emersion/go-imap v1.2.1` | IMAP client |
|
||||||
|
| `github.com/emersion/go-smtp` | SMTP client |
|
||||||
|
| `github.com/emersion/go-message` | MIME parsing |
|
||||||
|
| `github.com/gorilla/mux` | HTTP router |
|
||||||
|
| `github.com/mattn/go-sqlite3` | SQLite driver (CGO required) |
|
||||||
|
| `golang.org/x/crypto` | bcrypt |
|
||||||
|
| `golang.org/x/oauth2` | OAuth2 + Google/Microsoft endpoints |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
This project is licensed under the [GPL-3.0 license](LICENSE).
|
This project is licensed under the [GPL-3.0 license](LICENSE).
|
||||||
@@ -47,6 +47,16 @@ func main() {
|
|||||||
}
|
}
|
||||||
runDisableMFA(args[1])
|
runDisableMFA(args[1])
|
||||||
return
|
return
|
||||||
|
case "--blocklist":
|
||||||
|
runBlockList()
|
||||||
|
return
|
||||||
|
case "--unblock":
|
||||||
|
if len(args) < 2 {
|
||||||
|
fmt.Fprintln(os.Stderr, "Usage: gowebmail --unblock <ip>")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
runUnblock(args[1])
|
||||||
|
return
|
||||||
case "--help", "-h":
|
case "--help", "-h":
|
||||||
printHelp()
|
printHelp()
|
||||||
return
|
return
|
||||||
@@ -85,6 +95,14 @@ func main() {
|
|||||||
r.Use(middleware.CORS)
|
r.Use(middleware.CORS)
|
||||||
r.Use(cfg.HostCheckMiddleware)
|
r.Use(cfg.HostCheckMiddleware)
|
||||||
|
|
||||||
|
// Custom error handlers for non-API paths
|
||||||
|
r.NotFoundHandler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
middleware.ServeErrorPage(w, req, http.StatusNotFound, "Page Not Found", "The page you're looking for doesn't exist or has been moved.")
|
||||||
|
})
|
||||||
|
r.MethodNotAllowedHandler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
middleware.ServeErrorPage(w, req, http.StatusMethodNotAllowed, "Method Not Allowed", "This request method is not supported for this URL.")
|
||||||
|
})
|
||||||
|
|
||||||
// Static files
|
// Static files
|
||||||
r.PathPrefix("/static/").Handler(
|
r.PathPrefix("/static/").Handler(
|
||||||
http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))),
|
http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))),
|
||||||
@@ -103,7 +121,7 @@ func main() {
|
|||||||
// Public auth routes
|
// Public auth routes
|
||||||
auth := r.PathPrefix("/auth").Subrouter()
|
auth := r.PathPrefix("/auth").Subrouter()
|
||||||
auth.HandleFunc("/login", h.Auth.ShowLogin).Methods("GET")
|
auth.HandleFunc("/login", h.Auth.ShowLogin).Methods("GET")
|
||||||
auth.HandleFunc("/login", h.Auth.Login).Methods("POST")
|
auth.Handle("/login", middleware.BruteForceProtect(database, cfg, http.HandlerFunc(h.Auth.Login))).Methods("POST")
|
||||||
auth.HandleFunc("/logout", h.Auth.Logout).Methods("POST")
|
auth.HandleFunc("/logout", h.Auth.Logout).Methods("POST")
|
||||||
|
|
||||||
// MFA (session exists but mfa_verified=0)
|
// MFA (session exists but mfa_verified=0)
|
||||||
@@ -133,6 +151,7 @@ func main() {
|
|||||||
adminUI.HandleFunc("/", h.Admin.ShowAdmin).Methods("GET")
|
adminUI.HandleFunc("/", h.Admin.ShowAdmin).Methods("GET")
|
||||||
adminUI.HandleFunc("/settings", h.Admin.ShowAdmin).Methods("GET")
|
adminUI.HandleFunc("/settings", h.Admin.ShowAdmin).Methods("GET")
|
||||||
adminUI.HandleFunc("/audit", h.Admin.ShowAdmin).Methods("GET")
|
adminUI.HandleFunc("/audit", h.Admin.ShowAdmin).Methods("GET")
|
||||||
|
adminUI.HandleFunc("/security", h.Admin.ShowAdmin).Methods("GET")
|
||||||
|
|
||||||
// API
|
// API
|
||||||
api := r.PathPrefix("/api").Subrouter()
|
api := r.PathPrefix("/api").Subrouter()
|
||||||
@@ -141,10 +160,13 @@ func main() {
|
|||||||
|
|
||||||
// Profile / auth
|
// Profile / auth
|
||||||
api.HandleFunc("/me", h.Auth.Me).Methods("GET")
|
api.HandleFunc("/me", h.Auth.Me).Methods("GET")
|
||||||
|
api.HandleFunc("/profile", h.Auth.UpdateProfile).Methods("PUT")
|
||||||
api.HandleFunc("/change-password", h.Auth.ChangePassword).Methods("POST")
|
api.HandleFunc("/change-password", h.Auth.ChangePassword).Methods("POST")
|
||||||
api.HandleFunc("/mfa/setup", h.Auth.MFASetupBegin).Methods("POST")
|
api.HandleFunc("/mfa/setup", h.Auth.MFASetupBegin).Methods("POST")
|
||||||
api.HandleFunc("/mfa/confirm", h.Auth.MFASetupConfirm).Methods("POST")
|
api.HandleFunc("/mfa/confirm", h.Auth.MFASetupConfirm).Methods("POST")
|
||||||
api.HandleFunc("/mfa/disable", h.Auth.MFADisable).Methods("POST")
|
api.HandleFunc("/mfa/disable", h.Auth.MFADisable).Methods("POST")
|
||||||
|
api.HandleFunc("/ip-rules", h.Auth.GetUserIPRule).Methods("GET")
|
||||||
|
api.HandleFunc("/ip-rules", h.Auth.SetUserIPRule).Methods("PUT")
|
||||||
|
|
||||||
// Providers (which OAuth providers are configured)
|
// Providers (which OAuth providers are configured)
|
||||||
api.HandleFunc("/providers", h.API.GetProviders).Methods("GET")
|
api.HandleFunc("/providers", h.API.GetProviders).Methods("GET")
|
||||||
@@ -169,6 +191,8 @@ func main() {
|
|||||||
api.HandleFunc("/messages/{id:[0-9]+}/move", h.API.MoveMessage).Methods("PUT")
|
api.HandleFunc("/messages/{id:[0-9]+}/move", h.API.MoveMessage).Methods("PUT")
|
||||||
api.HandleFunc("/messages/{id:[0-9]+}/headers", h.API.GetMessageHeaders).Methods("GET")
|
api.HandleFunc("/messages/{id:[0-9]+}/headers", h.API.GetMessageHeaders).Methods("GET")
|
||||||
api.HandleFunc("/messages/{id:[0-9]+}/download.eml", h.API.DownloadEML).Methods("GET")
|
api.HandleFunc("/messages/{id:[0-9]+}/download.eml", h.API.DownloadEML).Methods("GET")
|
||||||
|
api.HandleFunc("/messages/{id:[0-9]+}/attachments", h.API.ListAttachments).Methods("GET")
|
||||||
|
api.HandleFunc("/messages/{id:[0-9]+}/attachments/{att_id:[0-9]+}", h.API.DownloadAttachment).Methods("GET")
|
||||||
api.HandleFunc("/messages/{id:[0-9]+}", h.API.DeleteMessage).Methods("DELETE")
|
api.HandleFunc("/messages/{id:[0-9]+}", h.API.DeleteMessage).Methods("DELETE")
|
||||||
api.HandleFunc("/messages/starred", h.API.StarredMessages).Methods("GET")
|
api.HandleFunc("/messages/starred", h.API.StarredMessages).Methods("GET")
|
||||||
|
|
||||||
@@ -180,6 +204,8 @@ func main() {
|
|||||||
api.HandleFunc("/send", h.API.SendMessage).Methods("POST")
|
api.HandleFunc("/send", h.API.SendMessage).Methods("POST")
|
||||||
api.HandleFunc("/reply", h.API.ReplyMessage).Methods("POST")
|
api.HandleFunc("/reply", h.API.ReplyMessage).Methods("POST")
|
||||||
api.HandleFunc("/forward", h.API.ForwardMessage).Methods("POST")
|
api.HandleFunc("/forward", h.API.ForwardMessage).Methods("POST")
|
||||||
|
api.HandleFunc("/forward-attachment", h.API.ForwardAsAttachment).Methods("POST")
|
||||||
|
api.HandleFunc("/draft", h.API.SaveDraft).Methods("POST")
|
||||||
|
|
||||||
// Folders
|
// Folders
|
||||||
api.HandleFunc("/folders", h.API.ListFolders).Methods("GET")
|
api.HandleFunc("/folders", h.API.ListFolders).Methods("GET")
|
||||||
@@ -189,6 +215,7 @@ func main() {
|
|||||||
api.HandleFunc("/folders/{id:[0-9]+}/count", h.API.CountFolderMessages).Methods("GET")
|
api.HandleFunc("/folders/{id:[0-9]+}/count", h.API.CountFolderMessages).Methods("GET")
|
||||||
api.HandleFunc("/folders/{id:[0-9]+}/move-to/{toId:[0-9]+}", h.API.MoveFolderContents).Methods("POST")
|
api.HandleFunc("/folders/{id:[0-9]+}/move-to/{toId:[0-9]+}", h.API.MoveFolderContents).Methods("POST")
|
||||||
api.HandleFunc("/folders/{id:[0-9]+}/empty", h.API.EmptyFolder).Methods("POST")
|
api.HandleFunc("/folders/{id:[0-9]+}/empty", h.API.EmptyFolder).Methods("POST")
|
||||||
|
api.HandleFunc("/folders/{id:[0-9]+}/mark-all-read", h.API.MarkFolderAllRead).Methods("POST")
|
||||||
api.HandleFunc("/folders/{id:[0-9]+}", h.API.DeleteFolder).Methods("DELETE")
|
api.HandleFunc("/folders/{id:[0-9]+}", h.API.DeleteFolder).Methods("DELETE")
|
||||||
api.HandleFunc("/accounts/{account_id:[0-9]+}/enable-all-sync", h.API.EnableAllFolderSync).Methods("POST")
|
api.HandleFunc("/accounts/{account_id:[0-9]+}/enable-all-sync", h.API.EnableAllFolderSync).Methods("POST")
|
||||||
api.HandleFunc("/poll", h.API.PollUnread).Methods("GET")
|
api.HandleFunc("/poll", h.API.PollUnread).Methods("GET")
|
||||||
@@ -213,6 +240,19 @@ func main() {
|
|||||||
adminAPI.HandleFunc("/audit", h.Admin.ListAuditLogs).Methods("GET")
|
adminAPI.HandleFunc("/audit", h.Admin.ListAuditLogs).Methods("GET")
|
||||||
adminAPI.HandleFunc("/settings", h.Admin.GetSettings).Methods("GET")
|
adminAPI.HandleFunc("/settings", h.Admin.GetSettings).Methods("GET")
|
||||||
adminAPI.HandleFunc("/settings", h.Admin.SetSettings).Methods("PUT")
|
adminAPI.HandleFunc("/settings", h.Admin.SetSettings).Methods("PUT")
|
||||||
|
adminAPI.HandleFunc("/ip-blocks", h.Admin.ListIPBlocks).Methods("GET")
|
||||||
|
adminAPI.HandleFunc("/ip-blocks", h.Admin.AddIPBlock).Methods("POST")
|
||||||
|
adminAPI.HandleFunc("/ip-blocks/{ip}", h.Admin.RemoveIPBlock).Methods("DELETE")
|
||||||
|
adminAPI.HandleFunc("/login-attempts", h.Admin.ListLoginAttempts).Methods("GET")
|
||||||
|
|
||||||
|
// Periodically purge expired IP blocks
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(1 * time.Hour)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for range ticker.C {
|
||||||
|
database.PurgeExpiredBlocks()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: cfg.ListenAddr,
|
Addr: cfg.ListenAddr,
|
||||||
@@ -305,21 +345,88 @@ func runDisableMFA(username string) {
|
|||||||
fmt.Printf("MFA disabled for admin '%s'. They can now log in with password only.\n", username)
|
fmt.Printf("MFA disabled for admin '%s'. They can now log in with password only.\n", username)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runBlockList() {
|
||||||
|
database, close := openDB()
|
||||||
|
defer close()
|
||||||
|
|
||||||
|
blocks, err := database.ListIPBlocksWithUsername()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if len(blocks) == 0 {
|
||||||
|
fmt.Println("No blocked IPs.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("%-18s %-20s %-5s %-22s %-22s %s\n",
|
||||||
|
"IP", "USERNAME USED", "TRIES", "BLOCKED AT", "EXPIRES", "REMAINING")
|
||||||
|
fmt.Printf("%-18s %-20s %-5s %-22s %-22s %s\n",
|
||||||
|
"--", "-------------", "-----", "----------", "-------", "---------")
|
||||||
|
for _, b := range blocks {
|
||||||
|
blockedAt := b.BlockedAt.UTC().Format("2006-01-02 15:04:05")
|
||||||
|
var expires, remaining string
|
||||||
|
if b.IsPermanent || b.ExpiresAt == nil {
|
||||||
|
expires = "permanent"
|
||||||
|
remaining = "∞ (manual unblock)"
|
||||||
|
} else {
|
||||||
|
expires = b.ExpiresAt.UTC().Format("2006-01-02 15:04:05")
|
||||||
|
left := time.Until(*b.ExpiresAt)
|
||||||
|
if left <= 0 {
|
||||||
|
remaining = "expired (purge pending)"
|
||||||
|
} else {
|
||||||
|
h := int(left.Hours())
|
||||||
|
m := int(left.Minutes()) % 60
|
||||||
|
s := int(left.Seconds()) % 60
|
||||||
|
if h > 0 {
|
||||||
|
remaining = fmt.Sprintf("%dh %dm", h, m)
|
||||||
|
} else if m > 0 {
|
||||||
|
remaining = fmt.Sprintf("%dm %ds", m, s)
|
||||||
|
} else {
|
||||||
|
remaining = fmt.Sprintf("%ds", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
username := b.LastUsername
|
||||||
|
if username == "" {
|
||||||
|
username = "(unknown)"
|
||||||
|
}
|
||||||
|
fmt.Printf("%-18s %-20s %-5d %-22s %-22s %s\n",
|
||||||
|
b.IP, username, b.Attempts, blockedAt, expires, remaining)
|
||||||
|
}
|
||||||
|
fmt.Printf("\nTotal: %d blocked IP(s)\n", len(blocks))
|
||||||
|
}
|
||||||
|
|
||||||
|
func runUnblock(ip string) {
|
||||||
|
database, close := openDB()
|
||||||
|
defer close()
|
||||||
|
|
||||||
|
if err := database.UnblockIP(ip); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error unblocking %s: %v\n", ip, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Printf("IP %s has been unblocked.\n", ip)
|
||||||
|
}
|
||||||
|
|
||||||
func printHelp() {
|
func printHelp() {
|
||||||
fmt.Print(`GoMail — Admin CLI
|
fmt.Print(`GoWebMail — Admin CLI
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
gowebmail Start the mail server
|
gowebmail Start the mail server
|
||||||
gowebmail --list-admin List all admin accounts (username, email, MFA status)
|
gowebmail --list-admin List all admin accounts (username, email, MFA status)
|
||||||
gowebmail --pw <username> <pass> Reset password for an admin account
|
gowebmail --pw <username> <pass> Reset password for an admin account
|
||||||
gowebmail --mfa-off <username> Disable MFA for an admin account
|
gowebmail --mfa-off <username> Disable MFA for an admin account
|
||||||
|
gowebmail --blocklist List all currently blocked IP addresses
|
||||||
|
gowebmail --unblock <ip> Remove block for a specific IP address
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
./gowebmail --list-admin
|
./gowebmail --list-admin
|
||||||
./gowebmail --pw admin "NewSecurePass123"
|
./gowebmail --pw admin "NewSecurePass123"
|
||||||
./gowebmail --mfa-off admin
|
./gowebmail --mfa-off admin
|
||||||
|
./gowebmail --blocklist
|
||||||
|
./gowebmail --unblock 1.2.3.4
|
||||||
|
|
||||||
Note: These commands only work on admin accounts.
|
Note: --list-admin, --pw, and --mfa-off only work on admin accounts.
|
||||||
Regular user management is done through the web UI.
|
Regular user management is done through the web UI.
|
||||||
Requires the same environment variables as the server (DB_PATH, ENCRYPTION_KEY, etc).
|
Requires the same environment variables as the server (DB_PATH, ENCRYPTION_KEY, etc).
|
||||||
`)
|
`)
|
||||||
|
|||||||
209
config/config.go
209
config/config.go
@@ -1,4 +1,4 @@
|
|||||||
// Package config loads and persists GoMail configuration from data/gowebmail.conf
|
// Package config loads and persists GoWebMail configuration from data/gowebmail.conf
|
||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -28,6 +28,23 @@ type Config struct {
|
|||||||
SessionMaxAge int
|
SessionMaxAge int
|
||||||
TrustedProxies []net.IPNet // CIDR ranges allowed to set X-Forwarded-For/Proto headers
|
TrustedProxies []net.IPNet // CIDR ranges allowed to set X-Forwarded-For/Proto headers
|
||||||
|
|
||||||
|
// Notification SMTP (outbound alerts — separate from user mail accounts)
|
||||||
|
NotifyEnabled bool
|
||||||
|
NotifySMTPHost string
|
||||||
|
NotifySMTPPort int
|
||||||
|
NotifyFrom string
|
||||||
|
NotifyUser string // optional — leave blank for unauthenticated relay
|
||||||
|
NotifyPass string // optional
|
||||||
|
|
||||||
|
// Brute force protection
|
||||||
|
BruteEnabled bool
|
||||||
|
BruteMaxAttempts int
|
||||||
|
BruteWindowMins int
|
||||||
|
BruteBanHours int
|
||||||
|
BruteWhitelist []net.IP // IPs exempt from blocking
|
||||||
|
GeoBlockCountries []string // 2-letter codes to deny (deny-list mode)
|
||||||
|
GeoAllowCountries []string // 2-letter codes to allow (allow-list mode, empty=allow all)
|
||||||
|
|
||||||
// Storage
|
// Storage
|
||||||
DBPath string
|
DBPath string
|
||||||
|
|
||||||
@@ -59,7 +76,7 @@ var allFields = []configField{
|
|||||||
defVal: "localhost",
|
defVal: "localhost",
|
||||||
comments: []string{
|
comments: []string{
|
||||||
"--- Server ---",
|
"--- Server ---",
|
||||||
"Public hostname of this GoMail instance (no port, no protocol).",
|
"Public hostname of this GoWebMail instance (no port, no protocol).",
|
||||||
"Examples: localhost | mail.example.com | 192.168.1.10",
|
"Examples: localhost | mail.example.com | 192.168.1.10",
|
||||||
"Used to build BASE_URL and OAuth redirect URIs automatically.",
|
"Used to build BASE_URL and OAuth redirect URIs automatically.",
|
||||||
"Also used in security checks to reject requests with unexpected Host headers.",
|
"Also used in security checks to reject requests with unexpected Host headers.",
|
||||||
@@ -92,7 +109,7 @@ var allFields = []configField{
|
|||||||
key: "SECURE_COOKIE",
|
key: "SECURE_COOKIE",
|
||||||
defVal: "false",
|
defVal: "false",
|
||||||
comments: []string{
|
comments: []string{
|
||||||
"Set to true when GoMail is served over HTTPS (directly or via proxy).",
|
"Set to true when GoWebMail is served over HTTPS (directly or via proxy).",
|
||||||
"Marks session cookies as Secure so browsers only send them over TLS.",
|
"Marks session cookies as Secure so browsers only send them over TLS.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -109,7 +126,7 @@ var allFields = []configField{
|
|||||||
comments: []string{
|
comments: []string{
|
||||||
"Comma-separated list of IP addresses or CIDR ranges of trusted reverse proxies.",
|
"Comma-separated list of IP addresses or CIDR ranges of trusted reverse proxies.",
|
||||||
"Requests from these IPs may set X-Forwarded-For and X-Forwarded-Proto headers,",
|
"Requests from these IPs may set X-Forwarded-For and X-Forwarded-Proto headers,",
|
||||||
"which GoMail uses to determine the real client IP and whether TLS is in use.",
|
"which GoWebMail uses to determine the real client IP and whether TLS is in use.",
|
||||||
" Examples:",
|
" Examples:",
|
||||||
" 127.0.0.1 (loopback only — Nginx/Traefik on same host)",
|
" 127.0.0.1 (loopback only — Nginx/Traefik on same host)",
|
||||||
" 10.0.0.0/8,172.16.0.0/12 (private networks)",
|
" 10.0.0.0/8,172.16.0.0/12 (private networks)",
|
||||||
@@ -118,6 +135,108 @@ var allFields = []configField{
|
|||||||
" NOTE: Do not add untrusted IPs — clients could spoof their source address.",
|
" NOTE: Do not add untrusted IPs — clients could spoof their source address.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "NOTIFY_ENABLED",
|
||||||
|
defVal: "true",
|
||||||
|
comments: []string{
|
||||||
|
"--- Security Notifications ---",
|
||||||
|
"Send email alerts to users when their account is targeted by brute-force attacks.",
|
||||||
|
"Set to false to disable all security notification emails.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "NOTIFY_SMTP_HOST",
|
||||||
|
defVal: "",
|
||||||
|
comments: []string{
|
||||||
|
"SMTP server hostname for sending security notification emails.",
|
||||||
|
"Example: smtp.example.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "NOTIFY_SMTP_PORT",
|
||||||
|
defVal: "587",
|
||||||
|
comments: []string{
|
||||||
|
"SMTP server port. Common values: 587 (STARTTLS), 465 (TLS), 25 (relay, no auth).",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "NOTIFY_FROM",
|
||||||
|
defVal: "",
|
||||||
|
comments: []string{
|
||||||
|
"Sender address for security notification emails. Example: security@example.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "NOTIFY_USER",
|
||||||
|
defVal: "",
|
||||||
|
comments: []string{
|
||||||
|
"SMTP username for authenticated relay. Leave blank for unauthenticated relay.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "NOTIFY_PASS",
|
||||||
|
defVal: "",
|
||||||
|
comments: []string{
|
||||||
|
"SMTP password for authenticated relay. Leave blank for unauthenticated relay.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "BRUTE_ENABLED",
|
||||||
|
defVal: "true",
|
||||||
|
comments: []string{
|
||||||
|
"--- Brute Force Protection ---",
|
||||||
|
"Enable automatic IP blocking after repeated failed logins.",
|
||||||
|
"Set to false to disable entirely.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "BRUTE_MAX_ATTEMPTS",
|
||||||
|
defVal: "5",
|
||||||
|
comments: []string{
|
||||||
|
"Number of failed login attempts within BRUTE_WINDOW_MINUTES that triggers a ban.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "BRUTE_WINDOW_MINUTES",
|
||||||
|
defVal: "30",
|
||||||
|
comments: []string{
|
||||||
|
"Time window in minutes for counting failed login attempts.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "BRUTE_BAN_HOURS",
|
||||||
|
defVal: "12",
|
||||||
|
comments: []string{
|
||||||
|
"How many hours to ban an offending IP. Set to 0 for permanent ban (admin must unban manually).",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "BRUTE_WHITELIST_IPS",
|
||||||
|
defVal: "",
|
||||||
|
comments: []string{
|
||||||
|
"Comma-separated IPv4/IPv6 addresses that are never blocked by brute force protection.",
|
||||||
|
"Example: 192.168.1.1,10.0.0.1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "GEO_BLOCK_COUNTRIES",
|
||||||
|
defVal: "",
|
||||||
|
comments: []string{
|
||||||
|
"--- Geo Blocking (uses ip-api.com, requires internet access) ---",
|
||||||
|
"Comma-separated 2-letter ISO country codes to DENY access from.",
|
||||||
|
"Example: CN,RU,KP",
|
||||||
|
"Leave blank to disable deny-list. Takes precedence over GEO_ALLOW_COUNTRIES.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "GEO_ALLOW_COUNTRIES",
|
||||||
|
defVal: "",
|
||||||
|
comments: []string{
|
||||||
|
"Comma-separated 2-letter ISO country codes to ALLOW (all others are denied).",
|
||||||
|
"Example: SK,CZ,DE",
|
||||||
|
"Leave blank to allow all countries. Only active if GEO_BLOCK_COUNTRIES is also blank.",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "DB_PATH",
|
key: "DB_PATH",
|
||||||
defVal: "./data/gowebmail.db",
|
defVal: "./data/gowebmail.db",
|
||||||
@@ -228,7 +347,7 @@ func Load() (*Config, error) {
|
|||||||
|
|
||||||
// get returns env var if set, else file value, else ""
|
// get returns env var if set, else file value, else ""
|
||||||
get := func(key string) string {
|
get := func(key string) string {
|
||||||
// Only check env vars that are explicitly GoMail-namespaced or well-known.
|
// Only check env vars that are explicitly GoWebMail-namespaced or well-known.
|
||||||
// We deliberately do NOT fall back to generic vars like PORT to avoid
|
// We deliberately do NOT fall back to generic vars like PORT to avoid
|
||||||
// picking up cloud-platform env vars unintentionally.
|
// picking up cloud-platform env vars unintentionally.
|
||||||
if v := os.Getenv("GOMAIL_" + key); v != "" {
|
if v := os.Getenv("GOMAIL_" + key); v != "" {
|
||||||
@@ -313,6 +432,21 @@ func Load() (*Config, error) {
|
|||||||
SessionMaxAge: atoi(get("SESSION_MAX_AGE"), 604800),
|
SessionMaxAge: atoi(get("SESSION_MAX_AGE"), 604800),
|
||||||
TrustedProxies: trustedProxies,
|
TrustedProxies: trustedProxies,
|
||||||
|
|
||||||
|
BruteEnabled: atobool(get("BRUTE_ENABLED"), true),
|
||||||
|
BruteMaxAttempts: atoi(get("BRUTE_MAX_ATTEMPTS"), 5),
|
||||||
|
BruteWindowMins: atoi(get("BRUTE_WINDOW_MINUTES"), 30),
|
||||||
|
BruteBanHours: atoi(get("BRUTE_BAN_HOURS"), 12),
|
||||||
|
BruteWhitelist: parseIPList(get("BRUTE_WHITELIST_IPS")),
|
||||||
|
GeoBlockCountries: parseCountryList(get("GEO_BLOCK_COUNTRIES")),
|
||||||
|
GeoAllowCountries: parseCountryList(get("GEO_ALLOW_COUNTRIES")),
|
||||||
|
|
||||||
|
NotifyEnabled: atobool(get("NOTIFY_ENABLED"), true),
|
||||||
|
NotifySMTPHost: get("NOTIFY_SMTP_HOST"),
|
||||||
|
NotifySMTPPort: atoi(get("NOTIFY_SMTP_PORT"), 587),
|
||||||
|
NotifyFrom: get("NOTIFY_FROM"),
|
||||||
|
NotifyUser: get("NOTIFY_USER"),
|
||||||
|
NotifyPass: get("NOTIFY_PASS"),
|
||||||
|
|
||||||
GoogleClientID: get("GOOGLE_CLIENT_ID"),
|
GoogleClientID: get("GOOGLE_CLIENT_ID"),
|
||||||
GoogleClientSecret: get("GOOGLE_CLIENT_SECRET"),
|
GoogleClientSecret: get("GOOGLE_CLIENT_SECRET"),
|
||||||
GoogleRedirectURL: googleRedirect,
|
GoogleRedirectURL: googleRedirect,
|
||||||
@@ -345,6 +479,42 @@ func buildBaseURL(hostname, port string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsIPWhitelisted returns true if the IP is in the brute force whitelist.
|
||||||
|
func (c *Config) IsIPWhitelisted(ipStr string) bool {
|
||||||
|
ip := net.ParseIP(ipStr)
|
||||||
|
if ip == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, w := range c.BruteWhitelist {
|
||||||
|
if w.Equal(ip) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsCountryAllowed returns true if traffic from the given 2-letter country code is permitted.
|
||||||
|
// Logic: deny-list takes precedence; then allow-list if non-empty; otherwise allow all.
|
||||||
|
func (c *Config) IsCountryAllowed(code string) bool {
|
||||||
|
code = strings.ToUpper(code)
|
||||||
|
if len(c.GeoBlockCountries) > 0 {
|
||||||
|
for _, bc := range c.GeoBlockCountries {
|
||||||
|
if bc == code {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(c.GeoAllowCountries) > 0 {
|
||||||
|
for _, ac := range c.GeoAllowCountries {
|
||||||
|
if ac == code {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// IsAllowedHost returns true if the request Host header matches our expected hostname.
|
// IsAllowedHost returns true if the request Host header matches our expected hostname.
|
||||||
// Accepts exact match, hostname:port, or any value if hostname is "localhost" (dev mode).
|
// Accepts exact match, hostname:port, or any value if hostname is "localhost" (dev mode).
|
||||||
func (c *Config) IsAllowedHost(requestHost string) bool {
|
func (c *Config) IsAllowedHost(requestHost string) bool {
|
||||||
@@ -443,7 +613,7 @@ func readConfigFile(path string) (map[string]string, error) {
|
|||||||
|
|
||||||
func writeConfigFile(path string, values map[string]string) error {
|
func writeConfigFile(path string, values map[string]string) error {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
sb.WriteString("# GoMail Configuration\n")
|
sb.WriteString("# GoWebMail Configuration\n")
|
||||||
sb.WriteString("# =====================\n")
|
sb.WriteString("# =====================\n")
|
||||||
sb.WriteString("# Auto-generated and updated on each startup.\n")
|
sb.WriteString("# Auto-generated and updated on each startup.\n")
|
||||||
sb.WriteString("# Edit freely — your values are always preserved.\n")
|
sb.WriteString("# Edit freely — your values are always preserved.\n")
|
||||||
@@ -576,7 +746,7 @@ func parseCIDRList(s string) ([]net.IPNet, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func logStartupInfo(cfg *Config) {
|
func logStartupInfo(cfg *Config) {
|
||||||
fmt.Printf("GoMail starting:\n")
|
fmt.Printf("GoWebMail starting:\n")
|
||||||
fmt.Printf(" Listen : %s\n", cfg.ListenAddr)
|
fmt.Printf(" Listen : %s\n", cfg.ListenAddr)
|
||||||
fmt.Printf(" Base URL: %s\n", cfg.BaseURL)
|
fmt.Printf(" Base URL: %s\n", cfg.BaseURL)
|
||||||
fmt.Printf(" Hostname: %s\n", cfg.Hostname)
|
fmt.Printf(" Hostname: %s\n", cfg.Hostname)
|
||||||
@@ -589,6 +759,31 @@ func logStartupInfo(cfg *Config) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseIPList(s string) []net.IP {
|
||||||
|
var ips []net.IP
|
||||||
|
for _, raw := range strings.Split(s, ",") {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ip := net.ParseIP(raw); ip != nil {
|
||||||
|
ips = append(ips, ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ips
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseCountryList(s string) []string {
|
||||||
|
var codes []string
|
||||||
|
for _, raw := range strings.Split(s, ",") {
|
||||||
|
raw = strings.TrimSpace(strings.ToUpper(raw))
|
||||||
|
if len(raw) == 2 {
|
||||||
|
codes = append(codes, raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return codes
|
||||||
|
}
|
||||||
|
|
||||||
func mustHex(n int) string {
|
func mustHex(n int) string {
|
||||||
b := make([]byte, n)
|
b := make([]byte, n)
|
||||||
if _, err := rand.Read(b); err != nil {
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Package db provides encrypted SQLite storage for GoMail.
|
// Package db provides encrypted SQLite storage for GoWebMail.
|
||||||
package db
|
package db
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -199,6 +199,52 @@ func (d *DB) Migrate() error {
|
|||||||
return fmt.Errorf("create pending_imap_ops: %w", err)
|
return fmt.Errorf("create pending_imap_ops: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Login attempt tracking for brute-force protection.
|
||||||
|
if _, err := d.sql.Exec(`CREATE TABLE IF NOT EXISTS login_attempts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
ip TEXT NOT NULL,
|
||||||
|
username TEXT NOT NULL DEFAULT '',
|
||||||
|
success INTEGER NOT NULL DEFAULT 0,
|
||||||
|
country TEXT NOT NULL DEFAULT '',
|
||||||
|
country_code TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at DATETIME DEFAULT (datetime('now'))
|
||||||
|
)`); err != nil {
|
||||||
|
return fmt.Errorf("create login_attempts: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := d.sql.Exec(`CREATE INDEX IF NOT EXISTS idx_login_attempts_ip_time ON login_attempts(ip, created_at)`); err != nil {
|
||||||
|
return fmt.Errorf("create login_attempts index: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IP block list — manually added or auto-created by brute force protection.
|
||||||
|
if _, err := d.sql.Exec(`CREATE TABLE IF NOT EXISTS ip_blocks (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
ip TEXT NOT NULL UNIQUE,
|
||||||
|
reason TEXT NOT NULL DEFAULT '',
|
||||||
|
country TEXT NOT NULL DEFAULT '',
|
||||||
|
country_code TEXT NOT NULL DEFAULT '',
|
||||||
|
attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
blocked_at DATETIME DEFAULT (datetime('now')),
|
||||||
|
expires_at DATETIME,
|
||||||
|
is_permanent INTEGER NOT NULL DEFAULT 0
|
||||||
|
)`); err != nil {
|
||||||
|
return fmt.Errorf("create ip_blocks: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-user IP access rules.
|
||||||
|
// mode: "brute_skip" = skip brute force check for this user from listed IPs
|
||||||
|
// "allow_only" = only allow login from listed IPs (all others get 403)
|
||||||
|
if _, err := d.sql.Exec(`CREATE TABLE IF NOT EXISTS user_ip_rules (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
mode TEXT NOT NULL DEFAULT 'brute_skip',
|
||||||
|
ip_list TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at DATETIME DEFAULT (datetime('now')),
|
||||||
|
updated_at DATETIME DEFAULT (datetime('now')),
|
||||||
|
UNIQUE(user_id)
|
||||||
|
)`); err != nil {
|
||||||
|
return fmt.Errorf("create user_ip_rules: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Bootstrap admin account if no users exist
|
// Bootstrap admin account if no users exist
|
||||||
return d.bootstrapAdmin()
|
return d.bootstrapAdmin()
|
||||||
}
|
}
|
||||||
@@ -868,7 +914,14 @@ func (d *DB) UpsertMessage(m *models.Message) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// LastInsertId returns 0 on conflict in SQLite — always look up the real ID.
|
||||||
id, _ := res.LastInsertId()
|
id, _ := res.LastInsertId()
|
||||||
|
if id == 0 {
|
||||||
|
d.sql.QueryRow(
|
||||||
|
`SELECT id FROM messages WHERE account_id=? AND folder_id=? AND remote_uid=?`,
|
||||||
|
m.AccountID, m.FolderID, m.RemoteUID,
|
||||||
|
).Scan(&id)
|
||||||
|
}
|
||||||
if m.ID == 0 {
|
if m.ID == 0 {
|
||||||
m.ID = id
|
m.ID = id
|
||||||
}
|
}
|
||||||
@@ -910,6 +963,12 @@ func (d *DB) GetMessage(messageID, userID int64) (*models.Message, error) {
|
|||||||
m.BodyText, _ = d.enc.Decrypt(bodyTextEnc)
|
m.BodyText, _ = d.enc.Decrypt(bodyTextEnc)
|
||||||
m.BodyHTML, _ = d.enc.Decrypt(bodyHTMLEnc)
|
m.BodyHTML, _ = d.enc.Decrypt(bodyHTMLEnc)
|
||||||
|
|
||||||
|
// Load attachment metadata
|
||||||
|
if m.HasAttachment {
|
||||||
|
atts, _ := d.GetAttachmentsByMessage(m.ID, userID)
|
||||||
|
m.Attachments = atts
|
||||||
|
}
|
||||||
|
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1564,3 +1623,416 @@ func (d *DB) GetNewMessagesSince(userID int64, sinceID int64) ([]map[string]inte
|
|||||||
}
|
}
|
||||||
return result, rows.Err()
|
return result, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Attachment metadata ----
|
||||||
|
|
||||||
|
// SaveAttachmentMeta saves attachment metadata for a message (no binary data).
|
||||||
|
// Uses INSERT OR REPLACE so a re-sync always refreshes the part path (ContentID).
|
||||||
|
func (d *DB) SaveAttachmentMeta(messageID int64, atts []models.Attachment) error {
|
||||||
|
// Delete stale rows first so re-syncs don't leave orphans
|
||||||
|
d.sql.Exec(`DELETE FROM attachments WHERE message_id=?`, messageID)
|
||||||
|
for _, a := range atts {
|
||||||
|
_, err := d.sql.Exec(`
|
||||||
|
INSERT INTO attachments (message_id, filename, content_type, size, content_id)
|
||||||
|
VALUES (?,?,?,?,?)`,
|
||||||
|
messageID, a.Filename, a.ContentType, a.Size, a.ContentID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAttachmentsByMessage returns attachment metadata for a message.
|
||||||
|
func (d *DB) GetAttachmentsByMessage(messageID, userID int64) ([]models.Attachment, error) {
|
||||||
|
rows, err := d.sql.Query(`
|
||||||
|
SELECT a.id, a.message_id, a.filename, a.content_type, a.size, a.content_id
|
||||||
|
FROM attachments a
|
||||||
|
JOIN messages m ON m.id=a.message_id
|
||||||
|
JOIN email_accounts ac ON ac.id=m.account_id
|
||||||
|
WHERE a.message_id=? AND ac.user_id=?`, messageID, userID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var result []models.Attachment
|
||||||
|
for rows.Next() {
|
||||||
|
var a models.Attachment
|
||||||
|
rows.Scan(&a.ID, &a.MessageID, &a.Filename, &a.ContentType, &a.Size, &a.ContentID)
|
||||||
|
result = append(result, a)
|
||||||
|
}
|
||||||
|
return result, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAttachment returns a single attachment record (ownership via userID check).
|
||||||
|
func (d *DB) GetAttachment(attachmentID, userID int64) (*models.Attachment, error) {
|
||||||
|
var a models.Attachment
|
||||||
|
err := d.sql.QueryRow(`
|
||||||
|
SELECT a.id, a.message_id, a.filename, a.content_type, a.size, a.content_id
|
||||||
|
FROM attachments a
|
||||||
|
JOIN messages m ON m.id=a.message_id
|
||||||
|
JOIN email_accounts ac ON ac.id=m.account_id
|
||||||
|
WHERE a.id=? AND ac.user_id=?`, attachmentID, userID,
|
||||||
|
).Scan(&a.ID, &a.MessageID, &a.Filename, &a.ContentType, &a.Size, &a.ContentID)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return &a, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Mark all read ----
|
||||||
|
|
||||||
|
// MarkFolderAllRead marks every message in a folder as read and enqueues IMAP flag ops.
|
||||||
|
// Returns the list of (remoteUID, folderPath, accountID) for IMAP ops.
|
||||||
|
func (d *DB) MarkFolderAllRead(folderID, userID int64) ([]PendingIMAPOp, error) {
|
||||||
|
// Verify folder ownership
|
||||||
|
var accountID int64
|
||||||
|
var fullPath string
|
||||||
|
err := d.sql.QueryRow(`
|
||||||
|
SELECT f.account_id, f.full_path FROM folders f
|
||||||
|
JOIN email_accounts a ON a.id=f.account_id
|
||||||
|
WHERE f.id=? AND a.user_id=?`, folderID, userID,
|
||||||
|
).Scan(&accountID, &fullPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("folder not found or not owned: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all unread messages in folder for IMAP ops
|
||||||
|
rows, err := d.sql.Query(`
|
||||||
|
SELECT remote_uid FROM messages WHERE folder_id=? AND is_read=0`, folderID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var ops []PendingIMAPOp
|
||||||
|
for rows.Next() {
|
||||||
|
var uid string
|
||||||
|
rows.Scan(&uid)
|
||||||
|
var uidNum uint32
|
||||||
|
fmt.Sscanf(uid, "%d", &uidNum)
|
||||||
|
if uidNum > 0 {
|
||||||
|
ops = append(ops, PendingIMAPOp{
|
||||||
|
AccountID: accountID, OpType: "flag_read",
|
||||||
|
RemoteUID: uidNum, FolderPath: fullPath, Extra: "1",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
|
||||||
|
// Bulk mark read in DB
|
||||||
|
_, err = d.sql.Exec(`UPDATE messages SET is_read=1 WHERE folder_id=?`, folderID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
d.UpdateFolderCounts(folderID)
|
||||||
|
return ops, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Admin MFA disable ----
|
||||||
|
|
||||||
|
// AdminDisableMFAByID disables MFA for a user by ID (admin action).
|
||||||
|
func (d *DB) AdminDisableMFAByID(targetUserID int64) error {
|
||||||
|
_, err := d.sql.Exec(`
|
||||||
|
UPDATE users SET mfa_enabled=0, mfa_secret='', mfa_pending=''
|
||||||
|
WHERE id=?`, targetUserID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Brute Force / IP Block ----
|
||||||
|
|
||||||
|
// IPBlock represents a blocked IP entry.
|
||||||
|
type IPBlock struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
IP string `json:"ip"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
Country string `json:"country"`
|
||||||
|
CountryCode string `json:"country_code"`
|
||||||
|
Attempts int `json:"attempts"`
|
||||||
|
BlockedAt time.Time `json:"blocked_at"`
|
||||||
|
ExpiresAt *time.Time `json:"expires_at"`
|
||||||
|
IsPermanent bool `json:"is_permanent"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginAttemptStat is used for summary display.
|
||||||
|
type LoginAttemptStat struct {
|
||||||
|
IP string `json:"ip"`
|
||||||
|
Country string `json:"country"`
|
||||||
|
CountryCode string `json:"country_code"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
Failures int `json:"failures"`
|
||||||
|
LastSeen string `json:"last_seen"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordLoginAttempt saves a login attempt for an IP.
|
||||||
|
func (d *DB) RecordLoginAttempt(ip, username, country, countryCode string, success bool) {
|
||||||
|
suc := 0
|
||||||
|
if success {
|
||||||
|
suc = 1
|
||||||
|
}
|
||||||
|
d.sql.Exec(`INSERT INTO login_attempts (ip, username, success, country, country_code) VALUES (?,?,?,?,?)`,
|
||||||
|
ip, username, suc, country, countryCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountRecentFailures returns the number of failed logins from an IP in the last windowMinutes.
|
||||||
|
func (d *DB) CountRecentFailures(ip string, windowMinutes int) int {
|
||||||
|
var count int
|
||||||
|
d.sql.QueryRow(`
|
||||||
|
SELECT COUNT(*) FROM login_attempts
|
||||||
|
WHERE ip=? AND success=0 AND created_at >= datetime('now', ? || ' minutes')`,
|
||||||
|
ip, fmt.Sprintf("-%d", windowMinutes),
|
||||||
|
).Scan(&count)
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsIPBlocked returns true if the IP is currently blocked (non-expired entry).
|
||||||
|
func (d *DB) IsIPBlocked(ip string) bool {
|
||||||
|
var count int
|
||||||
|
d.sql.QueryRow(`
|
||||||
|
SELECT COUNT(*) FROM ip_blocks
|
||||||
|
WHERE ip=? AND (is_permanent=1 OR expires_at IS NULL OR expires_at > datetime('now'))`,
|
||||||
|
ip,
|
||||||
|
).Scan(&count)
|
||||||
|
return count > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlockIP adds or updates a block entry for an IP.
|
||||||
|
// banHours=0 means permanent block (admin must remove manually).
|
||||||
|
func (d *DB) BlockIP(ip, reason, country, countryCode string, attempts int, banHours int) {
|
||||||
|
isPermanent := 0
|
||||||
|
var expiresExpr string
|
||||||
|
if banHours == 0 {
|
||||||
|
isPermanent = 1
|
||||||
|
expiresExpr = "NULL"
|
||||||
|
} else {
|
||||||
|
expiresExpr = fmt.Sprintf("datetime('now', '+%d hours')", banHours)
|
||||||
|
}
|
||||||
|
d.sql.Exec(fmt.Sprintf(`
|
||||||
|
INSERT INTO ip_blocks (ip, reason, country, country_code, attempts, is_permanent, expires_at)
|
||||||
|
VALUES (?,?,?,?,?,%d,%s)
|
||||||
|
ON CONFLICT(ip) DO UPDATE SET
|
||||||
|
reason=excluded.reason, attempts=excluded.attempts,
|
||||||
|
blocked_at=datetime('now'), is_permanent=%d, expires_at=%s`,
|
||||||
|
isPermanent, expiresExpr, isPermanent, expiresExpr,
|
||||||
|
), ip, reason, country, countryCode, attempts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnblockIP removes a block entry.
|
||||||
|
func (d *DB) UnblockIP(ip string) error {
|
||||||
|
_, err := d.sql.Exec(`DELETE FROM ip_blocks WHERE ip=?`, ip)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListIPBlocks returns all current (non-expired or permanent) blocked IPs.
|
||||||
|
func (d *DB) ListIPBlocks() ([]IPBlock, error) {
|
||||||
|
rows, err := d.sql.Query(`
|
||||||
|
SELECT id, ip, reason, country, country_code, attempts, blocked_at, expires_at, is_permanent
|
||||||
|
FROM ip_blocks
|
||||||
|
WHERE is_permanent=1 OR expires_at IS NULL OR expires_at > datetime('now')
|
||||||
|
ORDER BY blocked_at DESC`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var result []IPBlock
|
||||||
|
for rows.Next() {
|
||||||
|
var b IPBlock
|
||||||
|
var expiresAt sql.NullTime
|
||||||
|
rows.Scan(&b.ID, &b.IP, &b.Reason, &b.Country, &b.CountryCode,
|
||||||
|
&b.Attempts, &b.BlockedAt, &expiresAt, &b.IsPermanent)
|
||||||
|
if expiresAt.Valid {
|
||||||
|
t := expiresAt.Time
|
||||||
|
b.ExpiresAt = &t
|
||||||
|
}
|
||||||
|
result = append(result, b)
|
||||||
|
}
|
||||||
|
return result, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListLoginAttemptStats returns per-IP attempt summaries for display.
|
||||||
|
func (d *DB) ListLoginAttemptStats(limitHours int) ([]LoginAttemptStat, error) {
|
||||||
|
rows, err := d.sql.Query(`
|
||||||
|
SELECT ip, country, country_code,
|
||||||
|
COUNT(*) as total,
|
||||||
|
SUM(CASE WHEN success=0 THEN 1 ELSE 0 END) as failures,
|
||||||
|
MAX(created_at) as last_seen
|
||||||
|
FROM login_attempts
|
||||||
|
WHERE created_at >= datetime('now', ? || ' hours')
|
||||||
|
GROUP BY ip ORDER BY failures DESC LIMIT 100`,
|
||||||
|
fmt.Sprintf("-%d", limitHours),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var result []LoginAttemptStat
|
||||||
|
for rows.Next() {
|
||||||
|
var s LoginAttemptStat
|
||||||
|
rows.Scan(&s.IP, &s.Country, &s.CountryCode, &s.Total, &s.Failures, &s.LastSeen)
|
||||||
|
result = append(result, s)
|
||||||
|
}
|
||||||
|
return result, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// PurgeExpiredBlocks removes expired (non-permanent) blocks from the table.
|
||||||
|
func (d *DB) PurgeExpiredBlocks() {
|
||||||
|
d.sql.Exec(`DELETE FROM ip_blocks WHERE is_permanent=0 AND expires_at IS NOT NULL AND expires_at <= datetime('now')`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LookupIPCountry returns cached country info for an IP from recent login_attempts.
|
||||||
|
func (d *DB) LookupCachedCountry(ip string) (country, countryCode string) {
|
||||||
|
d.sql.QueryRow(`
|
||||||
|
SELECT country, country_code FROM login_attempts
|
||||||
|
WHERE ip=? AND country != '' ORDER BY created_at DESC LIMIT 1`, ip,
|
||||||
|
).Scan(&country, &countryCode)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Profile Updates ----
|
||||||
|
|
||||||
|
// UpdateUserEmail changes a user's email address. Returns error if already taken.
|
||||||
|
func (d *DB) UpdateUserEmail(userID int64, newEmail string) error {
|
||||||
|
_, err := d.sql.Exec(
|
||||||
|
`UPDATE users SET email=?, updated_at=datetime('now') WHERE id=?`,
|
||||||
|
newEmail, userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateUserUsername changes a user's display username. Returns error if already taken.
|
||||||
|
func (d *DB) UpdateUserUsername(userID int64, newUsername string) error {
|
||||||
|
_, err := d.sql.Exec(
|
||||||
|
`UPDATE users SET username=?, updated_at=datetime('now') WHERE id=?`,
|
||||||
|
newUsername, userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Per-User IP Rules ----
|
||||||
|
|
||||||
|
// UserIPRule holds per-user IP access settings.
|
||||||
|
type UserIPRule struct {
|
||||||
|
UserID int64 `json:"user_id"`
|
||||||
|
Mode string `json:"mode"` // "brute_skip" | "allow_only" | "disabled"
|
||||||
|
IPList string `json:"ip_list"` // comma-separated IPs
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserIPRule returns the IP rule for a user, or nil if none set.
|
||||||
|
func (d *DB) GetUserIPRule(userID int64) (*UserIPRule, error) {
|
||||||
|
row := d.sql.QueryRow(`SELECT user_id, mode, ip_list FROM user_ip_rules WHERE user_id=?`, userID)
|
||||||
|
r := &UserIPRule{}
|
||||||
|
if err := row.Scan(&r.UserID, &r.Mode, &r.IPList); err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
} else if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetUserIPRule upserts the IP rule for a user.
|
||||||
|
func (d *DB) SetUserIPRule(userID int64, mode, ipList string) error {
|
||||||
|
_, err := d.sql.Exec(`
|
||||||
|
INSERT INTO user_ip_rules (user_id, mode, ip_list, updated_at)
|
||||||
|
VALUES (?, ?, ?, datetime('now'))
|
||||||
|
ON CONFLICT(user_id) DO UPDATE SET
|
||||||
|
mode=excluded.mode,
|
||||||
|
ip_list=excluded.ip_list,
|
||||||
|
updated_at=datetime('now')`,
|
||||||
|
userID, mode, ipList)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteUserIPRule removes IP rules for a user (disables the feature).
|
||||||
|
func (d *DB) DeleteUserIPRule(userID int64) error {
|
||||||
|
_, err := d.sql.Exec(`DELETE FROM user_ip_rules WHERE user_id=?`, userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckUserIPAccess evaluates per-user IP rules against a connecting IP.
|
||||||
|
// Returns:
|
||||||
|
// "allow" — rule says allow (brute_skip match or allow_only match)
|
||||||
|
// "deny" — allow_only mode and IP is not in list
|
||||||
|
// "skip_brute" — brute_skip mode and IP is in list (skip brute force check)
|
||||||
|
// "default" — no rule exists, fall through to global rules
|
||||||
|
func (d *DB) CheckUserIPAccess(userID int64, ip string) string {
|
||||||
|
rule, err := d.GetUserIPRule(userID)
|
||||||
|
if err != nil || rule == nil || rule.Mode == "disabled" || rule.IPList == "" {
|
||||||
|
return "default"
|
||||||
|
}
|
||||||
|
for _, listed := range splitIPs(rule.IPList) {
|
||||||
|
if listed == ip {
|
||||||
|
if rule.Mode == "allow_only" {
|
||||||
|
return "allow"
|
||||||
|
}
|
||||||
|
return "skip_brute"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// IP not in list
|
||||||
|
if rule.Mode == "allow_only" {
|
||||||
|
return "deny"
|
||||||
|
}
|
||||||
|
return "default"
|
||||||
|
}
|
||||||
|
|
||||||
|
// SplitIPList splits a comma-separated IP string into trimmed, non-empty entries.
|
||||||
|
func SplitIPList(s string) []string {
|
||||||
|
return splitIPs(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitIPs(s string) []string {
|
||||||
|
var result []string
|
||||||
|
for _, p := range strings.Split(s, ",") {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p != "" {
|
||||||
|
result = append(result, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// IPBlockWithUsername extends IPBlock with the last username attempted from that IP.
|
||||||
|
type IPBlockWithUsername struct {
|
||||||
|
IPBlock
|
||||||
|
LastUsername string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListIPBlocksWithUsername returns active blocks enriched with the most recent
|
||||||
|
// username that was attempted from each IP (from login_attempts history).
|
||||||
|
func (d *DB) ListIPBlocksWithUsername() ([]IPBlockWithUsername, error) {
|
||||||
|
rows, err := d.sql.Query(`
|
||||||
|
SELECT
|
||||||
|
b.id, b.ip, b.reason, b.country, b.country_code,
|
||||||
|
b.attempts, b.blocked_at, b.expires_at, b.is_permanent,
|
||||||
|
COALESCE(
|
||||||
|
(SELECT username FROM login_attempts
|
||||||
|
WHERE ip=b.ip AND username != ''
|
||||||
|
ORDER BY created_at DESC LIMIT 1),
|
||||||
|
''
|
||||||
|
) AS last_username
|
||||||
|
FROM ip_blocks b
|
||||||
|
WHERE b.is_permanent=1 OR b.expires_at IS NULL OR b.expires_at > datetime('now')
|
||||||
|
ORDER BY b.blocked_at DESC`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var result []IPBlockWithUsername
|
||||||
|
for rows.Next() {
|
||||||
|
var b IPBlockWithUsername
|
||||||
|
var expiresAt sql.NullTime
|
||||||
|
err := rows.Scan(
|
||||||
|
&b.ID, &b.IP, &b.Reason, &b.Country, &b.CountryCode,
|
||||||
|
&b.Attempts, &b.BlockedAt, &expiresAt, &b.IsPermanent,
|
||||||
|
&b.LastUsername,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if expiresAt.Valid {
|
||||||
|
t := expiresAt.Time
|
||||||
|
b.ExpiresAt = &t
|
||||||
|
}
|
||||||
|
result = append(result, b)
|
||||||
|
}
|
||||||
|
return result, rows.Err()
|
||||||
|
}
|
||||||
|
|||||||
@@ -392,8 +392,14 @@ func parseIMAPMessage(msg *imap.Message, account *gomailModels.EmailAccount) (*g
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ParseMIMEFull is the exported version of parseMIME for use by handlers.
|
||||||
|
func ParseMIMEFull(raw []byte) (text, html string, attachments []gomailModels.Attachment) {
|
||||||
|
return parseMIME(raw)
|
||||||
|
}
|
||||||
|
|
||||||
// parseMIME takes a full RFC822 raw message (with headers) and extracts
|
// parseMIME takes a full RFC822 raw message (with headers) and extracts
|
||||||
// text/plain, text/html and attachment metadata.
|
// text/plain, text/html and attachment metadata.
|
||||||
|
// Inline images referenced by cid: are base64-embedded into the HTML as data: URIs.
|
||||||
func parseMIME(raw []byte) (text, html string, attachments []gomailModels.Attachment) {
|
func parseMIME(raw []byte) (text, html string, attachments []gomailModels.Attachment) {
|
||||||
msg, err := netmail.ReadMessage(bytes.NewReader(raw))
|
msg, err := netmail.ReadMessage(bytes.NewReader(raw))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -405,12 +411,144 @@ func parseMIME(raw []byte) (text, html string, attachments []gomailModels.Attach
|
|||||||
ct = "text/plain"
|
ct = "text/plain"
|
||||||
}
|
}
|
||||||
body, _ := io.ReadAll(msg.Body)
|
body, _ := io.ReadAll(msg.Body)
|
||||||
text, html, attachments = parsePart(ct, msg.Header.Get("Content-Transfer-Encoding"), body)
|
// cidMap: Content-ID → base64 data URI for inline images
|
||||||
|
cidMap := make(map[string]string)
|
||||||
|
text, html, attachments = parsePartIndexedCID(ct, msg.Header.Get("Content-Transfer-Encoding"), body, []int{}, cidMap)
|
||||||
|
|
||||||
|
// Rewrite cid: references in HTML to data: URIs
|
||||||
|
if html != "" && len(cidMap) > 0 {
|
||||||
|
html = rewriteCIDReferences(html, cidMap)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// rewriteCIDReferences replaces src="cid:xxx" with src="data:mime;base64,..." in HTML.
|
||||||
|
func rewriteCIDReferences(html string, cidMap map[string]string) string {
|
||||||
|
for cid, dataURI := range cidMap {
|
||||||
|
// Match both with and without angle brackets
|
||||||
|
html = strings.ReplaceAll(html, `cid:`+cid, dataURI)
|
||||||
|
// Some clients wrap CID in angle brackets in the src attribute
|
||||||
|
html = strings.ReplaceAll(html, `cid:<`+cid+`>`, dataURI)
|
||||||
|
}
|
||||||
|
return html
|
||||||
|
}
|
||||||
|
|
||||||
// parsePart recursively handles a MIME part.
|
// parsePart recursively handles a MIME part.
|
||||||
func parsePart(contentType, transferEncoding string, body []byte) (text, html string, attachments []gomailModels.Attachment) {
|
func parsePart(contentType, transferEncoding string, body []byte) (text, html string, attachments []gomailModels.Attachment) {
|
||||||
|
return parsePartIndexed(contentType, transferEncoding, body, []int{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsePartIndexedCID is like parsePartIndexed but also collects inline image parts into cidMap.
|
||||||
|
func parsePartIndexedCID(contentType, transferEncoding string, body []byte, path []int, cidMap map[string]string) (text, html string, attachments []gomailModels.Attachment) {
|
||||||
|
mediaType, params, err := mime.ParseMediaType(contentType)
|
||||||
|
if err != nil {
|
||||||
|
return string(body), "", nil
|
||||||
|
}
|
||||||
|
mediaType = strings.ToLower(mediaType)
|
||||||
|
decoded := decodeTransfer(transferEncoding, body)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case mediaType == "text/plain":
|
||||||
|
text = decodeCharset(params["charset"], decoded)
|
||||||
|
case mediaType == "text/html":
|
||||||
|
html = decodeCharset(params["charset"], decoded)
|
||||||
|
case strings.HasPrefix(mediaType, "multipart/"):
|
||||||
|
boundary := params["boundary"]
|
||||||
|
if boundary == "" {
|
||||||
|
return string(decoded), "", nil
|
||||||
|
}
|
||||||
|
mr := multipart.NewReader(bytes.NewReader(decoded), boundary)
|
||||||
|
partIdx := 0
|
||||||
|
for {
|
||||||
|
part, err := mr.NextPart()
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
partIdx++
|
||||||
|
childPath := append(append([]int{}, path...), partIdx)
|
||||||
|
|
||||||
|
partBody, _ := io.ReadAll(part)
|
||||||
|
partCT := part.Header.Get("Content-Type")
|
||||||
|
if partCT == "" {
|
||||||
|
partCT = "text/plain"
|
||||||
|
}
|
||||||
|
partTE := part.Header.Get("Content-Transfer-Encoding")
|
||||||
|
disposition := part.Header.Get("Content-Disposition")
|
||||||
|
contentID := strings.Trim(part.Header.Get("Content-ID"), "<>")
|
||||||
|
dispType, dispParams, _ := mime.ParseMediaType(disposition)
|
||||||
|
|
||||||
|
filename := dispParams["filename"]
|
||||||
|
if filename == "" {
|
||||||
|
filename = part.FileName()
|
||||||
|
}
|
||||||
|
if filename != "" {
|
||||||
|
wd := mime.WordDecoder{}
|
||||||
|
if dec, e := wd.DecodeHeader(filename); e == nil {
|
||||||
|
filename = dec
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
partMedia, _, _ := mime.ParseMediaType(partCT)
|
||||||
|
partMediaLower := strings.ToLower(partMedia)
|
||||||
|
|
||||||
|
// Inline image with Content-ID → embed as data URI for cid: resolution
|
||||||
|
if contentID != "" && strings.HasPrefix(partMediaLower, "image/") {
|
||||||
|
decodedPart := decodeTransfer(partTE, partBody)
|
||||||
|
dataURI := "data:" + partMediaLower + ";base64," + base64.StdEncoding.EncodeToString(decodedPart)
|
||||||
|
cidMap[contentID] = dataURI
|
||||||
|
// Don't add as attachment chip — it's inline
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
isAttachment := strings.EqualFold(dispType, "attachment") ||
|
||||||
|
(filename != "" && !strings.HasPrefix(partMediaLower, "text/") &&
|
||||||
|
!strings.HasPrefix(partMediaLower, "multipart/"))
|
||||||
|
|
||||||
|
if isAttachment {
|
||||||
|
if filename == "" {
|
||||||
|
filename = "attachment"
|
||||||
|
}
|
||||||
|
mimePartPath := mimePathString(childPath)
|
||||||
|
attachments = append(attachments, gomailModels.Attachment{
|
||||||
|
Filename: filename,
|
||||||
|
ContentType: partMedia,
|
||||||
|
Size: int64(len(partBody)),
|
||||||
|
ContentID: mimePartPath,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
t, h, atts := parsePartIndexedCID(partCT, partTE, partBody, childPath, cidMap)
|
||||||
|
if text == "" && t != "" {
|
||||||
|
text = t
|
||||||
|
}
|
||||||
|
if html == "" && h != "" {
|
||||||
|
html = h
|
||||||
|
}
|
||||||
|
attachments = append(attachments, atts...)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if mt, mtParams, e := mime.ParseMediaType(contentType); e == nil {
|
||||||
|
filename := mtParams["name"]
|
||||||
|
if filename != "" && !strings.HasPrefix(strings.ToLower(mt), "text/") {
|
||||||
|
wd := mime.WordDecoder{}
|
||||||
|
if dec, e2 := wd.DecodeHeader(filename); e2 == nil {
|
||||||
|
filename = dec
|
||||||
|
}
|
||||||
|
attachments = append(attachments, gomailModels.Attachment{
|
||||||
|
Filename: filename,
|
||||||
|
ContentType: mt,
|
||||||
|
Size: int64(len(decoded)),
|
||||||
|
ContentID: mimePathString(path),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsePartIndexed recursively handles a MIME part, tracking MIME part path for download.
|
||||||
|
func parsePartIndexed(contentType, transferEncoding string, body []byte, path []int) (text, html string, attachments []gomailModels.Attachment) {
|
||||||
mediaType, params, err := mime.ParseMediaType(contentType)
|
mediaType, params, err := mime.ParseMediaType(contentType)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return string(body), "", nil
|
return string(body), "", nil
|
||||||
@@ -430,11 +568,15 @@ func parsePart(contentType, transferEncoding string, body []byte) (text, html st
|
|||||||
return string(decoded), "", nil
|
return string(decoded), "", nil
|
||||||
}
|
}
|
||||||
mr := multipart.NewReader(bytes.NewReader(decoded), boundary)
|
mr := multipart.NewReader(bytes.NewReader(decoded), boundary)
|
||||||
|
partIdx := 0
|
||||||
for {
|
for {
|
||||||
part, err := mr.NextPart()
|
part, err := mr.NextPart()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
partIdx++
|
||||||
|
childPath := append(append([]int{}, path...), partIdx)
|
||||||
|
|
||||||
partBody, _ := io.ReadAll(part)
|
partBody, _ := io.ReadAll(part)
|
||||||
partCT := part.Header.Get("Content-Type")
|
partCT := part.Header.Get("Content-Type")
|
||||||
if partCT == "" {
|
if partCT == "" {
|
||||||
@@ -444,24 +586,41 @@ func parsePart(contentType, transferEncoding string, body []byte) (text, html st
|
|||||||
disposition := part.Header.Get("Content-Disposition")
|
disposition := part.Header.Get("Content-Disposition")
|
||||||
dispType, dispParams, _ := mime.ParseMediaType(disposition)
|
dispType, dispParams, _ := mime.ParseMediaType(disposition)
|
||||||
|
|
||||||
if strings.EqualFold(dispType, "attachment") {
|
// Filename from Content-Disposition or Content-Type params
|
||||||
filename := dispParams["filename"]
|
filename := dispParams["filename"]
|
||||||
if filename == "" {
|
if filename == "" {
|
||||||
filename = part.FileName()
|
filename = part.FileName()
|
||||||
}
|
}
|
||||||
|
// Decode RFC 2047 encoded filename
|
||||||
|
if filename != "" {
|
||||||
|
wd := mime.WordDecoder{}
|
||||||
|
if dec, err := wd.DecodeHeader(filename); err == nil {
|
||||||
|
filename = dec
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
partMedia, _, _ := mime.ParseMediaType(partCT)
|
||||||
|
|
||||||
|
isAttachment := strings.EqualFold(dispType, "attachment") ||
|
||||||
|
(filename != "" && !strings.HasPrefix(strings.ToLower(partMedia), "text/") &&
|
||||||
|
!strings.HasPrefix(strings.ToLower(partMedia), "multipart/"))
|
||||||
|
|
||||||
|
if isAttachment {
|
||||||
if filename == "" {
|
if filename == "" {
|
||||||
filename = "attachment"
|
filename = "attachment"
|
||||||
}
|
}
|
||||||
partMedia, _, _ := mime.ParseMediaType(partCT)
|
// Build MIME part path string e.g. "1.2" for nested
|
||||||
|
mimePartPath := mimePathString(childPath)
|
||||||
attachments = append(attachments, gomailModels.Attachment{
|
attachments = append(attachments, gomailModels.Attachment{
|
||||||
Filename: filename,
|
Filename: filename,
|
||||||
ContentType: partMedia,
|
ContentType: partMedia,
|
||||||
Size: int64(len(partBody)),
|
Size: int64(len(partBody)),
|
||||||
|
ContentID: mimePartPath, // reuse ContentID to store part path
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
t, h, atts := parsePart(partCT, partTE, partBody)
|
t, h, atts := parsePartIndexed(partCT, partTE, partBody, childPath)
|
||||||
if text == "" && t != "" {
|
if text == "" && t != "" {
|
||||||
text = t
|
text = t
|
||||||
}
|
}
|
||||||
@@ -471,13 +630,35 @@ func parsePart(contentType, transferEncoding string, body []byte) (text, html st
|
|||||||
attachments = append(attachments, atts...)
|
attachments = append(attachments, atts...)
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
// Any other type – treat as attachment if it has a filename
|
// Any other non-text type with a filename → treat as attachment
|
||||||
mt, _, _ := mime.ParseMediaType(contentType)
|
if mt, mtParams, e := mime.ParseMediaType(contentType); e == nil {
|
||||||
_ = mt
|
filename := mtParams["name"]
|
||||||
|
if filename != "" && !strings.HasPrefix(strings.ToLower(mt), "text/") {
|
||||||
|
wd := mime.WordDecoder{}
|
||||||
|
if dec, e2 := wd.DecodeHeader(filename); e2 == nil {
|
||||||
|
filename = dec
|
||||||
|
}
|
||||||
|
attachments = append(attachments, gomailModels.Attachment{
|
||||||
|
Filename: filename,
|
||||||
|
ContentType: mt,
|
||||||
|
Size: int64(len(decoded)),
|
||||||
|
ContentID: mimePathString(path),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mimePathString converts an int path like [1,2] to "1.2".
|
||||||
|
func mimePathString(path []int) string {
|
||||||
|
parts := make([]string, len(path))
|
||||||
|
for i, n := range path {
|
||||||
|
parts[i] = fmt.Sprintf("%d", n)
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ".")
|
||||||
|
}
|
||||||
|
|
||||||
func decodeTransfer(encoding string, data []byte) []byte {
|
func decodeTransfer(encoding string, data []byte) []byte {
|
||||||
switch strings.ToLower(strings.TrimSpace(encoding)) {
|
switch strings.ToLower(strings.TrimSpace(encoding)) {
|
||||||
case "base64":
|
case "base64":
|
||||||
@@ -763,7 +944,8 @@ func SendMessageFull(ctx context.Context, account *gomailModels.EmailAccount, re
|
|||||||
|
|
||||||
func buildMIMEMessage(buf *bytes.Buffer, account *gomailModels.EmailAccount, req *gomailModels.ComposeRequest) string {
|
func buildMIMEMessage(buf *bytes.Buffer, account *gomailModels.EmailAccount, req *gomailModels.ComposeRequest) string {
|
||||||
from := netmail.Address{Name: account.DisplayName, Address: account.EmailAddress}
|
from := netmail.Address{Name: account.DisplayName, Address: account.EmailAddress}
|
||||||
boundary := fmt.Sprintf("gomail_%x", time.Now().UnixNano())
|
altBoundary := fmt.Sprintf("gomail_alt_%x", time.Now().UnixNano())
|
||||||
|
mixedBoundary := fmt.Sprintf("gomail_mix_%x", time.Now().UnixNano()+1)
|
||||||
// Use the sender's actual domain for Message-ID so it passes spam filters
|
// Use the sender's actual domain for Message-ID so it passes spam filters
|
||||||
domain := account.EmailAddress
|
domain := account.EmailAddress
|
||||||
if at := strings.Index(domain, "@"); at >= 0 {
|
if at := strings.Index(domain, "@"); at >= 0 {
|
||||||
@@ -781,24 +963,32 @@ func buildMIMEMessage(buf *bytes.Buffer, account *gomailModels.EmailAccount, req
|
|||||||
buf.WriteString("Subject: " + encodeMIMEHeader(req.Subject) + "\r\n")
|
buf.WriteString("Subject: " + encodeMIMEHeader(req.Subject) + "\r\n")
|
||||||
buf.WriteString("Date: " + time.Now().Format("Mon, 02 Jan 2006 15:04:05 -0700") + "\r\n")
|
buf.WriteString("Date: " + time.Now().Format("Mon, 02 Jan 2006 15:04:05 -0700") + "\r\n")
|
||||||
buf.WriteString("MIME-Version: 1.0\r\n")
|
buf.WriteString("MIME-Version: 1.0\r\n")
|
||||||
buf.WriteString("Content-Type: multipart/alternative; boundary=\"" + boundary + "\"\r\n")
|
|
||||||
buf.WriteString("\r\n")
|
|
||||||
|
|
||||||
// Plain text part
|
hasAttachments := len(req.Attachments) > 0
|
||||||
buf.WriteString("--" + boundary + "\r\n")
|
|
||||||
buf.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
|
if hasAttachments {
|
||||||
buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n")
|
// Outer multipart/mixed wraps body + attachments
|
||||||
qpw := quotedprintable.NewWriter(buf)
|
buf.WriteString("Content-Type: multipart/mixed; boundary=\"" + mixedBoundary + "\"\r\n\r\n")
|
||||||
|
buf.WriteString("--" + mixedBoundary + "\r\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inner multipart/alternative: text/plain + text/html
|
||||||
|
buf.WriteString("Content-Type: multipart/alternative; boundary=\"" + altBoundary + "\"\r\n\r\n")
|
||||||
|
|
||||||
plainText := req.BodyText
|
plainText := req.BodyText
|
||||||
if plainText == "" && req.BodyHTML != "" {
|
if plainText == "" && req.BodyHTML != "" {
|
||||||
plainText = htmlToPlainText(req.BodyHTML)
|
plainText = htmlToPlainText(req.BodyHTML)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
buf.WriteString("--" + altBoundary + "\r\n")
|
||||||
|
buf.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
|
||||||
|
buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n")
|
||||||
|
qpw := quotedprintable.NewWriter(buf)
|
||||||
qpw.Write([]byte(plainText))
|
qpw.Write([]byte(plainText))
|
||||||
qpw.Close()
|
qpw.Close()
|
||||||
buf.WriteString("\r\n")
|
buf.WriteString("\r\n")
|
||||||
|
|
||||||
// HTML part
|
buf.WriteString("--" + altBoundary + "\r\n")
|
||||||
buf.WriteString("--" + boundary + "\r\n")
|
|
||||||
buf.WriteString("Content-Type: text/html; charset=utf-8\r\n")
|
buf.WriteString("Content-Type: text/html; charset=utf-8\r\n")
|
||||||
buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n")
|
buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n")
|
||||||
qpw2 := quotedprintable.NewWriter(buf)
|
qpw2 := quotedprintable.NewWriter(buf)
|
||||||
@@ -809,8 +999,31 @@ func buildMIMEMessage(buf *bytes.Buffer, account *gomailModels.EmailAccount, req
|
|||||||
}
|
}
|
||||||
qpw2.Close()
|
qpw2.Close()
|
||||||
buf.WriteString("\r\n")
|
buf.WriteString("\r\n")
|
||||||
|
buf.WriteString("--" + altBoundary + "--\r\n")
|
||||||
|
|
||||||
|
if hasAttachments {
|
||||||
|
for _, att := range req.Attachments {
|
||||||
|
buf.WriteString("\r\n--" + mixedBoundary + "\r\n")
|
||||||
|
ct := att.ContentType
|
||||||
|
if ct == "" {
|
||||||
|
ct = "application/octet-stream"
|
||||||
|
}
|
||||||
|
encodedName := mime.QEncoding.Encode("utf-8", att.Filename)
|
||||||
|
buf.WriteString("Content-Type: " + ct + "; name=\"" + encodedName + "\"\r\n")
|
||||||
|
buf.WriteString("Content-Transfer-Encoding: base64\r\n")
|
||||||
|
buf.WriteString("Content-Disposition: attachment; filename=\"" + encodedName + "\"\r\n\r\n")
|
||||||
|
encoded := base64.StdEncoding.EncodeToString(att.Data)
|
||||||
|
for i := 0; i < len(encoded); i += 76 {
|
||||||
|
end := i + 76
|
||||||
|
if end > len(encoded) {
|
||||||
|
end = len(encoded)
|
||||||
|
}
|
||||||
|
buf.WriteString(encoded[i:end] + "\r\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buf.WriteString("\r\n--" + mixedBoundary + "--\r\n")
|
||||||
|
}
|
||||||
|
|
||||||
buf.WriteString("--" + boundary + "--\r\n")
|
|
||||||
return msgID
|
return msgID
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -875,6 +1088,123 @@ func (c *Client) AppendToSent(rawMsg []byte) error {
|
|||||||
return c.imap.Append(sentName, flags, now, bytes.NewReader(rawMsg))
|
return c.imap.Append(sentName, flags, now, bytes.NewReader(rawMsg))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AppendToDrafts saves a draft message to the IMAP Drafts folder via APPEND.
|
||||||
|
// Returns the folder name that was used (for sync purposes).
|
||||||
|
func (c *Client) AppendToDrafts(rawMsg []byte) (string, error) {
|
||||||
|
mailboxes, err := c.ListMailboxes()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var draftsName string
|
||||||
|
for _, mb := range mailboxes {
|
||||||
|
ft := InferFolderType(mb.Name, mb.Attributes)
|
||||||
|
if ft == "drafts" {
|
||||||
|
draftsName = mb.Name
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if draftsName == "" {
|
||||||
|
return "", nil // no Drafts folder, skip silently
|
||||||
|
}
|
||||||
|
flags := []string{imap.DraftFlag, imap.SeenFlag}
|
||||||
|
now := time.Now()
|
||||||
|
return draftsName, c.imap.Append(draftsName, flags, now, bytes.NewReader(rawMsg))
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchAttachmentRaw fetches a specific attachment from a message by fetching the full
|
||||||
|
// raw message and parsing the requested MIME part path.
|
||||||
|
func (c *Client) FetchAttachmentRaw(mailboxName string, uid uint32, mimePartPath string) ([]byte, string, string, error) {
|
||||||
|
raw, err := c.FetchRawByUID(mailboxName, uid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", "", fmt.Errorf("fetch raw: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
msg, err := netmail.ReadMessage(bytes.NewReader(raw))
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", "", fmt.Errorf("parse message: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ct := msg.Header.Get("Content-Type")
|
||||||
|
if ct == "" {
|
||||||
|
ct = "text/plain"
|
||||||
|
}
|
||||||
|
body, _ := io.ReadAll(msg.Body)
|
||||||
|
|
||||||
|
data, filename, contentType, err := extractMIMEPart(ct, msg.Header.Get("Content-Transfer-Encoding"), body, mimePartPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", "", err
|
||||||
|
}
|
||||||
|
return data, filename, contentType, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractMIMEPart walks the MIME tree and returns the part at mimePartPath (e.g. "2" or "1.2").
|
||||||
|
func extractMIMEPart(contentType, transferEncoding string, body []byte, targetPath string) ([]byte, string, string, error) {
|
||||||
|
return extractMIMEPartAt(contentType, transferEncoding, body, targetPath, []int{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractMIMEPartAt(contentType, transferEncoding string, body []byte, targetPath string, currentPath []int) ([]byte, string, string, error) {
|
||||||
|
mediaType, params, err := mime.ParseMediaType(contentType)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", "", fmt.Errorf("parse content-type: %w", err)
|
||||||
|
}
|
||||||
|
decoded := decodeTransfer(transferEncoding, body)
|
||||||
|
|
||||||
|
if strings.HasPrefix(strings.ToLower(mediaType), "multipart/") {
|
||||||
|
boundary := params["boundary"]
|
||||||
|
if boundary == "" {
|
||||||
|
return nil, "", "", fmt.Errorf("no boundary")
|
||||||
|
}
|
||||||
|
mr := multipart.NewReader(bytes.NewReader(decoded), boundary)
|
||||||
|
partIdx := 0
|
||||||
|
for {
|
||||||
|
part, err := mr.NextPart()
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
partIdx++
|
||||||
|
childPath := append(append([]int{}, currentPath...), partIdx)
|
||||||
|
childPathStr := mimePathString(childPath)
|
||||||
|
|
||||||
|
partBody, _ := io.ReadAll(part)
|
||||||
|
partCT := part.Header.Get("Content-Type")
|
||||||
|
if partCT == "" {
|
||||||
|
partCT = "text/plain"
|
||||||
|
}
|
||||||
|
partTE := part.Header.Get("Content-Transfer-Encoding")
|
||||||
|
|
||||||
|
if childPathStr == targetPath {
|
||||||
|
// Found it
|
||||||
|
disposition := part.Header.Get("Content-Disposition")
|
||||||
|
_, dispParams, _ := mime.ParseMediaType(disposition)
|
||||||
|
filename := dispParams["filename"]
|
||||||
|
if filename == "" {
|
||||||
|
filename = part.FileName()
|
||||||
|
}
|
||||||
|
wd2 := mime.WordDecoder{}
|
||||||
|
if dec, e := wd2.DecodeHeader(filename); e == nil {
|
||||||
|
filename = dec
|
||||||
|
}
|
||||||
|
partMedia, _, _ := mime.ParseMediaType(partCT)
|
||||||
|
return decodeTransfer(partTE, partBody), filename, partMedia, nil
|
||||||
|
}
|
||||||
|
// Recurse into multipart children
|
||||||
|
partMedia, _, _ := mime.ParseMediaType(partCT)
|
||||||
|
if strings.HasPrefix(strings.ToLower(partMedia), "multipart/") {
|
||||||
|
if data, fn, ct2, e := extractMIMEPartAt(partCT, partTE, partBody, targetPath, childPath); e == nil && data != nil {
|
||||||
|
return data, fn, ct2, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, "", "", fmt.Errorf("part %s not found", targetPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leaf node — only matches if path is root (empty)
|
||||||
|
if targetPath == "" || targetPath == "1" {
|
||||||
|
return decoded, "", strings.ToLower(mediaType), nil
|
||||||
|
}
|
||||||
|
return nil, "", "", fmt.Errorf("part %s not found", targetPath)
|
||||||
|
}
|
||||||
|
|
||||||
func htmlEscape(s string) string {
|
func htmlEscape(s string) string {
|
||||||
s = strings.ReplaceAll(s, "&", "&")
|
s = strings.ReplaceAll(s, "&", "&")
|
||||||
s = strings.ReplaceAll(s, "<", "<")
|
s = strings.ReplaceAll(s, "<", "<")
|
||||||
|
|||||||
97
internal/geo/geo.go
Normal file
97
internal/geo/geo.go
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
// Package geo provides IP geolocation lookup using the free ip-api.com service.
|
||||||
|
// No API key is required. Rate limit: 45 requests/minute on the free tier.
|
||||||
|
// Results are cached in memory to reduce API calls.
|
||||||
|
package geo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GeoResult struct {
|
||||||
|
CountryCode string
|
||||||
|
Country string
|
||||||
|
Cached bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type cacheEntry struct {
|
||||||
|
result GeoResult
|
||||||
|
fetchedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
cache = make(map[string]*cacheEntry)
|
||||||
|
)
|
||||||
|
|
||||||
|
const cacheTTL = 24 * time.Hour
|
||||||
|
|
||||||
|
// Lookup returns the country for an IP address.
|
||||||
|
// Returns empty strings on failure (private IPs, rate limit, etc.).
|
||||||
|
func Lookup(ip string) GeoResult {
|
||||||
|
// Skip private / loopback
|
||||||
|
parsed := net.ParseIP(ip)
|
||||||
|
if parsed == nil || isPrivate(parsed) {
|
||||||
|
return GeoResult{}
|
||||||
|
}
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
if e, ok := cache[ip]; ok && time.Since(e.fetchedAt) < cacheTTL {
|
||||||
|
mu.Unlock()
|
||||||
|
r := e.result
|
||||||
|
r.Cached = true
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
result := fetchFromAPI(ip)
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
cache[ip] = &cacheEntry{result: result, fetchedAt: time.Now()}
|
||||||
|
mu.Unlock()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchFromAPI(ip string) GeoResult {
|
||||||
|
url := fmt.Sprintf("http://ip-api.com/json/%s?fields=status,country,countryCode", ip)
|
||||||
|
client := &http.Client{Timeout: 3 * time.Second}
|
||||||
|
resp, err := client.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("geo lookup failed for %s: %v", ip, err)
|
||||||
|
return GeoResult{}
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var data struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Country string `json:"country"`
|
||||||
|
CountryCode string `json:"countryCode"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil || data.Status != "success" {
|
||||||
|
return GeoResult{}
|
||||||
|
}
|
||||||
|
return GeoResult{
|
||||||
|
CountryCode: strings.ToUpper(data.CountryCode),
|
||||||
|
Country: data.Country,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPrivate(ip net.IP) bool {
|
||||||
|
privateRanges := []string{
|
||||||
|
"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16",
|
||||||
|
"127.0.0.0/8", "::1/128", "fc00::/7",
|
||||||
|
}
|
||||||
|
for _, cidr := range privateRanges {
|
||||||
|
_, network, _ := net.ParseCIDR(cidr)
|
||||||
|
if network != nil && network.Contains(ip) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
"github.com/ghostersk/gowebmail/config"
|
"github.com/ghostersk/gowebmail/config"
|
||||||
"github.com/ghostersk/gowebmail/internal/db"
|
"github.com/ghostersk/gowebmail/internal/db"
|
||||||
|
"github.com/ghostersk/gowebmail/internal/geo"
|
||||||
"github.com/ghostersk/gowebmail/internal/middleware"
|
"github.com/ghostersk/gowebmail/internal/middleware"
|
||||||
"github.com/ghostersk/gowebmail/internal/models"
|
"github.com/ghostersk/gowebmail/internal/models"
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
@@ -111,6 +112,7 @@ func (h *AdminHandler) UpdateUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
IsActive *bool `json:"is_active"`
|
IsActive *bool `json:"is_active"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
|
DisableMFA bool `json:"disable_mfa"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
h.writeError(w, http.StatusBadRequest, "invalid request")
|
h.writeError(w, http.StatusBadRequest, "invalid request")
|
||||||
@@ -133,6 +135,12 @@ func (h *AdminHandler) UpdateUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if req.DisableMFA {
|
||||||
|
if err := h.db.AdminDisableMFAByID(targetID); err != nil {
|
||||||
|
h.writeError(w, http.StatusInternalServerError, "failed to disable MFA")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
adminID := middleware.GetUserID(r)
|
adminID := middleware.GetUserID(r)
|
||||||
h.db.WriteAudit(&adminID, models.AuditUserUpdate,
|
h.db.WriteAudit(&adminID, models.AuditUserUpdate,
|
||||||
@@ -218,3 +226,68 @@ func (h *AdminHandler) SetSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
"changed": changed,
|
"changed": changed,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- IP Blocks ----
|
||||||
|
|
||||||
|
func (h *AdminHandler) ListIPBlocks(w http.ResponseWriter, r *http.Request) {
|
||||||
|
blocks, err := h.db.ListIPBlocks()
|
||||||
|
if err != nil {
|
||||||
|
h.writeError(w, http.StatusInternalServerError, "failed to list blocks")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if blocks == nil {
|
||||||
|
blocks = []db.IPBlock{}
|
||||||
|
}
|
||||||
|
h.writeJSON(w, map[string]interface{}{"blocks": blocks})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) AddIPBlock(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
IP string `json:"ip"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
BanHours int `json:"ban_hours"` // 0 = permanent
|
||||||
|
}
|
||||||
|
json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
if req.IP == "" {
|
||||||
|
h.writeError(w, http.StatusBadRequest, "ip required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Try geo lookup for the IP being manually blocked
|
||||||
|
g := geo.Lookup(req.IP)
|
||||||
|
if req.Reason == "" {
|
||||||
|
req.Reason = "Manual admin block"
|
||||||
|
}
|
||||||
|
h.db.BlockIP(req.IP, req.Reason, g.Country, g.CountryCode, 0, req.BanHours)
|
||||||
|
adminID := middleware.GetUserID(r)
|
||||||
|
h.db.WriteAudit(&adminID, models.AuditConfigChange, "manual IP block: "+req.IP, middleware.ClientIP(r), r.UserAgent())
|
||||||
|
h.writeJSON(w, map[string]bool{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) RemoveIPBlock(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ip := mux.Vars(r)["ip"]
|
||||||
|
if ip == "" {
|
||||||
|
h.writeError(w, http.StatusBadRequest, "ip required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.db.UnblockIP(ip); err != nil {
|
||||||
|
h.writeError(w, http.StatusInternalServerError, "unblock failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
adminID := middleware.GetUserID(r)
|
||||||
|
h.db.WriteAudit(&adminID, models.AuditConfigChange, "unblocked IP: "+ip, middleware.ClientIP(r), r.UserAgent())
|
||||||
|
h.writeJSON(w, map[string]bool{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Login Attempts ----
|
||||||
|
|
||||||
|
func (h *AdminHandler) ListLoginAttempts(w http.ResponseWriter, r *http.Request) {
|
||||||
|
stats, err := h.db.ListLoginAttemptStats(72) // last 72 hours
|
||||||
|
if err != nil {
|
||||||
|
h.writeError(w, http.StatusInternalServerError, "failed to query attempts")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if stats == nil {
|
||||||
|
stats = []db.LoginAttemptStat{}
|
||||||
|
}
|
||||||
|
h.writeJSON(w, map[string]interface{}{"attempts": stats})
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -532,6 +533,26 @@ func (h *APIHandler) GetMessage(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.db.MarkMessageRead(messageID, userID, true)
|
h.db.MarkMessageRead(messageID, userID, true)
|
||||||
|
|
||||||
|
// Lazy attachment backfill: if has_attachment=true but no rows in attachments table
|
||||||
|
// (message was synced before attachment parsing was added), fetch from IMAP now and save.
|
||||||
|
if msg.HasAttachment && len(msg.Attachments) == 0 {
|
||||||
|
if uid, folderPath, account, iErr := h.db.GetMessageIMAPInfo(messageID, userID); iErr == nil && uid != 0 && account != nil {
|
||||||
|
if c, cErr := email.Connect(context.Background(), account); cErr == nil {
|
||||||
|
if raw, rErr := c.FetchRawByUID(folderPath, uid); rErr == nil {
|
||||||
|
_, _, atts := email.ParseMIMEFull(raw)
|
||||||
|
if len(atts) > 0 {
|
||||||
|
h.db.SaveAttachmentMeta(messageID, atts)
|
||||||
|
if fresh, fErr := h.db.GetAttachmentsByMessage(messageID, userID); fErr == nil {
|
||||||
|
msg.Attachments = fresh
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
h.writeJSON(w, msg)
|
h.writeJSON(w, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -652,14 +673,55 @@ func (h *APIHandler) ReplyMessage(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (h *APIHandler) ForwardMessage(w http.ResponseWriter, r *http.Request) {
|
func (h *APIHandler) ForwardMessage(w http.ResponseWriter, r *http.Request) {
|
||||||
h.handleSend(w, r, "forward")
|
h.handleSend(w, r, "forward")
|
||||||
}
|
}
|
||||||
|
func (h *APIHandler) ForwardAsAttachment(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.handleSend(w, r, "forward-attachment")
|
||||||
|
}
|
||||||
|
|
||||||
func (h *APIHandler) handleSend(w http.ResponseWriter, r *http.Request, mode string) {
|
func (h *APIHandler) handleSend(w http.ResponseWriter, r *http.Request, mode string) {
|
||||||
userID := middleware.GetUserID(r)
|
userID := middleware.GetUserID(r)
|
||||||
|
|
||||||
var req models.ComposeRequest
|
var req models.ComposeRequest
|
||||||
|
|
||||||
|
ct := r.Header.Get("Content-Type")
|
||||||
|
if strings.HasPrefix(ct, "multipart/form-data") {
|
||||||
|
// Parse multipart form (attachments present)
|
||||||
|
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
||||||
|
h.writeError(w, http.StatusBadRequest, "invalid multipart form")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
metaStr := r.FormValue("meta")
|
||||||
|
if err := json.NewDecoder(strings.NewReader(metaStr)).Decode(&req); err != nil {
|
||||||
|
h.writeError(w, http.StatusBadRequest, "invalid meta JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.MultipartForm != nil {
|
||||||
|
for _, fheaders := range r.MultipartForm.File {
|
||||||
|
for _, fh := range fheaders {
|
||||||
|
f, err := fh.Open()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data, _ := io.ReadAll(f)
|
||||||
|
f.Close()
|
||||||
|
fileCT := fh.Header.Get("Content-Type")
|
||||||
|
if fileCT == "" {
|
||||||
|
fileCT = "application/octet-stream"
|
||||||
|
}
|
||||||
|
req.Attachments = append(req.Attachments, models.Attachment{
|
||||||
|
Filename: fh.Filename,
|
||||||
|
ContentType: fileCT,
|
||||||
|
Size: int64(len(data)),
|
||||||
|
Data: data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
h.writeError(w, http.StatusBadRequest, "invalid request")
|
h.writeError(w, http.StatusBadRequest, "invalid request")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
account, err := h.db.GetAccount(req.AccountID)
|
account, err := h.db.GetAccount(req.AccountID)
|
||||||
if err != nil || account == nil || account.UserID != userID {
|
if err != nil || account == nil || account.UserID != userID {
|
||||||
@@ -667,6 +729,30 @@ func (h *APIHandler) handleSend(w http.ResponseWriter, r *http.Request, mode str
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Forward-as-attachment: fetch original message as EML and attach it
|
||||||
|
if mode == "forward-attachment" && req.ForwardFromID > 0 {
|
||||||
|
origMsg, _ := h.db.GetMessage(req.ForwardFromID, userID)
|
||||||
|
if origMsg != nil {
|
||||||
|
uid, folderPath, origAccount, iErr := h.db.GetMessageIMAPInfo(req.ForwardFromID, userID)
|
||||||
|
if iErr == nil && uid != 0 && origAccount != nil {
|
||||||
|
if c, cErr := email.Connect(context.Background(), origAccount); cErr == nil {
|
||||||
|
if raw, rErr := c.FetchRawByUID(folderPath, uid); rErr == nil {
|
||||||
|
safe := sanitizeFilename(origMsg.Subject)
|
||||||
|
if safe == "" {
|
||||||
|
safe = "message"
|
||||||
|
}
|
||||||
|
req.Attachments = append(req.Attachments, models.Attachment{
|
||||||
|
Filename: safe + ".eml",
|
||||||
|
ContentType: "message/rfc822",
|
||||||
|
Data: raw,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
c.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := email.SendMessageFull(context.Background(), account, &req); err != nil {
|
if err := email.SendMessageFull(context.Background(), account, &req); err != nil {
|
||||||
log.Printf("SMTP send failed account=%d user=%d: %v", req.AccountID, userID, err)
|
log.Printf("SMTP send failed account=%d user=%d: %v", req.AccountID, userID, err)
|
||||||
h.db.WriteAudit(&userID, models.AuditAppError,
|
h.db.WriteAudit(&userID, models.AuditAppError,
|
||||||
@@ -1062,3 +1148,188 @@ func (h *APIHandler) NewMessagesSince(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
h.writeJSON(w, map[string]interface{}{"messages": msgs})
|
h.writeJSON(w, map[string]interface{}{"messages": msgs})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Attachment download ----
|
||||||
|
|
||||||
|
// DownloadAttachment fetches and streams a message attachment from IMAP.
|
||||||
|
func (h *APIHandler) DownloadAttachment(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := middleware.GetUserID(r)
|
||||||
|
messageID := pathInt64(r, "id")
|
||||||
|
|
||||||
|
// Get attachment metadata from DB
|
||||||
|
attachmentID := pathInt64(r, "att_id")
|
||||||
|
att, err := h.db.GetAttachment(attachmentID, userID)
|
||||||
|
if err != nil || att == nil {
|
||||||
|
h.writeError(w, http.StatusNotFound, "attachment not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = messageID // already verified via GetAttachment ownership check
|
||||||
|
|
||||||
|
// Get IMAP info for the message
|
||||||
|
uid, folderPath, account, iErr := h.db.GetMessageIMAPInfo(att.MessageID, userID)
|
||||||
|
if iErr != nil || uid == 0 || account == nil {
|
||||||
|
h.writeError(w, http.StatusNotFound, "message IMAP info not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c, cErr := email.Connect(context.Background(), account)
|
||||||
|
if cErr != nil {
|
||||||
|
h.writeError(w, http.StatusBadGateway, "IMAP connect failed: "+cErr.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
|
||||||
|
// att.ContentID stores the MIME part path (set during parse)
|
||||||
|
mimePartPath := att.ContentID
|
||||||
|
if mimePartPath == "" {
|
||||||
|
h.writeError(w, http.StatusNotFound, "attachment part path not stored")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data, filename, ct, fetchErr := c.FetchAttachmentRaw(folderPath, uid, mimePartPath)
|
||||||
|
if fetchErr != nil {
|
||||||
|
h.writeError(w, http.StatusBadGateway, "fetch failed: "+fetchErr.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if filename == "" {
|
||||||
|
filename = att.Filename
|
||||||
|
}
|
||||||
|
if ct == "" {
|
||||||
|
ct = att.ContentType
|
||||||
|
}
|
||||||
|
if ct == "" {
|
||||||
|
ct = "application/octet-stream"
|
||||||
|
}
|
||||||
|
|
||||||
|
safe := sanitizeFilename(filename)
|
||||||
|
// For browser-viewable types, use inline disposition so they open in a new tab.
|
||||||
|
// For everything else, force download.
|
||||||
|
disposition := "attachment"
|
||||||
|
ctLower := strings.ToLower(ct)
|
||||||
|
if strings.HasPrefix(ctLower, "image/") ||
|
||||||
|
strings.HasPrefix(ctLower, "text/") ||
|
||||||
|
strings.HasPrefix(ctLower, "video/") ||
|
||||||
|
strings.HasPrefix(ctLower, "audio/") ||
|
||||||
|
ctLower == "application/pdf" {
|
||||||
|
disposition = "inline"
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Disposition", fmt.Sprintf(`%s; filename="%s"`, disposition, safe))
|
||||||
|
w.Header().Set("Content-Type", ct)
|
||||||
|
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAttachments returns stored attachment metadata for a message.
|
||||||
|
func (h *APIHandler) ListAttachments(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := middleware.GetUserID(r)
|
||||||
|
messageID := pathInt64(r, "id")
|
||||||
|
atts, err := h.db.GetAttachmentsByMessage(messageID, userID)
|
||||||
|
if err != nil {
|
||||||
|
h.writeError(w, http.StatusInternalServerError, "failed to list attachments")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if atts == nil {
|
||||||
|
atts = []models.Attachment{}
|
||||||
|
}
|
||||||
|
// Strip raw data from response, keep metadata only
|
||||||
|
type attMeta struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
MessageID int64 `json:"message_id"`
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
ContentType string `json:"content_type"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
}
|
||||||
|
result := make([]attMeta, len(atts))
|
||||||
|
for i, a := range atts {
|
||||||
|
result[i] = attMeta{a.ID, a.MessageID, a.Filename, a.ContentType, a.Size}
|
||||||
|
}
|
||||||
|
h.writeJSON(w, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Mark folder all read ----
|
||||||
|
|
||||||
|
func (h *APIHandler) MarkFolderAllRead(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := middleware.GetUserID(r)
|
||||||
|
folderID := pathInt64(r, "id")
|
||||||
|
|
||||||
|
ops, err := h.db.MarkFolderAllRead(folderID, userID)
|
||||||
|
if err != nil {
|
||||||
|
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enqueue all flag_read ops and trigger sync
|
||||||
|
accountIDs := map[int64]bool{}
|
||||||
|
for _, op := range ops {
|
||||||
|
h.db.EnqueueIMAPOp(&db.PendingIMAPOp{
|
||||||
|
AccountID: op.AccountID, OpType: "flag_read",
|
||||||
|
RemoteUID: op.RemoteUID, FolderPath: op.FolderPath, Extra: "1",
|
||||||
|
})
|
||||||
|
accountIDs[op.AccountID] = true
|
||||||
|
}
|
||||||
|
for accID := range accountIDs {
|
||||||
|
h.syncer.TriggerAccountSync(accID)
|
||||||
|
}
|
||||||
|
|
||||||
|
h.writeJSON(w, map[string]interface{}{"ok": true, "marked": len(ops)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Save draft (IMAP APPEND to Drafts) ----
|
||||||
|
|
||||||
|
func (h *APIHandler) SaveDraft(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := middleware.GetUserID(r)
|
||||||
|
|
||||||
|
var req models.ComposeRequest
|
||||||
|
ct := r.Header.Get("Content-Type")
|
||||||
|
if strings.HasPrefix(ct, "multipart/form-data") {
|
||||||
|
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
||||||
|
h.writeError(w, http.StatusBadRequest, "invalid form")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
json.NewDecoder(strings.NewReader(r.FormValue("meta"))).Decode(&req)
|
||||||
|
} else {
|
||||||
|
json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
}
|
||||||
|
|
||||||
|
account, err := h.db.GetAccount(req.AccountID)
|
||||||
|
if err != nil || account == nil || account.UserID != userID {
|
||||||
|
h.writeError(w, http.StatusBadRequest, "account not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the MIME message bytes
|
||||||
|
var buf strings.Builder
|
||||||
|
buf.WriteString("From: " + account.EmailAddress + "\r\n")
|
||||||
|
if len(req.To) > 0 {
|
||||||
|
buf.WriteString("To: " + strings.Join(req.To, ", ") + "\r\n")
|
||||||
|
}
|
||||||
|
buf.WriteString("Subject: " + req.Subject + "\r\n")
|
||||||
|
buf.WriteString("MIME-Version: 1.0\r\n")
|
||||||
|
buf.WriteString("Content-Type: text/html; charset=utf-8\r\n\r\n")
|
||||||
|
buf.WriteString(req.BodyHTML)
|
||||||
|
|
||||||
|
raw := []byte(buf.String())
|
||||||
|
|
||||||
|
// Append to IMAP Drafts in background
|
||||||
|
go func() {
|
||||||
|
c, err := email.Connect(context.Background(), account)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[draft] IMAP connect %s: %v", account.EmailAddress, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
draftsFolder, err := c.AppendToDrafts(raw)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[draft] AppendToDrafts %s: %v", account.EmailAddress, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if draftsFolder != "" {
|
||||||
|
// Trigger a sync of the drafts folder to pick up the saved draft
|
||||||
|
h.syncer.TriggerAccountSync(account.ID)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
h.writeJSON(w, map[string]bool{"ok": true})
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -53,6 +54,17 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-user IP access check — evaluated before password to avoid timing leaks
|
||||||
|
switch h.db.CheckUserIPAccess(user.ID, ip) {
|
||||||
|
case "deny":
|
||||||
|
h.db.WriteAudit(&user.ID, models.AuditLoginFail, "IP not in allow-list: "+ip, ip, ua)
|
||||||
|
http.Redirect(w, r, "/auth/login?error=location_not_authorized", http.StatusFound)
|
||||||
|
return
|
||||||
|
case "skip_brute":
|
||||||
|
// Signal the BruteForceProtect middleware to skip failure counting for this user/IP
|
||||||
|
w.Header().Set("X-Skip-Brute", "1")
|
||||||
|
}
|
||||||
|
|
||||||
if err := crypto.CheckPassword(password, user.PasswordHash); err != nil {
|
if err := crypto.CheckPassword(password, user.PasswordHash); err != nil {
|
||||||
uid := user.ID
|
uid := user.ID
|
||||||
h.db.WriteAudit(&uid, models.AuditLoginFail, "bad password for: "+username, ip, ua)
|
h.db.WriteAudit(&uid, models.AuditLoginFail, "bad password for: "+username, ip, ua)
|
||||||
@@ -142,8 +154,8 @@ func (h *AuthHandler) MFASetupBegin(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
qr := mfa.QRCodeURL("GoMail", user.Email, secret)
|
qr := mfa.QRCodeURL("GoWebMail", user.Email, secret)
|
||||||
otpURL := mfa.OTPAuthURL("GoMail", user.Email, secret)
|
otpURL := mfa.OTPAuthURL("GoWebMail", user.Email, secret)
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]string{
|
json.NewEncoder(w).Encode(map[string]string{
|
||||||
@@ -403,3 +415,119 @@ func writeJSONError(w http.ResponseWriter, status int, msg string) {
|
|||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Profile Updates ----
|
||||||
|
|
||||||
|
func (h *AuthHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := middleware.GetUserID(r)
|
||||||
|
user, err := h.db.GetUserByID(userID)
|
||||||
|
if err != nil || user == nil {
|
||||||
|
writeJSONError(w, http.StatusUnauthorized, "not authenticated")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Field string `json:"field"` // "email" | "username"
|
||||||
|
Value string `json:"value"`
|
||||||
|
Password string `json:"password"` // current password required for confirmation
|
||||||
|
}
|
||||||
|
json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
|
||||||
|
if req.Value == "" {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, "value required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Password == "" {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, "current password required to confirm profile changes")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := crypto.CheckPassword(req.Password, user.PasswordHash); err != nil {
|
||||||
|
writeJSONError(w, http.StatusForbidden, "incorrect password")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch req.Field {
|
||||||
|
case "email":
|
||||||
|
// Check uniqueness
|
||||||
|
existing, _ := h.db.GetUserByEmail(req.Value)
|
||||||
|
if existing != nil && existing.ID != userID {
|
||||||
|
writeJSONError(w, http.StatusConflict, "email already in use")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.db.UpdateUserEmail(userID, req.Value); err != nil {
|
||||||
|
writeJSONError(w, http.StatusInternalServerError, "failed to update email")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case "username":
|
||||||
|
existing, _ := h.db.GetUserByUsername(req.Value)
|
||||||
|
if existing != nil && existing.ID != userID {
|
||||||
|
writeJSONError(w, http.StatusConflict, "username already in use")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.db.UpdateUserUsername(userID, req.Value); err != nil {
|
||||||
|
writeJSONError(w, http.StatusInternalServerError, "failed to update username")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
writeJSONError(w, http.StatusBadRequest, "field must be 'email' or 'username'")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ip := middleware.ClientIP(r)
|
||||||
|
h.db.WriteAudit(&userID, models.AuditUserUpdate, "profile update: "+req.Field, ip, r.UserAgent())
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]bool{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Per-User IP Rules ----
|
||||||
|
|
||||||
|
func (h *AuthHandler) GetUserIPRule(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := middleware.GetUserID(r)
|
||||||
|
rule, err := h.db.GetUserIPRule(userID)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONError(w, http.StatusInternalServerError, "db error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if rule == nil {
|
||||||
|
rule = &db.UserIPRule{UserID: userID, Mode: "disabled", IPList: ""}
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) SetUserIPRule(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := middleware.GetUserID(r)
|
||||||
|
var req struct {
|
||||||
|
Mode string `json:"mode"` // "disabled" | "brute_skip" | "allow_only"
|
||||||
|
IPList string `json:"ip_list"` // comma-separated
|
||||||
|
}
|
||||||
|
json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
|
||||||
|
validModes := map[string]bool{"disabled": true, "brute_skip": true, "allow_only": true}
|
||||||
|
if !validModes[req.Mode] {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, "mode must be disabled, brute_skip, or allow_only")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate IPs
|
||||||
|
for _, rawIP := range db.SplitIPList(req.IPList) {
|
||||||
|
if net.ParseIP(rawIP) == nil {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, "invalid IP address: "+rawIP)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Mode == "disabled" {
|
||||||
|
h.db.DeleteUserIPRule(userID)
|
||||||
|
} else {
|
||||||
|
if err := h.db.SetUserIPRule(userID, req.Mode, req.IPList); err != nil {
|
||||||
|
writeJSONError(w, http.StatusInternalServerError, "failed to save rule")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ip := middleware.ClientIP(r)
|
||||||
|
h.db.WriteAudit(&userID, models.AuditUserUpdate, "IP rule updated: "+req.Mode, ip, r.UserAgent())
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]bool{"ok": true})
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ func GenerateSecret() (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// OTPAuthURL builds an otpauth:// URI for QR code generation.
|
// OTPAuthURL builds an otpauth:// URI for QR code generation.
|
||||||
// issuer is the application name (e.g. "GoMail"), accountName is the user's email.
|
// issuer is the application name (e.g. "GoWebMail"), accountName is the user's email.
|
||||||
func OTPAuthURL(issuer, accountName, secret string) string {
|
func OTPAuthURL(issuer, accountName, secret string) string {
|
||||||
v := url.Values{}
|
v := url.Values{}
|
||||||
v.Set("secret", secret)
|
v.Set("secret", secret)
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
// Package middleware provides HTTP middleware for GoMail.
|
// Package middleware provides HTTP middleware for GoWebMail.
|
||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -11,7 +13,9 @@ import (
|
|||||||
|
|
||||||
"github.com/ghostersk/gowebmail/config"
|
"github.com/ghostersk/gowebmail/config"
|
||||||
"github.com/ghostersk/gowebmail/internal/db"
|
"github.com/ghostersk/gowebmail/internal/db"
|
||||||
|
"github.com/ghostersk/gowebmail/internal/geo"
|
||||||
"github.com/ghostersk/gowebmail/internal/models"
|
"github.com/ghostersk/gowebmail/internal/models"
|
||||||
|
"github.com/ghostersk/gowebmail/internal/notify"
|
||||||
)
|
)
|
||||||
|
|
||||||
type contextKey string
|
type contextKey string
|
||||||
@@ -47,7 +51,7 @@ func SecurityHeaders(next http.Handler) http.Handler {
|
|||||||
w.Header().Set("X-XSS-Protection", "1; mode=block")
|
w.Header().Set("X-XSS-Protection", "1; mode=block")
|
||||||
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||||
w.Header().Set("Content-Security-Policy",
|
w.Header().Set("Content-Security-Policy",
|
||||||
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src * data: blob:; frame-src 'self' blob:;")
|
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src * data: blob: cid:; frame-src 'self' blob: data:;")
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -117,9 +121,13 @@ func RequireAdmin(next http.Handler) http.Handler {
|
|||||||
role, _ := r.Context().Value(UserRoleKey).(models.UserRole)
|
role, _ := r.Context().Value(UserRoleKey).(models.UserRole)
|
||||||
if role != models.RoleAdmin {
|
if role != models.RoleAdmin {
|
||||||
if isAPIPath(r) {
|
if isAPIPath(r) {
|
||||||
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
fmt.Fprint(w, `{"error":"forbidden"}`)
|
||||||
} else {
|
} else {
|
||||||
http.Error(w, "403 Forbidden", http.StatusForbidden)
|
renderErrorPage(w, r, http.StatusForbidden,
|
||||||
|
"Access Denied",
|
||||||
|
"You don't have permission to access this page. Admin privileges are required.")
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -169,3 +177,216 @@ func ClientIP(r *http.Request) string {
|
|||||||
}
|
}
|
||||||
return r.RemoteAddr
|
return r.RemoteAddr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BruteForceProtect wraps the login POST handler with rate-limiting and geo-blocking.
|
||||||
|
// It must be called with the raw handler so it can intercept BEFORE auth.
|
||||||
|
func BruteForceProtect(database *db.DB, cfg *config.Config, next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ip := cfg.RealIP(r.RemoteAddr, r.Header.Get("X-Forwarded-For"))
|
||||||
|
|
||||||
|
// Whitelist check runs FIRST — whitelisted IPs bypass all blocking entirely.
|
||||||
|
if cfg.IsIPWhitelisted(ip) {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve country for geo-block and attempt recording.
|
||||||
|
// Only do a live lookup for non-GET to save API quota; GET uses cache only.
|
||||||
|
geoResult := geo.Lookup(ip)
|
||||||
|
|
||||||
|
// --- Geo block (apply to all requests) ---
|
||||||
|
if geoResult.CountryCode != "" {
|
||||||
|
if !cfg.IsCountryAllowed(geoResult.CountryCode) {
|
||||||
|
log.Printf("geo-block: %s (%s %s)", ip, geoResult.CountryCode, geoResult.Country)
|
||||||
|
renderErrorPage(w, r, http.StatusForbidden,
|
||||||
|
"Access Denied",
|
||||||
|
"Access from your country is not permitted.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !cfg.BruteEnabled || r.Method != http.MethodPost {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already blocked
|
||||||
|
if database.IsIPBlocked(ip) {
|
||||||
|
renderErrorPage(w, r, http.StatusForbidden,
|
||||||
|
"IP Address Blocked",
|
||||||
|
"Your IP address has been temporarily blocked due to too many failed login attempts. Please contact the administrator.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap the response writer to detect a failed login (redirect to error vs success)
|
||||||
|
rw := &loginResponseCapture{ResponseWriter: w, statusCode: 200}
|
||||||
|
next.ServeHTTP(rw, r)
|
||||||
|
|
||||||
|
// Determine success: a redirect away from login = success
|
||||||
|
success := rw.statusCode == http.StatusFound && !strings.Contains(rw.location, "error=")
|
||||||
|
username := r.FormValue("username")
|
||||||
|
database.RecordLoginAttempt(ip, username, geoResult.Country, geoResult.CountryCode, success)
|
||||||
|
|
||||||
|
if !success && !rw.skipBrute {
|
||||||
|
failures := database.CountRecentFailures(ip, cfg.BruteWindowMins)
|
||||||
|
if failures >= cfg.BruteMaxAttempts {
|
||||||
|
reason := "Too many failed logins"
|
||||||
|
database.BlockIP(ip, reason, geoResult.Country, geoResult.CountryCode, failures, cfg.BruteBanHours)
|
||||||
|
log.Printf("brute-force block: %s (%d failures in %d min, ban %d hrs)",
|
||||||
|
ip, failures, cfg.BruteWindowMins, cfg.BruteBanHours)
|
||||||
|
|
||||||
|
// Send security notification to the targeted user (non-blocking goroutine)
|
||||||
|
go func(targetUsername string) {
|
||||||
|
user, _ := database.GetUserByUsername(targetUsername)
|
||||||
|
if user == nil {
|
||||||
|
user, _ = database.GetUserByEmail(targetUsername)
|
||||||
|
}
|
||||||
|
if user != nil && user.Email != "" {
|
||||||
|
notify.SendBruteForceAlert(cfg, notify.BruteForceAlert{
|
||||||
|
Username: user.Username,
|
||||||
|
ToEmail: user.Email,
|
||||||
|
AttackerIP: ip,
|
||||||
|
Country: geoResult.Country,
|
||||||
|
CountryCode: geoResult.CountryCode,
|
||||||
|
Attempts: failures,
|
||||||
|
BlockedAt: time.Now().UTC(),
|
||||||
|
BanHours: cfg.BruteBanHours,
|
||||||
|
Hostname: cfg.Hostname,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}(username)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// loginResponseCapture captures the redirect location and skip-brute signal from the login handler.
|
||||||
|
type loginResponseCapture struct {
|
||||||
|
http.ResponseWriter
|
||||||
|
statusCode int
|
||||||
|
location string
|
||||||
|
skipBrute bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lrc *loginResponseCapture) WriteHeader(code int) {
|
||||||
|
lrc.statusCode = code
|
||||||
|
lrc.location = lrc.ResponseWriter.Header().Get("Location")
|
||||||
|
if lrc.Header().Get("X-Skip-Brute") == "1" {
|
||||||
|
lrc.skipBrute = true
|
||||||
|
lrc.Header().Del("X-Skip-Brute") // strip before sending to client
|
||||||
|
}
|
||||||
|
lrc.ResponseWriter.WriteHeader(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeErrorPage is the public wrapper used by main.go for 404/405 handlers.
|
||||||
|
func ServeErrorPage(w http.ResponseWriter, r *http.Request, status int, title, message string) {
|
||||||
|
renderErrorPage(w, r, status, title, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderErrorPage writes a themed HTML error page for browser requests,
|
||||||
|
// or a JSON error for API paths.
|
||||||
|
func renderErrorPage(w http.ResponseWriter, r *http.Request, status int, title, message string) {
|
||||||
|
if isAPIPath(r) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
fmt.Fprintf(w, `{"error":%q}`, message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Decide back-button destination: if the user has a session cookie they're
|
||||||
|
// likely logged in, so send them home. Otherwise send to login.
|
||||||
|
backHref := "/auth/login"
|
||||||
|
backLabel := "← Back to Login"
|
||||||
|
if _, err := r.Cookie("gomail_session"); err == nil {
|
||||||
|
backHref = "/"
|
||||||
|
backLabel = "← Go to Home"
|
||||||
|
}
|
||||||
|
|
||||||
|
data := struct {
|
||||||
|
Status int
|
||||||
|
Title string
|
||||||
|
Message string
|
||||||
|
BackHref string
|
||||||
|
BackLabel string
|
||||||
|
}{status, title, message, backHref, backLabel}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
if err := errorPageTmpl.Execute(w, data); err != nil {
|
||||||
|
// Last-resort plain text fallback
|
||||||
|
fmt.Fprintf(w, "%d %s: %s", status, title, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var errorPageTmpl = template.Must(template.New("error").Parse(`<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{.Status}} – {{.Title}}</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="/static/css/gowebmail.css">
|
||||||
|
<style>
|
||||||
|
html, body { height: 100%; margin: 0; }
|
||||||
|
.error-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg, #18191b);
|
||||||
|
font-family: 'DM Sans', sans-serif;
|
||||||
|
}
|
||||||
|
.error-card {
|
||||||
|
background: var(--surface, #232428);
|
||||||
|
border: 1px solid var(--border, #2e2f34);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 48px 56px;
|
||||||
|
text-align: center;
|
||||||
|
max-width: 480px;
|
||||||
|
width: 90%;
|
||||||
|
box-shadow: 0 8px 32px rgba(0,0,0,.4);
|
||||||
|
}
|
||||||
|
.error-code {
|
||||||
|
font-size: 64px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent, #6b8afd);
|
||||||
|
line-height: 1;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
letter-spacing: -2px;
|
||||||
|
}
|
||||||
|
.error-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text, #e8e9ed);
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
.error-message {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--muted, #8b8d97);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0 0 32px;
|
||||||
|
}
|
||||||
|
.error-back {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 10px 24px;
|
||||||
|
background: var(--accent, #6b8afd);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: opacity .15s;
|
||||||
|
}
|
||||||
|
.error-back:hover { opacity: .85; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="error-page">
|
||||||
|
<div class="error-card">
|
||||||
|
<div class="error-code">{{.Status}}</div>
|
||||||
|
<h1 class="error-title">{{.Title}}</h1>
|
||||||
|
<p class="error-message">{{.Message}}</p>
|
||||||
|
<a href="{{.BackHref}}" class="error-back">{{.BackLabel}}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`))
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import "time"
|
|||||||
|
|
||||||
// ---- Users ----
|
// ---- Users ----
|
||||||
|
|
||||||
// UserRole controls access level within GoMail.
|
// UserRole controls access level within GoWebMail.
|
||||||
type UserRole string
|
type UserRole string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -12,7 +12,7 @@ const (
|
|||||||
RoleUser UserRole = "user"
|
RoleUser UserRole = "user"
|
||||||
)
|
)
|
||||||
|
|
||||||
// User represents a GoMail application user.
|
// User represents a GoWebMail application user.
|
||||||
type User struct {
|
type User struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
@@ -213,6 +213,8 @@ type ComposeRequest struct {
|
|||||||
// For reply/forward
|
// For reply/forward
|
||||||
InReplyToID int64 `json:"in_reply_to_id,omitempty"`
|
InReplyToID int64 `json:"in_reply_to_id,omitempty"`
|
||||||
ForwardFromID int64 `json:"forward_from_id,omitempty"`
|
ForwardFromID int64 `json:"forward_from_id,omitempty"`
|
||||||
|
// Attachments: populated from multipart/form-data or inline base64
|
||||||
|
Attachments []Attachment `json:"attachments,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Search ----
|
// ---- Search ----
|
||||||
|
|||||||
201
internal/notify/notify.go
Normal file
201
internal/notify/notify.go
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
// Package notify sends security alert emails using a configurable SMTP relay.
|
||||||
|
// It supports both authenticated and unauthenticated (relay-only) SMTP servers.
|
||||||
|
package notify
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/tls"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"net/smtp"
|
||||||
|
"strings"
|
||||||
|
"text/template"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ghostersk/gowebmail/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BruteForceAlert holds the data for the brute-force notification email.
|
||||||
|
type BruteForceAlert struct {
|
||||||
|
Username string
|
||||||
|
ToEmail string
|
||||||
|
AttackerIP string
|
||||||
|
Country string
|
||||||
|
CountryCode string
|
||||||
|
Attempts int
|
||||||
|
BlockedAt time.Time
|
||||||
|
BanHours int // 0 = permanent
|
||||||
|
AppName string
|
||||||
|
Hostname string
|
||||||
|
}
|
||||||
|
|
||||||
|
var bruteForceTemplate = template.Must(template.New("brute").Parse(`From: {{.AppName}} Security <{{.From}}>
|
||||||
|
To: {{.ToEmail}}
|
||||||
|
Subject: Security Alert: Failed login attempts on your account
|
||||||
|
MIME-Version: 1.0
|
||||||
|
Content-Type: text/plain; charset=utf-8
|
||||||
|
|
||||||
|
Hello {{.Username}},
|
||||||
|
|
||||||
|
This is an automated security alert from {{.AppName}} ({{.Hostname}}).
|
||||||
|
|
||||||
|
We detected multiple failed login attempts on your account and have
|
||||||
|
automatically blocked the source IP address.
|
||||||
|
|
||||||
|
Account targeted : {{.Username}}
|
||||||
|
Source IP : {{.AttackerIP}}
|
||||||
|
{{- if .Country}}
|
||||||
|
Country : {{.Country}} ({{.CountryCode}})
|
||||||
|
{{- end}}
|
||||||
|
Failed attempts : {{.Attempts}}
|
||||||
|
Detected at : {{.BlockedAt.Format "2006-01-02 15:04:05 UTC"}}
|
||||||
|
{{- if eq .BanHours 0}}
|
||||||
|
Block duration : Permanent (administrator action required to unblock)
|
||||||
|
{{- else}}
|
||||||
|
Block duration : {{.BanHours}} hours
|
||||||
|
{{- end}}
|
||||||
|
|
||||||
|
If this was you, you may have mistyped your password. The block will
|
||||||
|
{{- if eq .BanHours 0}} remain until removed by an administrator.
|
||||||
|
{{- else}} expire automatically after {{.BanHours}} hours.{{end}}
|
||||||
|
|
||||||
|
If you did not attempt to log in, your account credentials may be at
|
||||||
|
risk. We recommend changing your password as soon as possible.
|
||||||
|
|
||||||
|
This is an automated message. Please do not reply.
|
||||||
|
|
||||||
|
--
|
||||||
|
{{.AppName}} Security
|
||||||
|
{{.Hostname}}
|
||||||
|
`))
|
||||||
|
|
||||||
|
type templateData struct {
|
||||||
|
BruteForceAlert
|
||||||
|
From string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendBruteForceAlert sends a security notification email to the targeted user.
|
||||||
|
// It runs in a goroutine — errors are logged but not returned.
|
||||||
|
func SendBruteForceAlert(cfg *config.Config, alert BruteForceAlert) {
|
||||||
|
if !cfg.NotifyEnabled || cfg.NotifySMTPHost == "" || cfg.NotifyFrom == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if alert.ToEmail == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
if err := sendAlert(cfg, alert); err != nil {
|
||||||
|
log.Printf("notify: failed to send brute-force alert to %s: %v", alert.ToEmail, err)
|
||||||
|
} else {
|
||||||
|
log.Printf("notify: sent brute-force alert to %s (attacker: %s)", alert.ToEmail, alert.AttackerIP)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendAlert(cfg *config.Config, alert BruteForceAlert) error {
|
||||||
|
if alert.AppName == "" {
|
||||||
|
alert.AppName = "GoWebMail"
|
||||||
|
}
|
||||||
|
if alert.Hostname == "" {
|
||||||
|
alert.Hostname = cfg.Hostname
|
||||||
|
}
|
||||||
|
|
||||||
|
data := templateData{BruteForceAlert: alert, From: cfg.NotifyFrom}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := bruteForceTemplate.Execute(&buf, data); err != nil {
|
||||||
|
return fmt.Errorf("template execute: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
addr := fmt.Sprintf("%s:%d", cfg.NotifySMTPHost, cfg.NotifySMTPPort)
|
||||||
|
|
||||||
|
// Choose auth method
|
||||||
|
var auth smtp.Auth
|
||||||
|
if cfg.NotifyUser != "" && cfg.NotifyPass != "" {
|
||||||
|
auth = smtp.PlainAuth("", cfg.NotifyUser, cfg.NotifyPass, cfg.NotifySMTPHost)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try STARTTLS first (port 587), fall back to plain, support TLS on 465
|
||||||
|
if cfg.NotifySMTPPort == 465 {
|
||||||
|
return sendTLS(addr, cfg.NotifySMTPHost, auth, cfg.NotifyFrom, alert.ToEmail, buf.Bytes())
|
||||||
|
}
|
||||||
|
return sendSTARTTLS(addr, cfg.NotifySMTPHost, auth, cfg.NotifyFrom, alert.ToEmail, buf.Bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendSTARTTLS sends via plain SMTP with optional STARTTLS upgrade (ports 25, 587).
|
||||||
|
func sendSTARTTLS(addr, host string, auth smtp.Auth, from, to string, msg []byte) error {
|
||||||
|
c, err := smtp.Dial(addr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("dial %s: %w", addr, err)
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
|
||||||
|
// Try STARTTLS — not all servers require it (plain relay servers often skip it)
|
||||||
|
if ok, _ := c.Extension("STARTTLS"); ok {
|
||||||
|
tlsCfg := &tls.Config{ServerName: host}
|
||||||
|
if err := c.StartTLS(tlsCfg); err != nil {
|
||||||
|
// Log but continue — some relays advertise STARTTLS but don't enforce it
|
||||||
|
log.Printf("notify: STARTTLS failed for %s, continuing unencrypted: %v", host, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if auth != nil {
|
||||||
|
if err := c.Auth(auth); err != nil {
|
||||||
|
return fmt.Errorf("smtp auth: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sendMessage(c, from, to, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendTLS sends via direct TLS connection (port 465).
|
||||||
|
func sendTLS(addr, host string, auth smtp.Auth, from, to string, msg []byte) error {
|
||||||
|
tlsCfg := &tls.Config{ServerName: host}
|
||||||
|
conn, err := tls.Dial("tcp", addr, tlsCfg)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("tls dial %s: %w", addr, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve host for the smtp.NewClient call
|
||||||
|
bareHost, _, _ := net.SplitHostPort(addr)
|
||||||
|
if bareHost == "" {
|
||||||
|
bareHost = host
|
||||||
|
}
|
||||||
|
|
||||||
|
c, err := smtp.NewClient(conn, bareHost)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("smtp client: %w", err)
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
|
||||||
|
if auth != nil {
|
||||||
|
if err := c.Auth(auth); err != nil {
|
||||||
|
return fmt.Errorf("smtp auth: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sendMessage(c, from, to, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendMessage(c *smtp.Client, from, to string, msg []byte) error {
|
||||||
|
if err := c.Mail(from); err != nil {
|
||||||
|
return fmt.Errorf("MAIL FROM: %w", err)
|
||||||
|
}
|
||||||
|
if err := c.Rcpt(to); err != nil {
|
||||||
|
return fmt.Errorf("RCPT TO: %w", err)
|
||||||
|
}
|
||||||
|
w, err := c.Data()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("DATA: %w", err)
|
||||||
|
}
|
||||||
|
// Normalise line endings to CRLF
|
||||||
|
normalized := strings.ReplaceAll(string(msg), "\r\n", "\n")
|
||||||
|
normalized = strings.ReplaceAll(normalized, "\n", "\r\n")
|
||||||
|
if _, err := w.Write([]byte(normalized)); err != nil {
|
||||||
|
return fmt.Errorf("write body: %w", err)
|
||||||
|
}
|
||||||
|
if err := w.Close(); err != nil {
|
||||||
|
return fmt.Errorf("close data: %w", err)
|
||||||
|
}
|
||||||
|
return c.Quit()
|
||||||
|
}
|
||||||
@@ -446,6 +446,10 @@ func (s *Scheduler) syncFolder(c *email.Client, account *models.EmailAccount, db
|
|||||||
msg.FolderID = dbFolder.ID
|
msg.FolderID = dbFolder.ID
|
||||||
if err := s.db.UpsertMessage(msg); err == nil {
|
if err := s.db.UpsertMessage(msg); err == nil {
|
||||||
newMessages++
|
newMessages++
|
||||||
|
// Save attachment metadata if any (enables download)
|
||||||
|
if len(msg.Attachments) > 0 && msg.ID > 0 {
|
||||||
|
_ = s.db.SaveAttachmentMeta(msg.ID, msg.Attachments)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
uid := uint32(0)
|
uid := uint32(0)
|
||||||
fmt.Sscanf(msg.RemoteUID, "%d", &uid)
|
fmt.Sscanf(msg.RemoteUID, "%d", &uid)
|
||||||
|
|||||||
@@ -369,10 +369,13 @@ body.admin-page{overflow:auto;background:var(--bg)}
|
|||||||
|
|
||||||
/* ---- Attachment chips ---- */
|
/* ---- Attachment chips ---- */
|
||||||
.attachment-chip{display:inline-flex;align-items:center;gap:5px;padding:4px 10px;
|
.attachment-chip{display:inline-flex;align-items:center;gap:5px;padding:4px 10px;
|
||||||
background:var(--surface3);border:1px solid var(--border2);border-radius:6px;font-size:12px;cursor:pointer}
|
background:var(--surface3);border:1px solid var(--border2);border-radius:6px;font-size:12px;cursor:pointer;
|
||||||
|
text-decoration:none;color:inherit}
|
||||||
.attachment-chip:hover{background:var(--border2)}
|
.attachment-chip:hover{background:var(--border2)}
|
||||||
.attachments-bar{display:flex;align-items:center;flex-wrap:wrap;gap:6px;
|
.attachments-bar{display:flex;align-items:center;flex-wrap:wrap;gap:6px;
|
||||||
padding:8px 14px;border-bottom:1px solid var(--border)}
|
padding:8px 14px;border-bottom:1px solid var(--border)}
|
||||||
|
/* Drag-and-drop compose overlay */
|
||||||
|
.compose-dialog.drag-over{outline:3px dashed var(--accent);outline-offset:-4px;}
|
||||||
|
|
||||||
/* ── Email tag input ─────────────────────────────────────────── */
|
/* ── Email tag input ─────────────────────────────────────────── */
|
||||||
.tag-container{display:flex;flex-wrap:wrap;align-items:center;gap:4px;flex:1;
|
.tag-container{display:flex;flex-wrap:wrap;align-items:center;gap:4px;flex:1;
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
// GoMail Admin SPA
|
// GoWebMail Admin SPA
|
||||||
|
|
||||||
const adminRoutes = {
|
const adminRoutes = {
|
||||||
'/admin': renderUsers,
|
'/admin': renderUsers,
|
||||||
'/admin/settings': renderSettings,
|
'/admin/settings': renderSettings,
|
||||||
'/admin/audit': renderAudit,
|
'/admin/audit': renderAudit,
|
||||||
|
'/admin/security': renderSecurity,
|
||||||
};
|
};
|
||||||
|
|
||||||
function navigate(path) {
|
function navigate(path) {
|
||||||
@@ -26,7 +27,7 @@ async function renderUsers() {
|
|||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
<div class="admin-page-header">
|
<div class="admin-page-header">
|
||||||
<h1>Users</h1>
|
<h1>Users</h1>
|
||||||
<p>Manage GoMail accounts and permissions.</p>
|
<p>Manage GoWebMail accounts and permissions.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="admin-card">
|
<div class="admin-card">
|
||||||
<div style="display:flex;justify-content:flex-end;margin-bottom:16px">
|
<div style="display:flex;justify-content:flex-end;margin-bottom:16px">
|
||||||
@@ -67,16 +68,19 @@ async function loadUsersTable() {
|
|||||||
if (!r) { el.innerHTML = '<p class="alert error">Failed to load users</p>'; return; }
|
if (!r) { el.innerHTML = '<p class="alert error">Failed to load users</p>'; return; }
|
||||||
if (!r.length) { el.innerHTML = '<p style="color:var(--muted);font-size:13px">No users yet.</p>'; return; }
|
if (!r.length) { el.innerHTML = '<p style="color:var(--muted);font-size:13px">No users yet.</p>'; return; }
|
||||||
el.innerHTML = `<table class="data-table">
|
el.innerHTML = `<table class="data-table">
|
||||||
<thead><tr><th>Username</th><th>Email</th><th>Role</th><th>Status</th><th>Last Login</th><th></th></tr></thead>
|
<thead><tr><th>Username</th><th>Email</th><th>Role</th><th>Status</th><th>MFA</th><th>Last Login</th><th></th></tr></thead>
|
||||||
<tbody>${r.map(u => `
|
<tbody>${r.map(u => `
|
||||||
<tr>
|
<tr>
|
||||||
<td style="font-weight:500">${esc(u.username)}</td>
|
<td style="font-weight:500">${esc(u.username)}</td>
|
||||||
<td style="color:var(--muted)">${esc(u.email)}</td>
|
<td style="color:var(--muted)">${esc(u.email)}</td>
|
||||||
<td><span class="badge ${u.role==='admin'?'blue':'amber'}">${u.role}</span></td>
|
<td><span class="badge ${u.role==='admin'?'blue':'amber'}">${u.role}</span></td>
|
||||||
<td><span class="badge ${u.is_active?'green':'red'}">${u.is_active?'Active':'Disabled'}</span></td>
|
<td><span class="badge ${u.is_active?'green':'red'}">${u.is_active?'Active':'Disabled'}</span></td>
|
||||||
|
<td><span class="badge ${u.mfa_enabled?'blue':'amber'}">${u.mfa_enabled?'On':'Off'}</span></td>
|
||||||
<td style="color:var(--muted);font-size:12px">${u.last_login_at ? new Date(u.last_login_at).toLocaleDateString() : 'Never'}</td>
|
<td style="color:var(--muted);font-size:12px">${u.last_login_at ? new Date(u.last_login_at).toLocaleDateString() : 'Never'}</td>
|
||||||
<td style="display:flex;gap:6px;justify-content:flex-end">
|
<td style="display:flex;gap:4px;justify-content:flex-end;flex-wrap:wrap">
|
||||||
<button class="btn-secondary" style="padding:4px 10px;font-size:12px" onclick="openEditUser(${u.id})">Edit</button>
|
<button class="btn-secondary" style="padding:4px 10px;font-size:12px" onclick="openEditUser(${u.id})">Edit</button>
|
||||||
|
<button class="btn-secondary" style="padding:4px 10px;font-size:12px" onclick="openResetPassword(${u.id},'${esc(u.username)}')">🔑 Reset PW</button>
|
||||||
|
${u.mfa_enabled?`<button class="btn-secondary" style="padding:4px 10px;font-size:12px;color:var(--warning,#f90)" onclick="disableMFA(${u.id},'${esc(u.username)}')">🔒 Disable MFA</button>`:''}
|
||||||
<button class="btn-danger" style="padding:4px 10px;font-size:12px" onclick="deleteUser(${u.id})">Delete</button>
|
<button class="btn-danger" style="padding:4px 10px;font-size:12px" onclick="deleteUser(${u.id})">Delete</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>`).join('')}
|
</tr>`).join('')}
|
||||||
@@ -139,6 +143,23 @@ async function deleteUser(userId) {
|
|||||||
else toast((r && r.error) || 'Delete failed', 'error');
|
else toast((r && r.error) || 'Delete failed', 'error');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function disableMFA(userId, username) {
|
||||||
|
if (!confirm(`Disable MFA for "${username}"? They will be able to log in without a TOTP code until they re-enable it.`)) return;
|
||||||
|
const r = await api('PUT', '/admin/users/' + userId, { disable_mfa: true });
|
||||||
|
if (r && r.ok) { toast('MFA disabled for ' + username, 'success'); loadUsersTable(); }
|
||||||
|
else toast((r && r.error) || 'Failed to disable MFA', 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openResetPassword(userId, username) {
|
||||||
|
const pw = prompt(`Reset password for "${username}"\n\nEnter new password (min. 8 characters):`);
|
||||||
|
if (!pw) return;
|
||||||
|
if (pw.length < 8) { toast('Password must be at least 8 characters', 'error'); return; }
|
||||||
|
api('PUT', '/admin/users/' + userId, { password: pw }).then(r => {
|
||||||
|
if (r && r.ok) toast('Password reset for ' + username, 'success');
|
||||||
|
else toast((r && r.error) || 'Failed to reset password', 'error');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Settings
|
// Settings
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -182,6 +203,34 @@ const SETTINGS_META = [
|
|||||||
{ key: 'DB_PATH', label: 'Database Path', desc: 'Path to SQLite file, relative to working directory', type: 'text' },
|
{ key: 'DB_PATH', label: 'Database Path', desc: 'Path to SQLite file, relative to working directory', type: 'text' },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
group: 'Security Notifications',
|
||||||
|
fields: [
|
||||||
|
{ key: 'NOTIFY_ENABLED', label: 'Enabled', desc: 'Send email to users when brute-force attack is detected on their account', type: 'select', options: ['true','false'] },
|
||||||
|
{ key: 'NOTIFY_SMTP_HOST', label: 'SMTP Host', desc: 'SMTP server for sending alerts. Example: smtp.example.com', type: 'text' },
|
||||||
|
{ key: 'NOTIFY_SMTP_PORT', label: 'SMTP Port', desc: '587 = STARTTLS, 465 = TLS, 25 = plain relay', type: 'number' },
|
||||||
|
{ key: 'NOTIFY_FROM', label: 'From Address', desc: 'Sender email. Example: security@example.com', type: 'text' },
|
||||||
|
{ key: 'NOTIFY_USER', label: 'SMTP Username', desc: 'Leave blank for unauthenticated relay', type: 'text' },
|
||||||
|
{ key: 'NOTIFY_PASS', label: 'SMTP Password', desc: 'Leave blank for unauthenticated relay', type: 'password' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: 'Brute Force Protection',
|
||||||
|
fields: [
|
||||||
|
{ key: 'BRUTE_ENABLED', label: 'Enabled', desc: 'Auto-block IPs after repeated failed logins', type: 'select', options: ['true','false'] },
|
||||||
|
{ key: 'BRUTE_MAX_ATTEMPTS', label: 'Max Attempts', desc: 'Failed logins before ban', type: 'number' },
|
||||||
|
{ key: 'BRUTE_WINDOW_MINUTES', label: 'Window (minutes)',desc: 'Time window for counting failures', type: 'number' },
|
||||||
|
{ key: 'BRUTE_BAN_HOURS', label: 'Ban Duration (hours)', desc: '0 = permanent ban (admin must unban)', type: 'number' },
|
||||||
|
{ key: 'BRUTE_WHITELIST_IPS', label: 'Whitelist IPs', desc: 'Comma-separated IPs that are never blocked', type: 'text' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: 'Geo Blocking',
|
||||||
|
fields: [
|
||||||
|
{ key: 'GEO_BLOCK_COUNTRIES', label: 'Block Countries', desc: 'Comma-separated ISO codes to DENY (e.g. CN,RU,KP). Takes precedence over Allow list.', type: 'text' },
|
||||||
|
{ key: 'GEO_ALLOW_COUNTRIES', label: 'Allow Countries', desc: 'Comma-separated ISO codes to ALLOW exclusively (e.g. SK,CZ,DE). Leave blank to allow all.', type: 'text' },
|
||||||
|
]
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
async function renderSettings() {
|
async function renderSettings() {
|
||||||
@@ -309,3 +358,134 @@ function eventBadge(evt) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
// ============================================================
|
||||||
|
// Security — IP Blocks & Login Attempts
|
||||||
|
// ============================================================
|
||||||
|
async function renderSecurity() {
|
||||||
|
const el = document.getElementById('admin-content');
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="admin-page-header">
|
||||||
|
<h1>Security</h1>
|
||||||
|
<p>Monitor login attempts, manage IP blocks, and control access by country.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-card" style="margin-bottom:24px">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
|
||||||
|
<h2 style="margin:0;font-size:16px">Blocked IPs</h2>
|
||||||
|
<button class="btn-primary" onclick="openAddBlock()">+ Block IP</button>
|
||||||
|
</div>
|
||||||
|
<div id="blocks-table"><div class="spinner"></div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-card">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
|
||||||
|
<h2 style="margin:0;font-size:16px">Login Attempts (last 72h)</h2>
|
||||||
|
<button class="btn-secondary" onclick="loadLoginAttempts()">↻ Refresh</button>
|
||||||
|
</div>
|
||||||
|
<div id="attempts-table"><div class="spinner"></div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="add-block-modal">
|
||||||
|
<div class="modal" style="max-width:420px">
|
||||||
|
<h2>Block IP Address</h2>
|
||||||
|
<div class="modal-field"><label>IP Address</label><input type="text" id="block-ip" placeholder="e.g. 192.168.1.100"></div>
|
||||||
|
<div class="modal-field"><label>Reason</label><input type="text" id="block-reason" placeholder="Manual admin block"></div>
|
||||||
|
<div class="modal-field"><label>Ban Hours (0 = permanent)</label><input type="number" id="block-hours" value="24" min="0"></div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn-secondary" onclick="closeModal('add-block-modal')">Cancel</button>
|
||||||
|
<button class="btn-primary" onclick="submitAddBlock()">Block IP</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
loadIPBlocks();
|
||||||
|
loadLoginAttempts();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadIPBlocks() {
|
||||||
|
const el = document.getElementById('blocks-table');
|
||||||
|
if (!el) return;
|
||||||
|
const r = await api('GET', '/admin/ip-blocks');
|
||||||
|
const blocks = r?.blocks || [];
|
||||||
|
if (!blocks.length) {
|
||||||
|
el.innerHTML = '<p style="color:var(--muted);padding:8px 0">No blocked IPs.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.innerHTML = `<table class="admin-table" style="width:100%">
|
||||||
|
<thead><tr>
|
||||||
|
<th>IP</th><th>Country</th><th>Reason</th><th>Attempts</th><th>Blocked At</th><th>Expires</th><th></th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
${blocks.map(b => `<tr>
|
||||||
|
<td><code>${esc(b.ip)}</code></td>
|
||||||
|
<td>${b.country_code ? `<span title="${esc(b.country)}">${esc(b.country_code)}</span>` : '—'}</td>
|
||||||
|
<td>${esc(b.reason)}</td>
|
||||||
|
<td>${b.attempts||0}</td>
|
||||||
|
<td style="font-size:11px">${fmtDate(b.blocked_at)}</td>
|
||||||
|
<td style="font-size:11px;color:var(--muted)">${b.is_permanent ? '♾ Permanent' : b.expires_at ? fmtDate(b.expires_at) : '—'}</td>
|
||||||
|
<td><button class="action-btn danger" onclick="unblockIP('${esc(b.ip)}')">Unblock</button></td>
|
||||||
|
</tr>`).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLoginAttempts() {
|
||||||
|
const el = document.getElementById('attempts-table');
|
||||||
|
if (!el) return;
|
||||||
|
const r = await api('GET', '/admin/login-attempts');
|
||||||
|
const attempts = r?.attempts || [];
|
||||||
|
if (!attempts.length) {
|
||||||
|
el.innerHTML = '<p style="color:var(--muted);padding:8px 0">No login attempts recorded in the last 72 hours.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.innerHTML = `<table class="admin-table" style="width:100%">
|
||||||
|
<thead><tr>
|
||||||
|
<th>IP</th><th>Country</th><th>Total</th><th>Failures</th><th>Last Seen</th><th></th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
${attempts.map(a => `<tr ${a.failures>3?'style="background:rgba(255,80,80,.07)"':''}>
|
||||||
|
<td><code>${esc(a.ip)}</code></td>
|
||||||
|
<td>${a.country_code ? `<span title="${esc(a.country)}">${esc(a.country_code)} ${esc(a.country)}</span>` : '—'}</td>
|
||||||
|
<td>${a.total}</td>
|
||||||
|
<td style="${a.failures>3?'color:#f87;font-weight:600':''}">${a.failures}</td>
|
||||||
|
<td style="font-size:11px">${a.last_seen||'—'}</td>
|
||||||
|
<td><button class="action-btn danger" onclick="blockFromAttempt('${esc(a.ip)}')">Block</button></td>
|
||||||
|
</tr>`).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAddBlock() { openModal('add-block-modal'); }
|
||||||
|
|
||||||
|
async function submitAddBlock() {
|
||||||
|
const ip = document.getElementById('block-ip').value.trim();
|
||||||
|
const reason = document.getElementById('block-reason').value.trim() || 'Manual admin block';
|
||||||
|
const hours = parseInt(document.getElementById('block-hours').value) || 0;
|
||||||
|
if (!ip) { toast('IP address required', 'error'); return; }
|
||||||
|
const r = await api('POST', '/admin/ip-blocks', { ip, reason, ban_hours: hours });
|
||||||
|
if (r?.ok) { toast('IP blocked', 'success'); closeModal('add-block-modal'); loadIPBlocks(); }
|
||||||
|
else toast(r?.error || 'Failed', 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unblockIP(ip) {
|
||||||
|
const r = await fetch('/api/admin/ip-blocks/' + encodeURIComponent(ip), { method: 'DELETE' });
|
||||||
|
const data = await r.json();
|
||||||
|
if (data?.ok) { toast('IP unblocked', 'success'); loadIPBlocks(); }
|
||||||
|
else toast(data?.error || 'Failed', 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
function blockFromAttempt(ip) {
|
||||||
|
document.getElementById('block-ip').value = ip;
|
||||||
|
document.getElementById('block-reason').value = 'Manual block from login attempts';
|
||||||
|
openModal('add-block-modal');
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(s) {
|
||||||
|
if (!s) return '—';
|
||||||
|
try { return new Date(s).toLocaleString(); } catch(e) { return s; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
if (!s) return '';
|
||||||
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// GoMail app.js — full client
|
// GoWebMail app.js — full client
|
||||||
|
|
||||||
// ── State ──────────────────────────────────────────────────────────────────
|
// ── State ──────────────────────────────────────────────────────────────────
|
||||||
const S = {
|
const S = {
|
||||||
@@ -6,12 +6,8 @@ const S = {
|
|||||||
folders: [], messages: [], totalMessages: 0,
|
folders: [], messages: [], totalMessages: 0,
|
||||||
currentPage: 1, currentFolder: 'unified', currentFolderName: 'Unified Inbox',
|
currentPage: 1, currentFolder: 'unified', currentFolderName: 'Unified Inbox',
|
||||||
currentMessage: null, selectedMessageId: null,
|
currentMessage: null, selectedMessageId: null,
|
||||||
searchQuery: '', composeMode: 'new', composeReplyToId: null,
|
searchQuery: '', composeMode: 'new', composeReplyToId: null, composeForwardFromId: null,
|
||||||
remoteWhitelist: new Set(),
|
filterUnread: false, filterAttachment: false,
|
||||||
draftTimer: null, draftDirty: false,
|
|
||||||
composeVisible: false, composeMinimised: false,
|
|
||||||
// Message list filters
|
|
||||||
filterUnread: false,
|
|
||||||
sortOrder: 'date-desc', // 'date-desc' | 'date-asc' | 'size-desc'
|
sortOrder: 'date-desc', // 'date-desc' | 'date-asc' | 'size-desc'
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -387,6 +383,7 @@ function showFolderMenu(e, folderId) {
|
|||||||
<div class="ctx-item" onclick="syncFolderNow(${folderId});closeMenu()">↻ Sync this folder</div>
|
<div class="ctx-item" onclick="syncFolderNow(${folderId});closeMenu()">↻ Sync this folder</div>
|
||||||
<div class="ctx-item" onclick="toggleFolderSync(${folderId});closeMenu()">${syncLabel}</div>
|
<div class="ctx-item" onclick="toggleFolderSync(${folderId});closeMenu()">${syncLabel}</div>
|
||||||
${enableAllEntry}
|
${enableAllEntry}
|
||||||
|
<div class="ctx-item" onclick="markFolderAllRead(${folderId});closeMenu()">✓ Mark all as read</div>
|
||||||
<div class="ctx-sep"></div>
|
<div class="ctx-sep"></div>
|
||||||
${moveEntry}
|
${moveEntry}
|
||||||
${emptyEntry}
|
${emptyEntry}
|
||||||
@@ -401,6 +398,15 @@ async function syncFolderNow(folderId) {
|
|||||||
else toast(r?.error||'Sync failed','error');
|
else toast(r?.error||'Sync failed','error');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function markFolderAllRead(folderId) {
|
||||||
|
const r=await api('POST','/folders/'+folderId+'/mark-all-read');
|
||||||
|
if(r?.ok){
|
||||||
|
toast(`Marked ${r.marked||0} message(s) as read`,'success');
|
||||||
|
loadFolders();
|
||||||
|
loadMessages();
|
||||||
|
} else toast(r?.error||'Failed','error');
|
||||||
|
}
|
||||||
|
|
||||||
async function toggleFolderSync(folderId) {
|
async function toggleFolderSync(folderId) {
|
||||||
const f = S.folders.find(f=>f.id===folderId);
|
const f = S.folders.find(f=>f.id===folderId);
|
||||||
if (!f) return;
|
if (!f) return;
|
||||||
@@ -533,18 +539,19 @@ async function loadMessages(append) {
|
|||||||
|
|
||||||
function setFilter(mode) {
|
function setFilter(mode) {
|
||||||
S.filterUnread = (mode === 'unread');
|
S.filterUnread = (mode === 'unread');
|
||||||
S.sortOrder = (mode === 'unread' || mode === 'default') ? 'date-desc' : mode;
|
S.filterAttachment = (mode === 'attachment');
|
||||||
|
S.sortOrder = (mode === 'unread' || mode === 'default' || mode === 'attachment') ? 'date-desc' : mode;
|
||||||
|
|
||||||
// Update checkmarks
|
// Update checkmarks
|
||||||
['default','unread','date-desc','date-asc','size-desc'].forEach(k => {
|
['default','unread','attachment','date-desc','date-asc','size-desc'].forEach(k => {
|
||||||
const el = document.getElementById('fopt-'+k);
|
const el = document.getElementById('fopt-'+k);
|
||||||
if (el) el.textContent = (k === mode ? '✓ ' : '○ ') + el.textContent.slice(2);
|
if (el) el.textContent = (k === mode ? '✓ ' : '○ ') + el.textContent.slice(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update button label
|
// Update button label
|
||||||
const labels = {
|
const labels = {
|
||||||
'default':'Filter', 'unread':'Unread', 'date-desc':'↓ Date',
|
'default':'Filter', 'unread':'Unread', 'attachment':'📎 Has Attachment',
|
||||||
'date-asc':'↑ Date', 'size-desc':'↓ Size'
|
'date-desc':'↓ Date', 'date-asc':'↑ Date', 'size-desc':'↓ Size'
|
||||||
};
|
};
|
||||||
const labelEl = document.getElementById('filter-label');
|
const labelEl = document.getElementById('filter-label');
|
||||||
if (labelEl) {
|
if (labelEl) {
|
||||||
@@ -569,6 +576,7 @@ function renderMessageList() {
|
|||||||
|
|
||||||
// Filter
|
// Filter
|
||||||
if (S.filterUnread) msgs = msgs.filter(m => !m.is_read);
|
if (S.filterUnread) msgs = msgs.filter(m => !m.is_read);
|
||||||
|
if (S.filterAttachment) msgs = msgs.filter(m => m.has_attachment);
|
||||||
|
|
||||||
// Sort
|
// Sort
|
||||||
if (S.sortOrder === 'date-asc') msgs.sort((a,b) => new Date(a.date)-new Date(b.date));
|
if (S.sortOrder === 'date-asc') msgs.sort((a,b) => new Date(a.date)-new Date(b.date));
|
||||||
@@ -576,7 +584,8 @@ function renderMessageList() {
|
|||||||
else msgs.sort((a,b) => new Date(b.date)-new Date(a.date));
|
else msgs.sort((a,b) => new Date(b.date)-new Date(a.date));
|
||||||
|
|
||||||
if (!msgs.length){
|
if (!msgs.length){
|
||||||
list.innerHTML=`<div class="empty-state"><svg viewBox="0 0 24 24"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/></svg><p>${S.filterUnread?'No unread messages':'No messages'}</p></div>`;
|
const emptyMsg = S.filterUnread ? 'No unread messages' : S.filterAttachment ? 'No messages with attachments' : 'No messages';
|
||||||
|
list.innerHTML=`<div class="empty-state"><svg viewBox="0 0 24 24"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/></svg><p>${emptyMsg}</p></div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -642,6 +651,7 @@ function handleMsgClick(e, id, idx) {
|
|||||||
function getFilteredSortedMsgs() {
|
function getFilteredSortedMsgs() {
|
||||||
let msgs=[...S.messages];
|
let msgs=[...S.messages];
|
||||||
if (S.filterUnread) msgs=msgs.filter(m=>!m.is_read);
|
if (S.filterUnread) msgs=msgs.filter(m=>!m.is_read);
|
||||||
|
if (S.filterAttachment) msgs=msgs.filter(m=>m.has_attachment);
|
||||||
if (S.sortOrder==='date-asc') msgs.sort((a,b)=>new Date(a.date)-new Date(b.date));
|
if (S.sortOrder==='date-asc') msgs.sort((a,b)=>new Date(a.date)-new Date(b.date));
|
||||||
else if (S.sortOrder==='size-desc') msgs.sort((a,b)=>(b.size||0)-(a.size||0));
|
else if (S.sortOrder==='size-desc') msgs.sort((a,b)=>(b.size||0)-(a.size||0));
|
||||||
else msgs.sort((a,b)=>new Date(b.date)-new Date(a.date));
|
else msgs.sort((a,b)=>new Date(b.date)-new Date(a.date));
|
||||||
@@ -712,39 +722,86 @@ async function openMessage(id) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── External link navigation whitelist ───────────────────────────────────────
|
||||||
|
// Persisted in sessionStorage so it resets on tab close (safety default).
|
||||||
|
const _extNavOk = new Set(JSON.parse(sessionStorage.getItem('extNavOk')||'[]'));
|
||||||
|
function _saveExtNavOk(){ sessionStorage.setItem('extNavOk', JSON.stringify([..._extNavOk])); }
|
||||||
|
|
||||||
|
function confirmExternalNav(url) {
|
||||||
|
const origin = (() => { try { return new URL(url).origin; } catch(e){ return url; } })();
|
||||||
|
if (_extNavOk.has(origin)) { window.open(url,'_blank','noopener,noreferrer'); return; }
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'modal-overlay open';
|
||||||
|
overlay.innerHTML = `<div class="modal" style="max-width:480px">
|
||||||
|
<h2 style="margin:0 0 12px">Open external link?</h2>
|
||||||
|
<div style="word-break:break-all;background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:10px;font-size:12px;font-family:monospace;margin-bottom:16px;color:var(--text2)">${esc(url)}</div>
|
||||||
|
<p style="margin:0 0 20px;font-size:13px;color:var(--text2)">This link was in a received email. Opening it will take you to an external website.</p>
|
||||||
|
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||||
|
<button class="btn-primary" id="enav-once">Open once</button>
|
||||||
|
<button class="btn-primary" id="enav-always" style="background:var(--accent2,#2a7)">Always allow ${esc(origin)}</button>
|
||||||
|
<button class="action-btn" id="enav-cancel">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
overlay.querySelector('#enav-once').onclick = () => { overlay.remove(); window.open(url,'_blank','noopener,noreferrer'); };
|
||||||
|
overlay.querySelector('#enav-always').onclick = () => { _extNavOk.add(origin); _saveExtNavOk(); overlay.remove(); window.open(url,'_blank','noopener,noreferrer'); };
|
||||||
|
overlay.querySelector('#enav-cancel').onclick = () => overlay.remove();
|
||||||
|
overlay.onclick = e => { if(e.target===overlay) overlay.remove(); };
|
||||||
|
}
|
||||||
|
|
||||||
function renderMessageDetail(msg, showRemoteContent) {
|
function renderMessageDetail(msg, showRemoteContent) {
|
||||||
const detail=document.getElementById('message-detail');
|
const detail=document.getElementById('message-detail');
|
||||||
const allowed=showRemoteContent||S.remoteWhitelist.has(msg.from_email);
|
const allowed=showRemoteContent||S.remoteWhitelist.has(msg.from_email);
|
||||||
|
|
||||||
// CSS injected into every iframe — forces white background so dark-themed emails
|
|
||||||
// don't inherit our app dark theme. allow-scripts is needed for some email onclick events.
|
|
||||||
const cssReset = `<style>html,body{background:#ffffff!important;color:#1a1a1a!important;` +
|
const cssReset = `<style>html,body{background:#ffffff!important;color:#1a1a1a!important;` +
|
||||||
`font-family:Arial,sans-serif;font-size:14px;line-height:1.5;margin:8px}a{color:#1a5fb4}` +
|
`font-family:Arial,sans-serif;font-size:14px;line-height:1.5;margin:8px}a{color:#1a5fb4}` +
|
||||||
`img{max-width:100%;height:auto}</style>`;
|
`img{max-width:100%;height:auto}iframe{display:none!important}</style>`;
|
||||||
|
|
||||||
|
// Injected into srcdoc: reports height + intercepts all link clicks → postMessage to parent
|
||||||
|
const heightScript = `<script>
|
||||||
|
function _reportH(){parent.postMessage({type:'gomail-frame-h',h:document.documentElement.scrollHeight},'*');}
|
||||||
|
document.addEventListener('DOMContentLoaded',_reportH);
|
||||||
|
window.addEventListener('load',_reportH);
|
||||||
|
new MutationObserver(_reportH).observe(document.documentElement,{subtree:true,childList:true,attributes:true});
|
||||||
|
document.addEventListener('click',function(e){
|
||||||
|
var el=e.target; while(el&&el.tagName!=='A') el=el.parentElement;
|
||||||
|
if(!el) return;
|
||||||
|
var href=el.getAttribute('href');
|
||||||
|
if(!href||href.startsWith('#')||href.startsWith('mailto:')) return;
|
||||||
|
e.preventDefault(); e.stopPropagation();
|
||||||
|
parent.postMessage({type:'gomail-open-url',url:href},'*');
|
||||||
|
},true);
|
||||||
|
<\/script>`;
|
||||||
|
|
||||||
|
const sandboxAttr = 'allow-scripts allow-popups allow-popups-to-escape-sandbox';
|
||||||
|
|
||||||
|
function stripUnresolvedCID(h){ return h.replace(/src\s*=\s*(['"])cid:[^'"]*\1/gi,'src=""').replace(/src\s*=\s*cid:\S+/gi,'src=""'); }
|
||||||
|
function stripEmbeddedFrames(h){ return h.replace(/<iframe[\s\S]*?<\/iframe>/gi,'').replace(/<iframe[^>]*>/gi,''); }
|
||||||
|
function stripRemoteImages(h){
|
||||||
|
return h.replace(/<img(\s[^>]*?)src\s*=\s*(['"])(https?:\/\/[^'"]+)\2/gi,'<img$1src="" data-blocked-src="$3"')
|
||||||
|
.replace(/url\s*\(\s*(['"]?)https?:\/\/[^)'"]+\1\s*\)/gi,'url()')
|
||||||
|
.replace(/<link[^>]*>/gi,'').replace(/<script[\s\S]*?<\/script>/gi,'');
|
||||||
|
}
|
||||||
|
|
||||||
let bodyHtml='';
|
let bodyHtml='';
|
||||||
if (msg.body_html) {
|
if (msg.body_html) {
|
||||||
|
let html = stripUnresolvedCID(stripEmbeddedFrames(msg.body_html));
|
||||||
if (allowed) {
|
if (allowed) {
|
||||||
const srcdoc = cssReset + msg.body_html;
|
const srcdoc = cssReset + heightScript + html;
|
||||||
bodyHtml=`<iframe id="msg-frame" sandbox="allow-same-origin allow-popups allow-scripts"
|
bodyHtml=`<iframe id="msg-frame" sandbox="${sandboxAttr}"
|
||||||
style="width:100%;border:none;min-height:400px;display:block"
|
style="width:100%;border:none;min-height:200px;display:block"
|
||||||
srcdoc="${srcdoc.replace(/"/g,'"')}"></iframe>`;
|
srcdoc="${srcdoc.replace(/"/g,'"')}"></iframe>`;
|
||||||
} else {
|
} else {
|
||||||
const stripped = msg.body_html
|
const stripped = stripRemoteImages(html);
|
||||||
.replace(/<img(\s[^>]*?)src\s*=\s*(['"])[^'"]*\2/gi, '<img$1src="" data-blocked="1"')
|
|
||||||
.replace(/url\s*\(\s*(['"]?)https?:\/\/[^)'"]+\1\s*\)/gi, 'url()')
|
|
||||||
.replace(/<link[^>]*>/gi, '')
|
|
||||||
.replace(/<script[\s\S]*?<\/script>/gi, '');
|
|
||||||
const srcdoc = cssReset + stripped;
|
|
||||||
bodyHtml=`<div class="remote-content-banner">
|
bodyHtml=`<div class="remote-content-banner">
|
||||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/></svg>
|
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/></svg>
|
||||||
Remote images blocked.
|
Remote images blocked.
|
||||||
<button class="rcb-btn" onclick="renderMessageDetail(S.currentMessage,true)">Load images</button>
|
<button class="rcb-btn" onclick="renderMessageDetail(S.currentMessage,true)">Load images</button>
|
||||||
<button class="rcb-btn" onclick="whitelistSender('${esc(msg.from_email)}')">Always allow from ${esc(msg.from_email)}</button>
|
<button class="rcb-btn" onclick="whitelistSender('${esc(msg.from_email)}')">Always allow from ${esc(msg.from_email)}</button>
|
||||||
</div>
|
</div>
|
||||||
<iframe id="msg-frame" sandbox="allow-same-origin allow-popups allow-scripts"
|
<iframe id="msg-frame" sandbox="${sandboxAttr}"
|
||||||
style="width:100%;border:none;min-height:400px;display:block"
|
style="width:100%;border:none;min-height:200px;display:block"
|
||||||
srcdoc="${srcdoc.replace(/"/g,'"')}"></iframe>`;
|
srcdoc="${(cssReset + heightScript + stripped).replace(/"/g,'"')}"></iframe>`;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
bodyHtml=`<div class="detail-body-text">${esc(msg.body_text||'(empty)')}</div>`;
|
bodyHtml=`<div class="detail-body-text">${esc(msg.body_text||'(empty)')}</div>`;
|
||||||
@@ -752,14 +809,20 @@ function renderMessageDetail(msg, showRemoteContent) {
|
|||||||
|
|
||||||
let attachHtml='';
|
let attachHtml='';
|
||||||
if (msg.attachments?.length) {
|
if (msg.attachments?.length) {
|
||||||
attachHtml=`<div class="attachments-bar">
|
const chips = msg.attachments.map(a=>{
|
||||||
<span style="font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.6px;margin-right:8px">Attachments</span>
|
const url=`/api/messages/${msg.id}/attachments/${a.id}`;
|
||||||
${msg.attachments.map(a=>`<div class="attachment-chip">
|
const ct=a.content_type||'';
|
||||||
📎 <span>${esc(a.filename)}</span>
|
const viewable=/^(image\/|text\/|application\/pdf$|video\/|audio\/)/.test(ct);
|
||||||
<span style="color:var(--muted);font-size:10px">${formatSize(a.size)}</span>
|
const icon=ct.startsWith('image/')?'🖼':ct==='application/pdf'?'📄':ct.startsWith('video/')?'🎬':ct.startsWith('audio/')?'🎵':'📎';
|
||||||
</div>`).join('')}
|
if(viewable){
|
||||||
</div>`;
|
return `<a class="attachment-chip" href="${url}" target="_blank" rel="noopener" title="Open ${esc(a.filename)}">${icon} <span>${esc(a.filename)}</span><span style="color:var(--muted);font-size:10px"> ${formatSize(a.size)}</span></a>`;
|
||||||
}
|
}
|
||||||
|
return `<a class="attachment-chip" href="${url}" download="${esc(a.filename)}" title="Download ${esc(a.filename)}">${icon} <span>${esc(a.filename)}</span><span style="color:var(--muted);font-size:10px"> ${formatSize(a.size)}</span></a>`;
|
||||||
|
}).join('');
|
||||||
|
const dlAll=`<button class="attachment-chip" onclick="downloadAllAttachments(${msg.id})" style="cursor:pointer;border:1px solid var(--border)">⬇ <span>Download all</span></button>`;
|
||||||
|
attachHtml=`<div class="attachments-bar">${dlAll}${chips}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
detail.innerHTML=`
|
detail.innerHTML=`
|
||||||
<div class="detail-header">
|
<div class="detail-header">
|
||||||
@@ -777,6 +840,7 @@ function renderMessageDetail(msg, showRemoteContent) {
|
|||||||
<div class="detail-actions">
|
<div class="detail-actions">
|
||||||
<button class="action-btn" onclick="openReply()">↩ Reply</button>
|
<button class="action-btn" onclick="openReply()">↩ Reply</button>
|
||||||
<button class="action-btn" onclick="openForward()">↪ Forward</button>
|
<button class="action-btn" onclick="openForward()">↪ Forward</button>
|
||||||
|
<button class="action-btn" onclick="openForwardAsAttachment()" title="Forward the original message as an .eml file attachment">↪ Fwd as Attachment</button>
|
||||||
<button class="action-btn" onclick="toggleStar(${msg.id})">${msg.is_starred?'★ Unstar':'☆ Star'}</button>
|
<button class="action-btn" onclick="toggleStar(${msg.id})">${msg.is_starred?'★ Unstar':'☆ Star'}</button>
|
||||||
<button class="action-btn" onclick="markRead(${msg.id},${!msg.is_read})">${msg.is_read?'Mark unread':'Mark read'}</button>
|
<button class="action-btn" onclick="markRead(${msg.id},${!msg.is_read})">${msg.is_read?'Mark unread':'Mark read'}</button>
|
||||||
<button class="action-btn" onclick="showMessageHeaders(${msg.id})">⋮ Headers</button>
|
<button class="action-btn" onclick="showMessageHeaders(${msg.id})">⋮ Headers</button>
|
||||||
@@ -786,25 +850,47 @@ function renderMessageDetail(msg, showRemoteContent) {
|
|||||||
${attachHtml}
|
${attachHtml}
|
||||||
<div class="detail-body">${bodyHtml}</div>`;
|
<div class="detail-body">${bodyHtml}</div>`;
|
||||||
|
|
||||||
// Auto-size iframe to content height using ResizeObserver
|
// Auto-size iframe via postMessage from injected height-reporting script.
|
||||||
|
// We cannot use contentDocument (null without allow-same-origin in sandbox).
|
||||||
if (msg.body_html) {
|
if (msg.body_html) {
|
||||||
const frame = document.getElementById('msg-frame');
|
const frame = document.getElementById('msg-frame');
|
||||||
if (frame) {
|
if (frame) {
|
||||||
const sizeFrame = () => {
|
// Clean up any previous listener
|
||||||
try {
|
if (window._frameMsgHandler) window.removeEventListener('message', window._frameMsgHandler);
|
||||||
const h = frame.contentDocument?.documentElement?.scrollHeight;
|
let lastH = 0;
|
||||||
if (h && h > 50) frame.style.height = (h + 20) + 'px';
|
window._frameMsgHandler = (e) => {
|
||||||
} catch(e) {}
|
if (e.data?.type === 'gomail-frame-h' && e.data.h > 50) {
|
||||||
};
|
const h = e.data.h + 24;
|
||||||
frame.onload = () => {
|
if (Math.abs(h - lastH) > 4) {
|
||||||
sizeFrame();
|
lastH = h;
|
||||||
// Also observe content changes (images loading)
|
frame.style.height = h + 'px';
|
||||||
try {
|
|
||||||
const ro = new ResizeObserver(sizeFrame);
|
|
||||||
ro.observe(frame.contentDocument.documentElement);
|
|
||||||
} catch(e) {}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
} else if (e.data?.type === 'gomail-open-url' && e.data.url) {
|
||||||
|
confirmExternalNav(e.data.url);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('message', window._frameMsgHandler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download all attachments for a message sequentially
|
||||||
|
async function downloadAllAttachments(msgId) {
|
||||||
|
const msg = S.currentMessage;
|
||||||
|
if (!msg?.attachments?.length) return;
|
||||||
|
for (const a of msg.attachments) {
|
||||||
|
const url = `/api/messages/${msgId}/attachments/${a.id}`;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(url);
|
||||||
|
const blob = await resp.blob();
|
||||||
|
const tmp = document.createElement('a');
|
||||||
|
tmp.href = URL.createObjectURL(blob);
|
||||||
|
tmp.download = a.filename || 'attachment';
|
||||||
|
tmp.click();
|
||||||
|
URL.revokeObjectURL(tmp.href);
|
||||||
|
// Small delay to avoid browser throttling sequential downloads
|
||||||
|
await new Promise(r => setTimeout(r, 400));
|
||||||
|
} catch(e) { toast('Failed to download '+esc(a.filename),'error'); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -946,6 +1032,7 @@ function showCompose() {
|
|||||||
d.style.display='flex';
|
d.style.display='flex';
|
||||||
m.style.display='none';
|
m.style.display='none';
|
||||||
S.composeVisible=true; S.composeMinimised=false;
|
S.composeVisible=true; S.composeMinimised=false;
|
||||||
|
initComposeDragDrop();
|
||||||
}
|
}
|
||||||
|
|
||||||
function minimizeCompose() {
|
function minimizeCompose() {
|
||||||
@@ -995,13 +1082,30 @@ function openReplyTo(msgId) {
|
|||||||
function openForward() {
|
function openForward() {
|
||||||
if (!S.currentMessage) return;
|
if (!S.currentMessage) return;
|
||||||
const msg=S.currentMessage;
|
const msg=S.currentMessage;
|
||||||
|
S.composeForwardFromId=msg.id;
|
||||||
openCompose({
|
openCompose({
|
||||||
mode:'forward', title:'Forward',
|
mode:'forward', forwardId:msg.id, title:'Forward',
|
||||||
subject:'Fwd: '+(msg.subject||''),
|
subject:'Fwd: '+(msg.subject||''),
|
||||||
body:`<br><br><div class="quote-divider">—— Forwarded message ——<br>From: ${esc(msg.from_email||'')}</div><blockquote>${msg.body_html||('<pre>'+esc(msg.body_text||'')+'</pre>')}</blockquote>`,
|
body:`<br><br><div class="quote-divider">—— Forwarded message ——<br>From: ${esc(msg.from_email||'')}</div><blockquote>${msg.body_html||('<pre>'+esc(msg.body_text||'')+'</pre>')}</blockquote>`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openForwardAsAttachment() {
|
||||||
|
if (!S.currentMessage) return;
|
||||||
|
const msg=S.currentMessage;
|
||||||
|
S.composeForwardFromId=msg.id;
|
||||||
|
openCompose({
|
||||||
|
mode:'forward-attachment', forwardId:msg.id, title:'Forward as Attachment',
|
||||||
|
subject:'Fwd: '+(msg.subject||''),
|
||||||
|
body:'',
|
||||||
|
});
|
||||||
|
// Add a visual placeholder chip (the actual EML is fetched server-side)
|
||||||
|
composeAttachments=[{name: sanitizeSubject(msg.subject||'message')+'.eml', size:0, isForward:true}];
|
||||||
|
updateAttachList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeSubject(s){return s.replace(/[/\\:*?"<>|]/g,'_').slice(0,60)||'message';}
|
||||||
|
|
||||||
// ── Email Tag Input ────────────────────────────────────────────────────────
|
// ── Email Tag Input ────────────────────────────────────────────────────────
|
||||||
function initTagField(containerId) {
|
function initTagField(containerId) {
|
||||||
const container=document.getElementById(containerId);
|
const container=document.getElementById(containerId);
|
||||||
@@ -1077,25 +1181,64 @@ function clearDraftAutosave() {
|
|||||||
|
|
||||||
async function saveDraft(silent) {
|
async function saveDraft(silent) {
|
||||||
S.draftDirty=false;
|
S.draftDirty=false;
|
||||||
|
const accountId=parseInt(document.getElementById('compose-from')?.value||0);
|
||||||
|
if(!accountId){ if(!silent) toast('Draft saved locally','success'); return; }
|
||||||
|
const editor=document.getElementById('compose-editor');
|
||||||
|
const meta={
|
||||||
|
account_id:accountId,
|
||||||
|
to:getTagValues('compose-to'),
|
||||||
|
subject:document.getElementById('compose-subject').value,
|
||||||
|
body_html:editor.innerHTML.trim(),
|
||||||
|
body_text:editor.innerText.trim(),
|
||||||
|
};
|
||||||
|
const r=await api('POST','/draft',meta);
|
||||||
if(!silent) toast('Draft saved','success');
|
if(!silent) toast('Draft saved','success');
|
||||||
else toast('Draft auto-saved','success');
|
else if(r?.ok) toast('Draft auto-saved to server','success');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Compose formatting ─────────────────────────────────────────────────────
|
// ── Compose formatting ─────────────────────────────────────────────────────
|
||||||
function execFmt(cmd,val) { document.getElementById('compose-editor').focus(); document.execCommand(cmd,false,val||null); }
|
function execFmt(cmd,val) { document.getElementById('compose-editor').focus(); document.execCommand(cmd,false,val||null); }
|
||||||
function triggerAttach() { document.getElementById('compose-attach-input').click(); }
|
function triggerAttach() { document.getElementById('compose-attach-input').click(); }
|
||||||
function handleAttachFiles(input) { for(const file of input.files) composeAttachments.push({file,name:file.name,size:file.size}); input.value=''; updateAttachList(); S.draftDirty=true; }
|
function handleAttachFiles(input) { for(const file of input.files) composeAttachments.push({file,name:file.name,size:file.size}); input.value=''; updateAttachList(); S.draftDirty=true; }
|
||||||
function removeAttachment(i) { composeAttachments.splice(i,1); updateAttachList(); }
|
function removeAttachment(i) {
|
||||||
|
// Don't remove EML forward placeholder (isForward) from UI; it's handled server-side
|
||||||
|
if(composeAttachments[i]?.isForward && S.composeMode==='forward-attachment'){
|
||||||
|
toast('The original message will be attached when sent','info'); return;
|
||||||
|
}
|
||||||
|
composeAttachments.splice(i,1); updateAttachList();
|
||||||
|
}
|
||||||
function updateAttachList() {
|
function updateAttachList() {
|
||||||
const el=document.getElementById('compose-attach-list');
|
const el=document.getElementById('compose-attach-list');
|
||||||
if(!composeAttachments.length){el.innerHTML='';return;}
|
if(!composeAttachments.length){el.innerHTML='';return;}
|
||||||
el.innerHTML=composeAttachments.map((a,i)=>`<div class="attachment-chip">
|
el.innerHTML=composeAttachments.map((a,i)=>`<div class="attachment-chip">
|
||||||
📎 <span>${esc(a.name)}</span>
|
📎 <span>${esc(a.name)}</span>
|
||||||
<span style="color:var(--muted);font-size:10px">${formatSize(a.size)}</span>
|
<span style="color:var(--muted);font-size:10px">${a.size?formatSize(a.size):''}</span>
|
||||||
<button onclick="removeAttachment(${i})" class="tag-remove" type="button">×</button>
|
<button onclick="removeAttachment(${i})" class="tag-remove" type="button">×</button>
|
||||||
</div>`).join('');
|
</div>`).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Compose drag-and-drop attachments ──────────────────────────────────────
|
||||||
|
function initComposeDragDrop() {
|
||||||
|
const dialog=document.getElementById('compose-dialog');
|
||||||
|
if(!dialog) return;
|
||||||
|
dialog.addEventListener('dragover', e=>{
|
||||||
|
e.preventDefault(); e.stopPropagation();
|
||||||
|
dialog.classList.add('drag-over');
|
||||||
|
});
|
||||||
|
dialog.addEventListener('dragleave', e=>{
|
||||||
|
if(!dialog.contains(e.relatedTarget)) dialog.classList.remove('drag-over');
|
||||||
|
});
|
||||||
|
dialog.addEventListener('drop', e=>{
|
||||||
|
e.preventDefault(); e.stopPropagation();
|
||||||
|
dialog.classList.remove('drag-over');
|
||||||
|
if(e.dataTransfer?.files?.length){
|
||||||
|
for(const file of e.dataTransfer.files) composeAttachments.push({file,name:file.name,size:file.size});
|
||||||
|
updateAttachList(); S.draftDirty=true;
|
||||||
|
toast(`${e.dataTransfer.files.length} file(s) attached`,'success');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function sendMessage() {
|
async function sendMessage() {
|
||||||
const accountId=parseInt(document.getElementById('compose-from')?.value||0);
|
const accountId=parseInt(document.getElementById('compose-from')?.value||0);
|
||||||
const to=getTagValues('compose-to');
|
const to=getTagValues('compose-to');
|
||||||
@@ -1104,15 +1247,44 @@ async function sendMessage() {
|
|||||||
const bodyHTML=editor.innerHTML.trim(), bodyText=editor.innerText.trim();
|
const bodyHTML=editor.innerHTML.trim(), bodyText=editor.innerText.trim();
|
||||||
const btn=document.getElementById('send-btn');
|
const btn=document.getElementById('send-btn');
|
||||||
btn.disabled=true; btn.textContent='Sending…';
|
btn.disabled=true; btn.textContent='Sending…';
|
||||||
const endpoint=S.composeMode==='reply'?'/reply':S.composeMode==='forward'?'/forward':'/send';
|
|
||||||
const r=await api('POST',endpoint,{
|
const endpoint=S.composeMode==='reply'?'/reply'
|
||||||
|
:S.composeMode==='forward'?'/forward'
|
||||||
|
:S.composeMode==='forward-attachment'?'/forward-attachment'
|
||||||
|
:'/send';
|
||||||
|
|
||||||
|
const meta={
|
||||||
account_id:accountId, to,
|
account_id:accountId, to,
|
||||||
cc:getTagValues('compose-cc-tags'),
|
cc:getTagValues('compose-cc-tags'),
|
||||||
bcc:getTagValues('compose-bcc-tags'),
|
bcc:getTagValues('compose-bcc-tags'),
|
||||||
subject:document.getElementById('compose-subject').value,
|
subject:document.getElementById('compose-subject').value,
|
||||||
body_text:bodyText, body_html:bodyHTML,
|
body_text:bodyText, body_html:bodyHTML,
|
||||||
in_reply_to_id:S.composeMode==='reply'?S.composeReplyToId:0,
|
in_reply_to_id:S.composeMode==='reply'?S.composeReplyToId:0,
|
||||||
});
|
forward_from_id:(S.composeMode==='forward'||S.composeMode==='forward-attachment')?S.composeForwardFromId:0,
|
||||||
|
};
|
||||||
|
|
||||||
|
let r;
|
||||||
|
// Use FormData when there are real file attachments, OR when forwarding as attachment
|
||||||
|
// (server needs multipart so it can read forward_from_id from meta and fetch the EML itself)
|
||||||
|
const hasRealFiles = composeAttachments.some(a => a.file instanceof Blob);
|
||||||
|
const needsFormData = hasRealFiles || S.composeMode === 'forward-attachment';
|
||||||
|
if(needsFormData){
|
||||||
|
const fd=new FormData();
|
||||||
|
fd.append('meta', JSON.stringify(meta));
|
||||||
|
for(const a of composeAttachments){
|
||||||
|
if(a.file instanceof Blob){ // only append real File/Blob objects
|
||||||
|
fd.append('file', a.file, a.name);
|
||||||
|
}
|
||||||
|
// isForward placeholders are intentionally skipped — the EML is fetched server-side
|
||||||
|
}
|
||||||
|
try{
|
||||||
|
const resp=await fetch('/api'+endpoint,{method:'POST',body:fd});
|
||||||
|
r=await resp.json();
|
||||||
|
}catch(e){ r={error:String(e)}; }
|
||||||
|
} else {
|
||||||
|
r=await api('POST',endpoint,meta);
|
||||||
|
}
|
||||||
|
|
||||||
btn.disabled=false; btn.textContent='Send';
|
btn.disabled=false; btn.textContent='Send';
|
||||||
if(r?.ok){ toast('Message sent!','success'); clearDraftAutosave(); _closeCompose(); }
|
if(r?.ok){ toast('Message sent!','success'); clearDraftAutosave(); _closeCompose(); }
|
||||||
else toast(r?.error||'Send failed','error');
|
else toast(r?.error||'Send failed','error');
|
||||||
@@ -1207,6 +1379,28 @@ async function openSettings() {
|
|||||||
openModal('settings-modal');
|
openModal('settings-modal');
|
||||||
loadSyncInterval();
|
loadSyncInterval();
|
||||||
renderMFAPanel();
|
renderMFAPanel();
|
||||||
|
loadIPRules();
|
||||||
|
// Pre-fill profile fields with current values
|
||||||
|
const me = await api('GET', '/me');
|
||||||
|
if (me) {
|
||||||
|
document.getElementById('profile-username').placeholder = me.username || 'New username';
|
||||||
|
document.getElementById('profile-email').placeholder = me.email || 'New email';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateProfile(field) {
|
||||||
|
const value = document.getElementById('profile-' + field).value.trim();
|
||||||
|
const password = document.getElementById('profile-confirm-pw').value;
|
||||||
|
if (!value) { toast('Please enter a new ' + field, 'error'); return; }
|
||||||
|
if (!password) { toast('Current password required to confirm changes', 'error'); return; }
|
||||||
|
const r = await api('PUT', '/profile', { field, value, password });
|
||||||
|
if (r?.ok) {
|
||||||
|
toast(field.charAt(0).toUpperCase() + field.slice(1) + ' updated', 'success');
|
||||||
|
document.getElementById('profile-' + field).value = '';
|
||||||
|
document.getElementById('profile-confirm-pw').value = '';
|
||||||
|
} else {
|
||||||
|
toast(r?.error || 'Update failed', 'error');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadSyncInterval() {
|
async function loadSyncInterval() {
|
||||||
@@ -1260,6 +1454,39 @@ async function disableMFA() {
|
|||||||
if(r?.ok){toast('MFA disabled','success');renderMFAPanel();}else toast(r?.error||'Invalid code','error');
|
if(r?.ok){toast('MFA disabled','success');renderMFAPanel();}else toast(r?.error||'Invalid code','error');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadIPRules() {
|
||||||
|
const r = await api('GET', '/ip-rules');
|
||||||
|
if (!r) return;
|
||||||
|
document.getElementById('ip-rule-mode').value = r.mode || 'disabled';
|
||||||
|
document.getElementById('ip-rule-list').value = r.ip_list || '';
|
||||||
|
toggleIPRuleHelp();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleIPRuleHelp() {
|
||||||
|
const mode = document.getElementById('ip-rule-mode').value;
|
||||||
|
const helpEl = document.getElementById('ip-rule-help');
|
||||||
|
const listField = document.getElementById('ip-rule-list-field');
|
||||||
|
const helps = {
|
||||||
|
disabled: '',
|
||||||
|
brute_skip: 'IPs in the list below will never be locked out of your account, even after many failed attempts. All other IPs are subject to global brute-force protection.',
|
||||||
|
allow_only: '⚠ Only IPs in the list below will be able to log into your account. All other IPs will see an "Access not authorized" error. Make sure to include your current IP before saving.',
|
||||||
|
};
|
||||||
|
helpEl.textContent = helps[mode] || '';
|
||||||
|
helpEl.style.display = mode !== 'disabled' ? 'block' : 'none';
|
||||||
|
listField.style.display = mode !== 'disabled' ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveIPRules() {
|
||||||
|
const mode = document.getElementById('ip-rule-mode').value;
|
||||||
|
const ip_list = document.getElementById('ip-rule-list').value.trim();
|
||||||
|
if (mode !== 'disabled' && !ip_list) {
|
||||||
|
toast('Please enter at least one IP address', 'error'); return;
|
||||||
|
}
|
||||||
|
const r = await api('PUT', '/ip-rules', { mode, ip_list });
|
||||||
|
if (r?.ok) toast('IP rules saved', 'success');
|
||||||
|
else toast(r?.error || 'Save failed', 'error');
|
||||||
|
}
|
||||||
|
|
||||||
async function doLogout() { await fetch('/auth/logout',{method:'POST'}); location.href='/auth/login'; }
|
async function doLogout() { await fetch('/auth/logout',{method:'POST'}); location.href='/auth/login'; }
|
||||||
|
|
||||||
// ── Context menu helper ────────────────────────────────────────────────────
|
// ── Context menu helper ────────────────────────────────────────────────────
|
||||||
@@ -1386,7 +1613,7 @@ function updateUnreadBadgeFromPoll(inboxUnread) {
|
|||||||
badge.style.display = 'none';
|
badge.style.display = 'none';
|
||||||
}
|
}
|
||||||
// Update browser tab title
|
// Update browser tab title
|
||||||
const base = 'GoMail';
|
const base = 'GoWebMail';
|
||||||
document.title = inboxUnread > 0 ? `(${inboxUnread}) ${base}` : base;
|
document.title = inboxUnread > 0 ? `(${inboxUnread}) ${base}` : base;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1444,7 +1671,7 @@ function sendOSNotification(msgs) {
|
|||||||
const first = msgs[0];
|
const first = msgs[0];
|
||||||
const title = count === 1
|
const title = count === 1
|
||||||
? (first.from_name || first.from_email || 'New message')
|
? (first.from_name || first.from_email || 'New message')
|
||||||
: `${count} new messages in GoMail`;
|
: `${count} new messages in GoWebMail`;
|
||||||
const body = count === 1
|
const body = count === 1
|
||||||
? (first.subject || '(no subject)')
|
? (first.subject || '(no subject)')
|
||||||
: `${first.from_name || first.from_email}: ${first.subject || '(no subject)'}`;
|
: `${first.from_name || first.from_email}: ${first.subject || '(no subject)'}`;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// GoMail shared utilities - loaded on every page
|
// GoWebMail shared utilities - loaded on every page
|
||||||
|
|
||||||
// ---- API helper ----
|
// ---- API helper ----
|
||||||
async function api(method, path, body) {
|
async function api(method, path, body) {
|
||||||
|
|||||||
@@ -23,6 +23,10 @@
|
|||||||
<svg viewBox="0 0 24 24"><path d="M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z"/></svg>
|
<svg viewBox="0 0 24 24"><path d="M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z"/></svg>
|
||||||
Audit Log
|
Audit Log
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/admin/security" id="nav-security">
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z"/></svg>
|
||||||
|
Security
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@@ -35,5 +39,5 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "scripts"}}
|
{{define "scripts"}}
|
||||||
<script src="/static/js/admin.js"></script>
|
<script src="/static/js/admin.js?v=23"></script>
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -61,6 +61,7 @@
|
|||||||
<div class="filter-opt" id="fopt-default" onclick="goMailSetFilter('default');event.stopPropagation()">✓ Default order</div>
|
<div class="filter-opt" id="fopt-default" onclick="goMailSetFilter('default');event.stopPropagation()">✓ Default order</div>
|
||||||
<div class="filter-sep-line"></div>
|
<div class="filter-sep-line"></div>
|
||||||
<div class="filter-opt" id="fopt-unread" onclick="goMailSetFilter('unread');event.stopPropagation()">○ Unread only</div>
|
<div class="filter-opt" id="fopt-unread" onclick="goMailSetFilter('unread');event.stopPropagation()">○ Unread only</div>
|
||||||
|
<div class="filter-opt" id="fopt-attachment" onclick="goMailSetFilter('attachment');event.stopPropagation()">○ 📎 Has attachment</div>
|
||||||
<div class="filter-sep-line"></div>
|
<div class="filter-sep-line"></div>
|
||||||
<div class="filter-opt" id="fopt-date-desc" onclick="goMailSetFilter('date-desc');event.stopPropagation()">○ Newest first</div>
|
<div class="filter-opt" id="fopt-date-desc" onclick="goMailSetFilter('date-desc');event.stopPropagation()">○ Newest first</div>
|
||||||
<div class="filter-opt" id="fopt-date-asc" onclick="goMailSetFilter('date-asc');event.stopPropagation()">○ Oldest first</div>
|
<div class="filter-opt" id="fopt-date-asc" onclick="goMailSetFilter('date-asc');event.stopPropagation()">○ Oldest first</div>
|
||||||
@@ -263,12 +264,34 @@
|
|||||||
|
|
||||||
<!-- ── Settings Modal ─────────────────────────────────────────────────────── -->
|
<!-- ── Settings Modal ─────────────────────────────────────────────────────── -->
|
||||||
<div class="modal-overlay" id="settings-modal">
|
<div class="modal-overlay" id="settings-modal">
|
||||||
<div class="modal" style="width:520px">
|
<div class="modal" style="width:540px;max-height:90vh;overflow-y:auto">
|
||||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:22px">
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:22px">
|
||||||
<h2 style="margin-bottom:0">Settings</h2>
|
<h2 style="margin-bottom:0">Settings</h2>
|
||||||
<button onclick="closeModal('settings-modal')" class="icon-btn"><svg viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></button>
|
<button onclick="closeModal('settings-modal')" class="icon-btn"><svg viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-group">
|
||||||
|
<div class="settings-group-title">Profile</div>
|
||||||
|
<div class="modal-field">
|
||||||
|
<label>Username</label>
|
||||||
|
<div style="display:flex;gap:8px">
|
||||||
|
<input type="text" id="profile-username" placeholder="New username" style="flex:1">
|
||||||
|
<button class="btn-primary" onclick="updateProfile('username')">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-field">
|
||||||
|
<label>Email Address</label>
|
||||||
|
<div style="display:flex;gap:8px">
|
||||||
|
<input type="email" id="profile-email" placeholder="New email address" style="flex:1">
|
||||||
|
<button class="btn-primary" onclick="updateProfile('email')">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-field">
|
||||||
|
<label>Current Password <span style="color:var(--muted);font-size:11px">(required to confirm changes)</span></label>
|
||||||
|
<input type="password" id="profile-confirm-pw" placeholder="Enter your current password">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="settings-group">
|
<div class="settings-group">
|
||||||
<div class="settings-group-title">Email Sync</div>
|
<div class="settings-group-title">Email Sync</div>
|
||||||
<div style="font-size:13px;color:var(--muted);margin-bottom:12px">How often to automatically check all your accounts for new mail.</div>
|
<div style="font-size:13px;color:var(--muted);margin-bottom:12px">How often to automatically check all your accounts for new mail.</div>
|
||||||
@@ -299,6 +322,27 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="mfa-panel">Loading...</div>
|
<div id="mfa-panel">Loading...</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-group">
|
||||||
|
<div class="settings-group-title">IP Access Rules</div>
|
||||||
|
<div style="font-size:13px;color:var(--muted);margin-bottom:14px">
|
||||||
|
Control which IP addresses can access your account. This overrides global brute-force settings for your account only.
|
||||||
|
</div>
|
||||||
|
<div class="modal-field">
|
||||||
|
<label>Mode</label>
|
||||||
|
<select id="ip-rule-mode" onchange="toggleIPRuleHelp()" style="width:100%;padding:8px 10px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-family:'DM Sans',sans-serif;font-size:13px;outline:none">
|
||||||
|
<option value="disabled">Disabled — use global settings</option>
|
||||||
|
<option value="brute_skip">Skip brute-force check — listed IPs bypass lockout</option>
|
||||||
|
<option value="allow_only">Allow only — only listed IPs can log in</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="ip-rule-help" style="font-size:12px;color:var(--muted);margin-bottom:10px;display:none"></div>
|
||||||
|
<div class="modal-field" id="ip-rule-list-field">
|
||||||
|
<label>Allowed IPs <span style="color:var(--muted);font-size:11px">(comma-separated)</span></label>
|
||||||
|
<input type="text" id="ip-rule-list" placeholder="e.g. 192.168.1.10, 10.0.0.5">
|
||||||
|
</div>
|
||||||
|
<button class="btn-primary" onclick="saveIPRules()">Save IP Rules</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -308,5 +352,5 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "scripts"}}
|
{{define "scripts"}}
|
||||||
<script src="/static/js/app.js?v=12"></script>
|
<script src="/static/js/app.js?v=23"></script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -5,12 +5,12 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{{block "title" .}}GoWebMail{{end}}</title>
|
<title>{{block "title" .}}GoWebMail{{end}}</title>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=DM+Sans:ital,wght@0,300;0,400;0,500;1,400&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=DM+Sans:ital,wght@0,300;0,400;0,500;1,400&display=swap" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="/static/css/gowebmail.css?v=12">
|
<link rel="stylesheet" href="/static/css/gowebmail.css?v=23">
|
||||||
{{block "head_extra" .}}{{end}}
|
{{block "head_extra" .}}{{end}}
|
||||||
</head>
|
</head>
|
||||||
<body class="{{block "body_class" .}}{{end}}">
|
<body class="{{block "body_class" .}}{{end}}">
|
||||||
{{block "body" .}}{{end}}
|
{{block "body" .}}{{end}}
|
||||||
<script src="/static/js/gowebmail.js?v=12"></script>
|
<script src="/static/js/gowebmail.js?v=23"></script>
|
||||||
{{block "scripts" .}}{{end}}
|
{{block "scripts" .}}{{end}}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
{{define "scripts"}}
|
{{define "scripts"}}
|
||||||
<script>
|
<script>
|
||||||
const msgs={invalid_credentials:'Invalid username or password.',missing_fields:'Please fill in all fields.'};
|
const msgs={invalid_credentials:'Invalid username or password.',missing_fields:'Please fill in all fields.',location_not_authorized:'Access from your current location is not permitted for this account.'};
|
||||||
const k=new URLSearchParams(location.search).get('error');
|
const k=new URLSearchParams(location.search).get('error');
|
||||||
if(k){const b=document.getElementById('err');b.textContent=msgs[k]||'An error occurred.';b.style.display='block';}
|
if(k){const b=document.getElementById('err');b.textContent=msgs[k]||'An error occurred.';b.style.display='block';}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user