// Package dav implements a CalDAV (RFC 4791) + CardDAV (RFC 6352) HTTP // server covering the core operations real clients need: PROPFIND (Depth 0/1 // discovery), REPORT (calendar-query/multiget, addressbook-query/multiget — // query filtering returns all objects in the collection in this pass, full // time-range/property filtering deferred), PUT (create/update), GET // (fetch), DELETE, OPTIONS. No MKCALENDAR/MKCOL — every user's default // addressbook and calendar are auto-created on first access instead, which // covers the common case (one addressbook, one calendar per user) without // needing collection-creation UI in an early phase. // // URL layout: // // /dav/contacts/{ownerType}/{ownerID}/ addressbook collection // /dav/contacts/{ownerType}/{ownerID}/{uid}.vcf a contact // /dav/calendars/{ownerType}/{ownerID}/ calendar collection // /dav/calendars/{ownerType}/{ownerID}/{uid}.ics a calendar event package dav import ( "encoding/xml" "fmt" "io" "log/slog" "net/http" "strings" "time" "gomail/internal/auth" "gomail/internal/crypto" "gomail/internal/db" "gomail/internal/ical" "gomail/internal/vcard" "github.com/google/uuid" ) type Handler struct { database *db.DB mk *crypto.MasterKey } func NewHandler(database *db.DB, mk *crypto.MasterKey) *Handler { return &Handler{database: database, mk: mk} } func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { user, ok := h.authenticate(r) if !ok { w.Header().Set("WWW-Authenticate", `Basic realm="GoMail DAV"`) http.Error(w, "unauthorized", http.StatusUnauthorized) return } path := strings.TrimPrefix(r.URL.Path, "/dav") switch { case strings.HasPrefix(path, "/contacts/"): h.serveCardDAV(w, r, user, strings.TrimPrefix(path, "/contacts/")) case strings.HasPrefix(path, "/calendars/"): h.serveCalDAV(w, r, user, strings.TrimPrefix(path, "/calendars/")) default: http.NotFound(w, r) } } func (h *Handler) authenticate(r *http.Request) (*db.User, bool) { username, password, ok := r.BasicAuth() if !ok { return nil, false } return auth.Authenticate(h.database, username, password, auth.ScopeCardDAV) } // ── CardDAV ─────────────────────────────────────────────────────────────────── func (h *Handler) serveCardDAV(w http.ResponseWriter, r *http.Request, user *db.User, rest string) { ownerType, ownerID, uid, ok := parseCollectionPath(rest, user) if !ok { http.NotFound(w, r) return } book, err := h.database.GetOrCreateAddressbook(ownerType, ownerID, "Default") if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } switch r.Method { case "OPTIONS": w.Header().Set("DAV", "1, 2, addressbook") w.Header().Set("Allow", "OPTIONS, PROPFIND, REPORT, GET, PUT, DELETE") w.WriteHeader(http.StatusOK) case "PROPFIND": h.propfindContacts(w, r, book, uid) case "REPORT": h.reportContacts(w, r, book) case http.MethodGet: if uid == "" { http.Error(w, "GET on collection not supported, use PROPFIND", http.StatusMethodNotAllowed) return } contact, err := h.database.GetContact(book.ID, strings.TrimSuffix(uid, ".vcf")) if err != nil { http.NotFound(w, r) return } plain, err := crypto.Decrypt(h.mk, contact.ID, "contact", contact.VCardEnc) if err != nil { http.Error(w, "decrypt error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/vcard; charset=utf-8") w.Header().Set("ETag", contact.ETag) w.Write(plain) case http.MethodPut: if uid == "" { http.Error(w, "PUT requires a resource path", http.StatusBadRequest) return } body, err := io.ReadAll(r.Body) if err != nil { http.Error(w, "read error", http.StatusBadRequest) return } card, err := vcard.Parse(string(body)) if err != nil { http.Error(w, "invalid vCard: "+err.Error(), http.StatusBadRequest) return } contactID := uuid.NewString() if existing, err := h.database.GetContact(book.ID, card.UID); err == nil { contactID = existing.ID } encrypted, err := crypto.Encrypt(h.mk, contactID, "contact", body) if err != nil { http.Error(w, "encrypt error", http.StatusInternalServerError) return } etag := fmt.Sprintf(`"%d"`, time.Now().UnixNano()) if err := h.database.UpsertContact(&db.Contact{ ID: contactID, AddressbookID: book.ID, UID: card.UID, VCardEnc: encrypted, ETag: etag, }); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("ETag", etag) w.WriteHeader(http.StatusCreated) case http.MethodDelete: if uid == "" { http.Error(w, "DELETE requires a resource path", http.StatusBadRequest) return } if err := h.database.DeleteContact(book.ID, strings.TrimSuffix(uid, ".vcf")); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) default: w.WriteHeader(http.StatusMethodNotAllowed) } } func (h *Handler) propfindContacts(w http.ResponseWriter, r *http.Request, book *db.Addressbook, uid string) { depth := r.Header.Get("Depth") var responses []multistatusResponse responses = append(responses, multistatusResponse{ Href: r.URL.Path, Props: propSet{ DisplayName: book.DisplayName, ResourceType: "", }, }) if depth == "1" && uid == "" { contacts, err := h.database.ListContacts(book.ID) if err == nil { for _, c := range contacts { responses = append(responses, multistatusResponse{ Href: strings.TrimSuffix(r.URL.Path, "/") + "/" + c.UID + ".vcf", Props: propSet{ETag: c.ETag, ContentType: "text/vcard; charset=utf-8"}, }) } } } writeMultistatus(w, responses) } func (h *Handler) reportContacts(w http.ResponseWriter, r *http.Request, book *db.Addressbook) { // addressbook-query and addressbook-multiget both return every contact's // current vCard in this pass — full filter/prop-match parsing is // deferred; clients doing a multiget for hrefs they already have (the // common sync pattern) get correct data, just not a filtered subset. contacts, err := h.database.ListContacts(book.ID) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } var responses []multistatusResponse for _, c := range contacts { plain, err := crypto.Decrypt(h.mk, c.ID, "contact", c.VCardEnc) if err != nil { continue } responses = append(responses, multistatusResponse{ Href: strings.TrimSuffix(r.URL.Path, "/") + "/" + c.UID + ".vcf", Props: propSet{ETag: c.ETag}, AddressData: string(plain), }) } writeMultistatus(w, responses) } // ── CalDAV ──────────────────────────────────────────────────────────────────── func (h *Handler) serveCalDAV(w http.ResponseWriter, r *http.Request, user *db.User, rest string) { ownerType, ownerID, uid, ok := parseCollectionPath(rest, user) if !ok { http.NotFound(w, r) return } cal, err := h.database.GetOrCreateCalendar(ownerType, ownerID, "Default") if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } switch r.Method { case "OPTIONS": w.Header().Set("DAV", "1, 2, calendar-access") w.Header().Set("Allow", "OPTIONS, PROPFIND, REPORT, GET, PUT, DELETE") w.WriteHeader(http.StatusOK) case "PROPFIND": h.propfindCalendar(w, r, cal, uid) case "REPORT": h.reportCalendar(w, r, cal) case http.MethodGet: if uid == "" { http.Error(w, "GET on collection not supported, use PROPFIND", http.StatusMethodNotAllowed) return } obj, err := h.database.GetCalendarObject(cal.ID, strings.TrimSuffix(uid, ".ics")) if err != nil { http.NotFound(w, r) return } plain, err := crypto.Decrypt(h.mk, obj.ID, "calendar", obj.ICalEnc) if err != nil { http.Error(w, "decrypt error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/calendar; charset=utf-8") w.Header().Set("ETag", obj.ETag) w.Write(plain) case http.MethodPut: if uid == "" { http.Error(w, "PUT requires a resource path", http.StatusBadRequest) return } body, err := io.ReadAll(r.Body) if err != nil { http.Error(w, "read error", http.StatusBadRequest) return } event, err := ical.Parse(string(body)) if err != nil { http.Error(w, "invalid iCal: "+err.Error(), http.StatusBadRequest) return } objID := uuid.NewString() if existing, err := h.database.GetCalendarObject(cal.ID, event.UID); err == nil { objID = existing.ID } encrypted, err := crypto.Encrypt(h.mk, objID, "calendar", body) if err != nil { http.Error(w, "encrypt error", http.StatusInternalServerError) return } etag := fmt.Sprintf(`"%d"`, time.Now().UnixNano()) obj := &db.CalendarObject{ ID: objID, CalendarID: cal.ID, UID: event.UID, ICalEnc: encrypted, ComponentType: "VEVENT", Summary: event.Summary, ETag: etag, } if !event.DTStart.IsZero() { obj.DTStart = &event.DTStart } if !event.DTEnd.IsZero() { obj.DTEnd = &event.DTEnd } if err := h.database.UpsertCalendarObject(obj); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("ETag", etag) w.WriteHeader(http.StatusCreated) case http.MethodDelete: if uid == "" { http.Error(w, "DELETE requires a resource path", http.StatusBadRequest) return } if err := h.database.DeleteCalendarObject(cal.ID, strings.TrimSuffix(uid, ".ics")); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) default: w.WriteHeader(http.StatusMethodNotAllowed) } } func (h *Handler) propfindCalendar(w http.ResponseWriter, r *http.Request, cal *db.Calendar, uid string) { depth := r.Header.Get("Depth") var responses []multistatusResponse responses = append(responses, multistatusResponse{ Href: r.URL.Path, Props: propSet{ DisplayName: cal.DisplayName, ResourceType: "", }, }) if depth == "1" && uid == "" { objs, err := h.database.ListCalendarObjects(cal.ID) if err == nil { for _, o := range objs { responses = append(responses, multistatusResponse{ Href: strings.TrimSuffix(r.URL.Path, "/") + "/" + o.UID + ".ics", Props: propSet{ETag: o.ETag, ContentType: "text/calendar; charset=utf-8"}, }) } } } writeMultistatus(w, responses) } func (h *Handler) reportCalendar(w http.ResponseWriter, r *http.Request, cal *db.Calendar) { // calendar-query and calendar-multiget both return every event in this // pass — time-range filtering (the most common real-world calendar-query // use, "give me events this week") is deferred; noted here rather than // silently ignored, since clients that rely on server-side time-range // filtering will over-fetch until that lands. objs, err := h.database.ListCalendarObjects(cal.ID) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } var responses []multistatusResponse for _, o := range objs { plain, err := crypto.Decrypt(h.mk, o.ID, "calendar", o.ICalEnc) if err != nil { continue } responses = append(responses, multistatusResponse{ Href: strings.TrimSuffix(r.URL.Path, "/") + "/" + o.UID + ".ics", Props: propSet{ETag: o.ETag}, CalendarData: string(plain), }) } writeMultistatus(w, responses) } // ── Path parsing ────────────────────────────────────────────────────────────── // parseCollectionPath extracts (ownerType, ownerID, resourceUID) from a // request path like "user/{userID}/{uid}.vcf" or "tenant/{tenantID}/". Only // allows a user to address their own personal collection or their own // tenant's shared one — cross-user access is rejected. func parseCollectionPath(rest string, requestingUser *db.User) (db.OwnerType, string, string, bool) { parts := strings.SplitN(strings.TrimPrefix(rest, "/"), "/", 3) if len(parts) < 2 { return "", "", "", false } ownerType := db.OwnerType(parts[0]) ownerID := parts[1] uid := "" if len(parts) == 3 { uid = parts[2] } switch ownerType { case db.OwnerUser: if ownerID != requestingUser.ID { return "", "", "", false // no cross-user access } case db.OwnerTenant: if ownerID != requestingUser.TenantID { return "", "", "", false // no cross-tenant access } default: return "", "", "", false } return ownerType, ownerID, uid, true } // ── Multistatus XML ─────────────────────────────────────────────────────────── type propSet struct { DisplayName string ResourceType string // raw XML fragment, since it varies by collection type ETag string ContentType string } type multistatusResponse struct { Href string Props propSet AddressData string // set only for CardDAV REPORT responses CalendarData string // set only for CalDAV REPORT responses } func writeMultistatus(w http.ResponseWriter, responses []multistatusResponse) { var b strings.Builder b.WriteString(xml.Header) b.WriteString(`` + "\n") for _, r := range responses { b.WriteString(" \n") b.WriteString(" " + xmlEscape(r.Href) + "\n") b.WriteString(" \n \n") if r.Props.DisplayName != "" { b.WriteString(" " + xmlEscape(r.Props.DisplayName) + "\n") } if r.Props.ResourceType != "" { b.WriteString(" " + r.Props.ResourceType + "\n") } if r.Props.ETag != "" { b.WriteString(" " + xmlEscape(r.Props.ETag) + "\n") } if r.Props.ContentType != "" { b.WriteString(" " + xmlEscape(r.Props.ContentType) + "\n") } if r.AddressData != "" { b.WriteString(" " + xmlEscape(r.AddressData) + "\n") } if r.CalendarData != "" { b.WriteString(" " + xmlEscape(r.CalendarData) + "\n") } b.WriteString(" \n HTTP/1.1 200 OK\n \n") b.WriteString(" \n") } b.WriteString("\n") w.Header().Set("Content-Type", "application/xml; charset=utf-8") w.WriteHeader(207) // Multi-Status if _, err := w.Write([]byte(b.String())); err != nil { slog.Debug("dav: write error", "err", err) } } func xmlEscape(s string) string { var b strings.Builder xml.EscapeText(&b, []byte(s)) return b.String() }