Compare commits

...

5 Commits

Author SHA1 Message Date
ghostersk
d470c8b71f added calendar and contact - basic 2026-03-22 11:28:33 +00:00
ghostersk
9e7e87d11b updated outlook account sync 2026-03-15 20:27:29 +00:00
ghostersk
a9c7f4c575 personal outlook working - still needs tuning 2026-03-15 19:33:51 +00:00
ghostersk
1e08d5f50f add option to re-order accounts 2026-03-15 13:15:46 +00:00
ghostersk
015c00251b fixed Gmail authentication, hotmail still in progress 2026-03-15 09:04:40 +00:00
28 changed files with 3754 additions and 370 deletions

1
.gitignore vendored
View File

@@ -6,3 +6,4 @@ data/gowebmail.conf
data/*.txt
gowebmail-devplan.md
testrun/
webmail.code-workspace

243
README.md
View File

@@ -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).

View File

@@ -1,13 +1,16 @@
package main
import (
"bytes"
"context"
"fmt"
"io"
"io/fs"
"log"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
@@ -15,6 +18,7 @@ import (
"github.com/ghostersk/gowebmail/config"
"github.com/ghostersk/gowebmail/internal/db"
"github.com/ghostersk/gowebmail/internal/handlers"
"github.com/ghostersk/gowebmail/internal/logger"
"github.com/ghostersk/gowebmail/internal/middleware"
"github.com/ghostersk/gowebmail/internal/syncer"
@@ -72,6 +76,18 @@ func main() {
if err != nil {
log.Fatalf("config load: %v", err)
}
logger.Init(cfg.Debug)
// Install a filtered log writer that suppresses harmless go-imap v1 parser
// noise ("atom contains forbidden char", "bad brackets nesting") which appears
// on Gmail connections due to non-standard server responses. These don't affect
// functionality — go-imap recovers and continues syncing correctly.
log.SetOutput(&filteredWriter{w: os.Stderr, suppress: []string{
"imap/client:",
"atom contains forbidden",
"atom contains bad",
"bad brackets nesting",
}})
database, err := db.New(cfg.DBPath, cfg.EncryptionKey)
if err != nil {
@@ -83,7 +99,7 @@ func main() {
log.Fatalf("migrations: %v", err)
}
sc := syncer.New(database)
sc := syncer.New(database, cfg)
sc.Start()
defer sc.Stop()
@@ -107,6 +123,15 @@ func main() {
r.PathPrefix("/static/").Handler(
http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))),
)
// Legacy /app path redirect — some browsers bookmark this; redirect to root
// which RequireAuth will then forward to login if not signed in.
r.HandleFunc("/app", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/", http.StatusFound)
}).Methods("GET")
r.HandleFunc("/app/", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/", http.StatusFound)
}).Methods("GET")
r.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
data, err := gowebmail.WebFS.ReadFile("web/static/img/favicon.png")
if err != nil {
@@ -137,11 +162,15 @@ 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()
app.Use(middleware.RequireAuth(database, cfg))
app.HandleFunc("/", h.App.Index).Methods("GET")
app.HandleFunc("/message/{id:[0-9]+}", h.App.ViewMessage).Methods("GET")
app.HandleFunc("/compose", h.App.ComposePage).Methods("GET")
// Admin UI
adminUI := r.PathPrefix("/admin").Subrouter()
@@ -224,10 +253,35 @@ func main() {
api.HandleFunc("/sync-interval", h.API.GetSyncInterval).Methods("GET")
api.HandleFunc("/sync-interval", h.API.SetSyncInterval).Methods("PUT")
api.HandleFunc("/compose-popup", h.API.SetComposePopup).Methods("PUT")
api.HandleFunc("/accounts/sort-order", h.API.SetAccountSortOrder).Methods("PUT")
api.HandleFunc("/ui-prefs", h.API.GetUIPrefs).Methods("GET")
api.HandleFunc("/ui-prefs", h.API.SetUIPrefs).Methods("PUT")
// Search
api.HandleFunc("/search", h.API.Search).Methods("GET")
// Contacts
api.HandleFunc("/contacts", h.API.ListContacts).Methods("GET")
api.HandleFunc("/contacts", h.API.CreateContact).Methods("POST")
api.HandleFunc("/contacts/{id:[0-9]+}", h.API.GetContact).Methods("GET")
api.HandleFunc("/contacts/{id:[0-9]+}", h.API.UpdateContact).Methods("PUT")
api.HandleFunc("/contacts/{id:[0-9]+}", h.API.DeleteContact).Methods("DELETE")
// Calendar events
api.HandleFunc("/calendar/events", h.API.ListCalendarEvents).Methods("GET")
api.HandleFunc("/calendar/events", h.API.CreateCalendarEvent).Methods("POST")
api.HandleFunc("/calendar/events/{id:[0-9]+}", h.API.GetCalendarEvent).Methods("GET")
api.HandleFunc("/calendar/events/{id:[0-9]+}", h.API.UpdateCalendarEvent).Methods("PUT")
api.HandleFunc("/calendar/events/{id:[0-9]+}", h.API.DeleteCalendarEvent).Methods("DELETE")
// CalDAV API tokens
api.HandleFunc("/caldav/tokens", h.API.ListCalDAVTokens).Methods("GET")
api.HandleFunc("/caldav/tokens", h.API.CreateCalDAVToken).Methods("POST")
api.HandleFunc("/caldav/tokens/{id:[0-9]+}", h.API.DeleteCalDAVToken).Methods("DELETE")
// CalDAV public feed — token-authenticated, no session needed
r.HandleFunc("/caldav/{token}/calendar.ics", h.API.ServeCalDAV).Methods("GET")
// Admin API
adminAPI := r.PathPrefix("/api/admin").Subrouter()
adminAPI.Use(middleware.RequireAuth(database, cfg))
@@ -431,3 +485,20 @@ Note: --list-admin, --pw, and --mfa-off only work on admin accounts.
Requires the same environment variables as the server (DB_PATH, ENCRYPTION_KEY, etc).
`)
}
// filteredWriter wraps an io.Writer and drops log lines containing any of the
// suppress substrings. Used to silence harmless go-imap internal parser errors.
type filteredWriter struct {
w io.Writer
suppress []string
}
func (f *filteredWriter) Write(p []byte) (n int, err error) {
line := string(bytes.TrimSpace(p))
for _, s := range f.suppress {
if strings.Contains(line, s) {
return len(p), nil // silently drop
}
}
return f.w.Write(p)
}

View File

@@ -21,6 +21,9 @@ type Config struct {
Hostname string // e.g. "mail.example.com" — used for BASE_URL and host checks
BaseURL string // auto-built from Hostname + ListenPort, or overridden explicitly
// Debug
Debug bool // set DEBUG=true in config to enable verbose logging
// Security
EncryptionKey []byte // 32 bytes / AES-256
SessionSecret []byte
@@ -303,10 +306,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).",
},
},
{
@@ -426,6 +434,7 @@ func Load() (*Config, error) {
Hostname: hostname,
BaseURL: baseURL,
DBPath: get("DB_PATH"),
Debug: atobool(get("DEBUG"), false),
EncryptionKey: encKey,
SessionSecret: []byte(sessSecret),
SecureCookie: atobool(get("SECURE_COOKIE"), false),
@@ -452,7 +461,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 +766,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
View File

@@ -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
View File

@@ -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=

View File

@@ -3,11 +3,15 @@ package auth
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/ghostersk/gowebmail/internal/logger"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"golang.org/x/oauth2/microsoft"
@@ -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
logger.Debug("[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.
@@ -130,3 +268,49 @@ func RefreshToken(ctx context.Context, cfg *oauth2.Config, refreshToken string)
ts := cfg.TokenSource(ctx, &oauth2.Token{RefreshToken: refreshToken})
return ts.Token()
}
// RefreshAccountToken refreshes the OAuth token for a Gmail or Outlook account.
// Pass the credentials for both providers; the correct ones are selected based
// on provider ("gmail" or "outlook").
func RefreshAccountToken(ctx context.Context,
provider, refreshToken, baseURL,
googleClientID, googleClientSecret,
msClientID, msClientSecret, msTenantID string,
) (accessToken, newRefresh string, expiry time.Time, err error) {
switch provider {
case "gmail":
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")
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)
}
}

View File

@@ -2,7 +2,9 @@
package db
import (
"crypto/rand"
"database/sql"
"encoding/base64"
"fmt"
"strings"
"time"
@@ -170,7 +172,6 @@ func (d *DB) Migrate() error {
`ALTER TABLE users ADD COLUMN compose_popup INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE messages ADD COLUMN folder_path TEXT NOT NULL DEFAULT ''`,
// Folder visibility: is_hidden hides from sidebar; sync_enabled controls auto-sync.
// Default: primary folder types sync by default, others don't.
`ALTER TABLE folders ADD COLUMN is_hidden INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE folders ADD COLUMN sync_enabled INTEGER NOT NULL DEFAULT 1`,
// Plaintext search index column — stores decrypted subject+from+preview for LIKE search.
@@ -178,6 +179,10 @@ func (d *DB) Migrate() error {
// Per-folder IMAP sync state for incremental/delta sync.
`ALTER TABLE folders ADD COLUMN uid_validity INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE folders ADD COLUMN last_seen_uid INTEGER NOT NULL DEFAULT 0`,
// Account display order for sidebar drag-and-drop reordering.
`ALTER TABLE email_accounts ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0`,
// UI preferences (JSON): collapsed accounts/folders, etc. Synced across devices.
`ALTER TABLE users ADD COLUMN ui_prefs TEXT NOT NULL DEFAULT '{}'`,
}
for _, stmt := range alterStmts {
d.sql.Exec(stmt) // ignore "duplicate column" errors intentionally
@@ -245,6 +250,62 @@ func (d *DB) Migrate() error {
return fmt.Errorf("create user_ip_rules: %w", err)
}
if _, err := d.sql.Exec(`CREATE TABLE IF NOT EXISTS contacts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
display_name TEXT NOT NULL DEFAULT '',
email TEXT NOT NULL DEFAULT '',
phone TEXT NOT NULL DEFAULT '',
company TEXT NOT NULL DEFAULT '',
notes TEXT NOT NULL DEFAULT '',
avatar_color TEXT NOT NULL DEFAULT '#6b7280',
created_at DATETIME DEFAULT (datetime('now')),
updated_at DATETIME DEFAULT (datetime('now'))
)`); err != nil {
return fmt.Errorf("create contacts: %w", err)
}
if _, err := d.sql.Exec(`CREATE INDEX IF NOT EXISTS idx_contacts_user ON contacts(user_id)`); err != nil {
return fmt.Errorf("index contacts_user: %w", err)
}
if _, err := d.sql.Exec(`CREATE TABLE IF NOT EXISTS calendar_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
account_id INTEGER REFERENCES email_accounts(id) ON DELETE SET NULL,
uid TEXT NOT NULL DEFAULT '',
title TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
location TEXT NOT NULL DEFAULT '',
start_time DATETIME NOT NULL,
end_time DATETIME NOT NULL,
all_day INTEGER NOT NULL DEFAULT 0,
recurrence_rule TEXT NOT NULL DEFAULT '',
color TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'confirmed',
organizer_email TEXT NOT NULL DEFAULT '',
attendees TEXT NOT NULL DEFAULT '',
ical_source TEXT NOT NULL DEFAULT '',
created_at DATETIME DEFAULT (datetime('now')),
updated_at DATETIME DEFAULT (datetime('now')),
UNIQUE(user_id, uid)
)`); err != nil {
return fmt.Errorf("create calendar_events: %w", err)
}
if _, err := d.sql.Exec(`CREATE INDEX IF NOT EXISTS idx_calendar_user_time ON calendar_events(user_id, start_time)`); err != nil {
return fmt.Errorf("index calendar_user_time: %w", err)
}
if _, err := d.sql.Exec(`CREATE TABLE IF NOT EXISTS caldav_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
label TEXT NOT NULL DEFAULT 'CalDAV token',
created_at DATETIME DEFAULT (datetime('now')),
last_used DATETIME
)`); err != nil {
return fmt.Errorf("create caldav_tokens: %w", err)
}
// Bootstrap admin account if no users exist
return d.bootstrapAdmin()
}
@@ -623,14 +684,14 @@ func (d *DB) GetAccount(accountID int64) (*models.EmailAccount, error) {
access_token, refresh_token, token_expiry,
imap_host, imap_port, smtp_host, smtp_port,
last_error, color, is_active, last_sync, created_at,
COALESCE(sync_days,30), COALESCE(sync_mode,'days')
COALESCE(sync_days,30), COALESCE(sync_mode,'days'), COALESCE(sort_order,0)
FROM email_accounts WHERE id=?`, accountID,
).Scan(
&a.ID, &a.UserID, &a.Provider, &a.EmailAddress, &a.DisplayName,
&accessEnc, &refreshEnc, &a.TokenExpiry,
&imapHostEnc, &a.IMAPPort, &smtpHostEnc, &a.SMTPPort,
&a.LastError, &a.Color, &a.IsActive, &lastSync, &a.CreatedAt,
&a.SyncDays, &a.SyncMode,
&a.SyncDays, &a.SyncMode, &a.SortOrder,
)
if err == sql.ErrNoRows {
return nil, nil
@@ -763,8 +824,10 @@ func (d *DB) ListAccountsByUser(userID int64) ([]*models.EmailAccount, error) {
SELECT id, user_id, provider, email_address, display_name,
access_token, refresh_token, token_expiry,
imap_host, imap_port, smtp_host, smtp_port,
last_error, color, is_active, last_sync, created_at
FROM email_accounts WHERE user_id=? AND is_active=1 ORDER BY created_at`, userID)
last_error, color, is_active, last_sync, created_at,
COALESCE(sort_order,0)
FROM email_accounts WHERE user_id=? AND is_active=1
ORDER BY COALESCE(sort_order,0), created_at`, userID)
if err != nil {
return nil, err
}
@@ -783,6 +846,7 @@ func (d *DB) scanAccounts(rows *sql.Rows) ([]*models.EmailAccount, error) {
&accessEnc, &refreshEnc, &a.TokenExpiry,
&imapHostEnc, &a.IMAPPort, &smtpHostEnc, &a.SMTPPort,
&a.LastError, &a.Color, &a.IsActive, &lastSync, &a.CreatedAt,
&a.SortOrder,
); err != nil {
return nil, err
}
@@ -805,6 +869,111 @@ func (d *DB) DeleteAccount(accountID, userID int64) error {
return err
}
// UpsertOAuthAccount inserts a new OAuth account or updates tokens/display name
// if an account with the same (user_id, provider, email_address) already exists.
// Used by OAuth callbacks so that re-connecting updates rather than duplicates.
func (d *DB) UpsertOAuthAccount(a *models.EmailAccount) (created bool, err error) {
accessEnc, _ := d.enc.Encrypt(a.AccessToken)
refreshEnc, _ := d.enc.Encrypt(a.RefreshToken)
// Check for existing account with same user + provider + email
var existingID int64
row := d.sql.QueryRow(
`SELECT id FROM email_accounts WHERE user_id=? AND provider=? AND email_address=?`,
a.UserID, a.Provider, a.EmailAddress,
)
scanErr := row.Scan(&existingID)
if scanErr == sql.ErrNoRows {
// New account — insert with next sort_order
var maxOrder int
d.sql.QueryRow(`SELECT COALESCE(MAX(sort_order),0) FROM email_accounts WHERE user_id=?`, a.UserID).Scan(&maxOrder)
res, insertErr := d.sql.Exec(`
INSERT INTO email_accounts
(user_id, provider, email_address, display_name, access_token, refresh_token,
token_expiry, imap_host, imap_port, smtp_host, smtp_port, color, sort_order)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`,
a.UserID, a.Provider, a.EmailAddress, a.DisplayName,
accessEnc, refreshEnc, a.TokenExpiry,
"", a.IMAPPort, "", a.SMTPPort,
a.Color, maxOrder+1,
)
if insertErr != nil {
return false, insertErr
}
id, _ := res.LastInsertId()
a.ID = id
return true, nil
}
if scanErr != nil {
return false, scanErr
}
// Existing account — update tokens and display name only.
// If refresh token is empty (Microsoft omits it after first auth),
// keep the existing one to avoid losing the ability to auto-refresh.
if a.RefreshToken != "" {
_, err = d.sql.Exec(`
UPDATE email_accounts SET
display_name=?, access_token=?, refresh_token=?, token_expiry=?, last_error=''
WHERE id=?`,
a.DisplayName, accessEnc, refreshEnc, a.TokenExpiry, existingID,
)
} else {
_, err = d.sql.Exec(`
UPDATE email_accounts SET
display_name=?, access_token=?, token_expiry=?, last_error=''
WHERE id=?`,
a.DisplayName, accessEnc, a.TokenExpiry, existingID,
)
}
a.ID = existingID
return false, err
}
// UpdateAccountSortOrder sets sort_order for a batch of accounts for a user.
// accountIDs is ordered from first to last in the desired display order.
func (d *DB) UpdateAccountSortOrder(userID int64, accountIDs []int64) error {
tx, err := d.sql.Begin()
if err != nil {
return err
}
for i, id := range accountIDs {
if _, err := tx.Exec(
`UPDATE email_accounts SET sort_order=? WHERE id=? AND user_id=?`,
i, id, userID,
); err != nil {
tx.Rollback()
return err
}
}
return tx.Commit()
}
// GetUIPrefs returns the JSON ui_prefs string for a user.
func (d *DB) GetUIPrefs(userID int64) (string, error) {
var prefs string
err := d.sql.QueryRow(`SELECT COALESCE(ui_prefs,'{}') FROM users WHERE id=?`, userID).Scan(&prefs)
if err != nil {
return "{}", err
}
return prefs, nil
}
// SetUIPrefs stores the JSON ui_prefs string for a user.
func (d *DB) SetUIPrefs(userID int64, prefs string) error {
_, err := d.sql.Exec(`UPDATE users SET ui_prefs=? WHERE id=?`, prefs, userID)
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(`
UPDATE folders SET
@@ -1116,6 +1285,22 @@ 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)
}
// GetNewestMessageDate returns the date of the most recent message in a folder.
// Returns zero time if the folder is empty.
func (d *DB) GetNewestMessageDate(folderID int64) time.Time {
var t time.Time
d.sql.QueryRow(`SELECT MAX(date) FROM messages WHERE folder_id=?`, folderID).Scan(&t)
return t
}
func (d *DB) ToggleMessageStar(messageID, userID int64) (bool, error) {
var current bool
err := d.sql.QueryRow(`
@@ -1311,6 +1496,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
@@ -2036,3 +2243,267 @@ func (d *DB) ListIPBlocksWithUsername() ([]IPBlockWithUsername, error) {
}
return result, rows.Err()
}
// ======== Contacts ========
func (d *DB) ListContacts(userID int64) ([]*models.Contact, error) {
rows, err := d.sql.Query(`
SELECT id, user_id, display_name, email, phone, company, notes, avatar_color, created_at, updated_at
FROM contacts WHERE user_id=? ORDER BY display_name COLLATE NOCASE`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*models.Contact
for rows.Next() {
var c models.Contact
var dn, em, ph, co, no, av []byte
rows.Scan(&c.ID, &c.UserID, &dn, &em, &ph, &co, &no, &av, &c.CreatedAt, &c.UpdatedAt)
c.DisplayName, _ = d.enc.Decrypt(string(dn))
c.Email, _ = d.enc.Decrypt(string(em))
c.Phone, _ = d.enc.Decrypt(string(ph))
c.Company, _ = d.enc.Decrypt(string(co))
c.Notes, _ = d.enc.Decrypt(string(no))
c.AvatarColor, _ = d.enc.Decrypt(string(av))
out = append(out, &c)
}
return out, nil
}
func (d *DB) GetContact(id, userID int64) (*models.Contact, error) {
var c models.Contact
var dn, em, ph, co, no, av []byte
err := d.sql.QueryRow(`
SELECT id, user_id, display_name, email, phone, company, notes, avatar_color, created_at, updated_at
FROM contacts WHERE id=? AND user_id=?`, id, userID).
Scan(&c.ID, &c.UserID, &dn, &em, &ph, &co, &no, &av, &c.CreatedAt, &c.UpdatedAt)
if err != nil {
return nil, err
}
c.DisplayName, _ = d.enc.Decrypt(string(dn))
c.Email, _ = d.enc.Decrypt(string(em))
c.Phone, _ = d.enc.Decrypt(string(ph))
c.Company, _ = d.enc.Decrypt(string(co))
c.Notes, _ = d.enc.Decrypt(string(no))
c.AvatarColor, _ = d.enc.Decrypt(string(av))
return &c, nil
}
func (d *DB) CreateContact(c *models.Contact) error {
dn, _ := d.enc.Encrypt(c.DisplayName)
em, _ := d.enc.Encrypt(c.Email)
ph, _ := d.enc.Encrypt(c.Phone)
co, _ := d.enc.Encrypt(c.Company)
no, _ := d.enc.Encrypt(c.Notes)
av, _ := d.enc.Encrypt(c.AvatarColor)
res, err := d.sql.Exec(`
INSERT INTO contacts (user_id, display_name, email, phone, company, notes, avatar_color)
VALUES (?,?,?,?,?,?,?)`, c.UserID, dn, em, ph, co, no, av)
if err != nil {
return err
}
c.ID, _ = res.LastInsertId()
return nil
}
func (d *DB) UpdateContact(c *models.Contact, userID int64) error {
dn, _ := d.enc.Encrypt(c.DisplayName)
em, _ := d.enc.Encrypt(c.Email)
ph, _ := d.enc.Encrypt(c.Phone)
co, _ := d.enc.Encrypt(c.Company)
no, _ := d.enc.Encrypt(c.Notes)
av, _ := d.enc.Encrypt(c.AvatarColor)
_, err := d.sql.Exec(`
UPDATE contacts SET display_name=?, email=?, phone=?, company=?, notes=?, avatar_color=?,
updated_at=datetime('now') WHERE id=? AND user_id=?`,
dn, em, ph, co, no, av, c.ID, userID)
return err
}
func (d *DB) DeleteContact(id, userID int64) error {
_, err := d.sql.Exec(`DELETE FROM contacts WHERE id=? AND user_id=?`, id, userID)
return err
}
func (d *DB) SearchContacts(userID int64, q string) ([]*models.Contact, error) {
all, err := d.ListContacts(userID)
if err != nil {
return nil, err
}
q = strings.ToLower(q)
var out []*models.Contact
for _, c := range all {
if strings.Contains(strings.ToLower(c.DisplayName), q) ||
strings.Contains(strings.ToLower(c.Email), q) ||
strings.Contains(strings.ToLower(c.Company), q) {
out = append(out, c)
}
}
return out, nil
}
// ======== Calendar Events ========
func (d *DB) ListCalendarEvents(userID int64, from, to string) ([]*models.CalendarEvent, error) {
rows, err := d.sql.Query(`
SELECT e.id, e.user_id, e.account_id, e.uid, e.title, e.description, e.location,
e.start_time, e.end_time, e.all_day, e.recurrence_rule, e.color,
e.status, e.organizer_email, e.attendees,
COALESCE(a.color,''), COALESCE(a.email_address,'')
FROM calendar_events e
LEFT JOIN email_accounts a ON a.id = e.account_id
WHERE e.user_id=? AND e.start_time >= ? AND e.start_time <= ?
ORDER BY e.start_time`, userID, from, to)
if err != nil {
return nil, err
}
defer rows.Close()
return scanCalendarEvents(d, rows)
}
func (d *DB) GetCalendarEvent(id, userID int64) (*models.CalendarEvent, error) {
rows, err := d.sql.Query(`
SELECT e.id, e.user_id, e.account_id, e.uid, e.title, e.description, e.location,
e.start_time, e.end_time, e.all_day, e.recurrence_rule, e.color,
e.status, e.organizer_email, e.attendees,
COALESCE(a.color,''), COALESCE(a.email_address,'')
FROM calendar_events e
LEFT JOIN email_accounts a ON a.id = e.account_id
WHERE e.id=? AND e.user_id=?`, id, userID)
if err != nil {
return nil, err
}
defer rows.Close()
evs, err := scanCalendarEvents(d, rows)
if err != nil || len(evs) == 0 {
return nil, err
}
return evs[0], nil
}
func scanCalendarEvents(d *DB, rows interface{ Next() bool; Scan(...interface{}) error }) ([]*models.CalendarEvent, error) {
var out []*models.CalendarEvent
for rows.Next() {
var e models.CalendarEvent
var accountID *int64
var ti, de, lo, rc, co, st, oe, at []byte
err := rows.Scan(
&e.ID, &e.UserID, &accountID, &e.UID,
&ti, &de, &lo,
&e.StartTime, &e.EndTime, &e.AllDay, &rc, &co,
&st, &oe, &at,
&e.AccountColor, &e.AccountEmail,
)
if err != nil {
return nil, err
}
e.AccountID = accountID
e.Title, _ = d.enc.Decrypt(string(ti))
e.Description, _ = d.enc.Decrypt(string(de))
e.Location, _ = d.enc.Decrypt(string(lo))
e.RecurrenceRule, _ = d.enc.Decrypt(string(rc))
e.Color, _ = d.enc.Decrypt(string(co))
e.Status, _ = d.enc.Decrypt(string(st))
e.OrganizerEmail, _ = d.enc.Decrypt(string(oe))
e.Attendees, _ = d.enc.Decrypt(string(at))
if e.Color == "" && e.AccountColor != "" {
e.Color = e.AccountColor
}
out = append(out, &e)
}
return out, nil
}
func (d *DB) UpsertCalendarEvent(e *models.CalendarEvent) error {
ti, _ := d.enc.Encrypt(e.Title)
de, _ := d.enc.Encrypt(e.Description)
lo, _ := d.enc.Encrypt(e.Location)
rc, _ := d.enc.Encrypt(e.RecurrenceRule)
co, _ := d.enc.Encrypt(e.Color)
st, _ := d.enc.Encrypt(e.Status)
oe, _ := d.enc.Encrypt(e.OrganizerEmail)
at, _ := d.enc.Encrypt(e.Attendees)
allDay := 0
if e.AllDay {
allDay = 1
}
if e.UID == "" {
e.UID = fmt.Sprintf("gwm-%d-%d", e.UserID, time.Now().UnixNano())
}
res, err := d.sql.Exec(`
INSERT INTO calendar_events
(user_id, account_id, uid, title, description, location,
start_time, end_time, all_day, recurrence_rule, color,
status, organizer_email, attendees)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(user_id, uid) DO UPDATE SET
title=excluded.title, description=excluded.description,
location=excluded.location, start_time=excluded.start_time,
end_time=excluded.end_time, all_day=excluded.all_day,
recurrence_rule=excluded.recurrence_rule, color=excluded.color,
status=excluded.status, organizer_email=excluded.organizer_email,
attendees=excluded.attendees,
updated_at=datetime('now')`,
e.UserID, e.AccountID, e.UID, ti, de, lo,
e.StartTime, e.EndTime, allDay, rc, co, st, oe, at)
if err != nil {
return err
}
if e.ID == 0 {
e.ID, _ = res.LastInsertId()
}
return nil
}
func (d *DB) DeleteCalendarEvent(id, userID int64) error {
_, err := d.sql.Exec(`DELETE FROM calendar_events WHERE id=? AND user_id=?`, id, userID)
return err
}
// ======== CalDAV Tokens ========
func (d *DB) CreateCalDAVToken(userID int64, label string) (*models.CalDAVToken, error) {
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return nil, err
}
token := base64.URLEncoding.EncodeToString(raw)
_, err := d.sql.Exec(`INSERT INTO caldav_tokens (user_id, token, label) VALUES (?,?,?)`,
userID, token, label)
if err != nil {
return nil, err
}
return &models.CalDAVToken{UserID: userID, Token: token, Label: label}, nil
}
func (d *DB) ListCalDAVTokens(userID int64) ([]*models.CalDAVToken, error) {
rows, err := d.sql.Query(`
SELECT id, user_id, token, label, created_at, COALESCE(last_used,'')
FROM caldav_tokens WHERE user_id=? ORDER BY created_at DESC`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*models.CalDAVToken
for rows.Next() {
var t models.CalDAVToken
rows.Scan(&t.ID, &t.UserID, &t.Token, &t.Label, &t.CreatedAt, &t.LastUsed)
out = append(out, &t)
}
return out, nil
}
func (d *DB) DeleteCalDAVToken(id, userID int64) error {
_, err := d.sql.Exec(`DELETE FROM caldav_tokens WHERE id=? AND user_id=?`, id, userID)
return err
}
func (d *DB) GetUserByCalDAVToken(token string) (int64, error) {
var userID int64
err := d.sql.QueryRow(`SELECT user_id FROM caldav_tokens WHERE token=?`, token).Scan(&userID)
if err != nil {
return 0, err
}
d.sql.Exec(`UPDATE caldav_tokens SET last_used=datetime('now') WHERE token=?`, token)
return userID, nil
}

View File

@@ -6,6 +6,7 @@ import (
"context"
"crypto/tls"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log"
@@ -18,6 +19,7 @@ import (
"strings"
"time"
"github.com/ghostersk/gowebmail/internal/logger"
"github.com/emersion/go-imap"
"github.com/emersion/go-imap/client"
@@ -54,7 +56,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 {
logger.Debug("[imap:xoauth2] server error for %s: %s", x.user, string(dec))
} else {
logger.Debug("[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 +99,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 +130,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 {
logger.Debug("[imap:connect] %s aud=%v scp=%q token=%s",
account.EmailAddress, claims.Aud, claims.Scp, tokenPreview)
} else {
logger.Debug("[imap:connect] %s raw claims: %s token=%s",
account.EmailAddress, string(payload), tokenPreview)
}
} else {
logger.Debug("[imap:connect] %s opaque token (not JWT): %s",
account.EmailAddress, tokenPreview)
}
} else {
logger.Debug("[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()
@@ -852,7 +901,7 @@ func SendMessageFull(ctx context.Context, account *gomailModels.EmailAccount, re
rawMsg := buf.Bytes()
addr := fmt.Sprintf("%s:%d", host, port)
log.Printf("[SMTP] dialing %s for account %s", addr, account.EmailAddress)
logger.Debug("[SMTP] dialing %s for account %s", addr, account.EmailAddress)
var c *smtp.Client
var err error
@@ -893,7 +942,7 @@ func SendMessageFull(ctx context.Context, account *gomailModels.EmailAccount, re
if err := authSMTP(c, account, host); err != nil {
return fmt.Errorf("SMTP auth failed for %s: %w", account.EmailAddress, err)
}
log.Printf("[SMTP] auth OK")
logger.Debug("[SMTP] auth OK")
if err := c.Mail(account.EmailAddress); err != nil {
return fmt.Errorf("SMTP MAIL FROM <%s>: %w", account.EmailAddress, err)
@@ -923,7 +972,7 @@ func SendMessageFull(ctx context.Context, account *gomailModels.EmailAccount, re
// DATA close is where the server accepts or rejects the message
return fmt.Errorf("SMTP server rejected message: %w", err)
}
log.Printf("[SMTP] message accepted by server")
logger.Debug("[SMTP] message accepted by server")
_ = c.Quit()
// Append to Sent folder via IMAP (best-effort, don't fail the send)

426
internal/graph/graph.go Normal file
View File

@@ -0,0 +1,426 @@
// 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"
"regexp"
"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() {
// OData filter: receivedDateTime gt 2006-01-02T15:04:05Z
// Use strings.ReplaceAll to keep colons unencoded — Graph accepts this form
dateStr := since.UTC().Format("2006-01-02T15:04:05Z")
filter = "&$filter=receivedDateTime gt " + url.PathEscape(dateStr)
}
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 ----
// stripHTML does a minimal HTML→plain-text conversion for the text/plain fallback.
// Spam filters score HTML-only email negatively; sending both parts improves deliverability.
func stripHTML(s string) string {
s = regexp.MustCompile(`(?i)<br\s*/?>|</p>|</div>|</li>|</tr>`).ReplaceAllString(s, "\n")
s = regexp.MustCompile(`<[^>]+>`).ReplaceAllString(s, "")
s = strings.NewReplacer("&amp;", "&", "&lt;", "<", "&gt;", ">", "&quot;", `"`, "&#39;", "'", "&nbsp;", " ").Replace(s)
s = regexp.MustCompile(`\n{3,}`).ReplaceAllString(s, "\n\n")
return strings.TrimSpace(s)
}
// SendMail sends an email via Graph API POST /me/sendMail.
// Sets both HTML and plain-text body to improve deliverability (spam filters
// penalise HTML-only messages with no text/plain alternative).
func (c *Client) SendMail(ctx context.Context, req *models.ComposeRequest) error {
// Build body: always provide both HTML and plain text for better deliverability
body := map[string]string{
"contentType": "HTML",
"content": req.BodyHTML,
}
if req.BodyHTML == "" {
body["contentType"] = "Text"
body["content"] = req.BodyText
}
// Set explicit from with display name
var fromField interface{}
if c.account.DisplayName != "" {
fromField = map[string]interface{}{
"emailAddress": map[string]string{
"address": c.account.EmailAddress,
"name": c.account.DisplayName,
},
}
}
msg := map[string]interface{}{
"subject": req.Subject,
"body": body,
"toRecipients": graphRecipients(req.To),
"ccRecipients": graphRecipients(req.CC),
"bccRecipients": graphRecipients(req.BCC),
}
if fromField != nil {
msg["from"] = fromField
}
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
}

