diff --git a/cmd/server/main.go b/cmd/server/main.go index 494e78a..f5a70a2 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -1,13 +1,16 @@ package main import ( + "bytes" "context" "fmt" + "io" "io/fs" "log" "net/http" "os" "os/signal" + "strings" "syscall" "time" @@ -75,6 +78,17 @@ func main() { } 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) @@ -155,6 +169,8 @@ func main() { 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() @@ -244,6 +260,28 @@ func main() { // Search api.HandleFunc("/search", h.API.Search).Methods("GET") + // Contacts + api.HandleFunc("/contacts", h.API.ListContacts).Methods("GET") + api.HandleFunc("/contacts", h.API.CreateContact).Methods("POST") + api.HandleFunc("/contacts/{id:[0-9]+}", h.API.GetContact).Methods("GET") + api.HandleFunc("/contacts/{id:[0-9]+}", h.API.UpdateContact).Methods("PUT") + api.HandleFunc("/contacts/{id:[0-9]+}", h.API.DeleteContact).Methods("DELETE") + + // Calendar events + api.HandleFunc("/calendar/events", h.API.ListCalendarEvents).Methods("GET") + api.HandleFunc("/calendar/events", h.API.CreateCalendarEvent).Methods("POST") + api.HandleFunc("/calendar/events/{id:[0-9]+}", h.API.GetCalendarEvent).Methods("GET") + api.HandleFunc("/calendar/events/{id:[0-9]+}", h.API.UpdateCalendarEvent).Methods("PUT") + api.HandleFunc("/calendar/events/{id:[0-9]+}", h.API.DeleteCalendarEvent).Methods("DELETE") + + // CalDAV API tokens + api.HandleFunc("/caldav/tokens", h.API.ListCalDAVTokens).Methods("GET") + api.HandleFunc("/caldav/tokens", h.API.CreateCalDAVToken).Methods("POST") + api.HandleFunc("/caldav/tokens/{id:[0-9]+}", h.API.DeleteCalDAVToken).Methods("DELETE") + + // CalDAV public feed — token-authenticated, no session needed + r.HandleFunc("/caldav/{token}/calendar.ics", h.API.ServeCalDAV).Methods("GET") + // Admin API adminAPI := r.PathPrefix("/api/admin").Subrouter() adminAPI.Use(middleware.RequireAuth(database, cfg)) @@ -447,3 +485,20 @@ Note: --list-admin, --pw, and --mfa-off only work on admin accounts. Requires the same environment variables as the server (DB_PATH, ENCRYPTION_KEY, etc). `) } + +// filteredWriter wraps an io.Writer and drops log lines containing any of the +// suppress substrings. Used to silence harmless go-imap internal parser errors. +type filteredWriter struct { + w io.Writer + suppress []string +} + +func (f *filteredWriter) Write(p []byte) (n int, err error) { + line := string(bytes.TrimSpace(p)) + for _, s := range f.suppress { + if strings.Contains(line, s) { + return len(p), nil // silently drop + } + } + return f.w.Write(p) +} diff --git a/internal/auth/oauth.go b/internal/auth/oauth.go index 69a937f..22213fa 100644 --- a/internal/auth/oauth.go +++ b/internal/auth/oauth.go @@ -21,7 +21,7 @@ import ( // GmailScopes are the OAuth2 scopes required for full Gmail access. var GmailScopes = []string{ - "https://mail.google.com/", // Full IMAP+SMTP access + "https://mail.google.com/", // Full IMAP+SMTP access "https://www.googleapis.com/auth/userinfo.email", "https://www.googleapis.com/auth/userinfo.profile", } @@ -162,10 +162,10 @@ func ExchangeForIMAPToken(ctx context.Context, clientID, clientSecret, tenantID, // MicrosoftUserInfo holds user info extracted from the Microsoft ID token. type MicrosoftUserInfo struct { ID string `json:"id"` - DisplayName string `json:"displayName"` // Graph field - Name string `json:"name"` // ID token claim + DisplayName string `json:"displayName"` // Graph field + Name string `json:"name"` // ID token claim Mail string `json:"mail"` - EmailClaim string `json:"email"` // ID token claim + EmailClaim string `json:"email"` // ID token claim UserPrincipalName string `json:"userPrincipalName"` PreferredUsername string `json:"preferred_username"` // ID token claim } diff --git a/internal/db/db.go b/internal/db/db.go index df07fd5..dd360ba 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -2,7 +2,9 @@ package db import ( + "crypto/rand" "database/sql" + "encoding/base64" "fmt" "strings" "time" @@ -248,6 +250,62 @@ func (d *DB) Migrate() error { return fmt.Errorf("create user_ip_rules: %w", err) } + if _, err := d.sql.Exec(`CREATE TABLE IF NOT EXISTS contacts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + display_name TEXT NOT NULL DEFAULT '', + email TEXT NOT NULL DEFAULT '', + phone TEXT NOT NULL DEFAULT '', + company TEXT NOT NULL DEFAULT '', + notes TEXT NOT NULL DEFAULT '', + avatar_color TEXT NOT NULL DEFAULT '#6b7280', + created_at DATETIME DEFAULT (datetime('now')), + updated_at DATETIME DEFAULT (datetime('now')) + )`); err != nil { + return fmt.Errorf("create contacts: %w", err) + } + if _, err := d.sql.Exec(`CREATE INDEX IF NOT EXISTS idx_contacts_user ON contacts(user_id)`); err != nil { + return fmt.Errorf("index contacts_user: %w", err) + } + + if _, err := d.sql.Exec(`CREATE TABLE IF NOT EXISTS calendar_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + account_id INTEGER REFERENCES email_accounts(id) ON DELETE SET NULL, + uid TEXT NOT NULL DEFAULT '', + title TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + location TEXT NOT NULL DEFAULT '', + start_time DATETIME NOT NULL, + end_time DATETIME NOT NULL, + all_day INTEGER NOT NULL DEFAULT 0, + recurrence_rule TEXT NOT NULL DEFAULT '', + color TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'confirmed', + organizer_email TEXT NOT NULL DEFAULT '', + attendees TEXT NOT NULL DEFAULT '', + ical_source TEXT NOT NULL DEFAULT '', + created_at DATETIME DEFAULT (datetime('now')), + updated_at DATETIME DEFAULT (datetime('now')), + UNIQUE(user_id, uid) + )`); err != nil { + return fmt.Errorf("create calendar_events: %w", err) + } + if _, err := d.sql.Exec(`CREATE INDEX IF NOT EXISTS idx_calendar_user_time ON calendar_events(user_id, start_time)`); err != nil { + return fmt.Errorf("index calendar_user_time: %w", err) + } + + if _, err := d.sql.Exec(`CREATE TABLE IF NOT EXISTS caldav_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, + label TEXT NOT NULL DEFAULT 'CalDAV token', + created_at DATETIME DEFAULT (datetime('now')), + last_used DATETIME + )`); err != nil { + return fmt.Errorf("create caldav_tokens: %w", err) + } + // Bootstrap admin account if no users exist return d.bootstrapAdmin() } @@ -2185,3 +2243,267 @@ func (d *DB) ListIPBlocksWithUsername() ([]IPBlockWithUsername, error) { } return result, rows.Err() } + +// ======== Contacts ======== + +func (d *DB) ListContacts(userID int64) ([]*models.Contact, error) { + rows, err := d.sql.Query(` + SELECT id, user_id, display_name, email, phone, company, notes, avatar_color, created_at, updated_at + FROM contacts WHERE user_id=? ORDER BY display_name COLLATE NOCASE`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []*models.Contact + for rows.Next() { + var c models.Contact + var dn, em, ph, co, no, av []byte + rows.Scan(&c.ID, &c.UserID, &dn, &em, &ph, &co, &no, &av, &c.CreatedAt, &c.UpdatedAt) + c.DisplayName, _ = d.enc.Decrypt(string(dn)) + c.Email, _ = d.enc.Decrypt(string(em)) + c.Phone, _ = d.enc.Decrypt(string(ph)) + c.Company, _ = d.enc.Decrypt(string(co)) + c.Notes, _ = d.enc.Decrypt(string(no)) + c.AvatarColor, _ = d.enc.Decrypt(string(av)) + out = append(out, &c) + } + return out, nil +} + +func (d *DB) GetContact(id, userID int64) (*models.Contact, error) { + var c models.Contact + var dn, em, ph, co, no, av []byte + err := d.sql.QueryRow(` + SELECT id, user_id, display_name, email, phone, company, notes, avatar_color, created_at, updated_at + FROM contacts WHERE id=? AND user_id=?`, id, userID). + Scan(&c.ID, &c.UserID, &dn, &em, &ph, &co, &no, &av, &c.CreatedAt, &c.UpdatedAt) + if err != nil { + return nil, err + } + c.DisplayName, _ = d.enc.Decrypt(string(dn)) + c.Email, _ = d.enc.Decrypt(string(em)) + c.Phone, _ = d.enc.Decrypt(string(ph)) + c.Company, _ = d.enc.Decrypt(string(co)) + c.Notes, _ = d.enc.Decrypt(string(no)) + c.AvatarColor, _ = d.enc.Decrypt(string(av)) + return &c, nil +} + +func (d *DB) CreateContact(c *models.Contact) error { + dn, _ := d.enc.Encrypt(c.DisplayName) + em, _ := d.enc.Encrypt(c.Email) + ph, _ := d.enc.Encrypt(c.Phone) + co, _ := d.enc.Encrypt(c.Company) + no, _ := d.enc.Encrypt(c.Notes) + av, _ := d.enc.Encrypt(c.AvatarColor) + res, err := d.sql.Exec(` + INSERT INTO contacts (user_id, display_name, email, phone, company, notes, avatar_color) + VALUES (?,?,?,?,?,?,?)`, c.UserID, dn, em, ph, co, no, av) + if err != nil { + return err + } + c.ID, _ = res.LastInsertId() + return nil +} + +func (d *DB) UpdateContact(c *models.Contact, userID int64) error { + dn, _ := d.enc.Encrypt(c.DisplayName) + em, _ := d.enc.Encrypt(c.Email) + ph, _ := d.enc.Encrypt(c.Phone) + co, _ := d.enc.Encrypt(c.Company) + no, _ := d.enc.Encrypt(c.Notes) + av, _ := d.enc.Encrypt(c.AvatarColor) + _, err := d.sql.Exec(` + UPDATE contacts SET display_name=?, email=?, phone=?, company=?, notes=?, avatar_color=?, + updated_at=datetime('now') WHERE id=? AND user_id=?`, + dn, em, ph, co, no, av, c.ID, userID) + return err +} + +func (d *DB) DeleteContact(id, userID int64) error { + _, err := d.sql.Exec(`DELETE FROM contacts WHERE id=? AND user_id=?`, id, userID) + return err +} + +func (d *DB) SearchContacts(userID int64, q string) ([]*models.Contact, error) { + all, err := d.ListContacts(userID) + if err != nil { + return nil, err + } + q = strings.ToLower(q) + var out []*models.Contact + for _, c := range all { + if strings.Contains(strings.ToLower(c.DisplayName), q) || + strings.Contains(strings.ToLower(c.Email), q) || + strings.Contains(strings.ToLower(c.Company), q) { + out = append(out, c) + } + } + return out, nil +} + +// ======== Calendar Events ======== + +func (d *DB) ListCalendarEvents(userID int64, from, to string) ([]*models.CalendarEvent, error) { + rows, err := d.sql.Query(` + SELECT e.id, e.user_id, e.account_id, e.uid, e.title, e.description, e.location, + e.start_time, e.end_time, e.all_day, e.recurrence_rule, e.color, + e.status, e.organizer_email, e.attendees, + COALESCE(a.color,''), COALESCE(a.email_address,'') + FROM calendar_events e + LEFT JOIN email_accounts a ON a.id = e.account_id + WHERE e.user_id=? AND e.start_time >= ? AND e.start_time <= ? + ORDER BY e.start_time`, userID, from, to) + if err != nil { + return nil, err + } + defer rows.Close() + return scanCalendarEvents(d, rows) +} + +func (d *DB) GetCalendarEvent(id, userID int64) (*models.CalendarEvent, error) { + rows, err := d.sql.Query(` + SELECT e.id, e.user_id, e.account_id, e.uid, e.title, e.description, e.location, + e.start_time, e.end_time, e.all_day, e.recurrence_rule, e.color, + e.status, e.organizer_email, e.attendees, + COALESCE(a.color,''), COALESCE(a.email_address,'') + FROM calendar_events e + LEFT JOIN email_accounts a ON a.id = e.account_id + WHERE e.id=? AND e.user_id=?`, id, userID) + if err != nil { + return nil, err + } + defer rows.Close() + evs, err := scanCalendarEvents(d, rows) + if err != nil || len(evs) == 0 { + return nil, err + } + return evs[0], nil +} + +func scanCalendarEvents(d *DB, rows interface{ Next() bool; Scan(...interface{}) error }) ([]*models.CalendarEvent, error) { + var out []*models.CalendarEvent + for rows.Next() { + var e models.CalendarEvent + var accountID *int64 + var ti, de, lo, rc, co, st, oe, at []byte + err := rows.Scan( + &e.ID, &e.UserID, &accountID, &e.UID, + &ti, &de, &lo, + &e.StartTime, &e.EndTime, &e.AllDay, &rc, &co, + &st, &oe, &at, + &e.AccountColor, &e.AccountEmail, + ) + if err != nil { + return nil, err + } + e.AccountID = accountID + e.Title, _ = d.enc.Decrypt(string(ti)) + e.Description, _ = d.enc.Decrypt(string(de)) + e.Location, _ = d.enc.Decrypt(string(lo)) + e.RecurrenceRule, _ = d.enc.Decrypt(string(rc)) + e.Color, _ = d.enc.Decrypt(string(co)) + e.Status, _ = d.enc.Decrypt(string(st)) + e.OrganizerEmail, _ = d.enc.Decrypt(string(oe)) + e.Attendees, _ = d.enc.Decrypt(string(at)) + if e.Color == "" && e.AccountColor != "" { + e.Color = e.AccountColor + } + out = append(out, &e) + } + return out, nil +} + +func (d *DB) UpsertCalendarEvent(e *models.CalendarEvent) error { + ti, _ := d.enc.Encrypt(e.Title) + de, _ := d.enc.Encrypt(e.Description) + lo, _ := d.enc.Encrypt(e.Location) + rc, _ := d.enc.Encrypt(e.RecurrenceRule) + co, _ := d.enc.Encrypt(e.Color) + st, _ := d.enc.Encrypt(e.Status) + oe, _ := d.enc.Encrypt(e.OrganizerEmail) + at, _ := d.enc.Encrypt(e.Attendees) + allDay := 0 + if e.AllDay { + allDay = 1 + } + if e.UID == "" { + e.UID = fmt.Sprintf("gwm-%d-%d", e.UserID, time.Now().UnixNano()) + } + res, err := d.sql.Exec(` + INSERT INTO calendar_events + (user_id, account_id, uid, title, description, location, + start_time, end_time, all_day, recurrence_rule, color, + status, organizer_email, attendees) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(user_id, uid) DO UPDATE SET + title=excluded.title, description=excluded.description, + location=excluded.location, start_time=excluded.start_time, + end_time=excluded.end_time, all_day=excluded.all_day, + recurrence_rule=excluded.recurrence_rule, color=excluded.color, + status=excluded.status, organizer_email=excluded.organizer_email, + attendees=excluded.attendees, + updated_at=datetime('now')`, + e.UserID, e.AccountID, e.UID, ti, de, lo, + e.StartTime, e.EndTime, allDay, rc, co, st, oe, at) + if err != nil { + return err + } + if e.ID == 0 { + e.ID, _ = res.LastInsertId() + } + return nil +} + +func (d *DB) DeleteCalendarEvent(id, userID int64) error { + _, err := d.sql.Exec(`DELETE FROM calendar_events WHERE id=? AND user_id=?`, id, userID) + return err +} + +// ======== CalDAV Tokens ======== + +func (d *DB) CreateCalDAVToken(userID int64, label string) (*models.CalDAVToken, error) { + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return nil, err + } + token := base64.URLEncoding.EncodeToString(raw) + _, err := d.sql.Exec(`INSERT INTO caldav_tokens (user_id, token, label) VALUES (?,?,?)`, + userID, token, label) + if err != nil { + return nil, err + } + return &models.CalDAVToken{UserID: userID, Token: token, Label: label}, nil +} + +func (d *DB) ListCalDAVTokens(userID int64) ([]*models.CalDAVToken, error) { + rows, err := d.sql.Query(` + SELECT id, user_id, token, label, created_at, COALESCE(last_used,'') + FROM caldav_tokens WHERE user_id=? ORDER BY created_at DESC`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []*models.CalDAVToken + for rows.Next() { + var t models.CalDAVToken + rows.Scan(&t.ID, &t.UserID, &t.Token, &t.Label, &t.CreatedAt, &t.LastUsed) + out = append(out, &t) + } + return out, nil +} + +func (d *DB) DeleteCalDAVToken(id, userID int64) error { + _, err := d.sql.Exec(`DELETE FROM caldav_tokens WHERE id=? AND user_id=?`, id, userID) + return err +} + +func (d *DB) GetUserByCalDAVToken(token string) (int64, error) { + var userID int64 + err := d.sql.QueryRow(`SELECT user_id FROM caldav_tokens WHERE token=?`, token).Scan(&userID) + if err != nil { + return 0, err + } + d.sql.Exec(`UPDATE caldav_tokens SET last_used=datetime('now') WHERE token=?`, token) + return userID, nil +} diff --git a/internal/handlers/app.go b/internal/handlers/app.go index e9bc889..ce73cd6 100644 --- a/internal/handlers/app.go +++ b/internal/handlers/app.go @@ -17,3 +17,13 @@ type AppHandler struct { func (h *AppHandler) Index(w http.ResponseWriter, r *http.Request) { h.renderer.Render(w, "app", nil) } + +// ViewMessage renders a single message in a full browser tab. +func (h *AppHandler) ViewMessage(w http.ResponseWriter, r *http.Request) { + h.renderer.Render(w, "message", nil) +} + +// ComposePage renders the compose form in a full browser tab. +func (h *AppHandler) ComposePage(w http.ResponseWriter, r *http.Request) { + h.renderer.Render(w, "compose", nil) +} diff --git a/internal/handlers/contacts_calendar.go b/internal/handlers/contacts_calendar.go new file mode 100644 index 0000000..cb1c5d9 --- /dev/null +++ b/internal/handlers/contacts_calendar.go @@ -0,0 +1,309 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "github.com/gorilla/mux" + + "github.com/ghostersk/gowebmail/internal/middleware" + "github.com/ghostersk/gowebmail/internal/models" +) + +// ======== Contacts ======== + +func (h *APIHandler) ListContacts(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r) + q := strings.TrimSpace(r.URL.Query().Get("q")) + var contacts interface{} + var err error + if q != "" { + contacts, err = h.db.SearchContacts(userID, q) + } else { + contacts, err = h.db.ListContacts(userID) + } + if err != nil { + h.writeError(w, http.StatusInternalServerError, "failed to list contacts") + return + } + if contacts == nil { + contacts = []*models.Contact{} + } + h.writeJSON(w, contacts) +} + +func (h *APIHandler) GetContact(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r) + id := pathInt64(r, "id") + c, err := h.db.GetContact(id, userID) + if err != nil || c == nil { + h.writeError(w, http.StatusNotFound, "contact not found") + return + } + h.writeJSON(w, c) +} + +func (h *APIHandler) CreateContact(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r) + var req models.Contact + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + h.writeError(w, http.StatusBadRequest, "invalid request") + return + } + req.UserID = userID + if req.AvatarColor == "" { + colors := []string{"#6b7280", "#0078D4", "#EA4335", "#34A853", "#FBBC04", "#9C27B0", "#FF6D00"} + req.AvatarColor = colors[int(userID)%len(colors)] + } + if err := h.db.CreateContact(&req); err != nil { + h.writeError(w, http.StatusInternalServerError, "failed to create contact") + return + } + h.writeJSON(w, req) +} + +func (h *APIHandler) UpdateContact(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r) + id := pathInt64(r, "id") + var req models.Contact + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + h.writeError(w, http.StatusBadRequest, "invalid request") + return + } + req.ID = id + if err := h.db.UpdateContact(&req, userID); err != nil { + h.writeError(w, http.StatusInternalServerError, "failed to update contact") + return + } + h.writeJSON(w, map[string]bool{"ok": true}) +} + +func (h *APIHandler) DeleteContact(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r) + id := pathInt64(r, "id") + if err := h.db.DeleteContact(id, userID); err != nil { + h.writeError(w, http.StatusInternalServerError, "failed to delete contact") + return + } + h.writeJSON(w, map[string]bool{"ok": true}) +} + +// ======== Calendar Events ======== + +func (h *APIHandler) ListCalendarEvents(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r) + from := r.URL.Query().Get("from") + to := r.URL.Query().Get("to") + if from == "" { + from = time.Now().AddDate(0, -1, 0).Format("2006-01-02") + } + if to == "" { + to = time.Now().AddDate(0, 3, 0).Format("2006-01-02") + } + events, err := h.db.ListCalendarEvents(userID, from, to) + if err != nil { + h.writeError(w, http.StatusInternalServerError, "failed to list events") + return + } + if events == nil { + events = []*models.CalendarEvent{} + } + h.writeJSON(w, events) +} + +func (h *APIHandler) GetCalendarEvent(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r) + id := pathInt64(r, "id") + ev, err := h.db.GetCalendarEvent(id, userID) + if err != nil || ev == nil { + h.writeError(w, http.StatusNotFound, "event not found") + return + } + h.writeJSON(w, ev) +} + +func (h *APIHandler) CreateCalendarEvent(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r) + var req models.CalendarEvent + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + h.writeError(w, http.StatusBadRequest, "invalid request") + return + } + req.UserID = userID + if err := h.db.UpsertCalendarEvent(&req); err != nil { + h.writeError(w, http.StatusInternalServerError, "failed to create event") + return + } + h.writeJSON(w, req) +} + +func (h *APIHandler) UpdateCalendarEvent(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r) + id := pathInt64(r, "id") + existing, err := h.db.GetCalendarEvent(id, userID) + if err != nil || existing == nil { + h.writeError(w, http.StatusNotFound, "event not found") + return + } + var req models.CalendarEvent + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + h.writeError(w, http.StatusBadRequest, "invalid request") + return + } + req.ID = id + req.UserID = userID + req.UID = existing.UID // preserve original UID + if err := h.db.UpsertCalendarEvent(&req); err != nil { + h.writeError(w, http.StatusInternalServerError, "failed to update event") + return + } + h.writeJSON(w, map[string]bool{"ok": true}) +} + +func (h *APIHandler) DeleteCalendarEvent(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r) + id := pathInt64(r, "id") + if err := h.db.DeleteCalendarEvent(id, userID); err != nil { + h.writeError(w, http.StatusInternalServerError, "failed to delete event") + return + } + h.writeJSON(w, map[string]bool{"ok": true}) +} + +// ======== CalDAV Tokens ======== + +func (h *APIHandler) ListCalDAVTokens(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r) + tokens, err := h.db.ListCalDAVTokens(userID) + if err != nil { + h.writeError(w, http.StatusInternalServerError, "failed to list tokens") + return + } + if tokens == nil { + tokens = []*models.CalDAVToken{} + } + h.writeJSON(w, tokens) +} + +func (h *APIHandler) CreateCalDAVToken(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r) + var req struct { + Label string `json:"label"` + } + json.NewDecoder(r.Body).Decode(&req) + if req.Label == "" { + req.Label = "CalDAV token" + } + t, err := h.db.CreateCalDAVToken(userID, req.Label) + if err != nil { + h.writeError(w, http.StatusInternalServerError, "failed to create token") + return + } + h.writeJSON(w, t) +} + +func (h *APIHandler) DeleteCalDAVToken(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r) + id := pathInt64(r, "id") + if err := h.db.DeleteCalDAVToken(id, userID); err != nil { + h.writeError(w, http.StatusInternalServerError, "failed to delete token") + return + } + h.writeJSON(w, map[string]bool{"ok": true}) +} + +// ======== CalDAV Server ======== +// Serves a read-only iCalendar feed at /caldav/{token}/calendar.ics +// Compatible with any CalDAV client that supports basic calendar subscription. + +func (h *APIHandler) ServeCalDAV(w http.ResponseWriter, r *http.Request) { + token := mux.Vars(r)["token"] + userID, err := h.db.GetUserByCalDAVToken(token) + if err != nil || userID == 0 { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Fetch events for next 12 months + past 3 months + from := time.Now().AddDate(0, -3, 0).Format("2006-01-02") + to := time.Now().AddDate(1, 0, 0).Format("2006-01-02") + events, err := h.db.ListCalendarEvents(userID, from, to) + if err != nil { + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/calendar; charset=utf-8") + w.Header().Set("Content-Disposition", `attachment; filename="gowebmail.ics"`) + + fmt.Fprintf(w, "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//GoWebMail//EN\r\nCALSCALE:GREGORIAN\r\nMETHOD:PUBLISH\r\nX-WR-CALNAME:GoWebMail\r\n") + + for _, ev := range events { + fmt.Fprintf(w, "BEGIN:VEVENT\r\n") + fmt.Fprintf(w, "UID:%s\r\n", escICAL(ev.UID)) + fmt.Fprintf(w, "SUMMARY:%s\r\n", escICAL(ev.Title)) + if ev.Description != "" { + fmt.Fprintf(w, "DESCRIPTION:%s\r\n", escICAL(ev.Description)) + } + if ev.Location != "" { + fmt.Fprintf(w, "LOCATION:%s\r\n", escICAL(ev.Location)) + } + if ev.AllDay { + // All-day events use DATE format + start := strings.ReplaceAll(strings.Split(ev.StartTime, "T")[0], "-", "") + end := strings.ReplaceAll(strings.Split(ev.EndTime, "T")[0], "-", "") + fmt.Fprintf(w, "DTSTART;VALUE=DATE:%s\r\n", start) + fmt.Fprintf(w, "DTEND;VALUE=DATE:%s\r\n", end) + } else { + fmt.Fprintf(w, "DTSTART:%s\r\n", toICALDate(ev.StartTime)) + fmt.Fprintf(w, "DTEND:%s\r\n", toICALDate(ev.EndTime)) + } + if ev.OrganizerEmail != "" { + fmt.Fprintf(w, "ORGANIZER:mailto:%s\r\n", ev.OrganizerEmail) + } + if ev.Status != "" { + fmt.Fprintf(w, "STATUS:%s\r\n", strings.ToUpper(ev.Status)) + } + if ev.RecurrenceRule != "" { + fmt.Fprintf(w, "RRULE:%s\r\n", ev.RecurrenceRule) + } + fmt.Fprintf(w, "END:VEVENT\r\n") + } + + fmt.Fprintf(w, "END:VCALENDAR\r\n") +} + +func escICAL(s string) string { + s = strings.ReplaceAll(s, "\\", "\\\\") + s = strings.ReplaceAll(s, ";", "\\;") + s = strings.ReplaceAll(s, ",", "\\,") + s = strings.ReplaceAll(s, "\n", "\\n") + s = strings.ReplaceAll(s, "\r", "") + // Fold long lines at 75 chars + if len(s) > 70 { + var out strings.Builder + for i, ch := range s { + if i > 0 && i%70 == 0 { + out.WriteString("\r\n ") + } + out.WriteRune(ch) + } + return out.String() + } + return s +} + +func toICALDate(s string) string { + // Convert "2006-01-02T15:04:05Z" or "2006-01-02 15:04:05" to "20060102T150405Z" + t, err := time.Parse("2006-01-02T15:04:05Z07:00", s) + if err != nil { + t, err = time.Parse("2006-01-02 15:04:05", s) + } + if err != nil { + return strings.NewReplacer("-", "", ":", "", " ", "T", "Z", "").Replace(s) + "Z" + } + return t.UTC().Format("20060102T150405Z") +} diff --git a/internal/handlers/renderer.go b/internal/handlers/renderer.go index 89a0dbb..1627a3c 100644 --- a/internal/handlers/renderer.go +++ b/internal/handlers/renderer.go @@ -26,6 +26,8 @@ func NewRenderer() (*Renderer, error) { "login.html", "mfa.html", "admin.html", + "message.html", + "compose.html", } templateFS, err := fs.Sub(gowebmail.WebFS, "web/templates") if err != nil { diff --git a/internal/models/models.go b/internal/models/models.go index 7a6db08..f304a02 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -243,3 +243,49 @@ type PagedMessages struct { PageSize int `json:"page_size"` HasMore bool `json:"has_more"` } + +// ---- Contacts ---- + +type Contact struct { + ID int64 `json:"id"` + UserID int64 `json:"user_id"` + DisplayName string `json:"display_name"` + Email string `json:"email"` + Phone string `json:"phone"` + Company string `json:"company"` + Notes string `json:"notes"` + AvatarColor string `json:"avatar_color"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// ---- Calendar ---- + +type CalendarEvent struct { + ID int64 `json:"id"` + UserID int64 `json:"user_id"` + AccountID *int64 `json:"account_id,omitempty"` + UID string `json:"uid"` + Title string `json:"title"` + Description string `json:"description"` + Location string `json:"location"` + StartTime string `json:"start_time"` + EndTime string `json:"end_time"` + AllDay bool `json:"all_day"` + RecurrenceRule string `json:"recurrence_rule"` + Color string `json:"color"` + Status string `json:"status"` + OrganizerEmail string `json:"organizer_email"` + Attendees string `json:"attendees"` + AccountColor string `json:"account_color,omitempty"` + AccountEmail string `json:"account_email,omitempty"` +} + +type CalDAVToken struct { + ID int64 `json:"id"` + UserID int64 `json:"user_id"` + Token string `json:"token"` + Label string `json:"label"` + CreatedAt string `json:"created_at"` + LastUsed string `json:"last_used,omitempty"` +} diff --git a/web/static/css/gowebmail.css b/web/static/css/gowebmail.css index 42ab165..07f4873 100644 --- a/web/static/css/gowebmail.css +++ b/web/static/css/gowebmail.css @@ -569,3 +569,52 @@ body.admin-page{overflow:auto;background:var(--bg)} /* Hide floating minimised bar on mobile, use back button instead */ .compose-minimised{display:none!important} } + +/* ── Contacts ──────────────────────────────────────────────────────────── */ +.contact-card{display:flex;align-items:center;gap:12px;padding:10px 14px;border-radius:8px; + cursor:pointer;transition:background .1s;border-bottom:1px solid var(--border)} +.contact-card:hover{background:var(--surface3)} +.contact-avatar{width:36px;height:36px;border-radius:50%;display:flex;align-items:center; + justify-content:center;font-size:15px;font-weight:600;color:white;flex-shrink:0} +.contact-info{flex:1;min-width:0} +.contact-name{font-size:14px;font-weight:500;color:var(--text)} +.contact-meta{font-size:12px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} + +/* ── Calendar ──────────────────────────────────────────────────────────── */ +.cal-grid-month{display:grid;grid-template-columns:repeat(7,1fr);border-left:1px solid var(--border);border-top:1px solid var(--border)} +.cal-day-header{text-align:center;font-size:11px;font-weight:600;text-transform:uppercase; + letter-spacing:.5px;color:var(--muted);padding:6px 0;background:var(--surface); + border-right:1px solid var(--border);border-bottom:1px solid var(--border)} +.cal-day{min-height:90px;padding:4px;border-right:1px solid var(--border);border-bottom:1px solid var(--border); + vertical-align:top;background:var(--surface);transition:background .1s;position:relative} +.cal-day:hover{background:var(--surface3)} +.cal-day.today{background:var(--accent-dim)} +.cal-day.other-month{opacity:.45} +.cal-day-num{font-size:12px;font-weight:500;color:var(--text2);margin-bottom:2px;cursor:pointer; + width:22px;height:22px;display:flex;align-items:center;justify-content:center;border-radius:50%} +.cal-day-num:hover{background:var(--border2)} +.cal-day.today .cal-day-num{background:var(--accent);color:white} +.cal-event{font-size:11px;padding:2px 5px;border-radius:3px;margin-bottom:2px; + cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:white; + transition:opacity .1s} +.cal-event:hover{opacity:.85} +.cal-more{font-size:10px;color:var(--muted);cursor:pointer;padding:1px 4px} +.cal-more:hover{color:var(--accent)} + +/* Week view */ +.cal-week-grid{display:grid;grid-template-columns:52px repeat(7,1fr);border-left:1px solid var(--border)} +.cal-week-header{text-align:center;padding:6px 2px;font-size:12px;border-right:1px solid var(--border); + border-bottom:1px solid var(--border);background:var(--surface)} +.cal-week-header.today-col{color:var(--accent);font-weight:600} +.cal-time-col{font-size:10px;color:var(--muted);text-align:right;padding-right:4px; + border-right:1px solid var(--border);border-bottom:1px solid var(--border);height:40px; + display:flex;align-items:flex-start;justify-content:flex-end;padding-top:2px} +.cal-week-cell{border-right:1px solid var(--border);border-bottom:1px solid var(--border); + height:40px;position:relative;transition:background .1s} +.cal-week-cell:hover{background:var(--surface3)} + +/* CalDAV token row */ +.caldav-token-row{display:flex;align-items:center;gap:8px;padding:8px 0;border-bottom:1px solid var(--border)} +.caldav-token-url{font-size:11px;font-family:monospace;color:var(--muted);overflow:hidden; + text-overflow:ellipsis;white-space:nowrap;flex:1;cursor:pointer} +.caldav-token-url:hover{color:var(--text)} diff --git a/web/static/js/app.js b/web/static/js/app.js index a608bbd..c8b884d 100644 --- a/web/static/js/app.js +++ b/web/static/js/app.js @@ -71,6 +71,25 @@ async function init() { } if (p.get('error')) { toast('Connection failed: '+p.get('error'), 'error'); history.replaceState({},'','/'); } + // Handle actions from full-page message/compose views + if (p.get('action') === 'reply' && p.get('id')) { + history.replaceState({},'','/'); + const id = parseInt(p.get('id')); + // Load the message then open reply + setTimeout(async () => { + const msg = await api('GET', '/messages/'+id); + if (msg) { S.currentMessage = msg; openReplyTo(id); } + }, 500); + } + if (p.get('action') === 'forward' && p.get('id')) { + history.replaceState({},'','/'); + const id = parseInt(p.get('id')); + setTimeout(async () => { + const msg = await api('GET', '/messages/'+id); + if (msg) { S.currentMessage = msg; openForward(); } + }, 500); + } + document.addEventListener('keydown', e => { if (['INPUT','TEXTAREA','SELECT'].includes(e.target.tagName)) return; if (e.target.contentEditable === 'true') return; @@ -1128,6 +1147,8 @@ function showMessageMenu(e, id) {
${moveItems}
` : ''; showCtxMenu(e,` +
↗ Open in new tab
+
↩ Reply
${msg?.is_starred?'★ Unstar':'☆ Star'}
${msg?.is_read?'Mark unread':'Mark read'}
@@ -1949,3 +1970,21 @@ window.addEventListener('resize', () => { document.getElementById('mob-sidebar-backdrop')?.classList.remove('mob-open'); } }); + +// ── Compose dropdown ──────────────────────────────────────────────────────── +function toggleComposeDropdown(e) { + e.stopPropagation(); + const dd = document.getElementById('compose-dropdown'); + if (!dd) return; + const isOpen = dd.style.display !== 'none'; + dd.style.display = isOpen ? 'none' : 'block'; + if (!isOpen) { + // Close on next outside click + setTimeout(() => document.addEventListener('click', closeComposeDropdown, { once: true }), 0); + } +} + +function closeComposeDropdown() { + const dd = document.getElementById('compose-dropdown'); + if (dd) dd.style.display = 'none'; +} diff --git a/web/static/js/contacts_calendar.js b/web/static/js/contacts_calendar.js new file mode 100644 index 0000000..6c91488 --- /dev/null +++ b/web/static/js/contacts_calendar.js @@ -0,0 +1,411 @@ +// ── Contacts & Calendar ───────────────────────────────────────────────────── + +let _currentView = 'mail'; + +// ======== VIEW SWITCHING ======== +// Uses data-view attribute on #app-root to switch panels via CSS, +// avoiding direct style manipulation of elements that may not exist. + +function _setView(view) { + _currentView = view; + // Update nav item active states + ['nav-unified','nav-starred','nav-contacts','nav-calendar'].forEach(id => { + document.getElementById(id)?.classList.remove('active'); + }); + // Show/hide panels + const mail1 = document.getElementById('message-list-panel'); + const mail2 = document.getElementById('message-detail'); + const contacts = document.getElementById('contacts-panel'); + const calendar = document.getElementById('calendar-panel'); + if (mail1) mail1.style.display = view === 'mail' ? '' : 'none'; + if (mail2) mail2.style.display = view === 'mail' ? '' : 'none'; + if (contacts) contacts.style.display = view === 'contacts' ? 'flex' : 'none'; + if (calendar) calendar.style.display = view === 'calendar' ? 'flex' : 'none'; +} + +function showMail() { + _setView('mail'); + document.getElementById('nav-unified')?.classList.add('active'); +} + +function showContacts() { + _setView('contacts'); + document.getElementById('nav-contacts')?.classList.add('active'); + if (typeof mobCloseNav === 'function') { mobCloseNav(); mobSetView('list'); } + loadContacts(); +} + +function showCalendar() { + _setView('calendar'); + document.getElementById('nav-calendar')?.classList.add('active'); + if (typeof mobCloseNav === 'function') { mobCloseNav(); mobSetView('list'); } + calRender(); +} + +// Patch selectFolder — called from app.js sidebar click handlers. +// When a mail folder is clicked while contacts/calendar is showing, switch back to mail first. +// Avoids infinite recursion by checking _currentView before doing anything. +(function() { + const _orig = window.selectFolder; + window.selectFolder = function(folderId, folderName) { + if (_currentView !== 'mail') { + showMail(); + // Give the DOM a tick to re-show the mail panels before loading + setTimeout(function() { + _orig && _orig(folderId, folderName); + }, 10); + return; + } + _orig && _orig(folderId, folderName); + }; +})(); + +// ======== CONTACTS ======== + +let _contacts = []; +let _editingContactId = null; + +async function loadContacts() { + const data = await api('GET', '/contacts'); + _contacts = data || []; + renderContacts(_contacts); +} + +function renderContacts(list) { + const el = document.getElementById('contacts-list'); + if (!el) return; + if (!list || list.length === 0) { + el.innerHTML = `
+ +

No contacts yet. Click "+ New Contact" to add one.

+
`; + return; + } + el.innerHTML = list.map(c => { + const initials = (c.display_name || c.email || '?').split(' ').map(w => w[0]).join('').substring(0,2).toUpperCase(); + const color = c.avatar_color || '#6b7280'; + const meta = [c.email, c.company].filter(Boolean).join(' · '); + return `
+
${esc(initials)}
+
+
${esc(c.display_name || c.email)}
+
${esc(meta)}
+
+ +
`; + }).join(''); +} + +function filterContacts(q) { + if (!q) { renderContacts(_contacts); return; } + const lower = q.toLowerCase(); + renderContacts(_contacts.filter(c => + (c.display_name||'').toLowerCase().includes(lower) || + (c.email||'').toLowerCase().includes(lower) || + (c.company||'').toLowerCase().includes(lower) + )); +} + +function composeToContact(email) { + showMail(); + setTimeout(() => { + if (typeof openCompose === 'function') openCompose(); + setTimeout(() => { if (typeof addTag === 'function') addTag('compose-to', email); }, 100); + }, 50); +} + +function openContactForm(id) { + _editingContactId = id || null; + const delBtn = document.getElementById('cf-delete-btn'); + if (id) { + document.getElementById('contact-modal-title').textContent = 'Edit Contact'; + if (delBtn) delBtn.style.display = ''; + const c = _contacts.find(x => x.id === id); + if (c) { + document.getElementById('cf-name').value = c.display_name || ''; + document.getElementById('cf-email').value = c.email || ''; + document.getElementById('cf-phone').value = c.phone || ''; + document.getElementById('cf-company').value = c.company || ''; + document.getElementById('cf-notes').value = c.notes || ''; + } + } else { + document.getElementById('contact-modal-title').textContent = 'New Contact'; + if (delBtn) delBtn.style.display = 'none'; + ['cf-name','cf-email','cf-phone','cf-company','cf-notes'].forEach(id => { + const el = document.getElementById(id); if (el) el.value = ''; + }); + } + openModal('contact-modal'); +} + +async function saveContact() { + const body = { + display_name: document.getElementById('cf-name').value.trim(), + email: document.getElementById('cf-email').value.trim(), + phone: document.getElementById('cf-phone').value.trim(), + company: document.getElementById('cf-company').value.trim(), + notes: document.getElementById('cf-notes').value.trim(), + }; + if (!body.display_name && !body.email) { toast('Name or email is required','error'); return; } + if (_editingContactId) { + await api('PUT', `/contacts/${_editingContactId}`, body); + } else { + await api('POST', '/contacts', body); + } + closeModal('contact-modal'); + await loadContacts(); + toast(_editingContactId ? 'Contact updated' : 'Contact saved', 'success'); +} + +async function deleteContact() { + if (!_editingContactId) return; + if (!confirm('Delete this contact?')) return; + await api('DELETE', `/contacts/${_editingContactId}`); + closeModal('contact-modal'); + await loadContacts(); + toast('Contact deleted', 'success'); +} + +// ======== CALENDAR ======== + +const CAL = { + view: 'month', + cursor: new Date(), + events: [], +}; + +function calSetView(v) { + CAL.view = v; + document.getElementById('cal-btn-month')?.classList.toggle('active', v === 'month'); + document.getElementById('cal-btn-week')?.classList.toggle('active', v === 'week'); + calRender(); +} + +function calNav(dir) { + if (CAL.view === 'month') { + CAL.cursor = new Date(CAL.cursor.getFullYear(), CAL.cursor.getMonth() + dir, 1); + } else { + CAL.cursor = new Date(CAL.cursor.getTime() + dir * 7 * 86400000); + } + calRender(); +} + +function calGoToday() { CAL.cursor = new Date(); calRender(); } + +async function calRender() { + const gridEl = document.getElementById('cal-grid'); + if (!gridEl) return; + + let from, to; + if (CAL.view === 'month') { + from = new Date(CAL.cursor.getFullYear(), CAL.cursor.getMonth(), 1); + to = new Date(CAL.cursor.getFullYear(), CAL.cursor.getMonth() + 1, 0); + from = new Date(from.getTime() - from.getDay() * 86400000); + to = new Date(to.getTime() + (6 - to.getDay()) * 86400000); + } else { + const dow = CAL.cursor.getDay(); + from = new Date(CAL.cursor.getTime() - dow * 86400000); + to = new Date(from.getTime() + 6 * 86400000); + } + + const fmt = d => d.toISOString().split('T')[0]; + const data = await api('GET', `/calendar/events?from=${fmt(from)}&to=${fmt(to)}`); + CAL.events = data || []; + + const months = ['January','February','March','April','May','June','July','August','September','October','November','December']; + const titleEl = document.getElementById('cal-title'); + if (CAL.view === 'month') { + if (titleEl) titleEl.textContent = `${months[CAL.cursor.getMonth()]} ${CAL.cursor.getFullYear()}`; + calRenderMonth(from, to); + } else { + if (titleEl) titleEl.textContent = `${months[from.getMonth()]} ${from.getDate()} – ${months[to.getMonth()]} ${to.getDate()}, ${to.getFullYear()}`; + calRenderWeek(from); + } +} + +function calRenderMonth(from, to) { + const days = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat']; + const today = new Date(); today.setHours(0,0,0,0); + let html = `
`; + days.forEach(d => html += `
${d}
`); + const cur = new Date(from); + const curMonth = CAL.cursor.getMonth(); + while (cur <= to) { + const dateStr = cur.toISOString().split('T')[0]; + const isToday = cur.getTime() === today.getTime(); + const isOther = cur.getMonth() !== curMonth; + const dayEvents = CAL.events.filter(e => e.start_time && e.start_time.startsWith(dateStr)); + const shown = dayEvents.slice(0, 3); + const more = dayEvents.length - 3; + html += `
+
${cur.getDate()}
+ ${shown.map(ev=>`
${esc(ev.title)}
`).join('')} + ${more>0?`
+${more} more
`:''} +
`; + cur.setDate(cur.getDate() + 1); + } + html += `
`; + document.getElementById('cal-grid').innerHTML = html; +} + +function calRenderWeek(weekStart) { + const days = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat']; + const today = new Date(); today.setHours(0,0,0,0); + let html = `
`; + html += `
`; + for (let i=0;i<7;i++) { + const d = new Date(weekStart.getTime()+i*86400000); + const isT = d.getTime()===today.getTime(); + html += `
${days[d.getDay()]} ${d.getDate()}
`; + } + for (let h=0;h<24;h++) { + const label = h===0?'12am':h<12?`${h}am`:h===12?'12pm':`${h-12}pm`; + html += `
${label}
`; + for (let i=0;i<7;i++) { + const d = new Date(weekStart.getTime()+i*86400000); + const dateStr = d.toISOString().split('T')[0]; + const slotEvs = CAL.events.filter(ev => { + if (!ev.start_time) return false; + return ev.start_time.startsWith(dateStr) && + parseInt((ev.start_time.split('T')[1]||'').split(':')[0]||'0') === h; + }); + const isT = d.getTime()===today.getTime(); + html += `
+ ${slotEvs.map(ev=>`
${esc(ev.title)}
`).join('')} +
`; + } + } + html += `
`; + document.getElementById('cal-grid').innerHTML = html; +} + +// ======== EVENT FORM ======== + +let _editingEventId = null; +let _selectedEvColor = '#0078D4'; + +function selectEvColor(el) { + _selectedEvColor = el.dataset.color; + document.querySelectorAll('#ev-colors span').forEach(s => s.style.borderColor = 'transparent'); + el.style.borderColor = 'white'; +} + +function openEventForm(id, defaultStart) { + _editingEventId = id || null; + const delBtn = document.getElementById('ev-delete-btn'); + _selectedEvColor = '#0078D4'; + document.querySelectorAll('#ev-colors span').forEach((s,i) => s.style.borderColor = i===0?'white':'transparent'); + if (id) { + document.getElementById('event-modal-title').textContent = 'Edit Event'; + if (delBtn) delBtn.style.display = ''; + const ev = CAL.events.find(e => e.id === id); + if (ev) { + document.getElementById('ev-title').value = ev.title||''; + document.getElementById('ev-start').value = (ev.start_time||'').replace(' ','T').substring(0,16); + document.getElementById('ev-end').value = (ev.end_time||'').replace(' ','T').substring(0,16); + document.getElementById('ev-allday').checked = !!ev.all_day; + document.getElementById('ev-location').value = ev.location||''; + document.getElementById('ev-desc').value = ev.description||''; + _selectedEvColor = ev.color||'#0078D4'; + document.querySelectorAll('#ev-colors span').forEach(s => { + s.style.borderColor = s.dataset.color===_selectedEvColor ? 'white' : 'transparent'; + }); + } + } else { + document.getElementById('event-modal-title').textContent = 'New Event'; + if (delBtn) delBtn.style.display = 'none'; + document.getElementById('ev-title').value = ''; + const start = defaultStart || new Date().toISOString().substring(0,16); + document.getElementById('ev-start').value = start; + const endDate = new Date(start); endDate.setHours(endDate.getHours()+1); + document.getElementById('ev-end').value = endDate.toISOString().substring(0,16); + document.getElementById('ev-allday').checked = false; + document.getElementById('ev-location').value = ''; + document.getElementById('ev-desc').value = ''; + } + openModal('event-modal'); +} + +async function saveEvent() { + const title = document.getElementById('ev-title').value.trim(); + if (!title) { toast('Title is required','error'); return; } + const body = { + title, + start_time: document.getElementById('ev-start').value.replace('T',' '), + end_time: document.getElementById('ev-end').value.replace('T',' '), + all_day: document.getElementById('ev-allday').checked, + location: document.getElementById('ev-location').value.trim(), + description:document.getElementById('ev-desc').value.trim(), + color: _selectedEvColor, + status: 'confirmed', + }; + if (_editingEventId) { + await api('PUT', `/calendar/events/${_editingEventId}`, body); + } else { + await api('POST', '/calendar/events', body); + } + closeModal('event-modal'); + await calRender(); + toast(_editingEventId ? 'Event updated' : 'Event created', 'success'); +} + +async function deleteEvent() { + if (!_editingEventId) return; + if (!confirm('Delete this event?')) return; + await api('DELETE', `/calendar/events/${_editingEventId}`); + closeModal('event-modal'); + await calRender(); + toast('Event deleted', 'success'); +} + +// ======== CALDAV ======== + +async function showCalDAVSettings() { + openModal('caldav-modal'); + await loadCalDAVTokens(); +} + +async function loadCalDAVTokens() { + const tokens = await api('GET', '/caldav/tokens') || []; + const el = document.getElementById('caldav-tokens-list'); + if (!el) return; + if (!tokens.length) { + el.innerHTML = '

No tokens yet.

'; + return; + } + el.innerHTML = tokens.map(t => { + const url = `${location.origin}/caldav/${t.token}/calendar.ics`; + return `
+
+
${esc(t.label)}
+
${url}
+
Created: ${t.created_at}${t.last_used?' · Last used: '+t.last_used:''}
+
+ +
`; + }).join(''); +} + +async function createCalDAVToken() { + const label = document.getElementById('caldav-label').value.trim() || 'CalDAV token'; + await api('POST', '/caldav/tokens', { label }); + document.getElementById('caldav-label').value = ''; + await loadCalDAVTokens(); + toast('Token created', 'success'); +} + +async function revokeCalDAVToken(id) { + if (!confirm('Revoke this token?')) return; + await api('DELETE', `/caldav/tokens/${id}`); + await loadCalDAVTokens(); + toast('Token revoked', 'success'); +} + +function copyCalDAVUrl(url) { + navigator.clipboard.writeText(url).then(() => toast('URL copied','success')); +} diff --git a/web/templates/app.html b/web/templates/app.html index f99cb74..245d979 100644 --- a/web/templates/app.html +++ b/web/templates/app.html @@ -14,6 +14,7 @@ GoWebMail + @@ -23,7 +24,16 @@
GoWebMail - +
+ + + +
+ +
@@ -103,6 +121,105 @@

Choose a message from the list to read it

+ + + + + + + + + + + + + + + + + @@ -397,5 +514,6 @@ {{end}} {{define "scripts"}} - + + {{end}} diff --git a/web/templates/base.html b/web/templates/base.html index 8e26328..89a0d67 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -5,12 +5,12 @@ {{block "title" .}}GoWebMail{{end}} - + {{block "head_extra" .}}{{end}} {{block "body" .}}{{end}} - + {{block "scripts" .}}{{end}} diff --git a/web/templates/compose.html b/web/templates/compose.html new file mode 100644 index 0000000..80cf317 --- /dev/null +++ b/web/templates/compose.html @@ -0,0 +1,220 @@ +{{template "base" .}} +{{define "title"}}Compose — GoWebMail{{end}} +{{define "body_class"}}app-page{{end}} + +{{define "body"}} +
+
+ + + Back to GoWebMail + + | + New Message +
+ + +
+
+ +
+ +
+ From + +
+ +
+ To +
+
+ +
+ CC +
+
+ +
+ Subject + +
+ +
+ +
+ +
+
+
+
+
+{{end}} + +{{define "scripts"}} + +{{end}} diff --git a/web/templates/message.html b/web/templates/message.html new file mode 100644 index 0000000..5503826 --- /dev/null +++ b/web/templates/message.html @@ -0,0 +1,95 @@ +{{template "base" .}} +{{define "title"}}Message — GoWebMail{{end}} +{{define "body_class"}}app-page{{end}} + +{{define "body"}} +
+
+ + + Back to GoWebMail + + | +
+
+ + +
+
+
+
+
+
+{{end}} + +{{define "scripts"}} + +{{end}}