Compare commits

...

25 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
ghostersk
68c81ebaed update README.MD 2026-03-08 18:37:52 +00:00
ghostersk
f122d29282 added parameters to list bans and unban ip from terminal 2026-03-08 18:07:19 +00:00
ghostersk
d6e987f66c added per user ip block/whitelist 2026-03-08 17:54:13 +00:00
ghostersk
ef85246806 added IP Block and notification for failed logins 2026-03-08 17:35:58 +00:00
ghostersk
948e111cc6 fix image and link rendering 2026-03-08 12:14:58 +00:00
ghostersk
ac43075d62 fix attachments 2026-03-08 11:48:27 +00:00
ghostersk
964a345657 fix embeding issue and sqlite db new database string 2026-03-08 06:51:04 +00:00
ghostersk
97e07689a9 add embed package and set it up 2026-03-08 06:26:49 +00:00
ghostersk
75904809dc remove shortcuts from readme 2026-03-08 06:08:09 +00:00
ghostersk
b29949e042 update name, project refference and synchronization 2026-03-08 06:06:38 +00:00
ghostersk
5d51b9778b message deletion sync fixed 2026-03-07 20:55:40 +00:00
ghostersk
b1fe22863a update MFA, add parameters to reset admin pw,mfa if locked out 2026-03-07 20:36:53 +00:00
ghostersk
12b1a44b96 Generally Working app 2026-03-07 20:29:20 +00:00
ghostersk
d4a4a5ec30 updated user management, add mesage select options 2026-03-07 20:00:15 +00:00
ghostersk
d5027ba7b0 Revise README for project status and instructions
Updated README to clarify project status and OAuth2 setup.
2026-03-07 17:24:27 +00:00
ghostersk
faa7dba2df Add feature images to README
Added images to showcase features in the README.
2026-03-07 17:18:42 +00:00
ghostersk
0bcd974b3d fixed message rendering with html content ( white background) 2026-03-07 17:09:41 +00:00
ghostersk
6df2de5f22 filter added. 2026-03-07 16:49:23 +00:00
ghostersk
b118056176 fixed sending of email(sending email considered it as spam) 2026-03-07 15:20:49 +00:00
ghostersk
1cf003edc4 fix the layout and email input for sending message 2026-03-07 15:14:57 +00:00
41 changed files with 9466 additions and 807 deletions

9
.gitignore vendored
View File

