Files
mailgoserver/internal/webui/caldav.go
T
2026-08-20 19:42:20 +01:00

343 lines
11 KiB
Go

// CalDAV server (RFC 4791) — exposes a mailbox owner's calendar (events + reminders)
// for sync from Apple Calendar, Thunderbird, DAVx5, etc. Structurally mirrors
// carddav.go throughout (same author/family library, same ctx-based auth pattern via
// davMailboxFromCtx, same path-hierarchy depth convention). Reminders round-trip as a
// VALARM sub-component so a real CalDAV client fires its own native OS notification —
// this server has no push/email notification system of its own (see the Calendar
// plan's explicit scope note).
package webui
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"path"
"strings"
"time"
"github.com/emersion/go-ical"
"github.com/emersion/go-webdav"
"github.com/emersion/go-webdav/caldav"
"github.com/teambition/rrule-go"
"mailgoserver/internal/db"
)
const caldavPrefix = "/caldav"
// icalProdID identifies this server as the VCALENDAR's producer, per RFC 5545 3.7.3 —
// required (checkComponent rejects an encode without it), arbitrary otherwise.
const icalProdID = "-//mailgoserver//Calendar//EN"
func caldavPrincipalPath(email string) string {
return caldavPrefix + "/" + url.PathEscape(email) + "/"
}
func caldavHomeSetPath(email string) string {
return caldavPrincipalPath(email) + "calendars/"
}
// caldavCalendarPath is the one, fixed calendar every mailbox gets — no
// multi-calendar UI exists in this app, same single-collection shape as CardDAV's one
// address book.
func caldavCalendarPath(email string) string {
return caldavHomeSetPath(email) + "default/"
}
func caldavObjectPath(email, uid string) string {
return caldavCalendarPath(email) + uid + ".ics"
}
// caldavUIDFromPath extracts the uid segment from a calendar-object request path (the
// last path segment, minus its ".ics" extension) — the inverse of caldavObjectPath.
func caldavUIDFromPath(p string) string {
return strings.TrimSuffix(path.Base(p), ".ics")
}
type caldavBackend struct {
DB *db.DB
}
func (b *caldavBackend) CurrentUserPrincipal(ctx context.Context) (string, error) {
mbox, err := davMailboxFromCtx(ctx)
if err != nil {
return "", err
}
return caldavPrincipalPath(mbox.Email), nil
}
func (b *caldavBackend) CalendarHomeSetPath(ctx context.Context) (string, error) {
mbox, err := davMailboxFromCtx(ctx)
if err != nil {
return "", err
}
return caldavHomeSetPath(mbox.Email), nil
}
func (b *caldavBackend) calendar(mbox *db.Mailbox) caldav.Calendar {
return caldav.Calendar{
Path: caldavCalendarPath(mbox.Email),
Name: "Calendar",
Description: "Calendar for " + mbox.Email,
SupportedComponentSet: []string{ical.CompEvent},
}
}
func (b *caldavBackend) ListCalendars(ctx context.Context) ([]caldav.Calendar, error) {
mbox, err := davMailboxFromCtx(ctx)
if err != nil {
return nil, err
}
return []caldav.Calendar{b.calendar(mbox)}, nil
}
func (b *caldavBackend) GetCalendar(ctx context.Context, p string) (*caldav.Calendar, error) {
mbox, err := davMailboxFromCtx(ctx)
if err != nil {
return nil, err
}
if p != caldavCalendarPath(mbox.Email) {
return nil, webdav.NewHTTPError(http.StatusNotFound, errors.New("no such calendar"))
}
cal := b.calendar(mbox)
return &cal, nil
}
// CreateCalendar: unsupported — every mailbox has exactly one fixed calendar, matching
// this app's own Calendar page, which has no concept of multiple calendars either (no
// DeleteCalendar exists in caldav.Backend to implement — the interface itself assumes
// a calendar, once created, persists).
func (b *caldavBackend) CreateCalendar(ctx context.Context, calendar *caldav.Calendar) error {
return webdav.NewHTTPError(http.StatusForbidden, errors.New("creating calendars is not supported"))
}
// eventToICal builds the VCALENDAR a CalDAV client sees for one stored event —
// PRODID/VERSION set once, one VEVENT child with a VALARM appended when a reminder is
// set (Props.SetDuration on TRIGGER encodes "N minutes before" as a negative
// duration, the standard RFC 5545 relative-trigger form).
func eventToICal(e db.CalendarEvent) *ical.Calendar {
cal := ical.NewCalendar()
cal.Props.SetText(ical.PropProductID, icalProdID)
cal.Props.SetText(ical.PropVersion, "2.0")
event := ical.NewEvent()
event.Props.SetText(ical.PropUID, e.UID)
event.Props.SetDateTime(ical.PropDateTimeStamp, e.UpdatedAt)
event.Props.SetText(ical.PropSummary, e.Summary)
if e.Description != "" {
event.Props.SetText(ical.PropDescription, e.Description)
}
if e.Location != "" {
event.Props.SetText(ical.PropLocation, e.Location)
}
if e.AllDay {
event.Props.SetDate(ical.PropDateTimeStart, e.StartAt)
event.Props.SetDate(ical.PropDateTimeEnd, e.EndAt)
} else {
event.Props.SetDateTime(ical.PropDateTimeStart, e.StartAt)
event.Props.SetDateTime(ical.PropDateTimeEnd, e.EndAt)
}
if e.RRule != "" {
if opt, err := rrule.StrToROption(e.RRule); err == nil {
event.Props.SetRecurrenceRule(opt)
}
}
if e.Color != "" {
event.Props.SetText(ical.PropColor, e.Color)
}
if e.ReminderMinutes != nil {
alarm := ical.NewComponent(ical.CompAlarm)
alarm.Props.SetText(ical.PropAction, "DISPLAY")
alarm.Props.SetText(ical.PropDescription, "Reminder")
trigger := ical.NewProp(ical.PropTrigger)
trigger.SetDuration(-time.Duration(*e.ReminderMinutes) * time.Minute)
alarm.Props.Set(trigger)
event.Children = append(event.Children, alarm)
}
cal.Children = append(cal.Children, event.Component)
return cal
}
// icalToEvent is eventToICal's inverse — the PUT-side parse. Rejects anything
// caldav.ValidateCalendarObject wouldn't accept (multiple UIDs, a non-VEVENT
// component, etc. — the standard CalDAV single-object-resource rule) before reading
// fields off the one VEVENT.
func icalToEvent(cal *ical.Calendar) (summary, description, location string, start, end time.Time, allDay bool, rrule, color string, reminderMinutes *int, err error) {
eventType, _, err := caldav.ValidateCalendarObject(cal)
if err != nil {
return "", "", "", time.Time{}, time.Time{}, false, "", "", nil, err
}
if eventType != ical.CompEvent {
return "", "", "", time.Time{}, time.Time{}, false, "", "", nil, fmt.Errorf("caldav: only VEVENT is supported, got %q", eventType)
}
events := cal.Events()
if len(events) == 0 {
return "", "", "", time.Time{}, time.Time{}, false, "", "", nil, errors.New("caldav: no VEVENT in calendar object")
}
event := events[0]
summary, _ = event.Props.Text(ical.PropSummary)
description, _ = event.Props.Text(ical.PropDescription)
location, _ = event.Props.Text(ical.PropLocation)
color, _ = event.Props.Text(ical.PropColor)
startProp := event.Props.Get(ical.PropDateTimeStart)
if startProp == nil {
return "", "", "", time.Time{}, time.Time{}, false, "", "", nil, errors.New("caldav: VEVENT has no DTSTART")
}
allDay = startProp.ValueType() == ical.ValueDate
start, err = event.DateTimeStart(time.UTC)
if err != nil {
return "", "", "", time.Time{}, time.Time{}, false, "", "", nil, err
}
end, err = event.DateTimeEnd(time.UTC)
if err != nil {
return "", "", "", time.Time{}, time.Time{}, false, "", "", nil, err
}
if end.IsZero() || !end.After(start) {
end = start.Add(time.Hour)
}
if opt, roErr := event.Props.RecurrenceRule(); roErr == nil && opt != nil {
rrule = opt.RRuleString()
}
for _, child := range event.Children {
if child.Name != ical.CompAlarm {
continue
}
trigger := child.Props.Get(ical.PropTrigger)
if trigger == nil {
continue
}
if dur, durErr := trigger.Duration(); durErr == nil {
minutes := int(-dur / time.Minute)
reminderMinutes = &minutes
}
}
return summary, description, location, start, end, allDay, rrule, color, reminderMinutes, nil
}
func eventETag(e db.CalendarEvent) string {
return fmt.Sprintf("%d-%d", e.ID, e.UpdatedAt.Unix())
}
func eventToCalendarObject(email string, e db.CalendarEvent) caldav.CalendarObject {
return caldav.CalendarObject{
Path: caldavObjectPath(email, e.UID),
ModTime: e.UpdatedAt,
ETag: eventETag(e),
Data: eventToICal(e),
}
}
func (b *caldavBackend) GetCalendarObject(ctx context.Context, p string, req *caldav.CalendarCompRequest) (*caldav.CalendarObject, error) {
mbox, err := davMailboxFromCtx(ctx)
if err != nil {
return nil, err
}
e, err := b.DB.GetEventByUID(mbox.ID, caldavUIDFromPath(p))
if err != nil {
return nil, err
}
if e == nil {
return nil, webdav.NewHTTPError(http.StatusNotFound, errors.New("no such event"))
}
co := eventToCalendarObject(mbox.Email, *e)
return &co, nil
}
func (b *caldavBackend) ListCalendarObjects(ctx context.Context, p string, req *caldav.CalendarCompRequest) ([]caldav.CalendarObject, error) {
mbox, err := davMailboxFromCtx(ctx)
if err != nil {
return nil, err
}
events, err := b.DB.ListAllEvents(mbox.ID)
if err != nil {
return nil, err
}
out := make([]caldav.CalendarObject, len(events))
for i, e := range events {
out[i] = eventToCalendarObject(mbox.Email, e)
}
return out, nil
}
// QueryCalendarObjects: like carddav's QueryAddressObjects, this doesn't hand-roll
// every possible CalDAV filter — only the time-range case (by far the common one, used
// by every client's initial/incremental sync) narrows the query; anything else falls
// back to every event. A client that issued a time-range REPORT still expands
// recurring-event occurrences itself per RFC 4791, so returning the whole matching
// event resource (not per-occurrence data) is correct either way.
func (b *caldavBackend) QueryCalendarObjects(ctx context.Context, p string, query *caldav.CalendarQuery) ([]caldav.CalendarObject, error) {
mbox, err := davMailboxFromCtx(ctx)
if err != nil {
return nil, err
}
start, end := query.CompFilter.Start, query.CompFilter.End
var events []db.CalendarEvent
if !start.IsZero() && !end.IsZero() {
events, err = b.DB.ListEventsInRange(mbox.ID, start, end)
} else {
events, err = b.DB.ListAllEvents(mbox.ID)
}
if err != nil {
return nil, err
}
out := make([]caldav.CalendarObject, len(events))
for i, e := range events {
out[i] = eventToCalendarObject(mbox.Email, e)
}
return out, nil
}
func (b *caldavBackend) PutCalendarObject(ctx context.Context, p string, cal *ical.Calendar, opts *caldav.PutCalendarObjectOptions) (*caldav.CalendarObject, error) {
mbox, err := davMailboxFromCtx(ctx)
if err != nil {
return nil, err
}
summary, description, location, start, end, allDay, rrule, color, reminderMinutes, err := icalToEvent(cal)
if err != nil {
return nil, webdav.NewHTTPError(http.StatusBadRequest, err)
}
uid := caldavUIDFromPath(p)
existing, err := b.DB.GetEventByUID(mbox.ID, uid)
if err != nil {
return nil, err
}
var id int64
if existing == nil {
if id, err = b.DB.CreateEventWithUID(mbox.ID, uid, summary, description, location, start, end, allDay, rrule, color); err != nil {
return nil, webdav.NewHTTPError(http.StatusConflict, err)
}
} else {
id = existing.ID
if err := b.DB.UpdateEvent(mbox.ID, id, summary, description, location, start, end, allDay, rrule, color); err != nil {
return nil, webdav.NewHTTPError(http.StatusConflict, err)
}
}
if err := b.DB.SetEventReminder(mbox.ID, id, reminderMinutes); err != nil {
return nil, err
}
stored, err := b.DB.GetEventByUID(mbox.ID, uid)
if err != nil || stored == nil {
return nil, err
}
co := eventToCalendarObject(mbox.Email, *stored)
return &co, nil
}
func (b *caldavBackend) DeleteCalendarObject(ctx context.Context, p string) error {
mbox, err := davMailboxFromCtx(ctx)
if err != nil {
return err
}
return b.DB.DeleteEventByUID(mbox.ID, caldavUIDFromPath(p))
}