mirror of
https://github.com/ghostersk/gowebmail.git
synced 2026-04-17 08:36:01 +01:00
added calendar and contact - basic
This commit is contained in:
@@ -1,13 +1,16 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -75,6 +78,17 @@ func main() {
|
|||||||
}
|
}
|
||||||
logger.Init(cfg.Debug)
|
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)
|
database, err := db.New(cfg.DBPath, cfg.EncryptionKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("database init: %v", err)
|
log.Fatalf("database init: %v", err)
|
||||||
@@ -155,6 +169,8 @@ func main() {
|
|||||||
app := r.PathPrefix("").Subrouter()
|
app := r.PathPrefix("").Subrouter()
|
||||||
app.Use(middleware.RequireAuth(database, cfg))
|
app.Use(middleware.RequireAuth(database, cfg))
|
||||||
app.HandleFunc("/", h.App.Index).Methods("GET")
|
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
|
// Admin UI
|
||||||
adminUI := r.PathPrefix("/admin").Subrouter()
|
adminUI := r.PathPrefix("/admin").Subrouter()
|
||||||
@@ -244,6 +260,28 @@ func main() {
|
|||||||
// Search
|
// Search
|
||||||
api.HandleFunc("/search", h.API.Search).Methods("GET")
|
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
|
// Admin API
|
||||||
adminAPI := r.PathPrefix("/api/admin").Subrouter()
|
adminAPI := r.PathPrefix("/api/admin").Subrouter()
|
||||||
adminAPI.Use(middleware.RequireAuth(database, cfg))
|
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).
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import (
|
|||||||
|
|
||||||
// GmailScopes are the OAuth2 scopes required for full Gmail access.
|
// GmailScopes are the OAuth2 scopes required for full Gmail access.
|
||||||
var GmailScopes = []string{
|
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.email",
|
||||||
"https://www.googleapis.com/auth/userinfo.profile",
|
"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.
|
// MicrosoftUserInfo holds user info extracted from the Microsoft ID token.
|
||||||
type MicrosoftUserInfo struct {
|
type MicrosoftUserInfo struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
DisplayName string `json:"displayName"` // Graph field
|
DisplayName string `json:"displayName"` // Graph field
|
||||||
Name string `json:"name"` // ID token claim
|
Name string `json:"name"` // ID token claim
|
||||||
Mail string `json:"mail"`
|
Mail string `json:"mail"`
|
||||||
EmailClaim string `json:"email"` // ID token claim
|
EmailClaim string `json:"email"` // ID token claim
|
||||||
UserPrincipalName string `json:"userPrincipalName"`
|
UserPrincipalName string `json:"userPrincipalName"`
|
||||||
PreferredUsername string `json:"preferred_username"` // ID token claim
|
PreferredUsername string `json:"preferred_username"` // ID token claim
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
package db
|
package db
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -248,6 +250,62 @@ func (d *DB) Migrate() error {
|
|||||||
return fmt.Errorf("create user_ip_rules: %w", err)
|
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
|
// Bootstrap admin account if no users exist
|
||||||
return d.bootstrapAdmin()
|
return d.bootstrapAdmin()
|
||||||
}
|
}
|
||||||
@@ -2185,3 +2243,267 @@ func (d *DB) ListIPBlocksWithUsername() ([]IPBlockWithUsername, error) {
|
|||||||
}
|
}
|
||||||
return result, rows.Err()
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,3 +17,13 @@ type AppHandler struct {
|
|||||||
func (h *AppHandler) Index(w http.ResponseWriter, r *http.Request) {
|
func (h *AppHandler) Index(w http.ResponseWriter, r *http.Request) {
|
||||||
h.renderer.Render(w, "app", nil)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
309
internal/handlers/contacts_calendar.go
Normal file
309
internal/handlers/contacts_calendar.go
Normal 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")
|
||||||
|
}
|
||||||
@@ -26,6 +26,8 @@ func NewRenderer() (*Renderer, error) {
|
|||||||
"login.html",
|
"login.html",
|
||||||
"mfa.html",
|
"mfa.html",
|
||||||
"admin.html",
|
"admin.html",
|
||||||
|
"message.html",
|
||||||
|
"compose.html",
|
||||||
}
|
}
|
||||||
templateFS, err := fs.Sub(gowebmail.WebFS, "web/templates")
|
templateFS, err := fs.Sub(gowebmail.WebFS, "web/templates")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -243,3 +243,49 @@ type PagedMessages struct {
|
|||||||
PageSize int `json:"page_size"`
|
PageSize int `json:"page_size"`
|
||||||
HasMore bool `json:"has_more"`
|
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"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -569,3 +569,52 @@ body.admin-page{overflow:auto;background:var(--bg)}
|
|||||||
/* Hide floating minimised bar on mobile, use back button instead */
|
/* Hide floating minimised bar on mobile, use back button instead */
|
||||||
.compose-minimised{display:none!important}
|
.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)}
|
||||||
|
|||||||
@@ -71,6 +71,25 @@ async function init() {
|
|||||||
}
|
}
|
||||||
if (p.get('error')) { toast('Connection failed: '+p.get('error'), 'error'); history.replaceState({},'','/'); }
|
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 => {
|
document.addEventListener('keydown', e => {
|
||||||
if (['INPUT','TEXTAREA','SELECT'].includes(e.target.tagName)) return;
|
if (['INPUT','TEXTAREA','SELECT'].includes(e.target.tagName)) return;
|
||||||
if (e.target.contentEditable === 'true') return;
|
if (e.target.contentEditable === 'true') return;
|
||||||
@@ -1128,6 +1147,8 @@ function showMessageMenu(e, id) {
|
|||||||
<div class="ctx-submenu">${moveItems}</div>
|
<div class="ctx-submenu">${moveItems}</div>
|
||||||
</div>` : '';
|
</div>` : '';
|
||||||
showCtxMenu(e,`
|
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="openReplyTo(${id});closeMenu()">↩ Reply</div>
|
||||||
<div class="ctx-item" onclick="toggleStar(${id});closeMenu()">${msg?.is_starred?'★ Unstar':'☆ Star'}</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>
|
<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');
|
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';
|
||||||
|
}
|
||||||
|
|||||||
411
web/static/js/contacts_calendar.js
Normal file
411
web/static/js/contacts_calendar.js
Normal 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'));
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<span class="mob-title" id="mob-title">GoWebMail</span>
|
<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="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>
|
</div>
|
||||||
|
|
||||||
<!-- Sidebar -->
|
<!-- 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>
|
<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>
|
<span class="logo-text"><a href="/">GoWebMail</a></span>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="nav-section">
|
<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>
|
<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
|
Starred
|
||||||
</div>
|
</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 id="folders-by-account"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -103,6 +121,105 @@
|
|||||||
<p>Choose a message from the list to read it</p>
|
<p>Choose a message from the list to read it</p>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</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">‹</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">›</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>
|
</div>
|
||||||
|
|
||||||
<!-- ── Accounts submenu popup ──────────────────────────────────────────────── -->
|
<!-- ── Accounts submenu popup ──────────────────────────────────────────────── -->
|
||||||
@@ -397,5 +514,6 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "scripts"}}
|
{{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}}
|
{{end}}
|
||||||
|
|||||||
@@ -5,12 +5,12 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{{block "title" .}}GoWebMail{{end}}</title>
|
<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 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}}
|
{{block "head_extra" .}}{{end}}
|
||||||
</head>
|
</head>
|
||||||
<body class="{{block "body_class" .}}{{end}}">
|
<body class="{{block "body_class" .}}{{end}}">
|
||||||
{{block "body" .}}{{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}}
|
{{block "scripts" .}}{{end}}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
220
web/templates/compose.html
Normal file
220
web/templates/compose.html
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||||
|
|
||||||
|
// 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}}
|
||||||
95
web/templates/message.html
Normal file
95
web/templates/message.html
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||||
|
|
||||||
|
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)"><${esc(msg.from_email)}></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}}
|
||||||
Reference in New Issue
Block a user