View File

@@ -13,8 +13,10 @@ import (
"time"
"github.com/ghostersk/gowebmail/config"
"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"
@@ -44,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 != "",
})
}
@@ -60,9 +63,13 @@ type safeAccount struct {
IMAPPort int `json:"imap_port,omitempty"`
SMTPHost string `json:"smtp_host,omitempty"`
SMTPPort int `json:"smtp_port,omitempty"`
SyncDays int `json:"sync_days"`
SyncMode string `json:"sync_mode"`
SortOrder int `json:"sort_order"`
LastError string `json:"last_error,omitempty"`
Color string `json:"color"`
LastSync string `json:"last_sync"`
TokenExpired bool `json:"token_expired,omitempty"`
}
func toSafeAccount(a *models.EmailAccount) safeAccount {
@@ -70,11 +77,17 @@ func toSafeAccount(a *models.EmailAccount) safeAccount {
if !a.LastSync.IsZero() {
lastSync = a.LastSync.Format("2006-01-02T15:04:05Z")
}
tokenExpired := false
if (a.Provider == models.ProviderGmail || a.Provider == models.ProviderOutlook || a.Provider == models.ProviderOutlookPersonal) && auth.IsTokenExpired(a.TokenExpiry) {
tokenExpired = true
}
return safeAccount{
ID: a.ID, Provider: a.Provider, EmailAddress: a.EmailAddress,
DisplayName: a.DisplayName, IMAPHost: a.IMAPHost, IMAPPort: a.IMAPPort,
SMTPHost: a.SMTPHost, SMTPPort: a.SMTPPort,
SyncDays: a.SyncDays, SyncMode: a.SyncMode, SortOrder: a.SortOrder,
LastError: a.LastError, Color: a.Color, LastSync: lastSync,
TokenExpired: tokenExpired,
}
}
@@ -476,6 +489,52 @@ func (h *APIHandler) SetComposePopup(w http.ResponseWriter, r *http.Request) {
h.writeJSON(w, map[string]bool{"ok": true})
}
func (h *APIHandler) SetAccountSortOrder(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
var req struct {
Order []int64 `json:"order"` // account IDs in desired display order
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || len(req.Order) == 0 {
h.writeError(w, http.StatusBadRequest, "invalid request")
return
}
if err := h.db.UpdateAccountSortOrder(userID, req.Order); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to save order")
return
}
h.writeJSON(w, map[string]bool{"ok": true})
}
func (h *APIHandler) GetUIPrefs(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
prefs, err := h.db.GetUIPrefs(userID)
if err != nil {
prefs = "{}"
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(prefs))
}
func (h *APIHandler) SetUIPrefs(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
body, err := io.ReadAll(io.LimitReader(r.Body, 64*1024))
if err != nil || len(body) == 0 {
h.writeError(w, http.StatusBadRequest, "invalid request")
return
}
// Validate it's valid JSON before storing
var check map[string]interface{}
if err := json.Unmarshal(body, &check); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
if err := h.db.SetUIPrefs(userID, string(body)); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to save")
return
}
h.writeJSON(w, map[string]bool{"ok": true})
}
// ---- Messages ----
func (h *APIHandler) ListMessages(w http.ResponseWriter, r *http.Request) {
@@ -534,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 {
@@ -563,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})
}
@@ -591,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})
}
@@ -627,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,
@@ -642,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,
@@ -753,6 +835,24 @@ 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
}
// Delay slightly so Microsoft has time to save to Sent Items before we sync
go func() {
time.Sleep(3 * time.Second)
h.syncer.TriggerAccountSync(account.ID)
}()
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,
@@ -761,6 +861,8 @@ func (h *APIHandler) handleSend(w http.ResponseWriter, r *http.Request, mode str
h.writeError(w, http.StatusBadGateway, err.Error())
return
}
// Trigger immediate sync so the sent message appears in Sent Items
h.syncer.TriggerAccountSync(account.ID)
h.writeJSON(w, map[string]bool{"ok": true})
}
@@ -1333,3 +1435,44 @@ func (h *APIHandler) SaveDraft(w http.ResponseWriter, r *http.Request) {
h.writeJSON(w, map[string]bool{"ok": true})
}
// ensureAccountTokenFresh refreshes the OAuth access token for a Gmail/Outlook
// 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 && account.Provider != models.ProviderOutlookPersonal {
return account
}
if !auth.IsTokenExpired(account.TokenExpiry) {
return account
}
if account.RefreshToken == "" {
log.Printf("[oauth:%s] token expired, no refresh token stored", account.EmailAddress)
return account
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
accessTok, refreshTok, expiry, err := auth.RefreshAccountToken(
ctx,
string(account.Provider),
account.RefreshToken,
h.cfg.BaseURL,
h.cfg.GoogleClientID, h.cfg.GoogleClientSecret,
h.cfg.MicrosoftClientID, h.cfg.MicrosoftClientSecret, h.cfg.MicrosoftTenantID,
)
if err != nil {
log.Printf("[oauth:%s] token refresh failed: %v", account.EmailAddress, err)
return account
}
if err := h.db.UpdateAccountTokens(account.ID, accessTok, refreshTok, expiry); err != nil {
log.Printf("[oauth:%s] failed to persist refreshed token: %v", account.EmailAddress, err)
return account
}
refreshed, err := h.db.GetAccount(account.ID)
if err != nil || refreshed == nil {
return account
}
log.Printf("[oauth:%s] access token refreshed for send (expires %s)", account.EmailAddress, expiry.Format("2006-01-02 15:04 UTC"))
return refreshed
}

