added calendar and contact - basic

This commit is contained in:
ghostersk
2026-03-22 11:28:33 +00:00
parent 9e7e87d11b
commit d470c8b71f
14 changed files with 1684 additions and 8 deletions

View File

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

View File

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

View File

@@ -17,3 +17,13 @@ type AppHandler struct {
func (h *AppHandler) Index(w http.ResponseWriter, r *http.Request) {
h.renderer.Render(w, "app", nil)
}
// ViewMessage renders a single message in a full browser tab.
func (h *AppHandler) ViewMessage(w http.ResponseWriter, r *http.Request) {
h.renderer.Render(w, "message", nil)
}
// ComposePage renders the compose form in a full browser tab.
func (h *AppHandler) ComposePage(w http.ResponseWriter, r *http.Request) {
h.renderer.Render(w, "compose", nil)
}

View File

@@ -0,0 +1,309 @@
package handlers
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/gorilla/mux"
"github.com/ghostersk/gowebmail/internal/middleware"
"github.com/ghostersk/gowebmail/internal/models"
)
// ======== Contacts ========
func (h *APIHandler) ListContacts(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
q := strings.TrimSpace(r.URL.Query().Get("q"))
var contacts interface{}
var err error
if q != "" {
contacts, err = h.db.SearchContacts(userID, q)
} else {
contacts, err = h.db.ListContacts(userID)
}
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to list contacts")
return
}
if contacts == nil {
contacts = []*models.Contact{}
}
h.writeJSON(w, contacts)
}
func (h *APIHandler) GetContact(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
id := pathInt64(r, "id")
c, err := h.db.GetContact(id, userID)
if err != nil || c == nil {
h.writeError(w, http.StatusNotFound, "contact not found")
return
}
h.writeJSON(w, c)
}
func (h *APIHandler) CreateContact(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
var req models.Contact
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid request")
return
}
req.UserID = userID
if req.AvatarColor == "" {
colors := []string{"#6b7280", "#0078D4", "#EA4335", "#34A853", "#FBBC04", "#9C27B0", "#FF6D00"}
req.AvatarColor = colors[int(userID)%len(colors)]
}
if err := h.db.CreateContact(&req); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to create contact")
return
}
h.writeJSON(w, req)
}
func (h *APIHandler) UpdateContact(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
id := pathInt64(r, "id")
var req models.Contact
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid request")
return
}
req.ID = id
if err := h.db.UpdateContact(&req, userID); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to update contact")
return
}
h.writeJSON(w, map[string]bool{"ok": true})
}
func (h *APIHandler) DeleteContact(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
id := pathInt64(r, "id")
if err := h.db.DeleteContact(id, userID); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to delete contact")
return
}
h.writeJSON(w, map[string]bool{"ok": true})
}
// ======== Calendar Events ========
func (h *APIHandler) ListCalendarEvents(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
from := r.URL.Query().Get("from")
to := r.URL.Query().Get("to")
if from == "" {
from = time.Now().AddDate(0, -1, 0).Format("2006-01-02")
}
if to == "" {
to = time.Now().AddDate(0, 3, 0).Format("2006-01-02")
}
events, err := h.db.ListCalendarEvents(userID, from, to)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to list events")
return
}
if events == nil {
events = []*models.CalendarEvent{}
}
h.writeJSON(w, events)
}
func (h *APIHandler) GetCalendarEvent(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
id := pathInt64(r, "id")
ev, err := h.db.GetCalendarEvent(id, userID)
if err != nil || ev == nil {
h.writeError(w, http.StatusNotFound, "event not found")
return
}
h.writeJSON(w, ev)
}
func (h *APIHandler) CreateCalendarEvent(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
var req models.CalendarEvent
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid request")
return
}
req.UserID = userID
if err := h.db.UpsertCalendarEvent(&req); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to create event")
return
}
h.writeJSON(w, req)
}
func (h *APIHandler) UpdateCalendarEvent(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
id := pathInt64(r, "id")
existing, err := h.db.GetCalendarEvent(id, userID)
if err != nil || existing == nil {
h.writeError(w, http.StatusNotFound, "event not found")
return
}
var req models.CalendarEvent
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid request")
return
}
req.ID = id
req.UserID = userID
req.UID = existing.UID // preserve original UID
if err := h.db.UpsertCalendarEvent(&req); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to update event")
return
}
h.writeJSON(w, map[string]bool{"ok": true})
}
func (h *APIHandler) DeleteCalendarEvent(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
id := pathInt64(r, "id")
if err := h.db.DeleteCalendarEvent(id, userID); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to delete event")
return
}
h.writeJSON(w, map[string]bool{"ok": true})
}
// ======== CalDAV Tokens ========
func (h *APIHandler) ListCalDAVTokens(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
tokens, err := h.db.ListCalDAVTokens(userID)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to list tokens")
return
}
if tokens == nil {
tokens = []*models.CalDAVToken{}
}
h.writeJSON(w, tokens)
}
func (h *APIHandler) CreateCalDAVToken(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
var req struct {
Label string `json:"label"`
}
json.NewDecoder(r.Body).Decode(&req)
if req.Label == "" {
req.Label = "CalDAV token"
}
t, err := h.db.CreateCalDAVToken(userID, req.Label)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to create token")
return
}
h.writeJSON(w, t)
}
func (h *APIHandler) DeleteCalDAVToken(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
id := pathInt64(r, "id")
if err := h.db.DeleteCalDAVToken(id, userID); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to delete token")
return
}
h.writeJSON(w, map[string]bool{"ok": true})
}
// ======== CalDAV Server ========
// Serves a read-only iCalendar feed at /caldav/{token}/calendar.ics
// Compatible with any CalDAV client that supports basic calendar subscription.
func (h *APIHandler) ServeCalDAV(w http.ResponseWriter, r *http.Request) {
token := mux.Vars(r)["token"]
userID, err := h.db.GetUserByCalDAVToken(token)
if err != nil || userID == 0 {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Fetch events for next 12 months + past 3 months
from := time.Now().AddDate(0, -3, 0).Format("2006-01-02")
to := time.Now().AddDate(1, 0, 0).Format("2006-01-02")
events, err := h.db.ListCalendarEvents(userID, from, to)
if err != nil {
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/calendar; charset=utf-8")
w.Header().Set("Content-Disposition", `attachment; filename="gowebmail.ics"`)
fmt.Fprintf(w, "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//GoWebMail//EN\r\nCALSCALE:GREGORIAN\r\nMETHOD:PUBLISH\r\nX-WR-CALNAME:GoWebMail\r\n")
for _, ev := range events {
fmt.Fprintf(w, "BEGIN:VEVENT\r\n")
fmt.Fprintf(w, "UID:%s\r\n", escICAL(ev.UID))
fmt.Fprintf(w, "SUMMARY:%s\r\n", escICAL(ev.Title))
if ev.Description != "" {
fmt.Fprintf(w, "DESCRIPTION:%s\r\n", escICAL(ev.Description))
}
if ev.Location != "" {
fmt.Fprintf(w, "LOCATION:%s\r\n", escICAL(ev.Location))
}
if ev.AllDay {
// All-day events use DATE format
start := strings.ReplaceAll(strings.Split(ev.StartTime, "T")[0], "-", "")
end := strings.ReplaceAll(strings.Split(ev.EndTime, "T")[0], "-", "")
fmt.Fprintf(w, "DTSTART;VALUE=DATE:%s\r\n", start)
fmt.Fprintf(w, "DTEND;VALUE=DATE:%s\r\n", end)
} else {
fmt.Fprintf(w, "DTSTART:%s\r\n", toICALDate(ev.StartTime))
fmt.Fprintf(w, "DTEND:%s\r\n", toICALDate(ev.EndTime))
}
if ev.OrganizerEmail != "" {
fmt.Fprintf(w, "ORGANIZER:mailto:%s\r\n", ev.OrganizerEmail)
}
if ev.Status != "" {
fmt.Fprintf(w, "STATUS:%s\r\n", strings.ToUpper(ev.Status))
}
if ev.RecurrenceRule != "" {
fmt.Fprintf(w, "RRULE:%s\r\n", ev.RecurrenceRule)
}
fmt.Fprintf(w, "END:VEVENT\r\n")
}
fmt.Fprintf(w, "END:VCALENDAR\r\n")
}
func escICAL(s string) string {
s = strings.ReplaceAll(s, "\\", "\\\\")
s = strings.ReplaceAll(s, ";", "\\;")
s = strings.ReplaceAll(s, ",", "\\,")
s = strings.ReplaceAll(s, "\n", "\\n")
s = strings.ReplaceAll(s, "\r", "")
// Fold long lines at 75 chars
if len(s) > 70 {
var out strings.Builder
for i, ch := range s {
if i > 0 && i%70 == 0 {
out.WriteString("\r\n ")
}
out.WriteRune(ch)
}
return out.String()
}
return s
}
func toICALDate(s string) string {
// Convert "2006-01-02T15:04:05Z" or "2006-01-02 15:04:05" to "20060102T150405Z"
t, err := time.Parse("2006-01-02T15:04:05Z07:00", s)
if err != nil {
t, err = time.Parse("2006-01-02 15:04:05", s)
}
if err != nil {
return strings.NewReplacer("-", "", ":", "", " ", "T", "Z", "").Replace(s) + "Z"
}
return t.UTC().Format("20060102T150405Z")
}

View File

@@ -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 {

View File

@@ -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"`
}

View File

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

View File

@@ -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) {
<div class="ctx-submenu">${moveItems}</div>
</div>` : '';
showCtxMenu(e,`
<div class="ctx-item" onclick="window.open('/message/${id}','_blank');closeMenu()">↗ Open in new tab</div>
<div class="ctx-sep"></div>
<div class="ctx-item" onclick="openReplyTo(${id});closeMenu()">↩ Reply</div>
<div class="ctx-item" onclick="toggleStar(${id});closeMenu()">${msg?.is_starred?'★ Unstar':'☆ Star'}</div>
<div class="ctx-item" onclick="markRead(${id},${msg?.is_read?'false':'true'});closeMenu()">${msg?.is_read?'Mark unread':'Mark read'}</div>
@@ -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';
}

View File

@@ -0,0 +1,411 @@
// ── Contacts & Calendar ─────────────────────────────────────────────────────
let _currentView = 'mail';
// ======== VIEW SWITCHING ========
// Uses data-view attribute on #app-root to switch panels via CSS,
// avoiding direct style manipulation of elements that may not exist.
function _setView(view) {
_currentView = view;
// Update nav item active states
['nav-unified','nav-starred','nav-contacts','nav-calendar'].forEach(id => {
document.getElementById(id)?.classList.remove('active');
});
// Show/hide panels
const mail1 = document.getElementById('message-list-panel');
const mail2 = document.getElementById('message-detail');
const contacts = document.getElementById('contacts-panel');
const calendar = document.getElementById('calendar-panel');
if (mail1) mail1.style.display = view === 'mail' ? '' : 'none';
if (mail2) mail2.style.display = view === 'mail' ? '' : 'none';
if (contacts) contacts.style.display = view === 'contacts' ? 'flex' : 'none';
if (calendar) calendar.style.display = view === 'calendar' ? 'flex' : 'none';
}
function showMail() {
_setView('mail');
document.getElementById('nav-unified')?.classList.add('active');
}
function showContacts() {
_setView('contacts');
document.getElementById('nav-contacts')?.classList.add('active');
if (typeof mobCloseNav === 'function') { mobCloseNav(); mobSetView('list'); }
loadContacts();
}
function showCalendar() {
_setView('calendar');
document.getElementById('nav-calendar')?.classList.add('active');
if (typeof mobCloseNav === 'function') { mobCloseNav(); mobSetView('list'); }
calRender();
}
// Patch selectFolder — called from app.js sidebar click handlers.
// When a mail folder is clicked while contacts/calendar is showing, switch back to mail first.
// Avoids infinite recursion by checking _currentView before doing anything.
(function() {
const _orig = window.selectFolder;
window.selectFolder = function(folderId, folderName) {
if (_currentView !== 'mail') {
showMail();
// Give the DOM a tick to re-show the mail panels before loading
setTimeout(function() {
_orig && _orig(folderId, folderName);
}, 10);
return;
}
_orig && _orig(folderId, folderName);
};
})();
// ======== CONTACTS ========
let _contacts = [];
let _editingContactId = null;
async function loadContacts() {
const data = await api('GET', '/contacts');
_contacts = data || [];
renderContacts(_contacts);
}
function renderContacts(list) {
const el = document.getElementById('contacts-list');
if (!el) return;
if (!list || list.length === 0) {
el.innerHTML = `<div style="text-align:center;padding:60px 20px;color:var(--muted)">
<svg viewBox="0 0 24 24" width="48" height="48" fill="currentColor" style="opacity:.25;margin-bottom:12px;display:block;margin:0 auto 12px"><path d="M20 0H4v2h16V0zM0 4v18h24V4H0zm22 16H2V6h20v14zM12 11c1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3 1.34 3 3 3zm-6 6c0-2.21 2.69-4 6-4s6 1.79 6 4H6z"/></svg>
<p>No contacts yet. Click "+ New Contact" to add one.</p>
</div>`;
return;
}
el.innerHTML = list.map(c => {
const initials = (c.display_name || c.email || '?').split(' ').map(w => w[0]).join('').substring(0,2).toUpperCase();
const color = c.avatar_color || '#6b7280';
const meta = [c.email, c.company].filter(Boolean).join(' · ');
return `<div class="contact-card" onclick="openContactForm(${c.id})">
<div class="contact-avatar" style="background:${esc(color)}">${esc(initials)}</div>
<div class="contact-info">
<div class="contact-name">${esc(c.display_name || c.email)}</div>
<div class="contact-meta">${esc(meta)}</div>
</div>
<button class="btn-secondary" style="font-size:11px;padding:4px 8px" onclick="event.stopPropagation();composeToContact('${esc(c.email)}')">Mail</button>
</div>`;
}).join('');
}
function filterContacts(q) {
if (!q) { renderContacts(_contacts); return; }
const lower = q.toLowerCase();
renderContacts(_contacts.filter(c =>
(c.display_name||'').toLowerCase().includes(lower) ||
(c.email||'').toLowerCase().includes(lower) ||
(c.company||'').toLowerCase().includes(lower)
));
}
function composeToContact(email) {
showMail();
setTimeout(() => {
if (typeof openCompose === 'function') openCompose();
setTimeout(() => { if (typeof addTag === 'function') addTag('compose-to', email); }, 100);
}, 50);
}
function openContactForm(id) {
_editingContactId = id || null;
const delBtn = document.getElementById('cf-delete-btn');
if (id) {
document.getElementById('contact-modal-title').textContent = 'Edit Contact';
if (delBtn) delBtn.style.display = '';
const c = _contacts.find(x => x.id === id);
if (c) {
document.getElementById('cf-name').value = c.display_name || '';
document.getElementById('cf-email').value = c.email || '';
document.getElementById('cf-phone').value = c.phone || '';
document.getElementById('cf-company').value = c.company || '';
document.getElementById('cf-notes').value = c.notes || '';
}
} else {
document.getElementById('contact-modal-title').textContent = 'New Contact';
if (delBtn) delBtn.style.display = 'none';
['cf-name','cf-email','cf-phone','cf-company','cf-notes'].forEach(id => {
const el = document.getElementById(id); if (el) el.value = '';
});
}
openModal('contact-modal');
}
async function saveContact() {
const body = {
display_name: document.getElementById('cf-name').value.trim(),
email: document.getElementById('cf-email').value.trim(),
phone: document.getElementById('cf-phone').value.trim(),
company: document.getElementById('cf-company').value.trim(),
notes: document.getElementById('cf-notes').value.trim(),
};
if (!body.display_name && !body.email) { toast('Name or email is required','error'); return; }
if (_editingContactId) {
await api('PUT', `/contacts/${_editingContactId}`, body);
} else {
await api('POST', '/contacts', body);
}
closeModal('contact-modal');
await loadContacts();
toast(_editingContactId ? 'Contact updated' : 'Contact saved', 'success');
}
async function deleteContact() {
if (!_editingContactId) return;
if (!confirm('Delete this contact?')) return;
await api('DELETE', `/contacts/${_editingContactId}`);
closeModal('contact-modal');
await loadContacts();
toast('Contact deleted', 'success');
}
// ======== CALENDAR ========
const CAL = {
view: 'month',
cursor: new Date(),
events: [],
};
function calSetView(v) {
CAL.view = v;
document.getElementById('cal-btn-month')?.classList.toggle('active', v === 'month');
document.getElementById('cal-btn-week')?.classList.toggle('active', v === 'week');
calRender();
}
function calNav(dir) {
if (CAL.view === 'month') {
CAL.cursor = new Date(CAL.cursor.getFullYear(), CAL.cursor.getMonth() + dir, 1);
} else {
CAL.cursor = new Date(CAL.cursor.getTime() + dir * 7 * 86400000);
}
calRender();
}
function calGoToday() { CAL.cursor = new Date(); calRender(); }
async function calRender() {
const gridEl = document.getElementById('cal-grid');
if (!gridEl) return;
let from, to;
if (CAL.view === 'month') {
from = new Date(CAL.cursor.getFullYear(), CAL.cursor.getMonth(), 1);
to = new Date(CAL.cursor.getFullYear(), CAL.cursor.getMonth() + 1, 0);
from = new Date(from.getTime() - from.getDay() * 86400000);
to = new Date(to.getTime() + (6 - to.getDay()) * 86400000);
} else {
const dow = CAL.cursor.getDay();
from = new Date(CAL.cursor.getTime() - dow * 86400000);
to = new Date(from.getTime() + 6 * 86400000);
}
const fmt = d => d.toISOString().split('T')[0];
const data = await api('GET', `/calendar/events?from=${fmt(from)}&to=${fmt(to)}`);
CAL.events = data || [];
const months = ['January','February','March','April','May','June','July','August','September','October','November','December'];
const titleEl = document.getElementById('cal-title');
if (CAL.view === 'month') {
if (titleEl) titleEl.textContent = `${months[CAL.cursor.getMonth()]} ${CAL.cursor.getFullYear()}`;
calRenderMonth(from, to);
} else {
if (titleEl) titleEl.textContent = `${months[from.getMonth()]} ${from.getDate()} ${months[to.getMonth()]} ${to.getDate()}, ${to.getFullYear()}`;
calRenderWeek(from);
}
}
function calRenderMonth(from, to) {
const days = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
const today = new Date(); today.setHours(0,0,0,0);
let html = `<div class="cal-grid-month">`;
days.forEach(d => html += `<div class="cal-day-header">${d}</div>`);
const cur = new Date(from);
const curMonth = CAL.cursor.getMonth();
while (cur <= to) {
const dateStr = cur.toISOString().split('T')[0];
const isToday = cur.getTime() === today.getTime();
const isOther = cur.getMonth() !== curMonth;
const dayEvents = CAL.events.filter(e => e.start_time && e.start_time.startsWith(dateStr));
const shown = dayEvents.slice(0, 3);
const more = dayEvents.length - 3;
html += `<div class="cal-day${isToday?' today':''}${isOther?' other-month':''}" data-date="${dateStr}">
<div class="cal-day-num" onclick="openEventForm(null,'${dateStr}T09:00')">${cur.getDate()}</div>
${shown.map(ev=>`<div class="cal-event" style="background:${ev.color||'#0078D4'}"
onclick="openEventForm(${ev.id})" title="${esc(ev.title)}">${esc(ev.title)}</div>`).join('')}
${more>0?`<div class="cal-more" onclick="openEventForm(null,'${dateStr}T09:00')">+${more} more</div>`:''}
</div>`;
cur.setDate(cur.getDate() + 1);
}
html += `</div>`;
document.getElementById('cal-grid').innerHTML = html;
}
function calRenderWeek(weekStart) {
const days = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
const today = new Date(); today.setHours(0,0,0,0);
let html = `<div class="cal-week-grid">`;
html += `<div class="cal-week-header" style="background:var(--surface)"></div>`;
for (let i=0;i<7;i++) {
const d = new Date(weekStart.getTime()+i*86400000);
const isT = d.getTime()===today.getTime();
html += `<div class="cal-week-header${isT?' today-col':''}">${days[d.getDay()]} ${d.getDate()}</div>`;
}
for (let h=0;h<24;h++) {
const label = h===0?'12am':h<12?`${h}am`:h===12?'12pm':`${h-12}pm`;
html += `<div class="cal-time-col">${label}</div>`;
for (let i=0;i<7;i++) {
const d = new Date(weekStart.getTime()+i*86400000);
const dateStr = d.toISOString().split('T')[0];
const slotEvs = CAL.events.filter(ev => {
if (!ev.start_time) return false;
return ev.start_time.startsWith(dateStr) &&
parseInt((ev.start_time.split('T')[1]||'').split(':')[0]||'0') === h;
});
const isT = d.getTime()===today.getTime();
html += `<div class="cal-week-cell${isT?' today':''}"
onclick="openEventForm(null,'${dateStr}T${String(h).padStart(2,'0')}:00')">
${slotEvs.map(ev=>`<div class="cal-event" style="background:${ev.color||'#0078D4'};font-size:10px;position:absolute;left:2px;right:2px;z-index:1"
onclick="event.stopPropagation();openEventForm(${ev.id})">${esc(ev.title)}</div>`).join('')}
</div>`;
}
}
html += `</div>`;
document.getElementById('cal-grid').innerHTML = html;
}
// ======== EVENT FORM ========
let _editingEventId = null;
let _selectedEvColor = '#0078D4';
function selectEvColor(el) {
_selectedEvColor = el.dataset.color;
document.querySelectorAll('#ev-colors span').forEach(s => s.style.borderColor = 'transparent');
el.style.borderColor = 'white';
}
function openEventForm(id, defaultStart) {
_editingEventId = id || null;
const delBtn = document.getElementById('ev-delete-btn');
_selectedEvColor = '#0078D4';
document.querySelectorAll('#ev-colors span').forEach((s,i) => s.style.borderColor = i===0?'white':'transparent');
if (id) {
document.getElementById('event-modal-title').textContent = 'Edit Event';
if (delBtn) delBtn.style.display = '';
const ev = CAL.events.find(e => e.id === id);
if (ev) {
document.getElementById('ev-title').value = ev.title||'';
document.getElementById('ev-start').value = (ev.start_time||'').replace(' ','T').substring(0,16);
document.getElementById('ev-end').value = (ev.end_time||'').replace(' ','T').substring(0,16);
document.getElementById('ev-allday').checked = !!ev.all_day;
document.getElementById('ev-location').value = ev.location||'';
document.getElementById('ev-desc').value = ev.description||'';
_selectedEvColor = ev.color||'#0078D4';
document.querySelectorAll('#ev-colors span').forEach(s => {
s.style.borderColor = s.dataset.color===_selectedEvColor ? 'white' : 'transparent';
});
}
} else {
document.getElementById('event-modal-title').textContent = 'New Event';
if (delBtn) delBtn.style.display = 'none';
document.getElementById('ev-title').value = '';
const start = defaultStart || new Date().toISOString().substring(0,16);
document.getElementById('ev-start').value = start;
const endDate = new Date(start); endDate.setHours(endDate.getHours()+1);
document.getElementById('ev-end').value = endDate.toISOString().substring(0,16);
document.getElementById('ev-allday').checked = false;
document.getElementById('ev-location').value = '';
document.getElementById('ev-desc').value = '';
}
openModal('event-modal');
}
async function saveEvent() {
const title = document.getElementById('ev-title').value.trim();
if (!title) { toast('Title is required','error'); return; }
const body = {
title,
start_time: document.getElementById('ev-start').value.replace('T',' '),
end_time: document.getElementById('ev-end').value.replace('T',' '),
all_day: document.getElementById('ev-allday').checked,
location: document.getElementById('ev-location').value.trim(),
description:document.getElementById('ev-desc').value.trim(),
color: _selectedEvColor,
status: 'confirmed',
};
if (_editingEventId) {
await api('PUT', `/calendar/events/${_editingEventId}`, body);
} else {
await api('POST', '/calendar/events', body);
}
closeModal('event-modal');
await calRender();
toast(_editingEventId ? 'Event updated' : 'Event created', 'success');
}
async function deleteEvent() {
if (!_editingEventId) return;
if (!confirm('Delete this event?')) return;
await api('DELETE', `/calendar/events/${_editingEventId}`);
closeModal('event-modal');
await calRender();
toast('Event deleted', 'success');
}
// ======== CALDAV ========
async function showCalDAVSettings() {
openModal('caldav-modal');
await loadCalDAVTokens();
}
async function loadCalDAVTokens() {
const tokens = await api('GET', '/caldav/tokens') || [];
const el = document.getElementById('caldav-tokens-list');
if (!el) return;
if (!tokens.length) {
el.innerHTML = '<p style="font-size:13px;color:var(--muted)">No tokens yet.</p>';
return;
}
el.innerHTML = tokens.map(t => {
const url = `${location.origin}/caldav/${t.token}/calendar.ics`;
return `<div class="caldav-token-row">
<div style="flex:1;min-width:0">
<div style="font-size:13px;font-weight:500">${esc(t.label)}</div>
<div class="caldav-token-url" onclick="copyCalDAVUrl('${url}')" title="Click to copy">${url}</div>
<div style="font-size:11px;color:var(--muted)">Created: ${t.created_at}${t.last_used?' · Last used: '+t.last_used:''}</div>
</div>
<button class="icon-btn" onclick="revokeCalDAVToken(${t.id})" title="Revoke" style="color:var(--danger);flex-shrink:0">
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
</button>
</div>`;
}).join('');
}
async function createCalDAVToken() {
const label = document.getElementById('caldav-label').value.trim() || 'CalDAV token';
await api('POST', '/caldav/tokens', { label });
document.getElementById('caldav-label').value = '';
await loadCalDAVTokens();
toast('Token created', 'success');
}
async function revokeCalDAVToken(id) {
if (!confirm('Revoke this token?')) return;
await api('DELETE', `/caldav/tokens/${id}`);
await loadCalDAVTokens();
toast('Token revoked', 'success');
}
function copyCalDAVUrl(url) {
navigator.clipboard.writeText(url).then(() => toast('URL copied','success'));
}

View File

@@ -14,6 +14,7 @@
</button>
<span class="mob-title" id="mob-title">GoWebMail</span>
<button class="compose-btn" onclick="openCompose()" style="margin-left:auto;padding:5px 10px;font-size:11px">+ New</button>
<button class="compose-btn" onclick="window.open('/compose','_blank')" style="padding:5px 8px;font-size:11px" title="Compose in new tab"></button>
</div>
<!-- Sidebar -->
@@ -23,7 +24,16 @@
<div class="logo-icon"><svg viewBox="0 0 24 24"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/></svg></div>
<span class="logo-text"><a href="/">GoWebMail</a></span>
</div>
<button class="compose-btn" onclick="openCompose()">+ New</button>
<div style="position:relative;display:inline-flex">
<button class="compose-btn" onclick="openCompose()" style="border-radius:6px 0 0 6px">+ New</button>
<button class="compose-btn" onclick="toggleComposeDropdown(event)" style="border-radius:0 6px 6px 0;border-left:1px solid rgba(255,255,255,.25);padding:6px 7px" title="More options">
<svg viewBox="0 0 24 24" width="10" height="10" fill="white"><path d="M7 10l5 5 5-5z"/></svg>
</button>
<div id="compose-dropdown" style="display:none;position:absolute;top:100%;left:0;margin-top:4px;background:var(--surface);border:1px solid var(--border2);border-radius:7px;box-shadow:0 4px 16px rgba(0,0,0,.2);z-index:200;min-width:200px;overflow:hidden">
<div class="ctx-item" onclick="openCompose();closeComposeDropdown()">✉ New message</div>
<div class="ctx-item" onclick="window.open('/compose','_blank');closeComposeDropdown()">↗ New message in new tab</div>
</div>
</div>
</div>
<div class="nav-section">
@@ -36,6 +46,14 @@
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/></svg>
Starred
</div>
<div class="nav-item" id="nav-contacts" onclick="showContacts()">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20 0H4v2h16V0zM0 4v18h24V4H0zm22 16H2V6h20v14zM12 11c1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3 1.34 3 3 3zm-6 6c0-2.21 2.69-4 6-4s6 1.79 6 4H6z"/></svg>
Contacts
</div>
<div class="nav-item" id="nav-calendar" onclick="showCalendar()">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20 3h-1V1h-2v2H7V1H5v2H4c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 18H4V8h16v13z"/></svg>
Calendar
</div>
<div id="folders-by-account"></div>
</div>
@@ -103,6 +121,105 @@
<p>Choose a message from the list to read it</p>
</div>
</main>
<!-- ── Contacts panel ──────────────────────────────────────────────────── -->
<div id="contacts-panel" style="display:none;flex:1;flex-direction:column;overflow:hidden;background:var(--bg)">
<div class="panel-header" style="padding:14px 18px 10px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;flex-shrink:0">
<span style="font-family:'DM Serif Display',serif;font-size:17px;flex:1">Contacts</span>
<input id="contacts-search" type="search" placeholder="Search contacts…" oninput="filterContacts(this.value)"
style="padding:5px 10px;border:1px solid var(--border2);border-radius:6px;background:var(--surface3);color:var(--text);font-size:13px;width:200px">
<button class="btn-secondary" onclick="openContactForm()" style="font-size:12px">+ New Contact</button>
</div>
<div id="contacts-list" style="flex:1;overflow-y:auto;padding:12px"></div>
</div>
<!-- ── Calendar panel ──────────────────────────────────────────────────── -->
<div id="calendar-panel" style="display:none;flex:1;flex-direction:column;overflow:hidden;background:var(--bg)">
<div style="padding:12px 18px 10px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:8px;flex-shrink:0">
<button class="icon-btn" onclick="calNav(-1)" title="Previous">&#8249;</button>
<span id="cal-title" style="font-family:'DM Serif Display',serif;font-size:17px;min-width:200px;text-align:center"></span>
<button class="icon-btn" onclick="calNav(1)" title="Next">&#8250;</button>
<button class="btn-secondary" onclick="calGoToday()" style="font-size:12px;margin-left:4px">Today</button>
<div style="margin-left:auto;display:flex;gap:4px">
<button class="btn-secondary" id="cal-btn-month" onclick="calSetView('month')" style="font-size:12px">Month</button>
<button class="btn-secondary" id="cal-btn-week" onclick="calSetView('week')" style="font-size:12px">Week</button>
<button class="btn-secondary" onclick="openEventForm()" style="font-size:12px;background:var(--accent);color:white;border-color:var(--accent)">+ Event</button>
<button class="icon-btn" onclick="showCalDAVSettings()" title="CalDAV / sharing">
<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z"/></svg>
</button>
</div>
</div>
<div id="cal-grid" style="flex:1;overflow-y:auto"></div>
</div>
</div>
<!-- ── Contact form modal ──────────────────────────────────────────────────── -->
<div class="modal-overlay" id="contact-modal">
<div class="modal" style="max-width:480px">
<h2 id="contact-modal-title">New Contact</h2>
<div class="modal-field"><label>Name</label><input id="cf-name" type="text" placeholder="Full name"></div>
<div class="modal-field"><label>Email</label><input id="cf-email" type="email" placeholder="email@example.com"></div>
<div class="modal-field"><label>Phone</label><input id="cf-phone" type="tel" placeholder="+1 555 000 0000"></div>
<div class="modal-field"><label>Company</label><input id="cf-company" type="text" placeholder="Company name"></div>
<div class="modal-field"><label>Notes</label><textarea id="cf-notes" rows="3" style="width:100%;resize:vertical;padding:8px;background:var(--surface3);border:1px solid var(--border2);border-radius:6px;color:var(--text);font-family:'DM Sans',sans-serif;font-size:13px"></textarea></div>
<div class="modal-actions">
<button class="modal-cancel" onclick="closeModal('contact-modal')">Cancel</button>
<button id="cf-delete-btn" class="btn-secondary" style="color:var(--danger);display:none" onclick="deleteContact()">Delete</button>
<button class="modal-submit" onclick="saveContact()">Save</button>
</div>
</div>
</div>
<!-- ── Event form modal ──────────────────────────────────────────────────── -->
<div class="modal-overlay" id="event-modal">
<div class="modal" style="max-width:520px">
<h2 id="event-modal-title">New Event</h2>
<div class="modal-field"><label>Title</label><input id="ev-title" type="text" placeholder="Event title"></div>
<div class="modal-row">
<div class="modal-field"><label>Start</label><input id="ev-start" type="datetime-local"></div>
<div class="modal-field"><label>End</label><input id="ev-end" type="datetime-local"></div>
</div>
<div class="modal-field" style="flex-direction:row;align-items:center;gap:8px">
<input id="ev-allday" type="checkbox" style="width:auto">
<label for="ev-allday" style="font-weight:normal;color:var(--text2)">All day</label>
</div>
<div class="modal-field"><label>Location</label><input id="ev-location" type="text" placeholder="Location or video link"></div>
<div class="modal-field"><label>Description</label><textarea id="ev-desc" rows="3" style="width:100%;resize:vertical;padding:8px;background:var(--surface3);border:1px solid var(--border2);border-radius:6px;color:var(--text);font-family:'DM Sans',sans-serif;font-size:13px"></textarea></div>
<div class="modal-field"><label>Color</label>
<div style="display:flex;gap:6px" id="ev-colors">
<span data-color="#0078D4" onclick="selectEvColor(this)" style="width:22px;height:22px;border-radius:50%;background:#0078D4;cursor:pointer;border:2px solid transparent"></span>
<span data-color="#EA4335" onclick="selectEvColor(this)" style="width:22px;height:22px;border-radius:50%;background:#EA4335;cursor:pointer;border:2px solid transparent"></span>
<span data-color="#34A853" onclick="selectEvColor(this)" style="width:22px;height:22px;border-radius:50%;background:#34A853;cursor:pointer;border:2px solid transparent"></span>
<span data-color="#FBBC04" onclick="selectEvColor(this)" style="width:22px;height:22px;border-radius:50%;background:#FBBC04;cursor:pointer;border:2px solid transparent"></span>
<span data-color="#9C27B0" onclick="selectEvColor(this)" style="width:22px;height:22px;border-radius:50%;background:#9C27B0;cursor:pointer;border:2px solid transparent"></span>
<span data-color="#FF6D00" onclick="selectEvColor(this)" style="width:22px;height:22px;border-radius:50%;background:#FF6D00;cursor:pointer;border:2px solid transparent"></span>
</div>
</div>
<div class="modal-actions">
<button class="modal-cancel" onclick="closeModal('event-modal')">Cancel</button>
<button id="ev-delete-btn" class="btn-secondary" style="color:var(--danger);display:none" onclick="deleteEvent()">Delete</button>
<button class="modal-submit" onclick="saveEvent()">Save</button>
</div>
</div>
</div>
<!-- ── CalDAV settings modal ──────────────────────────────────────────────── -->
<div class="modal-overlay" id="caldav-modal">
<div class="modal" style="max-width:560px">
<h2>CalDAV / Calendar Sharing</h2>
<p style="font-size:13px;color:var(--text2);margin-bottom:14px">
Subscribe to your GoWebMail calendar from any CalDAV client (Apple Calendar, Thunderbird, etc.) using a token URL. Tokens give read-only calendar access — no password needed.
</p>
<div id="caldav-tokens-list" style="margin-bottom:14px"></div>
<div style="display:flex;gap:8px;align-items:center">
<input id="caldav-label" type="text" placeholder="Token label (e.g. iPhone)" style="flex:1;padding:7px 10px;background:var(--surface3);border:1px solid var(--border2);border-radius:6px;color:var(--text);font-size:13px">
<button class="btn-secondary" onclick="createCalDAVToken()" style="white-space:nowrap">Generate Token</button>
</div>
<div class="modal-actions" style="margin-top:16px">
<button class="modal-cancel" onclick="closeModal('caldav-modal')">Close</button>
</div>
</div>
</div>
<!-- ── Accounts submenu popup ──────────────────────────────────────────────── -->
@@ -397,5 +514,6 @@
{{end}}
{{define "scripts"}}
<script src="/static/js/app.js?v=54"></script>
<script src="/static/js/app.js?v=58"></script>
<script src="/static/js/contacts_calendar.js?v=58"></script>
{{end}}

View File

@@ -5,12 +5,12 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{block "title" .}}GoWebMail{{end}}</title>
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=DM+Sans:ital,wght@0,300;0,400;0,500;1,400&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/css/gowebmail.css?v=54">
<link rel="stylesheet" href="/static/css/gowebmail.css?v=58">
{{block "head_extra" .}}{{end}}
</head>
<body class="{{block "body_class" .}}{{end}}">
{{block "body" .}}{{end}}
<script src="/static/js/gowebmail.js?v=54"></script>
<script src="/static/js/gowebmail.js?v=58"></script>
{{block "scripts" .}}{{end}}
</body>
</html>

