mirror of
https://github.com/ghostersk/gowebmail.git
synced 2026-04-17 16:46:01 +01:00
first commit
This commit is contained in:
220
internal/handlers/admin.go
Normal file
220
internal/handlers/admin.go
Normal 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
636
internal/handlers/api.go
Normal 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
19
internal/handlers/app.go
Normal 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
401
internal/handlers/auth.go
Normal 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})
|
||||
}
|
||||
30
internal/handlers/handlers.go
Normal file
30
internal/handlers/handlers.go
Normal 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},
|
||||
}
|
||||
}
|
||||
73
internal/handlers/renderer.go
Normal file
73
internal/handlers/renderer.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user