diff --git a/README.md b/README.md index 5ac5cab..ad15f72 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ DELETE FROM users WHERE name = 'bob'; ```sql DELETE FROM calendars WHERE name = 'bob_calendar' AND owner_id = (SELECT id FROM users WHERE name = 'bob'); /* or */ -DELETE FROM calendars WHERE ; +DELETE FROM calendars WHERE path = '/bob/calendars/old/'; ``` ## rename calendar ```sql diff --git a/config.yaml b/config.yaml index e19084b..3db4090 100644 --- a/config.yaml +++ b/config.yaml @@ -4,11 +4,18 @@ server: bind_address: 0.0.0.0:14243 default_class: CONFIDENTIAL public_url: http://nxc.nx2.site/ + email_domain: nx2.site redaction_text: '[-]' +smtp: + host: localhost + port: 587 + user: nxcaldav@nx2.site + password: Vastly-Wrinkle9-Corsage + users: - name: daniel - password: Cyclist-Hypnotize7-Blurb + password: ll groups: - family - name: lennart diff --git a/internal/backend/db.go b/internal/backend/db.go index 4966728..a631b10 100644 --- a/internal/backend/db.go +++ b/internal/backend/db.go @@ -34,8 +34,11 @@ type publicInfo struct { type DBBackend struct { pool *pgxpool.Pool // Connection pool to PostgreSQL prefix string // Public URL base path prefix + publicURL string // Full public URL base (e.g. http://nxc.nx2.site/) redactionText string // Text used to hide confidential event details (e.g. "[REDACED]") - defaultClass string // Class assumed if non is set ("PUBLIC", "PRIVATE", "CONFIDENTIAL") + defaultClass string // Class assumed if non is set ("PUBLIC", "PRIVATE", "CONFIDENTIAL") + emailDomain string // Domain for email addresses (e.g., "nx2.site") + smtp config.SMTPConfig // SMTP server configuration aggregates map[string]*config.Aggregate // In-memory map of path -> virtual calendar definitions userAggs map[string][]string // In-memory map of user -> list of aggregate paths they can see publicAccess map[string]publicInfo // In-memory map of public path -> internal info @@ -54,8 +57,11 @@ func NewDBBackend(ctx context.Context, cfg *config.Config) (*DBBackend, error) { b := &DBBackend{ pool: pool, prefix: cfg.Server.BasePath(), + publicURL: cfg.Server.PublicURL, redactionText: cfg.Server.Redaction, defaultClass: cfg.Server.DefaultClass, + emailDomain: cfg.Server.EmailDomain, + smtp: cfg.SMTP, aggregates: make(map[string]*config.Aggregate), userAggs: make(map[string][]string), publicAccess: make(map[string]publicInfo), @@ -912,6 +918,88 @@ func (b *DBBackend) PutCalendarObject(ctx context.Context, p string, calendar *i return nil, err } + // Step 3: Handle Status Propagation & Invitations + username, _ := b.getUsername(ctx) + userEmail := strings.ToLower(username + "@" + b.emailDomain) + + for _, event := range calendar.Events() { + organizer := event.Props.Get("ORGANIZER") + orgEmail := "" + if organizer != nil { + orgEmail = strings.TrimPrefix(strings.ToLower(organizer.Value), "mailto:") + } + + // --- Case A: User is the ATTENDEE updating their status --- + // If the user is NOT the organizer, but is an attendee, find the organizer's + // original event and update the status there. + if orgEmail != "" && orgEmail != userEmail { + log.Printf("[scheduling] Attendee %s updated event. Propagating to organizer %s", userEmail, orgEmail) + + // Find the attendee's status in this version + myStatus := "NEEDS-ACTION" + for _, att := range event.Props["ATTENDEE"] { + if strings.TrimPrefix(strings.ToLower(att.Value), "mailto:") == userEmail { + if stat := att.Params.Get("PARTSTAT"); stat != "" { + myStatus = stat + } + break + } + } + + // Find the UID of this event to locate the organizer's copy + uid := "" + if u := event.Props.Get("UID"); u != nil { + uid = u.Value + } + + if uid != "" { + // Search for the organizer's copy in their calendars + go b.propagateStatusToOrganizer(orgEmail, userEmail, uid, myStatus) + } + continue // Don't send invitations from an attendee's PUT + } + + // --- Case B: User is the ORGANIZER (Sending Invitations) --- + // Only send invites if the user is the organizer and we are in the owner's calendar + isOwnerCalendar := strings.Contains(p, "/"+username+"/") + if !isOwnerCalendar || (orgEmail != "" && orgEmail != userEmail) { + continue + } + + attendees := event.Props["ATTENDEE"] + if len(attendees) == 0 { + continue + } + + summary := "" + if s := event.Props.Get("SUMMARY"); s != nil { + summary = s.Value + } + description := "" + if d := event.Props.Get("DESCRIPTION"); d != nil { + description = d.Value + } + start := "" + if dtstart, err := event.DateTimeStart(time.UTC); err == nil { + start = dtstart.Format(time.RFC1123) + } + end := "" + if dtend, err := event.DateTimeEnd(time.UTC); err == nil { + end = dtend.Format(time.RFC1123) + } + + for _, attendee := range attendees { + recipientEmail := strings.TrimPrefix(attendee.Value, "mailto:") + if recipientEmail == "" { + continue + } + // Only send if it's a valid looking email and not the sender themselves + if strings.Contains(recipientEmail, "@") && !strings.HasPrefix(recipientEmail, username+"@") { + go b.sendInvitation(username, recipientEmail, summary, description, start, end, p, dataStr) + } + } + } + return &caldav.CalendarObject{ Path: p, Data: calendar, @@ -942,6 +1030,125 @@ func (b *DBBackend) DeleteCalendarObject(ctx context.Context, p string) error { return nil } +// propagateStatusToOrganizer finds the original event in the organizer's calendar +// and updates the attendee's status. +func (b *DBBackend) propagateStatusToOrganizer(orgEmail, attendeeEmail, uid, status string) { + ctx := context.Background() + log.Printf("[scheduling] Searching for original event UID %s for organizer %s", uid, orgEmail) + + // 1. Find the organizer's user ID + var orgUserID int + err := b.pool.QueryRow(ctx, "SELECT id FROM users WHERE name = $1 OR name = $2", + strings.Split(orgEmail, "@")[0], orgEmail).Scan(&orgUserID) + if err != nil { + log.Printf("[scheduling] Could not find organizer user %s: %v", orgEmail, err) + return + } + + // 2. Find the calendar object by UID within organizer's calendars + rows, err := b.pool.Query(ctx, ` + SELECT co.path, co.data, co.calendar_id + FROM calendar_objects co + JOIN calendars c ON co.calendar_id = c.id + WHERE c.owner_id = $1 AND co.data LIKE '%' || $2 || '%'`, + orgUserID, uid) + if err != nil { + log.Printf("[scheduling] Error searching for organizer's copy: %v", err) + return + } + defer rows.Close() + + for rows.Next() { + var p, dataStr string + var calID int + if err := rows.Scan(&p, &dataStr, &calID); err != nil { continue } + + // Verify UID (LIKE is just a hint) + calendar, err := ical.NewDecoder(strings.NewReader(dataStr)).Decode() + if err != nil { continue } + + found := false + for _, event := range calendar.Events() { + if u := event.Props.Get("UID"); u != nil && u.Value == uid { + // Update attendee status + for _, att := range event.Props["ATTENDEE"] { + if strings.TrimPrefix(strings.ToLower(att.Value), "mailto:") == attendeeEmail { + log.Printf("[scheduling] Updating %s status to %s in organizer's copy %s", attendeeEmail, status, p) + att.Params.Set("PARTSTAT", status) + found = true + } + } + } + } + + if found { + var buf bytes.Buffer + if err := ical.NewEncoder(&buf).Encode(calendar); err == nil { + newEtag := fmt.Sprintf(`"%d-%d"`, time.Now().Unix(), buf.Len()) + b.pool.Exec(ctx, "UPDATE calendar_objects SET data = $1, etag = $2 WHERE calendar_id = $3 AND path = $4", + buf.String(), newEtag, calID, p) + log.Printf("[scheduling] Organizer's copy %s updated successfully", p) + } + } + } +} + +// RespondToInvitation handles an attendee's Accept/Decline response. +func (b *DBBackend) RespondToInvitation(ctx context.Context, p, attendeeEmail, status string) error { + log.Printf("[email] Response for %s from %s: %s", p, attendeeEmail, status) + + // 1. Fetch the calendar object + var dataStr string + var calID int + err := b.pool.QueryRow(ctx, "SELECT calendar_id, data FROM calendar_objects WHERE path = $1", p).Scan(&calID, &dataStr) + if err != nil { + return fmt.Errorf("failed to find calendar object: %v", err) + } + + calendar, err := ical.NewDecoder(strings.NewReader(dataStr)).Decode() + if err != nil { + return fmt.Errorf("failed to decode calendar data: %v", err) + } + + // 2. Update PARTSTAT for the attendee + modified := false + status = strings.ToUpper(status) + attendeeEmail = strings.ToLower(strings.TrimSpace(attendeeEmail)) + + for _, event := range calendar.Events() { + attendees := event.Props["ATTENDEE"] + for _, attendee := range attendees { + email := strings.TrimPrefix(strings.ToLower(attendee.Value), "mailto:") + if email == attendeeEmail { + attendee.Params.Set("PARTSTAT", status) + modified = true + } + } + } + + if !modified { + return fmt.Errorf("attendee %s not found in event", attendeeEmail) + } + + // 3. Save back to DB + var buf bytes.Buffer + if err := ical.NewEncoder(&buf).Encode(calendar); err != nil { + return err + } + newDataStr := buf.String() + // Use a timestamp + length for a unique ETag + newEtag := fmt.Sprintf(`"%d-%d"`, time.Now().Unix(), len(newDataStr)) + + _, err = b.pool.Exec(ctx, "UPDATE calendar_objects SET data = $1, etag = $2 WHERE calendar_id = $3 AND path = $4", + newDataStr, newEtag, calID, p) + if err != nil { + return fmt.Errorf("failed to update calendar object in DB: %v", err) + } + + log.Printf("[email] Successfully updated status to %s for %s in %s", status, attendeeEmail, p) + return nil +} + // QueryCalendarObjects filters items based on a CalDAV query (e.g. time range). // Currently, it just lists all objects and lets the client filter, but // we use it to enforce privacy rules for the initial report. diff --git a/internal/backend/email.go b/internal/backend/email.go new file mode 100644 index 0000000..c18cf9b --- /dev/null +++ b/internal/backend/email.go @@ -0,0 +1,119 @@ +package backend + +import ( + "bytes" + "crypto/tls" + "fmt" + "log" + "net/smtp" + "net/url" + "strings" + + "github.com/emersion/go-ical" +) + +// sendInvitation sends an iMIP (RFC 6047) invitation email. +// It includes a plain-text fallback and a METHOD:REQUEST iCalendar attachment +// that calendar clients (Thunderbird, Apple, etc.) will recognize. +func (b *DBBackend) sendInvitation(senderName, recipientEmail, summary, description, start, end, objectPath, originalICS string) error { + fromAddr := fmt.Sprintf("%s@%s", senderName, b.emailDomain) + if b.smtp.User != "" { + fromAddr = b.smtp.User + } + fromHeader := fmt.Sprintf("%s <%s>", senderName, fromAddr) + + baseURL := strings.TrimSuffix(b.publicURL, "/") + acceptURL := fmt.Sprintf("%s/respond?path=%s&attendee=%s&status=ACCEPTED", baseURL, url.QueryEscape(objectPath), url.QueryEscape(recipientEmail)) + declineURL := fmt.Sprintf("%s/respond?path=%s&attendee=%s&status=DECLINED", baseURL, url.QueryEscape(objectPath), url.QueryEscape(recipientEmail)) + + // 1. Prepare plain-text fallback - with prominent links + textPart := "PLEASE RESPOND TO THIS INVITATION:\r\n" + textPart += fmt.Sprintf("✅ ACCEPT: %s\r\n", acceptURL) + textPart += fmt.Sprintf("❌ DECLINE: %s\r\n", declineURL) + textPart += "\r\n------------------------------------------\r\n\r\n" + textPart += fmt.Sprintf("You have been invited to an event by %s.\r\n\r\n", senderName) + textPart += fmt.Sprintf("Event: %s\r\n", summary) + + // 2. Prepare iCalendar part with METHOD:REQUEST + var icsContent string + calendar, err := ical.NewDecoder(strings.NewReader(originalICS)).Decode() + if err == nil { + calendar.Props.SetText("METHOD", "REQUEST") + // Discourage clients from sending their own response emails + for _, event := range calendar.Events() { + for _, attendee := range event.Props["ATTENDEE"] { + attendee.Params.Set("RSVP", "FALSE") + } + } + var buf bytes.Buffer + if err := ical.NewEncoder(&buf).Encode(calendar); err == nil { + icsContent = buf.String() + } + } + if icsContent == "" { + icsContent = originalICS // Fallback to raw if decoding failed + } + + // 3. Construct Multipart MIME Email + boundary := "nxcaldav_invite_boundary" + subject := fmt.Sprintf("Invitation: %s", summary) + + header := fmt.Sprintf("Subject: %s\r\n", subject) + header += fmt.Sprintf("From: %s\r\n", fromHeader) + header += fmt.Sprintf("To: %s\r\n", recipientEmail) + header += "MIME-Version: 1.0\r\n" + header += fmt.Sprintf("Content-Type: multipart/mixed; boundary=\"%s\"\r\n", boundary) + header += "\r\n" + + body := fmt.Sprintf("--%s\r\n", boundary) + body += "Content-Type: text/plain; charset=UTF-8\r\n" + body += "Content-Transfer-Encoding: 7bit\r\n" + body += "\r\n" + body += textPart + "\r\n" + + body += fmt.Sprintf("--%s\r\n", boundary) + body += "Content-Type: text/calendar; method=REQUEST; charset=UTF-8\r\n" + body += "Content-Transfer-Encoding: 7bit\r\n" + body += "\r\n" + body += icsContent + "\r\n" + body += fmt.Sprintf("--%s--\r\n", boundary) + + // 4. Send the mail + addr := fmt.Sprintf("%s:%d", b.smtp.Host, b.smtp.Port) + tlsConfig := &tls.Config{InsecureSkipVerify: true, ServerName: b.smtp.Host} + + var c *smtp.Client + if b.smtp.Port == 465 { + conn, err := tls.Dial("tcp", addr, tlsConfig) + if err != nil { return err } + c, err = smtp.NewClient(conn, b.smtp.Host) + } else { + c, err = smtp.Dial(addr) + } + if err != nil { return err } + defer c.Close() + + if err = c.Hello("localhost"); err != nil { return err } + if b.smtp.Port != 465 { + if ok, _ := c.Extension("STARTTLS"); ok { + if err = c.StartTLS(tlsConfig); err != nil { return err } + } + } + if b.smtp.User != "" && b.smtp.Password != "" { + auth := smtp.PlainAuth("", b.smtp.User, b.smtp.Password, b.smtp.Host) + if err = c.Auth(auth); err != nil { return err } + } + + if err = c.Mail(fromAddr); err != nil { return err } + if err = c.Rcpt(recipientEmail); err != nil { return err } + + w, err := c.Data() + if err != nil { return err } + _, err = w.Write([]byte(header + body)) + if err != nil { return err } + err = w.Close() + if err != nil { return err } + + log.Printf("[email] Successfully sent iMIP invitation to %s", recipientEmail) + return c.Quit() +} diff --git a/internal/config/config.go b/internal/config/config.go index 0aa0756..2ed4cbc 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -46,6 +46,7 @@ type DatabaseConfig struct { type ServerConfig struct { BindAddress string `yaml:"bind_address"` PublicURL string `yaml:"public_url"` + EmailDomain string `yaml:"email_domain"` Redaction string `yaml:"redaction_text"` DefaultClass string `yaml:"default_class"` } @@ -58,9 +59,17 @@ func (s ServerConfig) BasePath() string { return strings.TrimSuffix(u.Path, "/") } +type SMTPConfig struct { + Host string `yaml:"host"` + Port int `yaml:"port"` + User string `yaml:"user"` + Password string `yaml:"password"` +} + type Config struct { Server ServerConfig `yaml:"server"` Database DatabaseConfig `yaml:"database"` + SMTP SMTPConfig `yaml:"smtp"` Users []User `yaml:"users"` Calendars []Calendar `yaml:"calendars"` Aggregates []Aggregate `yaml:"aggregates"` @@ -100,7 +109,16 @@ func (c *Config) setDefaults() { if c.Server.DefaultClass == "" { c.Server.DefaultClass = "CONFIDENTIAL" } + if c.Server.EmailDomain == "" { + c.Server.EmailDomain = "nx2.site" + } if c.Database.URL == "" { c.Database.URL = "postgres://nxcaldav@localhost:5432/nxcaldav?sslmode=disable" } + if c.SMTP.Host == "" { + c.SMTP.Host = "localhost" + } + if c.SMTP.Port == 0 { + c.SMTP.Port = 25 + } } diff --git a/main.go b/main.go index b445bbe..37159d9 100644 --- a/main.go +++ b/main.go @@ -40,6 +40,27 @@ func main() { publicURL, _ := url.Parse(cfg.Server.PublicURL) + http.HandleFunc("/respond", func(w http.ResponseWriter, r *http.Request) { + p := r.URL.Query().Get("path") + attendee := r.URL.Query().Get("attendee") + status := r.URL.Query().Get("status") + + if p == "" || attendee == "" || status == "" { + http.Error(w, "Missing parameters", http.StatusBadRequest) + return + } + + err := be.RespondToInvitation(r.Context(), p, attendee, status) + if err != nil { + log.Printf("[email] Error handling response: %v", err) + http.Error(w, fmt.Sprintf("Error: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/html") + fmt.Fprintf(w, "

Response recorded

You have %s the invitation for %s.

", strings.ToLower(status), attendee) + }) + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { // Proxy-aware normalization: if publicURL != nil && publicURL.Host != "" {