220
web/templates/compose.html Normal file
View File

@@ -0,0 +1,220 @@
{{template "base" .}}
{{define "title"}}Compose — GoWebMail{{end}}
{{define "body_class"}}app-page{{end}}
{{define "body"}}
<div id="compose-page" style="max-width:860px;margin:0 auto;padding:20px 16px;min-height:100vh">
<div style="display:flex;align-items:center;gap:12px;margin-bottom:18px;padding-bottom:14px;border-bottom:1px solid var(--border)">
<a href="/" style="color:var(--accent);text-decoration:none;font-size:13px;display:flex;align-items:center;gap:4px">
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
Back to GoWebMail
</a>
<span style="color:var(--border);font-size:16px">|</span>
<span id="compose-page-title" style="font-size:14px;color:var(--text2)">New Message</span>
<div style="margin-left:auto;display:flex;gap:6px">
<button class="btn-secondary" id="save-draft-btn" onclick="saveDraft()" style="font-size:12px">Save Draft</button>
<button class="modal-submit" id="send-page-btn" onclick="sendFromPage()" style="font-size:13px;padding:7px 18px">Send</button>
</div>
</div>
<div id="compose-page-form">
<!-- From -->
<div style="display:flex;align-items:center;border-bottom:1px solid var(--border);padding:8px 0;gap:8px">
<span style="font-size:12px;color:var(--muted);width:48px;flex-shrink:0">From</span>
<select id="cp-from" style="flex:1;background:transparent;border:none;color:var(--text);font-size:13px;outline:none;cursor:pointer"></select>
</div>
<!-- To -->
<div style="display:flex;align-items:flex-start;border-bottom:1px solid var(--border);padding:8px 0;gap:8px">
<span style="font-size:12px;color:var(--muted);width:48px;flex-shrink:0;padding-top:6px">To</span>
<div id="cp-to-tags" class="tag-field" style="flex:1;min-height:30px"></div>
</div>
<!-- CC -->
<div style="display:flex;align-items:flex-start;border-bottom:1px solid var(--border);padding:8px 0;gap:8px">
<span style="font-size:12px;color:var(--muted);width:48px;flex-shrink:0;padding-top:6px">CC</span>
<div id="cp-cc-tags" class="tag-field" style="flex:1;min-height:30px"></div>
</div>
<!-- Subject -->
<div style="display:flex;align-items:center;border-bottom:1px solid var(--border);padding:8px 0;gap:8px">
<span style="font-size:12px;color:var(--muted);width:48px;flex-shrink:0">Subject</span>
<input id="cp-subject" type="text" placeholder="Subject" style="flex:1;background:transparent;border:none;color:var(--text);font-size:14px;outline:none;font-family:'DM Sans',sans-serif">
</div>
<!-- Body -->
<div id="cp-editor" contenteditable="true" style="min-height:400px;padding:16px 0;outline:none;font-size:14px;line-height:1.6;color:var(--text)" data-placeholder="Write your message…"></div>
<!-- Attachments -->
<div style="border-top:1px solid var(--border);padding:10px 0;display:flex;align-items:center;gap:8px;flex-wrap:wrap">
<label style="cursor:pointer;font-size:12px;color:var(--muted);display:flex;align-items:center;gap:4px">
<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M16.5 6v11.5c0 2.21-1.79 4-4 4s-4-1.79-4-4V5c0-1.38 1.12-2.5 2.5-2.5s2.5 1.12 2.5 2.5v10.5c0 .55-.45 1-1 1s-1-.45-1-1V6H10v9.5c0 1.38 1.12 2.5 2.5 2.5s2.5-1.12 2.5-2.5V5c0-2.21-1.79-4-4-4S7 2.79 7 5v12.5c0 3.04 2.46 5.5 5.5 5.5s5.5-2.46 5.5-5.5V6h-1.5z"/></svg>
Attach file
<input type="file" multiple style="display:none" onchange="addPageAttachments(this.files)">
</label>
<div id="cp-att-list" style="display:flex;flex-wrap:wrap;gap:6px"></div>
</div>
</div>
<div id="cp-status" style="font-size:13px;color:var(--muted);margin-top:8px"></div>
</div>
{{end}}
{{define "scripts"}}
<script>
// Parse URL params
const params = new URLSearchParams(location.search);
const replyId = parseInt(params.get('reply_id') || '0');
const forwardId = parseInt(params.get('forward_id') || '0');
const cpAttachments = [];
async function apiCall(method, path, body) {
const opts = { method, headers: {} };
if (body instanceof FormData) { opts.body = body; }
else if (body) { opts.body = JSON.stringify(body); opts.headers['Content-Type'] = 'application/json'; }
const r = await fetch('/api' + path, opts);
return r.ok ? r.json() : null;
}
function esc(s) { return (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
// Tag field (simple comma/enter separated)
function initTagField(id) {
const el = document.getElementById(id);
if (!el) return;
el.innerHTML = '<input class="tag-input" type="email" multiple style="border:none;background:transparent;outline:none;color:var(--text);font-size:13px;min-width:180px;font-family:\'DM Sans\',sans-serif">';
const inp = el.querySelector('input');
inp.addEventListener('keydown', e => {
if (e.key === 'Enter' || e.key === ',' || e.key === 'Tab') {
e.preventDefault();
const v = inp.value.trim().replace(/,$/, '');
if (v) addTagTo(id, v);
inp.value = '';
} else if (e.key === 'Backspace' && !inp.value) {
const tags = el.querySelectorAll('.tag-chip');
if (tags.length) tags[tags.length-1].remove();
}
});
inp.addEventListener('blur', () => {
const v = inp.value.trim().replace(/,$/, '');
if (v) { addTagTo(id, v); inp.value = ''; }
});
}
function addTagTo(fieldId, email) {
const el = document.getElementById(fieldId);
const inp = el.querySelector('input');
const chip = document.createElement('span');
chip.className = 'tag-chip';
chip.style.cssText = 'display:inline-flex;align-items:center;gap:4px;padding:2px 8px;background:var(--accent-dim);color:var(--accent);border-radius:12px;font-size:12px;margin:2px';
chip.innerHTML = `${esc(email)}<span style="cursor:pointer;margin-left:2px" onclick="this.parentNode.remove()">×</span>`;
el.insertBefore(chip, inp);
}
function getTagValues(fieldId) {
const el = document.getElementById(fieldId);
return Array.from(el.querySelectorAll('.tag-chip')).map(c => c.textContent.replace('×','').trim()).filter(Boolean);
}
function addPageAttachments(files) {
for (const f of files) {
cpAttachments.push(f);
const chip = document.createElement('span');
chip.style.cssText = 'font-size:11px;padding:3px 8px;background:var(--surface3);border:1px solid var(--border2);border-radius:4px;color:var(--text2)';
chip.textContent = f.name;
document.getElementById('cp-att-list').appendChild(chip);
}
}
async function loadAccounts() {
const accounts = await apiCall('GET', '/accounts') || [];
const sel = document.getElementById('cp-from');
accounts.forEach(a => {
const opt = document.createElement('option');
opt.value = a.id;
opt.textContent = `${a.display_name || a.email_address} <${a.email_address}>`;
sel.appendChild(opt);
});
}
async function prefillReply() {
if (!replyId) return;
document.getElementById('compose-page-title').textContent = 'Reply';
const msg = await apiCall('GET', '/messages/' + replyId);
if (!msg) return;
document.title = 'Reply: ' + (msg.subject || '') + ' — GoWebMail';
document.getElementById('cp-subject').value = msg.subject?.startsWith('Re:') ? msg.subject : 'Re: ' + (msg.subject || '');
addTagTo('cp-to-tags', msg.from_email || '');
const editor = document.getElementById('cp-editor');
editor.innerHTML = `<br><br><div style="border-left:3px solid #ccc;padding-left:12px;color:#666;margin-top:8px">
<div style="font-size:12px;margin-bottom:4px">On ${msg.date ? new Date(msg.date).toLocaleString() : ''}, ${esc(msg.from_email)} wrote:</div>
${msg.body_html || '<pre>' + (msg.body_text||'') + '</pre>'}
</div>`;
// Set from to same account
if (msg.account_id) {
const sel = document.getElementById('cp-from');
for (const opt of sel.options) { if (parseInt(opt.value) === msg.account_id) { opt.selected = true; break; } }
}
}
async function prefillForward() {
if (!forwardId) return;
document.getElementById('compose-page-title').textContent = 'Forward';
const msg = await apiCall('GET', '/messages/' + forwardId);
if (!msg) return;
document.title = 'Forward: ' + (msg.subject || '') + ' — GoWebMail';
document.getElementById('cp-subject').value = 'Fwd: ' + (msg.subject || '');
const editor = document.getElementById('cp-editor');
editor.innerHTML = `<br><br><div style="border-left:3px solid #ccc;padding-left:12px;color:#666;margin-top:8px">
<div style="font-size:12px;margin-bottom:4px">---------- Forwarded message ----------<br>From: ${esc(msg.from_email)}<br>Subject: ${esc(msg.subject)}</div>
${msg.body_html || '<pre>' + (msg.body_text||'') + '</pre>'}
</div>`;
}
async function sendFromPage() {
const btn = document.getElementById('send-page-btn');
const accountId = parseInt(document.getElementById('cp-from').value || '0');
const to = getTagValues('cp-to-tags');
if (!accountId || !to.length) { document.getElementById('cp-status').textContent = 'From account and To address required.'; return; }
btn.disabled = true; btn.textContent = 'Sending…';
const meta = {
account_id: accountId,
to,
cc: getTagValues('cp-cc-tags'),
bcc: [],
subject: document.getElementById('cp-subject').value,
body_html: document.getElementById('cp-editor').innerHTML,
body_text: document.getElementById('cp-editor').innerText,
in_reply_to_id: replyId || 0,
forward_from_id: forwardId || 0,
};
let r;
const endpoint = replyId ? '/reply' : forwardId ? '/forward' : '/send';
if (cpAttachments.length) {
const fd = new FormData();
fd.append('meta', JSON.stringify(meta));
cpAttachments.forEach(f => fd.append('file', f, f.name));
const resp = await fetch('/api' + endpoint, { method: 'POST', body: fd });
r = await resp.json().catch(() => null);
} else {
r = await apiCall('POST', endpoint, meta);
}
btn.disabled = false; btn.textContent = 'Send';
if (r?.ok) {
document.getElementById('cp-status').innerHTML = '✓ Message sent! <a href="/" style="color:var(--accent)">Back to inbox</a>';
document.getElementById('compose-page-form').style.opacity = '0.5';
document.getElementById('compose-page-form').style.pointerEvents = 'none';
} else {
document.getElementById('cp-status').textContent = r?.error || 'Send failed.';
}
}
async function saveDraft() {
document.getElementById('cp-status').textContent = 'Draft saving not yet supported in standalone view.';
}
// Init
initTagField('cp-to-tags');
initTagField('cp-cc-tags');
loadAccounts();
if (replyId) prefillReply();
else if (forwardId) prefillForward();
</script>
{{end}}

View File

@@ -0,0 +1,95 @@
{{template "base" .}}
{{define "title"}}Message — GoWebMail{{end}}
{{define "body_class"}}app-page{{end}}
{{define "body"}}
<div id="msg-page" style="max-width:860px;margin:0 auto;padding:20px 16px;min-height:100vh">
<div style="display:flex;align-items:center;gap:12px;margin-bottom:18px;padding-bottom:14px;border-bottom:1px solid var(--border)">
<a href="/" style="color:var(--accent);text-decoration:none;font-size:13px;display:flex;align-items:center;gap:4px">
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
Back to GoWebMail
</a>
<span style="color:var(--border);font-size:16px">|</span>
<div id="msg-actions" style="display:flex;gap:8px"></div>
<div style="margin-left:auto;display:flex;gap:6px">
<button class="btn-secondary" id="btn-reply" style="font-size:12px" onclick="replyFromPage()">↩ Reply</button>
<button class="btn-secondary" id="btn-forward" style="font-size:12px" onclick="forwardFromPage()">↪ Forward</button>
</div>
</div>
<div id="msg-content">
<div class="spinner" style="margin-top:80px"></div>
</div>
</div>
{{end}}
{{define "scripts"}}
<script>
const msgId = parseInt(location.pathname.split('/').pop());
async function api(method, path, body) {
const opts = { method, headers: {} };
if (body) { opts.body = JSON.stringify(body); opts.headers['Content-Type'] = 'application/json'; }
const r = await fetch('/api' + path, opts);
return r.ok ? r.json() : null;
}
function esc(s) { return (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
async function load() {
const msg = await api('GET', '/messages/' + msgId);
if (!msg) { document.getElementById('msg-content').innerHTML = '<p style="color:var(--danger)">Message not found or not accessible.</p>'; return; }
// Mark read
await api('PUT', '/messages/' + msgId + '/read', { read: true });
document.title = (msg.subject || '(no subject)') + ' — GoWebMail';
const atts = msg.attachments || [];
const attHtml = atts.length ? `
<div style="padding:12px 0;border-top:1px solid var(--border);display:flex;flex-wrap:wrap;gap:8px">
${atts.map(a => `<a href="/api/messages/${msgId}/attachments/${a.id}" download="${esc(a.filename)}"
style="display:inline-flex;align-items:center;gap:6px;padding:5px 10px;background:var(--surface3);
border:1px solid var(--border2);border-radius:6px;font-size:12px;color:var(--text);text-decoration:none">
📎 ${esc(a.filename)} <span style="color:var(--muted)">(${(a.size/1024).toFixed(0)}KB)</span></a>`).join('')}
</div>` : '';
document.getElementById('msg-content').innerHTML = `
<h1 style="font-size:22px;font-weight:600;margin-bottom:16px;line-height:1.3">${esc(msg.subject || '(no subject)')}</h1>
<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:16px;flex-wrap:wrap;gap:8px">
<div>
<span style="font-size:14px;font-weight:500">${esc(msg.from_name || msg.from_email)}</span>
${msg.from_name ? `<span style="font-size:13px;color:var(--muted)">&lt;${esc(msg.from_email)}&gt;</span>` : ''}
<div style="font-size:12px;color:var(--muted);margin-top:2px">To: ${esc(msg.to_list || '')}</div>
</div>
<span style="font-size:12px;color:var(--muted);white-space:nowrap">${esc(msg.date ? new Date(msg.date).toLocaleString() : '')}</span>
</div>
<div style="border:1px solid var(--border);border-radius:8px;overflow:hidden;margin-bottom:12px">
<iframe id="msg-iframe" sandbox="allow-same-origin" style="width:100%;border:none;min-height:400px;background:white"></iframe>
</div>
${attHtml}`;
// Write body into sandboxed iframe
const iframe = document.getElementById('msg-iframe');
const doc = iframe.contentDocument || iframe.contentWindow.document;
doc.open();
doc.write(`<!DOCTYPE html><html><head><style>
body{font-family:sans-serif;font-size:14px;line-height:1.6;padding:16px;margin:0;color:#111;word-break:break-word}
img{max-width:100%;height:auto}a{color:#0078D4}
</style></head><body>${msg.body_html || '<pre style="white-space:pre-wrap">' + (msg.body_text||'') + '</pre>'}</body></html>`);
doc.close();
// Auto-resize iframe
setTimeout(() => {
try { iframe.style.height = (doc.documentElement.scrollHeight + 20) + 'px'; } catch(e) {}
}, 200);
}
function replyFromPage() {
window.location = '/?action=reply&id=' + msgId;
}
function forwardFromPage() {
window.location = '/?action=forward&id=' + msgId;
}
load();
</script>
{{end}}