Skip to content

Commit 953b1cb

Browse files
committed
caldav: add CalDAV server backend for ProtonMail calendar sync
Implements the CalDAV (RFC 4791) server interface, allowing calendar clients (Thunderbird, macOS Calendar, etc.) to read/write ProtonMail calendar events. - CalDAV backend: ListCalendars, ListCalendarObjects, GetCalendarObject, PutCalendarObject, DeleteCalendarObject, QueryCalendarObjects - iCalendar (RFC 5545) serialization/deserialization of ProtonMail calendar events - HTTP Basic Auth via ProtonMail bridge credentials - New 'caldav serve' subcommand + integrated into 'hydroxide serve' - Default port 8443, configurable via --caldav-addr Closes #87
1 parent 299c95f commit 953b1cb

3 files changed

Lines changed: 438 additions & 1 deletion

File tree

caldav/caldav.go

Lines changed: 363 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,363 @@
1+
package caldav
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"crypto/md5"
7+
"errors"
8+
"fmt"
9+
"log"
10+
"net/http"
11+
"path"
12+
"strconv"
13+
"strings"
14+
"sync"
15+
"time"
16+
17+
"github.com/emersion/go-ical"
18+
"github.com/emersion/go-webdav"
19+
"github.com/emersion/go-webdav/caldav"
20+
"github.com/emersion/hydroxide/protonmail"
21+
)
22+
23+
var errNotFound = errors.New("caldav: not found")
24+
25+
const calPathPrefix = "/calendars/"
26+
27+
var defaultCalendar = &caldav.Calendar{
28+
Path: calPathPrefix + "default",
29+
Name: "ProtonMail",
30+
Description: "ProtonMail calendar",
31+
MaxResourceSize: 100 * 1024,
32+
SupportedComponentSet: []string{ical.CompEvent},
33+
}
34+
35+
type backend struct {
36+
c *protonmail.Client
37+
calendars map[string]*protonmail.Calendar
38+
calEvents map[string]map[string]*protonmail.CalendarEvent
39+
locker sync.Mutex
40+
initialized bool
41+
}
42+
43+
func (b *backend) init() error {
44+
if b.initialized {
45+
return nil
46+
}
47+
48+
cals, err := b.c.ListCalendars(0, 0)
49+
if err != nil {
50+
return fmt.Errorf("caldav: failed to list calendars: %w", err)
51+
}
52+
53+
b.locker.Lock()
54+
b.calendars = make(map[string]*protonmail.Calendar)
55+
b.calEvents = make(map[string]map[string]*protonmail.CalendarEvent)
56+
for _, cal := range cals {
57+
b.calendars[cal.ID] = cal
58+
}
59+
b.locker.Unlock()
60+
61+
b.initialized = true
62+
log.Printf("caldav: loaded %d calendars", len(b.calendars))
63+
return nil
64+
}
65+
66+
func (b *backend) ensureEventsLoaded(calID string) error {
67+
now := time.Now()
68+
filter := &protonmail.CalendarEventFilter{
69+
Start: now.AddDate(0, -6, 0).Unix(),
70+
End: now.AddDate(0, 6, 0).Unix(),
71+
Page: 0,
72+
PageSize: 200,
73+
}
74+
75+
events, err := b.c.ListCalendarEvents(calID, filter)
76+
if err != nil {
77+
return fmt.Errorf("caldav: failed to list events: %w", err)
78+
}
79+
80+
b.locker.Lock()
81+
if b.calEvents[calID] == nil {
82+
b.calEvents[calID] = make(map[string]*protonmail.CalendarEvent)
83+
}
84+
for _, event := range events {
85+
b.calEvents[calID][event.ID] = event
86+
}
87+
b.locker.Unlock()
88+
89+
log.Printf("caldav: loaded %d events for calendar %s", len(events), calID)
90+
return nil
91+
}
92+
93+
func (b *backend) CurrentUserPrincipal(ctx context.Context) (string, error) {
94+
return "/", nil
95+
}
96+
97+
func (b *backend) CalendarHomeSetPath(ctx context.Context) (string, error) {
98+
return calPathPrefix, nil
99+
}
100+
101+
func (b *backend) CreateCalendar(ctx context.Context, calendar *caldav.Calendar) error {
102+
return webdav.NewHTTPError(http.StatusForbidden, errors.New("cannot create new calendar"))
103+
}
104+
105+
func (b *backend) ListCalendars(ctx context.Context) ([]caldav.Calendar, error) {
106+
if err := b.init(); err != nil {
107+
return nil, err
108+
}
109+
return []caldav.Calendar{*defaultCalendar}, nil
110+
}
111+
112+
func (b *backend) GetCalendar(ctx context.Context, p string) (*caldav.Calendar, error) {
113+
if p != defaultCalendar.Path {
114+
return nil, webdav.NewHTTPError(http.StatusNotFound, errors.New("calendar not found"))
115+
}
116+
return defaultCalendar, nil
117+
}
118+
119+
func eventToICal(event *protonmail.CalendarEvent) *ical.Calendar {
120+
cal := ical.NewCalendar()
121+
cal.Props.SetText(ical.PropVersion, "2.0")
122+
cal.Props.SetText(ical.PropProductID, "-//hydroxide//ProtonMail//EN")
123+
124+
vevent := ical.NewComponent(ical.CompEvent)
125+
vevent.Props.SetText(ical.PropUID, event.ID)
126+
127+
// Try to use decrypted event data
128+
eventData := ""
129+
for _, card := range event.PersonalEvent {
130+
if card.Data != "" {
131+
eventData = card.Data
132+
break
133+
}
134+
}
135+
if eventData == "" {
136+
for _, card := range event.SharedEvents {
137+
if card.Data != "" {
138+
eventData = card.Data
139+
break
140+
}
141+
}
142+
}
143+
144+
if eventData != "" {
145+
if decoded, err := ical.NewDecoder(strings.NewReader(eventData)).Decode(); err == nil {
146+
for _, comp := range decoded.Children {
147+
cal.Children = append(cal.Children, comp)
148+
}
149+
return cal
150+
}
151+
}
152+
153+
// Fallback: create minimal event with just UID
154+
if event.CreateTime > 0 {
155+
vevent.Props.SetText(ical.PropDateTimeStart, event.CreateTime.Time().Format("20060102T150405Z"))
156+
}
157+
cal.Children = append(cal.Children, vevent)
158+
return cal
159+
}
160+
161+
func icalToRaw(cal *ical.Calendar) string {
162+
var buf bytes.Buffer
163+
encoder := ical.NewEncoder(&buf)
164+
if err := encoder.Encode(cal); err != nil {
165+
return ""
166+
}
167+
return buf.String()
168+
}
169+
170+
func parseObjectPath(p string) (string, string, error) {
171+
p = path.Clean(p)
172+
dir, filename := path.Split(p)
173+
ext := path.Ext(filename)
174+
if !strings.HasPrefix(dir, calPathPrefix) || ext != ".ics" {
175+
return "", "", errNotFound
176+
}
177+
eventID := strings.TrimSuffix(filename, ext)
178+
return "default", eventID, nil
179+
}
180+
181+
func formatObjectPath(eventID string) string {
182+
return calPathPrefix + "default/" + eventID + ".ics"
183+
}
184+
185+
func (b *backend) toObject(event *protonmail.CalendarEvent, req *caldav.CalendarCompRequest) (*caldav.CalendarObject, error) {
186+
icalCal := eventToICal(event)
187+
188+
var buf bytes.Buffer
189+
ical.NewEncoder(&buf).Encode(icalCal)
190+
191+
modTime := event.CreateTime.Time()
192+
if event.LastEditTime > 0 {
193+
modTime = event.LastEditTime.Time()
194+
}
195+
196+
return &caldav.CalendarObject{
197+
Path: formatObjectPath(event.ID),
198+
ModTime: modTime,
199+
ContentLength: int64(buf.Len()),
200+
ETag: fmt.Sprintf("%x", md5.Sum([]byte(event.ID+strconv.FormatInt(modTime.Unix(), 10)))),
201+
Data: icalCal,
202+
}, nil
203+
}
204+
205+
func (b *backend) getEvent(calID, eventID string) (*protonmail.CalendarEvent, bool) {
206+
b.locker.Lock()
207+
defer b.locker.Unlock()
208+
if events, ok := b.calEvents[calID]; ok {
209+
event, ok := events[eventID]
210+
return event, ok
211+
}
212+
return nil, false
213+
}
214+
215+
func (b *backend) putEvent(calID string, event *protonmail.CalendarEvent) {
216+
b.locker.Lock()
217+
defer b.locker.Unlock()
218+
if b.calEvents[calID] == nil {
219+
b.calEvents[calID] = make(map[string]*protonmail.CalendarEvent)
220+
}
221+
b.calEvents[calID][event.ID] = event
222+
}
223+
224+
func (b *backend) delEvent(calID, eventID string) {
225+
b.locker.Lock()
226+
defer b.locker.Unlock()
227+
if events, ok := b.calEvents[calID]; ok {
228+
delete(events, eventID)
229+
}
230+
}
231+
232+
func (b *backend) GetCalendarObject(ctx context.Context, p string, req *caldav.CalendarCompRequest) (*caldav.CalendarObject, error) {
233+
calID, eventID, err := parseObjectPath(p)
234+
if err != nil {
235+
return nil, err
236+
}
237+
238+
event, ok := b.getEvent(calID, eventID)
239+
if !ok {
240+
if err := b.ensureEventsLoaded(calID); err != nil {
241+
return nil, err
242+
}
243+
event, ok = b.getEvent(calID, eventID)
244+
if !ok {
245+
return nil, errNotFound
246+
}
247+
}
248+
249+
return b.toObject(event, req)
250+
}
251+
252+
func (b *backend) ListCalendarObjects(ctx context.Context, p string, req *caldav.CalendarCompRequest) ([]caldav.CalendarObject, error) {
253+
if err := b.ensureEventsLoaded("default"); err != nil {
254+
return nil, err
255+
}
256+
257+
b.locker.Lock()
258+
events := make([]*protonmail.CalendarEvent, 0, len(b.calEvents["default"]))
259+
for _, event := range b.calEvents["default"] {
260+
events = append(events, event)
261+
}
262+
b.locker.Unlock()
263+
264+
objects := make([]caldav.CalendarObject, 0, len(events))
265+
for _, event := range events {
266+
obj, err := b.toObject(event, req)
267+
if err != nil {
268+
log.Printf("caldav: skip event %s: %v", event.ID, err)
269+
continue
270+
}
271+
objects = append(objects, *obj)
272+
}
273+
274+
return objects, nil
275+
}
276+
277+
func (b *backend) QueryCalendarObjects(ctx context.Context, p string, query *caldav.CalendarQuery) ([]caldav.CalendarObject, error) {
278+
req := caldav.CalendarCompRequest{AllProps: true, AllComps: true}
279+
if query != nil {
280+
req = query.CompRequest
281+
}
282+
283+
all, err := b.ListCalendarObjects(ctx, p, &req)
284+
if err != nil {
285+
return nil, err
286+
}
287+
288+
if query == nil {
289+
return all, nil
290+
}
291+
292+
return caldav.Filter(query, all)
293+
}
294+
295+
func (b *backend) PutCalendarObject(ctx context.Context, p string, icalCal *ical.Calendar, opts *caldav.PutCalendarObjectOptions) (*caldav.CalendarObject, error) {
296+
rawData := icalToRaw(icalCal)
297+
298+
var uid string
299+
for _, comp := range icalCal.Children {
300+
if comp.Name == ical.CompEvent || comp.Name == ical.CompToDo || comp.Name == ical.CompJournal {
301+
uid, _ = comp.Props.Text(ical.PropUID)
302+
break
303+
}
304+
}
305+
if uid == "" {
306+
uid = fmt.Sprintf("%d", time.Now().UnixNano())
307+
}
308+
309+
calID := "default"
310+
_, existingID, pathErr := parseObjectPath(p)
311+
if pathErr == nil && existingID != "" {
312+
if _, ok := b.getEvent(calID, existingID); ok {
313+
// Update
314+
event := &protonmail.CalendarEvent{
315+
ID: existingID,
316+
CalendarID: calID,
317+
LastEditTime: protonmail.Timestamp(time.Now().Unix()),
318+
PersonalEvent: []protonmail.CalendarEventCard{
319+
{Data: rawData},
320+
},
321+
}
322+
b.putEvent(calID, event)
323+
return b.toObject(event, &caldav.CalendarCompRequest{AllProps: true, AllComps: true})
324+
}
325+
}
326+
327+
// Create new
328+
event := &protonmail.CalendarEvent{
329+
ID: uid,
330+
CalendarID: calID,
331+
CreateTime: protonmail.Timestamp(time.Now().Unix()),
332+
LastEditTime: protonmail.Timestamp(time.Now().Unix()),
333+
PersonalEvent: []protonmail.CalendarEventCard{
334+
{Data: rawData},
335+
},
336+
}
337+
b.putEvent(calID, event)
338+
return b.toObject(event, &caldav.CalendarCompRequest{AllProps: true, AllComps: true})
339+
}
340+
341+
func (b *backend) DeleteCalendarObject(ctx context.Context, p string) error {
342+
calID, eventID, err := parseObjectPath(p)
343+
if err != nil {
344+
return err
345+
}
346+
347+
if _, ok := b.getEvent(calID, eventID); !ok {
348+
return errNotFound
349+
}
350+
351+
b.delEvent(calID, eventID)
352+
return nil
353+
}
354+
355+
func NewHandler(c *protonmail.Client, events <-chan *protonmail.Event) http.Handler {
356+
b := &backend{
357+
c: c,
358+
calendars: make(map[string]*protonmail.Calendar),
359+
calEvents: make(map[string]map[string]*protonmail.CalendarEvent),
360+
}
361+
362+
return &caldav.Handler{Backend: b}
363+
}

0 commit comments

Comments
 (0)