first commit

This commit is contained in:
ghostersk
2026-03-07 06:20:39 +00:00
commit d18cdbecd8
33 changed files with 7938 additions and 0 deletions

220
internal/handlers/admin.go Normal file
View File

@@ -0,0 +1,220 @@
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"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).
type AdminHandler struct {
db *db.DB
cfg *config.Config
renderer *Renderer
}
func (h *AdminHandler) writeJSON(w http.ResponseWriter, v interface{}) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}
func (h *AdminHandler) writeError(w http.ResponseWriter, status int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]string{"error": msg})
}
// ShowAdmin serves the admin SPA shell for all /admin/* routes.
func (h *AdminHandler) ShowAdmin(w http.ResponseWriter, r *http.Request) {
h.renderer.Render(w, "admin", nil)
}
// ---- User Management ----
func (h *AdminHandler) ListUsers(w http.ResponseWriter, r *http.Request) {
users, err := h.db.ListUsers()
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to list users")
return
}
// 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"`
}
result := make([]safeUser, 0, len(users))
for _, u := range users {
result = append(result, safeUser{
ID: u.ID, Email: u.Email, Username: u.Username,
Role: u.Role, IsActive: u.IsActive, MFAEnabled: u.MFAEnabled,
LastLoginAt: u.LastLoginAt, CreatedAt: u.CreatedAt,
})
}
h.writeJSON(w, result)
}
func (h *AdminHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
var req struct {
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password"`
Role string `json:"role"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid request")
return
}
if req.Username == "" || req.Email == "" || req.Password == "" {
h.writeError(w, http.StatusBadRequest, "username, email and password required")
return
}
if len(req.Password) < 8 {
h.writeError(w, http.StatusBadRequest, "password must be at least 8 characters")
return
}
role := models.RoleUser
if req.Role == "admin" {
role = models.RoleAdmin
}
user, err := h.db.CreateUser(req.Username, req.Email, req.Password, role)
if err != nil {
h.writeError(w, http.StatusConflict, err.Error())
return
}
adminID := middleware.GetUserID(r)
h.db.WriteAudit(&adminID, models.AuditUserCreate,
"created user: "+req.Email, middleware.ClientIP(r), r.UserAgent())
h.writeJSON(w, map[string]interface{}{"id": user.ID, "ok": true})
}
func (h *AdminHandler) UpdateUser(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
targetID, _ := strconv.ParseInt(vars["id"], 10, 64)
var req struct {
IsActive *bool `json:"is_active"`
Password string `json:"password"`
Role string `json:"role"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid request")
return
}
if req.IsActive != nil {
if err := h.db.SetUserActive(targetID, *req.IsActive); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to update user")
return
}
}
if req.Password != "" {
if len(req.Password) < 8 {
h.writeError(w, http.StatusBadRequest, "password must be at least 8 characters")
return
}
if err := h.db.UpdateUserPassword(targetID, req.Password); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to update password")
return
}
}
adminID := middleware.GetUserID(r)
h.db.WriteAudit(&adminID, models.AuditUserUpdate,
"updated user id:"+vars["id"], middleware.ClientIP(r), r.UserAgent())
h.writeJSON(w, map[string]bool{"ok": true})
}
func (h *AdminHandler) DeleteUser(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
targetID, _ := strconv.ParseInt(vars["id"], 10, 64)
adminID := middleware.GetUserID(r)
if targetID == adminID {
h.writeError(w, http.StatusBadRequest, "cannot delete yourself")
return
}
if err := h.db.DeleteUser(targetID); err != nil {
h.writeError(w, http.StatusInternalServerError, "delete failed")
return
}
h.db.WriteAudit(&adminID, models.AuditUserDelete,
"deleted user id:"+vars["id"], middleware.ClientIP(r), r.UserAgent())
h.writeJSON(w, map[string]bool{"ok": true})
}
// ---- Audit Log ----
func (h *AdminHandler) ListAuditLogs(w http.ResponseWriter, r *http.Request) {
page := 1
pageSize := 100
if p := r.URL.Query().Get("page"); p != "" {
if v, err := strconv.Atoi(p); err == nil && v > 0 {
page = v
}
}
eventFilter := r.URL.Query().Get("event")
result, err := h.db.ListAuditLogs(page, pageSize, eventFilter)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to fetch logs")
return
}
h.writeJSON(w, result)
}
// ---- App Settings ----
func (h *AdminHandler) GetSettings(w http.ResponseWriter, r *http.Request) {
settings, err := config.GetSettings()
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to read settings")
return
}
h.writeJSON(w, settings)
}
func (h *AdminHandler) SetSettings(w http.ResponseWriter, r *http.Request) {
adminID := middleware.GetUserID(r)
var updates map[string]string
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid request body")
return
}
changed, err := config.SetSettings(updates)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to save settings")
return
}
if len(changed) > 0 {
detail := "changed config keys: " + strings.Join(changed, ", ")
h.db.WriteAudit(&adminID, models.AuditConfigChange,
detail, middleware.ClientIP(r), r.UserAgent())
}
h.writeJSON(w, map[string]interface{}{
"ok": true,
"changed": changed,
})
}