View File

@@ -17,3 +17,13 @@ type AppHandler struct {
func (h *AppHandler) Index(w http.ResponseWriter, r *http.Request) {
h.renderer.Render(w, "app", nil)
}
// ViewMessage renders a single message in a full browser tab.
func (h *AppHandler) ViewMessage(w http.ResponseWriter, r *http.Request) {
h.renderer.Render(w, "message", nil)
}
// ComposePage renders the compose form in a full browser tab.
func (h *AppHandler) ComposePage(w http.ResponseWriter, r *http.Request) {
h.renderer.Render(w, "compose", nil)
}

View File

@@ -4,10 +4,15 @@ import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"html"
"log"
"net"
"net/http"
"strings"
"time"
"github.com/ghostersk/gowebmail/internal/logger"
"github.com/ghostersk/gowebmail/config"
goauth "github.com/ghostersk/gowebmail/internal/auth"
"github.com/ghostersk/gowebmail/internal/crypto"
@@ -24,6 +29,7 @@ type AuthHandler struct {
db *db.DB
cfg *config.Config
renderer *Renderer
syncer interface{ TriggerReconcile() }
}
// ---- Login ----
@@ -311,12 +317,20 @@ func (h *AuthHandler) GmailCallback(w http.ResponseWriter, r *http.Request) {
AccessToken: token.AccessToken, RefreshToken: token.RefreshToken,
TokenExpiry: token.Expiry, Color: color, IsActive: true,
}
if err := h.db.CreateAccount(account); err != nil {
created, err := h.db.UpsertOAuthAccount(account)
if err != nil {
http.Redirect(w, r, "/?error=account_save_failed", http.StatusFound)
return
}
uid := userID
h.db.WriteAudit(&uid, models.AuditAccountAdd, "gmail:"+userInfo.Email, middleware.ClientIP(r), r.UserAgent())
action := "gmail:" + userInfo.Email
if !created {
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)
}
@@ -331,13 +345,51 @@ 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)
url := cfg.AuthCodeURL(state, oauth2.AccessTypeOffline)
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)
}
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)
@@ -347,29 +399,80 @@ 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
}
logger.Debug("[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 {
logger.Debug("[oauth:outlook] IMAP token exchange failed: %v — falling back to initial token", err)
imapToken = token
} else {
logger.Debug("[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,
}
if err := h.db.CreateAccount(account); err != nil {
created, err := h.db.UpsertOAuthAccount(account)
if err != nil {
http.Redirect(w, r, "/?error=account_save_failed", http.StatusFound)
return
}
uid := userID
h.db.WriteAudit(&uid, models.AuditAccountAdd, "outlook:"+userInfo.Email(), middleware.ClientIP(r), r.UserAgent())
action := "outlook:" + userInfo.Email()
if !created {
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)
}
@@ -531,3 +634,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, "."))
logger.Debug("[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)
}

View File

