mirror of
https://github.com/ghostersk/gowebmail.git
synced 2026-04-17 00:26:01 +01:00
personal outlook working - still needs tuning
This commit is contained in:
243
README.md
243
README.md
@@ -1,215 +1,110 @@
|
||||
# GoWebMail
|
||||
|
||||
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.).
|
||||
A self-hosted, encrypted web email client written entirely in Go. Supports Gmail and Outlook via OAuth2, plus any standard IMAP/SMTP provider.
|
||||
|
||||
> **Notes:**
|
||||
> - Work still in progress (Gmail and Outlook OAuth2 not yet fully tested in production)
|
||||
> - AI-assisted development — suggestions and contributions very welcome!
|
||||
# Notes:
|
||||
- work still in progress ( gmail and hotmail email not tested yet, just prepared the app for it)
|
||||
- AI is involved in making this work, as I do not have the skill and time to do it on my own
|
||||
- looking for any advice and suggestions to improve it!
|
||||
|
||||
## Features
|
||||
|
||||
### Email
|
||||
- **Unified inbox** — view emails from all connected accounts in one stream
|
||||
- **Gmail & Outlook OAuth2** — modern token-based auth (no raw passwords stored for these providers)
|
||||
- **IMAP/SMTP** — connect any standard provider with username/password credentials
|
||||
- **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)
|
||||
- **Gmail & Outlook OAuth2** — modern, token-based auth (no storing raw passwords for these providers)
|
||||
- **IMAP/SMTP** — connect any provider (ProtonMail Bridge, Fastmail, iCloud, etc.)
|
||||
- **AES-256-GCM encryption** — all email content encrypted at rest in SQLite
|
||||
- **bcrypt password hashing** — GoWebMail account passwords hashed with cost=12
|
||||
- **TOTP MFA** — custom implementation, no external library; ±60s window for clock skew tolerance
|
||||
- **Brute-force IP blocking** — auto-blocks IPs after configurable failed login attempts (default: 5 attempts in 30 min → 12h ban); permanent blocks supported
|
||||
- **Geo-blocking** — deny or allow-only access by country via ip-api.com (no API key needed); 24h in-memory cache
|
||||
- **Per-user IP access rules** — each user configures their own IP allow-list or brute-force bypass list independently of global rules
|
||||
- **Security alert emails** — notifies the targeted user when their account is brute-forced; supports STARTTLS, implicit TLS, and plain relay
|
||||
- **DNS rebinding protection** — `HostCheckMiddleware` rejects requests with unexpected `Host` headers
|
||||
- **Security headers** — CSP, X-Frame-Options, Referrer-Policy, Permissions-Policy, X-XSS-Protection on all responses
|
||||
- **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" />
|
||||
|
||||
---
|
||||
- **Send / Reply / Forward** — full compose workflow
|
||||
- **Folder navigation** — per-account folder/label browsing
|
||||
- **Full-text search** — across all accounts locally
|
||||
- **Dark-themed web UI** — clean, keyboard-shortcut-friendly interface
|
||||
<img width="1213" height="848" alt="image" src="https://github.com/user-attachments/assets/955eda04-e358-4779-80e7-0a9b299ac110" />
|
||||
<img width="1261" height="921" alt="image" src="https://github.com/user-attachments/assets/40ee58e8-6c4b-45c3-974d-98cc8ccc45a5" />
|
||||
<img width="1153" height="907" alt="image" src="https://github.com/user-attachments/assets/ebc92335-f6b7-46ed-b9a2-84512f70e1b2" />
|
||||
<img width="551" height="669" alt="image" src="https://github.com/user-attachments/assets/412585c0-434a-4177-ab04-7db69da9d08a" />
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Option 1: Build executable
|
||||
|
||||
```bash
|
||||
# 1. Clone / copy the project
|
||||
git clone https://github.com/ghostersk/gowebmail && cd gowebmail
|
||||
go build -o gowebmail ./cmd/server
|
||||
# Smaller binary (strip debug info):
|
||||
# if you want smaller exe ( strip down debuginformation):
|
||||
go build -ldflags="-s -w" -o gowebmail ./cmd/server
|
||||
./gowebmail
|
||||
```
|
||||
|
||||
Visit `http://localhost:8080`. Default login: `admin` / `admin`.
|
||||
Visit http://localhost:8080, default login admin/admin, register an account, then connect your email.
|
||||
|
||||
### Option 2: Run directly
|
||||
|
||||
```bash
|
||||
git clone https://github.com/ghostersk/gowebmail && cd gowebmail
|
||||
go run ./cmd/server/main.go
|
||||
# Check ./data/gowebmail.conf on first run — update as needed, then restart.
|
||||
# check ./data/gowebmail.conf what gets generated on first run if not exists, update as needed.
|
||||
# then restart the app
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
### Reset Admin password, MFA
|
||||
|
||||
```bash
|
||||
# List all admin accounts with MFA status
|
||||
# List all admins with MFA status
|
||||
./gowebmail --list-admin
|
||||
|
||||
# USERNAME EMAIL MFA
|
||||
# -------- ----- ---
|
||||
# admin admin@example.com ON
|
||||
|
||||
# Reset an admin's password (minimum 8 characters)
|
||||
# Reset an admin's password (min 8 chars)
|
||||
./gowebmail --pw admin "NewSecurePass123"
|
||||
|
||||
# Disable MFA for a locked-out admin
|
||||
# Disable MFA so a locked-out admin can log in again
|
||||
./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
|
||||
|
||||
### Gmail
|
||||
|
||||
1. Go to [Google Cloud Console](https://console.cloud.google.com/) → New project
|
||||
2. Enable **Gmail API**
|
||||
3. Create **OAuth 2.0 Client ID** (Web application type)
|
||||
4. Add Authorized redirect URI: `<BASE_URL>/auth/gmail/callback`
|
||||
5. Add scope `https://mail.google.com/` (required for full IMAP access)
|
||||
6. Add test users while the app is in "Testing" mode
|
||||
7. Set in config: `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`
|
||||
3. Create **OAuth 2.0 Client ID** (Web application)
|
||||
4. Add Authorized redirect URI: `http://localhost:8080/auth/gmail/callback`
|
||||
5. Set env vars: `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`
|
||||
|
||||
> **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.
|
||||
|
||||
### Outlook / Microsoft 365
|
||||
|
||||
1. Go to [Azure portal](https://portal.azure.com/) → App registrations → New registration
|
||||
2. Set redirect URI: `<BASE_URL>/auth/outlook/callback`
|
||||
3. Under API permissions add:
|
||||
2. Set redirect URI: `http://localhost:8080/auth/outlook/callback`
|
||||
3. Under API permissions, add:
|
||||
- `https://outlook.office.com/IMAP.AccessAsUser.All`
|
||||
- `https://outlook.office.com/SMTP.Send`
|
||||
- `offline_access`, `openid`, `profile`, `email`
|
||||
4. Create a Client secret under Certificates & secrets
|
||||
5. Set in config: `MICROSOFT_CLIENT_ID`, `MICROSOFT_CLIENT_SECRET`, `MICROSOFT_TENANT_ID`
|
||||
|
||||
---
|
||||
4. Create a Client secret
|
||||
5. Set env vars: `MICROSOFT_CLIENT_ID`, `MICROSOFT_CLIENT_SECRET`, `MICROSOFT_TENANT_ID`
|
||||
|
||||
## Security Notes
|
||||
|
||||
- **`ENCRYPTION_KEY` is critical** — back it up. Without it the encrypted SQLite database is permanently unreadable.
|
||||
- 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.
|
||||
- GoWebMail user passwords are bcrypt hashed (cost=12). Session tokens are 32-byte `crypto/rand` hex strings.
|
||||
- All HTTP responses include security headers (CSP, X-Frame-Options, Referrer-Policy, 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 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.
|
||||
- **ENCRYPTION_KEY** is critical — back it up. Without it, the encrypted SQLite database is unreadable.
|
||||
- Email content (subject, from, to, body) is encrypted at rest using AES-256-GCM.
|
||||
- OAuth2 tokens are stored encrypted in the database.
|
||||
- Passwords for GoWebMail accounts are bcrypt hashed (cost=12).
|
||||
- All HTTP responses include security headers (CSP, X-Frame-Options, etc.).
|
||||
- In production, run behind HTTPS (nginx/Caddy) and set `SECURE_COOKIE=true`.
|
||||
|
||||
---
|
||||
## 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
|
||||
|
||||
@@ -217,41 +112,7 @@ Geo lookups use [ip-api.com](http://ip-api.com) (free tier, no API key, ~45 req/
|
||||
CGO_ENABLED=1 GOOS=linux go build -ldflags="-s -w" -o gowebmail ./cmd/server
|
||||
```
|
||||
|
||||
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 |
|
||||
|
||||
---
|
||||
CGO is required by `go-sqlite3`. Cross-compilation requires a C cross-compiler.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the [GPL-3.0 license](LICENSE).
|
||||
@@ -146,6 +146,8 @@ func main() {
|
||||
oauthR.HandleFunc("/gmail/callback", h.Auth.GmailCallback).Methods("GET")
|
||||
oauthR.HandleFunc("/outlook/connect", h.Auth.OutlookConnect).Methods("GET")
|
||||
oauthR.HandleFunc("/outlook/callback", h.Auth.OutlookCallback).Methods("GET")
|
||||
oauthR.HandleFunc("/outlook-personal/connect", h.Auth.OutlookPersonalConnect).Methods("GET")
|
||||
oauthR.HandleFunc("/outlook-personal/callback", h.Auth.OutlookPersonalCallback).Methods("GET")
|
||||
|
||||
// App
|
||||
app := r.PathPrefix("").Subrouter()
|
||||
|
||||
@@ -303,10 +303,15 @@ var allFields = []configField{
|
||||
},
|
||||
{
|
||||
key: "MICROSOFT_TENANT_ID",
|
||||
defVal: "common",
|
||||
defVal: "consumers",
|
||||
comments: []string{
|
||||
"Use 'common' to allow any Microsoft account,",
|
||||
"or your Azure tenant ID to restrict to one organisation.",
|
||||
"Tenant endpoint to use for Microsoft OAuth2.",
|
||||
" common - Any Entra ID + Personal Microsoft accounts (outlook.com/hotmail/live)",
|
||||
" Use this if your Azure app is registered as 'Any Entra ID + Personal'.",
|
||||
" consumers - Personal Microsoft accounts only (outlook.com/hotmail/live).",
|
||||
" Use if registered as 'Personal accounts only'.",
|
||||
" organizations - Work/school Microsoft 365 accounts only.",
|
||||
" <your-tenant-id> - Restrict to a single Azure AD tenant (company accounts).",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -452,7 +457,7 @@ func Load() (*Config, error) {
|
||||
GoogleRedirectURL: googleRedirect,
|
||||
MicrosoftClientID: get("MICROSOFT_CLIENT_ID"),
|
||||
MicrosoftClientSecret: get("MICROSOFT_CLIENT_SECRET"),
|
||||
MicrosoftTenantID: orDefault(get("MICROSOFT_TENANT_ID"), "common"),
|
||||
MicrosoftTenantID: orDefault(get("MICROSOFT_TENANT_ID"), "consumers"),
|
||||
MicrosoftRedirectURL: outlookRedirect,
|
||||
}
|
||||
|
||||
@@ -757,6 +762,13 @@ func logStartupInfo(cfg *Config) {
|
||||
}
|
||||
fmt.Printf(" Proxies : %s\n", strings.Join(cidrs, ", "))
|
||||
}
|
||||
if cfg.GoogleClientID != "" {
|
||||
fmt.Printf(" Gmail OAuth redirect : %s\n", cfg.GoogleRedirectURL)
|
||||
}
|
||||
if cfg.MicrosoftClientID != "" {
|
||||
fmt.Printf(" Outlook OAuth redirect: %s\n", cfg.MicrosoftRedirectURL)
|
||||
fmt.Printf(" Outlook tenant : %s\n", cfg.MicrosoftTenantID)
|
||||
}
|
||||
}
|
||||
|
||||
func parseIPList(s string) []net.IP {
|
||||
|
||||
9
go.mod
9
go.mod
@@ -5,14 +5,13 @@ go 1.26
|
||||
require (
|
||||
github.com/emersion/go-imap v1.2.1
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/mattn/go-sqlite3 v1.14.22
|
||||
golang.org/x/crypto v0.24.0
|
||||
golang.org/x/oauth2 v0.21.0
|
||||
github.com/mattn/go-sqlite3 v1.14.34
|
||||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go/compute/metadata v0.3.0 // indirect
|
||||
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 // indirect
|
||||
github.com/google/go-cmp v0.6.0 // indirect
|
||||
golang.org/x/text v0.16.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
)
|
||||
|
||||
18
go.sum
18
go.sum
@@ -6,18 +6,16 @@ github.com/emersion/go-message v0.15.0/go.mod h1:wQUEfE+38+7EW8p8aZ96ptg6bAb1iwd
|
||||
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ=
|
||||
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
|
||||
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
|
||||
golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
|
||||
golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
|
||||
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
|
||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
|
||||
@@ -3,9 +3,13 @@ package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
@@ -60,33 +64,110 @@ func GetGoogleUserInfo(ctx context.Context, token *oauth2.Token, cfg *oauth2.Con
|
||||
|
||||
// ---- Microsoft / Outlook OAuth2 ----
|
||||
|
||||
// OutlookScopes are required for Outlook/Microsoft 365 mail access.
|
||||
var OutlookScopes = []string{
|
||||
// OutlookAuthScopes are used for the Microsoft 365 / Outlook work & school OAuth flow.
|
||||
// Uses https://outlook.office.com/ prefix so the resulting token has the correct
|
||||
// audience for IMAP XOAUTH2 authentication.
|
||||
var OutlookAuthScopes = []string{
|
||||
"https://outlook.office.com/IMAP.AccessAsUser.All",
|
||||
"https://outlook.office.com/SMTP.Send",
|
||||
"offline_access",
|
||||
"openid",
|
||||
"profile",
|
||||
"email",
|
||||
}
|
||||
|
||||
// NewOutlookConfig creates an OAuth2 config for Microsoft/Outlook.
|
||||
// NewOutlookConfig creates the OAuth2 config for the authorization flow.
|
||||
func NewOutlookConfig(clientID, clientSecret, tenantID, redirectURL string) *oauth2.Config {
|
||||
if tenantID == "" {
|
||||
tenantID = "consumers"
|
||||
}
|
||||
// "consumers" forces the Azure AD v2.0 endpoint for personal accounts
|
||||
// and returns a proper JWT Bearer token (aud=https://outlook.office.com).
|
||||
// "common" routes personal accounts through login.live.com which returns
|
||||
// a v1.0 opaque token (starts with EwA) that IMAP XOAUTH2 rejects.
|
||||
return &oauth2.Config{
|
||||
ClientID: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
RedirectURL: redirectURL,
|
||||
Scopes: OutlookScopes,
|
||||
Scopes: OutlookAuthScopes,
|
||||
Endpoint: microsoft.AzureADEndpoint(tenantID),
|
||||
}
|
||||
}
|
||||
|
||||
// MicrosoftUserInfo holds data from Microsoft Graph /me endpoint.
|
||||
// ExchangeForIMAPToken takes the refresh_token obtained from the Graph-scoped
|
||||
// authorization and exchanges it for an access token scoped to the Outlook
|
||||
// resource (aud=https://outlook.office.com), which the IMAP server requires.
|
||||
// The two-step approach is necessary because:
|
||||
// - Azure personal app registrations only expose bare Graph scope names in their UI
|
||||
// - The IMAP server rejects tokens whose aud is graph.microsoft.com
|
||||
// - Using the refresh_token against the Outlook resource produces a correct token
|
||||
func ExchangeForIMAPToken(ctx context.Context, clientID, clientSecret, tenantID, refreshToken string) (*oauth2.Token, error) {
|
||||
if tenantID == "" {
|
||||
tenantID = "consumers"
|
||||
}
|
||||
tokenURL := "https://login.microsoftonline.com/" + tenantID + "/oauth2/v2.0/token"
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("grant_type", "refresh_token")
|
||||
params.Set("client_id", clientID)
|
||||
params.Set("client_secret", clientSecret)
|
||||
params.Set("refresh_token", refreshToken)
|
||||
params.Set("scope", "https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send offline_access")
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(params.Encode()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build IMAP token request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("IMAP token request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Error string `json:"error"`
|
||||
ErrorDesc string `json:"error_description"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("decode IMAP token response: %w", err)
|
||||
}
|
||||
if result.Error != "" {
|
||||
return nil, fmt.Errorf("microsoft IMAP token error: %s — %s", result.Error, result.ErrorDesc)
|
||||
}
|
||||
if result.AccessToken == "" {
|
||||
return nil, fmt.Errorf("microsoft returned empty IMAP access token")
|
||||
}
|
||||
|
||||
// Log first 30 chars and whether it looks like a JWT (3 dot-separated parts)
|
||||
preview := result.AccessToken
|
||||
if len(preview) > 30 {
|
||||
preview = preview[:30] + "..."
|
||||
}
|
||||
parts := strings.Count(result.AccessToken, ".") + 1
|
||||
log.Printf("[oauth:outlook:exchange] got token with %d parts: %s (scope=%s)",
|
||||
parts, preview, params.Get("scope"))
|
||||
|
||||
expiry := time.Now().Add(time.Duration(result.ExpiresIn) * time.Second)
|
||||
return &oauth2.Token{
|
||||
AccessToken: result.AccessToken,
|
||||
RefreshToken: result.RefreshToken,
|
||||
Expiry: expiry,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// MicrosoftUserInfo holds user info extracted from the Microsoft ID token.
|
||||
type MicrosoftUserInfo struct {
|
||||
ID string `json:"id"`
|
||||
DisplayName string `json:"displayName"`
|
||||
DisplayName string `json:"displayName"` // Graph field
|
||||
Name string `json:"name"` // ID token claim
|
||||
Mail string `json:"mail"`
|
||||
EmailClaim string `json:"email"` // ID token claim
|
||||
UserPrincipalName string `json:"userPrincipalName"`
|
||||
PreferredUsername string `json:"preferred_username"` // ID token claim
|
||||
}
|
||||
|
||||
// Email returns the best available email address.
|
||||
@@ -94,27 +175,84 @@ func (m *MicrosoftUserInfo) Email() string {
|
||||
if m.Mail != "" {
|
||||
return m.Mail
|
||||
}
|
||||
if m.EmailClaim != "" {
|
||||
return m.EmailClaim
|
||||
}
|
||||
if m.PreferredUsername != "" {
|
||||
return m.PreferredUsername
|
||||
}
|
||||
return m.UserPrincipalName
|
||||
}
|
||||
|
||||
// GetMicrosoftUserInfo fetches user info from Microsoft Graph.
|
||||
// BestName returns the best available display name.
|
||||
func (m *MicrosoftUserInfo) BestName() string {
|
||||
if m.DisplayName != "" {
|
||||
return m.DisplayName
|
||||
}
|
||||
return m.Name
|
||||
}
|
||||
|
||||
// GetMicrosoftUserInfo extracts user info from the OAuth2 token's ID token JWT.
|
||||
// This avoids calling graph.microsoft.com/v1.0/me which requires a Graph-scoped
|
||||
// token — but our token is scoped to outlook.office.com for IMAP/SMTP access.
|
||||
// The ID token is issued alongside the access token and contains email/name claims.
|
||||
func GetMicrosoftUserInfo(ctx context.Context, token *oauth2.Token, cfg *oauth2.Config) (*MicrosoftUserInfo, error) {
|
||||
client := cfg.Client(ctx, token)
|
||||
resp, err := client.Get("https://graph.microsoft.com/v1.0/me")
|
||||
idToken, _ := token.Extra("id_token").(string)
|
||||
if idToken == "" {
|
||||
return nil, fmt.Errorf("no id_token in Microsoft token response")
|
||||
}
|
||||
|
||||
// JWT structure: header.payload.signature — decode the payload only
|
||||
parts := strings.SplitN(idToken, ".", 3)
|
||||
if len(parts) != 3 {
|
||||
return nil, fmt.Errorf("malformed id_token: expected 3 parts, got %d", len(parts))
|
||||
}
|
||||
|
||||
decoded, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("graph /me request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("graph /me returned %d", resp.StatusCode)
|
||||
return nil, fmt.Errorf("id_token base64 decode: %w", err)
|
||||
}
|
||||
|
||||
var info MicrosoftUserInfo
|
||||
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
|
||||
return nil, err
|
||||
if err := json.Unmarshal(decoded, &info); err != nil {
|
||||
return nil, fmt.Errorf("id_token JSON decode: %w", err)
|
||||
}
|
||||
|
||||
if info.Email() == "" {
|
||||
return nil, fmt.Errorf("id_token contains no usable email address (raw claims: %s)", string(decoded))
|
||||
}
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
// ---- Outlook Personal (Graph API) ----
|
||||
|
||||
// OutlookPersonalScopes are used for personal outlook.com accounts.
|
||||
// These use Microsoft Graph which correctly issues JWT tokens for personal accounts.
|
||||
// Mail is accessed via Graph REST API instead of IMAP.
|
||||
var OutlookPersonalScopes = []string{
|
||||
"https://graph.microsoft.com/Mail.ReadWrite",
|
||||
"https://graph.microsoft.com/Mail.Send",
|
||||
"https://graph.microsoft.com/User.Read",
|
||||
"offline_access",
|
||||
"openid",
|
||||
"email",
|
||||
}
|
||||
|
||||
// NewOutlookPersonalConfig creates OAuth2 config for personal outlook.com accounts.
|
||||
// Uses consumers tenant to force Azure AD v2.0 endpoint and get JWT tokens.
|
||||
func NewOutlookPersonalConfig(clientID, clientSecret, tenantID, redirectURL string) *oauth2.Config {
|
||||
if tenantID == "" {
|
||||
tenantID = "consumers"
|
||||
}
|
||||
return &oauth2.Config{
|
||||
ClientID: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
RedirectURL: redirectURL,
|
||||
Scopes: OutlookPersonalScopes,
|
||||
Endpoint: microsoft.AzureADEndpoint(tenantID),
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Token refresh helpers ----
|
||||
|
||||
// IsTokenExpired reports whether the token expires within a 60-second buffer.
|
||||
@@ -140,19 +278,39 @@ func RefreshAccountToken(ctx context.Context,
|
||||
msClientID, msClientSecret, msTenantID string,
|
||||
) (accessToken, newRefresh string, expiry time.Time, err error) {
|
||||
|
||||
var cfg *oauth2.Config
|
||||
switch provider {
|
||||
case "gmail":
|
||||
cfg = NewGmailConfig(googleClientID, googleClientSecret, baseURL+"/auth/gmail/callback")
|
||||
cfg := NewGmailConfig(googleClientID, googleClientSecret, baseURL+"/auth/gmail/callback")
|
||||
tok, err := RefreshToken(ctx, cfg, refreshToken)
|
||||
if err != nil {
|
||||
return "", "", time.Time{}, err
|
||||
}
|
||||
return tok.AccessToken, tok.RefreshToken, tok.Expiry, nil
|
||||
case "outlook":
|
||||
cfg = NewOutlookConfig(msClientID, msClientSecret, msTenantID, baseURL+"/auth/outlook/callback")
|
||||
cfg := NewOutlookConfig(msClientID, msClientSecret, msTenantID, baseURL+"/auth/outlook/callback")
|
||||
tok, err := RefreshToken(ctx, cfg, refreshToken)
|
||||
if err != nil {
|
||||
return "", "", time.Time{}, err
|
||||
}
|
||||
rt := tok.RefreshToken
|
||||
if rt == "" {
|
||||
rt = refreshToken
|
||||
}
|
||||
return tok.AccessToken, rt, tok.Expiry, nil
|
||||
case "outlook_personal":
|
||||
// Personal outlook.com accounts use Graph API scopes — standard refresh works
|
||||
cfg := NewOutlookPersonalConfig(msClientID, msClientSecret, msTenantID,
|
||||
baseURL+"/auth/outlook-personal/callback")
|
||||
tok, err := RefreshToken(ctx, cfg, refreshToken)
|
||||
if err != nil {
|
||||
return "", "", time.Time{}, err
|
||||
}
|
||||
rt := tok.RefreshToken
|
||||
if rt == "" {
|
||||
rt = refreshToken
|
||||
}
|
||||
return tok.AccessToken, rt, tok.Expiry, nil
|
||||
default:
|
||||
return "", "", time.Time{}, fmt.Errorf("not an OAuth provider: %s", provider)
|
||||
}
|
||||
|
||||
tok, err := RefreshToken(ctx, cfg, refreshToken)
|
||||
if err != nil {
|
||||
return "", "", time.Time{}, err
|
||||
}
|
||||
return tok.AccessToken, tok.RefreshToken, tok.Expiry, nil
|
||||
}
|
||||
|
||||
@@ -908,6 +908,13 @@ func (d *DB) SetUIPrefs(userID int64, prefs string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateFolderCountsDirect sets folder counts directly (used by Graph sync where
|
||||
// the server provides accurate counts without needing a local recount).
|
||||
func (d *DB) UpdateFolderCountsDirect(folderID int64, total, unread int) {
|
||||
d.sql.Exec(`UPDATE folders SET total_count=?, unread_count=? WHERE id=?`,
|
||||
total, unread, folderID)
|
||||
}
|
||||
|
||||
// UpdateFolderCounts refreshes the unread/total counts for a folder.
|
||||
func (d *DB) UpdateFolderCounts(folderID int64) {
|
||||
d.sql.Exec(`
|
||||
@@ -1220,6 +1227,14 @@ func (d *DB) MarkMessageRead(messageID, userID int64, read bool) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateMessageBody persists body text/html for a message (used by Graph lazy fetch).
|
||||
func (d *DB) UpdateMessageBody(messageID int64, bodyText, bodyHTML string) {
|
||||
bodyTextEnc, _ := d.enc.Encrypt(bodyText)
|
||||
bodyHTMLEnc, _ := d.enc.Encrypt(bodyHTML)
|
||||
d.sql.Exec(`UPDATE messages SET body_text=?, body_html=? WHERE id=?`,
|
||||
bodyTextEnc, bodyHTMLEnc, messageID)
|
||||
}
|
||||
|
||||
func (d *DB) ToggleMessageStar(messageID, userID int64) (bool, error) {
|
||||
var current bool
|
||||
err := d.sql.QueryRow(`
|
||||
@@ -1415,6 +1430,28 @@ func (d *DB) GetMessageIMAPInfo(messageID, userID int64) (remoteUID uint32, fold
|
||||
return remoteUID, folder.FullPath, account, err
|
||||
}
|
||||
|
||||
// GetMessageGraphInfo returns the Graph message ID (remote_uid as string), folder ID string,
|
||||
// and account for a Graph-backed message. Used by handlers for outlook_personal accounts.
|
||||
func (d *DB) GetMessageGraphInfo(messageID, userID int64) (graphMsgID string, folderGraphID string, account *models.EmailAccount, err error) {
|
||||
var accountID int64
|
||||
var folderID int64
|
||||
err = d.sql.QueryRow(`
|
||||
SELECT m.remote_uid, m.account_id, m.folder_id
|
||||
FROM messages m
|
||||
JOIN email_accounts a ON a.id = m.account_id
|
||||
WHERE m.id=? AND a.user_id=?`, messageID, userID,
|
||||
).Scan(&graphMsgID, &accountID, &folderID)
|
||||
if err != nil {
|
||||
return "", "", nil, err
|
||||
}
|
||||
folder, err := d.GetFolderByID(folderID)
|
||||
if err != nil || folder == nil {
|
||||
return graphMsgID, "", nil, fmt.Errorf("folder not found")
|
||||
}
|
||||
account, err = d.GetAccount(accountID)
|
||||
return graphMsgID, folder.FullPath, account, err
|
||||
}
|
||||
|
||||
// ListStarredMessages returns all starred messages for a user, newest first.
|
||||
func (d *DB) ListStarredMessages(userID int64, page, pageSize int) (*models.PagedMessages, error) {
|
||||
offset := (page - 1) * pageSize
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
@@ -54,7 +55,24 @@ func (x *xoauth2Client) Start() (string, []byte, error) {
|
||||
payload := fmt.Sprintf("user=%s\x01auth=Bearer %s\x01\x01", x.user, x.token)
|
||||
return "XOAUTH2", []byte(payload), nil
|
||||
}
|
||||
func (x *xoauth2Client) Next([]byte) ([]byte, error) { return []byte{}, nil }
|
||||
|
||||
// Next handles the XOAUTH2 challenge from the server.
|
||||
// When auth fails, Microsoft sends a base64-encoded JSON error as a challenge.
|
||||
// The correct response is an empty \x01 byte to abort; go-imap then gets the
|
||||
// final tagged NO response and returns a proper error.
|
||||
func (x *xoauth2Client) Next(challenge []byte) ([]byte, error) {
|
||||
if len(challenge) > 0 {
|
||||
// Decode and log the error from Microsoft so it appears in server logs
|
||||
if dec, err := base64.StdEncoding.DecodeString(string(challenge)); err == nil {
|
||||
log.Printf("[imap:xoauth2] server error for %s: %s", x.user, string(dec))
|
||||
} else {
|
||||
log.Printf("[imap:xoauth2] server challenge for %s: %s", x.user, string(challenge))
|
||||
}
|
||||
// Send empty response to let the server send the final error
|
||||
return []byte("\x01"), nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
type xoauth2SMTP struct{ user, token string }
|
||||
|
||||
@@ -80,6 +98,9 @@ type Client struct {
|
||||
}
|
||||
|
||||
func Connect(ctx context.Context, account *gomailModels.EmailAccount) (*Client, error) {
|
||||
if account.Provider == gomailModels.ProviderOutlookPersonal {
|
||||
return nil, fmt.Errorf("outlook_personal accounts use Graph API, not IMAP")
|
||||
}
|
||||
host, port := imapHostFor(account.Provider)
|
||||
if account.IMAPHost != "" {
|
||||
host = account.IMAPHost
|
||||
@@ -108,6 +129,33 @@ func Connect(ctx context.Context, account *gomailModels.EmailAccount) (*Client,
|
||||
|
||||
switch account.Provider {
|
||||
case gomailModels.ProviderGmail, gomailModels.ProviderOutlook:
|
||||
// Always log the token's audience and scope so we can diagnose IMAP auth failures.
|
||||
tokenPreview := account.AccessToken
|
||||
if len(tokenPreview) > 20 {
|
||||
tokenPreview = tokenPreview[:20] + "..."
|
||||
}
|
||||
if parts := strings.SplitN(account.AccessToken, ".", 3); len(parts) == 3 {
|
||||
if payload, err := base64.RawURLEncoding.DecodeString(parts[1]); err == nil {
|
||||
var claims struct {
|
||||
Aud interface{} `json:"aud"`
|
||||
Scp string `json:"scp"`
|
||||
Upn string `json:"upn"`
|
||||
}
|
||||
if json.Unmarshal(payload, &claims) == nil {
|
||||
log.Printf("[imap:connect] %s aud=%v scp=%q token=%s",
|
||||
account.EmailAddress, claims.Aud, claims.Scp, tokenPreview)
|
||||
} else {
|
||||
log.Printf("[imap:connect] %s raw claims: %s token=%s",
|
||||
account.EmailAddress, string(payload), tokenPreview)
|
||||
}
|
||||
} else {
|
||||
log.Printf("[imap:connect] %s opaque token (not JWT): %s",
|
||||
account.EmailAddress, tokenPreview)
|
||||
}
|
||||
} else {
|
||||
log.Printf("[imap:connect] %s token has %d parts (not JWT): %s",
|
||||
account.EmailAddress, len(strings.Split(account.AccessToken, ".")), tokenPreview)
|
||||
}
|
||||
sasl := &xoauth2Client{user: account.EmailAddress, token: account.AccessToken}
|
||||
if err := c.Authenticate(sasl); err != nil {
|
||||
c.Logout()
|
||||
|
||||
397
internal/graph/graph.go
Normal file
397
internal/graph/graph.go
Normal file
@@ -0,0 +1,397 @@
|
||||
// Package graph provides Microsoft Graph API mail access for personal
|
||||
// outlook.com accounts. Personal accounts cannot use IMAP OAuth with
|
||||
// custom Azure app registrations (Microsoft only issues opaque v1 tokens),
|
||||
// so we use the Graph REST API instead with the JWT access token.
|
||||
package graph
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ghostersk/gowebmail/internal/models"
|
||||
)
|
||||
|
||||
const baseURL = "https://graph.microsoft.com/v1.0/me"
|
||||
|
||||
// Client wraps Graph API calls for a single account.
|
||||
type Client struct {
|
||||
token string
|
||||
account *models.EmailAccount
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// New creates a Graph client for the given account.
|
||||
func New(account *models.EmailAccount) *Client {
|
||||
return &Client{
|
||||
token: account.AccessToken,
|
||||
account: account,
|
||||
http: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) get(ctx context.Context, path string, out interface{}) error {
|
||||
fullURL := path
|
||||
if !strings.HasPrefix(path, "https://") {
|
||||
fullURL = baseURL + path
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("graph API %s returned %d: %s", path, resp.StatusCode, string(body))
|
||||
}
|
||||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
}
|
||||
|
||||
func (c *Client) patch(ctx context.Context, path string, body map[string]interface{}) error {
|
||||
b, _ := json.Marshal(body)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, baseURL+path,
|
||||
strings.NewReader(string(b)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("graph PATCH %s returned %d", path, resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) deleteReq(ctx context.Context, path string) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, baseURL+path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("graph DELETE %s returned %d", path, resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- Folders ----
|
||||
|
||||
// GraphFolder represents a mail folder from Graph API.
|
||||
type GraphFolder struct {
|
||||
ID string `json:"id"`
|
||||
DisplayName string `json:"displayName"`
|
||||
TotalCount int `json:"totalItemCount"`
|
||||
UnreadCount int `json:"unreadItemCount"`
|
||||
WellKnown string `json:"wellKnownName"`
|
||||
}
|
||||
|
||||
type foldersResp struct {
|
||||
Value []GraphFolder `json:"value"`
|
||||
NextLink string `json:"@odata.nextLink"`
|
||||
}
|
||||
|
||||
// ListFolders returns all mail folders for the account.
|
||||
func (c *Client) ListFolders(ctx context.Context) ([]GraphFolder, error) {
|
||||
var all []GraphFolder
|
||||
path := "/mailFolders?$top=100&$select=id,displayName,totalItemCount,unreadItemCount"
|
||||
for path != "" {
|
||||
var resp foldersResp
|
||||
if err := c.get(ctx, path, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
all = append(all, resp.Value...)
|
||||
if resp.NextLink != "" {
|
||||
path = resp.NextLink
|
||||
} else {
|
||||
path = ""
|
||||
}
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
|
||||
// ---- Messages ----
|
||||
|
||||
// EmailAddress wraps a Graph email address object.
|
||||
type EmailAddress struct {
|
||||
EmailAddress struct {
|
||||
Name string `json:"name"`
|
||||
Address string `json:"address"`
|
||||
} `json:"emailAddress"`
|
||||
}
|
||||
|
||||
// GraphMessage represents a mail message from Graph API.
|
||||
type GraphMessage struct {
|
||||
ID string `json:"id"`
|
||||
Subject string `json:"subject"`
|
||||
IsRead bool `json:"isRead"`
|
||||
Flag struct{ Status string `json:"flagStatus"` } `json:"flag"`
|
||||
ReceivedDateTime time.Time `json:"receivedDateTime"`
|
||||
HasAttachments bool `json:"hasAttachments"`
|
||||
From *EmailAddress `json:"from"`
|
||||
ToRecipients []EmailAddress `json:"toRecipients"`
|
||||
CcRecipients []EmailAddress `json:"ccRecipients"`
|
||||
Body struct {
|
||||
Content string `json:"content"`
|
||||
ContentType string `json:"contentType"`
|
||||
} `json:"body"`
|
||||
InternetMessageID string `json:"internetMessageId"`
|
||||
}
|
||||
|
||||
// IsFlagged returns true if the message is flagged.
|
||||
func (m *GraphMessage) IsFlagged() bool {
|
||||
return m.Flag.Status == "flagged"
|
||||
}
|
||||
|
||||
// FromName returns the sender display name.
|
||||
func (m *GraphMessage) FromName() string {
|
||||
if m.From == nil {
|
||||
return ""
|
||||
}
|
||||
return m.From.EmailAddress.Name
|
||||
}
|
||||
|
||||
// FromEmail returns the sender email address.
|
||||
func (m *GraphMessage) FromEmail() string {
|
||||
if m.From == nil {
|
||||
return ""
|
||||
}
|
||||
return m.From.EmailAddress.Address
|
||||
}
|
||||
|
||||
// ToList returns a comma-separated list of recipients.
|
||||
func (m *GraphMessage) ToList() string {
|
||||
var parts []string
|
||||
for _, r := range m.ToRecipients {
|
||||
parts = append(parts, r.EmailAddress.Address)
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
type messagesResp struct {
|
||||
Value []GraphMessage `json:"value"`
|
||||
NextLink string `json:"@odata.nextLink"`
|
||||
}
|
||||
|
||||
// ListMessages returns messages in a folder, optionally filtered by received date.
|
||||
func (c *Client) ListMessages(ctx context.Context, folderID string, since time.Time, maxResults int) ([]GraphMessage, error) {
|
||||
filter := ""
|
||||
if !since.IsZero() {
|
||||
filter = "&$filter=receivedDateTime+gt+" +
|
||||
url.QueryEscape(since.UTC().Format("2006-01-02T15:04:05Z"))
|
||||
}
|
||||
top := 50
|
||||
if maxResults > 0 && maxResults < top {
|
||||
top = maxResults
|
||||
}
|
||||
path := fmt.Sprintf("/mailFolders/%s/messages?$top=%d&$select=id,subject,isRead,flag,receivedDateTime,hasAttachments,from,toRecipients,internetMessageId%s&$orderby=receivedDateTime desc",
|
||||
folderID, top, filter)
|
||||
|
||||
var all []GraphMessage
|
||||
for path != "" {
|
||||
var resp messagesResp
|
||||
if err := c.get(ctx, path, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
all = append(all, resp.Value...)
|
||||
if resp.NextLink != "" && (maxResults <= 0 || len(all) < maxResults) {
|
||||
path = resp.NextLink
|
||||
} else {
|
||||
path = ""
|
||||
}
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
|
||||
// GetMessage returns a single message with full body.
|
||||
func (c *Client) GetMessage(ctx context.Context, msgID string) (*GraphMessage, error) {
|
||||
var msg GraphMessage
|
||||
err := c.get(ctx, "/messages/"+msgID+
|
||||
"?$select=id,subject,isRead,flag,receivedDateTime,hasAttachments,from,toRecipients,ccRecipients,body,internetMessageId",
|
||||
&msg)
|
||||
return &msg, err
|
||||
}
|
||||
|
||||
// GetMessageRaw returns the raw RFC 822 message bytes.
|
||||
func (c *Client) GetMessageRaw(ctx context.Context, msgID string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
|
||||
baseURL+"/messages/"+msgID+"/$value", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("graph raw message returned %d", resp.StatusCode)
|
||||
}
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
// MarkRead sets the isRead flag on a message.
|
||||
func (c *Client) MarkRead(ctx context.Context, msgID string, read bool) error {
|
||||
return c.patch(ctx, "/messages/"+msgID, map[string]interface{}{"isRead": read})
|
||||
}
|
||||
|
||||
// MarkFlagged sets or clears the flag on a message.
|
||||
func (c *Client) MarkFlagged(ctx context.Context, msgID string, flagged bool) error {
|
||||
status := "notFlagged"
|
||||
if flagged {
|
||||
status = "flagged"
|
||||
}
|
||||
return c.patch(ctx, "/messages/"+msgID, map[string]interface{}{
|
||||
"flag": map[string]string{"flagStatus": status},
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteMessage moves a message to Deleted Items (soft delete).
|
||||
func (c *Client) DeleteMessage(ctx context.Context, msgID string) error {
|
||||
return c.deleteReq(ctx, "/messages/"+msgID)
|
||||
}
|
||||
|
||||
// MoveMessage moves a message to a different folder.
|
||||
func (c *Client) MoveMessage(ctx context.Context, msgID, destFolderID string) error {
|
||||
b, _ := json.Marshal(map[string]string{"destinationId": destFolderID})
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
baseURL+"/messages/"+msgID+"/move", strings.NewReader(string(b)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("graph move returned %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// InferFolderType maps Graph folder names/display names to GoWebMail folder types.
|
||||
// WellKnown field is not selectable via $select — we infer from displayName instead.
|
||||
func InferFolderType(displayName string) string {
|
||||
switch strings.ToLower(displayName) {
|
||||
case "inbox":
|
||||
return "inbox"
|
||||
case "sent items", "sent":
|
||||
return "sent"
|
||||
case "drafts":
|
||||
return "drafts"
|
||||
case "deleted items", "trash", "bin":
|
||||
return "trash"
|
||||
case "junk email", "spam", "junk":
|
||||
return "spam"
|
||||
case "archive":
|
||||
return "archive"
|
||||
default:
|
||||
return "custom"
|
||||
}
|
||||
}
|
||||
|
||||
// WellKnownToFolderType kept for compatibility.
|
||||
func WellKnownToFolderType(wk string) string {
|
||||
return InferFolderType(wk)
|
||||
}
|
||||
|
||||
// ---- Send mail ----
|
||||
|
||||
// SendMail sends an email via Graph API POST /me/sendMail.
|
||||
func (c *Client) SendMail(ctx context.Context, req *models.ComposeRequest) error {
|
||||
contentType := "HTML"
|
||||
body := req.BodyHTML
|
||||
if body == "" {
|
||||
contentType = "Text"
|
||||
body = req.BodyText
|
||||
}
|
||||
|
||||
msg := map[string]interface{}{
|
||||
"subject": req.Subject,
|
||||
"body": map[string]string{
|
||||
"contentType": contentType,
|
||||
"content": body,
|
||||
},
|
||||
"toRecipients": graphRecipients(req.To),
|
||||
"ccRecipients": graphRecipients(req.CC),
|
||||
"bccRecipients": graphRecipients(req.BCC),
|
||||
}
|
||||
|
||||
if len(req.Attachments) > 0 {
|
||||
var atts []map[string]interface{}
|
||||
for _, a := range req.Attachments {
|
||||
atts = append(atts, map[string]interface{}{
|
||||
"@odata.type": "#microsoft.graph.fileAttachment",
|
||||
"name": a.Filename,
|
||||
"contentType": a.ContentType,
|
||||
"contentBytes": base64.StdEncoding.EncodeToString(a.Data),
|
||||
})
|
||||
}
|
||||
msg["attachments"] = atts
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(map[string]interface{}{
|
||||
"message": msg,
|
||||
"saveToSentItems": true,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal sendMail: %w", err)
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
baseURL+"/sendMail", strings.NewReader(string(payload)))
|
||||
if err != nil {
|
||||
return fmt.Errorf("build sendMail request: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Authorization", "Bearer "+c.token)
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.http.Do(httpReq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("sendMail request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
errBody, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("sendMail returned %d: %s", resp.StatusCode, string(errBody))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func graphRecipients(addrs []string) []map[string]interface{} {
|
||||
result := []map[string]interface{}{}
|
||||
for _, a := range addrs {
|
||||
a = strings.TrimSpace(a)
|
||||
if a != "" {
|
||||
result = append(result, map[string]interface{}{
|
||||
"emailAddress": map[string]string{"address": a},
|
||||
})
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/ghostersk/gowebmail/internal/auth"
|
||||
"github.com/ghostersk/gowebmail/internal/db"
|
||||
"github.com/ghostersk/gowebmail/internal/email"
|
||||
graphpkg "github.com/ghostersk/gowebmail/internal/graph"
|
||||
"github.com/ghostersk/gowebmail/internal/middleware"
|
||||
"github.com/ghostersk/gowebmail/internal/models"
|
||||
"github.com/ghostersk/gowebmail/internal/syncer"
|
||||
@@ -45,8 +46,9 @@ func (h *APIHandler) writeError(w http.ResponseWriter, status int, msg string) {
|
||||
// GetProviders returns which OAuth providers are configured and enabled.
|
||||
func (h *APIHandler) GetProviders(w http.ResponseWriter, r *http.Request) {
|
||||
h.writeJSON(w, map[string]bool{
|
||||
"gmail": h.cfg.GoogleClientID != "" && h.cfg.GoogleClientSecret != "",
|
||||
"outlook": h.cfg.MicrosoftClientID != "" && h.cfg.MicrosoftClientSecret != "",
|
||||
"gmail": h.cfg.GoogleClientID != "" && h.cfg.GoogleClientSecret != "",
|
||||
"outlook": h.cfg.MicrosoftClientID != "" && h.cfg.MicrosoftClientSecret != "",
|
||||
"outlook_personal": h.cfg.MicrosoftClientID != "" && h.cfg.MicrosoftClientSecret != "",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -76,7 +78,7 @@ func toSafeAccount(a *models.EmailAccount) safeAccount {
|
||||
lastSync = a.LastSync.Format("2006-01-02T15:04:05Z")
|
||||
}
|
||||
tokenExpired := false
|
||||
if (a.Provider == models.ProviderGmail || a.Provider == models.ProviderOutlook) && auth.IsTokenExpired(a.TokenExpiry) {
|
||||
if (a.Provider == models.ProviderGmail || a.Provider == models.ProviderOutlook || a.Provider == models.ProviderOutlookPersonal) && auth.IsTokenExpired(a.TokenExpiry) {
|
||||
tokenExpired = true
|
||||
}
|
||||
return safeAccount{
|
||||
@@ -591,6 +593,22 @@ func (h *APIHandler) GetMessage(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
h.db.MarkMessageRead(messageID, userID, true)
|
||||
|
||||
// For Graph accounts: fetch body lazily on open (not stored during list sync)
|
||||
if msg.BodyHTML == "" && msg.BodyText == "" {
|
||||
if graphMsgID, _, account, gerr := h.db.GetMessageGraphInfo(messageID, userID); gerr == nil &&
|
||||
account != nil && account.Provider == models.ProviderOutlookPersonal {
|
||||
if gMsg, gErr := graphpkg.New(account).GetMessage(context.Background(), graphMsgID); gErr == nil {
|
||||
if gMsg.Body.ContentType == "html" {
|
||||
msg.BodyHTML = gMsg.Body.Content
|
||||
} else {
|
||||
msg.BodyText = gMsg.Body.Content
|
||||
}
|
||||
// Persist so next open is instant
|
||||
h.db.UpdateMessageBody(messageID, msg.BodyText, msg.BodyHTML)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -620,22 +638,22 @@ func (h *APIHandler) MarkRead(w http.ResponseWriter, r *http.Request) {
|
||||
Read bool `json:"read"`
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
|
||||
// Update local DB first
|
||||
h.db.MarkMessageRead(messageID, userID, req.Read)
|
||||
|
||||
// Enqueue IMAP op — drained by background worker with retry
|
||||
uid, folderPath, account, err := h.db.GetMessageIMAPInfo(messageID, userID)
|
||||
if err == nil && uid != 0 && account != nil {
|
||||
val := "0"
|
||||
if req.Read {
|
||||
val = "1"
|
||||
if graphMsgID, _, account, err := h.db.GetMessageGraphInfo(messageID, userID); err == nil && account != nil &&
|
||||
account.Provider == models.ProviderOutlookPersonal {
|
||||
go graphpkg.New(account).MarkRead(context.Background(), graphMsgID, req.Read)
|
||||
} else {
|
||||
uid, folderPath, acc, err2 := h.db.GetMessageIMAPInfo(messageID, userID)
|
||||
if err2 == nil && uid != 0 && acc != nil {
|
||||
val := "0"
|
||||
if req.Read { val = "1" }
|
||||
h.db.EnqueueIMAPOp(&db.PendingIMAPOp{
|
||||
AccountID: acc.ID, OpType: "flag_read",
|
||||
RemoteUID: uid, FolderPath: folderPath, Extra: val,
|
||||
})
|
||||
h.syncer.TriggerAccountSync(acc.ID)
|
||||
}
|
||||
h.db.EnqueueIMAPOp(&db.PendingIMAPOp{
|
||||
AccountID: account.ID, OpType: "flag_read",
|
||||
RemoteUID: uid, FolderPath: folderPath, Extra: val,
|
||||
})
|
||||
h.syncer.TriggerAccountSync(account.ID)
|
||||
}
|
||||
h.writeJSON(w, map[string]bool{"ok": true})
|
||||
}
|
||||
@@ -648,17 +666,20 @@ func (h *APIHandler) ToggleStar(w http.ResponseWriter, r *http.Request) {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to toggle star")
|
||||
return
|
||||
}
|
||||
uid, folderPath, account, ierr := h.db.GetMessageIMAPInfo(messageID, userID)
|
||||
if ierr == nil && uid != 0 && account != nil {
|
||||
val := "0"
|
||||
if starred {
|
||||
val = "1"
|
||||
if graphMsgID, _, account, err2 := h.db.GetMessageGraphInfo(messageID, userID); err2 == nil && account != nil &&
|
||||
account.Provider == models.ProviderOutlookPersonal {
|
||||
go graphpkg.New(account).MarkFlagged(context.Background(), graphMsgID, starred)
|
||||
} else {
|
||||
uid, folderPath, acc, ierr := h.db.GetMessageIMAPInfo(messageID, userID)
|
||||
if ierr == nil && uid != 0 && acc != nil {
|
||||
val := "0"
|
||||
if starred { val = "1" }
|
||||
h.db.EnqueueIMAPOp(&db.PendingIMAPOp{
|
||||
AccountID: acc.ID, OpType: "flag_star",
|
||||
RemoteUID: uid, FolderPath: folderPath, Extra: val,
|
||||
})
|
||||
h.syncer.TriggerAccountSync(acc.ID)
|
||||
}
|
||||
h.db.EnqueueIMAPOp(&db.PendingIMAPOp{
|
||||
AccountID: account.ID, OpType: "flag_star",
|
||||
RemoteUID: uid, FolderPath: folderPath, Extra: val,
|
||||
})
|
||||
h.syncer.TriggerAccountSync(account.ID)
|
||||
}
|
||||
h.writeJSON(w, map[string]bool{"ok": true, "starred": starred})
|
||||
}
|
||||
@@ -684,8 +705,11 @@ func (h *APIHandler) MoveMessage(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Enqueue IMAP move
|
||||
if imapErr == nil && uid != 0 && account != nil && destFolder != nil {
|
||||
// Route to Graph or IMAP
|
||||
if graphMsgID, _, graphAcc, gerr := h.db.GetMessageGraphInfo(messageID, userID); gerr == nil && graphAcc != nil &&
|
||||
graphAcc.Provider == models.ProviderOutlookPersonal && destFolder != nil {
|
||||
go graphpkg.New(graphAcc).MoveMessage(context.Background(), graphMsgID, destFolder.FullPath)
|
||||
} else if imapErr == nil && uid != 0 && account != nil && destFolder != nil {
|
||||
h.db.EnqueueIMAPOp(&db.PendingIMAPOp{
|
||||
AccountID: account.ID, OpType: "move",
|
||||
RemoteUID: uid, FolderPath: srcPath, Extra: destFolder.FullPath,
|
||||
@@ -699,17 +723,18 @@ func (h *APIHandler) DeleteMessage(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
messageID := pathInt64(r, "id")
|
||||
|
||||
// Get IMAP info before deleting from DB
|
||||
// Get message info before deleting from DB
|
||||
graphMsgID, _, graphAcc, graphErr := h.db.GetMessageGraphInfo(messageID, userID)
|
||||
uid, folderPath, account, imapErr := h.db.GetMessageIMAPInfo(messageID, userID)
|
||||
|
||||
// Delete from local DB
|
||||
if err := h.db.DeleteMessage(messageID, userID); err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "delete failed")
|
||||
return
|
||||
}
|
||||
|
||||
// Enqueue IMAP delete
|
||||
if imapErr == nil && uid != 0 && account != nil {
|
||||
if graphErr == nil && graphAcc != nil && graphAcc.Provider == models.ProviderOutlookPersonal {
|
||||
go graphpkg.New(graphAcc).DeleteMessage(context.Background(), graphMsgID)
|
||||
} else if imapErr == nil && uid != 0 && account != nil {
|
||||
h.db.EnqueueIMAPOp(&db.PendingIMAPOp{
|
||||
AccountID: account.ID, OpType: "delete",
|
||||
RemoteUID: uid, FolderPath: folderPath,
|
||||
@@ -811,6 +836,18 @@ func (h *APIHandler) handleSend(w http.ResponseWriter, r *http.Request, mode str
|
||||
}
|
||||
|
||||
account = h.ensureAccountTokenFresh(account)
|
||||
|
||||
// Graph accounts (personal outlook.com) send via Graph API, not SMTP
|
||||
if account.Provider == models.ProviderOutlookPersonal {
|
||||
if err := graphpkg.New(account).SendMail(context.Background(), &req); err != nil {
|
||||
log.Printf("Graph send failed account=%d user=%d: %v", req.AccountID, userID, err)
|
||||
h.writeError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, map[string]bool{"ok": true})
|
||||
return
|
||||
}
|
||||
|
||||
if err := email.SendMessageFull(context.Background(), account, &req); err != nil {
|
||||
log.Printf("SMTP send failed account=%d user=%d: %v", req.AccountID, userID, err)
|
||||
h.db.WriteAudit(&userID, models.AuditAppError,
|
||||
@@ -1396,7 +1433,7 @@ func (h *APIHandler) SaveDraft(w http.ResponseWriter, r *http.Request) {
|
||||
// account if it is near expiry. Returns a pointer to the (possibly updated)
|
||||
// account, or the original if no refresh was needed / possible.
|
||||
func (h *APIHandler) ensureAccountTokenFresh(account *models.EmailAccount) *models.EmailAccount {
|
||||
if account.Provider != models.ProviderGmail && account.Provider != models.ProviderOutlook {
|
||||
if account.Provider != models.ProviderGmail && account.Provider != models.ProviderOutlook && account.Provider != models.ProviderOutlookPersonal {
|
||||
return account
|
||||
}
|
||||
if !auth.IsTokenExpired(account.TokenExpiry) {
|
||||
|
||||
@@ -4,8 +4,12 @@ import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ghostersk/gowebmail/config"
|
||||
@@ -24,6 +28,7 @@ type AuthHandler struct {
|
||||
db *db.DB
|
||||
cfg *config.Config
|
||||
renderer *Renderer
|
||||
syncer interface{ TriggerReconcile() }
|
||||
}
|
||||
|
||||
// ---- Login ----
|
||||
@@ -322,6 +327,9 @@ func (h *AuthHandler) GmailCallback(w http.ResponseWriter, r *http.Request) {
|
||||
action = "gmail-reconnect:" + userInfo.Email
|
||||
}
|
||||
h.db.WriteAudit(&uid, models.AuditAccountAdd, action, middleware.ClientIP(r), r.UserAgent())
|
||||
if h.syncer != nil {
|
||||
h.syncer.TriggerReconcile()
|
||||
}
|
||||
http.Redirect(w, r, "/?connected=gmail", http.StatusFound)
|
||||
}
|
||||
|
||||
@@ -336,8 +344,9 @@ func (h *AuthHandler) OutlookConnect(w http.ResponseWriter, r *http.Request) {
|
||||
state := encodeOAuthState(userID, "outlook")
|
||||
cfg := goauth.NewOutlookConfig(h.cfg.MicrosoftClientID, h.cfg.MicrosoftClientSecret,
|
||||
h.cfg.MicrosoftTenantID, h.cfg.MicrosoftRedirectURL)
|
||||
// ApprovalForce + prompt=consent ensures Microsoft always returns a refresh_token,
|
||||
// even when the user has previously authorized the app.
|
||||
log.Printf("[oauth:outlook] starting auth flow tenant=%s redirectURL=%s",
|
||||
h.cfg.MicrosoftTenantID, h.cfg.MicrosoftRedirectURL)
|
||||
// ApprovalForce + prompt=consent ensures Microsoft always returns a refresh_token.
|
||||
url := cfg.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.ApprovalForce,
|
||||
oauth2.SetAuthURLParam("prompt", "consent"))
|
||||
http.Redirect(w, r, url, http.StatusFound)
|
||||
@@ -346,6 +355,40 @@ func (h *AuthHandler) OutlookConnect(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *AuthHandler) OutlookCallback(w http.ResponseWriter, r *http.Request) {
|
||||
state := r.URL.Query().Get("state")
|
||||
code := r.URL.Query().Get("code")
|
||||
|
||||
// Microsoft returns ?error=...&error_description=... instead of ?code=...
|
||||
// when the user denies consent or the app has misconfigured permissions.
|
||||
if msErr := r.URL.Query().Get("error"); msErr != "" {
|
||||
msDesc := r.URL.Query().Get("error_description")
|
||||
log.Printf("[oauth:outlook] Microsoft returned error: %s — %s", msErr, msDesc)
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
fmt.Fprintf(w, `<!DOCTYPE html><html><head><title>Outlook OAuth Error</title>
|
||||
<style>body{font-family:monospace;background:#111;color:#eee;padding:40px;max-width:900px;margin:auto}
|
||||
pre{background:#1e1e1e;padding:20px;border-radius:8px;white-space:pre-wrap;word-break:break-all;color:#f87171}
|
||||
h2{color:#f87171}a{color:#6b8afd}li{margin:6px 0}</style></head><body>
|
||||
<h2>Microsoft returned: %s</h2>
|
||||
<pre>%s</pre>
|
||||
<hr><p><strong>Most likely cause:</strong> the Azure app is missing the correct API permissions.</p>
|
||||
<ul>
|
||||
<li>In Azure portal → API Permissions → Add a permission</li>
|
||||
<li>Click <strong>"APIs my organization uses"</strong> tab</li>
|
||||
<li>Search: <strong>Office 365 Exchange Online</strong></li>
|
||||
<li>Delegated permissions → add <code>IMAP.AccessAsUser.All</code> and <code>SMTP.Send</code></li>
|
||||
<li>Then click <strong>Grant admin consent</strong></li>
|
||||
<li>Do NOT use Microsoft Graph versions of these scopes</li>
|
||||
</ul>
|
||||
<p><a href="/">← Back to GoWebMail</a></p>
|
||||
</body></html>`, html.EscapeString(msErr), html.EscapeString(msDesc))
|
||||
return
|
||||
}
|
||||
|
||||
if code == "" {
|
||||
log.Printf("[oauth:outlook] callback received with no code and no error — possible state mismatch")
|
||||
http.Redirect(w, r, "/?error=oauth_no_code", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
userID, provider := decodeOAuthState(state)
|
||||
if userID == 0 || provider != "outlook" {
|
||||
http.Redirect(w, r, "/?error=oauth_state_mismatch", http.StatusFound)
|
||||
@@ -355,22 +398,65 @@ func (h *AuthHandler) OutlookCallback(w http.ResponseWriter, r *http.Request) {
|
||||
h.cfg.MicrosoftTenantID, h.cfg.MicrosoftRedirectURL)
|
||||
token, err := oauthCfg.Exchange(r.Context(), code)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/?error=oauth_exchange_failed", http.StatusFound)
|
||||
log.Printf("[oauth:outlook] token exchange failed (tenant=%s clientID=%s redirectURL=%s): %v",
|
||||
h.cfg.MicrosoftTenantID, h.cfg.MicrosoftClientID, h.cfg.MicrosoftRedirectURL, err)
|
||||
// Show the raw error in the browser so the user can diagnose the problem
|
||||
// (redirect URI mismatch, wrong secret, wrong tenant, missing permissions, etc.)
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
fmt.Fprintf(w, `<!DOCTYPE html><html><head><title>Outlook OAuth Error</title>
|
||||
<style>body{font-family:monospace;background:#111;color:#eee;padding:40px;max-width:900px;margin:auto}
|
||||
pre{background:#1e1e1e;padding:20px;border-radius:8px;overflow-x:auto;white-space:pre-wrap;word-break:break-all;color:#f87171}
|
||||
h2{color:#f87171} a{color:#6b8afd}</style></head><body>
|
||||
<h2>Outlook OAuth Token Exchange Failed</h2>
|
||||
<p>Microsoft returned an error when exchanging the auth code for a token.</p>
|
||||
<pre>%s</pre>
|
||||
<hr>
|
||||
<p><strong>Things to check:</strong></p>
|
||||
<ul>
|
||||
<li>Redirect URI in Azure must exactly match: <code>%s</code></li>
|
||||
<li>Tenant ID in config: <code>%s</code> — must match your app's "Supported account types"</li>
|
||||
<li>MICROSOFT_CLIENT_SECRET must be the <strong>Value</strong> column, not the Secret ID</li>
|
||||
<li>In Azure API Permissions, IMAP/SMTP scopes must be from <strong>Office 365 Exchange Online</strong> (under "APIs my organization uses"), not Microsoft Graph</li>
|
||||
<li>Admin consent must be granted (green checkmarks in API Permissions)</li>
|
||||
</ul>
|
||||
<p><a href="/">← Back to GoWebMail</a></p>
|
||||
</body></html>`, html.EscapeString(err.Error()), h.cfg.MicrosoftRedirectURL, h.cfg.MicrosoftTenantID)
|
||||
return
|
||||
}
|
||||
userInfo, err := goauth.GetMicrosoftUserInfo(r.Context(), token, oauthCfg)
|
||||
if err != nil {
|
||||
log.Printf("[oauth:outlook] userinfo fetch failed: %v", err)
|
||||
http.Redirect(w, r, "/?error=userinfo_failed", http.StatusFound)
|
||||
return
|
||||
}
|
||||
log.Printf("[oauth:outlook] auth successful for %s, getting IMAP token...", userInfo.Email())
|
||||
|
||||
// Exchange initial token for one scoped to https://outlook.office.com
|
||||
// so IMAP auth succeeds (aud must be outlook.office.com not graph/live)
|
||||
imapToken, err := goauth.ExchangeForIMAPToken(
|
||||
r.Context(),
|
||||
h.cfg.MicrosoftClientID, h.cfg.MicrosoftClientSecret,
|
||||
h.cfg.MicrosoftTenantID, token.RefreshToken,
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("[oauth:outlook] IMAP token exchange failed: %v — falling back to initial token", err)
|
||||
imapToken = token
|
||||
} else {
|
||||
log.Printf("[oauth:outlook] IMAP token obtained, aud should be https://outlook.office.com")
|
||||
if imapToken.RefreshToken == "" {
|
||||
imapToken.RefreshToken = token.RefreshToken
|
||||
}
|
||||
}
|
||||
|
||||
accounts, _ := h.db.ListAccountsByUser(userID)
|
||||
colors := []string{"#0078D4", "#EA4335", "#34A853", "#FBBC04", "#FF6D00", "#9C27B0"}
|
||||
color := colors[len(accounts)%len(colors)]
|
||||
account := &models.EmailAccount{
|
||||
UserID: userID, Provider: models.ProviderOutlook,
|
||||
EmailAddress: userInfo.Email(), DisplayName: userInfo.DisplayName,
|
||||
AccessToken: token.AccessToken, RefreshToken: token.RefreshToken,
|
||||
TokenExpiry: token.Expiry, Color: color, IsActive: true,
|
||||
EmailAddress: userInfo.Email(), DisplayName: userInfo.BestName(),
|
||||
AccessToken: imapToken.AccessToken, RefreshToken: imapToken.RefreshToken,
|
||||
TokenExpiry: imapToken.Expiry, Color: color, IsActive: true,
|
||||
}
|
||||
created, err := h.db.UpsertOAuthAccount(account)
|
||||
if err != nil {
|
||||
@@ -383,6 +469,9 @@ func (h *AuthHandler) OutlookCallback(w http.ResponseWriter, r *http.Request) {
|
||||
action = "outlook-reconnect:" + userInfo.Email()
|
||||
}
|
||||
h.db.WriteAudit(&uid, models.AuditAccountAdd, action, middleware.ClientIP(r), r.UserAgent())
|
||||
if h.syncer != nil {
|
||||
h.syncer.TriggerReconcile()
|
||||
}
|
||||
http.Redirect(w, r, "/?connected=outlook", http.StatusFound)
|
||||
}
|
||||
|
||||
@@ -544,3 +633,101 @@ func (h *AuthHandler) SetUserIPRule(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
// ---- Outlook Personal (Graph API) OAuth2 ----
|
||||
|
||||
func (h *AuthHandler) OutlookPersonalConnect(w http.ResponseWriter, r *http.Request) {
|
||||
if h.cfg.MicrosoftClientID == "" {
|
||||
writeJSONError(w, http.StatusServiceUnavailable, "Microsoft OAuth2 not configured.")
|
||||
return
|
||||
}
|
||||
redirectURL := h.cfg.BaseURL + "/auth/outlook-personal/callback"
|
||||
userID := middleware.GetUserID(r)
|
||||
state := encodeOAuthState(userID, "outlook_personal")
|
||||
cfg := goauth.NewOutlookPersonalConfig(h.cfg.MicrosoftClientID, h.cfg.MicrosoftClientSecret,
|
||||
h.cfg.MicrosoftTenantID, redirectURL)
|
||||
authURL := cfg.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.ApprovalForce,
|
||||
oauth2.SetAuthURLParam("prompt", "consent"))
|
||||
log.Printf("[oauth:outlook-personal] starting auth flow tenant=%s redirect=%s",
|
||||
h.cfg.MicrosoftTenantID, redirectURL)
|
||||
http.Redirect(w, r, authURL, http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) OutlookPersonalCallback(w http.ResponseWriter, r *http.Request) {
|
||||
state := r.URL.Query().Get("state")
|
||||
code := r.URL.Query().Get("code")
|
||||
|
||||
if msErr := r.URL.Query().Get("error"); msErr != "" {
|
||||
msDesc := r.URL.Query().Get("error_description")
|
||||
log.Printf("[oauth:outlook-personal] error: %s — %s", msErr, msDesc)
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
fmt.Fprintf(w, `<!DOCTYPE html><html><head><title>Outlook OAuth Error</title>
|
||||
<style>body{font-family:monospace;background:#111;color:#eee;padding:40px;max-width:900px;margin:auto}
|
||||
pre{background:#1e1e1e;padding:20px;border-radius:8px;white-space:pre-wrap;color:#f87171}
|
||||
h2{color:#f87171}a{color:#6b8afd}</style></head><body>
|
||||
<h2>Microsoft returned: %s</h2><pre>%s</pre>
|
||||
<p>Make sure your Azure app has these Microsoft Graph permissions:<br>
|
||||
Mail.ReadWrite, Mail.Send, User.Read, openid, email, offline_access</p>
|
||||
<p><a href="/">← Back</a></p></body></html>`,
|
||||
html.EscapeString(msErr), html.EscapeString(msDesc))
|
||||
return
|
||||
}
|
||||
if code == "" {
|
||||
http.Redirect(w, r, "/?error=oauth_no_code", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
userID, provider := decodeOAuthState(state)
|
||||
if userID == 0 || provider != "outlook_personal" {
|
||||
http.Redirect(w, r, "/?error=oauth_state_mismatch", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
oauthCfg := goauth.NewOutlookPersonalConfig(h.cfg.MicrosoftClientID, h.cfg.MicrosoftClientSecret,
|
||||
h.cfg.MicrosoftTenantID, h.cfg.BaseURL+"/auth/outlook-personal/callback")
|
||||
token, err := oauthCfg.Exchange(r.Context(), code)
|
||||
if err != nil {
|
||||
log.Printf("[oauth:outlook-personal] token exchange failed: %v", err)
|
||||
http.Redirect(w, r, "/?error=oauth_exchange_failed", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Get user info from ID token
|
||||
userInfo, err := goauth.GetMicrosoftUserInfo(r.Context(), token, oauthCfg)
|
||||
if err != nil {
|
||||
log.Printf("[oauth:outlook-personal] userinfo failed: %v", err)
|
||||
http.Redirect(w, r, "/?error=userinfo_failed", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify it's a JWT (Graph token for personal accounts should be a JWT)
|
||||
tokenParts := len(strings.Split(token.AccessToken, "."))
|
||||
log.Printf("[oauth:outlook-personal] auth successful for %s, token parts: %d",
|
||||
userInfo.Email(), tokenParts)
|
||||
|
||||
accounts, _ := h.db.ListAccountsByUser(userID)
|
||||
colors := []string{"#0078D4", "#EA4335", "#34A853", "#FBBC04", "#FF6D00", "#9C27B0"}
|
||||
color := colors[len(accounts)%len(colors)]
|
||||
account := &models.EmailAccount{
|
||||
UserID: userID, Provider: models.ProviderOutlookPersonal,
|
||||
EmailAddress: userInfo.Email(), DisplayName: userInfo.BestName(),
|
||||
AccessToken: token.AccessToken, RefreshToken: token.RefreshToken,
|
||||
TokenExpiry: token.Expiry, Color: color, IsActive: true,
|
||||
}
|
||||
created, err := h.db.UpsertOAuthAccount(account)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/?error=account_save_failed", http.StatusFound)
|
||||
return
|
||||
}
|
||||
uid := userID
|
||||
action := "outlook-personal:" + userInfo.Email()
|
||||
if !created {
|
||||
action = "outlook-personal-reconnect:" + userInfo.Email()
|
||||
}
|
||||
h.db.WriteAudit(&uid, models.AuditAccountAdd, action, middleware.ClientIP(r), r.UserAgent())
|
||||
if h.syncer != nil {
|
||||
h.syncer.TriggerReconcile()
|
||||
}
|
||||
http.Redirect(w, r, "/?connected=outlook_personal", http.StatusFound)
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ func New(database *db.DB, cfg *config.Config, sc *syncer.Scheduler) *Handlers {
|
||||
}
|
||||
|
||||
return &Handlers{
|
||||
Auth: &AuthHandler{db: database, cfg: cfg, renderer: renderer},
|
||||
Auth: &AuthHandler{db: database, cfg: cfg, renderer: renderer, syncer: sc},
|
||||
App: &AppHandler{db: database, cfg: cfg, renderer: renderer},
|
||||
API: &APIHandler{db: database, cfg: cfg, syncer: sc},
|
||||
Admin: &AdminHandler{db: database, cfg: cfg, renderer: renderer},
|
||||
|
||||
@@ -83,9 +83,10 @@ type AuditPage struct {
|
||||
type AccountProvider string
|
||||
|
||||
const (
|
||||
ProviderGmail AccountProvider = "gmail"
|
||||
ProviderOutlook AccountProvider = "outlook"
|
||||
ProviderIMAPSMTP AccountProvider = "imap_smtp"
|
||||
ProviderGmail AccountProvider = "gmail"
|
||||
ProviderOutlook AccountProvider = "outlook"
|
||||
ProviderOutlookPersonal AccountProvider = "outlook_personal" // personal outlook.com via Graph API
|
||||
ProviderIMAPSMTP AccountProvider = "imap_smtp"
|
||||
)
|
||||
|
||||
// EmailAccount represents a connected email account (Gmail, Outlook, IMAP).
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -16,6 +17,7 @@ import (
|
||||
"github.com/ghostersk/gowebmail/internal/auth"
|
||||
"github.com/ghostersk/gowebmail/internal/db"
|
||||
"github.com/ghostersk/gowebmail/internal/email"
|
||||
"github.com/ghostersk/gowebmail/internal/graph"
|
||||
"github.com/ghostersk/gowebmail/internal/models"
|
||||
)
|
||||
|
||||
@@ -29,15 +31,28 @@ type Scheduler struct {
|
||||
// push channels: accountID -> channel to signal "something changed on server"
|
||||
pushMu sync.Mutex
|
||||
pushCh map[int64]chan struct{}
|
||||
|
||||
// reconcileCh signals the main loop to immediately check for new/removed accounts.
|
||||
reconcileCh chan struct{}
|
||||
}
|
||||
|
||||
// New creates a new Scheduler.
|
||||
func New(database *db.DB, cfg *config.Config) *Scheduler {
|
||||
return &Scheduler{
|
||||
db: database,
|
||||
cfg: cfg,
|
||||
stop: make(chan struct{}),
|
||||
pushCh: make(map[int64]chan struct{}),
|
||||
db: database,
|
||||
cfg: cfg,
|
||||
stop: make(chan struct{}),
|
||||
pushCh: make(map[int64]chan struct{}),
|
||||
reconcileCh: make(chan struct{}, 1),
|
||||
}
|
||||
}
|
||||
|
||||
// TriggerReconcile asks the main loop to immediately check for new accounts.
|
||||
// Safe to call from any goroutine; non-blocking.
|
||||
func (s *Scheduler) TriggerReconcile() {
|
||||
select {
|
||||
case s.reconcileCh <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,6 +142,13 @@ func (s *Scheduler) mainLoop() {
|
||||
stopWorker(id)
|
||||
}
|
||||
return
|
||||
case <-s.reconcileCh:
|
||||
// Immediately check for new/removed accounts (e.g. after OAuth connect)
|
||||
activeIDs := make(map[int64]bool, len(workers))
|
||||
for id := range workers {
|
||||
activeIDs[id] = true
|
||||
}
|
||||
s.reconcileWorkers(activeIDs, spawnWorker, stopWorker)
|
||||
case <-ticker.C:
|
||||
// Build active IDs map for reconciliation
|
||||
activeIDs := make(map[int64]bool, len(workers))
|
||||
@@ -191,6 +213,12 @@ func (s *Scheduler) accountWorker(account *models.EmailAccount, stop chan struct
|
||||
return a
|
||||
}
|
||||
|
||||
// Graph-based accounts (personal outlook.com) use a different sync path
|
||||
if account.Provider == models.ProviderOutlookPersonal {
|
||||
s.graphWorker(account, stop, push)
|
||||
return
|
||||
}
|
||||
|
||||
// Initial sync on startup
|
||||
s.drainPendingOps(account)
|
||||
s.deltaSync(getAccount())
|
||||
@@ -355,7 +383,20 @@ func (s *Scheduler) deltaSync(account *models.EmailAccount) {
|
||||
|
||||
mailboxes, err := c.ListMailboxes()
|
||||
if err != nil {
|
||||
errMsg := err.Error()
|
||||
if strings.Contains(errMsg, "not connected") {
|
||||
// For personal outlook.com accounts: Microsoft does not issue JWT Bearer tokens
|
||||
// to custom Azure app registrations for IMAP OAuth — only opaque v1 tokens which
|
||||
// authenticate but cannot access the mailbox. This is a Microsoft platform limitation.
|
||||
// Workaround: use a Microsoft 365 work/school account, or add this account as a
|
||||
// standard IMAP account using an App Password from account.microsoft.com/security.
|
||||
errMsg = "IMAP OAuth is not supported for personal outlook.com accounts with custom Azure app registrations. " +
|
||||
"To connect this account: go to account.microsoft.com/security → Advanced security options → App passwords, " +
|
||||
"create an app password, then remove this account and re-add it as a standard IMAP account using " +
|
||||
"server: outlook.office365.com, port: 993, with your email and the app password."
|
||||
}
|
||||
log.Printf("[sync:%s] list mailboxes: %v", account.EmailAddress, err)
|
||||
s.db.SetAccountError(account.ID, errMsg)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -555,12 +596,20 @@ func (s *Scheduler) drainPendingOps(account *models.EmailAccount) {
|
||||
// to the database, and returns a refreshed account pointer.
|
||||
// For non-OAuth accounts (imap_smtp) it is a no-op.
|
||||
func (s *Scheduler) ensureFreshToken(account *models.EmailAccount) *models.EmailAccount {
|
||||
if account.Provider != models.ProviderGmail && account.Provider != models.ProviderOutlook {
|
||||
if account.Provider != models.ProviderGmail && account.Provider != models.ProviderOutlook && account.Provider != models.ProviderOutlookPersonal {
|
||||
return account
|
||||
}
|
||||
if !auth.IsTokenExpired(account.TokenExpiry) {
|
||||
// Force refresh if Outlook token is opaque (not a JWT — doesn't contain dots).
|
||||
// Opaque tokens (EwAYBOl3... format) are v1.0 tokens that IMAP rejects.
|
||||
// A valid IMAP token is a 3-part JWT: header.payload.signature
|
||||
isOpaque := account.Provider == models.ProviderOutlook &&
|
||||
strings.Count(account.AccessToken, ".") < 2
|
||||
if !auth.IsTokenExpired(account.TokenExpiry) && !isOpaque {
|
||||
return account
|
||||
}
|
||||
if isOpaque {
|
||||
log.Printf("[oauth:%s] opaque v1 token detected — forcing refresh to get JWT", account.EmailAddress)
|
||||
}
|
||||
if account.RefreshToken == "" {
|
||||
log.Printf("[oauth:%s] token expired but no refresh token stored — re-authorisation required", account.EmailAddress)
|
||||
return account
|
||||
@@ -631,3 +680,131 @@ func (s *Scheduler) SyncFolderNow(accountID, folderID int64) (int, error) {
|
||||
|
||||
return s.syncFolder(c, account, folder)
|
||||
}
|
||||
|
||||
// ---- Microsoft Graph sync (personal outlook.com accounts) ----
|
||||
|
||||
// graphWorker is the accountWorker equivalent for ProviderOutlookPersonal accounts.
|
||||
// It polls Graph API instead of using IMAP.
|
||||
func (s *Scheduler) graphWorker(account *models.EmailAccount, stop chan struct{}, push chan struct{}) {
|
||||
log.Printf("[graph] worker started for %s", account.EmailAddress)
|
||||
|
||||
getAccount := func() *models.EmailAccount {
|
||||
a, _ := s.db.GetAccount(account.ID)
|
||||
if a == nil {
|
||||
return account
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// Initial sync
|
||||
s.graphDeltaSync(getAccount())
|
||||
|
||||
syncTicker := time.NewTicker(30 * time.Second)
|
||||
defer syncTicker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
log.Printf("[graph] worker stopped for %s", account.EmailAddress)
|
||||
return
|
||||
case <-push:
|
||||
acc := getAccount()
|
||||
s.graphDeltaSync(acc)
|
||||
case <-syncTicker.C:
|
||||
acc := getAccount()
|
||||
// Respect sync interval
|
||||
if !acc.LastSync.IsZero() {
|
||||
interval := time.Duration(acc.SyncInterval) * time.Minute
|
||||
if interval <= 0 {
|
||||
interval = 15 * time.Minute
|
||||
}
|
||||
if time.Since(acc.LastSync) < interval {
|
||||
continue
|
||||
}
|
||||
}
|
||||
s.graphDeltaSync(acc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// graphDeltaSync fetches mail via Graph API and stores it in the same DB tables
|
||||
// as the IMAP sync path, so the rest of the app works unchanged.
|
||||
func (s *Scheduler) graphDeltaSync(account *models.EmailAccount) {
|
||||
account = s.ensureFreshToken(account)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
gc := graph.New(account)
|
||||
|
||||
// Fetch folders
|
||||
gFolders, err := gc.ListFolders(ctx)
|
||||
if err != nil {
|
||||
log.Printf("[graph:%s] list folders: %v", account.EmailAddress, err)
|
||||
s.db.SetAccountError(account.ID, "Graph API error: "+err.Error())
|
||||
return
|
||||
}
|
||||
s.db.ClearAccountError(account.ID)
|
||||
|
||||
totalNew := 0
|
||||
for _, gf := range gFolders {
|
||||
folderType := graph.InferFolderType(gf.DisplayName)
|
||||
dbFolder := &models.Folder{
|
||||
AccountID: account.ID,
|
||||
Name: gf.DisplayName,
|
||||
FullPath: gf.ID, // Graph uses opaque IDs as folder path
|
||||
FolderType: folderType,
|
||||
UnreadCount: gf.UnreadCount,
|
||||
TotalCount: gf.TotalCount,
|
||||
SyncEnabled: true,
|
||||
}
|
||||
if err := s.db.UpsertFolder(dbFolder); err != nil {
|
||||
continue
|
||||
}
|
||||
dbFolderSaved, _ := s.db.GetFolderByPath(account.ID, gf.ID)
|
||||
if dbFolderSaved == nil || !dbFolderSaved.SyncEnabled {
|
||||
continue
|
||||
}
|
||||
|
||||
// Determine how far back to fetch
|
||||
var since time.Time
|
||||
if account.SyncMode == "days" && account.SyncDays > 0 {
|
||||
since = time.Now().AddDate(0, 0, -account.SyncDays)
|
||||
}
|
||||
|
||||
msgs, err := gc.ListMessages(ctx, gf.ID, since, 500)
|
||||
if err != nil {
|
||||
log.Printf("[graph:%s] list messages in %s: %v", account.EmailAddress, gf.DisplayName, err)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, gm := range msgs {
|
||||
// Body is NOT included in list response — fetched lazily on first open via GetMessage.
|
||||
msg := &models.Message{
|
||||
AccountID: account.ID,
|
||||
FolderID: dbFolderSaved.ID,
|
||||
RemoteUID: gm.ID,
|
||||
MessageID: gm.InternetMessageID,
|
||||
Subject: gm.Subject,
|
||||
FromName: gm.FromName(),
|
||||
FromEmail: gm.FromEmail(),
|
||||
ToList: gm.ToList(),
|
||||
Date: gm.ReceivedDateTime,
|
||||
IsRead: gm.IsRead,
|
||||
IsStarred: gm.IsFlagged(),
|
||||
HasAttachment: gm.HasAttachments,
|
||||
}
|
||||
if err := s.db.UpsertMessage(msg); err == nil {
|
||||
totalNew++
|
||||
}
|
||||
}
|
||||
|
||||
// Update folder counts from Graph (more accurate than counting locally)
|
||||
s.db.UpdateFolderCountsDirect(dbFolderSaved.ID, gf.TotalCount, gf.UnreadCount)
|
||||
}
|
||||
|
||||
s.db.UpdateAccountLastSync(account.ID)
|
||||
if totalNew > 0 {
|
||||
log.Printf("[graph:%s] %d new messages", account.EmailAddress, totalNew)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -506,3 +506,66 @@ body.admin-page{overflow:auto;background:var(--bg)}
|
||||
from{opacity:0;transform:translateY(16px) scale(.96)}
|
||||
to{opacity:1;transform:translateY(0) scale(1)}
|
||||
}
|
||||
|
||||
/* ── Mobile top bar (hidden on desktop) ───────────────────────────────── */
|
||||
.mob-topbar{display:none}
|
||||
|
||||
/* ── Responsive layout ────────────────────────────────────────────────── */
|
||||
@media (max-width:700px){
|
||||
/* Show mobile top bar */
|
||||
.mob-topbar{
|
||||
display:flex;align-items:center;gap:8px;
|
||||
position:fixed;top:0;left:0;right:0;height:50px;z-index:200;
|
||||
background:var(--surface);border-bottom:1px solid var(--border);
|
||||
padding:0 12px;
|
||||
}
|
||||
.mob-nav-btn,.mob-back-btn{
|
||||
background:none;border:none;cursor:pointer;color:var(--text);
|
||||
padding:6px;border-radius:6px;display:flex;align-items:center;justify-content:center;
|
||||
flex-shrink:0;
|
||||
}
|
||||
.mob-nav-btn:hover,.mob-back-btn:hover{background:var(--surface3)}
|
||||
.mob-nav-btn svg,.mob-back-btn svg{width:20px;height:20px;fill:currentColor}
|
||||
.mob-title{font-family:'DM Serif Display',serif;font-size:15px;overflow:hidden;
|
||||
text-overflow:ellipsis;white-space:nowrap;flex:1}
|
||||
|
||||
/* Push content below topbar */
|
||||
body.app-page{overflow:hidden}
|
||||
.app{flex-direction:column;height:100dvh;height:100vh;padding-top:50px}
|
||||
|
||||
/* Sidebar becomes a drawer */
|
||||
.sidebar{
|
||||
position:fixed;top:50px;left:0;bottom:0;z-index:150;
|
||||
transform:translateX(-100%);transition:transform .25s ease;
|
||||
width:280px;max-width:85vw;
|
||||
}
|
||||
.sidebar.mob-open{transform:translateX(0)}
|
||||
.mob-sidebar-backdrop{
|
||||
display:none;position:fixed;inset:0;top:50px;z-index:140;
|
||||
background:rgba(0,0,0,.45);
|
||||
}
|
||||
.mob-sidebar-backdrop.mob-open{display:block}
|
||||
|
||||
/* Desktop compose button in sidebar header hidden on mobile (topbar has one) */
|
||||
.sidebar-header .compose-btn{display:none}
|
||||
|
||||
/* Message list panel: full width, shown/hidden by data-mob-view */
|
||||
.message-list-panel{width:100%;border-right:none;flex-shrink:0}
|
||||
.message-detail{width:100%}
|
||||
|
||||
/* View switching via data-mob-view on #app-root */
|
||||
#app-root[data-mob-view="list"] .message-list-panel{display:flex}
|
||||
#app-root[data-mob-view="list"] .message-detail{display:none}
|
||||
#app-root[data-mob-view="detail"] .message-list-panel{display:none}
|
||||
#app-root[data-mob-view="detail"] .message-detail{display:flex}
|
||||
|
||||
/* Compose dialog: full screen on mobile */
|
||||
.compose-dialog{
|
||||
position:fixed!important;
|
||||
top:50px!important;left:0!important;right:0!important;bottom:0!important;
|
||||
width:100%!important;height:calc(100dvh - 50px)!important;
|
||||
border-radius:0!important;resize:none!important;
|
||||
}
|
||||
/* Hide floating minimised bar on mobile, use back button instead */
|
||||
.compose-minimised{display:none!important}
|
||||
}
|
||||
|
||||
BIN
web/static/img/outlook.png
Normal file
BIN
web/static/img/outlook.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.3 KiB |
@@ -52,16 +52,20 @@ async function init() {
|
||||
if (p.get('connected')) {
|
||||
toast('Account connected! Loading…', 'success');
|
||||
history.replaceState({},'','/');
|
||||
// Poll until the new account appears (syncer needs a moment to start)
|
||||
// Reload accounts immediately — new account may already be in DB
|
||||
await loadAccounts();
|
||||
await loadFolders();
|
||||
// Poll for folder population (syncer takes a moment after account creation)
|
||||
let tries = 0;
|
||||
const prevCount = S.accounts.length;
|
||||
const poll = setInterval(async () => {
|
||||
tries++;
|
||||
await loadAccounts();
|
||||
await loadFolders();
|
||||
if (S.accounts.length > prevCount || tries >= 12) {
|
||||
// Stop when at least one account now has folders, or after ~30s
|
||||
const hasFolders = S.accounts.some(a => S.folders.some(f => f.account_id === a.id));
|
||||
if (hasFolders || tries >= 12) {
|
||||
clearInterval(poll);
|
||||
if (S.accounts.length > prevCount) toast('Account ready!', 'success');
|
||||
toast('Account ready!', 'success');
|
||||
}
|
||||
}, 2500);
|
||||
}
|
||||
@@ -76,11 +80,12 @@ async function init() {
|
||||
|
||||
initComposeDragResize();
|
||||
startPoller();
|
||||
mobSetView('list'); // initialise mobile view state
|
||||
}
|
||||
|
||||
// ── Providers ──────────────────────────────────────────────────────────────
|
||||
function updateProviderButtons() {
|
||||
['gmail','outlook'].forEach(p => {
|
||||
['gmail','outlook','outlook_personal'].forEach(p => {
|
||||
const btn = document.getElementById('btn-'+p);
|
||||
if (!btn) return;
|
||||
if (!S.providers[p]) { btn.disabled=true; btn.classList.add('unavailable'); btn.title='Not configured'; }
|
||||
@@ -145,7 +150,13 @@ async function loadAccounts() {
|
||||
populateComposeFrom();
|
||||
}
|
||||
|
||||
function connectOAuth(p) { location.href='/auth/'+p+'/connect'; }
|
||||
function connectOAuth(p) {
|
||||
if (p === 'outlook_personal') {
|
||||
location.href = '/auth/outlook-personal/connect';
|
||||
} else {
|
||||
location.href = '/auth/' + p + '/connect';
|
||||
}
|
||||
}
|
||||
|
||||
function openAddAccountModal() {
|
||||
['imap-email','imap-name','imap-password','imap-host','smtp-host'].forEach(id=>{ const el=document.getElementById(id); if(el) el.value=''; });
|
||||
@@ -221,7 +232,7 @@ async function openEditAccount(id) {
|
||||
document.getElementById('edit-account-email').textContent=r.email_address;
|
||||
document.getElementById('edit-name').value=r.display_name||'';
|
||||
|
||||
const isOAuth = r.provider==='gmail' || r.provider==='outlook';
|
||||
const isOAuth = r.provider==='gmail' || r.provider==='outlook' || r.provider==='outlook_personal';
|
||||
|
||||
// Show/hide credential section and test button based on provider type
|
||||
document.getElementById('edit-creds-section').style.display = isOAuth ? 'none' : '';
|
||||
@@ -229,7 +240,7 @@ async function openEditAccount(id) {
|
||||
const oauthSection = document.getElementById('edit-oauth-section');
|
||||
if (oauthSection) oauthSection.style.display = isOAuth ? '' : 'none';
|
||||
if (isOAuth) {
|
||||
const providerLabel = r.provider==='gmail' ? 'Google' : 'Microsoft';
|
||||
const providerLabel = r.provider==='gmail' ? 'Google' : r.provider==='outlook_personal' ? 'Microsoft (Personal)' : 'Microsoft';
|
||||
const lbl = document.getElementById('edit-oauth-provider-label');
|
||||
const lblBtn = document.getElementById('edit-oauth-provider-label-btn');
|
||||
const expWarn = document.getElementById('edit-oauth-expired-warning');
|
||||
@@ -412,7 +423,26 @@ function renderFolders() {
|
||||
|
||||
el.innerHTML = orderedAccounts.map(acc => {
|
||||
const folders = byAcc[acc.id];
|
||||
if (!folders?.length) return '';
|
||||
// Show account even if no folders yet — it was just added and syncer hasn't run
|
||||
if (!folders?.length) {
|
||||
const statusHtml = acc.last_error
|
||||
? `<div style="padding:6px 10px 8px;font-size:11px;color:var(--danger);background:rgba(239,68,68,.08);border-radius:0 0 6px 6px;line-height:1.4">
|
||||
⚠ ${esc(acc.last_error)}
|
||||
</div>`
|
||||
: `<div style="padding:6px 12px 8px;font-size:11px;color:var(--muted)">⏳ Syncing folders…</div>`;
|
||||
return `<div class="nav-account-group" data-acc-id="${acc.id}">
|
||||
<div class="nav-folder-header" style="cursor:default">
|
||||
<span class="acc-drag-handle">⋮</span>
|
||||
<span style="width:7px;height:7px;border-radius:50%;background:${acc.color};display:inline-block;flex-shrink:0"></span>
|
||||
<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"
|
||||
title="${esc(acc.email_address)}">${esc(acc.display_name||acc.email_address)}</span>
|
||||
<button class="icon-sync-btn" title="Retry sync" onclick="syncNow(${acc.id},event)" style="flex-shrink:0">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor"><path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46C19.54 15.03 20 13.57 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74C4.46 8.97 4 10.43 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
${statusHtml}
|
||||
</div>`;
|
||||
}
|
||||
const accId = acc.id;
|
||||
const collapsed = isAccountCollapsed(accId);
|
||||
const sorted = [
|
||||
@@ -666,6 +696,8 @@ function selectFolder(folderId, folderName) {
|
||||
:folderId==='starred'?document.getElementById('nav-starred')
|
||||
:document.getElementById('nav-f'+folderId);
|
||||
if (navEl) navEl.classList.add('active');
|
||||
mobCloseNav();
|
||||
mobSetView('list');
|
||||
loadMessages();
|
||||
}
|
||||
|
||||
@@ -862,6 +894,7 @@ function loadMoreMessages(){ S.currentPage++; loadMessages(true); }
|
||||
|
||||
async function openMessage(id) {
|
||||
S.selectedMessageId=id; renderMessageList();
|
||||
mobSetView('detail');
|
||||
const detail=document.getElementById('message-detail');
|
||||
detail.innerHTML='<div class="spinner" style="margin-top:100px"></div>';
|
||||
const msg=await api('GET','/messages/'+id);
|
||||
@@ -1152,10 +1185,17 @@ function formatSize(b){if(!b)return'';if(b<1024)return b+' B';if(b<1048576)retur
|
||||
// ── Compose ────────────────────────────────────────────────────────────────
|
||||
let composeAttachments=[];
|
||||
|
||||
function populateComposeFrom() {
|
||||
function populateComposeFrom(preferAccountId) {
|
||||
const sel=document.getElementById('compose-from');
|
||||
if(!sel) return;
|
||||
sel.innerHTML=S.accounts.map(a=>`<option value="${a.id}">${esc(a.display_name||a.email_address)} <${esc(a.email_address)}></option>`).join('');
|
||||
// Default to the account of the currently viewed folder, or explicitly passed account
|
||||
if (preferAccountId) {
|
||||
sel.value = String(preferAccountId);
|
||||
} else if (S.currentFolder && S.currentFolder !== 'unified' && S.currentFolder !== 'starred') {
|
||||
const folder = S.folders.find(f => f.id === S.currentFolder);
|
||||
if (folder) sel.value = String(folder.account_id);
|
||||
}
|
||||
}
|
||||
|
||||
function openCompose(opts={}) {
|
||||
@@ -1175,6 +1215,7 @@ function openCompose(opts={}) {
|
||||
editor.innerHTML=opts.body||'';
|
||||
S.draftDirty=false;
|
||||
updateAttachList();
|
||||
populateComposeFrom(opts.accountId||null);
|
||||
showCompose();
|
||||
setTimeout(()=>{ const inp=document.querySelector('#compose-to .tag-input'); if(inp) inp.focus(); },80);
|
||||
startDraftAutosave();
|
||||
@@ -1227,6 +1268,7 @@ function openReplyTo(msgId) {
|
||||
if (!msg) return;
|
||||
openCompose({
|
||||
mode:'reply', replyId:msgId, title:'Reply',
|
||||
accountId: msg.account_id||null,
|
||||
subject:msg.subject&&!msg.subject.startsWith('Re:')?'Re: '+msg.subject:(msg.subject||''),
|
||||
body:`<br><br><div class="quote-divider">—— Original message ——</div><blockquote>${msg.body_html||('<pre>'+esc(msg.body_text||'')+'</pre>')}</blockquote>`,
|
||||
});
|
||||
@@ -1239,6 +1281,7 @@ function openForward() {
|
||||
S.composeForwardFromId=msg.id;
|
||||
openCompose({
|
||||
mode:'forward', forwardId:msg.id, title:'Forward',
|
||||
accountId: msg.account_id||null,
|
||||
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>`,
|
||||
});
|
||||
@@ -1250,6 +1293,7 @@ function openForwardAsAttachment() {
|
||||
S.composeForwardFromId=msg.id;
|
||||
openCompose({
|
||||
mode:'forward-attachment', forwardId:msg.id, title:'Forward as Attachment',
|
||||
accountId: msg.account_id||null,
|
||||
subject:'Fwd: '+(msg.subject||''),
|
||||
body:'',
|
||||
});
|
||||
@@ -1850,3 +1894,58 @@ function sendOSNotification(msgs) {
|
||||
// Some browsers block even with granted permission in certain contexts
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mobile navigation ────────────────────────────────────────────────────────
|
||||
function isMobile() { return window.innerWidth <= 700; }
|
||||
|
||||
function mobSetView(view) {
|
||||
if (!isMobile()) return;
|
||||
const app = document.getElementById('app-root');
|
||||
if (!app) return;
|
||||
app.dataset.mobView = view;
|
||||
const navBtn = document.getElementById('mob-nav-btn');
|
||||
const backBtn = document.getElementById('mob-back-btn');
|
||||
const titleEl = document.getElementById('mob-title');
|
||||
if (view === 'detail') {
|
||||
if (navBtn) navBtn.style.display = 'none';
|
||||
if (backBtn) backBtn.style.display = 'flex';
|
||||
if (titleEl) titleEl.textContent = S.currentMessage?.subject || 'Message';
|
||||
} else {
|
||||
if (navBtn) navBtn.style.display = 'flex';
|
||||
if (backBtn) backBtn.style.display = 'none';
|
||||
if (titleEl) titleEl.textContent = S.currentFolderName || 'GoWebMail';
|
||||
}
|
||||
}
|
||||
|
||||
function mobBack() {
|
||||
if (!isMobile()) return;
|
||||
const app = document.getElementById('app-root');
|
||||
if (!app) return;
|
||||
if (app.dataset.mobView === 'detail') {
|
||||
mobSetView('list');
|
||||
}
|
||||
}
|
||||
|
||||
function mobShowNav() {
|
||||
document.querySelector('.sidebar')?.classList.add('mob-open');
|
||||
document.getElementById('mob-sidebar-backdrop')?.classList.add('mob-open');
|
||||
}
|
||||
|
||||
function mobCloseNav() {
|
||||
document.querySelector('.sidebar')?.classList.remove('mob-open');
|
||||
document.getElementById('mob-sidebar-backdrop')?.classList.remove('mob-open');
|
||||
}
|
||||
|
||||
// Update mob title when folder changes
|
||||
const _origSelectFolder = selectFolder;
|
||||
// (selectFolder already calls mobSetView/mobCloseNav inline)
|
||||
|
||||
// On resize between mobile/desktop, reset any leftover mobile state
|
||||
window.addEventListener('resize', () => {
|
||||
if (!isMobile()) {
|
||||
const app = document.getElementById('app-root');
|
||||
if (app) app.dataset.mobView = 'list';
|
||||
document.querySelector('.sidebar')?.classList.remove('mob-open');
|
||||
document.getElementById('mob-sidebar-backdrop')?.classList.remove('mob-open');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3,7 +3,19 @@
|
||||
{{define "body_class"}}app-page{{end}}
|
||||
|
||||
{{define "body"}}
|
||||
<div class="app">
|
||||
<div class="app" id="app-root" data-mob-view="list">
|
||||
<!-- Mobile top bar (hidden on desktop) -->
|
||||
<div class="mob-topbar" id="mob-topbar">
|
||||
<button class="mob-nav-btn" id="mob-nav-btn" onclick="mobShowNav()" title="Menu">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z"/></svg>
|
||||
</button>
|
||||
<button class="mob-back-btn" id="mob-back-btn" onclick="mobBack()" title="Back" style="display:none">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
|
||||
</button>
|
||||
<span class="mob-title" id="mob-title">GoWebMail</span>
|
||||
<button class="compose-btn" onclick="openCompose()" style="margin-left:auto;padding:5px 10px;font-size:11px">+ New</button>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
@@ -45,6 +57,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<!-- Mobile sidebar backdrop -->
|
||||
<div class="mob-sidebar-backdrop" id="mob-sidebar-backdrop" onclick="mobCloseNav()"></div>
|
||||
|
||||
<!-- Message list -->
|
||||
<div class="message-list-panel">
|
||||
@@ -184,7 +198,11 @@
|
||||
</button>
|
||||
<button class="provider-btn" id="btn-outlook" onclick="connectOAuth('outlook')">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="#0078D4"><path d="M21.179 4.781H11.25V12h9.929V4.781zM11.25 19.219h9.929V12H11.25v7.219zM2.821 12H11.25V4.781H2.821V12zm0 7.219H11.25V12H2.821v7.219z"/></svg>
|
||||
Outlook
|
||||
Microsoft 365
|
||||
</button>
|
||||
<button class="provider-btn" id="btn-outlook-personal" onclick="connectOAuth('outlook_personal')">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="#0078D4"><path d="M21.179 4.781H11.25V12h9.929V4.781zM11.25 19.219h9.929V12H11.25v7.219zM2.821 12H11.25V4.781H2.821V12zm0 7.219H11.25V12H2.821v7.219z"/></svg>
|
||||
Outlook Personal
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-divider"><span>or add IMAP account</span></div>
|
||||
@@ -368,5 +386,5 @@
|
||||
{{end}}
|
||||
|
||||
{{define "scripts"}}
|
||||
<script src="/static/js/app.js?v=25"></script>
|
||||
<script src="/static/js/app.js?v=49"></script>
|
||||
{{end}}
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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 rel="stylesheet" href="/static/css/gowebmail.css?v=25">
|
||||
<link rel="stylesheet" href="/static/css/gowebmail.css?v=49">
|
||||
{{block "head_extra" .}}{{end}}
|
||||
</head>
|
||||
<body class="{{block "body_class" .}}{{end}}">
|
||||
{{block "body" .}}{{end}}
|
||||
<script src="/static/js/gowebmail.js?v=25"></script>
|
||||
<script src="/static/js/gowebmail.js?v=49"></script>
|
||||
{{block "scripts" .}}{{end}}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user