diff --git a/caldav/caldav.go b/caldav/caldav.go
index 04b8399..0f4bea7 100644
--- a/caldav/caldav.go
+++ b/caldav/caldav.go
@@ -145,3 +145,8 @@ type SyncResponse struct {
Updated []CalendarObject
Deleted []string
}
+
+type Inbox struct {
+ Path string
+ UserAddressSet []string
+}
diff --git a/caldav/elements.go b/caldav/elements.go
index aed6050..f4ac7cd 100644
--- a/caldav/elements.go
+++ b/caldav/elements.go
@@ -11,7 +11,9 @@ import (
const namespace = "urn:ietf:params:xml:ns:caldav"
var (
- calendarHomeSetName = xml.Name{namespace, "calendar-home-set"}
+ calendarHomeSetName = xml.Name{namespace, "calendar-home-set"}
+ calendarUserAddressSetName = xml.Name{namespace, "calendar-user-address-set"}
+ scheduleInboxURLName = xml.Name{namespace, "schedule-inbox-URL"}
calendarDescriptionName = xml.Name{namespace, "calendar-description"}
supportedCalendarDataName = xml.Name{namespace, "supported-calendar-data"}
@@ -21,8 +23,9 @@ var (
calendarQueryName = xml.Name{namespace, "calendar-query"}
calendarMultigetName = xml.Name{namespace, "calendar-multiget"}
- calendarName = xml.Name{namespace, "calendar"}
- calendarDataName = xml.Name{namespace, "calendar-data"}
+ calendarName = xml.Name{namespace, "calendar"}
+ scheduleInboxName = xml.Name{namespace, "schedule-inbox"}
+ calendarDataName = xml.Name{namespace, "calendar-data"}
)
// https://tools.ietf.org/html/rfc4791#section-6.2.1
@@ -35,6 +38,18 @@ func (a *calendarHomeSet) GetXMLName() xml.Name {
return calendarHomeSetName
}
+// https://datatracker.ietf.org/doc/html/rfc6638#section-2.4.1
+type calendarUserAddressSet struct {
+ XMLName xml.Name `xml:"urn:ietf:params:xml:ns:caldav calendar-user-address-set"`
+ Addresses []string `xml:"DAV: href"`
+}
+
+// https://datatracker.ietf.org/doc/html/rfc6638#section-2.2.1
+type scheduleInboxURL struct {
+ XMLName xml.Name `xml:"urn:ietf:params:xml:ns:caldav schedule-inbox-URL"`
+ Href internal.Href `xml:"DAV: href"`
+}
+
// https://tools.ietf.org/html/rfc4791#section-5.2.1
type calendarDescription struct {
XMLName xml.Name `xml:"urn:ietf:params:xml:ns:caldav calendar-description"`
diff --git a/caldav/server.go b/caldav/server.go
index 1a25479..7ddb734 100644
--- a/caldav/server.go
+++ b/caldav/server.go
@@ -44,6 +44,32 @@ type Backend interface {
webdav.UserPrincipalBackend
}
+// Conforming to `InboxBackend` signals RFC6638 support, which is partial and focused on replying to invitations.
+// https://datatracker.ietf.org/doc/html/rfc6638
+//
+// In practice this does a few things to support scheduling.
+// 1. It adds the `calendar-auto-schedule` capability
+// 2. It returns `schedule-inbox-URL` and `calendar-user-address-set` based on the `GetInbox` result.
+// 3. It adds an inbox-calendar to the `PROPFIND` for all collections.
+// 4. It returns the `CalendarObject`:s using `ListCalendarObjects` when getting a `REPORT` towards the inbox.
+//
+// To properly implement this, you need to.
+// 1. Implement `GetInbox`, and return a list of addresses for the users that can be invited, e.g. on the form: `mailto:test@example.com`.
+// 2. Implement `ListCalendarObjects`, which returns `CalendarObject`:s that have an attendee that matches one of the addresses returned from `GetInbox`. e.g.
+// ```go
+// attendee := ical.NewProp(ical.PropAttendee)
+// attendee.Params.Set(ical.ParamCommonName, name)
+// attendee.Params.Set(ical.ParamParticipationStatus, "NEEDS-ACTION")
+// attendee.SetText("mailto:test@example.com")
+// attendee.SetValueType(ical.ValueCalendarAddress)
+// event.Props.Add(attendee)
+// ```
+//
+// Doing this should make it possible to accept/decline invitations in your client, which would trigger a `PutCalendarObject` towards the invite, with an updateded `ParamParticipationStatus`.
+type InboxBackend interface {
+ GetInbox(ctx context.Context) (*Inbox, error)
+}
+
// Handler handles CalDAV HTTP requests. It can be used to create a CalDAV
// server.
type Handler struct {
@@ -300,14 +326,21 @@ const (
resourceTypeCalendarHomeSet
resourceTypeCalendar
resourceTypeCalendarObject
+ resourceTypeScheduleInbox
)
-func (b *backend) resourceTypeAtPath(reqPath string) resourceType {
+func (b *backend) resourceTypeAtPath(ctx context.Context, reqPath string) resourceType {
p := path.Clean(reqPath)
p = strings.TrimPrefix(p, b.Prefix)
if !strings.HasPrefix(p, "/") {
p = "/" + p
}
+ if inboxBackend, ok := b.Backend.(InboxBackend); ok {
+ inbox, err := inboxBackend.GetInbox(ctx)
+ if err == nil && inbox.Path == p {
+ return resourceTypeScheduleInbox
+ }
+ }
if p == "/" {
return resourceTypeRoot
}
@@ -317,7 +350,12 @@ func (b *backend) resourceTypeAtPath(reqPath string) resourceType {
func (b *backend) Options(r *http.Request) (caps []string, allow []string, err error) {
caps = []string{"calendar-access"}
- if b.resourceTypeAtPath(r.URL.Path) != resourceTypeCalendarObject {
+ if _, ok := b.Backend.(InboxBackend); ok {
+ // https://datatracker.ietf.org/doc/html/rfc6638#section-2
+ caps = append(caps, "calendar-auto-schedule")
+ }
+
+ if b.resourceTypeAtPath(r.Context(), r.URL.Path) != resourceTypeCalendarObject {
return caps, []string{http.MethodOptions, "PROPFIND", "REPORT", "DELETE", "MKCOL"}, nil
}
@@ -367,7 +405,7 @@ func (b *backend) HeadGet(w http.ResponseWriter, r *http.Request) error {
}
func (b *backend) PropFind(r *http.Request, propfind *internal.PropFind, depth internal.Depth) (*internal.MultiStatus, error) {
- resType := b.resourceTypeAtPath(r.URL.Path)
+ resType := b.resourceTypeAtPath(r.Context(), r.URL.Path)
var dataReq CalendarCompRequest
var resps []internal.Response
@@ -436,7 +474,7 @@ func (b *backend) PropFind(r *http.Request, propfind *internal.PropFind, depth i
}
resps = append(resps, *resp)
if depth != internal.DepthZero {
- resps_, err := b.propFindAllCalendarObjects(r.Context(), propfind, ab)
+ resps_, err := b.propFindAllCalendarObjects(r.Context(), propfind, ab.Path)
if err != nil {
return nil, err
}
@@ -453,6 +491,25 @@ func (b *backend) PropFind(r *http.Request, propfind *internal.PropFind, depth i
return nil, err
}
resps = append(resps, *resp)
+ case resourceTypeScheduleInbox:
+ if inboxBackend, ok := b.Backend.(InboxBackend); ok {
+ inbox, err := inboxBackend.GetInbox(r.Context())
+ if err != nil {
+ return nil, err
+ }
+ resp, err := b.propFindInbox(propfind, inbox.Path)
+ if err != nil {
+ return nil, err
+ }
+ resps = append(resps, *resp)
+ if depth != internal.DepthZero {
+ resps_, err := b.propFindAllCalendarObjects(r.Context(), propfind, inbox.Path)
+ if err != nil {
+ return nil, err
+ }
+ resps = append(resps, resps_...)
+ }
+ }
}
return internal.NewMultiStatus(resps...), nil
@@ -492,6 +549,18 @@ func (b *backend) propFindUserPrincipal(ctx context.Context, propfind *internal.
}),
internal.ResourceTypeName: internal.PropFindValue(internal.NewResourceType(internal.CollectionName, internal.PrincipalName)),
}
+ if inboxBackend, ok := b.Backend.(InboxBackend); ok {
+ inbox, err := inboxBackend.GetInbox(ctx)
+ if err != nil {
+ return nil, err
+ }
+ props[scheduleInboxURLName] = internal.PropFindValue(&scheduleInboxURL{
+ Href: internal.Href{Path: inbox.Path},
+ })
+ props[calendarUserAddressSetName] = internal.PropFindValue(&calendarUserAddressSet{
+ Addresses: inbox.UserAddressSet,
+ })
+ }
return internal.NewPropFindResponse(principalPath, propfind, props)
}
@@ -576,6 +645,18 @@ func (b *backend) propFindCalendar(ctx context.Context, propfind *internal.PropF
return internal.NewPropFindResponse(cal.Path, propfind, props)
}
+func (b *backend) propFindInbox(propfind *internal.PropFind, path string) (*internal.Response, error) {
+ props := map[xml.Name]internal.PropFindFunc{
+ internal.ResourceTypeName: internal.PropFindValue(internal.NewResourceType(internal.CollectionName, scheduleInboxName)),
+ internal.CurrentUserPrivilegeSetName: internal.PropFindValue(&internal.CurrentUserPrivilegeSet{
+ Privilege: []internal.Privilege{{
+ SchedulerDeliver: &struct{}{},
+ }},
+ }),
+ }
+ return internal.NewPropFindResponse(path, propfind, props)
+}
+
func (b *backend) propFindAllCalendars(ctx context.Context, propfind *internal.PropFind, recurse bool) ([]internal.Response, error) {
abs, err := b.Backend.ListCalendars(ctx)
if err != nil {
@@ -590,13 +671,24 @@ func (b *backend) propFindAllCalendars(ctx context.Context, propfind *internal.P
}
resps = append(resps, *resp)
if recurse {
- resps_, err := b.propFindAllCalendarObjects(ctx, propfind, &ab)
+ resps_, err := b.propFindAllCalendarObjects(ctx, propfind, ab.Path)
if err != nil {
return nil, err
}
resps = append(resps, resps_...)
}
}
+ if inboxBackend, ok := b.Backend.(InboxBackend); ok {
+ inbox, err := inboxBackend.GetInbox(ctx)
+ if err != nil {
+ return nil, err
+ }
+ resp, err := b.propFindInbox(propfind, inbox.Path)
+ if err != nil {
+ return nil, err
+ }
+ resps = append(resps, *resp)
+ }
return resps, nil
}
@@ -643,9 +735,9 @@ func (b *backend) propFindCalendarObject(ctx context.Context, propfind *internal
return internal.NewPropFindResponse(co.Path, propfind, props)
}
-func (b *backend) propFindAllCalendarObjects(ctx context.Context, propfind *internal.PropFind, cal *Calendar) ([]internal.Response, error) {
+func (b *backend) propFindAllCalendarObjects(ctx context.Context, propfind *internal.PropFind, path string) ([]internal.Response, error) {
var dataReq CalendarCompRequest
- aos, err := b.Backend.ListCalendarObjects(ctx, cal.Path, &dataReq)
+ aos, err := b.Backend.ListCalendarObjects(ctx, path, &dataReq)
if err != nil {
return nil, err
}
@@ -716,7 +808,7 @@ func (b *backend) Delete(r *http.Request) error {
}
func (b *backend) Mkcol(r *http.Request) error {
- if b.resourceTypeAtPath(r.URL.Path) != resourceTypeCalendar {
+ if b.resourceTypeAtPath(r.Context(), r.URL.Path) != resourceTypeCalendar {
return internal.HTTPErrorf(http.StatusForbidden, "caldav: calendar creation not allowed at given location")
}
diff --git a/caldav/server_test.go b/caldav/server_test.go
index 594b87b..812cf47 100644
--- a/caldav/server_test.go
+++ b/caldav/server_test.go
@@ -177,6 +177,88 @@ func TestMultiCalendarBackend(t *testing.T) {
}
}
+func TestCalendarWithInbox(t *testing.T) {
+ inboxPath := "/user/inbox"
+ summary := "Team Meeting Invite"
+ calendarInvite := calendarObjectWithInvite(inboxPath, summary)
+ handler := Handler{Backend: inboxTestBackend{
+ testBackend: testBackend{
+ calendars: []Calendar{{Path: "/user/calendars/a"}},
+ objectMap: map[string][]CalendarObject{
+ inboxPath: {calendarInvite},
+ },
+ },
+ inbox: Inbox{Path: inboxPath, UserAddressSet: []string{"mailto:test@example.com"}},
+ }}
+
+ // The OPTIONS request should signal scheduling support
+ req := httptest.NewRequest("OPTIONS", "/", nil)
+ w := httptest.NewRecorder()
+ handler.ServeHTTP(w, req)
+
+ res := w.Result()
+ defer res.Body.Close()
+ dav := res.Header.Get("DAV")
+ if !strings.Contains(dav, "calendar-auto-schedule") {
+ t.Errorf("Expected DAV header to contain calendar-auto-schedule, got: %s", dav)
+ }
+
+ // PROPFIND should reveal where the inbox is located
+ req = httptest.NewRequest("PROPFIND", "/user/", strings.NewReader(propFindSchedulingProps))
+ req.Header.Set("Content-Type", "application/xml")
+ w = httptest.NewRecorder()
+ handler.ServeHTTP(w, req)
+
+ res = w.Result()
+ defer res.Body.Close()
+ data, err := io.ReadAll(res.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp := string(data)
+ if !strings.Contains(resp, `/user/inbox`) {
+ t.Errorf("Expected schedule-inbox-URL in principal PROPFIND, response:\n%s", resp)
+ }
+ if !strings.Contains(resp, `mailto:test@example.com`) {
+ t.Errorf("Expected calendar-user-address-set in principal PROPFIND, response:\n%s", resp)
+ }
+
+ // PROPFIND on the inbox should return the privileges
+ req = httptest.NewRequest("PROPFIND", inboxPath, strings.NewReader(propFindSchedulingProps))
+ req.Header.Set("Content-Type", "application/xml")
+ w = httptest.NewRecorder()
+ handler.ServeHTTP(w, req)
+
+ res = w.Result()
+ defer res.Body.Close()
+ data, err = io.ReadAll(res.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp = string(data)
+
+ if !strings.Contains(resp, ``) {
+ t.Errorf("Expected schedule-deliver privilege in response:\n%s", resp)
+ }
+
+ // Now do a REPORT to get the actual data for the event
+ req = httptest.NewRequest("REPORT", inboxPath, strings.NewReader(fmt.Sprintf(reportCalendarData, calendarInvite.Path)))
+ req.Header.Set("Content-Type", "application/xml")
+ w = httptest.NewRecorder()
+ handler.ServeHTTP(w, req)
+
+ res = w.Result()
+ defer res.Body.Close()
+ data, err = io.ReadAll(res.Body)
+ if err != nil {
+ t.Error(err)
+ }
+ resp = string(data)
+ if !strings.Contains(resp, fmt.Sprintf("SUMMARY:%s", summary)) {
+ t.Errorf("ICAL content not properly returned in response:\n%v", resp)
+ }
+}
+
type testBackend struct {
calendars []Calendar
objectMap map[string][]CalendarObject
@@ -233,3 +315,40 @@ func (t testBackend) ListCalendarObjects(ctx context.Context, path string, req *
func (t testBackend) QueryCalendarObjects(ctx context.Context, path string, query *CalendarQuery) ([]CalendarObject, error) {
return nil, nil
}
+
+// inboxTestBackend extends testBackend with InboxBackend support (RFC 6638 scheduling)
+type inboxTestBackend struct {
+ testBackend
+ inbox Inbox
+}
+
+func (t inboxTestBackend) GetInbox(ctx context.Context) (*Inbox, error) {
+ return &t.inbox, nil
+}
+
+var propFindSchedulingProps = `
+
+
+
+
+
+
+
+
+
+`
+
+func calendarObjectWithInvite(inboxPath string, summary string) CalendarObject {
+ event := ical.NewEvent()
+ event.Props.SetText(ical.PropUID, "invite-uid-1")
+ event.Props.SetDateTime(ical.PropDateTimeStamp, time.Now())
+ event.Props.SetText(ical.PropSummary, summary)
+ cal := ical.NewCalendar()
+ cal.Props.SetText(ical.PropVersion, "2.0")
+ cal.Props.SetText(ical.PropProductID, "-//Test//Test//EN")
+ cal.Children = []*ical.Component{event.Component}
+ return CalendarObject{
+ Path: inboxPath + "/invite1.ics",
+ Data: cal,
+ }
+}
diff --git a/internal/elements.go b/internal/elements.go
index 77df514..a90c026 100644
--- a/internal/elements.go
+++ b/internal/elements.go
@@ -466,8 +466,10 @@ type CurrentUserPrivilegeSet struct {
}
// https://tools.ietf.org/html/rfc3744#section-5.4
+// https://datatracker.ietf.org/doc/html/rfc6638#section-6.1.1
type Privilege struct {
- XMLName xml.Name `xml:"DAV: privilege"`
- Read *struct{} `xml:"DAV: read,omitempty"`
- Write *struct{} `xml:"DAV: write,omitempty"`
+ XMLName xml.Name `xml:"DAV: privilege"`
+ Read *struct{} `xml:"DAV: read,omitempty"`
+ Write *struct{} `xml:"DAV: write,omitempty"`
+ SchedulerDeliver *struct{} `xml:"DAV: schedule-deliver,omitempty"`
}