@@ -0,0 +1,309 @@
package handlers
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/gorilla/mux"
"github.com/ghostersk/gowebmail/internal/middleware"
"github.com/ghostersk/gowebmail/internal/models"
)
// ======== Contacts ========
func (h *APIHandler) ListContacts(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
q := strings.TrimSpace(r.URL.Query().Get("q"))
var contacts interface{}
var err error
if q != "" {
contacts, err = h.db.SearchContacts(userID, q)
} else {
contacts, err = h.db.ListContacts(userID)
}
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to list contacts")
return
}
if contacts == nil {
contacts = []*models.Contact{}
}
h.writeJSON(w, contacts)
}
func (h *APIHandler) GetContact(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
id := pathInt64(r, "id")
c, err := h.db.GetContact(id, userID)
if err != nil || c == nil {
h.writeError(w, http.StatusNotFound, "contact not found")
return
}
h.writeJSON(w, c)
}
func (h *APIHandler) CreateContact(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
var req models.Contact
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid request")
return
}
req.UserID = userID
if req.AvatarColor == "" {
colors := []string{"#6b7280", "#0078D4", "#EA4335", "#34A853", "#FBBC04", "#9C27B0", "#FF6D00"}
req.AvatarColor = colors[int(userID)%len(colors)]
}
if err := h.db.CreateContact(&req); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to create contact")
return
}
h.writeJSON(w, req)
}
func (h *APIHandler) UpdateContact(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
id := pathInt64(r, "id")
var req models.Contact
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid request")
return
}
req.ID = id
if err := h.db.UpdateContact(&req, userID); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to update contact")
return
}
h.writeJSON(w, map[string]bool{"ok": true})
}
func (h *APIHandler) DeleteContact(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
id := pathInt64(r, "id")
if err := h.db.DeleteContact(id, userID); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to delete contact")
return
}
h.writeJSON(w, map[string]bool{"ok": true})
}
// ======== Calendar Events ========
func (h *APIHandler) ListCalendarEvents(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
from := r.URL.Query().Get("from")
to := r.URL.Query().Get("to")
if from == "" {
from = time.Now().AddDate(0, -1, 0).Format("2006-01-02")
}
if to == "" {
to = time.Now().AddDate(0, 3, 0).Format("2006-01-02")
}
events, err := h.db.ListCalendarEvents(userID, from, to)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to list events")
return
}
if events == nil {
events = []*models.CalendarEvent{}
}
h.writeJSON(w, events)
}
func (h *APIHandler) GetCalendarEvent(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
id := pathInt64(r, "id")
ev, err := h.db.GetCalendarEvent(id, userID)
if err != nil || ev == nil {
h.writeError(w, http.StatusNotFound, "event not found")
return
}
h.writeJSON(w, ev)
}
func (h *APIHandler) CreateCalendarEvent(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
var req models.CalendarEvent
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid request")
return
}
req.UserID = userID
if err := h.db.UpsertCalendarEvent(&req); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to create event")
return
}
h.writeJSON(w, req)
}
func (h *APIHandler) UpdateCalendarEvent(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
id := pathInt64(r, "id")
existing, err := h.db.GetCalendarEvent(id, userID)
if err != nil || existing == nil {
h.writeError(w, http.StatusNotFound, "event not found")
return
}
var req models.CalendarEvent
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid request")
return
}
req.ID = id
req.UserID = userID
req.UID = existing.UID // preserve original UID
if err := h.db.UpsertCalendarEvent(&req); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to update event")
return
}
h.writeJSON(w, map[string]bool{"ok": true})
}
func (h *APIHandler) DeleteCalendarEvent(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
id := pathInt64(r, "id")
if err := h.db.DeleteCalendarEvent(id, userID); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to delete event")
return
}
h.writeJSON(w, map[string]bool{"ok": true})
}
// ======== CalDAV Tokens ========
func (h *APIHandler) ListCalDAVTokens(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
tokens, err := h.db.ListCalDAVTokens(userID)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to list tokens")
return
}
if tokens == nil {
tokens = []*models.CalDAVToken{}
}
h.writeJSON(w, tokens)
}
func (h *APIHandler) CreateCalDAVToken(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
var req struct {
Label string `json:"label"`
}
json.NewDecoder(r.Body).Decode(&req)
if req.Label == "" {
req.Label = "CalDAV token"
}
t, err := h.db.CreateCalDAVToken(userID, req.Label)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to create token")
return
}
h.writeJSON(w, t)
}
func (h *APIHandler) DeleteCalDAVToken(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
id := pathInt64(r, "id")
if err := h.db.DeleteCalDAVToken(id, userID); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to delete token")
return
}
h.writeJSON(w, map[string]bool{"ok": true})
}
// ======== CalDAV Server ========
// Serves a read-only iCalendar feed at /caldav/{token}/calendar.ics
// Compatible with any CalDAV client that supports basic calendar subscription.
func (h *APIHandler) ServeCalDAV(w http.ResponseWriter, r *http.Request) {
token := mux.Vars(r)["token"]
userID, err := h.db.GetUserByCalDAVToken(token)
if err != nil || userID == 0 {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Fetch events for next 12 months + past 3 months
from := time.Now().AddDate(0, -3, 0).Format("2006-01-02")
to := time.Now().AddDate(1, 0, 0).Format("2006-01-02")
events, err := h.db.ListCalendarEvents(userID, from, to)
if err != nil {
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/calendar; charset=utf-8")
w.Header().Set("Content-Disposition", `attachment; filename="gowebmail.ics"`)
fmt.Fprintf(w, "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//GoWebMail//EN\r\nCALSCALE:GREGORIAN\r\nMETHOD:PUBLISH\r\nX-WR-CALNAME:GoWebMail\r\n")
for _, ev := range events {
fmt.Fprintf(w, "BEGIN:VEVENT\r\n")
fmt.Fprintf(w, "UID:%s\r\n", escICAL(ev.UID))
fmt.Fprintf(w, "SUMMARY:%s\r\n", escICAL(ev.Title))
if ev.Description != "" {
fmt.Fprintf(w, "DESCRIPTION:%s\r\n", escICAL(ev.Description))
}
if ev.Location != "" {
fmt.Fprintf(w, "LOCATION:%s\r\n", escICAL(ev.Location))
}
if ev.AllDay {
// All-day events use DATE format
start := strings.ReplaceAll(strings.Split(ev.StartTime, "T")[0], "-", "")
end := strings.ReplaceAll(strings.Split(ev.EndTime, "T")[0], "-", "")
fmt.Fprintf(w, "DTSTART;VALUE=DATE:%s\r\n", start)
fmt.Fprintf(w, "DTEND;VALUE=DATE:%s\r\n", end)
} else {
fmt.Fprintf(w, "DTSTART:%s\r\n", toICALDate(ev.StartTime))
fmt.Fprintf(w, "DTEND:%s\r\n", toICALDate(ev.EndTime))
}
if ev.OrganizerEmail != "" {
fmt.Fprintf(w, "ORGANIZER:mailto:%s\r\n", ev.OrganizerEmail)
}
if ev.Status != "" {
fmt.Fprintf(w, "STATUS:%s\r\n", strings.ToUpper(ev.Status))
}
if ev.RecurrenceRule != "" {
fmt.Fprintf(w, "RRULE:%s\r\n", ev.RecurrenceRule)
}
fmt.Fprintf(w, "END:VEVENT\r\n")
}
fmt.Fprintf(w, "END:VCALENDAR\r\n")
}
func escICAL(s string) string {
s = strings.ReplaceAll(s, "\\", "\\\\")
s = strings.ReplaceAll(s, ";", "\\;")
s = strings.ReplaceAll(s, ",", "\\,")
s = strings.ReplaceAll(s, "\n", "\\n")
s = strings.ReplaceAll(s, "\r", "")
// Fold long lines at 75 chars
if len(s) > 70 {
var out strings.Builder
for i, ch := range s {
if i > 0 && i%70 == 0 {
out.WriteString("\r\n ")
}
out.WriteRune(ch)
}
return out.String()
}
return s
}
func toICALDate(s string) string {
// Convert "2006-01-02T15:04:05Z" or "2006-01-02 15:04:05" to "20060102T150405Z"
t, err := time.Parse("2006-01-02T15:04:05Z07:00", s)
if err != nil {
t, err = time.Parse("2006-01-02 15:04:05", s)
}
if err != nil {
return strings.NewReplacer("-", "", ":", "", " ", "T", "Z", "").Replace(s) + "Z"
}
return t.UTC().Format("20060102T150405Z")
}

View File

@@ -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},

View File

@@ -26,6 +26,8 @@ func NewRenderer() (*Renderer, error) {
"login.html",
"mfa.html",
"admin.html",
"message.html",
"compose.html",
}
templateFS, err := fs.Sub(gowebmail.WebFS, "web/templates")
if err != nil {

24
internal/logger/logger.go Normal file
View File

@@ -0,0 +1,24 @@
// Package logger provides a conditional debug logger controlled by config.Debug.
package logger
import "log"
var debugEnabled bool
// Init sets whether debug logging is active. Call once at startup.
func Init(debug bool) {
debugEnabled = debug
if debug {
log.Println("[logger] debug logging enabled")
}
}
// Debug logs a message only when debug mode is on.
func Debug(format string, args ...interface{}) {
if debugEnabled {
log.Printf(format, args...)
}
}
// IsEnabled returns true if debug logging is on.
func IsEnabled() bool { return debugEnabled }

View File

@@ -292,20 +292,17 @@ func renderErrorPage(w http.ResponseWriter, r *http.Request, status int, title,
fmt.Fprintf(w, `{"error":%q}`, message)
return
}
// Decide back-button destination: if the user has a session cookie they're
// likely logged in, so send them home. Otherwise send to login.
backHref := "/auth/login"
backLabel := "← Back to Login"
if _, err := r.Cookie("gomail_session"); err == nil {
backHref = "/"
backLabel = "← Go to Home"
}
// Back-button destination: always send to "/" which RequireAuth will
// transparently forward to /auth/login if the session is absent or invalid.
// This avoids a stale-cookie loop where cookie presence ≠ valid session.
backHref := "/"
backLabel := "← Go Back"
data := struct {
Status int
Title string
Message string
BackHref string
Status int
Title string
Message string
BackHref string
BackLabel string
}{status, title, message, backHref, backLabel}

View File

@@ -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).
@@ -113,6 +114,7 @@ type EmailAccount struct {
// Display
Color string `json:"color"`
IsActive bool `json:"is_active"`
SortOrder int `json:"sort_order"`
LastSync time.Time `json:"last_sync"`
CreatedAt time.Time `json:"created_at"`
}
@@ -241,3 +243,49 @@ type PagedMessages struct {
PageSize int `json:"page_size"`
HasMore bool `json:"has_more"`
}
// ---- Contacts ----
type Contact struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
DisplayName string `json:"display_name"`
Email string `json:"email"`
Phone string `json:"phone"`
Company string `json:"company"`
Notes string `json:"notes"`
AvatarColor string `json:"avatar_color"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// ---- Calendar ----
type CalendarEvent struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
AccountID *int64 `json:"account_id,omitempty"`
UID string `json:"uid"`
Title string `json:"title"`
Description string `json:"description"`
Location string `json:"location"`
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
AllDay bool `json:"all_day"`
RecurrenceRule string `json:"recurrence_rule"`
Color string `json:"color"`
Status string `json:"status"`
OrganizerEmail string `json:"organizer_email"`
Attendees string `json:"attendees"`
AccountColor string `json:"account_color,omitempty"`
AccountEmail string `json:"account_email,omitempty"`
}
type CalDAVToken struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
Token string `json:"token"`
Label string `json:"label"`
CreatedAt string `json:"created_at"`
LastUsed string `json:"last_used,omitempty"`
}

View File

@@ -9,31 +9,51 @@ import (
"context"
"fmt"
"log"
"strings"
"sync"
"time"
"github.com/ghostersk/gowebmail/internal/logger"
"github.com/ghostersk/gowebmail/config"
"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"
)
// Scheduler coordinates all background sync activity.
type Scheduler struct {
db *db.DB
cfg *config.Config
stop chan struct{}
wg sync.WaitGroup
// 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) *Scheduler {
func New(database *db.DB, cfg *config.Config) *Scheduler {
return &Scheduler{
db: database,
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:
}
}
@@ -123,6 +143,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))
@@ -187,6 +214,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())
@@ -258,6 +291,7 @@ func (s *Scheduler) idleWatcher(account *models.EmailAccount, stop chan struct{}
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
account = s.ensureFreshToken(account)
c, err := email.Connect(ctx, account)
cancel()
if err != nil {
@@ -338,6 +372,7 @@ func (s *Scheduler) deltaSync(account *models.EmailAccount) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
account = s.ensureFreshToken(account)
c, err := email.Connect(ctx, account)
if err != nil {
log.Printf("[sync:%s] connect: %v", account.EmailAddress, err)
@@ -349,7 +384,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
}
@@ -380,7 +428,7 @@ func (s *Scheduler) deltaSync(account *models.EmailAccount) {
s.db.UpdateAccountLastSync(account.ID)
if totalNew > 0 {
log.Printf("[sync:%s] %d new messages", account.EmailAddress, totalNew)
logger.Debug("[sync:%s] %d new messages", account.EmailAddress, totalNew)
}
}
@@ -389,6 +437,7 @@ func (s *Scheduler) syncInbox(account *models.EmailAccount) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
account = s.ensureFreshToken(account)
c, err := email.Connect(ctx, account)
if err != nil {
return
@@ -405,7 +454,7 @@ func (s *Scheduler) syncInbox(account *models.EmailAccount) {
return
}
if n > 0 {
log.Printf("[idle:%s] %d new messages in INBOX", account.EmailAddress, n)
logger.Debug("[idle:%s] %d new messages in INBOX", account.EmailAddress, n)
}
}
@@ -488,6 +537,10 @@ func (s *Scheduler) syncFolder(c *email.Client, account *models.EmailAccount, db
// Applies queued IMAP write operations (delete/move/flag) with retry logic.
func (s *Scheduler) drainPendingOps(account *models.EmailAccount) {
// Graph accounts don't use the IMAP ops queue
if account.Provider == models.ProviderOutlookPersonal {
return
}
ops, err := s.db.DequeuePendingOps(account.ID, 50)
if err != nil || len(ops) == 0 {
return
@@ -496,6 +549,7 @@ func (s *Scheduler) drainPendingOps(account *models.EmailAccount) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
account = s.ensureFreshToken(account)
c, err := email.Connect(ctx, account)
if err != nil {
log.Printf("[ops:%s] connect for drain: %v", account.EmailAddress, err)
@@ -540,6 +594,62 @@ func (s *Scheduler) drainPendingOps(account *models.EmailAccount) {
}
}
// ---- OAuth token refresh ----
// ensureFreshToken checks whether an OAuth account's access token is near
// expiry and, if so, exchanges the refresh token for a new one, persists it
// 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 && account.Provider != models.ProviderOutlookPersonal {
return account
}
// 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 {
logger.Debug("[oauth:%s] opaque v1 token detected — forcing refresh to get JWT", account.EmailAddress)
}
if account.RefreshToken == "" {
logger.Debug("[oauth:%s] token expired but no refresh token stored — re-authorisation required", account.EmailAddress)
return account
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
accessTok, refreshTok, expiry, err := auth.RefreshAccountToken(
ctx,
string(account.Provider),
account.RefreshToken,
s.cfg.BaseURL,
s.cfg.GoogleClientID, s.cfg.GoogleClientSecret,
s.cfg.MicrosoftClientID, s.cfg.MicrosoftClientSecret, s.cfg.MicrosoftTenantID,
)
if err != nil {
logger.Debug("[oauth:%s] token refresh failed: %v", account.EmailAddress, err)
s.db.SetAccountError(account.ID, "OAuth token refresh failed: "+err.Error())
return account // return original; connect will fail and log the error
}
if err := s.db.UpdateAccountTokens(account.ID, accessTok, refreshTok, expiry); err != nil {
logger.Debug("[oauth:%s] failed to persist refreshed token: %v", account.EmailAddress, err)
return account
}
// Re-fetch so the caller gets the updated access token from the DB.
refreshed, fetchErr := s.db.GetAccount(account.ID)
if fetchErr != nil || refreshed == nil {
return account
}
logger.Debug("[oauth:%s] access token refreshed (expires %s)", account.EmailAddress, expiry.Format("2006-01-02 15:04 UTC"))
return refreshed
}
// ---- Public API (called by HTTP handlers) ----
// SyncAccountNow performs an immediate delta sync of one account.
@@ -564,8 +674,45 @@ func (s *Scheduler) SyncFolderNow(accountID, folderID int64) (int, error) {
return 0, fmt.Errorf("folder %d not found", folderID)
}
// Graph accounts use the Graph sync path, not IMAP
if account.Provider == models.ProviderOutlookPersonal {
account = s.ensureFreshToken(account)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
gc := graph.New(account)
// Force full resync of this folder by ignoring the since filter
msgs, err := gc.ListMessages(ctx, folder.FullPath, time.Time{}, 100)
if err != nil {
return 0, fmt.Errorf("graph list messages: %w", err)
}
n := 0
for _, gm := range msgs {
msg := &models.Message{
AccountID: account.ID,
FolderID: folder.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 dbErr := s.db.UpsertMessage(msg); dbErr == nil {
n++
}
}
// Update folder counts
s.db.UpdateFolderCountsDirect(folder.ID, len(msgs), 0)
return n, nil
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
account = s.ensureFreshToken(account)
c, err := email.Connect(ctx, account)
if err != nil {
return 0, err
@@ -574,3 +721,129 @@ 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{}) {
logger.Debug("[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:
logger.Debug("[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
}
// Fetch latest messages — no since filter, rely on upsert idempotency.
// Graph uses sentDateTime for sent items which differs from receivedDateTime,
// making date-based filters unreliable across folder types.
// Fetching top 100 newest per folder per sync is efficient enough.
msgs, err := gc.ListMessages(ctx, gf.ID, time.Time{}, 100)
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 {
logger.Debug("[graph:%s] %d new messages", account.EmailAddress, totalNew)
}
}

View File

@@ -165,7 +165,15 @@ body.app-page{overflow:hidden}
.unread-badge{margin-left:auto;background:var(--accent);color:white;font-size:10px;
font-weight:600;padding:1px 6px;border-radius:10px;min-width:18px;text-align:center}
.nav-folder-header{font-size:10px;font-weight:500;text-transform:uppercase;letter-spacing:1px;
color:var(--muted);padding:10px 8px 3px;display:flex;align-items:center;gap:6px}
color:var(--muted);padding:10px 8px 3px;display:flex;align-items:center;gap:6px;
cursor:pointer;user-select:none;border-radius:6px;transition:background .15s}
.nav-folder-header:hover{background:var(--surface3)}
.acc-drag-handle{cursor:grab;color:var(--muted);font-size:13px;opacity:.5;flex-shrink:0;line-height:1}
.acc-drag-handle:hover{opacity:1}
.acc-chevron{flex-shrink:0;color:var(--muted);display:flex;align-items:center}
.nav-account-group{border-radius:6px;transition:background .15s}
.nav-account-group.acc-drag-target{background:rgba(74,144,226,.12);outline:1px dashed var(--accent)}
.nav-account-group.acc-dragging{opacity:.4}
.sidebar-footer{padding:10px 14px;border-top:1px solid var(--border);display:flex;
align-items:center;justify-content:space-between;flex-shrink:0}
.user-info{display:flex;flex-direction:column;gap:2px;min-width:0}
@@ -498,3 +506,115 @@ 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}
}
/* ── Contacts ──────────────────────────────────────────────────────────── */
.contact-card{display:flex;align-items:center;gap:12px;padding:10px 14px;border-radius:8px;
cursor:pointer;transition:background .1s;border-bottom:1px solid var(--border)}
.contact-card:hover{background:var(--surface3)}
.contact-avatar{width:36px;height:36px;border-radius:50%;display:flex;align-items:center;
justify-content:center;font-size:15px;font-weight:600;color:white;flex-shrink:0}
.contact-info{flex:1;min-width:0}
.contact-name{font-size:14px;font-weight:500;color:var(--text)}
.contact-meta{font-size:12px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
/* ── Calendar ──────────────────────────────────────────────────────────── */
.cal-grid-month{display:grid;grid-template-columns:repeat(7,1fr);border-left:1px solid var(--border);border-top:1px solid var(--border)}
.cal-day-header{text-align:center;font-size:11px;font-weight:600;text-transform:uppercase;
letter-spacing:.5px;color:var(--muted);padding:6px 0;background:var(--surface);
border-right:1px solid var(--border);border-bottom:1px solid var(--border)}
.cal-day{min-height:90px;padding:4px;border-right:1px solid var(--border);border-bottom:1px solid var(--border);
vertical-align:top;background:var(--surface);transition:background .1s;position:relative}
.cal-day:hover{background:var(--surface3)}
.cal-day.today{background:var(--accent-dim)}
.cal-day.other-month{opacity:.45}
.cal-day-num{font-size:12px;font-weight:500;color:var(--text2);margin-bottom:2px;cursor:pointer;
width:22px;height:22px;display:flex;align-items:center;justify-content:center;border-radius:50%}
.cal-day-num:hover{background:var(--border2)}
.cal-day.today .cal-day-num{background:var(--accent);color:white}
.cal-event{font-size:11px;padding:2px 5px;border-radius:3px;margin-bottom:2px;
cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:white;
transition:opacity .1s}
.cal-event:hover{opacity:.85}
.cal-more{font-size:10px;color:var(--muted);cursor:pointer;padding:1px 4px}
.cal-more:hover{color:var(--accent)}
/* Week view */
.cal-week-grid{display:grid;grid-template-columns:52px repeat(7,1fr);border-left:1px solid var(--border)}
.cal-week-header{text-align:center;padding:6px 2px;font-size:12px;border-right:1px solid var(--border);
border-bottom:1px solid var(--border);background:var(--surface)}
.cal-week-header.today-col{color:var(--accent);font-weight:600}
.cal-time-col{font-size:10px;color:var(--muted);text-align:right;padding-right:4px;
border-right:1px solid var(--border);border-bottom:1px solid var(--border);height:40px;
display:flex;align-items:flex-start;justify-content:flex-end;padding-top:2px}
.cal-week-cell{border-right:1px solid var(--border);border-bottom:1px solid var(--border);
height:40px;position:relative;transition:background .1s}
.cal-week-cell:hover{background:var(--surface3)}
/* CalDAV token row */
.caldav-token-row{display:flex;align-items:center;gap:8px;padding:8px 0;border-bottom:1px solid var(--border)}
.caldav-token-url{font-size:11px;font-family:monospace;color:var(--muted);overflow:hidden;
text-overflow:ellipsis;white-space:nowrap;flex:1;cursor:pointer}
.caldav-token-url:hover{color:var(--text)}

View File

@@ -9,12 +9,27 @@ const S = {
searchQuery: '', composeMode: 'new', composeReplyToId: null, composeForwardFromId: null,
filterUnread: false, filterAttachment: false,
sortOrder: 'date-desc', // 'date-desc' | 'date-asc' | 'size-desc'
uiPrefs: {}, // server-persisted UI preferences (collapsed accounts/folders etc.)
};
// ── UI Preferences (server-persisted, cross-device) ─────────────────────────
let _uiPrefsSaveTimer = null;
function uiPrefsGet(key, def) { return (key in S.uiPrefs) ? S.uiPrefs[key] : def; }
function uiPrefsSet(key, val) {
S.uiPrefs[key] = val;
clearTimeout(_uiPrefsSaveTimer);
_uiPrefsSaveTimer = setTimeout(() => {
api('PUT', '/ui-prefs', S.uiPrefs);
}, 600); // debounce 600ms
}
function isAccountCollapsed(accId) { return uiPrefsGet('ac_'+accId, false); }
function setAccountCollapsed(accId, v) { uiPrefsSet('ac_'+accId, v); }
// ── Boot ───────────────────────────────────────────────────────────────────
async function init() {
const [me, providers, wl] = await Promise.all([
const [me, providers, wl, uiPrefsRaw] = await Promise.all([
api('GET','/me'), api('GET','/providers'), api('GET','/remote-content-whitelist'),
api('GET','/ui-prefs'),
]);
if (me) {
S.me = me;
@@ -23,6 +38,7 @@ async function init() {
}
if (providers) { S.providers = providers; updateProviderButtons(); }
if (wl?.whitelist) S.remoteWhitelist = new Set(wl.whitelist);
if (uiPrefsRaw && typeof uiPrefsRaw === 'object') S.uiPrefs = uiPrefsRaw;
await loadAccounts();
await loadFolders();
@@ -33,8 +49,46 @@ async function init() {
}
const p = new URLSearchParams(location.search);
if (p.get('connected')) { toast('Account connected!', 'success'); history.replaceState({},'','/'); }
if (p.get('error')) { toast('Connection failed: '+p.get('error'), 'error'); history.replaceState({},'','/'); }
if (p.get('connected')) {
toast('Account connected! Loading…', 'success');
history.replaceState({},'','/');
// 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 poll = setInterval(async () => {
tries++;
await loadAccounts();
await loadFolders();
// 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);
toast('Account ready!', 'success');
}
}, 2500);
}
if (p.get('error')) { toast('Connection failed: '+p.get('error'), 'error'); history.replaceState({},'','/'); }
// Handle actions from full-page message/compose views
if (p.get('action') === 'reply' && p.get('id')) {
history.replaceState({},'','/');
const id = parseInt(p.get('id'));
// Load the message then open reply
setTimeout(async () => {
const msg = await api('GET', '/messages/'+id);
if (msg) { S.currentMessage = msg; openReplyTo(id); }
}, 500);
}
if (p.get('action') === 'forward' && p.get('id')) {
history.replaceState({},'','/');
const id = parseInt(p.get('id'));
setTimeout(async () => {
const msg = await api('GET', '/messages/'+id);
if (msg) { S.currentMessage = msg; openForward(); }
}, 500);
}
document.addEventListener('keydown', e => {
if (['INPUT','TEXTAREA','SELECT'].includes(e.target.tagName)) return;
@@ -45,11 +99,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'; }
@@ -79,12 +134,16 @@ function renderAccountsPopup() {
el.innerHTML = '<div style="font-size:12px;color:var(--muted);padding:8px 0">No accounts connected.</div>';
return;
}
el.innerHTML = S.accounts.map(a => `
<div class="acct-popup-item" title="${esc(a.email_address)}${a.last_error?' ⚠ '+esc(a.last_error):''}">
el.innerHTML = S.accounts.map(a => {
const hasWarning = a.last_error || a.token_expired;
const warningTitle = a.token_expired ? 'OAuth token expired — click Settings to reconnect' : (a.last_error ? '⚠ '+a.last_error : '');
return `
<div class="acct-popup-item" title="${esc(a.email_address)}${hasWarning?' — '+warningTitle:''}">
<div style="display:flex;align-items:center;gap:8px;flex:1;min-width:0">
<span class="account-dot" style="background:${a.color};flex-shrink:0"></span>
<span style="font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(a.display_name||a.email_address)}</span>
${a.last_error?'<span style="color:var(--danger);font-size:11px">⚠</span>':''}
${a.token_expired?'<span style="color:var(--danger);font-size:11px" title="OAuth token expired">🔑</span>':
a.last_error?'<span style="color:var(--danger);font-size:11px">⚠</span>':''}
</div>
<div style="display:flex;gap:4px;flex-shrink:0">
<button class="icon-btn" title="Sync now" onclick="syncNow(${a.id},event)">
@@ -97,7 +156,8 @@ function renderAccountsPopup() {
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
</button>
</div>
</div>`).join('');
</div>`;
}).join('');
}
// ── Accounts ───────────────────────────────────────────────────────────────
@@ -109,7 +169,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=''; });
@@ -184,13 +250,38 @@ async function openEditAccount(id) {
document.getElementById('edit-account-id').value=id;
document.getElementById('edit-account-email').textContent=r.email_address;
document.getElementById('edit-name').value=r.display_name||'';
document.getElementById('edit-password').value='';
document.getElementById('edit-imap-host').value=r.imap_host||'';
document.getElementById('edit-imap-port').value=r.imap_port||993;
document.getElementById('edit-smtp-host').value=r.smtp_host||'';
document.getElementById('edit-smtp-port').value=r.smtp_port||587;
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' : '';
document.getElementById('edit-test-btn').style.display = isOAuth ? 'none' : '';
const oauthSection = document.getElementById('edit-oauth-section');
if (oauthSection) oauthSection.style.display = isOAuth ? '' : 'none';
if (isOAuth) {
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');
if (lbl) lbl.textContent = providerLabel;
if (lblBtn) lblBtn.textContent = providerLabel;
if (expWarn) expWarn.style.display = r.token_expired ? '' : 'none';
const reconnectBtn = document.getElementById('edit-oauth-reconnect-btn');
if (reconnectBtn) reconnectBtn.onclick = () => {
closeModal('edit-account-modal');
connectOAuth(r.provider);
};
}
if (!isOAuth) {
document.getElementById('edit-password').value='';
document.getElementById('edit-imap-host').value=r.imap_host||'';
document.getElementById('edit-imap-port').value=r.imap_port||993;
document.getElementById('edit-smtp-host').value=r.smtp_host||'';
document.getElementById('edit-smtp-port').value=r.smtp_port||587;
}
document.getElementById('edit-sync-days').value=r.sync_days||30;
// Restore sync mode select: map stored days/mode back to a preset option
const sel = document.getElementById('edit-sync-mode');
if (r.sync_mode==='all' || !r.sync_days) {
sel.value='all';
@@ -199,12 +290,12 @@ async function openEditAccount(id) {
sel.value = presetMap[r.sync_days] || 'days';
}
toggleSyncDaysField();
const errEl=document.getElementById('edit-last-error'), connEl=document.getElementById('edit-conn-result');
connEl.style.display='none';
errEl.style.display=r.last_error?'block':'none';
if (r.last_error) errEl.textContent='Last sync error: '+r.last_error;
// Load hidden folders for this account
const hiddenEl = document.getElementById('edit-hidden-folders');
const hidden = S.folders.filter(f=>f.account_id===id && f.is_hidden);
if (!hidden.length) {
@@ -249,6 +340,10 @@ function toggleSyncDaysField() {
}
async function testEditConnection() {
// Only relevant for IMAP/SMTP accounts — OAuth accounts reconnect via the button
if (document.getElementById('edit-creds-section').style.display === 'none') {
return;
}
const btn=document.getElementById('edit-test-btn'), connEl=document.getElementById('edit-conn-result');
const pw=document.getElementById('edit-password').value, email=document.getElementById('edit-account-email').textContent.trim();
if (!pw){connEl.textContent='Enter new password to test.';connEl.className='test-result err';connEl.style.display='block';return;}
@@ -263,11 +358,16 @@ async function testEditConnection() {
async function saveAccountEdit() {
const id=document.getElementById('edit-account-id').value;
const body={display_name:document.getElementById('edit-name').value.trim(),
imap_host:document.getElementById('edit-imap-host').value.trim(),imap_port:parseInt(document.getElementById('edit-imap-port').value)||993,
smtp_host:document.getElementById('edit-smtp-host').value.trim(),smtp_port:parseInt(document.getElementById('edit-smtp-port').value)||587};
const pw=document.getElementById('edit-password').value;
if (pw) body.password=pw;
const isOAuth = document.getElementById('edit-creds-section').style.display === 'none';
const body={display_name:document.getElementById('edit-name').value.trim()};
if (!isOAuth) {
body.imap_host=document.getElementById('edit-imap-host').value.trim();
body.imap_port=parseInt(document.getElementById('edit-imap-port').value)||993;
body.smtp_host=document.getElementById('edit-smtp-host').value.trim();
body.smtp_port=parseInt(document.getElementById('edit-smtp-port').value)||587;
const pw=document.getElementById('edit-password').value;
if (pw) body.password=pw;
}
const modeVal = document.getElementById('edit-sync-mode').value;
let syncMode='all', syncDays=0;
if (modeVal==='days') {
@@ -331,32 +431,135 @@ const FOLDER_ICONS = {
};
function renderFolders() {
const el=document.getElementById('folders-by-account');
const accMap={}; S.accounts.forEach(a=>accMap[a.id]=a);
const byAcc={};
S.folders.filter(f=>!f.is_hidden).forEach(f=>{(byAcc[f.account_id]=byAcc[f.account_id]||[]).push(f);});
const prio=['inbox','sent','drafts','trash','spam','archive'];
el.innerHTML=Object.entries(byAcc).map(([accId,folders])=>{
const acc=accMap[parseInt(accId)];
const accColor = acc?.color || '#888';
const accEmail = acc?.email_address || 'Account '+accId;
if(!folders?.length) return '';
const sorted=[...prio.map(t=>folders.find(f=>f.folder_type===t)).filter(Boolean),...folders.filter(f=>f.folder_type==='custom')];
return `<div class="nav-folder-header">
<span style="width:6px;height:6px;border-radius:50%;background:${accColor};display:inline-block;flex-shrink:0"></span>
<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(accEmail)}</span>
<button class="icon-sync-btn" title="Sync account" onclick="syncNow(${parseInt(accId)},event)" style="margin-left:4px">
<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>`+sorted.map(f=>`
<div class="nav-item${f.sync_enabled?'':' folder-nosync'}" id="nav-f${f.id}" data-fid="${f.id}" onclick="selectFolder(${f.id},'${esc(f.name)}')"
const el = document.getElementById('folders-by-account');
const accMap = {}; S.accounts.forEach(a => accMap[a.id] = a);
const byAcc = {};
S.folders.filter(f => !f.is_hidden).forEach(f => {
(byAcc[f.account_id] = byAcc[f.account_id] || []).push(f);
});
const prio = ['inbox','sent','drafts','trash','spam','archive'];
const orderedAccounts = [...S.accounts].sort((a,b) => (a.sort_order||0) - (b.sort_order||0));
el.innerHTML = orderedAccounts.map(acc => {
const folders = byAcc[acc.id];
// 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">&#8942;</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 = [
...prio.map(t => folders.find(f => f.folder_type===t)).filter(Boolean),
...folders.filter(f => f.folder_type==='custom')
];
const totalUnread = folders.reduce((s,f) => s+(f.unread_count||0), 0);
const chevron = collapsed
? '<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>'
: '<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor"><path d="M7 10l5 5 5-5z"/></svg>';
const folderRows = collapsed ? '' : sorted.map(f => `
<div class="nav-item${f.sync_enabled?'':' folder-nosync'}" id="nav-f${f.id}"
data-fid="${f.id}" onclick="selectFolder(${f.id},'${esc(f.name)}')"
oncontextmenu="showFolderMenu(event,${f.id})">
<svg viewBox="0 0 24 24" fill="currentColor">${FOLDER_ICONS[f.folder_type]||FOLDER_ICONS.custom}</svg>
${esc(f.name)}
${f.unread_count>0?`<span class="unread-badge">${f.unread_count}</span>`:''}
${!f.sync_enabled?'<span style="font-size:9px;color:var(--muted);margin-left:auto" title="Sync disabled"></span>':''}
${!f.sync_enabled?'<span style="font-size:9px;color:var(--muted);margin-left:auto" title="Sync disabled">\u29b8</span>':''}
</div>`).join('');
return `<div class="nav-account-group" data-acc-id="${accId}"
draggable="true"
ondragstart="accDragStart(event,${accId})"
ondragover="accDragOver(event)"
ondragleave="accDragLeave(event)"
ondrop="accDrop(event,${accId})">
<div class="nav-folder-header" onclick="toggleAccountCollapse(${accId})">
<span class="acc-drag-handle" title="Drag to reorder" onclick="event.stopPropagation()">&#8942;</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>
${totalUnread>0&&collapsed?`<span class="unread-badge" style="margin-left:auto">${totalUnread}</span>`:''}
<button class="icon-sync-btn" title="Sync account" onclick="syncNow(${accId},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>
<span class="acc-chevron">${chevron}</span>
</div>
${folderRows}
</div>`;
}).join('');
// Re-wire drag-drop onto folder rows for message-to-folder moves
el.querySelectorAll('.nav-item[data-fid]').forEach(item => {
item.ondragover = e => { e.preventDefault(); item.classList.add('drag-over'); };
item.ondragleave = () => item.classList.remove('drag-over');
item.ondrop = e => {
e.preventDefault(); item.classList.remove('drag-over');
const fid = parseInt(item.dataset.fid);
const mid = parseInt(e.dataTransfer.getData('text/plain'));
if (mid && fid) moveMessage(mid, fid);
};
});
}
function toggleAccountCollapse(accId) {
setAccountCollapsed(accId, !isAccountCollapsed(accId));
renderFolders();
}
// ── Account drag-to-reorder ─────────────────────────────────────────────────
let _dragSrcAccId = null;
function accDragStart(e, accId) {
_dragSrcAccId = accId;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', String(accId));
setTimeout(() => e.currentTarget?.classList.add('acc-dragging'), 0);
}
function accDragOver(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const g = e.currentTarget;
if (g && parseInt(g.dataset.accId) !== _dragSrcAccId) g.classList.add('acc-drag-target');
}
function accDragLeave(e) { e.currentTarget?.classList.remove('acc-drag-target'); }
async function accDrop(e, targetAccId) {
e.preventDefault();
e.currentTarget?.classList.remove('acc-drag-target');
document.querySelectorAll('.acc-dragging').forEach(el => el.classList.remove('acc-dragging'));
if (_dragSrcAccId === null || _dragSrcAccId === targetAccId) { _dragSrcAccId = null; return; }
const ordered = [...S.accounts].sort((a,b) => (a.sort_order||0)-(b.sort_order||0));
const srcIdx = ordered.findIndex(a => a.id === _dragSrcAccId);
const dstIdx = ordered.findIndex(a => a.id === targetAccId);
if (srcIdx === -1 || dstIdx === -1) { _dragSrcAccId = null; return; }
const [moved] = ordered.splice(srcIdx, 1);
ordered.splice(dstIdx, 0, moved);
ordered.forEach((a, i) => { a.sort_order = i; });
S.accounts = ordered;
_dragSrcAccId = null;
renderFolders();
await api('PUT', '/accounts/sort-order', { order: ordered.map(a => a.id) });
}
function showFolderMenu(e, folderId) {
@@ -512,6 +715,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();
}
@@ -708,6 +913,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);
@@ -941,6 +1147,8 @@ function showMessageMenu(e, id) {
<div class="ctx-submenu">${moveItems}</div>
</div>` : '';
showCtxMenu(e,`
<div class="ctx-item" onclick="window.open('/message/${id}','_blank');closeMenu()">↗ Open in new tab</div>
<div class="ctx-sep"></div>
<div class="ctx-item" onclick="openReplyTo(${id});closeMenu()">↩ Reply</div>
<div class="ctx-item" onclick="toggleStar(${id});closeMenu()">${msg?.is_starred?'★ Unstar':'☆ Star'}</div>
<div class="ctx-item" onclick="markRead(${id},${msg?.is_read?'false':'true'});closeMenu()">${msg?.is_read?'Mark unread':'Mark read'}</div>
@@ -998,10 +1206,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)} &lt;${esc(a.email_address)}&gt;</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={}) {
@@ -1021,6 +1236,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();
@@ -1073,6 +1289,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>`,
});
@@ -1085,6 +1302,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>`,
});
@@ -1096,6 +1314,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:'',
});
@@ -1286,7 +1505,13 @@ async function sendMessage() {
}
btn.disabled=false; btn.textContent='Send';
if(r?.ok){ toast('Message sent!','success'); clearDraftAutosave(); _closeCompose(); }
if(r?.ok){
toast('Message sent!','success');
clearDraftAutosave();
_closeCompose();
// Refresh after a short delay so the syncer has time to pick up the sent message
setTimeout(async () => { await loadFolders(); await loadMessages(); }, 2500);
}
else toast(r?.error||'Send failed','error');
}
@@ -1559,7 +1784,7 @@ async function startPoller() {
function schedulePoll() {
if (!POLLER.active) return;
POLLER.timer = setTimeout(runPoll, 20000); // 20 second interval
POLLER.timer = setTimeout(runPoll, 10000); // 10 second interval
}
async function runPoll() {
@@ -1585,15 +1810,9 @@ async function runPoll() {
sendOSNotification(newMsgs);
}
// Refresh current view if we're looking at inbox/unified
const isInboxView = S.currentFolder === 'unified' ||
S.folders.find(f => f.id === S.currentFolder && f.folder_type === 'inbox');
if (isInboxView) {
await loadMessages();
await loadFolders();
} else {
await loadFolders(); // update counts in sidebar
}
// Always refresh the message list and folder counts when new mail arrives
await loadFolders();
await loadMessages();
}
} catch(e) {
// Network error — silent, retry next cycle
@@ -1696,3 +1915,76 @@ 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');
}
});
// ── Compose dropdown ────────────────────────────────────────────────────────
function toggleComposeDropdown(e) {
e.stopPropagation();
const dd = document.getElementById('compose-dropdown');
if (!dd) return;
const isOpen = dd.style.display !== 'none';
dd.style.display = isOpen ? 'none' : 'block';
if (!isOpen) {
// Close on next outside click
setTimeout(() => document.addEventListener('click', closeComposeDropdown, { once: true }), 0);
}
}
function closeComposeDropdown() {
const dd = document.getElementById('compose-dropdown');
if (dd) dd.style.display = 'none';
}