636
internal/handlers/api.go Normal file
View File

@@ -0,0 +1,636 @@
package handlers
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"strconv"
"strings"
"github.com/gorilla/mux"
"github.com/yourusername/gomail/config"
"github.com/yourusername/gomail/internal/db"
"github.com/yourusername/gomail/internal/email"
"github.com/yourusername/gomail/internal/middleware"
"github.com/yourusername/gomail/internal/models"
"github.com/yourusername/gomail/internal/syncer"
)
// APIHandler handles all /api/* JSON endpoints.
type APIHandler struct {
db *db.DB
cfg *config.Config
syncer *syncer.Scheduler
}
func (h *APIHandler) writeJSON(w http.ResponseWriter, v interface{}) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}
func (h *APIHandler) writeError(w http.ResponseWriter, status int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]string{"error": msg})
}
// ---- Provider availability ----
// GetProviders returns which OAuth providers are configured and enabled.
func (h *APIHandler) GetProviders(w http.ResponseWriter, r *http.Request) {
h.writeJSON(w, map[string]bool{
"gmail": h.cfg.GoogleClientID != "" && h.cfg.GoogleClientSecret != "",
"outlook": h.cfg.MicrosoftClientID != "" && h.cfg.MicrosoftClientSecret != "",
})
}
// ---- Accounts ----
type safeAccount struct {
ID int64 `json:"id"`
Provider models.AccountProvider `json:"provider"`
EmailAddress string `json:"email_address"`
DisplayName string `json:"display_name"`
IMAPHost string `json:"imap_host,omitempty"`
IMAPPort int `json:"imap_port,omitempty"`
SMTPHost string `json:"smtp_host,omitempty"`
SMTPPort int `json:"smtp_port,omitempty"`
LastError string `json:"last_error,omitempty"`
Color string `json:"color"`
LastSync string `json:"last_sync"`
}
func toSafeAccount(a *models.EmailAccount) safeAccount {
lastSync := ""
if !a.LastSync.IsZero() {
lastSync = a.LastSync.Format("2006-01-02T15:04:05Z")
}
return safeAccount{
ID: a.ID, Provider: a.Provider, EmailAddress: a.EmailAddress,
DisplayName: a.DisplayName, IMAPHost: a.IMAPHost, IMAPPort: a.IMAPPort,
SMTPHost: a.SMTPHost, SMTPPort: a.SMTPPort,
LastError: a.LastError, Color: a.Color, LastSync: lastSync,
}
}
func (h *APIHandler) ListAccounts(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
accounts, err := h.db.ListAccountsByUser(userID)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to list accounts")
return
}
result := make([]safeAccount, 0, len(accounts))
for _, a := range accounts {
result = append(result, toSafeAccount(a))
}
h.writeJSON(w, result)
}
func (h *APIHandler) AddAccount(w http.ResponseWriter, r *http.Request) {
var req struct {
Email string `json:"email"`
DisplayName string `json:"display_name"`
Password string `json:"password"`
IMAPHost string `json:"imap_host"`
IMAPPort int `json:"imap_port"`
SMTPHost string `json:"smtp_host"`
SMTPPort int `json:"smtp_port"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Email == "" || req.Password == "" {
h.writeError(w, http.StatusBadRequest, "email and password required")
return
}
if req.IMAPHost == "" {
h.writeError(w, http.StatusBadRequest, "IMAP host required")
return
}
if req.IMAPPort == 0 {
req.IMAPPort = 993
}
if req.SMTPPort == 0 {
req.SMTPPort = 587
}
userID := middleware.GetUserID(r)
accounts, _ := h.db.ListAccountsByUser(userID)
colors := []string{"#4A90D9", "#EA4335", "#34A853", "#FBBC04", "#FF6D00", "#9C27B0"}
color := colors[len(accounts)%len(colors)]
account := &models.EmailAccount{
UserID: userID, Provider: models.ProviderIMAPSMTP,
EmailAddress: req.Email, DisplayName: req.DisplayName,
AccessToken: req.Password,
IMAPHost: req.IMAPHost, IMAPPort: req.IMAPPort,
SMTPHost: req.SMTPHost, SMTPPort: req.SMTPPort,
Color: color, IsActive: true,
}
if err := h.db.CreateAccount(account); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to create account")
return
}
uid := userID
h.db.WriteAudit(&uid, models.AuditAccountAdd, "imap:"+req.Email, middleware.ClientIP(r), r.UserAgent())
// Trigger an immediate sync in background
go h.syncer.SyncAccountNow(account.ID)
h.writeJSON(w, map[string]interface{}{"id": account.ID, "ok": true})
}
func (h *APIHandler) GetAccount(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
accountID := pathInt64(r, "id")
account, err := h.db.GetAccount(accountID)
if err != nil || account == nil || account.UserID != userID {
h.writeError(w, http.StatusNotFound, "account not found")
return
}
h.writeJSON(w, toSafeAccount(account))
}
func (h *APIHandler) UpdateAccount(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
accountID := pathInt64(r, "id")
account, err := h.db.GetAccount(accountID)
if err != nil || account == nil || account.UserID != userID {
h.writeError(w, http.StatusNotFound, "account not found")
return
}
var req struct {
DisplayName string `json:"display_name"`
Password string `json:"password"`
IMAPHost string `json:"imap_host"`
IMAPPort int `json:"imap_port"`
SMTPHost string `json:"smtp_host"`
SMTPPort int `json:"smtp_port"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid request")
return
}
if req.DisplayName != "" {
account.DisplayName = req.DisplayName
}
if req.Password != "" {
account.AccessToken = req.Password
}
if req.IMAPHost != "" {
account.IMAPHost = req.IMAPHost
}
if req.IMAPPort > 0 {
account.IMAPPort = req.IMAPPort
}
if req.SMTPHost != "" {
account.SMTPHost = req.SMTPHost
}
if req.SMTPPort > 0 {
account.SMTPPort = req.SMTPPort
}
if err := h.db.UpdateAccount(account); err != nil {
h.writeError(w, http.StatusInternalServerError, "update failed")
return
}
h.writeJSON(w, map[string]bool{"ok": true})
}
func (h *APIHandler) TestConnection(w http.ResponseWriter, r *http.Request) {
var req struct {
Email string `json:"email"`
Password string `json:"password"`
IMAPHost string `json:"imap_host"`
IMAPPort int `json:"imap_port"`
SMTPHost string `json:"smtp_host"`
SMTPPort int `json:"smtp_port"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid request")
return
}
if req.IMAPPort == 0 {
req.IMAPPort = 993
}
testAccount := &models.EmailAccount{
Provider: models.ProviderIMAPSMTP,
EmailAddress: req.Email,
AccessToken: req.Password,
IMAPHost: req.IMAPHost,
IMAPPort: req.IMAPPort,
SMTPHost: req.SMTPHost,
SMTPPort: req.SMTPPort,
}
if err := email.TestConnection(testAccount); err != nil {
h.writeJSON(w, map[string]interface{}{"ok": false, "error": err.Error()})
return
}
h.writeJSON(w, map[string]bool{"ok": true})
}
func (h *APIHandler) DeleteAccount(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
accountID := pathInt64(r, "id")
if err := h.db.DeleteAccount(accountID, userID); err != nil {
h.writeError(w, http.StatusInternalServerError, "delete failed")
return
}
uid := userID
h.db.WriteAudit(&uid, models.AuditAccountDel, strconv.FormatInt(accountID, 10), middleware.ClientIP(r), r.UserAgent())
h.writeJSON(w, map[string]bool{"ok": true})
}
func (h *APIHandler) SyncAccount(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
accountID := pathInt64(r, "id")
account, err := h.db.GetAccount(accountID)
if err != nil || account == nil || account.UserID != userID {
h.writeError(w, http.StatusNotFound, "account not found")
return
}
synced, err := h.syncer.SyncAccountNow(accountID)
if err != nil {
h.writeError(w, http.StatusBadGateway, err.Error())
return
}
h.writeJSON(w, map[string]interface{}{"ok": true, "synced": synced})
}
func (h *APIHandler) SyncFolder(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
folderID := pathInt64(r, "id")
folder, err := h.db.GetFolderByID(folderID)
if err != nil || folder == nil {
h.writeError(w, http.StatusNotFound, "folder not found")
return
}
account, err := h.db.GetAccount(folder.AccountID)
if err != nil || account == nil || account.UserID != userID {
h.writeError(w, http.StatusNotFound, "folder not found")
return
}
synced, err := h.syncer.SyncFolderNow(folder.AccountID, folderID)
if err != nil {
h.writeError(w, http.StatusBadGateway, err.Error())
return
}
h.writeJSON(w, map[string]interface{}{"ok": true, "synced": synced})
}
func (h *APIHandler) SetAccountSyncSettings(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
accountID := pathInt64(r, "id")
var req struct {
SyncDays int `json:"sync_days"`
SyncMode string `json:"sync_mode"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid request")
return
}
if err := h.db.SetAccountSyncSettings(accountID, userID, req.SyncDays, req.SyncMode); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to save")
return
}
h.writeJSON(w, map[string]bool{"ok": true})
}
func (h *APIHandler) SetComposePopup(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
var req struct {
Popup bool `json:"compose_popup"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid request")
return
}
if err := h.db.SetComposePopup(userID, req.Popup); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to save")
return
}
h.writeJSON(w, map[string]bool{"ok": true})
}
// ---- Messages ----
func (h *APIHandler) ListMessages(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
page := queryInt(r, "page", 1)
pageSize := queryInt(r, "page_size", 50)
accountID := queryInt64(r, "account_id", 0)
folderID := queryInt64(r, "folder_id", 0)
var folderIDs []int64
if folderID > 0 {
folderIDs = []int64{folderID}
}
result, err := h.db.ListMessages(userID, folderIDs, accountID, page, pageSize)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to list messages")
return
}
h.writeJSON(w, result)
}
func (h *APIHandler) UnifiedInbox(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
page := queryInt(r, "page", 1)
pageSize := queryInt(r, "page_size", 50)
// Get all inbox folder IDs for this user
folders, err := h.db.GetFoldersByUser(userID)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to get folders")
return
}
var inboxIDs []int64
for _, f := range folders {
if f.FolderType == "inbox" {
inboxIDs = append(inboxIDs, f.ID)
}
}
result, err := h.db.ListMessages(userID, inboxIDs, 0, page, pageSize)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to list messages")
return
}
h.writeJSON(w, result)
}
func (h *APIHandler) GetMessage(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
messageID := pathInt64(r, "id")
msg, err := h.db.GetMessage(messageID, userID)
if err != nil || msg == nil {
h.writeError(w, http.StatusNotFound, "message not found")
return
}
h.db.MarkMessageRead(messageID, userID, true)
h.writeJSON(w, msg)
}
func (h *APIHandler) MarkRead(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
messageID := pathInt64(r, "id")
var req struct{ Read bool `json:"read"` }
json.NewDecoder(r.Body).Decode(&req)
h.db.MarkMessageRead(messageID, userID, req.Read)
h.writeJSON(w, map[string]bool{"ok": true})
}
func (h *APIHandler) ToggleStar(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
messageID := pathInt64(r, "id")
starred, err := h.db.ToggleMessageStar(messageID, userID)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to toggle star")
return
}
h.writeJSON(w, map[string]bool{"ok": true, "starred": starred})
}
func (h *APIHandler) MoveMessage(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
messageID := pathInt64(r, "id")
var req struct{ FolderID int64 `json:"folder_id"` }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.FolderID == 0 {
h.writeError(w, http.StatusBadRequest, "folder_id required")
return
}
if err := h.db.MoveMessage(messageID, userID, req.FolderID); err != nil {
h.writeError(w, http.StatusInternalServerError, "move failed")
return
}
h.writeJSON(w, map[string]bool{"ok": true})
}
func (h *APIHandler) DeleteMessage(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
messageID := pathInt64(r, "id")
if err := h.db.DeleteMessage(messageID, userID); err != nil {
h.writeError(w, http.StatusInternalServerError, "delete failed")
return
}
h.writeJSON(w, map[string]bool{"ok": true})
}
// ---- Send / Reply / Forward ----
func (h *APIHandler) SendMessage(w http.ResponseWriter, r *http.Request) {
h.handleSend(w, r, "new")
}
func (h *APIHandler) ReplyMessage(w http.ResponseWriter, r *http.Request) {
h.handleSend(w, r, "reply")
}
func (h *APIHandler) ForwardMessage(w http.ResponseWriter, r *http.Request) {
h.handleSend(w, r, "forward")
}
func (h *APIHandler) handleSend(w http.ResponseWriter, r *http.Request, mode string) {
userID := middleware.GetUserID(r)
var req models.ComposeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid request")
return
}
account, err := h.db.GetAccount(req.AccountID)
if err != nil || account == nil || account.UserID != userID {
h.writeError(w, http.StatusBadRequest, "account not found")
return
}
if err := email.SendMessageFull(context.Background(), account, &req); err != nil {
log.Printf("SMTP send failed account=%d user=%d: %v", req.AccountID, userID, err)
h.db.WriteAudit(&userID, models.AuditAppError,
fmt.Sprintf("send failed account:%d %v", req.AccountID, err),
middleware.ClientIP(r), r.UserAgent())
h.writeError(w, http.StatusBadGateway, err.Error())
return
}
h.writeJSON(w, map[string]bool{"ok": true})
}
// ---- Folders ----
func (h *APIHandler) ListFolders(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
folders, err := h.db.GetFoldersByUser(userID)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to get folders")
return
}
if folders == nil {
folders = []*models.Folder{}
}
h.writeJSON(w, folders)
}
func (h *APIHandler) ListAccountFolders(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
accountID := pathInt64(r, "account_id")
account, err := h.db.GetAccount(accountID)
if err != nil || account == nil || account.UserID != userID {
h.writeError(w, http.StatusNotFound, "account not found")
return
}
folders, err := h.db.ListFoldersByAccount(accountID)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to get folders")
return
}
if folders == nil {
folders = []*models.Folder{}
}
h.writeJSON(w, folders)
}
// ---- Search ----
func (h *APIHandler) Search(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
q := strings.TrimSpace(r.URL.Query().Get("q"))
if q == "" {
h.writeError(w, http.StatusBadRequest, "q parameter required")
return
}
page := queryInt(r, "page", 1)
pageSize := queryInt(r, "page_size", 50)
result, err := h.db.SearchMessages(userID, q, page, pageSize)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "search failed")
return
}
h.writeJSON(w, result)
}
// ---- Sync interval (per-user) ----
func (h *APIHandler) GetSyncInterval(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
interval, err := h.db.GetUserSyncInterval(userID)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to get sync interval")
return
}
h.writeJSON(w, map[string]int{"sync_interval": interval})
}
func (h *APIHandler) SetSyncInterval(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
var req struct {
SyncInterval int `json:"sync_interval"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid request")
return
}
if req.SyncInterval != 0 && (req.SyncInterval < 1 || req.SyncInterval > 60) {
h.writeError(w, http.StatusBadRequest, "sync_interval must be 0 (manual) or 1-60 minutes")
return
}
if err := h.db.SetUserSyncInterval(userID, req.SyncInterval); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to update sync interval")
return
}
h.writeJSON(w, map[string]bool{"ok": true})
}
// ---- Helpers ----
func pathInt64(r *http.Request, key string) int64 {
v, _ := strconv.ParseInt(mux.Vars(r)[key], 10, 64)
return v
}
func queryInt(r *http.Request, key string, def int) int {
if v := r.URL.Query().Get(key); v != "" {
if i, err := strconv.Atoi(v); err == nil {
return i
}
}
return def
}
func queryInt64(r *http.Request, key string, def int64) int64 {
if v := r.URL.Query().Get(key); v != "" {
if i, err := strconv.ParseInt(v, 10, 64); err == nil {
return i
}
}
return def
}
// ---- Message headers (for troubleshooting) ----
func (h *APIHandler) GetMessageHeaders(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
messageID := pathInt64(r, "id")
msg, err := h.db.GetMessage(messageID, userID)
if err != nil || msg == nil {
h.writeError(w, http.StatusNotFound, "message not found")
return
}
// Return a simplified set of headers we store
headers := map[string]string{
"Message-ID": msg.MessageID,
"From": fmt.Sprintf("%s <%s>", msg.FromName, msg.FromEmail),
"To": msg.ToList,
"Cc": msg.CCList,
"Bcc": msg.BCCList,
"Reply-To": msg.ReplyTo,
"Subject": msg.Subject,
"Date": msg.Date.Format("Mon, 02 Jan 2006 15:04:05 -0700"),
}
h.writeJSON(w, map[string]interface{}{"headers": headers})
}
// ---- Remote content whitelist ----
func (h *APIHandler) GetRemoteContentWhitelist(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
list, err := h.db.GetRemoteContentWhitelist(userID)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to get whitelist")
return
}
if list == nil {
list = []string{}
}
h.writeJSON(w, map[string]interface{}{"whitelist": list})
}
func (h *APIHandler) AddRemoteContentWhitelist(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
var req struct {
Sender string `json:"sender"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Sender == "" {
h.writeError(w, http.StatusBadRequest, "sender required")
return
}
if err := h.db.AddRemoteContentWhitelist(userID, req.Sender); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to add to whitelist")
return
}
h.writeJSON(w, map[string]bool{"ok": true})
}

19
internal/handlers/app.go Normal file
View File

@@ -0,0 +1,19 @@
package handlers
import (
"net/http"
"github.com/yourusername/gomail/config"
"github.com/yourusername/gomail/internal/db"
)
// AppHandler serves the main app pages using the shared Renderer.
type AppHandler struct {
db *db.DB
cfg *config.Config
renderer *Renderer
}
func (h *AppHandler) Index(w http.ResponseWriter, r *http.Request) {
h.renderer.Render(w, "app", nil)
}

401
internal/handlers/auth.go Normal file
View File

@@ -0,0 +1,401 @@
package handlers
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"net/http"
"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"
"golang.org/x/oauth2"
)
// AuthHandler handles login, register, logout, MFA, and OAuth2 connect flows.
type AuthHandler struct {
db *db.DB
cfg *config.Config
renderer *Renderer
}
// ---- Login ----
func (h *AuthHandler) ShowLogin(w http.ResponseWriter, r *http.Request) {
h.renderer.Render(w, "login", nil)
}
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
username := r.FormValue("username")
password := r.FormValue("password")
ip := middleware.ClientIP(r)
ua := r.UserAgent()
if username == "" || password == "" {
http.Redirect(w, r, "/auth/login?error=missing_fields", http.StatusFound)
return
}
// Accept login by username or email
user, err := h.db.GetUserByUsername(username)
if err != nil || user == nil {
user, err = h.db.GetUserByEmail(username)
}
if err != nil || user == nil || !user.IsActive {
h.db.WriteAudit(nil, models.AuditLoginFail, "unknown user: "+username, ip, ua)
http.Redirect(w, r, "/auth/login?error=invalid_credentials", http.StatusFound)
return
}
if err := crypto.CheckPassword(password, user.PasswordHash); err != nil {
uid := user.ID
h.db.WriteAudit(&uid, models.AuditLoginFail, "bad password for: "+username, ip, ua)
http.Redirect(w, r, "/auth/login?error=invalid_credentials", http.StatusFound)
return
}
token, _ := h.db.CreateSession(user.ID, 7*24*time.Hour)
h.setSessionCookie(w, token)
h.db.TouchLastLogin(user.ID)
uid := user.ID
h.db.WriteAudit(&uid, models.AuditLogin, "login from "+ip, ip, ua)
if user.MFAEnabled {
http.Redirect(w, r, "/auth/mfa", http.StatusFound)
return
}
http.Redirect(w, r, "/", http.StatusFound)
}
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("gomail_session")
if err == nil {
userID := middleware.GetUserID(r)
if userID > 0 {
h.db.WriteAudit(&userID, models.AuditLogout, "", middleware.ClientIP(r), r.UserAgent())
}
h.db.DeleteSession(cookie.Value)
}
http.SetCookie(w, &http.Cookie{
Name: "gomail_session", Value: "", MaxAge: -1, Path: "/",
Secure: h.cfg.SecureCookie, HttpOnly: true, SameSite: http.SameSiteLaxMode,
})
http.Redirect(w, r, "/auth/login", http.StatusFound)
}
// ---- MFA ----
func (h *AuthHandler) ShowMFA(w http.ResponseWriter, r *http.Request) {
h.renderer.Render(w, "mfa", nil)
}
func (h *AuthHandler) VerifyMFA(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
code := r.FormValue("code")
ip := middleware.ClientIP(r)
ua := r.UserAgent()
user, err := h.db.GetUserByID(userID)
if err != nil || user == nil {
http.Redirect(w, r, "/auth/login", http.StatusFound)
return
}
if !mfa.Validate(user.MFASecret, code) {
h.db.WriteAudit(&userID, models.AuditMFAFail, "bad TOTP code", ip, ua)
http.Redirect(w, r, "/auth/mfa?error=invalid_code", http.StatusFound)
return
}
cookie, _ := r.Cookie("gomail_session")
h.db.SetSessionMFAVerified(cookie.Value)
h.db.WriteAudit(&userID, models.AuditMFASuccess, "", ip, ua)
http.Redirect(w, r, "/", http.StatusFound)
}
// ---- MFA Setup (user settings) ----
func (h *AuthHandler) MFASetupBegin(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
}
secret, err := mfa.GenerateSecret()
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "failed to generate secret")
return
}
if err := h.db.SetMFAPending(userID, secret); err != nil {
writeJSONError(w, http.StatusInternalServerError, "failed to store pending secret")
return
}
qr := mfa.QRCodeURL("GoMail", user.Email, secret)
otpURL := mfa.OTPAuthURL("GoMail", user.Email, secret)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"qr_url": qr,
"otp_url": otpURL,
"secret": secret,
})
}
func (h *AuthHandler) MFASetupConfirm(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
var req struct{ Code string `json:"code"` }
json.NewDecoder(r.Body).Decode(&req)
user, _ := h.db.GetUserByID(userID)
if user == nil || user.MFAPending == "" {
writeJSONError(w, http.StatusBadRequest, "no pending MFA setup")
return
}
if !mfa.Validate(user.MFAPending, req.Code) {
writeJSONError(w, http.StatusBadRequest, "invalid code — try again")
return
}
if err := h.db.EnableMFA(userID, user.MFAPending); err != nil {
writeJSONError(w, http.StatusInternalServerError, "failed to enable MFA")
return
}
h.db.WriteAudit(&userID, models.AuditMFAEnable, "", middleware.ClientIP(r), r.UserAgent())
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]bool{"ok": true})
}
func (h *AuthHandler) MFADisable(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
var req struct{ Code string `json:"code"` }
json.NewDecoder(r.Body).Decode(&req)
user, _ := h.db.GetUserByID(userID)
if user == nil || !user.MFAEnabled {
writeJSONError(w, http.StatusBadRequest, "MFA not enabled")
return
}
if !mfa.Validate(user.MFASecret, req.Code) {
writeJSONError(w, http.StatusBadRequest, "invalid code")
return
}
h.db.DisableMFA(userID)
h.db.WriteAudit(&userID, models.AuditMFADisable, "", middleware.ClientIP(r), r.UserAgent())
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]bool{"ok": true})
}
// ---- Change password ----
func (h *AuthHandler) ChangePassword(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
var req struct {
CurrentPassword string `json:"current_password"`
NewPassword string `json:"new_password"`
}
json.NewDecoder(r.Body).Decode(&req)
user, _ := h.db.GetUserByID(userID)
if user == nil {
writeJSONError(w, http.StatusUnauthorized, "not authenticated")
return
}
if crypto.CheckPassword(req.CurrentPassword, user.PasswordHash) != nil {
writeJSONError(w, http.StatusBadRequest, "current password incorrect")
return
}
if len(req.NewPassword) < 8 {
writeJSONError(w, http.StatusBadRequest, "password must be at least 8 characters")
return
}
if err := h.db.UpdateUserPassword(userID, req.NewPassword); err != nil {
writeJSONError(w, http.StatusInternalServerError, "failed to update password")
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]bool{"ok": true})
}
// ---- Me ----
func (h *AuthHandler) Me(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
user, _ := h.db.GetUserByID(userID)
if user == nil {
writeJSONError(w, http.StatusUnauthorized, "not authenticated")
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"id": user.ID,
"email": user.Email,
"username": user.Username,
"role": user.Role,
"mfa_enabled": user.MFAEnabled,
"compose_popup": user.ComposePopup,
"sync_interval": user.SyncInterval,
})
}
// ---- Gmail OAuth2 ----
func (h *AuthHandler) GmailConnect(w http.ResponseWriter, r *http.Request) {
if h.cfg.GoogleClientID == "" {
writeJSONError(w, http.StatusServiceUnavailable, "Google OAuth2 not configured.")
return
}
userID := middleware.GetUserID(r)
state := encodeOAuthState(userID, "gmail")
cfg := goauth.NewGmailConfig(h.cfg.GoogleClientID, h.cfg.GoogleClientSecret, h.cfg.GoogleRedirectURL)
url := cfg.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.ApprovalForce)
http.Redirect(w, r, url, http.StatusFound)
}
func (h *AuthHandler) GmailCallback(w http.ResponseWriter, r *http.Request) {
state := r.URL.Query().Get("state")
code := r.URL.Query().Get("code")
userID, provider := decodeOAuthState(state)
if userID == 0 || provider != "gmail" {
http.Redirect(w, r, "/?error=oauth_state_mismatch", http.StatusFound)
return
}
oauthCfg := goauth.NewGmailConfig(h.cfg.GoogleClientID, h.cfg.GoogleClientSecret, h.cfg.GoogleRedirectURL)
token, err := oauthCfg.Exchange(r.Context(), code)
if err != nil {
http.Redirect(w, r, "/?error=oauth_exchange_failed", http.StatusFound)
return
}
userInfo, err := goauth.GetGoogleUserInfo(r.Context(), token, oauthCfg)
if err != nil {
http.Redirect(w, r, "/?error=userinfo_failed", http.StatusFound)
return
}
colors := []string{"#EA4335", "#4285F4", "#34A853", "#FBBC04", "#FF6D00", "#9C27B0"}
accounts, _ := h.db.ListAccountsByUser(userID)
color := colors[len(accounts)%len(colors)]
account := &models.EmailAccount{
UserID: userID, Provider: models.ProviderGmail,
EmailAddress: userInfo.Email, DisplayName: userInfo.Name,
AccessToken: token.AccessToken, RefreshToken: token.RefreshToken,
TokenExpiry: token.Expiry, Color: color, IsActive: true,
}
if err := h.db.CreateAccount(account); 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())
http.Redirect(w, r, "/?connected=gmail", http.StatusFound)
}
// ---- Outlook OAuth2 ----
func (h *AuthHandler) OutlookConnect(w http.ResponseWriter, r *http.Request) {
if h.cfg.MicrosoftClientID == "" {
writeJSONError(w, http.StatusServiceUnavailable, "Microsoft OAuth2 not configured.")
return
}
userID := middleware.GetUserID(r)
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)
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")
userID, provider := decodeOAuthState(state)
if userID == 0 || provider != "outlook" {
http.Redirect(w, r, "/?error=oauth_state_mismatch", http.StatusFound)
return
}
oauthCfg := goauth.NewOutlookConfig(h.cfg.MicrosoftClientID, h.cfg.MicrosoftClientSecret,
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)
return
}
userInfo, err := goauth.GetMicrosoftUserInfo(r.Context(), token, oauthCfg)
if err != nil {
http.Redirect(w, r, "/?error=userinfo_failed", http.StatusFound)
return
}
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,
}
if err := h.db.CreateAccount(account); 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())
http.Redirect(w, r, "/?connected=outlook", http.StatusFound)
}
// ---- Helpers ----
type oauthStatePayload struct {
UserID int64 `json:"u"`
Provider string `json:"p"`
Nonce string `json:"n"`
}
func encodeOAuthState(userID int64, provider string) string {
nonce := make([]byte, 16)
rand.Read(nonce)
payload := oauthStatePayload{UserID: userID, Provider: provider,
Nonce: base64.URLEncoding.EncodeToString(nonce)}
b, _ := json.Marshal(payload)
return base64.URLEncoding.EncodeToString(b)
}
func decodeOAuthState(state string) (int64, string) {
b, err := base64.URLEncoding.DecodeString(state)
if err != nil {
return 0, ""
}
var payload oauthStatePayload
if err := json.Unmarshal(b, &payload); err != nil {
return 0, ""
}
return payload.UserID, payload.Provider
}
func (h *AuthHandler) setSessionCookie(w http.ResponseWriter, token string) {
http.SetCookie(w, &http.Cookie{
Name: "gomail_session", Value: token, Path: "/",
MaxAge: 7 * 24 * 3600, Secure: h.cfg.SecureCookie,
HttpOnly: true, SameSite: http.SameSiteLaxMode,
})
}
func writeJSONError(w http.ResponseWriter, status int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]string{"error": msg})
}

View File

@@ -0,0 +1,30 @@
package handlers
import (
"log"
"github.com/yourusername/gomail/config"
"github.com/yourusername/gomail/internal/db"
"github.com/yourusername/gomail/internal/syncer"
)
type Handlers struct {
Auth *AuthHandler
App *AppHandler
API *APIHandler
Admin *AdminHandler
}
func New(database *db.DB, cfg *config.Config, sc *syncer.Scheduler) *Handlers {
renderer, err := NewRenderer()
if err != nil {
log.Fatalf("failed to load templates: %v", err)
}
return &Handlers{
Auth: &AuthHandler{db: database, cfg: cfg, renderer: renderer},
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

@@ -0,0 +1,73 @@
package handlers
import (
"bytes"
"fmt"
"html/template"
"log"
"net/http"
"path/filepath"
)
// Renderer holds one compiled *template.Template per page name.
// Each entry is parsed from base.html + <page>.html in isolation so that
// {{define}} blocks from one page never bleed into another (the ParseGlob bug).
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) {
pages := []string{
"app.html",
"login.html",
"mfa.html",
"admin.html",
}
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)
if err != nil {
return nil, fmt.Errorf("renderer: parse %s: %w", page, err)
}
name := page[:len(page)-5] // strip ".html"
r.templates[name] = t
log.Printf("renderer: loaded template %q", name)
}
return r, nil
}
// Render executes the named page template and writes it to w.
// Renders into a buffer first so a mid-execution error doesn't send partial HTML.
func (r *Renderer) Render(w http.ResponseWriter, name string, data interface{}) {
t, ok := r.templates[name]
if !ok {
log.Printf("renderer: unknown template %q", name)
http.Error(w, "page not found", http.StatusInternalServerError)
return
}
var buf bytes.Buffer
// Always execute "base" — it pulls in the page's block overrides automatically.
if err := t.ExecuteTemplate(&buf, "base", data); err != nil {
log.Printf("renderer: execute %q: %v", name, err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
buf.WriteTo(w)
}