package main import ( "bytes" "context" "fmt" "io" "io/fs" "log" "net/http" "os" "os/signal" "strings" "syscall" "time" "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 reset an admin's password // ./gowebmail --mfa-off 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 \"\"") os.Exit(1) } runResetPassword(args[1], args[2]) return case "--mfa-off": if len(args) < 2 { fmt.Fprintln(os.Stderr, "Usage: gowebmail --mfa-off ") os.Exit(1) } runDisableMFA(args[1]) return case "--blocklist": runBlockList() return case "--unblock": if len(args) < 2 { fmt.Fprintln(os.Stderr, "Usage: gowebmail --unblock ") 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 { log.Fatalf("database init: %v", err) } defer database.Close() if err := database.Migrate(); err != nil { log.Fatalf("migrations: %v", err) } sc := syncer.New(database, cfg) sc.Start() defer sc.Stop() r := mux.NewRouter() h := handlers.New(database, cfg, sc) r.Use(middleware.Logger) r.Use(middleware.SecurityHeaders) 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.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) }) // PWA manifest + service worker — served at root (not /static/) so the service worker's // default scope covers the whole app, not just /static/. r.HandleFunc("/manifest.json", func(w http.ResponseWriter, r *http.Request) { data, err := gowebmail.WebFS.ReadFile("web/static/manifest.json") if err != nil { http.NotFound(w, r) return } w.Header().Set("Content-Type", "application/manifest+json") w.Write(data) }) r.HandleFunc("/sw.js", func(w http.ResponseWriter, r *http.Request) { data, err := gowebmail.WebFS.ReadFile("web/static/sw.js") if err != nil { http.NotFound(w, r) return } w.Header().Set("Content-Type", "application/javascript") w.Write(data) }) // Public auth routes auth := r.PathPrefix("/auth").Subrouter() auth.HandleFunc("/login", h.Auth.ShowLogin).Methods("GET") 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) mfaR := r.PathPrefix("/auth/mfa").Subrouter() mfaR.Use(middleware.RequireAuth(database, cfg)) mfaR.HandleFunc("", h.Auth.ShowMFA).Methods("GET") mfaR.HandleFunc("/verify", h.Auth.VerifyMFA).Methods("POST") // OAuth callbacks (require auth to associate with user) oauthR := r.PathPrefix("/auth").Subrouter() oauthR.Use(middleware.RequireAuth(database, cfg)) oauthR.HandleFunc("/gmail/connect", h.Auth.GmailConnect).Methods("GET") 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() adminUI.Use(middleware.RequireAuth(database, cfg)) adminUI.Use(middleware.RequireAdmin) adminUI.HandleFunc("", h.Admin.ShowAdmin).Methods("GET") 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() api.Use(middleware.RequireAuth(database, cfg)) api.Use(middleware.JSONContentType) // 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") // Accounts 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/trust-cert", h.API.TrustCertificate).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") api.HandleFunc("/accounts/{id:[0-9]+}/sync", h.API.SyncAccount).Methods("POST") api.HandleFunc("/accounts/{id:[0-9]+}/sync-settings", h.API.SetAccountSyncSettings).Methods("PUT") // Messages api.HandleFunc("/messages", h.API.ListMessages).Methods("GET") api.HandleFunc("/messages/unified", h.API.UnifiedInbox).Methods("GET") api.HandleFunc("/messages/{id:[0-9]+}", h.API.GetMessage).Methods("GET") api.HandleFunc("/messages/{id:[0-9]+}/read", h.API.MarkRead).Methods("PUT") 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]+}/snooze", h.API.SnoozeMessage).Methods("PUT") api.HandleFunc("/messages/{id:[0-9]+}/snooze", h.API.UnsnoozeMessage).Methods("DELETE") 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") api.HandleFunc("/messages/snoozed", h.API.SnoozedMessages).Methods("GET") api.HandleFunc("/messages/by-label/{id:[0-9]+}", h.API.MessagesByLabel).Methods("GET") api.HandleFunc("/messages/{id:[0-9]+}/labels/{label_id:[0-9]+}", h.API.AssignLabel).Methods("POST") api.HandleFunc("/messages/{id:[0-9]+}/labels/{label_id:[0-9]+}", h.API.UnassignLabel).Methods("DELETE") api.HandleFunc("/labels", h.API.ListLabels).Methods("GET") api.HandleFunc("/labels", h.API.CreateLabel).Methods("POST") api.HandleFunc("/labels/{id:[0-9]+}", h.API.UpdateLabel).Methods("PUT") api.HandleFunc("/labels/{id:[0-9]+}", h.API.DeleteLabel).Methods("DELETE") // Remote content whitelist api.HandleFunc("/remote-content-whitelist", h.API.GetRemoteContentWhitelist).Methods("GET") api.HandleFunc("/remote-content-whitelist", h.API.AddRemoteContentWhitelist).Methods("POST") api.HandleFunc("/remote-content-whitelist", h.API.DeleteRemoteContentWhitelist).Methods("DELETE") // Spam blocklist api.HandleFunc("/spam-block", h.API.ListSpamBlock).Methods("GET") api.HandleFunc("/spam-block", h.API.AddSpamBlock).Methods("POST") api.HandleFunc("/spam-block", h.API.DeleteSpamBlock).Methods("DELETE") // Send 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("/draft", h.API.SaveDraft).Methods("POST") api.HandleFunc("/draft/discard", h.API.DiscardDraft).Methods("POST") api.HandleFunc("/send-later", h.API.CreateScheduledSend).Methods("POST") api.HandleFunc("/scheduled-sends", h.API.ListScheduledSends).Methods("GET") api.HandleFunc("/scheduled-sends/{id:[0-9]+}", h.API.CancelScheduledSend).Methods("DELETE") // 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]+}/export", h.API.ExportFolder).Methods("GET") 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("/accounts/{account_id:[0-9]+}/folders", h.API.CreateFolder).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") // Web Push (background new-mail notifications) api.HandleFunc("/push/vapid-public-key", h.API.GetVAPIDPublicKey).Methods("GET") api.HandleFunc("/push/subscribe", h.API.SubscribePush).Methods("POST") api.HandleFunc("/push/unsubscribe", h.API.UnsubscribePush).Methods("POST") api.HandleFunc("/login-history", h.API.ListMyLoginHistory).Methods("GET") // 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") // Mail rules (filters) api.HandleFunc("/rules", h.API.ListRules).Methods("GET") api.HandleFunc("/rules", h.API.CreateRule).Methods("POST") api.HandleFunc("/rules/{id:[0-9]+}", h.API.UpdateRule).Methods("PUT") api.HandleFunc("/rules/{id:[0-9]+}", h.API.DeleteRule).Methods("DELETE") // Signatures api.HandleFunc("/signatures", h.API.ListSignatures).Methods("GET") api.HandleFunc("/signatures", h.API.CreateSignature).Methods("POST") api.HandleFunc("/signatures/{id:[0-9]+}", h.API.UpdateSignature).Methods("PUT") api.HandleFunc("/signatures/{id:[0-9]+}", h.API.DeleteSignature).Methods("DELETE") api.HandleFunc("/accounts/{id:[0-9]+}/signature-defaults", h.API.SetSignatureDefaults).Methods("PUT") // S/MIME certificates api.HandleFunc("/smime/identity", h.API.SMIMEIdentity).Methods("GET") api.HandleFunc("/smime/identity", h.API.SMIMEGenerate).Methods("POST") api.HandleFunc("/smime/identity/import", h.API.SMIMEImport).Methods("POST") api.HandleFunc("/smime/identity/{id:[0-9]+}", h.API.SMIMERemoveIdentity).Methods("DELETE") api.HandleFunc("/smime/contacts", h.API.SMIMEContacts).Methods("GET") api.HandleFunc("/smime/contacts", h.API.SMIMEAddContact).Methods("POST") api.HandleFunc("/smime/contacts/{id:[0-9]+}", h.API.SMIMERemoveContact).Methods("DELETE") // PGP keys api.HandleFunc("/pgp/identity", h.API.PGPIdentity).Methods("GET") api.HandleFunc("/pgp/identity", h.API.PGPGenerate).Methods("POST") api.HandleFunc("/pgp/identity/import", h.API.PGPImport).Methods("POST") api.HandleFunc("/pgp/identity/{id:[0-9]+}", h.API.PGPRemoveIdentity).Methods("DELETE") api.HandleFunc("/pgp/unlock", h.API.PGPUnlock).Methods("POST") api.HandleFunc("/pgp/contacts", h.API.PGPContacts).Methods("GET") api.HandleFunc("/pgp/contacts", h.API.PGPAddContact).Methods("POST") api.HandleFunc("/pgp/contacts/{id:[0-9]+}", h.API.PGPRemoveContact).Methods("DELETE") // Admin API adminAPI := r.PathPrefix("/api/admin").Subrouter() adminAPI.Use(middleware.RequireAuth(database, cfg)) adminAPI.Use(middleware.JSONContentType) adminAPI.Use(middleware.RequireAdmin) adminAPI.HandleFunc("/users", h.Admin.ListUsers).Methods("GET") adminAPI.HandleFunc("/users", h.Admin.CreateUser).Methods("POST") adminAPI.HandleFunc("/users/{id:[0-9]+}", h.Admin.UpdateUser).Methods("PUT") adminAPI.HandleFunc("/users/{id:[0-9]+}", h.Admin.DeleteUser).Methods("DELETE") 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() } }() // Deliver due scheduled sends and wake expired message snoozes go func() { ticker := time.NewTicker(1 * time.Minute) defer ticker.Stop() for range ticker.C { h.API.ProcessDueScheduledSends() h.API.WakeExpiredSnoozes() } }() srv := &http.Server{ Addr: cfg.ListenAddr, Handler: r, ReadTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 120 * time.Second, } quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) go func() { log.Printf("GoWebMail listening on %s", cfg.ListenAddr) if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("server: %v", err) } }() <-quit log.Println("Shutting down...") ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 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 Reset password for an admin account gowebmail --mfa-off Disable MFA for an admin account gowebmail --blocklist List all currently blocked IP addresses gowebmail --unblock 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) }