View File

@@ -0,0 +1,411 @@
// ── Contacts & Calendar ─────────────────────────────────────────────────────
let _currentView = 'mail';
// ======== VIEW SWITCHING ========
// Uses data-view attribute on #app-root to switch panels via CSS,
// avoiding direct style manipulation of elements that may not exist.
function _setView(view) {
_currentView = view;
// Update nav item active states
['nav-unified','nav-starred','nav-contacts','nav-calendar'].forEach(id => {
document.getElementById(id)?.classList.remove('active');
});
// Show/hide panels
const mail1 = document.getElementById('message-list-panel');
const mail2 = document.getElementById('message-detail');
const contacts = document.getElementById('contacts-panel');
const calendar = document.getElementById('calendar-panel');
if (mail1) mail1.style.display = view === 'mail' ? '' : 'none';
if (mail2) mail2.style.display = view === 'mail' ? '' : 'none';
if (contacts) contacts.style.display = view === 'contacts' ? 'flex' : 'none';
if (calendar) calendar.style.display = view === 'calendar' ? 'flex' : 'none';
}
function showMail() {
_setView('mail');
document.getElementById('nav-unified')?.classList.add('active');
}
function showContacts() {
_setView('contacts');
document.getElementById('nav-contacts')?.classList.add('active');
if (typeof mobCloseNav === 'function') { mobCloseNav(); mobSetView('list'); }
loadContacts();
}
function showCalendar() {
_setView('calendar');
document.getElementById('nav-calendar')?.classList.add('active');
if (typeof mobCloseNav === 'function') { mobCloseNav(); mobSetView('list'); }
calRender();
}
// Patch selectFolder — called from app.js sidebar click handlers.
// When a mail folder is clicked while contacts/calendar is showing, switch back to mail first.
// Avoids infinite recursion by checking _currentView before doing anything.
(function() {
const _orig = window.selectFolder;
window.selectFolder = function(folderId, folderName) {
if (_currentView !== 'mail') {
showMail();
// Give the DOM a tick to re-show the mail panels before loading
setTimeout(function() {
_orig && _orig(folderId, folderName);
}, 10);
return;
}
_orig && _orig(folderId, folderName);
};
})();
// ======== CONTACTS ========
let _contacts = [];
let _editingContactId = null;
async function loadContacts() {
const data = await api('GET', '/contacts');
_contacts = data || [];
renderContacts(_contacts);
}
function renderContacts(list) {
const el = document.getElementById('contacts-list');
if (!el) return;
if (!list || list.length === 0) {
el.innerHTML = `<div style="text-align:center;padding:60px 20px;color:var(--muted)">
<svg viewBox="0 0 24 24" width="48" height="48" fill="currentColor" style="opacity:.25;margin-bottom:12px;display:block;margin:0 auto 12px"><path d="M20 0H4v2h16V0zM0 4v18h24V4H0zm22 16H2V6h20v14zM12 11c1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3 1.34 3 3 3zm-6 6c0-2.21 2.69-4 6-4s6 1.79 6 4H6z"/></svg>
<p>No contacts yet. Click "+ New Contact" to add one.</p>
</div>`;
return;
}
el.innerHTML = list.map(c => {
const initials = (c.display_name || c.email || '?').split(' ').map(w => w[0]).join('').substring(0,2).toUpperCase();
const color = c.avatar_color || '#6b7280';
const meta = [c.email, c.company].filter(Boolean).join(' · ');
return `<div class="contact-card" onclick="openContactForm(${c.id})">
<div class="contact-avatar" style="background:${esc(color)}">${esc(initials)}</div>
<div class="contact-info">
<div class="contact-name">${esc(c.display_name || c.email)}</div>
<div class="contact-meta">${esc(meta)}</div>
</div>
<button class="btn-secondary" style="font-size:11px;padding:4px 8px" onclick="event.stopPropagation();composeToContact('${esc(c.email)}')">Mail</button>
</div>`;
}).join('');
}
function filterContacts(q) {
if (!q) { renderContacts(_contacts); return; }
const lower = q.toLowerCase();
renderContacts(_contacts.filter(c =>
(c.display_name||'').toLowerCase().includes(lower) ||
(c.email||'').toLowerCase().includes(lower) ||
(c.company||'').toLowerCase().includes(lower)
));
}
function composeToContact(email) {
showMail();
setTimeout(() => {
if (typeof openCompose === 'function') openCompose();
setTimeout(() => { if (typeof addTag === 'function') addTag('compose-to', email); }, 100);
}, 50);
}
function openContactForm(id) {
_editingContactId = id || null;
const delBtn = document.getElementById('cf-delete-btn');
if (id) {
document.getElementById('contact-modal-title').textContent = 'Edit Contact';
if (delBtn) delBtn.style.display = '';
const c = _contacts.find(x => x.id === id);
if (c) {
document.getElementById('cf-name').value = c.display_name || '';
document.getElementById('cf-email').value = c.email || '';
document.getElementById('cf-phone').value = c.phone || '';
document.getElementById('cf-company').value = c.company || '';
document.getElementById('cf-notes').value = c.notes || '';
}
} else {
document.getElementById('contact-modal-title').textContent = 'New Contact';
if (delBtn) delBtn.style.display = 'none';
['cf-name','cf-email','cf-phone','cf-company','cf-notes'].forEach(id => {
const el = document.getElementById(id); if (el) el.value = '';
});
}
openModal('contact-modal');
}
async function saveContact() {
const body = {
display_name: document.getElementById('cf-name').value.trim(),
email: document.getElementById('cf-email').value.trim(),
phone: document.getElementById('cf-phone').value.trim(),
company: document.getElementById('cf-company').value.trim(),
notes: document.getElementById('cf-notes').value.trim(),
};
if (!body.display_name && !body.email) { toast('Name or email is required','error'); return; }
if (_editingContactId) {
await api('PUT', `/contacts/${_editingContactId}`, body);
} else {
await api('POST', '/contacts', body);
}
closeModal('contact-modal');
await loadContacts();
toast(_editingContactId ? 'Contact updated' : 'Contact saved', 'success');
}
async function deleteContact() {
if (!_editingContactId) return;
if (!confirm('Delete this contact?')) return;
await api('DELETE', `/contacts/${_editingContactId}`);
closeModal('contact-modal');
await loadContacts();
toast('Contact deleted', 'success');
}
// ======== CALENDAR ========
const CAL = {
view: 'month',
cursor: new Date(),
events: [],
};
function calSetView(v) {
CAL.view = v;
document.getElementById('cal-btn-month')?.classList.toggle('active', v === 'month');
document.getElementById('cal-btn-week')?.classList.toggle('active', v === 'week');
calRender();
}
function calNav(dir) {
if (CAL.view === 'month') {
CAL.cursor = new Date(CAL.cursor.getFullYear(), CAL.cursor.getMonth() + dir, 1);
} else {
CAL.cursor = new Date(CAL.cursor.getTime() + dir * 7 * 86400000);
}
calRender();
}
function calGoToday() { CAL.cursor = new Date(); calRender(); }
async function calRender() {
const gridEl = document.getElementById('cal-grid');
if (!gridEl) return;
let from, to;
if (CAL.view === 'month') {
from = new Date(CAL.cursor.getFullYear(), CAL.cursor.getMonth(), 1);
to = new Date(CAL.cursor.getFullYear(), CAL.cursor.getMonth() + 1, 0);
from = new Date(from.getTime() - from.getDay() * 86400000);
to = new Date(to.getTime() + (6 - to.getDay()) * 86400000);
} else {
const dow = CAL.cursor.getDay();
from = new Date(CAL.cursor.getTime() - dow * 86400000);
to = new Date(from.getTime() + 6 * 86400000);
}
const fmt = d => d.toISOString().split('T')[0];
const data = await api('GET', `/calendar/events?from=${fmt(from)}&to=${fmt(to)}`);
CAL.events = data || [];
const months = ['January','February','March','April','May','June','July','August','September','October','November','December'];
const titleEl = document.getElementById('cal-title');
if (CAL.view === 'month') {
if (titleEl) titleEl.textContent = `${months[CAL.cursor.getMonth()]} ${CAL.cursor.getFullYear()}`;
calRenderMonth(from, to);
} else {
if (titleEl) titleEl.textContent = `${months[from.getMonth()]} ${from.getDate()} ${months[to.getMonth()]} ${to.getDate()}, ${to.getFullYear()}`;
calRenderWeek(from);
}
}
function calRenderMonth(from, to) {
const days = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
const today = new Date(); today.setHours(0,0,0,0);
let html = `<div class="cal-grid-month">`;
days.forEach(d => html += `<div class="cal-day-header">${d}</div>`);
const cur = new Date(from);
const curMonth = CAL.cursor.getMonth();
while (cur <= to) {
const dateStr = cur.toISOString().split('T')[0];
const isToday = cur.getTime() === today.getTime();
const isOther = cur.getMonth() !== curMonth;
const dayEvents = CAL.events.filter(e => e.start_time && e.start_time.startsWith(dateStr));
const shown = dayEvents.slice(0, 3);
const more = dayEvents.length - 3;
html += `<div class="cal-day${isToday?' today':''}${isOther?' other-month':''}" data-date="${dateStr}">
<div class="cal-day-num" onclick="openEventForm(null,'${dateStr}T09:00')">${cur.getDate()}</div>
${shown.map(ev=>`<div class="cal-event" style="background:${ev.color||'#0078D4'}"
onclick="openEventForm(${ev.id})" title="${esc(ev.title)}">${esc(ev.title)}</div>`).join('')}
${more>0?`<div class="cal-more" onclick="openEventForm(null,'${dateStr}T09:00')">+${more} more</div>`:''}
</div>`;
cur.setDate(cur.getDate() + 1);
}
html += `</div>`;
document.getElementById('cal-grid').innerHTML = html;
}
function calRenderWeek(weekStart) {
const days = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
const today = new Date(); today.setHours(0,0,0,0);
let html = `<div class="cal-week-grid">`;
html += `<div class="cal-week-header" style="background:var(--surface)"></div>`;
for (let i=0;i<7;i++) {
const d = new Date(weekStart.getTime()+i*86400000);
const isT = d.getTime()===today.getTime();
html += `<div class="cal-week-header${isT?' today-col':''}">${days[d.getDay()]} ${d.getDate()}</div>`;
}
for (let h=0;h<24;h++) {
const label = h===0?'12am':h<12?`${h}am`:h===12?'12pm':`${h-12}pm`;
html += `<div class="cal-time-col">${label}</div>`;
for (let i=0;i<7;i++) {
const d = new Date(weekStart.getTime()+i*86400000);
const dateStr = d.toISOString().split('T')[0];
const slotEvs = CAL.events.filter(ev => {
if (!ev.start_time) return false;
return ev.start_time.startsWith(dateStr) &&
parseInt((ev.start_time.split('T')[1]||'').split(':')[0]||'0') === h;
});
const isT = d.getTime()===today.getTime();
html += `<div class="cal-week-cell${isT?' today':''}"
onclick="openEventForm(null,'${dateStr}T${String(h).padStart(2,'0')}:00')">
${slotEvs.map(ev=>`<div class="cal-event" style="background:${ev.color||'#0078D4'};font-size:10px;position:absolute;left:2px;right:2px;z-index:1"
onclick="event.stopPropagation();openEventForm(${ev.id})">${esc(ev.title)}</div>`).join('')}
</div>`;
}
}
html += `</div>`;
document.getElementById('cal-grid').innerHTML = html;
}
// ======== EVENT FORM ========
let _editingEventId = null;
let _selectedEvColor = '#0078D4';
function selectEvColor(el) {
_selectedEvColor = el.dataset.color;
document.querySelectorAll('#ev-colors span').forEach(s => s.style.borderColor = 'transparent');
el.style.borderColor = 'white';
}
function openEventForm(id, defaultStart) {
_editingEventId = id || null;
const delBtn = document.getElementById('ev-delete-btn');
_selectedEvColor = '#0078D4';
document.querySelectorAll('#ev-colors span').forEach((s,i) => s.style.borderColor = i===0?'white':'transparent');
if (id) {
document.getElementById('event-modal-title').textContent = 'Edit Event';
if (delBtn) delBtn.style.display = '';
const ev = CAL.events.find(e => e.id === id);
if (ev) {
document.getElementById('ev-title').value = ev.title||'';
document.getElementById('ev-start').value = (ev.start_time||'').replace(' ','T').substring(0,16);
document.getElementById('ev-end').value = (ev.end_time||'').replace(' ','T').substring(0,16);
document.getElementById('ev-allday').checked = !!ev.all_day;
document.getElementById('ev-location').value = ev.location||'';
document.getElementById('ev-desc').value = ev.description||'';
_selectedEvColor = ev.color||'#0078D4';
document.querySelectorAll('#ev-colors span').forEach(s => {
s.style.borderColor = s.dataset.color===_selectedEvColor ? 'white' : 'transparent';
});
}
} else {
document.getElementById('event-modal-title').textContent = 'New Event';
if (delBtn) delBtn.style.display = 'none';
document.getElementById('ev-title').value = '';
const start = defaultStart || new Date().toISOString().substring(0,16);
document.getElementById('ev-start').value = start;
const endDate = new Date(start); endDate.setHours(endDate.getHours()+1);
document.getElementById('ev-end').value = endDate.toISOString().substring(0,16);
document.getElementById('ev-allday').checked = false;
document.getElementById('ev-location').value = '';
document.getElementById('ev-desc').value = '';
}
openModal('event-modal');
}
async function saveEvent() {
const title = document.getElementById('ev-title').value.trim();
if (!title) { toast('Title is required','error'); return; }
const body = {
title,
start_time: document.getElementById('ev-start').value.replace('T',' '),
end_time: document.getElementById('ev-end').value.replace('T',' '),
all_day: document.getElementById('ev-allday').checked,
location: document.getElementById('ev-location').value.trim(),
description:document.getElementById('ev-desc').value.trim(),
color: _selectedEvColor,
status: 'confirmed',
};
if (_editingEventId) {
await api('PUT', `/calendar/events/${_editingEventId}`, body);
} else {
await api('POST', '/calendar/events', body);
}
closeModal('event-modal');
await calRender();
toast(_editingEventId ? 'Event updated' : 'Event created', 'success');
}
async function deleteEvent() {
if (!_editingEventId) return;
if (!confirm('Delete this event?')) return;
await api('DELETE', `/calendar/events/${_editingEventId}`);
closeModal('event-modal');
await calRender();
toast('Event deleted', 'success');
}
// ======== CALDAV ========
async function showCalDAVSettings() {
openModal('caldav-modal');
await loadCalDAVTokens();
}
async function loadCalDAVTokens() {
const tokens = await api('GET', '/caldav/tokens') || [];
const el = document.getElementById('caldav-tokens-list');
if (!el) return;
if (!tokens.length) {
el.innerHTML = '<p style="font-size:13px;color:var(--muted)">No tokens yet.</p>';
return;
}
el.innerHTML = tokens.map(t => {
const url = `${location.origin}/caldav/${t.token}/calendar.ics`;
return `<div class="caldav-token-row">
<div style="flex:1;min-width:0">
<div style="font-size:13px;font-weight:500">${esc(t.label)}</div>
<div class="caldav-token-url" onclick="copyCalDAVUrl('${url}')" title="Click to copy">${url}</div>
<div style="font-size:11px;color:var(--muted)">Created: ${t.created_at}${t.last_used?' · Last used: '+t.last_used:''}</div>
</div>
<button class="icon-btn" onclick="revokeCalDAVToken(${t.id})" title="Revoke" style="color:var(--danger);flex-shrink:0">
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
</button>
</div>`;
}).join('');
}
async function createCalDAVToken() {
const label = document.getElementById('caldav-label').value.trim() || 'CalDAV token';
await api('POST', '/caldav/tokens', { label });
document.getElementById('caldav-label').value = '';
await loadCalDAVTokens();
toast('Token created', 'success');
}
async function revokeCalDAVToken(id) {
if (!confirm('Revoke this token?')) return;
await api('DELETE', `/caldav/tokens/${id}`);
await loadCalDAVTokens();
toast('Token revoked', 'success');
}
function copyCalDAVUrl(url) {
navigator.clipboard.writeText(url).then(() => toast('URL copied','success'));
}