@@ -1,4 +1,9 @@
data/envs
data/*.db
data/gomail.conf
data/*.txt
data/*.db-shm
data/*db-wal
data/gowebmail.conf
data/*.txt
gowebmail-devplan.md
testrun/
webmail.code-workspace

View File

@@ -7,24 +7,24 @@ COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=1 GOOS=linux go build -ldflags="-s -w" -o gomail ./cmd/server
RUN CGO_ENABLED=1 GOOS=linux go build -ldflags="-s -w" -o gowebmail ./cmd/server
# ---- Runtime ----
FROM alpine:3.19
RUN apk add --no-cache sqlite-libs ca-certificates tzdata
WORKDIR /app
COPY --from=builder /app/gomail .
COPY --from=builder /app/gowebmail .
COPY --from=builder /app/web ./web
RUN mkdir -p /data && addgroup -S gomail && adduser -S gomail -G gomail
RUN chown -R gomail:gomail /app /data
USER gomail
RUN mkdir -p /data && addgroup -S gowebmail && adduser -S gowebmail -G gowebmail
RUN chown -R gowebmail:gowebmail /app /data
USER gowebmail
VOLUME ["/data"]
EXPOSE 8080
ENV DB_PATH=/data/gomail.db
ENV DB_PATH=/data/gowebmail.db
ENV LISTEN_ADDR=:8080
CMD ["./gomail"]
CMD ["./gowebmail"]

103
README.md
View File

@@ -1,9 +1,9 @@
# GoMail
# GoWebMail
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
- 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!
@@ -13,60 +13,55 @@ A self-hosted, encrypted web email client written entirely in Go. Supports Gmail
- **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** — GoMail account passwords hashed with cost=12
- **bcrypt password hashing** — GoWebMail account passwords hashed with cost=12
- **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
## Architecture
```
cmd/server/main.go Entry point, HTTP server setup
config/config.go Environment-based config
internal/
auth/oauth.go OAuth2 flows (Google + Microsoft)
crypto/crypto.go AES-256-GCM encryption + bcrypt
db/db.go SQLite database with field-level encryption
email/imap.go IMAP fetch + SMTP send via XOAUTH2
handlers/ HTTP handlers (auth, app, api)
middleware/middleware.go Logger, auth guard, security headers
models/models.go Data models
web/static/
login.html Sign-in page
register.html Registration page
app.html Single-page app (email client UI)
```
<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: Docker Compose (recommended)
### Option 1: Build executable
```bash
# 1. Clone / copy the project
git clone https://github.com/yourname/gomail && cd gomail
# 2. Generate secrets
export ENCRYPTION_KEY=$(openssl rand -hex 32)
export SESSION_SECRET=$(openssl rand -hex 32)
echo "ENCRYPTION_KEY=$ENCRYPTION_KEY" # SAVE THIS — losing it means losing your email cache
# 3. Add your OAuth2 credentials to docker-compose.yml (see below)
# 4. Run
ENCRYPTION_KEY=$ENCRYPTION_KEY SESSION_SECRET=$SESSION_SECRET docker compose up
git clone https://github.com/ghostersk/gowebmail && cd gowebmail
go build -o gowebmail ./cmd/server
# if you want smaller exe ( strip down debuginformation):
go build -ldflags="-s -w" -o gowebmail ./cmd/server
./gowebmail
```
Visit http://localhost:8080, register an account, then connect your email.
Visit http://localhost:8080, default login admin/admin, register an account, then connect your email.
### Option 2: Run directly
```bash
go build -o gomail ./cmd/server
export ENCRYPTION_KEY=$(openssl rand -hex 32)
export SESSION_SECRET=$(openssl rand -hex 32)
./gomail
git clone https://github.com/ghostersk/gowebmail && cd gowebmail
go run ./cmd/server/main.go
# check ./data/gowebmail.conf what gets generated on first run if not exists, update as needed.
# then restart the app
```
### Reset Admin password, MFA
```bash
# List all admins with MFA status
./gowebmail --list-admin
# USERNAME EMAIL MFA
# -------- ----- ---
# admin admin@example.com ON
# Reset an admin's password (min 8 chars)
./gowebmail --pw admin "NewSecurePass123"
# Disable MFA so a locked-out admin can log in again
./gowebmail --mfa-off admin
```
## Setting up OAuth2
### Gmail
@@ -90,40 +85,15 @@ export SESSION_SECRET=$(openssl rand -hex 32)
4. Create a Client secret
5. Set env vars: `MICROSOFT_CLIENT_ID`, `MICROSOFT_CLIENT_SECRET`, `MICROSOFT_TENANT_ID`
## Environment Variables
| Variable | Required | Description |
|---|---|---|
| `ENCRYPTION_KEY` | **Yes** | 64-char hex string (32 bytes). Auto-generated on first run but must be persisted. |
| `SESSION_SECRET` | **Yes** | Random string for session signing. |
| `LISTEN_ADDR` | No | Default `:8080` |
| `DB_PATH` | No | Default `./data/gomail.db` |
| `BASE_URL` | No | Default `http://localhost:8080` |
| `GOOGLE_CLIENT_ID` | For Gmail | Google OAuth2 client ID |
| `GOOGLE_CLIENT_SECRET` | For Gmail | Google OAuth2 client secret |
| `GOOGLE_REDIRECT_URL` | No | Default `{BASE_URL}/auth/gmail/callback` |
| `MICROSOFT_CLIENT_ID` | For Outlook | Azure AD app client ID |
| `MICROSOFT_CLIENT_SECRET` | For Outlook | Azure AD app client secret |
| `MICROSOFT_TENANT_ID` | No | Default `common` (multi-tenant) |
| `SECURE_COOKIE` | No | Set `true` in production (HTTPS only) |
## Security Notes
- **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 GoMail accounts are bcrypt hashed (cost=12).
- 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`.
## Keyboard Shortcuts
| Shortcut | Action |
|---|---|
| `Ctrl/Cmd + N` | Compose new message |
| `Ctrl/Cmd + K` | Focus search |
| `Escape` | Close compose / modal |
## Dependencies
```
@@ -139,11 +109,10 @@ golang.org/x/oauth2 OAuth2 + Google/Microsoft endpoints
## Building for Production
```bash
CGO_ENABLED=1 GOOS=linux go build -ldflags="-s -w" -o gomail ./cmd/server
CGO_ENABLED=1 GOOS=linux go build -ldflags="-s -w" -o gowebmail ./cmd/server
```
CGO is required by `go-sqlite3`. Cross-compilation requires a C cross-compiler.
## License
MIT
This project is licensed under the [GPL-3.0 license](LICENSE).

View File

@@ -1,28 +1,93 @@
package main
import (
"bytes"
"context"
"fmt"
"io"
"io/fs"
"log"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/yourusername/gomail/config"
"github.com/yourusername/gomail/internal/db"
"github.com/yourusername/gomail/internal/handlers"
"github.com/yourusername/gomail/internal/middleware"
"github.com/yourusername/gomail/internal/syncer"
"github.com/ghostersk/gowebmail"
"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"
"github.com/gorilla/mux"
)
func main() {
// ── CLI admin commands (run without starting the HTTP server) ──────────
// Usage:
// ./gowebmail --list-admin list all admin usernames
// ./gowebmail --pw <username> <pass> reset an admin's password
// ./gowebmail --mfa-off <username> disable MFA for an admin
args := os.Args[1:]
if len(args) > 0 {
switch args[0] {
case "--list-admin":
runListAdmins()
return
case "--pw":
if len(args) < 3 {
fmt.Fprintln(os.Stderr, "Usage: gowebmail --pw <username> \"<password>\"")
os.Exit(1)
}
runResetPassword(args[1], args[2])
return
case "--mfa-off":
if len(args) < 2 {
fmt.Fprintln(os.Stderr, "Usage: gowebmail --mfa-off <username>")
os.Exit(1)
}
runDisableMFA(args[1])
return
case "--blocklist":
runBlockList()
return
case "--unblock":
if len(args) < 2 {
fmt.Fprintln(os.Stderr, "Usage: gowebmail --unblock <ip>")
os.Exit(1)
}
runUnblock(args[1])
return
case "--help", "-h":
printHelp()
return
}
}
// ── Normal server startup ──────────────────────────────────────────────
staticFS, err := fs.Sub(gowebmail.WebFS, "web/static")
if err != nil {
log.Fatalf("embed static fs: %v", err)
}
cfg, err := config.Load()
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 {
@@ -34,7 +99,7 @@ func main() {
log.Fatalf("migrations: %v", err)
}
sc := syncer.New(database)
sc := syncer.New(database, cfg)
sc.Start()
defer sc.Stop()
@@ -46,15 +111,42 @@ func main() {
r.Use(middleware.CORS)
r.Use(cfg.HostCheckMiddleware)
// Custom error handlers for non-API paths
r.NotFoundHandler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
middleware.ServeErrorPage(w, req, http.StatusNotFound, "Page Not Found", "The page you're looking for doesn't exist or has been moved.")
})
r.MethodNotAllowedHandler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
middleware.ServeErrorPage(w, req, http.StatusMethodNotAllowed, "Method Not Allowed", "This request method is not supported for this URL.")
})
// Static files
r.PathPrefix("/static/").Handler(
http.StripPrefix("/static/", http.FileServer(http.Dir("./web/static/"))),
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 {
log.Printf("favicon error: %v", err)
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "image/png")
w.Write(data)
})
// Public auth routes
auth := r.PathPrefix("/auth").Subrouter()
auth.HandleFunc("/login", h.Auth.ShowLogin).Methods("GET")
auth.HandleFunc("/login", h.Auth.Login).Methods("POST")
auth.Handle("/login", middleware.BruteForceProtect(database, cfg, http.HandlerFunc(h.Auth.Login))).Methods("POST")
auth.HandleFunc("/logout", h.Auth.Logout).Methods("POST")
// MFA (session exists but mfa_verified=0)
@@ -70,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()
@@ -84,6 +180,7 @@ func main() {
adminUI.HandleFunc("/", h.Admin.ShowAdmin).Methods("GET")
adminUI.HandleFunc("/settings", h.Admin.ShowAdmin).Methods("GET")
adminUI.HandleFunc("/audit", h.Admin.ShowAdmin).Methods("GET")
adminUI.HandleFunc("/security", h.Admin.ShowAdmin).Methods("GET")
// API
api := r.PathPrefix("/api").Subrouter()
@@ -92,10 +189,13 @@ func main() {
// Profile / auth
api.HandleFunc("/me", h.Auth.Me).Methods("GET")
api.HandleFunc("/profile", h.Auth.UpdateProfile).Methods("PUT")
api.HandleFunc("/change-password", h.Auth.ChangePassword).Methods("POST")
api.HandleFunc("/mfa/setup", h.Auth.MFASetupBegin).Methods("POST")
api.HandleFunc("/mfa/confirm", h.Auth.MFASetupConfirm).Methods("POST")
api.HandleFunc("/mfa/disable", h.Auth.MFADisable).Methods("POST")
api.HandleFunc("/ip-rules", h.Auth.GetUserIPRule).Methods("GET")
api.HandleFunc("/ip-rules", h.Auth.SetUserIPRule).Methods("PUT")
// Providers (which OAuth providers are configured)
api.HandleFunc("/providers", h.API.GetProviders).Methods("GET")
@@ -104,6 +204,7 @@ func main() {
api.HandleFunc("/accounts", h.API.ListAccounts).Methods("GET")
api.HandleFunc("/accounts", h.API.AddAccount).Methods("POST")
api.HandleFunc("/accounts/test", h.API.TestConnection).Methods("POST")
api.HandleFunc("/accounts/detect", h.API.DetectMailSettings).Methods("POST")
api.HandleFunc("/accounts/{id:[0-9]+}", h.API.GetAccount).Methods("GET")
api.HandleFunc("/accounts/{id:[0-9]+}", h.API.UpdateAccount).Methods("PUT")
api.HandleFunc("/accounts/{id:[0-9]+}", h.API.DeleteAccount).Methods("DELETE")
@@ -118,7 +219,11 @@ func main() {
api.HandleFunc("/messages/{id:[0-9]+}/star", h.API.ToggleStar).Methods("PUT")
api.HandleFunc("/messages/{id:[0-9]+}/move", h.API.MoveMessage).Methods("PUT")
api.HandleFunc("/messages/{id:[0-9]+}/headers", h.API.GetMessageHeaders).Methods("GET")
api.HandleFunc("/messages/{id:[0-9]+}/download.eml", h.API.DownloadEML).Methods("GET")
api.HandleFunc("/messages/{id:[0-9]+}/attachments", h.API.ListAttachments).Methods("GET")
api.HandleFunc("/messages/{id:[0-9]+}/attachments/{att_id:[0-9]+}", h.API.DownloadAttachment).Methods("GET")
api.HandleFunc("/messages/{id:[0-9]+}", h.API.DeleteMessage).Methods("DELETE")
api.HandleFunc("/messages/starred", h.API.StarredMessages).Methods("GET")
// Remote content whitelist
api.HandleFunc("/remote-content-whitelist", h.API.GetRemoteContentWhitelist).Methods("GET")
@@ -128,19 +233,55 @@ func main() {
api.HandleFunc("/send", h.API.SendMessage).Methods("POST")
api.HandleFunc("/reply", h.API.ReplyMessage).Methods("POST")
api.HandleFunc("/forward", h.API.ForwardMessage).Methods("POST")
api.HandleFunc("/forward-attachment", h.API.ForwardAsAttachment).Methods("POST")
api.HandleFunc("/draft", h.API.SaveDraft).Methods("POST")
// Folders
api.HandleFunc("/folders", h.API.ListFolders).Methods("GET")
api.HandleFunc("/folders/{account_id:[0-9]+}", h.API.ListAccountFolders).Methods("GET")
api.HandleFunc("/folders/{id:[0-9]+}/sync", h.API.SyncFolder).Methods("POST")
api.HandleFunc("/folders/{id:[0-9]+}/visibility", h.API.SetFolderVisibility).Methods("PUT")
api.HandleFunc("/folders/{id:[0-9]+}/count", h.API.CountFolderMessages).Methods("GET")
api.HandleFunc("/folders/{id:[0-9]+}/move-to/{toId:[0-9]+}", h.API.MoveFolderContents).Methods("POST")
api.HandleFunc("/folders/{id:[0-9]+}/empty", h.API.EmptyFolder).Methods("POST")
api.HandleFunc("/folders/{id:[0-9]+}/mark-all-read", h.API.MarkFolderAllRead).Methods("POST")
api.HandleFunc("/folders/{id:[0-9]+}", h.API.DeleteFolder).Methods("DELETE")
api.HandleFunc("/accounts/{account_id:[0-9]+}/enable-all-sync", h.API.EnableAllFolderSync).Methods("POST")
api.HandleFunc("/poll", h.API.PollUnread).Methods("GET")
api.HandleFunc("/new-messages", h.API.NewMessagesSince).Methods("GET")
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))
@@ -153,6 +294,19 @@ func main() {
adminAPI.HandleFunc("/audit", h.Admin.ListAuditLogs).Methods("GET")
adminAPI.HandleFunc("/settings", h.Admin.GetSettings).Methods("GET")
adminAPI.HandleFunc("/settings", h.Admin.SetSettings).Methods("PUT")
adminAPI.HandleFunc("/ip-blocks", h.Admin.ListIPBlocks).Methods("GET")
adminAPI.HandleFunc("/ip-blocks", h.Admin.AddIPBlock).Methods("POST")
adminAPI.HandleFunc("/ip-blocks/{ip}", h.Admin.RemoveIPBlock).Methods("DELETE")
adminAPI.HandleFunc("/login-attempts", h.Admin.ListLoginAttempts).Methods("GET")
// Periodically purge expired IP blocks
go func() {
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()
for range ticker.C {
database.PurgeExpiredBlocks()
}
}()
srv := &http.Server{
Addr: cfg.ListenAddr,
@@ -166,7 +320,7 @@ func main() {
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
go func() {
log.Printf("GoMail listening on %s", cfg.ListenAddr)
log.Printf("GoWebMail listening on %s", cfg.ListenAddr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("server: %v", err)
}
@@ -178,3 +332,173 @@ func main() {
defer cancel()
srv.Shutdown(ctx)
}
// ── CLI helpers ────────────────────────────────────────────────────────────
func openDB() (*db.DB, func()) {
cfg, err := config.Load()
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
os.Exit(1)
}
database, err := db.New(cfg.DBPath, cfg.EncryptionKey)
if err != nil {
fmt.Fprintf(os.Stderr, "Error opening database: %v\n", err)
os.Exit(1)
}
return database, func() { database.Close() }
}
func runListAdmins() {
database, close := openDB()
defer close()
admins, err := database.AdminListAdmins()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if len(admins) == 0 {
fmt.Println("No admin accounts found.")
return
}
fmt.Printf("%-24s %-36s %s\n", "USERNAME", "EMAIL", "MFA")
fmt.Printf("%-24s %-36s %s\n", "--------", "-----", "---")
for _, a := range admins {
mfaStatus := "off"
if a.MFAEnabled {
mfaStatus = "ON"
}
fmt.Printf("%-24s %-36s %s\n", a.Username, a.Email, mfaStatus)
}
}
func runResetPassword(username, password string) {
if len(password) < 8 {
fmt.Fprintln(os.Stderr, "Error: password must be at least 8 characters")
os.Exit(1)
}
database, close := openDB()
defer close()
if err := database.AdminResetPassword(username, password); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
fmt.Printf("Password updated for admin '%s'.\n", username)
}
func runDisableMFA(username string) {
database, close := openDB()
defer close()
if err := database.AdminDisableMFA(username); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
fmt.Printf("MFA disabled for admin '%s'. They can now log in with password only.\n", username)
}
func runBlockList() {
database, close := openDB()
defer close()
blocks, err := database.ListIPBlocksWithUsername()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if len(blocks) == 0 {
fmt.Println("No blocked IPs.")
return
}
fmt.Printf("%-18s %-20s %-5s %-22s %-22s %s\n",
"IP", "USERNAME USED", "TRIES", "BLOCKED AT", "EXPIRES", "REMAINING")
fmt.Printf("%-18s %-20s %-5s %-22s %-22s %s\n",
"--", "-------------", "-----", "----------", "-------", "---------")
for _, b := range blocks {
blockedAt := b.BlockedAt.UTC().Format("2006-01-02 15:04:05")
var expires, remaining string
if b.IsPermanent || b.ExpiresAt == nil {
expires = "permanent"
remaining = "∞ (manual unblock)"
} else {
expires = b.ExpiresAt.UTC().Format("2006-01-02 15:04:05")
left := time.Until(*b.ExpiresAt)
if left <= 0 {
remaining = "expired (purge pending)"
} else {
h := int(left.Hours())
m := int(left.Minutes()) % 60
s := int(left.Seconds()) % 60
if h > 0 {
remaining = fmt.Sprintf("%dh %dm", h, m)
} else if m > 0 {
remaining = fmt.Sprintf("%dm %ds", m, s)
} else {
remaining = fmt.Sprintf("%ds", s)
}
}
}
username := b.LastUsername
if username == "" {
username = "(unknown)"
}
fmt.Printf("%-18s %-20s %-5d %-22s %-22s %s\n",
b.IP, username, b.Attempts, blockedAt, expires, remaining)
}
fmt.Printf("\nTotal: %d blocked IP(s)\n", len(blocks))
}
func runUnblock(ip string) {
database, close := openDB()
defer close()
if err := database.UnblockIP(ip); err != nil {
fmt.Fprintf(os.Stderr, "Error unblocking %s: %v\n", ip, err)
os.Exit(1)
}
fmt.Printf("IP %s has been unblocked.\n", ip)
}
func printHelp() {
fmt.Print(`GoWebMail — Admin CLI
Usage:
gowebmail Start the mail server
gowebmail --list-admin List all admin accounts (username, email, MFA status)
gowebmail --pw <username> <pass> Reset password for an admin account
gowebmail --mfa-off <username> Disable MFA for an admin account
gowebmail --blocklist List all currently blocked IP addresses
gowebmail --unblock <ip> Remove block for a specific IP address
Examples:
./gowebmail --list-admin
./gowebmail --pw admin "NewSecurePass123"
./gowebmail --mfa-off admin
./gowebmail --blocklist
./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.
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

@@ -1,4 +1,4 @@
// Package config loads and persists GoMail configuration from data/gomail.conf
// Package config loads and persists GoWebMail configuration from data/gowebmail.conf
package config
import (
@@ -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
@@ -28,6 +31,23 @@ type Config struct {
SessionMaxAge int
TrustedProxies []net.IPNet // CIDR ranges allowed to set X-Forwarded-For/Proto headers
// Notification SMTP (outbound alerts — separate from user mail accounts)
NotifyEnabled bool
NotifySMTPHost string
NotifySMTPPort int
NotifyFrom string
NotifyUser string // optional — leave blank for unauthenticated relay
NotifyPass string // optional
// Brute force protection
BruteEnabled bool
BruteMaxAttempts int
BruteWindowMins int
BruteBanHours int
BruteWhitelist []net.IP // IPs exempt from blocking
GeoBlockCountries []string // 2-letter codes to deny (deny-list mode)
GeoAllowCountries []string // 2-letter codes to allow (allow-list mode, empty=allow all)
// Storage
DBPath string
@@ -43,7 +63,7 @@ type Config struct {
MicrosoftRedirectURL string // auto-derived from BaseURL if blank
}
const configPath = "./data/gomail.conf"
const configPath = "./data/gowebmail.conf"
type configField struct {
key string
@@ -52,14 +72,14 @@ type configField struct {
}
// allFields is the single source of truth for config keys.
// Adding a field here causes it to automatically appear in gomail.conf on next startup.
// Adding a field here causes it to automatically appear in gowebmail.conf on next startup.
var allFields = []configField{
{
key: "HOSTNAME",
defVal: "localhost",
comments: []string{
"--- Server ---",
"Public hostname of this GoMail instance (no port, no protocol).",
"Public hostname of this GoWebMail instance (no port, no protocol).",
"Examples: localhost | mail.example.com | 192.168.1.10",
"Used to build BASE_URL and OAuth redirect URIs automatically.",
"Also used in security checks to reject requests with unexpected Host headers.",
@@ -92,7 +112,7 @@ var allFields = []configField{
key: "SECURE_COOKIE",
defVal: "false",
comments: []string{
"Set to true when GoMail is served over HTTPS (directly or via proxy).",
"Set to true when GoWebMail is served over HTTPS (directly or via proxy).",
"Marks session cookies as Secure so browsers only send them over TLS.",
},
},
@@ -109,7 +129,7 @@ var allFields = []configField{
comments: []string{
"Comma-separated list of IP addresses or CIDR ranges of trusted reverse proxies.",
"Requests from these IPs may set X-Forwarded-For and X-Forwarded-Proto headers,",
"which GoMail uses to determine the real client IP and whether TLS is in use.",
"which GoWebMail uses to determine the real client IP and whether TLS is in use.",
" Examples:",
" 127.0.0.1 (loopback only — Nginx/Traefik on same host)",
" 10.0.0.0/8,172.16.0.0/12 (private networks)",
@@ -118,9 +138,111 @@ var allFields = []configField{
" NOTE: Do not add untrusted IPs — clients could spoof their source address.",
},
},
{
key: "NOTIFY_ENABLED",
defVal: "true",
comments: []string{
"--- Security Notifications ---",
"Send email alerts to users when their account is targeted by brute-force attacks.",
"Set to false to disable all security notification emails.",
},
},
{
key: "NOTIFY_SMTP_HOST",
defVal: "",
comments: []string{
"SMTP server hostname for sending security notification emails.",
"Example: smtp.example.com",
},
},
{
key: "NOTIFY_SMTP_PORT",
defVal: "587",
comments: []string{
"SMTP server port. Common values: 587 (STARTTLS), 465 (TLS), 25 (relay, no auth).",
},
},
{
key: "NOTIFY_FROM",
defVal: "",
comments: []string{
"Sender address for security notification emails. Example: security@example.com",
},
},
{
key: "NOTIFY_USER",
defVal: "",
comments: []string{
"SMTP username for authenticated relay. Leave blank for unauthenticated relay.",
},
},
{
key: "NOTIFY_PASS",
defVal: "",
comments: []string{
"SMTP password for authenticated relay. Leave blank for unauthenticated relay.",
},
},
{
key: "BRUTE_ENABLED",
defVal: "true",
comments: []string{
"--- Brute Force Protection ---",
"Enable automatic IP blocking after repeated failed logins.",
"Set to false to disable entirely.",
},
},
{
key: "BRUTE_MAX_ATTEMPTS",
defVal: "5",
comments: []string{
"Number of failed login attempts within BRUTE_WINDOW_MINUTES that triggers a ban.",
},
},
{
key: "BRUTE_WINDOW_MINUTES",
defVal: "30",
comments: []string{
"Time window in minutes for counting failed login attempts.",
},
},
{
key: "BRUTE_BAN_HOURS",
defVal: "12",
comments: []string{
"How many hours to ban an offending IP. Set to 0 for permanent ban (admin must unban manually).",
},
},
{
key: "BRUTE_WHITELIST_IPS",
defVal: "",
comments: []string{
"Comma-separated IPv4/IPv6 addresses that are never blocked by brute force protection.",
"Example: 192.168.1.1,10.0.0.1",
},
},
{
key: "GEO_BLOCK_COUNTRIES",
defVal: "",
comments: []string{
"--- Geo Blocking (uses ip-api.com, requires internet access) ---",
"Comma-separated 2-letter ISO country codes to DENY access from.",
"Example: CN,RU,KP",
"Leave blank to disable deny-list. Takes precedence over GEO_ALLOW_COUNTRIES.",
},
},
{
key: "GEO_ALLOW_COUNTRIES",
defVal: "",
comments: []string{
"Comma-separated 2-letter ISO country codes to ALLOW (all others are denied).",
"Example: SK,CZ,DE",
"Leave blank to allow all countries. Only active if GEO_BLOCK_COUNTRIES is also blank.",
},
},
{
key: "DB_PATH",
defVal: "./data/gomail.db",
defVal: "./data/gowebmail.db",
comments: []string{
"--- Storage ---",
"Path to the SQLite database file.",
@@ -184,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).",
},
},
{
@@ -200,7 +327,7 @@ var allFields = []configField{
},
}
// Load reads/creates data/gomail.conf, fills in missing keys, then returns Config.
// Load reads/creates data/gowebmail.conf, fills in missing keys, then returns Config.
// Environment variables override file values when set.
func Load() (*Config, error) {
if err := os.MkdirAll("./data", 0700); err != nil {
@@ -215,7 +342,7 @@ func Load() (*Config, error) {
// Auto-generate secrets if missing
if existing["ENCRYPTION_KEY"] == "" {
existing["ENCRYPTION_KEY"] = mustHex(32)
fmt.Println("WARNING: Generated new ENCRYPTION_KEY — it is saved in data/gomail.conf — back it up!")
fmt.Println("WARNING: Generated new ENCRYPTION_KEY — it is saved in data/gowebmail.conf — back it up!")
}
if existing["SESSION_SECRET"] == "" {
existing["SESSION_SECRET"] = mustHex(32)
@@ -228,7 +355,7 @@ func Load() (*Config, error) {
// get returns env var if set, else file value, else ""
get := func(key string) string {
// Only check env vars that are explicitly GoMail-namespaced or well-known.
// Only check env vars that are explicitly GoWebMail-namespaced or well-known.
// We deliberately do NOT fall back to generic vars like PORT to avoid
// picking up cloud-platform env vars unintentionally.
if v := os.Getenv("GOMAIL_" + key); v != "" {
@@ -307,18 +434,34 @@ 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),
SessionMaxAge: atoi(get("SESSION_MAX_AGE"), 604800),
TrustedProxies: trustedProxies,
BruteEnabled: atobool(get("BRUTE_ENABLED"), true),
BruteMaxAttempts: atoi(get("BRUTE_MAX_ATTEMPTS"), 5),
BruteWindowMins: atoi(get("BRUTE_WINDOW_MINUTES"), 30),
BruteBanHours: atoi(get("BRUTE_BAN_HOURS"), 12),
BruteWhitelist: parseIPList(get("BRUTE_WHITELIST_IPS")),
GeoBlockCountries: parseCountryList(get("GEO_BLOCK_COUNTRIES")),
GeoAllowCountries: parseCountryList(get("GEO_ALLOW_COUNTRIES")),
NotifyEnabled: atobool(get("NOTIFY_ENABLED"), true),
NotifySMTPHost: get("NOTIFY_SMTP_HOST"),
NotifySMTPPort: atoi(get("NOTIFY_SMTP_PORT"), 587),
NotifyFrom: get("NOTIFY_FROM"),
NotifyUser: get("NOTIFY_USER"),
NotifyPass: get("NOTIFY_PASS"),
GoogleClientID: get("GOOGLE_CLIENT_ID"),
GoogleClientSecret: get("GOOGLE_CLIENT_SECRET"),
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,
}
@@ -345,6 +488,42 @@ func buildBaseURL(hostname, port string) string {
}
}
// IsIPWhitelisted returns true if the IP is in the brute force whitelist.
func (c *Config) IsIPWhitelisted(ipStr string) bool {
ip := net.ParseIP(ipStr)
if ip == nil {
return false
}
for _, w := range c.BruteWhitelist {
if w.Equal(ip) {
return true
}
}
return false
}
// IsCountryAllowed returns true if traffic from the given 2-letter country code is permitted.
// Logic: deny-list takes precedence; then allow-list if non-empty; otherwise allow all.
func (c *Config) IsCountryAllowed(code string) bool {
code = strings.ToUpper(code)
if len(c.GeoBlockCountries) > 0 {
for _, bc := range c.GeoBlockCountries {
if bc == code {
return false
}
}
}
if len(c.GeoAllowCountries) > 0 {
for _, ac := range c.GeoAllowCountries {
if ac == code {
return true
}
}
return false
}
return true
}
// IsAllowedHost returns true if the request Host header matches our expected hostname.
// Accepts exact match, hostname:port, or any value if hostname is "localhost" (dev mode).
func (c *Config) IsAllowedHost(requestHost string) bool {
@@ -443,7 +622,7 @@ func readConfigFile(path string) (map[string]string, error) {
func writeConfigFile(path string, values map[string]string) error {
var sb strings.Builder
sb.WriteString("# GoMail Configuration\n")
sb.WriteString("# GoWebMail Configuration\n")
sb.WriteString("# =====================\n")
sb.WriteString("# Auto-generated and updated on each startup.\n")
sb.WriteString("# Edit freely — your values are always preserved.\n")
@@ -576,7 +755,7 @@ func parseCIDRList(s string) ([]net.IPNet, error) {
}
func logStartupInfo(cfg *Config) {
fmt.Printf("GoMail starting:\n")
fmt.Printf("GoWebMail starting:\n")
fmt.Printf(" Listen : %s\n", cfg.ListenAddr)
fmt.Printf(" Base URL: %s\n", cfg.BaseURL)
fmt.Printf(" Hostname: %s\n", cfg.Hostname)
@@ -587,6 +766,38 @@ 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 {
var ips []net.IP
for _, raw := range strings.Split(s, ",") {
raw = strings.TrimSpace(raw)
if raw == "" {
continue
}
if ip := net.ParseIP(raw); ip != nil {
ips = append(ips, ip)
}
}
return ips
}
func parseCountryList(s string) []string {
var codes []string
for _, raw := range strings.Split(s, ",") {
raw = strings.TrimSpace(strings.ToUpper(raw))
if len(raw) == 2 {
codes = append(codes, raw)
}
}
return codes
}
func mustHex(n int) string {

View File

@@ -47,7 +47,7 @@ TRUSTED_PROXIES =
# --- Storage ---
# Path to the SQLite database file.
DB_PATH = ./data/gomail.db
DB_PATH = ./data/gowebmail.db
# AES-256 key protecting all sensitive data at rest (emails, tokens, MFA secrets).
# Must be exactly 64 hex characters (= 32 bytes). Auto-generated on first run.

View File

@@ -1,12 +1,12 @@
version: '3.9'
services:
gomail:
gowebmail:
build: .
ports:
- "8080:8080"
volumes:
- gomail-data:/data
- gowebmail-data:/data
environment:
# REQUIRED: Generate with: openssl rand -hex 32
ENCRYPTION_KEY: ""
@@ -32,4 +32,4 @@ services:
restart: unless-stopped
volumes:
gomail-data:
gowebmail-data:

11
go.mod
View File

@@ -1,18 +1,17 @@
module github.com/yourusername/gomail
module github.com/ghostersk/gowebmail
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)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,7 @@ import (
"context"
"crypto/tls"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log"
@@ -18,10 +19,11 @@ import (
"strings"
"time"
"github.com/ghostersk/gowebmail/internal/logger"
"github.com/emersion/go-imap"
"github.com/emersion/go-imap/client"
gomailModels "github.com/yourusername/gomail/internal/models"
gomailModels "github.com/ghostersk/gowebmail/internal/models"
)
func imapHostFor(provider gomailModels.AccountProvider) (string, int) {
@@ -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()
@@ -134,6 +183,97 @@ func TestConnection(account *gomailModels.EmailAccount) error {
func (c *Client) Close() { c.imap.Logout() }
func (c *Client) DeleteMailbox(name string) error {
return c.imap.Delete(name)
}
// MoveByUID copies a message to destMailbox and marks it deleted in srcMailbox.
func (c *Client) MoveByUID(srcMailbox, destMailbox string, uid uint32) error {
if _, err := c.imap.Select(srcMailbox, false); err != nil {
return fmt.Errorf("select %s: %w", srcMailbox, err)
}
seqSet := new(imap.SeqSet)
seqSet.AddNum(uid)
// COPY to destination
if err := c.imap.UidCopy(seqSet, destMailbox); err != nil {
return fmt.Errorf("uid copy: %w", err)
}
// Mark deleted in source
item := imap.FormatFlagsOp(imap.SetFlags, true)
flags := []interface{}{imap.DeletedFlag}
if err := c.imap.UidStore(seqSet, item, flags, nil); err != nil {
return fmt.Errorf("uid store deleted: %w", err)
}
return c.imap.Expunge(nil)
}
// DeleteByUID moves message to Trash, or hard-deletes if already in Trash.
func (c *Client) DeleteByUID(mailboxName string, uid uint32, trashName string) error {
if _, err := c.imap.Select(mailboxName, false); err != nil {
return fmt.Errorf("select %s: %w", mailboxName, err)
}
seqSet := new(imap.SeqSet)
seqSet.AddNum(uid)
isTrash := strings.EqualFold(mailboxName, trashName) || trashName == ""
if !isTrash && trashName != "" {
// Move to trash
if err := c.imap.UidCopy(seqSet, trashName); err == nil {
item := imap.FormatFlagsOp(imap.SetFlags, true)
_ = c.imap.UidStore(seqSet, item, []interface{}{imap.DeletedFlag}, nil)
return c.imap.Expunge(nil)
}
}
// Hard delete (already in trash or no trash folder)
item := imap.FormatFlagsOp(imap.SetFlags, true)
if err := c.imap.UidStore(seqSet, item, []interface{}{imap.DeletedFlag}, nil); err != nil {
return fmt.Errorf("uid store deleted: %w", err)
}
return c.imap.Expunge(nil)
}
// SetFlagByUID sets or clears an IMAP flag (e.g. \Seen, \Flagged) for a message.
func (c *Client) SetFlagByUID(mailboxName string, uid uint32, flag string, set bool) error {
if _, err := c.imap.Select(mailboxName, false); err != nil {
return err
}
seqSet := new(imap.SeqSet)
seqSet.AddNum(uid)
var op imap.FlagsOp
if set {
op = imap.AddFlags
} else {
op = imap.RemoveFlags
}
item := imap.FormatFlagsOp(op, true)
return c.imap.UidStore(seqSet, item, []interface{}{flag}, nil)
}
// FetchRawByUID returns the raw RFC 822 message bytes for the given UID.
func (c *Client) FetchRawByUID(mailboxName string, uid uint32) ([]byte, error) {
if _, err := c.imap.Select(mailboxName, true); err != nil {
return nil, fmt.Errorf("select %s: %w", mailboxName, err)
}
seqSet := new(imap.SeqSet)
seqSet.AddNum(uid)
section := &imap.BodySectionName{}
items := []imap.FetchItem{section.FetchItem()}
ch := make(chan *imap.Message, 1)
done := make(chan error, 1)
go func() { done <- c.imap.UidFetch(seqSet, items, ch) }()
msg := <-ch
if err := <-done; err != nil {
return nil, err
}
if msg == nil {
return nil, fmt.Errorf("message not found")
}
body := msg.GetBody(section)
if body == nil {
return nil, fmt.Errorf("no body")
}
return io.ReadAll(body)
}
func (c *Client) ListMailboxes() ([]*imap.MailboxInfo, error) {
ch := make(chan *imap.MailboxInfo, 64)
done := make(chan error, 1)
@@ -145,8 +285,8 @@ func (c *Client) ListMailboxes() ([]*imap.MailboxInfo, error) {
return result, <-done
}
// FetchMessages fetches messages received within the last `days` days.
// Falls back to the most recent 200 if the server does not support SEARCH.
// FetchMessages fetches messages from a mailbox.
// If days <= 0, fetches ALL messages. Otherwise fetches messages since `days` days ago.
func (c *Client) FetchMessages(mailboxName string, days int) ([]*gomailModels.Message, error) {
mbox, err := c.imap.Select(mailboxName, true)
if err != nil {
@@ -156,15 +296,22 @@ func (c *Client) FetchMessages(mailboxName string, days int) ([]*gomailModels.Me
return nil, nil
}
since := time.Now().AddDate(0, 0, -days)
criteria := imap.NewSearchCriteria()
criteria.Since = since
var uids []uint32
if days <= 0 {
// Fetch ALL messages — empty criteria matches everything
uids, err = c.imap.UidSearch(imap.NewSearchCriteria())
} else {
since := time.Now().AddDate(0, 0, -days)
criteria := imap.NewSearchCriteria()
criteria.Since = since
uids, err = c.imap.UidSearch(criteria)
}
uids, err := c.imap.Search(criteria)
if err != nil || len(uids) == 0 {
// Fallback: fetch last 500 by sequence number
from := uint32(1)
if mbox.Messages > 200 {
from = mbox.Messages - 199
if mbox.Messages > 500 {
from = mbox.Messages - 499
}
seqSet := new(imap.SeqSet)
seqSet.AddRange(from, mbox.Messages)
@@ -175,7 +322,7 @@ func (c *Client) FetchMessages(mailboxName string, days int) ([]*gomailModels.Me
for _, uid := range uids {
seqSet.AddNum(uid)
}
return c.fetchBySeqSet(seqSet)
return c.fetchByUIDSet(seqSet)
}
func (c *Client) fetchBySeqSet(seqSet *imap.SeqSet) ([]*gomailModels.Message, error) {
@@ -205,6 +352,33 @@ func (c *Client) fetchBySeqSet(seqSet *imap.SeqSet) ([]*gomailModels.Message, er
return results, nil
}
// fetchByUIDSet fetches messages by UID set (used when UIDs are returned from UidSearch).
func (c *Client) fetchByUIDSet(seqSet *imap.SeqSet) ([]*gomailModels.Message, error) {
items := []imap.FetchItem{
imap.FetchUid, imap.FetchEnvelope,
imap.FetchFlags, imap.FetchBodyStructure,
imap.FetchRFC822,
}
ch := make(chan *imap.Message, 64)
done := make(chan error, 1)
go func() { done <- c.imap.UidFetch(seqSet, items, ch) }()
var results []*gomailModels.Message
for msg := range ch {
m, err := parseIMAPMessage(msg, c.account)
if err != nil {
log.Printf("parse message uid=%d: %v", msg.Uid, err)
continue
}
results = append(results, m)
}
if err := <-done; err != nil {
return results, fmt.Errorf("uid fetch: %w", err)
}
return results, nil
}
func parseIMAPMessage(msg *imap.Message, account *gomailModels.EmailAccount) (*gomailModels.Message, error) {
m := &gomailModels.Message{
AccountID: account.ID,
@@ -267,8 +441,14 @@ func parseIMAPMessage(msg *imap.Message, account *gomailModels.EmailAccount) (*g
return m, nil
}
// ParseMIMEFull is the exported version of parseMIME for use by handlers.
func ParseMIMEFull(raw []byte) (text, html string, attachments []gomailModels.Attachment) {
return parseMIME(raw)
}
// parseMIME takes a full RFC822 raw message (with headers) and extracts
// text/plain, text/html and attachment metadata.
// Inline images referenced by cid: are base64-embedded into the HTML as data: URIs.
func parseMIME(raw []byte) (text, html string, attachments []gomailModels.Attachment) {
msg, err := netmail.ReadMessage(bytes.NewReader(raw))
if err != nil {
@@ -280,12 +460,144 @@ func parseMIME(raw []byte) (text, html string, attachments []gomailModels.Attach
ct = "text/plain"
}
body, _ := io.ReadAll(msg.Body)
text, html, attachments = parsePart(ct, msg.Header.Get("Content-Transfer-Encoding"), body)
// cidMap: Content-ID → base64 data URI for inline images
cidMap := make(map[string]string)
text, html, attachments = parsePartIndexedCID(ct, msg.Header.Get("Content-Transfer-Encoding"), body, []int{}, cidMap)
// Rewrite cid: references in HTML to data: URIs
if html != "" && len(cidMap) > 0 {
html = rewriteCIDReferences(html, cidMap)
}
return
}
// rewriteCIDReferences replaces src="cid:xxx" with src="data:mime;base64,..." in HTML.
func rewriteCIDReferences(html string, cidMap map[string]string) string {
for cid, dataURI := range cidMap {
// Match both with and without angle brackets
html = strings.ReplaceAll(html, `cid:`+cid, dataURI)
// Some clients wrap CID in angle brackets in the src attribute
html = strings.ReplaceAll(html, `cid:<`+cid+`>`, dataURI)
}
return html
}
// parsePart recursively handles a MIME part.
func parsePart(contentType, transferEncoding string, body []byte) (text, html string, attachments []gomailModels.Attachment) {
return parsePartIndexed(contentType, transferEncoding, body, []int{})
}
// parsePartIndexedCID is like parsePartIndexed but also collects inline image parts into cidMap.
func parsePartIndexedCID(contentType, transferEncoding string, body []byte, path []int, cidMap map[string]string) (text, html string, attachments []gomailModels.Attachment) {
mediaType, params, err := mime.ParseMediaType(contentType)
if err != nil {
return string(body), "", nil
}
mediaType = strings.ToLower(mediaType)
decoded := decodeTransfer(transferEncoding, body)
switch {
case mediaType == "text/plain":
text = decodeCharset(params["charset"], decoded)
case mediaType == "text/html":
html = decodeCharset(params["charset"], decoded)
case strings.HasPrefix(mediaType, "multipart/"):
boundary := params["boundary"]
if boundary == "" {
return string(decoded), "", nil
}
mr := multipart.NewReader(bytes.NewReader(decoded), boundary)
partIdx := 0
for {
part, err := mr.NextPart()
if err != nil {
break
}
partIdx++
childPath := append(append([]int{}, path...), partIdx)
partBody, _ := io.ReadAll(part)
partCT := part.Header.Get("Content-Type")
if partCT == "" {
partCT = "text/plain"
}
partTE := part.Header.Get("Content-Transfer-Encoding")
disposition := part.Header.Get("Content-Disposition")
contentID := strings.Trim(part.Header.Get("Content-ID"), "<>")
dispType, dispParams, _ := mime.ParseMediaType(disposition)
filename := dispParams["filename"]
if filename == "" {
filename = part.FileName()
}
if filename != "" {
wd := mime.WordDecoder{}
if dec, e := wd.DecodeHeader(filename); e == nil {
filename = dec
}
}
partMedia, _, _ := mime.ParseMediaType(partCT)
partMediaLower := strings.ToLower(partMedia)
// Inline image with Content-ID → embed as data URI for cid: resolution
if contentID != "" && strings.HasPrefix(partMediaLower, "image/") {
decodedPart := decodeTransfer(partTE, partBody)
dataURI := "data:" + partMediaLower + ";base64," + base64.StdEncoding.EncodeToString(decodedPart)
cidMap[contentID] = dataURI
// Don't add as attachment chip — it's inline
continue
}
isAttachment := strings.EqualFold(dispType, "attachment") ||
(filename != "" && !strings.HasPrefix(partMediaLower, "text/") &&
!strings.HasPrefix(partMediaLower, "multipart/"))
if isAttachment {
if filename == "" {
filename = "attachment"
}
mimePartPath := mimePathString(childPath)
attachments = append(attachments, gomailModels.Attachment{
Filename: filename,
ContentType: partMedia,
Size: int64(len(partBody)),
ContentID: mimePartPath,
})
continue
}
t, h, atts := parsePartIndexedCID(partCT, partTE, partBody, childPath, cidMap)
if text == "" && t != "" {
text = t
}
if html == "" && h != "" {
html = h
}
attachments = append(attachments, atts...)
}
default:
if mt, mtParams, e := mime.ParseMediaType(contentType); e == nil {
filename := mtParams["name"]
if filename != "" && !strings.HasPrefix(strings.ToLower(mt), "text/") {
wd := mime.WordDecoder{}
if dec, e2 := wd.DecodeHeader(filename); e2 == nil {
filename = dec
}
attachments = append(attachments, gomailModels.Attachment{
Filename: filename,
ContentType: mt,
Size: int64(len(decoded)),
ContentID: mimePathString(path),
})
}
}
}
return
}
// parsePartIndexed recursively handles a MIME part, tracking MIME part path for download.
func parsePartIndexed(contentType, transferEncoding string, body []byte, path []int) (text, html string, attachments []gomailModels.Attachment) {
mediaType, params, err := mime.ParseMediaType(contentType)
if err != nil {
return string(body), "", nil
@@ -305,11 +617,15 @@ func parsePart(contentType, transferEncoding string, body []byte) (text, html st
return string(decoded), "", nil
}
mr := multipart.NewReader(bytes.NewReader(decoded), boundary)
partIdx := 0
for {
part, err := mr.NextPart()
if err != nil {
break
}
partIdx++
childPath := append(append([]int{}, path...), partIdx)
partBody, _ := io.ReadAll(part)
partCT := part.Header.Get("Content-Type")
if partCT == "" {
@@ -319,24 +635,41 @@ func parsePart(contentType, transferEncoding string, body []byte) (text, html st
disposition := part.Header.Get("Content-Disposition")
dispType, dispParams, _ := mime.ParseMediaType(disposition)
if strings.EqualFold(dispType, "attachment") {
filename := dispParams["filename"]
if filename == "" {
filename = part.FileName()
// Filename from Content-Disposition or Content-Type params
filename := dispParams["filename"]
if filename == "" {
filename = part.FileName()
}
// Decode RFC 2047 encoded filename
if filename != "" {
wd := mime.WordDecoder{}
if dec, err := wd.DecodeHeader(filename); err == nil {
filename = dec
}
}
partMedia, _, _ := mime.ParseMediaType(partCT)
isAttachment := strings.EqualFold(dispType, "attachment") ||
(filename != "" && !strings.HasPrefix(strings.ToLower(partMedia), "text/") &&
!strings.HasPrefix(strings.ToLower(partMedia), "multipart/"))
if isAttachment {
if filename == "" {
filename = "attachment"
}
partMedia, _, _ := mime.ParseMediaType(partCT)
// Build MIME part path string e.g. "1.2" for nested
mimePartPath := mimePathString(childPath)
attachments = append(attachments, gomailModels.Attachment{
Filename: filename,
ContentType: partMedia,
Size: int64(len(partBody)),
ContentID: mimePartPath, // reuse ContentID to store part path
})
continue
}
t, h, atts := parsePart(partCT, partTE, partBody)
t, h, atts := parsePartIndexed(partCT, partTE, partBody, childPath)
if text == "" && t != "" {
text = t
}
@@ -346,13 +679,35 @@ func parsePart(contentType, transferEncoding string, body []byte) (text, html st
attachments = append(attachments, atts...)
}
default:
// Any other type treat as attachment if it has a filename
mt, _, _ := mime.ParseMediaType(contentType)
_ = mt
// Any other non-text type with a filename → treat as attachment
if mt, mtParams, e := mime.ParseMediaType(contentType); e == nil {
filename := mtParams["name"]
if filename != "" && !strings.HasPrefix(strings.ToLower(mt), "text/") {
wd := mime.WordDecoder{}
if dec, e2 := wd.DecodeHeader(filename); e2 == nil {
filename = dec
}
attachments = append(attachments, gomailModels.Attachment{
Filename: filename,
ContentType: mt,
Size: int64(len(decoded)),
ContentID: mimePathString(path),
})
}
}
}
return
}
// mimePathString converts an int path like [1,2] to "1.2".
func mimePathString(path []int) string {
parts := make([]string, len(path))
for i, n := range path {
parts[i] = fmt.Sprintf("%d", n)
}
return strings.Join(parts, ".")
}
func decodeTransfer(encoding string, data []byte) []byte {
switch strings.ToLower(strings.TrimSpace(encoding)) {
case "base64":
@@ -546,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
@@ -587,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)
@@ -617,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)
@@ -638,8 +993,14 @@ func SendMessageFull(ctx context.Context, account *gomailModels.EmailAccount, re
func buildMIMEMessage(buf *bytes.Buffer, account *gomailModels.EmailAccount, req *gomailModels.ComposeRequest) string {
from := netmail.Address{Name: account.DisplayName, Address: account.EmailAddress}
boundary := fmt.Sprintf("gomail_%x", time.Now().UnixNano())
msgID := fmt.Sprintf("<%d.%s@gomail>", time.Now().UnixNano(), strings.ReplaceAll(account.EmailAddress, "@", "."))
altBoundary := fmt.Sprintf("gomail_alt_%x", time.Now().UnixNano())
mixedBoundary := fmt.Sprintf("gomail_mix_%x", time.Now().UnixNano()+1)
// Use the sender's actual domain for Message-ID so it passes spam filters
domain := account.EmailAddress
if at := strings.Index(domain, "@"); at >= 0 {
domain = domain[at+1:]
}
msgID := fmt.Sprintf("<%d.%s@%s>", time.Now().UnixNano(), strings.ReplaceAll(account.EmailAddress, "@", "."), domain)
buf.WriteString("Message-ID: " + msgID + "\r\n")
buf.WriteString("From: " + from.String() + "\r\n")
@@ -651,24 +1012,32 @@ func buildMIMEMessage(buf *bytes.Buffer, account *gomailModels.EmailAccount, req
buf.WriteString("Subject: " + encodeMIMEHeader(req.Subject) + "\r\n")
buf.WriteString("Date: " + time.Now().Format("Mon, 02 Jan 2006 15:04:05 -0700") + "\r\n")
buf.WriteString("MIME-Version: 1.0\r\n")
buf.WriteString("Content-Type: multipart/alternative; boundary=\"" + boundary + "\"\r\n")
buf.WriteString("\r\n")
// Plain text part
buf.WriteString("--" + boundary + "\r\n")
buf.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n")
qpw := quotedprintable.NewWriter(buf)
hasAttachments := len(req.Attachments) > 0
if hasAttachments {
// Outer multipart/mixed wraps body + attachments
buf.WriteString("Content-Type: multipart/mixed; boundary=\"" + mixedBoundary + "\"\r\n\r\n")
buf.WriteString("--" + mixedBoundary + "\r\n")
}
// Inner multipart/alternative: text/plain + text/html
buf.WriteString("Content-Type: multipart/alternative; boundary=\"" + altBoundary + "\"\r\n\r\n")
plainText := req.BodyText
if plainText == "" && req.BodyHTML != "" {
plainText = htmlToPlainText(req.BodyHTML)
}
buf.WriteString("--" + altBoundary + "\r\n")
buf.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n")
qpw := quotedprintable.NewWriter(buf)
qpw.Write([]byte(plainText))
qpw.Close()
buf.WriteString("\r\n")
// HTML part
buf.WriteString("--" + boundary + "\r\n")
buf.WriteString("--" + altBoundary + "\r\n")
buf.WriteString("Content-Type: text/html; charset=utf-8\r\n")
buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n")
qpw2 := quotedprintable.NewWriter(buf)
@@ -679,8 +1048,31 @@ func buildMIMEMessage(buf *bytes.Buffer, account *gomailModels.EmailAccount, req
}
qpw2.Close()
buf.WriteString("\r\n")
buf.WriteString("--" + altBoundary + "--\r\n")
if hasAttachments {
for _, att := range req.Attachments {
buf.WriteString("\r\n--" + mixedBoundary + "\r\n")
ct := att.ContentType
if ct == "" {
ct = "application/octet-stream"
}
encodedName := mime.QEncoding.Encode("utf-8", att.Filename)
buf.WriteString("Content-Type: " + ct + "; name=\"" + encodedName + "\"\r\n")
buf.WriteString("Content-Transfer-Encoding: base64\r\n")
buf.WriteString("Content-Disposition: attachment; filename=\"" + encodedName + "\"\r\n\r\n")
encoded := base64.StdEncoding.EncodeToString(att.Data)
for i := 0; i < len(encoded); i += 76 {
end := i + 76
if end > len(encoded) {
end = len(encoded)
}
buf.WriteString(encoded[i:end] + "\r\n")
}
}
buf.WriteString("\r\n--" + mixedBoundary + "--\r\n")
}
buf.WriteString("--" + boundary + "--\r\n")
return msgID
}
@@ -745,6 +1137,123 @@ func (c *Client) AppendToSent(rawMsg []byte) error {
return c.imap.Append(sentName, flags, now, bytes.NewReader(rawMsg))
}
// AppendToDrafts saves a draft message to the IMAP Drafts folder via APPEND.
// Returns the folder name that was used (for sync purposes).
func (c *Client) AppendToDrafts(rawMsg []byte) (string, error) {
mailboxes, err := c.ListMailboxes()
if err != nil {
return "", err
}
var draftsName string
for _, mb := range mailboxes {
ft := InferFolderType(mb.Name, mb.Attributes)
if ft == "drafts" {
draftsName = mb.Name
break
}
}
if draftsName == "" {
return "", nil // no Drafts folder, skip silently
}
flags := []string{imap.DraftFlag, imap.SeenFlag}
now := time.Now()
return draftsName, c.imap.Append(draftsName, flags, now, bytes.NewReader(rawMsg))
}
// FetchAttachmentRaw fetches a specific attachment from a message by fetching the full
// raw message and parsing the requested MIME part path.
func (c *Client) FetchAttachmentRaw(mailboxName string, uid uint32, mimePartPath string) ([]byte, string, string, error) {
raw, err := c.FetchRawByUID(mailboxName, uid)
if err != nil {
return nil, "", "", fmt.Errorf("fetch raw: %w", err)
}
msg, err := netmail.ReadMessage(bytes.NewReader(raw))
if err != nil {
return nil, "", "", fmt.Errorf("parse message: %w", err)
}
ct := msg.Header.Get("Content-Type")
if ct == "" {
ct = "text/plain"
}
body, _ := io.ReadAll(msg.Body)
data, filename, contentType, err := extractMIMEPart(ct, msg.Header.Get("Content-Transfer-Encoding"), body, mimePartPath)
if err != nil {
return nil, "", "", err
}
return data, filename, contentType, nil
}
// extractMIMEPart walks the MIME tree and returns the part at mimePartPath (e.g. "2" or "1.2").
func extractMIMEPart(contentType, transferEncoding string, body []byte, targetPath string) ([]byte, string, string, error) {
return extractMIMEPartAt(contentType, transferEncoding, body, targetPath, []int{})
}
func extractMIMEPartAt(contentType, transferEncoding string, body []byte, targetPath string, currentPath []int) ([]byte, string, string, error) {
mediaType, params, err := mime.ParseMediaType(contentType)
if err != nil {
return nil, "", "", fmt.Errorf("parse content-type: %w", err)
}
decoded := decodeTransfer(transferEncoding, body)
if strings.HasPrefix(strings.ToLower(mediaType), "multipart/") {
boundary := params["boundary"]
if boundary == "" {
return nil, "", "", fmt.Errorf("no boundary")
}
mr := multipart.NewReader(bytes.NewReader(decoded), boundary)
partIdx := 0
for {
part, err := mr.NextPart()
if err != nil {
break
}
partIdx++
childPath := append(append([]int{}, currentPath...), partIdx)
childPathStr := mimePathString(childPath)
partBody, _ := io.ReadAll(part)
partCT := part.Header.Get("Content-Type")
if partCT == "" {
partCT = "text/plain"
}
partTE := part.Header.Get("Content-Transfer-Encoding")
if childPathStr == targetPath {
// Found it
disposition := part.Header.Get("Content-Disposition")
_, dispParams, _ := mime.ParseMediaType(disposition)
filename := dispParams["filename"]
if filename == "" {
filename = part.FileName()
}
wd2 := mime.WordDecoder{}
if dec, e := wd2.DecodeHeader(filename); e == nil {
filename = dec
}
partMedia, _, _ := mime.ParseMediaType(partCT)
return decodeTransfer(partTE, partBody), filename, partMedia, nil
}
// Recurse into multipart children
partMedia, _, _ := mime.ParseMediaType(partCT)
if strings.HasPrefix(strings.ToLower(partMedia), "multipart/") {
if data, fn, ct2, e := extractMIMEPartAt(partCT, partTE, partBody, targetPath, childPath); e == nil && data != nil {
return data, fn, ct2, nil
}
}
}
return nil, "", "", fmt.Errorf("part %s not found", targetPath)
}
// Leaf node — only matches if path is root (empty)
if targetPath == "" || targetPath == "1" {
return decoded, "", strings.ToLower(mediaType), nil
}
return nil, "", "", fmt.Errorf("part %s not found", targetPath)
}
func htmlEscape(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
@@ -808,3 +1317,123 @@ func partPathToInts(path string) []int {
}
return result
}
// ---- Delta sync helpers ----
// FolderStatus returns the current UIDVALIDITY, UIDNEXT, and message count
// for a mailbox without fetching any messages.
type FolderStatus struct {
UIDValidity uint32
UIDNext uint32
Messages uint32
}
func (c *Client) GetFolderStatus(mailboxName string) (*FolderStatus, error) {
mbox, err := c.imap.Select(mailboxName, true)
if err != nil {
return nil, fmt.Errorf("select %s: %w", mailboxName, err)
}
return &FolderStatus{
UIDValidity: mbox.UidValidity,
UIDNext: mbox.UidNext,
Messages: mbox.Messages,
}, nil
}
// ListAllUIDs returns all UIDs currently in the mailbox. Used for purge detection.
func (c *Client) ListAllUIDs(mailboxName string) ([]uint32, error) {
mbox, err := c.imap.Select(mailboxName, true)
if err != nil {
return nil, fmt.Errorf("select %s: %w", mailboxName, err)
}
if mbox.Messages == 0 {
return nil, nil
}
uids, err := c.imap.UidSearch(imap.NewSearchCriteria())
if err != nil {
return nil, fmt.Errorf("uid search all: %w", err)
}
return uids, nil
}
// FetchNewMessages fetches only messages with UID > afterUID (incremental).
func (c *Client) FetchNewMessages(mailboxName string, afterUID uint32) ([]*gomailModels.Message, error) {
mbox, err := c.imap.Select(mailboxName, true)
if err != nil {
return nil, fmt.Errorf("select %s: %w", mailboxName, err)
}
if mbox.Messages == 0 {
return nil, nil
}
// SEARCH UID afterUID+1:*
seqSet := new(imap.SeqSet)
seqSet.AddRange(afterUID+1, ^uint32(0)) // afterUID+1 to * (max)
items := []imap.FetchItem{
imap.FetchUid, imap.FetchEnvelope,
imap.FetchFlags, imap.FetchBodyStructure,
imap.FetchRFC822,
}
ch := make(chan *imap.Message, 64)
done := make(chan error, 1)
go func() { done <- c.imap.UidFetch(seqSet, items, ch) }()
var results []*gomailModels.Message
for msg := range ch {
if msg.Uid <= afterUID {
continue // skip if server returns older (shouldn't happen)
}
m, err := parseIMAPMessage(msg, c.account)
if err != nil {
log.Printf("parse message uid=%d: %v", msg.Uid, err)
continue
}
results = append(results, m)
}
if err := <-done; err != nil {
// UID range with no results gives an error on some servers — treat as empty
if strings.Contains(err.Error(), "No matching messages") ||
strings.Contains(err.Error(), "BADUID") ||
strings.Contains(err.Error(), "UID range") {
return nil, nil
}
return results, fmt.Errorf("uid fetch new: %w", err)
}
return results, nil
}
// SyncFlags fetches FLAGS for all messages in a mailbox efficiently.
// Returns map[uid]->flags for reconciliation with local state.
func (c *Client) SyncFlags(mailboxName string) (map[uint32][]string, error) {
mbox, err := c.imap.Select(mailboxName, true)
if err != nil {
return nil, fmt.Errorf("select %s: %w", mailboxName, err)
}
if mbox.Messages == 0 {
return map[uint32][]string{}, nil
}
seqSet := new(imap.SeqSet)
seqSet.AddRange(1, mbox.Messages)
items := []imap.FetchItem{imap.FetchUid, imap.FetchFlags}
ch := make(chan *imap.Message, 256)
done := make(chan error, 1)
go func() { done <- c.imap.Fetch(seqSet, items, ch) }()
result := make(map[uint32][]string, mbox.Messages)
for msg := range ch {
result[msg.Uid] = msg.Flags
}
if err := <-done; err != nil {
return result, fmt.Errorf("fetch flags: %w", err)
}
return result, nil
}
// SelectMailbox selects a mailbox and returns its status info.
func (c *Client) SelectMailbox(name string) (*imap.MailboxStatus, error) {
return c.imap.Select(name, true)
}

97
internal/geo/geo.go Normal file
View File

@@ -0,0 +1,97 @@
// Package geo provides IP geolocation lookup using the free ip-api.com service.
// No API key is required. Rate limit: 45 requests/minute on the free tier.
// Results are cached in memory to reduce API calls.
package geo
import (
"encoding/json"
"fmt"
"log"
"net"
"net/http"
"strings"
"sync"
"time"
)
type GeoResult struct {
CountryCode string
Country string
Cached bool
}
type cacheEntry struct {
result GeoResult
fetchedAt time.Time
}
var (
mu sync.Mutex
cache = make(map[string]*cacheEntry)
)
const cacheTTL = 24 * time.Hour
// Lookup returns the country for an IP address.
// Returns empty strings on failure (private IPs, rate limit, etc.).
func Lookup(ip string) GeoResult {
// Skip private / loopback
parsed := net.ParseIP(ip)
if parsed == nil || isPrivate(parsed) {
return GeoResult{}
}
mu.Lock()
if e, ok := cache[ip]; ok && time.Since(e.fetchedAt) < cacheTTL {
mu.Unlock()
r := e.result
r.Cached = true
return r
}
mu.Unlock()
result := fetchFromAPI(ip)
mu.Lock()
cache[ip] = &cacheEntry{result: result, fetchedAt: time.Now()}
mu.Unlock()
return result
}
func fetchFromAPI(ip string) GeoResult {
url := fmt.Sprintf("http://ip-api.com/json/%s?fields=status,country,countryCode", ip)
client := &http.Client{Timeout: 3 * time.Second}
resp, err := client.Get(url)
if err != nil {
log.Printf("geo lookup failed for %s: %v", ip, err)
return GeoResult{}
}
defer resp.Body.Close()
var data struct {
Status string `json:"status"`
Country string `json:"country"`
CountryCode string `json:"countryCode"`
}
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil || data.Status != "success" {
return GeoResult{}
}
return GeoResult{
CountryCode: strings.ToUpper(data.CountryCode),
Country: data.Country,
}
}
func isPrivate(ip net.IP) bool {
privateRanges := []string{
"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16",
"127.0.0.0/8", "::1/128", "fc00::/7",
}
for _, cidr := range privateRanges {
_, network, _ := net.ParseCIDR(cidr)
if network != nil && network.Contains(ip) {
return true
}
}
return false
}

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

@@ -6,11 +6,12 @@ import (
"strconv"
"strings"
"github.com/ghostersk/gowebmail/config"
"github.com/ghostersk/gowebmail/internal/db"
"github.com/ghostersk/gowebmail/internal/geo"
"github.com/ghostersk/gowebmail/internal/middleware"
"github.com/ghostersk/gowebmail/internal/models"
"github.com/gorilla/mux"
"github.com/yourusername/gomail/config"
"github.com/yourusername/gomail/internal/db"
"github.com/yourusername/gomail/internal/middleware"
"github.com/yourusername/gomail/internal/models"
)
// AdminHandler handles /admin/* routes (all require admin role).
@@ -46,14 +47,14 @@ func (h *AdminHandler) ListUsers(w http.ResponseWriter, r *http.Request) {
}
// Sanitize: strip password hash
type safeUser struct {
ID int64 `json:"id"`
Email string `json:"email"`
Username string `json:"username"`
Role models.UserRole `json:"role"`
IsActive bool `json:"is_active"`
MFAEnabled bool `json:"mfa_enabled"`
LastLoginAt interface{} `json:"last_login_at"`
CreatedAt interface{} `json:"created_at"`
ID int64 `json:"id"`
Email string `json:"email"`
Username string `json:"username"`
Role models.UserRole `json:"role"`
IsActive bool `json:"is_active"`
MFAEnabled bool `json:"mfa_enabled"`
LastLoginAt interface{} `json:"last_login_at"`
CreatedAt interface{} `json:"created_at"`
}
result := make([]safeUser, 0, len(users))
for _, u := range users {
@@ -108,9 +109,10 @@ func (h *AdminHandler) UpdateUser(w http.ResponseWriter, r *http.Request) {
targetID, _ := strconv.ParseInt(vars["id"], 10, 64)
var req struct {
IsActive *bool `json:"is_active"`
Password string `json:"password"`
Role string `json:"role"`
IsActive *bool `json:"is_active"`
Password string `json:"password"`
Role string `json:"role"`
DisableMFA bool `json:"disable_mfa"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid request")
@@ -133,6 +135,12 @@ func (h *AdminHandler) UpdateUser(w http.ResponseWriter, r *http.Request) {
return
}
}
if req.DisableMFA {
if err := h.db.AdminDisableMFAByID(targetID); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to disable MFA")
return
}
}
adminID := middleware.GetUserID(r)
h.db.WriteAudit(&adminID, models.AuditUserUpdate,
@@ -218,3 +226,68 @@ func (h *AdminHandler) SetSettings(w http.ResponseWriter, r *http.Request) {
"changed": changed,
})
}
// ---- IP Blocks ----
func (h *AdminHandler) ListIPBlocks(w http.ResponseWriter, r *http.Request) {
blocks, err := h.db.ListIPBlocks()
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to list blocks")
return
}
if blocks == nil {
blocks = []db.IPBlock{}
}
h.writeJSON(w, map[string]interface{}{"blocks": blocks})
}
func (h *AdminHandler) AddIPBlock(w http.ResponseWriter, r *http.Request) {
var req struct {
IP string `json:"ip"`
Reason string `json:"reason"`
BanHours int `json:"ban_hours"` // 0 = permanent
}
json.NewDecoder(r.Body).Decode(&req)
if req.IP == "" {
h.writeError(w, http.StatusBadRequest, "ip required")
return
}
// Try geo lookup for the IP being manually blocked
g := geo.Lookup(req.IP)
if req.Reason == "" {
req.Reason = "Manual admin block"
}
h.db.BlockIP(req.IP, req.Reason, g.Country, g.CountryCode, 0, req.BanHours)
adminID := middleware.GetUserID(r)
h.db.WriteAudit(&adminID, models.AuditConfigChange, "manual IP block: "+req.IP, middleware.ClientIP(r), r.UserAgent())
h.writeJSON(w, map[string]bool{"ok": true})
}
func (h *AdminHandler) RemoveIPBlock(w http.ResponseWriter, r *http.Request) {
ip := mux.Vars(r)["ip"]
if ip == "" {
h.writeError(w, http.StatusBadRequest, "ip required")
return
}
if err := h.db.UnblockIP(ip); err != nil {
h.writeError(w, http.StatusInternalServerError, "unblock failed")
return
}
adminID := middleware.GetUserID(r)
h.db.WriteAudit(&adminID, models.AuditConfigChange, "unblocked IP: "+ip, middleware.ClientIP(r), r.UserAgent())
h.writeJSON(w, map[string]bool{"ok": true})
}
// ---- Login Attempts ----
func (h *AdminHandler) ListLoginAttempts(w http.ResponseWriter, r *http.Request) {
stats, err := h.db.ListLoginAttemptStats(72) // last 72 hours
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to query attempts")
return
}
if stats == nil {
stats = []db.LoginAttemptStat{}
}
h.writeJSON(w, map[string]interface{}{"attempts": stats})
}

File diff suppressed because it is too large Load Diff

View File

@@ -3,8 +3,8 @@ package handlers
import (
"net/http"
"github.com/yourusername/gomail/config"
"github.com/yourusername/gomail/internal/db"
"github.com/ghostersk/gowebmail/config"
"github.com/ghostersk/gowebmail/internal/db"
)
// AppHandler serves the main app pages using the shared Renderer.
@@ -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,16 +4,22 @@ import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"html"
"log"
"net"
"net/http"
"strings"
"time"
"github.com/yourusername/gomail/config"
goauth "github.com/yourusername/gomail/internal/auth"
"github.com/yourusername/gomail/internal/crypto"
"github.com/yourusername/gomail/internal/db"
"github.com/yourusername/gomail/internal/mfa"
"github.com/yourusername/gomail/internal/middleware"
"github.com/yourusername/gomail/internal/models"
"github.com/ghostersk/gowebmail/internal/logger"
"github.com/ghostersk/gowebmail/config"
goauth "github.com/ghostersk/gowebmail/internal/auth"
"github.com/ghostersk/gowebmail/internal/crypto"
"github.com/ghostersk/gowebmail/internal/db"
"github.com/ghostersk/gowebmail/internal/mfa"
"github.com/ghostersk/gowebmail/internal/middleware"
"github.com/ghostersk/gowebmail/internal/models"
"golang.org/x/oauth2"
)
@@ -23,6 +29,7 @@ type AuthHandler struct {
db *db.DB
cfg *config.Config
renderer *Renderer
syncer interface{ TriggerReconcile() }
}
// ---- Login ----
@@ -53,6 +60,17 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
return
}
// Per-user IP access check — evaluated before password to avoid timing leaks
switch h.db.CheckUserIPAccess(user.ID, ip) {
case "deny":
h.db.WriteAudit(&user.ID, models.AuditLoginFail, "IP not in allow-list: "+ip, ip, ua)
http.Redirect(w, r, "/auth/login?error=location_not_authorized", http.StatusFound)
return
case "skip_brute":
// Signal the BruteForceProtect middleware to skip failure counting for this user/IP
w.Header().Set("X-Skip-Brute", "1")
}
if err := crypto.CheckPassword(password, user.PasswordHash); err != nil {
uid := user.ID
h.db.WriteAudit(&uid, models.AuditLoginFail, "bad password for: "+username, ip, ua)
@@ -142,8 +160,8 @@ func (h *AuthHandler) MFASetupBegin(w http.ResponseWriter, r *http.Request) {
return
}
qr := mfa.QRCodeURL("GoMail", user.Email, secret)
otpURL := mfa.OTPAuthURL("GoMail", user.Email, secret)
qr := mfa.QRCodeURL("GoWebMail", user.Email, secret)
otpURL := mfa.OTPAuthURL("GoWebMail", user.Email, secret)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
@@ -155,7 +173,9 @@ func (h *AuthHandler) MFASetupBegin(w http.ResponseWriter, r *http.Request) {
func (h *AuthHandler) MFASetupConfirm(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
var req struct{ Code string `json:"code"` }
var req struct {
Code string `json:"code"`
}
json.NewDecoder(r.Body).Decode(&req)
user, _ := h.db.GetUserByID(userID)
@@ -181,7 +201,9 @@ func (h *AuthHandler) MFASetupConfirm(w http.ResponseWriter, r *http.Request) {
func (h *AuthHandler) MFADisable(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
var req struct{ Code string `json:"code"` }
var req struct {
Code string `json:"code"`
}
json.NewDecoder(r.Body).Decode(&req)
user, _ := h.db.GetUserByID(userID)
@@ -295,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)
}
@@ -315,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)
@@ -331,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)
}
@@ -399,3 +518,217 @@ func writeJSONError(w http.ResponseWriter, status int, msg string) {
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]string{"error": msg})
}
// ---- Profile Updates ----
func (h *AuthHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
user, err := h.db.GetUserByID(userID)
if err != nil || user == nil {
writeJSONError(w, http.StatusUnauthorized, "not authenticated")
return
}
var req struct {
Field string `json:"field"` // "email" | "username"
Value string `json:"value"`
Password string `json:"password"` // current password required for confirmation
}
json.NewDecoder(r.Body).Decode(&req)
if req.Value == "" {
writeJSONError(w, http.StatusBadRequest, "value required")
return
}
if req.Password == "" {
writeJSONError(w, http.StatusBadRequest, "current password required to confirm profile changes")
return
}
if err := crypto.CheckPassword(req.Password, user.PasswordHash); err != nil {
writeJSONError(w, http.StatusForbidden, "incorrect password")
return
}
switch req.Field {
case "email":
// Check uniqueness
existing, _ := h.db.GetUserByEmail(req.Value)
if existing != nil && existing.ID != userID {
writeJSONError(w, http.StatusConflict, "email already in use")
return
}
if err := h.db.UpdateUserEmail(userID, req.Value); err != nil {
writeJSONError(w, http.StatusInternalServerError, "failed to update email")
return
}
case "username":
existing, _ := h.db.GetUserByUsername(req.Value)
if existing != nil && existing.ID != userID {
writeJSONError(w, http.StatusConflict, "username already in use")
return
}
if err := h.db.UpdateUserUsername(userID, req.Value); err != nil {
writeJSONError(w, http.StatusInternalServerError, "failed to update username")
return
}
default:
writeJSONError(w, http.StatusBadRequest, "field must be 'email' or 'username'")
return
}
ip := middleware.ClientIP(r)
h.db.WriteAudit(&userID, models.AuditUserUpdate, "profile update: "+req.Field, ip, r.UserAgent())
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]bool{"ok": true})
}
// ---- Per-User IP Rules ----
func (h *AuthHandler) GetUserIPRule(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
rule, err := h.db.GetUserIPRule(userID)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "db error")
return
}
if rule == nil {
rule = &db.UserIPRule{UserID: userID, Mode: "disabled", IPList: ""}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(rule)
}
func (h *AuthHandler) SetUserIPRule(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
var req struct {
Mode string `json:"mode"` // "disabled" | "brute_skip" | "allow_only"
IPList string `json:"ip_list"` // comma-separated
}
json.NewDecoder(r.Body).Decode(&req)
validModes := map[string]bool{"disabled": true, "brute_skip": true, "allow_only": true}
if !validModes[req.Mode] {
writeJSONError(w, http.StatusBadRequest, "mode must be disabled, brute_skip, or allow_only")
return
}
// Validate IPs
for _, rawIP := range db.SplitIPList(req.IPList) {
if net.ParseIP(rawIP) == nil {
writeJSONError(w, http.StatusBadRequest, "invalid IP address: "+rawIP)
return
}
}
if req.Mode == "disabled" {
h.db.DeleteUserIPRule(userID)
} else {
if err := h.db.SetUserIPRule(userID, req.Mode, req.IPList); err != nil {
writeJSONError(w, http.StatusInternalServerError, "failed to save rule")
return
}
}
ip := middleware.ClientIP(r)
h.db.WriteAudit(&userID, models.AuditUserUpdate, "IP rule updated: "+req.Mode, ip, r.UserAgent())
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]bool{"ok": true})
}
// ---- 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

@@ -3,9 +3,9 @@ package handlers
import (
"log"
"github.com/yourusername/gomail/config"
"github.com/yourusername/gomail/internal/db"
"github.com/yourusername/gomail/internal/syncer"
"github.com/ghostersk/gowebmail/config"
"github.com/ghostersk/gowebmail/internal/db"
"github.com/ghostersk/gowebmail/internal/syncer"
)
type Handlers struct {
@@ -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

@@ -4,9 +4,11 @@ import (
"bytes"
"fmt"
"html/template"
"io/fs"
"log"
"net/http"
"path/filepath"
"github.com/ghostersk/gowebmail"
)
// Renderer holds one compiled *template.Template per page name.
@@ -16,11 +18,6 @@ type Renderer struct {
templates map[string]*template.Template
}
const (
tmplBase = "web/templates/base.html"
tmplDir = "web/templates"
)
// NewRenderer parses every page template paired with the base layout.
// Call once at startup; fails fast if any template has a syntax error.
func NewRenderer() (*Renderer, error) {
@@ -29,20 +26,26 @@ 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 {
log.Fatalf("embed templates fs: %v", err)
}
r := &Renderer{templates: make(map[string]*template.Template, len(pages))}
for _, page := range pages {
pagePath := filepath.Join(tmplDir, page)
// New instance per page — base FIRST, then the page file.
// This means the page's {{define}} blocks override the base's {{block}} defaults
// without any other page's definitions being present in the same pool.
t, err := template.New("base").ParseFiles(tmplBase, pagePath)
t, err := template.ParseFS(templateFS, "base.html", page)
if err != nil {
return nil, fmt.Errorf("renderer: parse %s: %w", page, err)
}
name := page[:len(page)-5] // strip ".html"
name := page[:len(page)-5]
r.templates[name] = t
log.Printf("renderer: loaded template %q", name)
}

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

@@ -10,6 +10,7 @@ import (
"encoding/base32"
"encoding/binary"
"fmt"
"log"
"math"
"net/url"
"strings"
@@ -17,9 +18,9 @@ import (
)
const (
totpDigits = 6
totpPeriod = 30 // seconds
totpWindow = 1 // accept ±1 period to allow for clock skew
totpDigits = 6
totpPeriod = 30 // seconds
totpWindow = 2 // accept ±2 periods (±60s) to handle clock skew and slow input
)
// GenerateSecret creates a new random 20-byte (160-bit) TOTP secret,
@@ -33,7 +34,7 @@ func GenerateSecret() (string, error) {
}
// OTPAuthURL builds an otpauth:// URI for QR code generation.
// issuer is the application name (e.g. "GoMail"), accountName is the user's email.
// issuer is the application name (e.g. "GoWebMail"), accountName is the user's email.
func OTPAuthURL(issuer, accountName, secret string) string {
v := url.Values{}
v.Set("secret", secret)
@@ -58,15 +59,19 @@ func QRCodeURL(issuer, accountName, secret string) string {
// Validate checks whether code is a valid TOTP code for secret at the current time.
// It accepts codes from [now-window*period, now+window*period] to handle clock skew.
// Handles both padded and unpadded base32 secrets.
func Validate(secret, code string) bool {
code = strings.TrimSpace(code)
if len(code) != totpDigits {
return false
}
keyBytes, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(
strings.ToUpper(secret),
)
// Normalise: uppercase, strip spaces and padding, then re-decode.
// Accept both padded (JBSWY3DP====) and unpadded (JBSWY3DP) base32.
cleaned := strings.ToUpper(strings.ReplaceAll(secret, " ", ""))
cleaned = strings.TrimRight(cleaned, "=")
keyBytes, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(cleaned)
if err != nil {
log.Printf("mfa: base32 decode error (secret len=%d): %v", len(secret), err)
return false
}
now := time.Now().Unix()

View File

@@ -1,17 +1,21 @@
// Package middleware provides HTTP middleware for GoMail.
// Package middleware provides HTTP middleware for GoWebMail.
package middleware
import (
"context"
"fmt"
"html/template"
"log"
"net"
"net/http"
"strings"
"time"
"github.com/yourusername/gomail/config"
"github.com/yourusername/gomail/internal/db"
"github.com/yourusername/gomail/internal/models"
"github.com/ghostersk/gowebmail/config"
"github.com/ghostersk/gowebmail/internal/db"
"github.com/ghostersk/gowebmail/internal/geo"
"github.com/ghostersk/gowebmail/internal/models"
"github.com/ghostersk/gowebmail/internal/notify"
)
type contextKey string
@@ -47,7 +51,7 @@ func SecurityHeaders(next http.Handler) http.Handler {
w.Header().Set("X-XSS-Protection", "1; mode=block")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
w.Header().Set("Content-Security-Policy",
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https://api.qrserver.com;")
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src * data: blob: cid:; frame-src 'self' blob: data:;")
next.ServeHTTP(w, r)
})
}
@@ -117,9 +121,13 @@ func RequireAdmin(next http.Handler) http.Handler {
role, _ := r.Context().Value(UserRoleKey).(models.UserRole)
if role != models.RoleAdmin {
if isAPIPath(r) {
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
fmt.Fprint(w, `{"error":"forbidden"}`)
} else {
http.Error(w, "403 Forbidden", http.StatusForbidden)
renderErrorPage(w, r, http.StatusForbidden,
"Access Denied",
"You don't have permission to access this page. Admin privileges are required.")
}
return
}
@@ -169,3 +177,213 @@ func ClientIP(r *http.Request) string {
}
return r.RemoteAddr
}
// BruteForceProtect wraps the login POST handler with rate-limiting and geo-blocking.
// It must be called with the raw handler so it can intercept BEFORE auth.
func BruteForceProtect(database *db.DB, cfg *config.Config, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := cfg.RealIP(r.RemoteAddr, r.Header.Get("X-Forwarded-For"))
// Whitelist check runs FIRST — whitelisted IPs bypass all blocking entirely.
if cfg.IsIPWhitelisted(ip) {
next.ServeHTTP(w, r)
return
}
// Resolve country for geo-block and attempt recording.
// Only do a live lookup for non-GET to save API quota; GET uses cache only.
geoResult := geo.Lookup(ip)
// --- Geo block (apply to all requests) ---
if geoResult.CountryCode != "" {
if !cfg.IsCountryAllowed(geoResult.CountryCode) {
log.Printf("geo-block: %s (%s %s)", ip, geoResult.CountryCode, geoResult.Country)
renderErrorPage(w, r, http.StatusForbidden,
"Access Denied",
"Access from your country is not permitted.")
return
}
}
if !cfg.BruteEnabled || r.Method != http.MethodPost {
next.ServeHTTP(w, r)
return
}
// Check if already blocked
if database.IsIPBlocked(ip) {
renderErrorPage(w, r, http.StatusForbidden,
"IP Address Blocked",
"Your IP address has been temporarily blocked due to too many failed login attempts. Please contact the administrator.")
return
}
// Wrap the response writer to detect a failed login (redirect to error vs success)
rw := &loginResponseCapture{ResponseWriter: w, statusCode: 200}
next.ServeHTTP(rw, r)
// Determine success: a redirect away from login = success
success := rw.statusCode == http.StatusFound && !strings.Contains(rw.location, "error=")
username := r.FormValue("username")
database.RecordLoginAttempt(ip, username, geoResult.Country, geoResult.CountryCode, success)
if !success && !rw.skipBrute {
failures := database.CountRecentFailures(ip, cfg.BruteWindowMins)
if failures >= cfg.BruteMaxAttempts {
reason := "Too many failed logins"
database.BlockIP(ip, reason, geoResult.Country, geoResult.CountryCode, failures, cfg.BruteBanHours)
log.Printf("brute-force block: %s (%d failures in %d min, ban %d hrs)",
ip, failures, cfg.BruteWindowMins, cfg.BruteBanHours)
// Send security notification to the targeted user (non-blocking goroutine)
go func(targetUsername string) {
user, _ := database.GetUserByUsername(targetUsername)
if user == nil {
user, _ = database.GetUserByEmail(targetUsername)
}
if user != nil && user.Email != "" {
notify.SendBruteForceAlert(cfg, notify.BruteForceAlert{
Username: user.Username,
ToEmail: user.Email,
AttackerIP: ip,
Country: geoResult.Country,
CountryCode: geoResult.CountryCode,
Attempts: failures,
BlockedAt: time.Now().UTC(),
BanHours: cfg.BruteBanHours,
Hostname: cfg.Hostname,
})
}
}(username)
}
}
})
}
// loginResponseCapture captures the redirect location and skip-brute signal from the login handler.
type loginResponseCapture struct {
http.ResponseWriter
statusCode int
location string
skipBrute bool
}
func (lrc *loginResponseCapture) WriteHeader(code int) {
lrc.statusCode = code
lrc.location = lrc.ResponseWriter.Header().Get("Location")
if lrc.Header().Get("X-Skip-Brute") == "1" {
lrc.skipBrute = true
lrc.Header().Del("X-Skip-Brute") // strip before sending to client
}
lrc.ResponseWriter.WriteHeader(code)
}
// ServeErrorPage is the public wrapper used by main.go for 404/405 handlers.
func ServeErrorPage(w http.ResponseWriter, r *http.Request, status int, title, message string) {
renderErrorPage(w, r, status, title, message)
}
// renderErrorPage writes a themed HTML error page for browser requests,
// or a JSON error for API paths.
func renderErrorPage(w http.ResponseWriter, r *http.Request, status int, title, message string) {
if isAPIPath(r) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
fmt.Fprintf(w, `{"error":%q}`, message)
return
}
// 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
BackLabel string
}{status, title, message, backHref, backLabel}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
if err := errorPageTmpl.Execute(w, data); err != nil {
// Last-resort plain text fallback
fmt.Fprintf(w, "%d %s: %s", status, title, message)
}
}
var errorPageTmpl = template.Must(template.New("error").Parse(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Status}} {{.Title}}</title>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/css/gowebmail.css">
<style>
html, body { height: 100%; margin: 0; }
.error-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg, #18191b);
font-family: 'DM Sans', sans-serif;
}
.error-card {
background: var(--surface, #232428);
border: 1px solid var(--border, #2e2f34);
border-radius: 16px;
padding: 48px 56px;
text-align: center;
max-width: 480px;
width: 90%;
box-shadow: 0 8px 32px rgba(0,0,0,.4);
}
.error-code {
font-size: 64px;
font-weight: 700;
color: var(--accent, #6b8afd);
line-height: 1;
margin: 0 0 8px;
letter-spacing: -2px;
}
.error-title {
font-size: 20px;
font-weight: 600;
color: var(--text, #e8e9ed);
margin: 0 0 12px;
}
.error-message {
font-size: 14px;
color: var(--muted, #8b8d97);
line-height: 1.6;
margin: 0 0 32px;
}
.error-back {
display: inline-block;
padding: 10px 24px;
background: var(--accent, #6b8afd);
color: #fff;
border-radius: 8px;
text-decoration: none;
font-size: 14px;
font-weight: 500;
transition: opacity .15s;
}
.error-back:hover { opacity: .85; }
</style>
</head>
<body>
<div class="error-page">
<div class="error-card">
<div class="error-code">{{.Status}}</div>
<h1 class="error-title">{{.Title}}</h1>
<p class="error-message">{{.Message}}</p>
<a href="{{.BackHref}}" class="error-back">{{.BackLabel}}</a>
</div>
</div>
</body>
</html>`))

View File

@@ -4,7 +4,7 @@ import "time"
// ---- Users ----
// UserRole controls access level within GoMail.
// UserRole controls access level within GoWebMail.
type UserRole string
const (
@@ -12,7 +12,7 @@ const (
RoleUser UserRole = "user"
)
// User represents a GoMail application user.
// User represents a GoWebMail application user.
type User struct {
ID int64 `json:"id"`
Email string `json:"email"`
@@ -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"`
}
@@ -125,6 +127,8 @@ type Folder struct {
FolderType string `json:"folder_type"` // inbox, sent, drafts, trash, spam, custom
UnreadCount int `json:"unread_count"`
TotalCount int `json:"total_count"`
IsHidden bool `json:"is_hidden"`
SyncEnabled bool `json:"sync_enabled"`
}
// ---- Messages ----
@@ -211,6 +215,8 @@ type ComposeRequest struct {
// For reply/forward
InReplyToID int64 `json:"in_reply_to_id,omitempty"`
ForwardFromID int64 `json:"forward_from_id,omitempty"`
// Attachments: populated from multipart/form-data or inline base64
Attachments []Attachment `json:"attachments,omitempty"`
}
// ---- Search ----
@@ -237,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"`
}

201
internal/notify/notify.go Normal file
View File

@@ -0,0 +1,201 @@
// Package notify sends security alert emails using a configurable SMTP relay.
// It supports both authenticated and unauthenticated (relay-only) SMTP servers.
package notify
import (
"bytes"
"crypto/tls"
"fmt"
"log"
"net"
"net/smtp"
"strings"
"text/template"
"time"
"github.com/ghostersk/gowebmail/config"
)
// BruteForceAlert holds the data for the brute-force notification email.
type BruteForceAlert struct {
Username string
ToEmail string
AttackerIP string
Country string
CountryCode string
Attempts int
BlockedAt time.Time
BanHours int // 0 = permanent
AppName string
Hostname string
}
var bruteForceTemplate = template.Must(template.New("brute").Parse(`From: {{.AppName}} Security <{{.From}}>
To: {{.ToEmail}}
Subject: Security Alert: Failed login attempts on your account
MIME-Version: 1.0
Content-Type: text/plain; charset=utf-8
Hello {{.Username}},
This is an automated security alert from {{.AppName}} ({{.Hostname}}).
We detected multiple failed login attempts on your account and have
automatically blocked the source IP address.
Account targeted : {{.Username}}
Source IP : {{.AttackerIP}}
{{- if .Country}}
Country : {{.Country}} ({{.CountryCode}})
{{- end}}
Failed attempts : {{.Attempts}}
Detected at : {{.BlockedAt.Format "2006-01-02 15:04:05 UTC"}}
{{- if eq .BanHours 0}}
Block duration : Permanent (administrator action required to unblock)
{{- else}}
Block duration : {{.BanHours}} hours
{{- end}}
If this was you, you may have mistyped your password. The block will
{{- if eq .BanHours 0}} remain until removed by an administrator.
{{- else}} expire automatically after {{.BanHours}} hours.{{end}}
If you did not attempt to log in, your account credentials may be at
risk. We recommend changing your password as soon as possible.
This is an automated message. Please do not reply.
--
{{.AppName}} Security
{{.Hostname}}
`))
type templateData struct {
BruteForceAlert
From string
}
// SendBruteForceAlert sends a security notification email to the targeted user.
// It runs in a goroutine — errors are logged but not returned.
func SendBruteForceAlert(cfg *config.Config, alert BruteForceAlert) {
if !cfg.NotifyEnabled || cfg.NotifySMTPHost == "" || cfg.NotifyFrom == "" {
return
}
if alert.ToEmail == "" {
return
}
go func() {
if err := sendAlert(cfg, alert); err != nil {
log.Printf("notify: failed to send brute-force alert to %s: %v", alert.ToEmail, err)
} else {
log.Printf("notify: sent brute-force alert to %s (attacker: %s)", alert.ToEmail, alert.AttackerIP)
}
}()
}
func sendAlert(cfg *config.Config, alert BruteForceAlert) error {
if alert.AppName == "" {
alert.AppName = "GoWebMail"
}
if alert.Hostname == "" {
alert.Hostname = cfg.Hostname
}
data := templateData{BruteForceAlert: alert, From: cfg.NotifyFrom}
var buf bytes.Buffer
if err := bruteForceTemplate.Execute(&buf, data); err != nil {
return fmt.Errorf("template execute: %w", err)
}
addr := fmt.Sprintf("%s:%d", cfg.NotifySMTPHost, cfg.NotifySMTPPort)
// Choose auth method
var auth smtp.Auth
if cfg.NotifyUser != "" && cfg.NotifyPass != "" {
auth = smtp.PlainAuth("", cfg.NotifyUser, cfg.NotifyPass, cfg.NotifySMTPHost)
}
// Try STARTTLS first (port 587), fall back to plain, support TLS on 465
if cfg.NotifySMTPPort == 465 {
return sendTLS(addr, cfg.NotifySMTPHost, auth, cfg.NotifyFrom, alert.ToEmail, buf.Bytes())
}
return sendSTARTTLS(addr, cfg.NotifySMTPHost, auth, cfg.NotifyFrom, alert.ToEmail, buf.Bytes())
}
// sendSTARTTLS sends via plain SMTP with optional STARTTLS upgrade (ports 25, 587).
func sendSTARTTLS(addr, host string, auth smtp.Auth, from, to string, msg []byte) error {
c, err := smtp.Dial(addr)
if err != nil {
return fmt.Errorf("dial %s: %w", addr, err)
}
defer c.Close()
// Try STARTTLS — not all servers require it (plain relay servers often skip it)
if ok, _ := c.Extension("STARTTLS"); ok {
tlsCfg := &tls.Config{ServerName: host}
if err := c.StartTLS(tlsCfg); err != nil {
// Log but continue — some relays advertise STARTTLS but don't enforce it
log.Printf("notify: STARTTLS failed for %s, continuing unencrypted: %v", host, err)
}
}
if auth != nil {
if err := c.Auth(auth); err != nil {
return fmt.Errorf("smtp auth: %w", err)
}
}
return sendMessage(c, from, to, msg)
}
// sendTLS sends via direct TLS connection (port 465).
func sendTLS(addr, host string, auth smtp.Auth, from, to string, msg []byte) error {
tlsCfg := &tls.Config{ServerName: host}
conn, err := tls.Dial("tcp", addr, tlsCfg)
if err != nil {
return fmt.Errorf("tls dial %s: %w", addr, err)
}
// Resolve host for the smtp.NewClient call
bareHost, _, _ := net.SplitHostPort(addr)
if bareHost == "" {
bareHost = host
}
c, err := smtp.NewClient(conn, bareHost)
if err != nil {
return fmt.Errorf("smtp client: %w", err)
}
defer c.Close()
if auth != nil {
if err := c.Auth(auth); err != nil {
return fmt.Errorf("smtp auth: %w", err)
}
}
return sendMessage(c, from, to, msg)
}
func sendMessage(c *smtp.Client, from, to string, msg []byte) error {
if err := c.Mail(from); err != nil {
return fmt.Errorf("MAIL FROM: %w", err)
}
if err := c.Rcpt(to); err != nil {
return fmt.Errorf("RCPT TO: %w", err)
}
w, err := c.Data()
if err != nil {
return fmt.Errorf("DATA: %w", err)
}
// Normalise line endings to CRLF
normalized := strings.ReplaceAll(string(msg), "\r\n", "\n")
normalized = strings.ReplaceAll(normalized, "\n", "\r\n")
if _, err := w.Write([]byte(normalized)); err != nil {
return fmt.Errorf("write body: %w", err)
}
if err := w.Close(); err != nil {
return fmt.Errorf("close data: %w", err)
}
return c.Quit()
}

View File

@@ -1,79 +1,666 @@
// Package syncer provides background IMAP synchronisation for all active accounts.
// Architecture:
// - One goroutine per account runs IDLE on the INBOX to receive push notifications.
// - A separate drain goroutine flushes pending_imap_ops (delete/move/flag writes).
// - Periodic full-folder delta sync catches changes made by other clients.
package syncer
import (
"context"
"fmt"
"log"
"strings"
"sync"
"time"
"github.com/yourusername/gomail/internal/db"
"github.com/yourusername/gomail/internal/email"
"github.com/yourusername/gomail/internal/models"
"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 runs background sync for all active accounts according to their
// individual sync_interval settings.
// 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. Call Start() to begin background syncing.
func New(database *db.DB) *Scheduler {
return &Scheduler{db: database, stop: make(chan struct{})}
// New creates a new Scheduler.
func New(database *db.DB, cfg *config.Config) *Scheduler {
return &Scheduler{
db: database,
cfg: cfg,
stop: make(chan struct{}),
pushCh: make(map[int64]chan struct{}),
reconcileCh: make(chan struct{}, 1),
}
}
// Start launches the scheduler goroutine. Ticks every minute and checks
// which accounts are due for sync based on last_sync and sync_interval.
// 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:
}
}
// Start launches all background goroutines.
func (s *Scheduler) Start() {
s.wg.Add(1)
go func() {
log.Println("Background sync scheduler started")
s.runDue()
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ticker.C:
s.runDue()
case <-s.stop:
log.Println("Background sync scheduler stopped")
return
}
}
defer s.wg.Done()
s.mainLoop()
}()
log.Println("[sync] scheduler started")
}
// Stop signals the scheduler to exit.
// Stop signals all goroutines to exit and waits for them.
func (s *Scheduler) Stop() {
close(s.stop)
s.wg.Wait()
log.Println("[sync] scheduler stopped")
}
func (s *Scheduler) runDue() {
// TriggerAccountSync signals an immediate sync for an account (called after IMAP write ops).
func (s *Scheduler) TriggerAccountSync(accountID int64) {
s.pushMu.Lock()
ch, ok := s.pushCh[accountID]
s.pushMu.Unlock()
if ok {
select {
case ch <- struct{}{}:
default: // already pending
}
}
}
// ---- Main coordination loop ----
func (s *Scheduler) mainLoop() {
// Ticker for the outer "check which accounts are due" loop.
// Runs every 30s; individual accounts control their own interval.
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
// Track per-account goroutines so we only launch one per account.
type accountWorker struct {
stop chan struct{}
pushCh chan struct{}
}
workers := make(map[int64]*accountWorker)
spawnWorker := func(account *models.EmailAccount) {
if _, exists := workers[account.ID]; exists {
return
}
w := &accountWorker{
stop: make(chan struct{}),
pushCh: make(chan struct{}, 1),
}
workers[account.ID] = w
s.pushMu.Lock()
s.pushCh[account.ID] = w.pushCh
s.pushMu.Unlock()
s.wg.Add(1)
go func(acc *models.EmailAccount, w *accountWorker) {
defer s.wg.Done()
s.accountWorker(acc, w.stop, w.pushCh)
}(account, w)
}
stopWorker := func(accountID int64) {
if w, ok := workers[accountID]; ok {
close(w.stop)
delete(workers, accountID)
s.pushMu.Lock()
delete(s.pushCh, accountID)
s.pushMu.Unlock()
}
}
// Initial spawn
s.spawnForActive(spawnWorker)
for {
select {
case <-s.stop:
for id := range workers {
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))
for id := range workers {
activeIDs[id] = true
}
s.reconcileWorkers(activeIDs, spawnWorker, stopWorker)
}
}
}
func (s *Scheduler) spawnForActive(spawn func(*models.EmailAccount)) {
accounts, err := s.db.ListAllActiveAccounts()
if err != nil {
log.Printf("Sync scheduler: list accounts: %v", err)
log.Printf("[sync] list accounts: %v", err)
return
}
now := time.Now()
for _, account := range accounts {
if account.SyncInterval <= 0 {
continue
for _, acc := range accounts {
spawn(acc)
}
}
func (s *Scheduler) reconcileWorkers(
activeIDs map[int64]bool,
spawn func(*models.EmailAccount),
stop func(int64),
) {
accounts, err := s.db.ListAllActiveAccounts()
if err != nil {
return
}
serverActive := make(map[int64]bool)
for _, acc := range accounts {
serverActive[acc.ID] = true
if !activeIDs[acc.ID] {
spawn(acc)
}
nextSync := account.LastSync.Add(time.Duration(account.SyncInterval) * time.Minute)
if account.LastSync.IsZero() || now.After(nextSync) {
go s.syncAccount(account)
}
for id := range activeIDs {
if !serverActive[id] {
stop(id)
}
}
}
// SyncAccountNow performs an immediate sync of one account. Returns messages synced.
// ---- Per-account worker ----
// Each worker:
// 1. On startup: drain pending ops, then do a full delta sync.
// 2. Runs an IDLE loop on INBOX for push notifications.
// 3. Every syncInterval minutes (or on push signal): delta sync all enabled folders.
// 4. Every 2 minutes: drain pending ops (retries failed writes).
func (s *Scheduler) accountWorker(account *models.EmailAccount, stop chan struct{}, push chan struct{}) {
log.Printf("[sync] worker started for %s", account.EmailAddress)
// Fresh account data function (interval can change at runtime)
getAccount := func() *models.EmailAccount {
a, _ := s.db.GetAccount(account.ID)
if a == nil {
return account
}
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())
// Drain ticker: retry pending ops every 90 seconds
drainTicker := time.NewTicker(90 * time.Second)
defer drainTicker.Stop()
// Full sync ticker: based on account sync_interval, check every 30s
syncTicker := time.NewTicker(30 * time.Second)
defer syncTicker.Stop()
// IDLE watcher for INBOX push notifications
idleCh := make(chan struct{}, 1)
s.wg.Add(1)
go func() {
defer s.wg.Done()
s.idleWatcher(account, stop, idleCh)
}()
for {
select {
case <-stop:
log.Printf("[sync] worker stopped for %s", account.EmailAddress)
return
case <-drainTicker.C:
s.drainPendingOps(getAccount())
case <-idleCh:
// Server signalled new mail/changes in INBOX — sync just INBOX
acc := getAccount()
s.syncInbox(acc)
case <-push:
// Local trigger (after write op) — drain ops then sync
acc := getAccount()
s.drainPendingOps(acc)
s.deltaSync(acc)
case <-syncTicker.C:
acc := getAccount()
if acc.SyncInterval <= 0 {
continue
}
nextSync := acc.LastSync.Add(time.Duration(acc.SyncInterval) * time.Minute)
if acc.LastSync.IsZero() || time.Now().After(nextSync) {
s.deltaSync(acc)
}
}
}
}
// ---- IDLE watcher ----
// Maintains a persistent IMAP connection to INBOX and issues IDLE.
// When EXISTS or EXPUNGE arrives, sends to idleCh.
func (s *Scheduler) idleWatcher(account *models.EmailAccount, stop chan struct{}, idleCh chan struct{}) {
const reconnectDelay = 30 * time.Second
const idleTimeout = 25 * time.Minute // RFC 2177 recommends < 29min
signal := func() {
select {
case idleCh <- struct{}{}:
default:
}
}
for {
select {
case <-stop:
return
default:
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
account = s.ensureFreshToken(account)
c, err := email.Connect(ctx, account)
cancel()
if err != nil {
log.Printf("[idle:%s] connect: %v — retry in %s", account.EmailAddress, err, reconnectDelay)
select {
case <-stop:
return
case <-time.After(reconnectDelay):
continue
}
}
// Select INBOX
_, err = c.SelectMailbox("INBOX")
if err != nil {
c.Close()
select {
case <-stop:
return
case <-time.After(reconnectDelay):
continue
}
}
// IDLE loop — go-imap v1 does not have built-in IDLE, we poll with short
// CHECK + NOOP and rely on the EXISTS response to wake us.
// We use a 1-minute poll since go-imap v1 doesn't expose IDLE directly.
pollTicker := time.NewTicker(60 * time.Second)
idleTimer := time.NewTimer(idleTimeout)
pollLoop:
for {
select {
case <-stop:
pollTicker.Stop()
idleTimer.Stop()
c.Close()
return
case <-idleTimer.C:
// Reconnect to keep connection alive
pollTicker.Stop()
c.Close()
break pollLoop
case <-pollTicker.C:
// Poll server for changes
status, err := c.GetFolderStatus("INBOX")
if err != nil {
log.Printf("[idle:%s] status check: %v", account.EmailAddress, err)
pollTicker.Stop()
idleTimer.Stop()
c.Close()
break pollLoop
}
// Check if message count changed
localCount := s.db.GetFolderMessageCount(account.ID, "INBOX")
if status.Messages != uint32(localCount) {
signal()
}
}
}
select {
case <-stop:
return
case <-time.After(2 * time.Second):
}
}
}
// ---- Delta sync ----
// For each enabled folder:
// 1. Check UIDVALIDITY — if changed, full re-sync (folder was recreated on server).
// 2. Fetch only new messages (UID > last_seen_uid).
// 3. Fetch FLAGS for all existing messages to catch read/star changes from other clients.
// 4. Fetch all server UIDs and purge locally deleted messages.
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)
s.db.SetAccountError(account.ID, err.Error())
return
}
defer c.Close()
s.db.ClearAccountError(account.ID)
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
}
totalNew := 0
for _, mb := range mailboxes {
folderType := email.InferFolderType(mb.Name, mb.Attributes)
folder := &models.Folder{
AccountID: account.ID,
Name: mb.Name,
FullPath: mb.Name,
FolderType: folderType,
}
if err := s.db.UpsertFolder(folder); err != nil {
continue
}
dbFolder, _ := s.db.GetFolderByPath(account.ID, mb.Name)
if dbFolder == nil || !dbFolder.SyncEnabled {
continue
}
n, err := s.syncFolder(c, account, dbFolder)
if err != nil {
log.Printf("[sync:%s] folder %s: %v", account.EmailAddress, mb.Name, err)
continue
}
totalNew += n
}
s.db.UpdateAccountLastSync(account.ID)
if totalNew > 0 {
logger.Debug("[sync:%s] %d new messages", account.EmailAddress, totalNew)
}
}
// syncInbox is a fast path that only syncs the INBOX folder.
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
}
defer c.Close()
dbFolder, _ := s.db.GetFolderByPath(account.ID, "INBOX")
if dbFolder == nil {
return
}
n, err := s.syncFolder(c, account, dbFolder)
if err != nil {
log.Printf("[idle:%s] INBOX sync: %v", account.EmailAddress, err)
return
}
if n > 0 {
logger.Debug("[idle:%s] %d new messages in INBOX", account.EmailAddress, n)
}
}
func (s *Scheduler) syncFolder(c *email.Client, account *models.EmailAccount, dbFolder *models.Folder) (int, error) {
status, err := c.GetFolderStatus(dbFolder.FullPath)
if err != nil {
return 0, fmt.Errorf("status: %w", err)
}
storedValidity, lastSeenUID := s.db.GetFolderSyncState(dbFolder.ID)
newMessages := 0
// UIDVALIDITY changed = folder was recreated on server; wipe local and re-fetch all
if storedValidity != 0 && status.UIDValidity != storedValidity {
log.Printf("[sync] UIDVALIDITY changed for %s/%s — full re-sync", account.EmailAddress, dbFolder.FullPath)
s.db.DeleteAllFolderMessages(dbFolder.ID)
lastSeenUID = 0
}
// 1. Fetch new messages (UID > lastSeenUID)
var msgs []*models.Message
if lastSeenUID == 0 {
// First sync: respect the account's days/all setting
days := account.SyncDays
if days <= 0 || account.SyncMode == "all" {
days = 0
}
msgs, err = c.FetchMessages(dbFolder.FullPath, days)
} else {
msgs, err = c.FetchNewMessages(dbFolder.FullPath, lastSeenUID)
}
if err != nil {
return 0, fmt.Errorf("fetch new: %w", err)
}
maxUID := lastSeenUID
for _, msg := range msgs {
msg.FolderID = dbFolder.ID
if err := s.db.UpsertMessage(msg); err == nil {
newMessages++
// Save attachment metadata if any (enables download)
if len(msg.Attachments) > 0 && msg.ID > 0 {
_ = s.db.SaveAttachmentMeta(msg.ID, msg.Attachments)
}
}
uid := uint32(0)
fmt.Sscanf(msg.RemoteUID, "%d", &uid)
if uid > maxUID {
maxUID = uid
}
}
// 2. Sync flags for ALL existing messages (catch read/star changes from other clients)
flags, err := c.SyncFlags(dbFolder.FullPath)
if err != nil {
log.Printf("[sync] flags %s/%s: %v", account.EmailAddress, dbFolder.FullPath, err)
} else if len(flags) > 0 {
s.db.ReconcileFlags(dbFolder.ID, flags)
}
// 3. Fetch all server UIDs and purge messages deleted on server
serverUIDs, err := c.ListAllUIDs(dbFolder.FullPath)
if err != nil {
log.Printf("[sync] list uids %s/%s: %v", account.EmailAddress, dbFolder.FullPath, err)
} else {
purged, _ := s.db.PurgeDeletedMessages(dbFolder.ID, serverUIDs)
if purged > 0 {
log.Printf("[sync] purged %d server-deleted messages from %s/%s", purged, account.EmailAddress, dbFolder.FullPath)
}
}
// Save sync state
s.db.SetFolderSyncState(dbFolder.ID, status.UIDValidity, maxUID)
s.db.UpdateFolderCounts(dbFolder.ID)
return newMessages, nil
}
// ---- Pending ops drain ----
// 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
}
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)
return
}
defer c.Close()
// Find trash folder name once
trashName := ""
if mboxes, err := c.ListMailboxes(); err == nil {
for _, mb := range mboxes {
if email.InferFolderType(mb.Name, mb.Attributes) == "trash" {
trashName = mb.Name
break
}
}
}
for _, op := range ops {
var applyErr error
switch op.OpType {
case "delete":
applyErr = c.DeleteByUID(op.FolderPath, op.RemoteUID, trashName)
case "move":
applyErr = c.MoveByUID(op.FolderPath, op.Extra, op.RemoteUID)
case "flag_read":
applyErr = c.SetFlagByUID(op.FolderPath, op.RemoteUID, `\Seen`, op.Extra == "1")
case "flag_star":
applyErr = c.SetFlagByUID(op.FolderPath, op.RemoteUID, `\Flagged`, op.Extra == "1")
}
if applyErr != nil {
log.Printf("[ops:%s] %s uid=%d folder=%s: %v", account.EmailAddress, op.OpType, op.RemoteUID, op.FolderPath, applyErr)
s.db.IncrementPendingOpAttempts(op.ID)
} else {
s.db.DeletePendingOp(op.ID)
}
}
if n := s.db.CountPendingOps(account.ID); n > 0 {
log.Printf("[ops:%s] %d ops still pending after drain", account.EmailAddress, n)
}
}
// ---- 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.
func (s *Scheduler) SyncAccountNow(accountID int64) (int, error) {
account, err := s.db.GetAccount(accountID)
if err != nil || account == nil {
return 0, fmt.Errorf("account %d not found", accountID)
}
return s.doSync(account)
s.drainPendingOps(account)
s.deltaSync(account)
return 0, nil
}
// SyncFolderNow syncs a single folder for an account.
@@ -86,103 +673,177 @@ func (s *Scheduler) SyncFolderNow(accountID, folderID int64) (int, error) {
if err != nil || folder == nil || folder.AccountID != accountID {
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
}
defer c.Close()
days := account.SyncDays
if days <= 0 || account.SyncMode == "all" {
days = 36500 // ~100 years = full mailbox
}
messages, err := c.FetchMessages(folder.FullPath, days)
if err != nil {
return 0, err
}
synced := 0
for _, msg := range messages {
msg.FolderID = folder.ID
if err := s.db.UpsertMessage(msg); err == nil {
synced++
}
}
s.db.UpdateFolderCounts(folder.ID)
s.db.UpdateAccountLastSync(accountID)
return synced, nil
return s.syncFolder(c, account, folder)
}
func (s *Scheduler) syncAccount(account *models.EmailAccount) {
synced, err := s.doSync(account)
// ---- 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("Sync [%s]: %v", account.EmailAddress, err)
s.db.SetAccountError(account.ID, err.Error())
s.db.WriteAudit(nil, models.AuditAppError,
"sync error for "+account.EmailAddress+": "+err.Error(), "", "")
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)
if synced > 0 {
log.Printf("Synced %d messages for %s", synced, account.EmailAddress)
}
}
func (s *Scheduler) doSync(account *models.EmailAccount) (int, error) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
c, err := email.Connect(ctx, account)
if err != nil {
return 0, err
}
defer c.Close()
mailboxes, err := c.ListMailboxes()
if err != nil {
return 0, fmt.Errorf("list mailboxes: %w", err)
}
synced := 0
for _, mb := range mailboxes {
folderType := email.InferFolderType(mb.Name, mb.Attributes)
folder := &models.Folder{
AccountID: account.ID,
Name: mb.Name,
FullPath: mb.Name,
FolderType: folderType,
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(folder); err != nil {
log.Printf("Upsert folder %s: %v", mb.Name, err)
if err := s.db.UpsertFolder(dbFolder); err != nil {
continue
}
dbFolderSaved, _ := s.db.GetFolderByPath(account.ID, gf.ID)
if dbFolderSaved == nil || !dbFolderSaved.SyncEnabled {
continue
}
dbFolder, _ := s.db.GetFolderByPath(account.ID, mb.Name)
if dbFolder == nil {
continue
}
days := account.SyncDays
if days <= 0 || account.SyncMode == "all" {
days = 36500 // ~100 years = full mailbox
}
messages, err := c.FetchMessages(mb.Name, days)
// 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("Fetch %s/%s: %v", account.EmailAddress, mb.Name, err)
log.Printf("[graph:%s] list messages in %s: %v", account.EmailAddress, gf.DisplayName, err)
continue
}
for _, msg := range messages {
msg.FolderID = dbFolder.ID
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 {
synced++
totalNew++
}
}
s.db.UpdateFolderCounts(dbFolder.ID)
// Update folder counts from Graph (more accurate than counting locally)
s.db.UpdateFolderCountsDirect(dbFolderSaved.ID, gf.TotalCount, gf.UnreadCount)
}
s.db.UpdateAccountLastSync(account.ID)
return synced, nil
if totalNew > 0 {
logger.Debug("[graph:%s] %d new messages", account.EmailAddress, totalNew)
}
}

View File

@@ -145,6 +145,7 @@ body.app-page{overflow:hidden}
border-right:1px solid var(--border);display:flex;flex-direction:column;overflow:hidden}
.sidebar-header{padding:16px 14px 12px;border-bottom:1px solid var(--border);
display:flex;align-items:center;justify-content:space-between}
.sidebar-header .logo a{display:flex;align-items:center;gap:8px;text-decoration:none;color:var(--text)}
.logo{display:flex;align-items:center;gap:8px}
.logo-icon{width:26px;height:26px;background:var(--accent);border-radius:6px;
display:flex;align-items:center;justify-content:center;flex-shrink:0}
@@ -153,18 +154,8 @@ body.app-page{overflow:hidden}
.compose-btn{padding:6px 12px;background:var(--accent);border:none;border-radius:6px;
color:white;font-family:'DM Sans',sans-serif;font-size:12px;font-weight:500;cursor:pointer;transition:opacity .15s}
.compose-btn:hover{opacity:.85}
.accounts-section{padding:10px 8px 4px;border-bottom:1px solid var(--border)}
.section-label{font-size:10px;font-weight:500;text-transform:uppercase;letter-spacing:1px;
color:var(--muted);padding:0 6px 6px}
.account-item{display:flex;align-items:center;gap:7px;padding:5px 6px;border-radius:6px;
cursor:pointer;transition:background .1s;position:relative}
.account-item:hover{background:var(--surface3)}
/* ── Account dot (still used in popup) */
.account-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0}
.account-email{font-size:12px;color:var(--text2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}
.account-error-dot{width:6px;height:6px;background:var(--danger);border-radius:50%;flex-shrink:0}
.add-account-btn{display:flex;align-items:center;gap:6px;padding:5px 6px;color:var(--accent);
font-size:12px;cursor:pointer;border-radius:6px;transition:background .1s;margin-top:2px}
.add-account-btn:hover{background:var(--accent-dim)}
.nav-section{padding:4px 8px;flex:1;overflow-y:auto}
.nav-item{display:flex;align-items:center;gap:9px;padding:7px 8px;border-radius:7px;
cursor:pointer;transition:background .1s;color:var(--text2);user-select:none;font-size:13px}
@@ -174,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}
@@ -201,9 +200,14 @@ body.app-page{overflow:hidden}
.message-item{padding:10px 12px;border-bottom:1px solid var(--border);cursor:pointer;transition:background .1s;position:relative}
.message-item:hover{background:var(--surface2)}
.message-item.active{background:var(--accent-dim);border-left:2px solid var(--accent);padding-left:10px}
.message-item.unread .msg-subject{font-weight:500;color:var(--text)}
.message-item.unread::before{content:'';position:absolute;left:0;top:50%;transform:translateY(-50%);
width:3px;height:22px;background:var(--accent);border-radius:0 2px 2px 0}
/* Unread: lighter background + bold sender so it pops clearly */
.message-item.unread{background:rgba(255,255,255,.035)}
.message-item.unread:hover{background:rgba(255,255,255,.055)}
.message-item.unread .msg-from{color:var(--text);font-weight:600}
.message-item.unread .msg-subject{font-weight:600;color:var(--text)}
.message-item.unread::before{content:'';position:absolute;left:0;top:0;bottom:0;
width:3px;background:var(--accent);border-radius:0 2px 2px 0}
.message-item.unread.active{background:var(--accent-dim)}
.message-item.unread.active::before{display:none}
.msg-top{display:flex;align-items:center;justify-content:space-between;gap:6px;margin-bottom:2px}
.msg-from{font-size:13px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}
@@ -247,30 +251,60 @@ body.app-page{overflow:hidden}
.detail-body-text{font-size:13px;line-height:1.7;color:var(--text2);white-space:pre-wrap;word-break:break-word}
.detail-body iframe{width:100%;border:none;min-height:400px}
/* Compose */
.compose-overlay{position:fixed;bottom:20px;right:24px;z-index:50;display:none}
.compose-overlay.open{display:block}
.compose-window{width:540px;background:var(--surface2);border:1px solid var(--border2);
border-radius:12px;box-shadow:0 20px 60px rgba(0,0,0,.6);display:flex;flex-direction:column}
.compose-header{padding:12px 16px;border-bottom:1px solid var(--border);
display:flex;align-items:center;justify-content:space-between}
.compose-title{font-size:14px;font-weight:500}
.compose-close{background:none;border:none;color:var(--muted);font-size:18px;cursor:pointer;
line-height:1;padding:2px 6px;border-radius:4px}
/* ── Compose dialog (draggable, all-edge resizable) ──────────── */
.compose-dialog{
position:fixed;bottom:20px;right:24px;
width:540px;height:480px;
background:var(--surface2);border:1px solid var(--border2);
border-radius:12px;box-shadow:0 24px 64px rgba(0,0,0,.65);
display:none;flex-direction:column;z-index:200;
min-width:360px;min-height:280px;overflow:hidden;
user-select:none;
}
.compose-dialog-header{
padding:10px 12px 10px 16px;border-bottom:1px solid var(--border);
display:flex;align-items:center;justify-content:space-between;
cursor:grab;flex-shrink:0;background:var(--surface2);
}
.compose-dialog-header:active{cursor:grabbing}
.compose-body-wrap{display:flex;flex-direction:column;flex:1;overflow:hidden;min-height:0}
.compose-title{font-size:13px;font-weight:500;pointer-events:none}
.compose-close{background:none;border:none;color:var(--muted);font-size:17px;cursor:pointer;
line-height:1;padding:2px 5px;border-radius:4px;pointer-events:all}
.compose-close:hover{background:var(--surface3);color:var(--text)}
.compose-field{display:flex;align-items:center;border-bottom:1px solid var(--border);padding:8px 14px;gap:10px}
.compose-field{display:flex;align-items:center;border-bottom:1px solid var(--border);padding:6px 14px;gap:10px;flex-shrink:0}
.compose-field label{font-size:12px;color:var(--muted);width:44px;flex-shrink:0}
.compose-field input,.compose-field select{flex:1;background:none;border:none;color:var(--text);
font-family:'DM Sans',sans-serif;font-size:13px;outline:none}
.compose-field select option{background:var(--surface2)}
.compose-body textarea{width:100%;height:200px;background:none;border:none;color:var(--text);
font-family:'DM Sans',sans-serif;font-size:13px;line-height:1.6;resize:none;outline:none;padding:12px 14px}
.compose-footer{padding:8px 14px;border-top:1px solid var(--border);display:flex;align-items:center;gap:8px}
.compose-footer{padding:8px 14px;border-top:1px solid var(--border);display:flex;align-items:center;gap:8px;flex-shrink:0}
.send-btn{padding:7px 20px;background:var(--accent);border:none;border-radius:6px;color:white;
font-family:'DM Sans',sans-serif;font-size:13px;font-weight:500;cursor:pointer;transition:opacity .15s}
.send-btn:hover{opacity:.85}
.send-btn:disabled{opacity:.5;cursor:default}
/* Resize handles — 8-directional */
.compose-resize{position:absolute;z-index:10}
.compose-resize[data-dir="e"] {right:0;top:8px;bottom:8px;width:5px;cursor:e-resize}
.compose-resize[data-dir="w"] {left:0;top:8px;bottom:8px;width:5px;cursor:w-resize}
.compose-resize[data-dir="s"] {bottom:0;left:8px;right:8px;height:5px;cursor:s-resize}
.compose-resize[data-dir="n"] {top:0;left:8px;right:8px;height:5px;cursor:n-resize}
.compose-resize[data-dir="se"]{right:0;bottom:0;width:12px;height:12px;cursor:se-resize}
.compose-resize[data-dir="sw"]{left:0;bottom:0;width:12px;height:12px;cursor:sw-resize}
.compose-resize[data-dir="ne"]{right:0;top:0;width:12px;height:12px;cursor:ne-resize}
.compose-resize[data-dir="nw"]{left:0;top:0;width:12px;height:12px;cursor:nw-resize}
/* Minimised pill */
.compose-minimised{
position:fixed;bottom:0;right:24px;z-index:201;
display:none;align-items:center;gap:8px;
padding:8px 16px;background:var(--surface2);
border:1px solid var(--border2);border-bottom:none;
border-radius:8px 8px 0 0;font-size:13px;cursor:pointer;
box-shadow:0 -4px 20px rgba(0,0,0,.3);
}
.compose-minimised:hover{background:var(--surface3)}
/* Provider buttons */
.provider-btns{display:flex;gap:10px;margin-bottom:14px}
.provider-btn{flex:1;padding:10px;background:var(--surface3);border:1px solid var(--border2);
@@ -324,8 +358,8 @@ body.admin-page{overflow:auto;background:var(--bg)}
.fmt-btn{background:none;border:none;color:var(--text2);cursor:pointer;padding:4px 7px;border-radius:4px;font-size:13px;line-height:1;transition:background .1s}
.fmt-btn:hover{background:var(--border2);color:var(--text)}
.fmt-sep{width:1px;height:16px;background:var(--border2);margin:0 3px}
.compose-editor{flex:1;min-height:160px;max-height:320px;overflow-y:auto;padding:12px 14px;
font-size:13px;line-height:1.6;color:var(--text);outline:none;background:var(--bg)}
.compose-editor{flex:1;overflow-y:auto;padding:12px 14px;
font-size:13px;line-height:1.6;color:var(--text);outline:none;background:var(--bg);min-height:0}
.compose-editor:empty::before{content:attr(placeholder);color:var(--muted);pointer-events:none}
.compose-editor blockquote{border-left:3px solid var(--border2);margin:8px 0;padding-left:12px;color:var(--muted)}
.compose-editor .quote-divider{font-size:11px;color:var(--muted);margin:10px 0 4px}
@@ -343,36 +377,244 @@ body.admin-page{overflow:auto;background:var(--bg)}
/* ---- Attachment chips ---- */
.attachment-chip{display:inline-flex;align-items:center;gap:5px;padding:4px 10px;
background:var(--surface3);border:1px solid var(--border2);border-radius:6px;font-size:12px;cursor:pointer}
background:var(--surface3);border:1px solid var(--border2);border-radius:6px;font-size:12px;cursor:pointer;
text-decoration:none;color:inherit}
.attachment-chip:hover{background:var(--border2)}
.attachments-bar{display:flex;align-items:center;flex-wrap:wrap;gap:6px;
padding:8px 14px;border-bottom:1px solid var(--border)}
/* Drag-and-drop compose overlay */
.compose-dialog.drag-over{outline:3px dashed var(--accent);outline-offset:-4px;}
/* ── Email tag input ─────────────────────────────────────────── */
.tag-container{display:flex;flex-wrap:wrap;align-items:center;gap:4px;flex:1;
padding:4px 6px;min-height:34px;cursor:text;background:var(--bg);
border:1px solid var(--border);border-radius:6px}
.tag-container:focus-within{border-color:var(--accent);box-shadow:0 0 0 2px rgba(99,102,241,.15)}
.compose-tag-field label{flex-shrink:0;align-self:flex-start;padding-top:8px}
.email-tag{display:inline-flex;align-items:center;gap:4px;padding:2px 6px 2px 8px;
padding:4px 6px;min-height:32px;cursor:text;background:transparent}
.tag-container:focus-within{}
.compose-tag-field label{flex-shrink:0;align-self:flex-start;padding-top:7px}
.email-tag{display:inline-flex;align-items:center;gap:3px;padding:2px 6px 2px 8px;
background:var(--surface3);border:1px solid var(--border2);border-radius:12px;
font-size:12px;color:var(--text);white-space:nowrap}
.email-tag.invalid{background:rgba(239,68,68,.1);border-color:rgba(239,68,68,.4);color:#fca5a5}
font-size:12px;color:var(--text);white-space:nowrap;max-width:260px}
.email-tag.invalid{background:rgba(239,68,68,.12);border-color:rgba(239,68,68,.4);color:#fca5a5}
.tag-remove{background:none;border:none;color:var(--muted);cursor:pointer;
padding:0;font-size:14px;line-height:1;margin-left:2px}
padding:0 1px;font-size:14px;line-height:1;flex-shrink:0}
.tag-remove:hover{color:var(--text)}
.tag-input{background:none;border:none;outline:none;color:var(--text);font-size:13px;
font-family:inherit;min-width:120px;flex:1;padding:1px 0}
font-family:inherit;min-width:80px;flex:1;padding:1px 0;pointer-events:all;cursor:text}
/* ── Compose resize handle ───────────────────────────────────── */
#compose-resize-handle{position:absolute;top:0;left:0;right:0;height:5px;
cursor:n-resize;border-radius:10px 10px 0 0;z-index:1}
#compose-resize-handle:hover{background:var(--accent);opacity:.4}
.compose-window{position:relative;display:flex;flex-direction:column;
min-width:360px;min-height:280px;resize:none}
.compose-attach-list{display:flex;flex-wrap:wrap;gap:6px;padding:6px 14px 0;min-height:0}
/* ── Accounts popup ──────────────────────────────────────────── */
.accounts-popup{
position:fixed;bottom:52px;left:8px;
width:300px;background:var(--surface2);border:1px solid var(--border2);
border-radius:12px;box-shadow:0 16px 48px rgba(0,0,0,.55);
z-index:300;display:none;flex-direction:column;overflow:hidden;
}
.accounts-popup.open{display:flex}
.accounts-popup-backdrop{display:none;position:fixed;inset:0;z-index:299}
.accounts-popup-backdrop.open{display:block}
.accounts-popup-inner{padding:12px}
.accounts-popup-header{display:flex;align-items:center;justify-content:space-between;
font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.9px;
color:var(--muted);margin-bottom:8px}
.acct-popup-item{display:flex;align-items:center;gap:6px;padding:7px 6px;border-radius:7px;
transition:background .1s}
.acct-popup-item:hover{background:var(--surface3)}
.accounts-add-btn{display:flex;align-items:center;gap:7px;width:100%;padding:8px 6px;
margin-top:4px;background:none;border:1px dashed var(--border2);border-radius:7px;
color:var(--accent);font-family:'DM Sans',sans-serif;font-size:12px;cursor:pointer;
transition:background .1s}
.accounts-add-btn:hover{background:var(--accent-dim)}
/* ── Icon sync button ─────────────────────────────────────────── */
/* ── Inline confirm toast ────────────────────────────────────── */
.inline-confirm{
position:fixed;top:50%;left:50%;transform:translate(-50%,-44%);
background:var(--surface2);border:1px solid var(--border2);border-radius:12px;
box-shadow:0 24px 64px rgba(0,0,0,.7);padding:20px 22px;
min-width:300px;max-width:440px;z-index:400;
opacity:0;pointer-events:none;transition:opacity .18s,transform .18s;
}
.inline-confirm.open{opacity:1;pointer-events:all;transform:translate(-50%,-50%)}
/* ── Context menu submenu ────────────────────────────────────── */
.ctx-has-sub{position:relative;justify-content:space-between}
.ctx-sub-arrow{margin-left:auto;font-size:12px;color:var(--muted);pointer-events:none}
.ctx-submenu{
display:none;position:absolute;left:100%;top:-4px;
background:var(--surface2);border:1px solid var(--border2);
border-radius:8px;padding:4px;min-width:160px;
box-shadow:0 8px 28px rgba(0,0,0,.55);z-index:210;
}
.ctx-has-sub:hover>.ctx-submenu{display:block}
.ctx-sub-item{white-space:nowrap}
/* ── Multi-select & drag-drop ────────────────────────────────── */
.message-item.selected{background:rgba(74,144,226,.18)!important;outline:1px solid var(--accent)}
.message-item.selected:hover{background:rgba(74,144,226,.26)!important}
.nav-folder.drag-over,.nav-item.drag-over{background:rgba(74,144,226,.22)!important;border-radius:6px}
#bulk-action-bar{display:none}
.folder-nosync{opacity:.65}
/* ── Compose attach list ─────────────────────────────────────── */
.compose-attach-list{display:flex;flex-wrap:wrap;gap:6px;padding:4px 14px 0;min-height:0;flex-shrink:0}
/* ── Icon sync button ────────────────────────────────────────── */
.icon-sync-btn{background:none;border:none;color:var(--muted);cursor:pointer;
padding:2px;border-radius:4px;line-height:1;flex-shrink:0;transition:color .15s}
.icon-sync-btn:hover{color:var(--text)}
/* ── Message filter dropdown ─────────────────────────────────── */
.filter-dropdown{position:relative}
.filter-dropdown-btn{display:flex;align-items:center;gap:5px;background:none;
border:1px solid var(--border2);border-radius:6px;color:var(--muted);
font-family:'DM Sans',sans-serif;font-size:12px;cursor:pointer;
padding:4px 9px;transition:all .1s;white-space:nowrap}
.filter-dropdown-btn:hover{background:var(--surface3);color:var(--text)}
.filter-dropdown-btn.active{border-color:rgba(91,141,239,.4);color:var(--accent);background:var(--accent-dim)}
.filter-dropdown-menu{position:absolute;top:calc(100% + 6px);right:0;
background:var(--surface2);border:1px solid var(--border2);border-radius:8px;
box-shadow:0 8px 28px rgba(0,0,0,.5);min-width:160px;padding:4px;
z-index:250}
.filter-opt{padding:7px 12px;border-radius:5px;font-size:13px;cursor:pointer;
color:var(--text2);transition:background .1s;white-space:nowrap}
.filter-opt:hover{background:var(--surface3);color:var(--text)}
.filter-sep-line{height:1px;background:var(--border);margin:3px 0}
/* ── New mail corner toast ───────────────────────────────────── */
.newmail-toast{
position:fixed;bottom:24px;right:24px;z-index:2000;
display:flex;align-items:flex-start;gap:10px;
background:var(--surface2);border:1px solid var(--border2);
border-left:3px solid var(--accent);
border-radius:10px;padding:12px 14px;
box-shadow:0 8px 32px rgba(0,0,0,.55);
max-width:320px;min-width:240px;
cursor:pointer;
animation:toastSlideIn .25s cubic-bezier(.2,.8,.4,1);
transition:opacity .2s,transform .2s;
}
.newmail-toast:hover{border-left-color:#7eb8f7;background:var(--surface3)}
.newmail-toast-icon{font-size:18px;flex-shrink:0;color:var(--accent);line-height:1.2}
.newmail-toast-body{flex:1;font-size:13px;line-height:1.4;color:var(--text);
min-width:0;word-break:break-word}
.newmail-toast-body strong{color:var(--text);display:block;margin-bottom:2px}
.newmail-toast-body span{color:var(--text2);font-size:12px;
display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.newmail-toast-close{
background:none;border:none;color:var(--muted);cursor:pointer;
font-size:13px;padding:0 0 0 6px;line-height:1;flex-shrink:0;align-self:flex-start
}
.newmail-toast-close:hover{color:var(--text)}
@keyframes toastSlideIn{
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)}

BIN
web/static/img/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -1,9 +1,10 @@
// GoMail Admin SPA
// GoWebMail Admin SPA
const adminRoutes = {
'/admin': renderUsers,
'/admin/settings': renderSettings,
'/admin/audit': renderAudit,
'/admin': renderUsers,
'/admin/settings': renderSettings,
'/admin/audit': renderAudit,
'/admin/security': renderSecurity,
};
function navigate(path) {
@@ -26,7 +27,7 @@ async function renderUsers() {
el.innerHTML = `
<div class="admin-page-header">
<h1>Users</h1>
<p>Manage GoMail accounts and permissions.</p>
<p>Manage GoWebMail accounts and permissions.</p>
</div>
<div class="admin-card">
<div style="display:flex;justify-content:flex-end;margin-bottom:16px">
@@ -67,16 +68,19 @@ async function loadUsersTable() {
if (!r) { el.innerHTML = '<p class="alert error">Failed to load users</p>'; return; }
if (!r.length) { el.innerHTML = '<p style="color:var(--muted);font-size:13px">No users yet.</p>'; return; }
el.innerHTML = `<table class="data-table">
<thead><tr><th>Username</th><th>Email</th><th>Role</th><th>Status</th><th>Last Login</th><th></th></tr></thead>
<thead><tr><th>Username</th><th>Email</th><th>Role</th><th>Status</th><th>MFA</th><th>Last Login</th><th></th></tr></thead>
<tbody>${r.map(u => `
<tr>
<td style="font-weight:500">${esc(u.username)}</td>
<td style="color:var(--muted)">${esc(u.email)}</td>
<td><span class="badge ${u.role==='admin'?'blue':'amber'}">${u.role}</span></td>
<td><span class="badge ${u.is_active?'green':'red'}">${u.is_active?'Active':'Disabled'}</span></td>
<td><span class="badge ${u.mfa_enabled?'blue':'amber'}">${u.mfa_enabled?'On':'Off'}</span></td>
<td style="color:var(--muted);font-size:12px">${u.last_login_at ? new Date(u.last_login_at).toLocaleDateString() : 'Never'}</td>
<td style="display:flex;gap:6px;justify-content:flex-end">
<td style="display:flex;gap:4px;justify-content:flex-end;flex-wrap:wrap">
<button class="btn-secondary" style="padding:4px 10px;font-size:12px" onclick="openEditUser(${u.id})">Edit</button>
<button class="btn-secondary" style="padding:4px 10px;font-size:12px" onclick="openResetPassword(${u.id},'${esc(u.username)}')">🔑 Reset PW</button>
${u.mfa_enabled?`<button class="btn-secondary" style="padding:4px 10px;font-size:12px;color:var(--warning,#f90)" onclick="disableMFA(${u.id},'${esc(u.username)}')">🔒 Disable MFA</button>`:''}
<button class="btn-danger" style="padding:4px 10px;font-size:12px" onclick="deleteUser(${u.id})">Delete</button>
</td>
</tr>`).join('')}
@@ -139,6 +143,23 @@ async function deleteUser(userId) {
else toast((r && r.error) || 'Delete failed', 'error');
}
async function disableMFA(userId, username) {
if (!confirm(`Disable MFA for "${username}"? They will be able to log in without a TOTP code until they re-enable it.`)) return;
const r = await api('PUT', '/admin/users/' + userId, { disable_mfa: true });
if (r && r.ok) { toast('MFA disabled for ' + username, 'success'); loadUsersTable(); }
else toast((r && r.error) || 'Failed to disable MFA', 'error');
}
function openResetPassword(userId, username) {
const pw = prompt(`Reset password for "${username}"\n\nEnter new password (min. 8 characters):`);
if (!pw) return;
if (pw.length < 8) { toast('Password must be at least 8 characters', 'error'); return; }
api('PUT', '/admin/users/' + userId, { password: pw }).then(r => {
if (r && r.ok) toast('Password reset for ' + username, 'success');
else toast((r && r.error) || 'Failed to reset password', 'error');
});
}
// ============================================================
// Settings
// ============================================================
@@ -182,6 +203,34 @@ const SETTINGS_META = [
{ key: 'DB_PATH', label: 'Database Path', desc: 'Path to SQLite file, relative to working directory', type: 'text' },
]
},
{
group: 'Security Notifications',
fields: [
{ key: 'NOTIFY_ENABLED', label: 'Enabled', desc: 'Send email to users when brute-force attack is detected on their account', type: 'select', options: ['true','false'] },
{ key: 'NOTIFY_SMTP_HOST', label: 'SMTP Host', desc: 'SMTP server for sending alerts. Example: smtp.example.com', type: 'text' },
{ key: 'NOTIFY_SMTP_PORT', label: 'SMTP Port', desc: '587 = STARTTLS, 465 = TLS, 25 = plain relay', type: 'number' },
{ key: 'NOTIFY_FROM', label: 'From Address', desc: 'Sender email. Example: security@example.com', type: 'text' },
{ key: 'NOTIFY_USER', label: 'SMTP Username', desc: 'Leave blank for unauthenticated relay', type: 'text' },
{ key: 'NOTIFY_PASS', label: 'SMTP Password', desc: 'Leave blank for unauthenticated relay', type: 'password' },
]
},
{
group: 'Brute Force Protection',
fields: [
{ key: 'BRUTE_ENABLED', label: 'Enabled', desc: 'Auto-block IPs after repeated failed logins', type: 'select', options: ['true','false'] },
{ key: 'BRUTE_MAX_ATTEMPTS', label: 'Max Attempts', desc: 'Failed logins before ban', type: 'number' },
{ key: 'BRUTE_WINDOW_MINUTES', label: 'Window (minutes)',desc: 'Time window for counting failures', type: 'number' },
{ key: 'BRUTE_BAN_HOURS', label: 'Ban Duration (hours)', desc: '0 = permanent ban (admin must unban)', type: 'number' },
{ key: 'BRUTE_WHITELIST_IPS', label: 'Whitelist IPs', desc: 'Comma-separated IPs that are never blocked', type: 'text' },
]
},
{
group: 'Geo Blocking',
fields: [
{ key: 'GEO_BLOCK_COUNTRIES', label: 'Block Countries', desc: 'Comma-separated ISO codes to DENY (e.g. CN,RU,KP). Takes precedence over Allow list.', type: 'text' },
{ key: 'GEO_ALLOW_COUNTRIES', label: 'Allow Countries', desc: 'Comma-separated ISO codes to ALLOW exclusively (e.g. SK,CZ,DE). Leave blank to allow all.', type: 'text' },
]
},
];
async function renderSettings() {
@@ -209,7 +258,7 @@ async function renderSettings() {
el.innerHTML = `
<div class="admin-page-header">
<h1>Application Settings</h1>
<p>Changes are saved to <code style="font-family:monospace;background:var(--surface3);padding:2px 6px;border-radius:4px">data/gomail.conf</code> and take effect immediately for most settings. A restart is required for LISTEN_ADDR changes.</p>
<p>Changes are saved to <code style="font-family:monospace;background:var(--surface3);padding:2px 6px;border-radius:4px">data/gowebmail.conf</code> and take effect immediately for most settings. A restart is required for LISTEN_ADDR changes.</p>
</div>
<div id="settings-alert" style="display:none"></div>
<div class="admin-card">
@@ -308,4 +357,135 @@ function eventBadge(evt) {
navigate(a.getAttribute('href'));
});
});
})();
})();
// ============================================================
// Security — IP Blocks & Login Attempts
// ============================================================
async function renderSecurity() {
const el = document.getElementById('admin-content');
el.innerHTML = `
<div class="admin-page-header">
<h1>Security</h1>
<p>Monitor login attempts, manage IP blocks, and control access by country.</p>
</div>
<div class="admin-card" style="margin-bottom:24px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<h2 style="margin:0;font-size:16px">Blocked IPs</h2>
<button class="btn-primary" onclick="openAddBlock()">+ Block IP</button>
</div>
<div id="blocks-table"><div class="spinner"></div></div>
</div>
<div class="admin-card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<h2 style="margin:0;font-size:16px">Login Attempts (last 72h)</h2>
<button class="btn-secondary" onclick="loadLoginAttempts()">↻ Refresh</button>
</div>
<div id="attempts-table"><div class="spinner"></div></div>
</div>
<div class="modal-overlay" id="add-block-modal">
<div class="modal" style="max-width:420px">
<h2>Block IP Address</h2>
<div class="modal-field"><label>IP Address</label><input type="text" id="block-ip" placeholder="e.g. 192.168.1.100"></div>
<div class="modal-field"><label>Reason</label><input type="text" id="block-reason" placeholder="Manual admin block"></div>
<div class="modal-field"><label>Ban Hours (0 = permanent)</label><input type="number" id="block-hours" value="24" min="0"></div>
<div class="modal-actions">
<button class="btn-secondary" onclick="closeModal('add-block-modal')">Cancel</button>
<button class="btn-primary" onclick="submitAddBlock()">Block IP</button>
</div>
</div>
</div>`;
loadIPBlocks();
loadLoginAttempts();
}
async function loadIPBlocks() {
const el = document.getElementById('blocks-table');
if (!el) return;
const r = await api('GET', '/admin/ip-blocks');
const blocks = r?.blocks || [];
if (!blocks.length) {
el.innerHTML = '<p style="color:var(--muted);padding:8px 0">No blocked IPs.</p>';
return;
}
el.innerHTML = `<table class="admin-table" style="width:100%">
<thead><tr>
<th>IP</th><th>Country</th><th>Reason</th><th>Attempts</th><th>Blocked At</th><th>Expires</th><th></th>
</tr></thead>
<tbody>
${blocks.map(b => `<tr>
<td><code>${esc(b.ip)}</code></td>
<td>${b.country_code ? `<span title="${esc(b.country)}">${esc(b.country_code)}</span>` : '—'}</td>
<td>${esc(b.reason)}</td>
<td>${b.attempts||0}</td>
<td style="font-size:11px">${fmtDate(b.blocked_at)}</td>
<td style="font-size:11px;color:var(--muted)">${b.is_permanent ? '♾ Permanent' : b.expires_at ? fmtDate(b.expires_at) : '—'}</td>
<td><button class="action-btn danger" onclick="unblockIP('${esc(b.ip)}')">Unblock</button></td>
</tr>`).join('')}
</tbody>
</table>`;
}
async function loadLoginAttempts() {
const el = document.getElementById('attempts-table');
if (!el) return;
const r = await api('GET', '/admin/login-attempts');
const attempts = r?.attempts || [];
if (!attempts.length) {
el.innerHTML = '<p style="color:var(--muted);padding:8px 0">No login attempts recorded in the last 72 hours.</p>';
return;
}
el.innerHTML = `<table class="admin-table" style="width:100%">
<thead><tr>
<th>IP</th><th>Country</th><th>Total</th><th>Failures</th><th>Last Seen</th><th></th>
</tr></thead>
<tbody>
${attempts.map(a => `<tr ${a.failures>3?'style="background:rgba(255,80,80,.07)"':''}>
<td><code>${esc(a.ip)}</code></td>
<td>${a.country_code ? `<span title="${esc(a.country)}">${esc(a.country_code)} ${esc(a.country)}</span>` : '—'}</td>
<td>${a.total}</td>
<td style="${a.failures>3?'color:#f87;font-weight:600':''}">${a.failures}</td>
<td style="font-size:11px">${a.last_seen||'—'}</td>
<td><button class="action-btn danger" onclick="blockFromAttempt('${esc(a.ip)}')">Block</button></td>
</tr>`).join('')}
</tbody>
</table>`;
}
function openAddBlock() { openModal('add-block-modal'); }
async function submitAddBlock() {
const ip = document.getElementById('block-ip').value.trim();
const reason = document.getElementById('block-reason').value.trim() || 'Manual admin block';
const hours = parseInt(document.getElementById('block-hours').value) || 0;
if (!ip) { toast('IP address required', 'error'); return; }
const r = await api('POST', '/admin/ip-blocks', { ip, reason, ban_hours: hours });
if (r?.ok) { toast('IP blocked', 'success'); closeModal('add-block-modal'); loadIPBlocks(); }
else toast(r?.error || 'Failed', 'error');
}
async function unblockIP(ip) {
const r = await fetch('/api/admin/ip-blocks/' + encodeURIComponent(ip), { method: 'DELETE' });
const data = await r.json();
if (data?.ok) { toast('IP unblocked', 'success'); loadIPBlocks(); }
else toast(data?.error || 'Failed', 'error');
}
function blockFromAttempt(ip) {
document.getElementById('block-ip').value = ip;
document.getElementById('block-reason').value = 'Manual block from login attempts';
openModal('add-block-modal');
}
function fmtDate(s) {
if (!s) return '—';
try { return new Date(s).toLocaleString(); } catch(e) { return s; }
}
function esc(s) {
if (!s) return '';
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}

File diff suppressed because it is too large Load Diff

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

@@ -1,4 +1,4 @@
// GoMail shared utilities - loaded on every page
// GoWebMail shared utilities - loaded on every page
// ---- API helper ----
async function api(method, path, body) {
@@ -108,3 +108,24 @@ function insertLink() {
document.getElementById('compose-editor').focus();
document.execCommand('createLink', false, url);
}
// ── Filter dropdown (stubs — real logic in app.js, but onclick needs global scope) ──
function goMailToggleFilter(e) {
e.stopPropagation();
const menu = document.getElementById('filter-dropdown-menu');
if (!menu) return;
const isOpen = menu.classList.contains('open');
menu.classList.toggle('open', !isOpen);
if (!isOpen) {
document.addEventListener('click', function closeFilter() {
menu.classList.remove('open');
document.removeEventListener('click', closeFilter);
});
}
}
function goMailSetFilter(mode) {
var menu = document.getElementById('filter-dropdown-menu');
if (menu) menu.style.display = 'none';
if (typeof setFilter === 'function') setFilter(mode);
}

View File

@@ -1,5 +1,5 @@
{{template "base" .}}
{{define "title"}}GoMail Admin{{end}}
{{define "title"}}GoWebMail Admin{{end}}
{{define "body_class"}}admin-page{{end}}
{{define "body"}}
<div class="admin-layout">
@@ -7,7 +7,7 @@
<div class="logo-area">
<a href="/">
<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">GoMail</span>
<span class="logo-text">GoWebMail</span>
</a>
</div>
<div class="admin-nav">
@@ -23,6 +23,10 @@
<svg viewBox="0 0 24 24"><path d="M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z"/></svg>
Audit Log
</a>
<a href="/admin/security" id="nav-security">
<svg viewBox="0 0 24 24"><path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z"/></svg>
Security
</a>
</div>
</nav>
@@ -35,5 +39,5 @@
{{end}}
{{define "scripts"}}
<script src="/static/js/admin.js"></script>
<script src="/static/js/admin.js?v=25"></script>
{{end}}

View File

@@ -1,25 +1,38 @@
{{template "base" .}}
{{define "title"}}GoMail{{end}}
{{define "title"}}GoWebMail{{end}}
{{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">
<div class="logo">
<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">GoMail</span>
<span class="logo-text"><a href="/">GoWebMail</a></span>
</div>
<button class="compose-btn" onclick="openCompose()">+ Compose</button>
</div>
<div class="accounts-section">
<div class="section-label">Accounts</div>
<div id="accounts-list"></div>
<div class="add-account-btn" onclick="openModal('add-account-modal')">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
Connect account
<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>
@@ -33,15 +46,26 @@
<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>
<div class="sidebar-footer">
<div class="user-info">
<span class="user-name" id="user-display">...</span>
<a href="/admin" id="admin-link" style="display:none;font-size:11px;color:var(--accent);text-decoration:none">Admin</a>
<a href="/admin" id="admin-link" style="display:none;font-size:11px;color:var(--accent);text-decoration:none">Server Administration</a>
</div>
<div class="footer-actions">
<button class="icon-btn" id="accounts-btn" onclick="toggleAccountsMenu(event)" title="Manage accounts">
<svg viewBox="0 0 24 24"><path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z"/></svg>
</button>
<button class="icon-btn" onclick="openSettings()" title="Settings">
<svg viewBox="0 0 24 24"><path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg>
</button>
@@ -51,12 +75,32 @@
</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">
<div class="panel-header">
<span class="panel-title" id="panel-title">Unified Inbox</span>
<span class="panel-count" id="panel-count"></span>
<div style="display:flex;align-items:center;gap:6px">
<span class="panel-count" id="panel-count"></span>
<div class="filter-dropdown" id="filter-dropdown">
<button class="filter-dropdown-btn" id="filter-dropdown-btn" title="Filter &amp; sort" onclick="var m=document.getElementById('filter-dropdown-menu');m.style.display=m.style.display==='block'?'none':'block';event.stopPropagation()">
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M10 18h4v-2h-4v2zM3 6v2h18V6H3zm3 7h12v-2H6v2z"/></svg>
<span id="filter-label">Filter</span>
</button>
<div class="filter-dropdown-menu" id="filter-dropdown-menu" style="display:none">
<div class="filter-opt" id="fopt-default" onclick="goMailSetFilter('default');event.stopPropagation()">✓ Default order</div>
<div class="filter-sep-line"></div>
<div class="filter-opt" id="fopt-unread" onclick="goMailSetFilter('unread');event.stopPropagation()">○ Unread only</div>
<div class="filter-opt" id="fopt-attachment" onclick="goMailSetFilter('attachment');event.stopPropagation()">○ 📎 Has attachment</div>
<div class="filter-sep-line"></div>
<div class="filter-opt" id="fopt-date-desc" onclick="goMailSetFilter('date-desc');event.stopPropagation()">○ Newest first</div>
<div class="filter-opt" id="fopt-date-asc" onclick="goMailSetFilter('date-asc');event.stopPropagation()">○ Oldest first</div>
<div class="filter-opt" id="fopt-size-desc" onclick="goMailSetFilter('size-desc');event.stopPropagation()">○ Largest first</div>
</div>
</div>
</div>
</div>
<div class="search-bar">
<div class="search-wrap">
@@ -77,16 +121,135 @@
<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>
<!-- Compose window -->
<div class="compose-overlay" id="compose-overlay">
<div class="compose-window" id="compose-window">
<div id="compose-resize-handle"></div>
<div class="compose-header">
<span class="compose-title" id="compose-title">New Message</span>
<button class="compose-close" onclick="closeCompose()">&#215;</button>
<!-- ── 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 ──────────────────────────────────────────────── -->
<div class="accounts-popup" id="accounts-popup">
<div class="accounts-popup-inner">
<div class="accounts-popup-header">
<span>Accounts</span>
<button class="icon-btn" onclick="closeAccountsMenu()" style="margin:-4px -4px -4px 0">
<svg viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
</button>
</div>
<div id="accounts-popup-list"></div>
<button class="accounts-add-btn" onclick="closeAccountsMenu();openAddAccountModal()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
Connect new account
</button>
</div>
</div>
<div class="accounts-popup-backdrop" id="accounts-popup-backdrop" onclick="closeAccountsMenu()"></div>
<!-- ── Draggable Compose dialog ───────────────────────────────────────────── -->
<div class="compose-dialog" id="compose-dialog">
<div class="compose-dialog-header" id="compose-drag-handle">
<span class="compose-title" id="compose-title">New Message</span>
<div style="display:flex;align-items:center;gap:2px">
<button class="compose-close" onclick="minimizeCompose()" title="Minimise">&#8211;</button>
<button class="compose-close" onclick="closeCompose()" title="Close">&#215;</button>
</div>
</div>
<div class="compose-body-wrap" id="compose-body-wrap">
<div class="compose-field"><label>From</label><select id="compose-from"></select></div>
<div class="compose-field compose-tag-field"><label>To</label><div id="compose-to" class="tag-container"></div></div>
<div class="compose-field compose-tag-field" id="cc-row" style="display:none"><label>CC</label><div id="compose-cc-tags" class="tag-container"></div></div>
@@ -108,35 +271,81 @@
<div class="compose-footer">
<button class="send-btn" id="send-btn" onclick="sendMessage()">Send</button>
<div style="display:flex;gap:6px;margin-left:4px">
<button class="btn-secondary" style="font-size:12px" onclick="document.getElementById('cc-row').style.display='flex'">+CC</button>
<button class="btn-secondary" style="font-size:12px" onclick="document.getElementById('bcc-row').style.display='flex'">+BCC</button>
<button class="btn-secondary" style="font-size:12px" onclick="showCCRow()">+CC</button>
<button class="btn-secondary" style="font-size:12px" onclick="showBCCRow()">+BCC</button>
<button class="btn-secondary" style="font-size:12px" onclick="triggerAttach()">&#128206; Attach</button>
<button class="btn-secondary" style="font-size:12px" onclick="saveDraft()">&#9998; Draft</button>
</div>
<input type="file" id="compose-attach-input" multiple style="display:none" onchange="handleAttachFiles(this)">
</div>
</div>
<div class="compose-resize" data-dir="e"></div>
<div class="compose-resize" data-dir="s"></div>
<div class="compose-resize" data-dir="se"></div>
<div class="compose-resize" data-dir="w"></div>
<div class="compose-resize" data-dir="sw"></div>
<div class="compose-resize" data-dir="n"></div>
<div class="compose-resize" data-dir="ne"></div>
<div class="compose-resize" data-dir="nw"></div>
</div>
<!-- Minimised pill (shown when user clicks on header) -->
<div class="compose-minimised" id="compose-minimised" onclick="restoreCompose()">
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><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>
<span id="compose-minimised-label">New Message</span>
</div>
<!-- Add Account Modal -->
<!-- ── Inline confirm (replaces browser confirm()) ───────────────────────── -->
<div class="inline-confirm" id="inline-confirm">
<p id="inline-confirm-msg" style="margin:0 0 14px;font-size:13px;line-height:1.5"></p>
<div style="display:flex;gap:8px;justify-content:flex-end">
<button class="btn-secondary" style="font-size:12px" id="inline-confirm-cancel">Cancel</button>
<button class="btn-danger" style="font-size:12px" id="inline-confirm-ok">Confirm</button>
</div>
</div>
<!-- ── Add Account Modal ──────────────────────────────────────────────────── -->
<div class="modal-overlay" id="add-account-modal">
<div class="modal">
<h2>Connect an account</h2>
<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>
<div class="modal-field"><label>Email Address</label><input type="email" id="imap-email" placeholder="you@example.com"></div>
<div class="modal-field"><label>Email Address</label>
<div style="display:flex;gap:8px;flex:1">
<input type="email" id="imap-email" placeholder="you@example.com" style="flex:1">
<button class="btn-secondary" id="detect-btn" onclick="detectMailSettings()" style="white-space:nowrap;font-size:12px">Auto-detect</button>
</div>
</div>
<div class="modal-field"><label>Display Name</label><input type="text" id="imap-name" placeholder="Your Name"></div>
<div class="modal-field"><label>Password / App Password</label><input type="password" id="imap-password"></div>
<div style="font-size:11px;color:var(--muted);padding:0 0 8px;line-height:1.6">
Common ports — IMAP: <strong>993</strong> TLS/SSL, <strong>143</strong> STARTTLS/Plain &nbsp;·&nbsp;
SMTP: <strong>587</strong> STARTTLS, <strong>465</strong> TLS/SSL, <strong>25</strong> Plain
</div>
<div class="modal-row">
<div class="modal-field"><label>IMAP Host</label><input type="text" id="imap-host" placeholder="imap.example.com"></div>
<div class="modal-field"><label>IMAP Port</label><input type="number" id="imap-port" value="993"></div>
@@ -154,35 +363,59 @@
</div>
</div>
<!-- Edit Account Modal -->
<!-- ── Edit Account Modal ─────────────────────────────────────────────────── -->
<div class="modal-overlay" id="edit-account-modal">
<div class="modal">
<h2>Account Settings</h2>
<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>Import mode</label>
<label>Email history to sync</label>
<select id="edit-sync-mode" onchange="toggleSyncDaysField()" style="padding:8px 10px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px;outline:none">
<option value="days">Last N days</option>
<option value="all">Full mailbox (all email)</option>
<option value="preset-30">Last 1 month</option>
<option value="preset-90">Last 3 months</option>
<option value="preset-180">Last 6 months</option>
<option value="preset-365">Last 1 year</option>
<option value="preset-730">Last 2 years</option>
<option value="preset-1825">Last 5 years</option>
<option value="all" selected>All emails (full mailbox)</option>
<option value="days">Custom (days)</option>
</select>
</div>
<div class="modal-row" id="edit-sync-days-row">
<div class="modal-field"><label>Days to fetch</label><input type="number" id="edit-sync-days" value="30" min="1" max="3650"></div>
<div class="modal-row" id="edit-sync-days-row" style="display:none">
<div class="modal-field"><label>Custom days to fetch</label><input type="number" id="edit-sync-days" value="30" min="1" max="36500"></div>
</div>
<div id="edit-conn-result" class="test-result" style="display:none"></div>
<div id="edit-last-error" style="display:none" class="alert error"></div>
<div class="settings-group-title" style="margin:16px 0 8px">Hidden Folders</div>
<div id="edit-hidden-folders" style="font-size:12px;color:var(--muted)">Loading…</div>
<div class="modal-actions">
<button class="modal-cancel" onclick="closeModal('edit-account-modal')">Cancel</button>
<button class="btn-secondary" id="edit-test-btn" onclick="testEditConnection()">Test Connection</button>
@@ -191,14 +424,36 @@
</div>
</div>
<!-- Settings Modal -->
<!-- ── Settings Modal ─────────────────────────────────────────────────────── -->
<div class="modal-overlay" id="settings-modal">
<div class="modal" style="width:520px">
<div class="modal" style="width:540px;max-height:90vh;overflow-y:auto">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:22px">
<h2 style="margin-bottom:0">Settings</h2>
<button onclick="closeModal('settings-modal')" class="icon-btn"><svg viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></button>
</div>
<div class="settings-group">
<div class="settings-group-title">Profile</div>
<div class="modal-field">
<label>Username</label>
<div style="display:flex;gap:8px">
<input type="text" id="profile-username" placeholder="New username" style="flex:1">
<button class="btn-primary" onclick="updateProfile('username')">Save</button>
</div>
</div>
<div class="modal-field">
<label>Email Address</label>
<div style="display:flex;gap:8px">
<input type="email" id="profile-email" placeholder="New email address" style="flex:1">
<button class="btn-primary" onclick="updateProfile('email')">Save</button>
</div>
</div>
<div class="modal-field">
<label>Current Password <span style="color:var(--muted);font-size:11px">(required to confirm changes)</span></label>
<input type="password" id="profile-confirm-pw" placeholder="Enter your current password">
</div>
</div>
<div class="settings-group">
<div class="settings-group-title">Email Sync</div>
<div style="font-size:13px;color:var(--muted);margin-bottom:12px">How often to automatically check all your accounts for new mail.</div>
@@ -216,15 +471,6 @@
</div>
</div>
<div class="settings-group">
<div class="settings-group-title">Compose Window</div>
<div style="font-size:13px;color:var(--muted);margin-bottom:12px">Open new message as an in-page panel (default) or a separate popup window.</div>
<label style="display:flex;align-items:center;gap:10px;font-size:13px;cursor:pointer">
<input type="checkbox" id="compose-popup-toggle" onchange="saveComposePopupPref()">
Open compose in new popup window
</label>
</div>
<div class="settings-group">
<div class="settings-group-title">Change Password</div>
<div class="modal-field"><label>Current Password</label><input type="password" id="cur-pw"></div>
@@ -238,6 +484,27 @@
</div>
<div id="mfa-panel">Loading...</div>
</div>
<div class="settings-group">
<div class="settings-group-title">IP Access Rules</div>
<div style="font-size:13px;color:var(--muted);margin-bottom:14px">
Control which IP addresses can access your account. This overrides global brute-force settings for your account only.
</div>
<div class="modal-field">
<label>Mode</label>
<select id="ip-rule-mode" onchange="toggleIPRuleHelp()" style="width:100%;padding:8px 10px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-family:'DM Sans',sans-serif;font-size:13px;outline:none">
<option value="disabled">Disabled — use global settings</option>
<option value="brute_skip">Skip brute-force check — listed IPs bypass lockout</option>
<option value="allow_only">Allow only — only listed IPs can log in</option>
</select>
</div>
<div id="ip-rule-help" style="font-size:12px;color:var(--muted);margin-bottom:10px;display:none"></div>
<div class="modal-field" id="ip-rule-list-field">
<label>Allowed IPs <span style="color:var(--muted);font-size:11px">(comma-separated)</span></label>
<input type="text" id="ip-rule-list" placeholder="e.g. 192.168.1.10, 10.0.0.5">
</div>
<button class="btn-primary" onclick="saveIPRules()">Save IP Rules</button>
</div>
</div>
</div>
@@ -247,5 +514,6 @@
{{end}}
{{define "scripts"}}
<script src="/static/js/app.js"></script>
<script src="/static/js/app.js?v=58"></script>
<script src="/static/js/contacts_calendar.js?v=58"></script>
{{end}}

View File

@@ -3,15 +3,15 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{block "title" .}}GoMail{{end}}</title>
<title>{{block "title" .}}GoWebMail{{end}}</title>
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=DM+Sans:ital,wght@0,300;0,400;0,500;1,400&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/css/gomail.css">
<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/gomail.js"></script>
<script src="/static/js/gowebmail.js?v=58"></script>
{{block "scripts" .}}{{end}}
</body>
</html>
{{end}}
{{end}}

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

@@ -1,26 +1,25 @@
{{template "base" .}}
{{define "title"}}GoMail — Sign In{{end}}
{{define "title"}}GoWebMail — Sign In{{end}}
{{define "body_class"}}auth-page{{end}}
{{define "body"}}
<div class="auth-card">
<div class="logo">
<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">GoMail</span>
<span class="logo-text">GoWebMail</span>
</div>
<h1>Welcome back</h1>
<p class="subtitle">Sign in to your GoMail account</p>
<p class="subtitle">Sign in to your Web Mail Client</p>
<div id="err" class="alert error" style="display:none"></div>
<form method="POST" action="/auth/login">
<div class="field"><label>Username or Email</label><input type="text" name="username" placeholder="admin" required autocomplete="username"></div>
<div class="field"><label>Password</label><input type="password" name="password" placeholder="••••••••" required autocomplete="current-password"></div>
<button class="btn-primary" type="submit" style="width:100%;padding:13px;font-size:15px;margin-top:8px">Sign In</button>
</form>
<p style="font-size:12px;color:var(--muted);margin-top:16px;text-align:center">Default credentials: <strong>admin</strong> / <strong>admin</strong></p>
</div>
{{end}}
{{define "scripts"}}
<script>
const msgs={invalid_credentials:'Invalid username or password.',missing_fields:'Please fill in all fields.'};
const msgs={invalid_credentials:'Invalid username or password.',missing_fields:'Please fill in all fields.',location_not_authorized:'Access from your current location is not permitted for this account.'};
const k=new URLSearchParams(location.search).get('error');
if(k){const b=document.getElementById('err');b.textContent=msgs[k]||'An error occurred.';b.style.display='block';}
</script>

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

View File

@@ -1,11 +1,11 @@
{{template "base" .}}
{{define "title"}}GoMail — Two-Factor Auth{{end}}
{{define "title"}}GoWebMail — Two-Factor Auth{{end}}
{{define "body_class"}}auth-page{{end}}
{{define "body"}}
<div class="auth-card">
<div class="logo">
<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">GoMail</span>
<span class="logo-text">GoWebMail</span>
</div>
<h1>Two-Factor Auth</h1>
<p class="subtitle">Enter the 6-digit code from your authenticator app</p>

8
webfs.go Normal file
View File

@@ -0,0 +1,8 @@
package gowebmail
import "embed"
// Global access to the web assets
//
//go:embed web/static/css/* web/static/js/* web/static/img/* web/templates/**
var WebFS embed.FS