View File

@@ -39,5 +39,5 @@
{{end}}
{{define "scripts"}}
<script src="/static/js/admin.js?v=23"></script>
<script src="/static/js/admin.js?v=25"></script>
{{end}}

View File

@@ -3,7 +3,20 @@
{{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>
<button class="compose-btn" onclick="window.open('/compose','_blank')" style="padding:5px 8px;font-size:11px" title="Compose in new tab"></button>
</div>
<!-- Sidebar -->
<aside class="sidebar">
<div class="sidebar-header">
@@ -11,7 +24,16 @@
<div class="logo-icon"><svg viewBox="0 0 24 24"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/></svg></div>
<span class="logo-text"><a href="/">GoWebMail</a></span>
</div>
<button class="compose-btn" onclick="openCompose()">+ New</button>
<div style="position:relative;display:inline-flex">
<button class="compose-btn" onclick="openCompose()" style="border-radius:6px 0 0 6px">+ New</button>
<button class="compose-btn" onclick="toggleComposeDropdown(event)" style="border-radius:0 6px 6px 0;border-left:1px solid rgba(255,255,255,.25);padding:6px 7px" title="More options">
<svg viewBox="0 0 24 24" width="10" height="10" fill="white"><path d="M7 10l5 5 5-5z"/></svg>
</button>
<div id="compose-dropdown" style="display:none;position:absolute;top:100%;left:0;margin-top:4px;background:var(--surface);border:1px solid var(--border2);border-radius:7px;box-shadow:0 4px 16px rgba(0,0,0,.2);z-index:200;min-width:200px;overflow:hidden">
<div class="ctx-item" onclick="openCompose();closeComposeDropdown()">✉ New message</div>
<div class="ctx-item" onclick="window.open('/compose','_blank');closeComposeDropdown()">↗ New message in new tab</div>
</div>
</div>
</div>
<div class="nav-section">
@@ -24,6 +46,14 @@
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/></svg>
Starred
</div>
<div class="nav-item" id="nav-contacts" onclick="showContacts()">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20 0H4v2h16V0zM0 4v18h24V4H0zm22 16H2V6h20v14zM12 11c1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3 1.34 3 3 3zm-6 6c0-2.21 2.69-4 6-4s6 1.79 6 4H6z"/></svg>
Contacts
</div>
<div class="nav-item" id="nav-calendar" onclick="showCalendar()">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20 3h-1V1h-2v2H7V1H5v2H4c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 18H4V8h16v13z"/></svg>
Calendar
</div>
<div id="folders-by-account"></div>
</div>
@@ -45,6 +75,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">
@@ -89,6 +121,105 @@
<p>Choose a message from the list to read it</p>
</div>
</main>
<!-- ── Contacts panel ──────────────────────────────────────────────────── -->
<div id="contacts-panel" style="display:none;flex:1;flex-direction:column;overflow:hidden;background:var(--bg)">
<div class="panel-header" style="padding:14px 18px 10px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;flex-shrink:0">
<span style="font-family:'DM Serif Display',serif;font-size:17px;flex:1">Contacts</span>
<input id="contacts-search" type="search" placeholder="Search contacts…" oninput="filterContacts(this.value)"
style="padding:5px 10px;border:1px solid var(--border2);border-radius:6px;background:var(--surface3);color:var(--text);font-size:13px;width:200px">
<button class="btn-secondary" onclick="openContactForm()" style="font-size:12px">+ New Contact</button>
</div>
<div id="contacts-list" style="flex:1;overflow-y:auto;padding:12px"></div>
</div>
<!-- ── Calendar panel ──────────────────────────────────────────────────── -->
<div id="calendar-panel" style="display:none;flex:1;flex-direction:column;overflow:hidden;background:var(--bg)">
<div style="padding:12px 18px 10px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:8px;flex-shrink:0">
<button class="icon-btn" onclick="calNav(-1)" title="Previous">&#8249;</button>
<span id="cal-title" style="font-family:'DM Serif Display',serif;font-size:17px;min-width:200px;text-align:center"></span>
<button class="icon-btn" onclick="calNav(1)" title="Next">&#8250;</button>
<button class="btn-secondary" onclick="calGoToday()" style="font-size:12px;margin-left:4px">Today</button>
<div style="margin-left:auto;display:flex;gap:4px">
<button class="btn-secondary" id="cal-btn-month" onclick="calSetView('month')" style="font-size:12px">Month</button>
<button class="btn-secondary" id="cal-btn-week" onclick="calSetView('week')" style="font-size:12px">Week</button>
<button class="btn-secondary" onclick="openEventForm()" style="font-size:12px;background:var(--accent);color:white;border-color:var(--accent)">+ Event</button>
<button class="icon-btn" onclick="showCalDAVSettings()" title="CalDAV / sharing">
<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z"/></svg>
</button>
</div>
</div>
<div id="cal-grid" style="flex:1;overflow-y:auto"></div>
</div>
</div>
<!-- ── Contact form modal ──────────────────────────────────────────────────── -->
<div class="modal-overlay" id="contact-modal">
<div class="modal" style="max-width:480px">
<h2 id="contact-modal-title">New Contact</h2>
<div class="modal-field"><label>Name</label><input id="cf-name" type="text" placeholder="Full name"></div>
<div class="modal-field"><label>Email</label><input id="cf-email" type="email" placeholder="email@example.com"></div>
<div class="modal-field"><label>Phone</label><input id="cf-phone" type="tel" placeholder="+1 555 000 0000"></div>
<div class="modal-field"><label>Company</label><input id="cf-company" type="text" placeholder="Company name"></div>
<div class="modal-field"><label>Notes</label><textarea id="cf-notes" rows="3" style="width:100%;resize:vertical;padding:8px;background:var(--surface3);border:1px solid var(--border2);border-radius:6px;color:var(--text);font-family:'DM Sans',sans-serif;font-size:13px"></textarea></div>
<div class="modal-actions">
<button class="modal-cancel" onclick="closeModal('contact-modal')">Cancel</button>
<button id="cf-delete-btn" class="btn-secondary" style="color:var(--danger);display:none" onclick="deleteContact()">Delete</button>
<button class="modal-submit" onclick="saveContact()">Save</button>
</div>
</div>
</div>
<!-- ── Event form modal ──────────────────────────────────────────────────── -->
<div class="modal-overlay" id="event-modal">
<div class="modal" style="max-width:520px">
<h2 id="event-modal-title">New Event</h2>
<div class="modal-field"><label>Title</label><input id="ev-title" type="text" placeholder="Event title"></div>
<div class="modal-row">
<div class="modal-field"><label>Start</label><input id="ev-start" type="datetime-local"></div>
<div class="modal-field"><label>End</label><input id="ev-end" type="datetime-local"></div>
</div>
<div class="modal-field" style="flex-direction:row;align-items:center;gap:8px">
<input id="ev-allday" type="checkbox" style="width:auto">
<label for="ev-allday" style="font-weight:normal;color:var(--text2)">All day</label>
</div>
<div class="modal-field"><label>Location</label><input id="ev-location" type="text" placeholder="Location or video link"></div>
<div class="modal-field"><label>Description</label><textarea id="ev-desc" rows="3" style="width:100%;resize:vertical;padding:8px;background:var(--surface3);border:1px solid var(--border2);border-radius:6px;color:var(--text);font-family:'DM Sans',sans-serif;font-size:13px"></textarea></div>
<div class="modal-field"><label>Color</label>
<div style="display:flex;gap:6px" id="ev-colors">
<span data-color="#0078D4" onclick="selectEvColor(this)" style="width:22px;height:22px;border-radius:50%;background:#0078D4;cursor:pointer;border:2px solid transparent"></span>
<span data-color="#EA4335" onclick="selectEvColor(this)" style="width:22px;height:22px;border-radius:50%;background:#EA4335;cursor:pointer;border:2px solid transparent"></span>
<span data-color="#34A853" onclick="selectEvColor(this)" style="width:22px;height:22px;border-radius:50%;background:#34A853;cursor:pointer;border:2px solid transparent"></span>
<span data-color="#FBBC04" onclick="selectEvColor(this)" style="width:22px;height:22px;border-radius:50%;background:#FBBC04;cursor:pointer;border:2px solid transparent"></span>
<span data-color="#9C27B0" onclick="selectEvColor(this)" style="width:22px;height:22px;border-radius:50%;background:#9C27B0;cursor:pointer;border:2px solid transparent"></span>
<span data-color="#FF6D00" onclick="selectEvColor(this)" style="width:22px;height:22px;border-radius:50%;background:#FF6D00;cursor:pointer;border:2px solid transparent"></span>
</div>
</div>
<div class="modal-actions">
<button class="modal-cancel" onclick="closeModal('event-modal')">Cancel</button>
<button id="ev-delete-btn" class="btn-secondary" style="color:var(--danger);display:none" onclick="deleteEvent()">Delete</button>
<button class="modal-submit" onclick="saveEvent()">Save</button>
</div>
</div>
</div>
<!-- ── CalDAV settings modal ──────────────────────────────────────────────── -->
<div class="modal-overlay" id="caldav-modal">
<div class="modal" style="max-width:560px">
<h2>CalDAV / Calendar Sharing</h2>
<p style="font-size:13px;color:var(--text2);margin-bottom:14px">
Subscribe to your GoWebMail calendar from any CalDAV client (Apple Calendar, Thunderbird, etc.) using a token URL. Tokens give read-only calendar access — no password needed.
</p>
<div id="caldav-tokens-list" style="margin-bottom:14px"></div>
<div style="display:flex;gap:8px;align-items:center">
<input id="caldav-label" type="text" placeholder="Token label (e.g. iPhone)" style="flex:1;padding:7px 10px;background:var(--surface3);border:1px solid var(--border2);border-radius:6px;color:var(--text);font-size:13px">
<button class="btn-secondary" onclick="createCalDAVToken()" style="white-space:nowrap">Generate Token</button>
</div>
<div class="modal-actions" style="margin-top:16px">
<button class="modal-cancel" onclick="closeModal('caldav-modal')">Close</button>
</div>
</div>
</div>
<!-- ── Accounts submenu popup ──────────────────────────────────────────────── -->
@@ -179,12 +310,27 @@
<p>Connect Gmail or Outlook via OAuth, or any email via IMAP/SMTP.</p>
<div class="provider-btns">
<button class="provider-btn" id="btn-gmail" onclick="connectOAuth('gmail')">
<svg viewBox="0 0 24 24" width="18" height="18"><path fill="#EA4335" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/><path fill="#4285F4" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/><path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg>
<svg viewBox="0 0 24 24" width="20" height="20"><path fill="#EA4335" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/><path fill="#4285F4" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/><path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg>
Gmail
</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 icon -->
<svg viewBox="0 0 24 24" width="20" height="20" xmlns="http://www.w3.org/2000/svg">
<path fill="#EA3E23" d="M11.4 4H4v7.4h7.4V4z"/>
<path fill="#0364B8" d="M11.4 12.6H4V20h7.4v-7.4z"/>
<path fill="#0078D4" d="M20 4h-7.4v7.4H20V4z"/>
<path fill="#28A8E8" d="M20 12.6h-7.4V20H20v-7.4z"/>
</svg>
Microsoft 365
</button>
<button class="provider-btn" id="btn-outlook-personal" onclick="connectOAuth('outlook_personal')">
<!-- Outlook icon (blue envelope) -->
<svg viewBox="0 0 24 24" width="20" height="20" xmlns="http://www.w3.org/2000/svg">
<rect width="24" height="24" rx="3" fill="#0078D4"/>
<path fill="white" d="M6 7h12v10H6z" opacity=".2"/>
<path fill="white" d="M6 7l6 5 6-5H6zm0 1.5V17h12V8.5l-6 5-6-5z"/>
</svg>
Outlook Personal
</button>
</div>
<div class="modal-divider"><span>or add IMAP account</span></div>
@@ -224,15 +370,31 @@
<p id="edit-account-email" style="font-weight:500;color:var(--text);margin-bottom:16px"></p>
<input type="hidden" id="edit-account-id">
<div class="modal-field"><label>Display Name</label><input type="text" id="edit-name"></div>
<div class="modal-field"><label>New Password (leave blank to keep current)</label><input type="password" id="edit-password"></div>
<div class="modal-row">
<div class="modal-field"><label>IMAP Host</label><input type="text" id="edit-imap-host"></div>
<div class="modal-field"><label>IMAP Port</label><input type="number" id="edit-imap-port"></div>
<!-- OAuth reconnect — shown only for gmail/outlook accounts -->
<div id="edit-oauth-section" style="display:none">
<div id="edit-oauth-expired-warning" style="display:none;background:rgba(239,68,68,.12);border:1px solid rgba(239,68,68,.35);border-radius:8px;padding:10px 14px;margin-bottom:10px;font-size:13px;color:#f87171">
⚠️ Access token has expired — sync and send will fail until you reconnect.
</div>
<div style="background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:14px 16px;margin-bottom:4px">
<div style="font-size:13px;color:var(--muted);margin-bottom:10px">This account connects via <strong id="edit-oauth-provider-label"></strong> OAuth. To update permissions or fix an expired token, reconnect below.</div>
<button class="btn-secondary" id="edit-oauth-reconnect-btn" style="width:100%">🔗 Reconnect with <span id="edit-oauth-provider-label-btn"></span></button>
</div>
</div>
<div class="modal-row">
<div class="modal-field"><label>SMTP Host</label><input type="text" id="edit-smtp-host"></div>
<div class="modal-field"><label>SMTP Port</label><input type="number" id="edit-smtp-port"></div>
<!-- IMAP/SMTP credentials (hidden for OAuth accounts) -->
<div id="edit-creds-section">
<div class="modal-field"><label>New Password (leave blank to keep current)</label><input type="password" id="edit-password"></div>
<div class="modal-row">
<div class="modal-field"><label>IMAP Host</label><input type="text" id="edit-imap-host"></div>
<div class="modal-field"><label>IMAP Port</label><input type="number" id="edit-imap-port"></div>
</div>
<div class="modal-row">
<div class="modal-field"><label>SMTP Host</label><input type="text" id="edit-smtp-host"></div>
<div class="modal-field"><label>SMTP Port</label><input type="number" id="edit-smtp-port"></div>
</div>
</div>
<div class="settings-group-title" style="margin:16px 0 8px">Sync Settings</div>
<div class="modal-field">
<label>Email history to sync</label>
@@ -352,5 +514,6 @@
{{end}}
{{define "scripts"}}
<script src="/static/js/app.js?v=23"></script>
<script src="/static/js/app.js?v=58"></script>
<script src="/static/js/contacts_calendar.js?v=58"></script>
{{end}}

View File

@@ -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=23">
<link rel="stylesheet" href="/static/css/gowebmail.css?v=58">
{{block "head_extra" .}}{{end}}
</head>
<body class="{{block "body_class" .}}{{end}}">
{{block "body" .}}{{end}}
<script src="/static/js/gowebmail.js?v=23"></script>
<script src="/static/js/gowebmail.js?v=58"></script>
{{block "scripts" .}}{{end}}
</body>
</html>

220
web/templates/compose.html Normal file
View File

@@ -0,0 +1,220 @@
{{template "base" .}}
{{define "title"}}Compose — GoWebMail{{end}}
{{define "body_class"}}app-page{{end}}
{{define "body"}}
<div id="compose-page" style="max-width:860px;margin:0 auto;padding:20px 16px;min-height:100vh">
<div style="display:flex;align-items:center;gap:12px;margin-bottom:18px;padding-bottom:14px;border-bottom:1px solid var(--border)">
<a href="/" style="color:var(--accent);text-decoration:none;font-size:13px;display:flex;align-items:center;gap:4px">
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
Back to GoWebMail
</a>
<span style="color:var(--border);font-size:16px">|</span>
<span id="compose-page-title" style="font-size:14px;color:var(--text2)">New Message</span>
<div style="margin-left:auto;display:flex;gap:6px">
<button class="btn-secondary" id="save-draft-btn" onclick="saveDraft()" style="font-size:12px">Save Draft</button>
<button class="modal-submit" id="send-page-btn" onclick="sendFromPage()" style="font-size:13px;padding:7px 18px">Send</button>
</div>
</div>
<div id="compose-page-form">
<!-- From -->
<div style="display:flex;align-items:center;border-bottom:1px solid var(--border);padding:8px 0;gap:8px">
<span style="font-size:12px;color:var(--muted);width:48px;flex-shrink:0">From</span>
<select id="cp-from" style="flex:1;background:transparent;border:none;color:var(--text);font-size:13px;outline:none;cursor:pointer"></select>
</div>
<!-- To -->
<div style="display:flex;align-items:flex-start;border-bottom:1px solid var(--border);padding:8px 0;gap:8px">
<span style="font-size:12px;color:var(--muted);width:48px;flex-shrink:0;padding-top:6px">To</span>
<div id="cp-to-tags" class="tag-field" style="flex:1;min-height:30px"></div>
</div>
<!-- CC -->
<div style="display:flex;align-items:flex-start;border-bottom:1px solid var(--border);padding:8px 0;gap:8px">
<span style="font-size:12px;color:var(--muted);width:48px;flex-shrink:0;padding-top:6px">CC</span>
<div id="cp-cc-tags" class="tag-field" style="flex:1;min-height:30px"></div>
</div>
<!-- Subject -->
<div style="display:flex;align-items:center;border-bottom:1px solid var(--border);padding:8px 0;gap:8px">
<span style="font-size:12px;color:var(--muted);width:48px;flex-shrink:0">Subject</span>
<input id="cp-subject" type="text" placeholder="Subject" style="flex:1;background:transparent;border:none;color:var(--text);font-size:14px;outline:none;font-family:'DM Sans',sans-serif">
</div>
<!-- Body -->
<div id="cp-editor" contenteditable="true" style="min-height:400px;padding:16px 0;outline:none;font-size:14px;line-height:1.6;color:var(--text)" data-placeholder="Write your message…"></div>
<!-- Attachments -->
<div style="border-top:1px solid var(--border);padding:10px 0;display:flex;align-items:center;gap:8px;flex-wrap:wrap">
<label style="cursor:pointer;font-size:12px;color:var(--muted);display:flex;align-items:center;gap:4px">
<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M16.5 6v11.5c0 2.21-1.79 4-4 4s-4-1.79-4-4V5c0-1.38 1.12-2.5 2.5-2.5s2.5 1.12 2.5 2.5v10.5c0 .55-.45 1-1 1s-1-.45-1-1V6H10v9.5c0 1.38 1.12 2.5 2.5 2.5s2.5-1.12 2.5-2.5V5c0-2.21-1.79-4-4-4S7 2.79 7 5v12.5c0 3.04 2.46 5.5 5.5 5.5s5.5-2.46 5.5-5.5V6h-1.5z"/></svg>
Attach file
<input type="file" multiple style="display:none" onchange="addPageAttachments(this.files)">
</label>
<div id="cp-att-list" style="display:flex;flex-wrap:wrap;gap:6px"></div>
</div>
</div>
<div id="cp-status" style="font-size:13px;color:var(--muted);margin-top:8px"></div>
</div>
{{end}}
{{define "scripts"}}
<script>
// Parse URL params
const params = new URLSearchParams(location.search);
const replyId = parseInt(params.get('reply_id') || '0');
const forwardId = parseInt(params.get('forward_id') || '0');
const cpAttachments = [];
async function apiCall(method, path, body) {
const opts = { method, headers: {} };
if (body instanceof FormData) { opts.body = body; }
else if (body) { opts.body = JSON.stringify(body); opts.headers['Content-Type'] = 'application/json'; }
const r = await fetch('/api' + path, opts);
return r.ok ? r.json() : null;
}
function esc(s) { return (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
// Tag field (simple comma/enter separated)
function initTagField(id) {
const el = document.getElementById(id);
if (!el) return;
el.innerHTML = '<input class="tag-input" type="email" multiple style="border:none;background:transparent;outline:none;color:var(--text);font-size:13px;min-width:180px;font-family:\'DM Sans\',sans-serif">';
const inp = el.querySelector('input');
inp.addEventListener('keydown', e => {
if (e.key === 'Enter' || e.key === ',' || e.key === 'Tab') {
e.preventDefault();
const v = inp.value.trim().replace(/,$/, '');
if (v) addTagTo(id, v);
inp.value = '';
} else if (e.key === 'Backspace' && !inp.value) {
const tags = el.querySelectorAll('.tag-chip');
if (tags.length) tags[tags.length-1].remove();
}
});
inp.addEventListener('blur', () => {
const v = inp.value.trim().replace(/,$/, '');
if (v) { addTagTo(id, v); inp.value = ''; }
});
}
function addTagTo(fieldId, email) {
const el = document.getElementById(fieldId);
const inp = el.querySelector('input');
const chip = document.createElement('span');
chip.className = 'tag-chip';
chip.style.cssText = 'display:inline-flex;align-items:center;gap:4px;padding:2px 8px;background:var(--accent-dim);color:var(--accent);border-radius:12px;font-size:12px;margin:2px';
chip.innerHTML = `${esc(email)}<span style="cursor:pointer;margin-left:2px" onclick="this.parentNode.remove()">×</span>`;
el.insertBefore(chip, inp);
}
function getTagValues(fieldId) {
const el = document.getElementById(fieldId);
return Array.from(el.querySelectorAll('.tag-chip')).map(c => c.textContent.replace('×','').trim()).filter(Boolean);
}
function addPageAttachments(files) {
for (const f of files) {
cpAttachments.push(f);
const chip = document.createElement('span');
chip.style.cssText = 'font-size:11px;padding:3px 8px;background:var(--surface3);border:1px solid var(--border2);border-radius:4px;color:var(--text2)';
chip.textContent = f.name;
document.getElementById('cp-att-list').appendChild(chip);
}
}
async function loadAccounts() {
const accounts = await apiCall('GET', '/accounts') || [];
const sel = document.getElementById('cp-from');
accounts.forEach(a => {
const opt = document.createElement('option');
opt.value = a.id;
opt.textContent = `${a.display_name || a.email_address} <${a.email_address}>`;
sel.appendChild(opt);
});
}
async function prefillReply() {
if (!replyId) return;
document.getElementById('compose-page-title').textContent = 'Reply';
const msg = await apiCall('GET', '/messages/' + replyId);
if (!msg) return;
document.title = 'Reply: ' + (msg.subject || '') + ' — GoWebMail';
document.getElementById('cp-subject').value = msg.subject?.startsWith('Re:') ? msg.subject : 'Re: ' + (msg.subject || '');
addTagTo('cp-to-tags', msg.from_email || '');
const editor = document.getElementById('cp-editor');
editor.innerHTML = `<br><br><div style="border-left:3px solid #ccc;padding-left:12px;color:#666;margin-top:8px">
<div style="font-size:12px;margin-bottom:4px">On ${msg.date ? new Date(msg.date).toLocaleString() : ''}, ${esc(msg.from_email)} wrote:</div>
${msg.body_html || '<pre>' + (msg.body_text||'') + '</pre>'}
</div>`;
// Set from to same account
if (msg.account_id) {
const sel = document.getElementById('cp-from');
for (const opt of sel.options) { if (parseInt(opt.value) === msg.account_id) { opt.selected = true; break; } }
}
}
async function prefillForward() {
if (!forwardId) return;
document.getElementById('compose-page-title').textContent = 'Forward';
const msg = await apiCall('GET', '/messages/' + forwardId);
if (!msg) return;
document.title = 'Forward: ' + (msg.subject || '') + ' — GoWebMail';
document.getElementById('cp-subject').value = 'Fwd: ' + (msg.subject || '');
const editor = document.getElementById('cp-editor');
editor.innerHTML = `<br><br><div style="border-left:3px solid #ccc;padding-left:12px;color:#666;margin-top:8px">
<div style="font-size:12px;margin-bottom:4px">---------- Forwarded message ----------<br>From: ${esc(msg.from_email)}<br>Subject: ${esc(msg.subject)}</div>
${msg.body_html || '<pre>' + (msg.body_text||'') + '</pre>'}
</div>`;
}
async function sendFromPage() {
const btn = document.getElementById('send-page-btn');
const accountId = parseInt(document.getElementById('cp-from').value || '0');
const to = getTagValues('cp-to-tags');
if (!accountId || !to.length) { document.getElementById('cp-status').textContent = 'From account and To address required.'; return; }
btn.disabled = true; btn.textContent = 'Sending…';
const meta = {
account_id: accountId,
to,
cc: getTagValues('cp-cc-tags'),
bcc: [],
subject: document.getElementById('cp-subject').value,
body_html: document.getElementById('cp-editor').innerHTML,
body_text: document.getElementById('cp-editor').innerText,
in_reply_to_id: replyId || 0,
forward_from_id: forwardId || 0,
};
let r;
const endpoint = replyId ? '/reply' : forwardId ? '/forward' : '/send';
if (cpAttachments.length) {
const fd = new FormData();
fd.append('meta', JSON.stringify(meta));
cpAttachments.forEach(f => fd.append('file', f, f.name));
const resp = await fetch('/api' + endpoint, { method: 'POST', body: fd });
r = await resp.json().catch(() => null);
} else {
r = await apiCall('POST', endpoint, meta);
}
btn.disabled = false; btn.textContent = 'Send';
if (r?.ok) {
document.getElementById('cp-status').innerHTML = '✓ Message sent! <a href="/" style="color:var(--accent)">Back to inbox</a>';
document.getElementById('compose-page-form').style.opacity = '0.5';
document.getElementById('compose-page-form').style.pointerEvents = 'none';
} else {
document.getElementById('cp-status').textContent = r?.error || 'Send failed.';
}
}
async function saveDraft() {
document.getElementById('cp-status').textContent = 'Draft saving not yet supported in standalone view.';
}
// Init
initTagField('cp-to-tags');
initTagField('cp-cc-tags');
loadAccounts();
if (replyId) prefillReply();
else if (forwardId) prefillForward();
</script>
{{end}}

View File

@@ -0,0 +1,95 @@
{{template "base" .}}
{{define "title"}}Message — GoWebMail{{end}}
{{define "body_class"}}app-page{{end}}
{{define "body"}}
<div id="msg-page" style="max-width:860px;margin:0 auto;padding:20px 16px;min-height:100vh">
<div style="display:flex;align-items:center;gap:12px;margin-bottom:18px;padding-bottom:14px;border-bottom:1px solid var(--border)">
<a href="/" style="color:var(--accent);text-decoration:none;font-size:13px;display:flex;align-items:center;gap:4px">
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
Back to GoWebMail
</a>
<span style="color:var(--border);font-size:16px">|</span>
<div id="msg-actions" style="display:flex;gap:8px"></div>
<div style="margin-left:auto;display:flex;gap:6px">
<button class="btn-secondary" id="btn-reply" style="font-size:12px" onclick="replyFromPage()">↩ Reply</button>
<button class="btn-secondary" id="btn-forward" style="font-size:12px" onclick="forwardFromPage()">↪ Forward</button>
</div>
</div>
<div id="msg-content">
<div class="spinner" style="margin-top:80px"></div>
</div>
</div>
{{end}}
{{define "scripts"}}
<script>
const msgId = parseInt(location.pathname.split('/').pop());
async function api(method, path, body) {
const opts = { method, headers: {} };
if (body) { opts.body = JSON.stringify(body); opts.headers['Content-Type'] = 'application/json'; }
const r = await fetch('/api' + path, opts);
return r.ok ? r.json() : null;
}
function esc(s) { return (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
async function load() {
const msg = await api('GET', '/messages/' + msgId);
if (!msg) { document.getElementById('msg-content').innerHTML = '<p style="color:var(--danger)">Message not found or not accessible.</p>'; return; }
// Mark read
await api('PUT', '/messages/' + msgId + '/read', { read: true });
document.title = (msg.subject || '(no subject)') + ' — GoWebMail';
const atts = msg.attachments || [];
const attHtml = atts.length ? `
<div style="padding:12px 0;border-top:1px solid var(--border);display:flex;flex-wrap:wrap;gap:8px">
${atts.map(a => `<a href="/api/messages/${msgId}/attachments/${a.id}" download="${esc(a.filename)}"
style="display:inline-flex;align-items:center;gap:6px;padding:5px 10px;background:var(--surface3);
border:1px solid var(--border2);border-radius:6px;font-size:12px;color:var(--text);text-decoration:none">
📎 ${esc(a.filename)} <span style="color:var(--muted)">(${(a.size/1024).toFixed(0)}KB)</span></a>`).join('')}
</div>` : '';
document.getElementById('msg-content').innerHTML = `
<h1 style="font-size:22px;font-weight:600;margin-bottom:16px;line-height:1.3">${esc(msg.subject || '(no subject)')}</h1>
<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:16px;flex-wrap:wrap;gap:8px">
<div>
<span style="font-size:14px;font-weight:500">${esc(msg.from_name || msg.from_email)}</span>
${msg.from_name ? `<span style="font-size:13px;color:var(--muted)">&lt;${esc(msg.from_email)}&gt;</span>` : ''}
<div style="font-size:12px;color:var(--muted);margin-top:2px">To: ${esc(msg.to_list || '')}</div>
</div>
<span style="font-size:12px;color:var(--muted);white-space:nowrap">${esc(msg.date ? new Date(msg.date).toLocaleString() : '')}</span>
</div>
<div style="border:1px solid var(--border);border-radius:8px;overflow:hidden;margin-bottom:12px">
<iframe id="msg-iframe" sandbox="allow-same-origin" style="width:100%;border:none;min-height:400px;background:white"></iframe>
</div>
${attHtml}`;
// Write body into sandboxed iframe
const iframe = document.getElementById('msg-iframe');
const doc = iframe.contentDocument || iframe.contentWindow.document;
doc.open();
doc.write(`<!DOCTYPE html><html><head><style>
body{font-family:sans-serif;font-size:14px;line-height:1.6;padding:16px;margin:0;color:#111;word-break:break-word}
img{max-width:100%;height:auto}a{color:#0078D4}
</style></head><body>${msg.body_html || '<pre style="white-space:pre-wrap">' + (msg.body_text||'') + '</pre>'}</body></html>`);
doc.close();
// Auto-resize iframe
setTimeout(() => {
try { iframe.style.height = (doc.documentElement.scrollHeight + 20) + 'px'; } catch(e) {}
}, 200);
}
function replyFromPage() {
window.location = '/?action=reply&id=' + msgId;
}
function forwardFromPage() {
window.location = '/?action=forward&id=' + msgId;
}
load();
</script>
{{end}}