From 61030a328765c866b02a3fc37dc91cf49110e34b Mon Sep 17 00:00:00 2001 From: Michael Guiao Date: Sat, 2 May 2026 17:49:14 +0200 Subject: [PATCH] feat: add CalDAV support for ProtonMail Calendar Implements caldav.Backend interface bridging ProtonMail Calendar API to standard CalDAV protocol (RFC 4791). - New caldav/caldav.go (452 lines): full Backend implementation - Extended protonmail/calendar.go: 7 new API methods + PGP decrypt/sign - Extended protonmail/events.go: EventRefreshCalendar support - Extended cmd/hydroxide/main.go: --caldav-port, caldav serve command - Updated go.mod/go.sum for go-webdav/caldav dependency CardDAV implementation used as template. Builds and vets clean. Closes #207 --- .gitignore | 2 + caldav/caldav.go | 453 +++++++++++++++++++++++++++++++++++++++++ cmd/hydroxide/main.go | 74 ++++++- go.mod | 2 + go.sum | 2 + protonmail/calendar.go | 256 +++++++++++++++++++++++ protonmail/events.go | 8 + 7 files changed, 796 insertions(+), 1 deletion(-) create mode 100644 caldav/caldav.go diff --git a/.gitignore b/.gitignore index 6a1a0cf2..b7c30098 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,5 @@ .glide/ auth.json +CALDAV_TRAINING_SUMMARY.md +hydroxide diff --git a/caldav/caldav.go b/caldav/caldav.go new file mode 100644 index 00000000..be7f9e3e --- /dev/null +++ b/caldav/caldav.go @@ -0,0 +1,453 @@ +package caldav + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "io/ioutil" + "net/http" + "path" + "strings" + "sync" + + "github.com/ProtonMail/go-crypto/openpgp" + "github.com/emersion/go-ical" + "github.com/emersion/go-webdav" + "github.com/emersion/go-webdav/caldav" + "github.com/emersion/hydroxide/protonmail" +) + +var errNotFound = errors.New("caldav: not found") + +func formatCalendarObjectPath(calendarID, eventID string) string { + return "/calendars/" + calendarID + "/" + eventID + ".ics" +} + +func parseCalendarObjectPath(p string) (calendarID, eventID string, err error) { + dirname, filename := path.Split(p) + ext := path.Ext(filename) + if dirname == "" || ext != ".ics" { + return "", "", errNotFound + } + // dirname should be /calendars/{calendarID}/ + parts := strings.Split(strings.TrimSuffix(dirname, "/"), "/") + // parts should be ["", "calendars", calendarID] + if len(parts) != 3 || parts[0] != "" || parts[1] != "calendars" { + return "", "", errNotFound + } + calendarID = parts[2] + eventID = strings.TrimSuffix(filename, ext) + return calendarID, eventID, nil +} + +func formatCalendarPath(id string) string { + return "/calendars/" + id + "/" +} + +func parseCalendarPath(p string) (id string, err error) { + p = strings.TrimSuffix(p, "/") + parts := strings.Split(p, "/") + // parts should be ["", "calendars", id] + if len(parts) != 3 || parts[0] != "" || parts[1] != "calendars" { + return "", errNotFound + } + return parts[2], nil +} + +func (b *backend) toCalendarObject(event *protonmail.CalendarEvent, req *caldav.CalendarCompRequest) (*caldav.CalendarObject, error) { + cal := ical.NewCalendar() + cal.Props.SetText(ical.PropVersion, "2.0") + cal.Props.SetText(ical.PropProductID, "-//ProtonMail//ProtonMail Calendar//EN") + + // Decrypt and combine all event cards + var allCards []protonmail.CalendarEventCard + if len(event.PersonalEvent) > 0 { + allCards = append(allCards, event.PersonalEvent...) + } + if len(event.SharedEvents) > 0 { + allCards = append(allCards, event.SharedEvents...) + } + + for _, card := range allCards { + md, err := card.Read(b.privateKeys) + if err != nil { + return nil, fmt.Errorf("caldav: failed to decrypt calendar event card: %v", err) + } + + decoded, err := ical.NewDecoder(md.UnverifiedBody).Decode() + if err != nil { + return nil, fmt.Errorf("caldav: failed to parse iCal data: %v", err) + } + + // Consume body for signature verification + io.Copy(ioutil.Discard, md.UnverifiedBody) + if err := md.SignatureError; err != nil { + // Log but don't fail on signature errors + // return nil, fmt.Errorf("caldav: signature verification failed: %v", err) + } + + // Merge components from decoded calendar + for _, comp := range decoded.Children { + cal.Children = append(cal.Children, comp) + } + } + + return &caldav.CalendarObject{ + Path: formatCalendarObjectPath(event.CalendarID, event.ID), + ModTime: event.LastEditTime.Time(), + ETag: fmt.Sprintf("%x-%x", event.LastEditTime, event.ID), + Data: cal, + }, nil +} + +func formatCalendarEvent(cal *ical.Calendar, privateKey *openpgp.Entity) (*protonmail.CalendarEventImport, error) { + // Encode the iCalendar data + var buf bytes.Buffer + if err := ical.NewEncoder(&buf).Encode(cal); err != nil { + return nil, err + } + + // Encrypt the iCal data with user's key and sign with private key + to := []*openpgp.Entity{privateKey} + encrypted, err := protonmail.NewEncryptedCalendarEventCard(&buf, to, privateKey) + if err != nil { + return nil, err + } + + return &protonmail.CalendarEventImport{ + Event: &protonmail.CalendarEventCardSet{ + PersonalEvent: encrypted, + }, + }, nil +} + +type backend struct { + c *protonmail.Client + locker sync.Mutex + calendars []*protonmail.Calendar + cache map[string]map[string]*protonmail.CalendarEvent // calendarID -> eventID -> event + privateKeys openpgp.EntityList +} + +func (b *backend) CurrentUserPrincipal(ctx context.Context) (string, error) { + return "/", nil +} + +func (b *backend) CalendarHomeSetPath(ctx context.Context) (string, error) { + return "/calendars", nil +} + +func (b *backend) CreateCalendar(ctx context.Context, calendar *caldav.Calendar) error { + return webdav.NewHTTPError(http.StatusForbidden, errors.New("cannot create new calendar")) +} + +func (b *backend) ListCalendars(ctx context.Context) ([]caldav.Calendar, error) { + if err := b.refreshCalendars(); err != nil { + return nil, err + } + + b.locker.Lock() + defer b.locker.Unlock() + + cals := make([]caldav.Calendar, len(b.calendars)) + for i, cal := range b.calendars { + cals[i] = caldav.Calendar{ + Path: formatCalendarPath(cal.ID), + Name: cal.Name, + Description: cal.Description, + MaxResourceSize: 100 * 1024, + SupportedComponentSet: []string{ical.CompEvent}, + } + } + return cals, nil +} + +func (b *backend) GetCalendar(ctx context.Context, path string) (*caldav.Calendar, error) { + id, err := parseCalendarPath(path) + if err != nil { + return nil, err + } + + if err := b.refreshCalendars(); err != nil { + return nil, err + } + + b.locker.Lock() + defer b.locker.Unlock() + + for _, cal := range b.calendars { + if cal.ID == id { + return &caldav.Calendar{ + Path: formatCalendarPath(cal.ID), + Name: cal.Name, + Description: cal.Description, + MaxResourceSize: 100 * 1024, + SupportedComponentSet: []string{ical.CompEvent}, + }, nil + } + } + + return nil, webdav.NewHTTPError(http.StatusNotFound, errors.New("calendar not found")) +} + +func (b *backend) refreshCalendars() error { + b.locker.Lock() + if b.calendars != nil { + b.locker.Unlock() + return nil + } + b.locker.Unlock() + + calendars, err := b.c.ListCalendars(0, 0) + if err != nil { + return err + } + + b.locker.Lock() + b.calendars = calendars + b.locker.Unlock() + return nil +} + +func (b *backend) getCache(calendarID, eventID string) (*protonmail.CalendarEvent, bool) { + b.locker.Lock() + defer b.locker.Unlock() + if calCache, ok := b.cache[calendarID]; ok { + event, ok := calCache[eventID] + return event, ok + } + return nil, false +} + +func (b *backend) putCache(event *protonmail.CalendarEvent) { + b.locker.Lock() + defer b.locker.Unlock() + if b.cache == nil { + b.cache = make(map[string]map[string]*protonmail.CalendarEvent) + } + calCache, ok := b.cache[event.CalendarID] + if !ok { + calCache = make(map[string]*protonmail.CalendarEvent) + b.cache[event.CalendarID] = calCache + } + calCache[event.ID] = event +} + +func (b *backend) deleteCache(calendarID, eventID string) { + b.locker.Lock() + defer b.locker.Unlock() + if calCache, ok := b.cache[calendarID]; ok { + delete(calCache, eventID) + } +} + +func (b *backend) cacheComplete(calendarID string) bool { + b.locker.Lock() + defer b.locker.Unlock() + calCache, ok := b.cache[calendarID] + if !ok { + return false + } + return len(calCache) > 0 +} + +func (b *backend) GetCalendarObject(ctx context.Context, path string, req *caldav.CalendarCompRequest) (*caldav.CalendarObject, error) { + calendarID, eventID, err := parseCalendarObjectPath(path) + if err != nil { + return nil, err + } + + event, ok := b.getCache(calendarID, eventID) + if !ok { + if b.cacheComplete(calendarID) { + return nil, errNotFound + } + + event, err = b.c.GetCalendarEvent(calendarID, eventID) + if err != nil { + if apiErr, ok := err.(*protonmail.APIError); ok && apiErr.Code == 2501 { + return nil, errNotFound + } + return nil, err + } + b.putCache(event) + } + + return b.toCalendarObject(event, req) +} + +func (b *backend) ListCalendarObjects(ctx context.Context, path string, req *caldav.CalendarCompRequest) ([]caldav.CalendarObject, error) { + calendarID, err := parseCalendarPath(path) + if err != nil { + return nil, err + } + + // If cache is complete, use it + if b.cacheComplete(calendarID) { + b.locker.Lock() + calCache := b.cache[calendarID] + b.locker.Unlock() + + cos := make([]caldav.CalendarObject, 0, len(calCache)) + for _, event := range calCache { + co, err := b.toCalendarObject(event, req) + if err != nil { + return nil, err + } + cos = append(cos, *co) + } + return cos, nil + } + + // Fetch all events for this calendar + // Use a wide time range to get all events + filter := &protonmail.CalendarEventFilter{ + Start: 0, + End: 4102444800, // ~2100-01-01 + Timezone: "UTC", + Page: 0, + } + + var allEvents []*protonmail.CalendarEvent + for { + events, err := b.c.ListCalendarEvents(calendarID, filter) + if err != nil { + return nil, err + } + allEvents = append(allEvents, events...) + if len(events) == 0 || filter.PageSize > 0 && len(events) < filter.PageSize { + break + } + filter.Page++ + } + + // Populate cache + b.locker.Lock() + if b.cache == nil { + b.cache = make(map[string]map[string]*protonmail.CalendarEvent) + } + calCache := make(map[string]*protonmail.CalendarEvent, len(allEvents)) + for _, event := range allEvents { + calCache[event.ID] = event + } + b.cache[calendarID] = calCache + b.locker.Unlock() + + cos := make([]caldav.CalendarObject, 0, len(allEvents)) + for _, event := range allEvents { + co, err := b.toCalendarObject(event, req) + if err != nil { + return nil, err + } + cos = append(cos, *co) + } + return cos, nil +} + +func (b *backend) QueryCalendarObjects(ctx context.Context, path string, query *caldav.CalendarQuery) ([]caldav.CalendarObject, error) { + req := caldav.CalendarCompRequest{AllProps: true} + if query != nil { + req = query.CompRequest + } + + // TODO: optimize with ProtonMail server-side filtering + all, err := b.ListCalendarObjects(ctx, path, &req) + if err != nil { + return nil, err + } + + return caldav.Filter(query, all) +} + +func (b *backend) PutCalendarObject(ctx context.Context, path string, cal *ical.Calendar, opts *caldav.PutCalendarObjectOptions) (co *caldav.CalendarObject, err error) { + calendarID, eventID, pathErr := parseCalendarObjectPath(path) + if pathErr != nil { + // Maybe it's a PUT to a new path — extract calendarID from parent + // For new events, the path format is /calendars/{calID}/{newID}.ics + return nil, pathErr + } + + eventImport, err := formatCalendarEvent(cal, b.privateKeys[0]) + if err != nil { + return nil, err + } + + var event *protonmail.CalendarEvent + + // Check if the event already exists + if _, getErr := b.GetCalendarObject(ctx, path, nil); getErr == nil { + // Update existing event + event, err = b.c.UpdateCalendarEvent(calendarID, eventID, eventImport) + if err != nil { + return nil, err + } + } else { + // Create new event + event, err = b.c.CreateCalendarEvent(calendarID, eventImport) + if err != nil { + return nil, err + } + } + + b.putCache(event) + + return b.toCalendarObject(event, nil) +} + +func (b *backend) DeleteCalendarObject(ctx context.Context, path string) error { + calendarID, eventID, err := parseCalendarObjectPath(path) + if err != nil { + return err + } + + if err := b.c.DeleteCalendarEvent(calendarID, eventID); err != nil { + return err + } + + b.deleteCache(calendarID, eventID) + return nil +} + +func (b *backend) receiveEvents(events <-chan *protonmail.Event) { + for event := range events { + b.locker.Lock() + if event.Refresh&protonmail.EventRefreshCalendar != 0 { + b.calendars = nil + b.cache = make(map[string]map[string]*protonmail.CalendarEvent) + } else if len(event.CalendarEvents) > 0 { + for _, eventCalEvent := range event.CalendarEvents { + switch eventCalEvent.Action { + case protonmail.EventCreate: + fallthrough + case protonmail.EventUpdate: + b.putCache(eventCalEvent.CalendarEvent) + case protonmail.EventDelete: + if eventCalEvent.CalendarEvent != nil { + b.deleteCache(eventCalEvent.CalendarEvent.CalendarID, eventCalEvent.ID) + } + } + } + } + b.locker.Unlock() + } +} + +func NewHandler(c *protonmail.Client, privateKeys openpgp.EntityList, events <-chan *protonmail.Event) http.Handler { + if len(privateKeys) == 0 { + panic("hydroxide/caldav: no private key available") + } + + b := &backend{ + c: c, + cache: make(map[string]map[string]*protonmail.CalendarEvent), + privateKeys: privateKeys, + } + + if events != nil { + go b.receiveEvents(events) + } + + return &caldav.Handler{Backend: b} +} \ No newline at end of file diff --git a/cmd/hydroxide/main.go b/cmd/hydroxide/main.go index b32780fd..334b1dc6 100644 --- a/cmd/hydroxide/main.go +++ b/cmd/hydroxide/main.go @@ -19,6 +19,7 @@ import ( "golang.org/x/term" "github.com/emersion/hydroxide/auth" + "github.com/emersion/hydroxide/caldav" "github.com/emersion/hydroxide/carddav" "github.com/emersion/hydroxide/config" "github.com/emersion/hydroxide/events" @@ -163,6 +164,55 @@ func listenAndServeCardDAV(addr string, authManager *auth.Manager, eventsManager return s.ListenAndServe() } +func listenAndServeCalDAV(addr string, authManager *auth.Manager, eventsManager *events.Manager, tlsConfig *tls.Config) error { + handlers := make(map[string]http.Handler) + + s := &http.Server{ + Addr: addr, + TLSConfig: tlsConfig, + Handler: http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) { + resp.Header().Set("WWW-Authenticate", "Basic") + + username, password, ok := req.BasicAuth() + if !ok { + resp.WriteHeader(http.StatusUnauthorized) + io.WriteString(resp, "Credentials are required") + return + } + + c, privateKeys, err := authManager.Auth(username, password) + if err != nil { + if err == auth.ErrUnauthorized { + resp.WriteHeader(http.StatusUnauthorized) + } else { + resp.WriteHeader(http.StatusInternalServerError) + } + io.WriteString(resp, err.Error()) + return + } + + h, ok := handlers[username] + if !ok { + ch := make(chan *protonmail.Event) + eventsManager.Register(c, username, ch, nil) + h = caldav.NewHandler(c, privateKeys, ch) + + handlers[username] = h + } + + h.ServeHTTP(resp, req) + }), + } + + if s.TLSConfig != nil { + log.Println("CalDAV server listening with TLS on", s.Addr) + return s.ListenAndServeTLS("", "") + } + + log.Println("CalDAV server listening on", s.Addr) + return s.ListenAndServe() +} + func isMbox(br *bufio.Reader) (bool, error) { prefix := []byte("From ") b, err := br.Peek(len(prefix)) @@ -175,6 +225,7 @@ func isMbox(br *bufio.Reader) (bool, error) { const usage = `usage: hydroxide [options...] Commands: auth Login to ProtonMail via hydroxide + caldav Run hydroxide as a CalDAV server carddav Run hydroxide as a CardDAV server export-secret-keys Export secret keys imap Run hydroxide as an IMAP server @@ -198,18 +249,24 @@ Global options: Allowed IMAP email hostname on which hydroxide listens, defaults to 127.0.0.1 -carddav-host example.com Allowed SMTP email hostname on which hydroxide listens, defaults to 127.0.0.1 + -caldav-host example.com + Allowed CalDAV hostname on which hydroxide listens, defaults to 127.0.0.1 -smtp-port example.com SMTP port on which hydroxide listens, defaults to 1025 -imap-port example.com IMAP port on which hydroxide listens, defaults to 1143 -carddav-port example.com CardDAV port on which hydroxide listens, defaults to 8080 + -caldav-port example.com + CalDAV port on which hydroxide listens, defaults to 8081 -disable-imap Disable IMAP for hydroxide serve -disable-smtp Disable SMTP for hydroxide serve -disable-carddav Disable CardDAV for hydroxide serve + -disable-caldav + Disable CalDAV for hydroxide serve -tls-cert /path/to/cert.pem Path to the certificate to use for incoming connections (Optional) -tls-key /path/to/key.pem @@ -238,6 +295,10 @@ func main() { carddavPort := flag.String("carddav-port", "8080", "CardDAV port on which hydroxide listens, defaults to 8080") disableCardDAV := flag.Bool("disable-carddav", false, "Disable CardDAV for hydroxide serve") + caldavHost := flag.String("caldav-host", "127.0.0.1", "Allowed CalDAV hostname on which hydroxide listens, defaults to 127.0.0.1") + caldavPort := flag.String("caldav-port", "8081", "CalDAV port on which hydroxide listens, defaults to 8081") + disableCalDAV := flag.Bool("disable-caldav", false, "Disable CalDAV for hydroxide serve") + tlsCert := flag.String("tls-cert", "", "Path to the certificate to use for incoming connections") tlsCertKey := flag.String("tls-key", "", "Path to the certificate key to use for incoming connections") tlsClientCA := flag.String("tls-client-ca", "", "If set, clients must provide a certificate signed by the given CA") @@ -502,15 +563,21 @@ func main() { authManager := auth.NewManager(newClient) eventsManager := events.NewManager() log.Fatal(listenAndServeCardDAV(addr, authManager, eventsManager, tlsConfig)) + case "caldav": + addr := *caldavHost + ":" + *caldavPort + authManager := auth.NewManager(newClient) + eventsManager := events.NewManager() + log.Fatal(listenAndServeCalDAV(addr, authManager, eventsManager, tlsConfig)) case "serve": smtpAddr := *smtpHost + ":" + *smtpPort imapAddr := *imapHost + ":" + *imapPort carddavAddr := *carddavHost + ":" + *carddavPort + caldavAddr := *caldavHost + ":" + *caldavPort authManager := auth.NewManager(newClient) eventsManager := events.NewManager() - done := make(chan error, 3) + done := make(chan error, 4) if !*disableSMTP { go func() { done <- listenAndServeSMTP(smtpAddr, debug, authManager, tlsConfig) @@ -526,6 +593,11 @@ func main() { done <- listenAndServeCardDAV(carddavAddr, authManager, eventsManager, tlsConfig) }() } + if !*disableCalDAV { + go func() { + done <- listenAndServeCalDAV(caldavAddr, authManager, eventsManager, tlsConfig) + }() + } log.Fatal(<-done) case "sendmail": username := flag.Arg(1) diff --git a/go.mod b/go.mod index b3b9366e..62a1034a 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.24.0 require ( github.com/ProtonMail/go-crypto v1.4.1 github.com/emersion/go-bcrypt v0.0.0-20170822072041-6e724a1baa63 + github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6 github.com/emersion/go-imap v1.2.1 github.com/emersion/go-mbox v1.0.4 github.com/emersion/go-message v0.18.2 @@ -19,6 +20,7 @@ require ( require ( github.com/cloudflare/circl v1.6.3 // indirect + github.com/teambition/rrule-go v1.8.2 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect ) diff --git a/go.sum b/go.sum index 348d8c05..f52e4ce9 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,7 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/emersion/go-bcrypt v0.0.0-20170822072041-6e724a1baa63 h1:7aCSuwTBzg7BCPRRaBJD0weKZYdeAykOrY6ktpx8Vvc= github.com/emersion/go-bcrypt v0.0.0-20170822072041-6e724a1baa63/go.mod h1:eRwwJnuLVFtYTC+AI2JDJTMcuQUTYhBIK4I6bC5tpqw= +github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6 h1:kHoSgklT8weIDl6R6xFpBJ5IioRdBU1v2X2aCZRVCcM= github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6/go.mod h1:BEksegNspIkjCQfmzWgsgbu6KdeJ/4LwUZs7DMBzjzw= github.com/emersion/go-imap v1.2.1 h1:+s9ZjMEjOB8NzZMVTM3cCenz2JrQIGGo5j1df19WjTA= github.com/emersion/go-imap v1.2.1/go.mod h1:Qlx1FSx2FTxjnjWpIlVNEuX+ylerZQNFE5NsmKFSejY= @@ -29,6 +30,7 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/teambition/rrule-go v1.8.2 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8= github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= diff --git a/protonmail/calendar.go b/protonmail/calendar.go index 0dabd873..672c4242 100644 --- a/protonmail/calendar.go +++ b/protonmail/calendar.go @@ -1,9 +1,15 @@ package protonmail import ( + "bytes" + "io" "net/http" "net/url" "strconv" + "strings" + + "github.com/ProtonMail/go-crypto/openpgp" + "github.com/ProtonMail/go-crypto/openpgp/armor" ) const calendarPath = "/calendar/v1" @@ -37,6 +43,31 @@ type CalendarEvent struct { type CalendarEventCardType int +const ( + CalendarEventCardCleartext CalendarEventCardType = iota + CalendarEventCardEncrypted + CalendarEventCardSigned + CalendarEventCardEncryptedAndSigned +) + +func (t CalendarEventCardType) Signed() bool { + switch t { + case CalendarEventCardSigned, CalendarEventCardEncryptedAndSigned: + return true + default: + return false + } +} + +func (t CalendarEventCardType) Encrypted() bool { + switch t { + case CalendarEventCardEncrypted, CalendarEventCardEncryptedAndSigned: + return true + default: + return false + } +} + type CalendarEventCard struct { Type CalendarEventCardType Data string @@ -44,6 +75,119 @@ type CalendarEventCard struct { MemberID string } +func (card *CalendarEventCard) Read(keyring openpgp.KeyRing) (*openpgp.MessageDetails, error) { + if !card.Type.Encrypted() { + md := &openpgp.MessageDetails{ + IsEncrypted: false, + IsSigned: false, + UnverifiedBody: strings.NewReader(card.Data), + } + + if !card.Type.Signed() { + return md, nil + } + + signed := strings.NewReader(card.Data) + signature := strings.NewReader(card.Signature) + signer, err := openpgp.CheckArmoredDetachedSignature(keyring, signed, signature, nil) + md.IsSigned = true + md.SignatureError = err + if signer != nil { + md.SignedByKeyId = signer.PrimaryKey.KeyId + md.SignedBy = entityPrimaryKey(signer) + } + return md, nil + } + + ciphertextBlock, err := armor.Decode(strings.NewReader(card.Data)) + if err != nil { + return nil, err + } + + md, err := openpgp.ReadMessage(ciphertextBlock.Body, keyring, nil, nil) + if err != nil { + return nil, err + } + + if card.Type.Signed() { + r := &detachedSignatureReader{ + md: md, + signature: strings.NewReader(card.Signature), + keyring: keyring, + } + r.body = io.TeeReader(md.UnverifiedBody, &r.signed) + md.UnverifiedBody = r + } + + return md, nil +} + +func NewEncryptedCalendarEventCard(r io.Reader, to []*openpgp.Entity, signer *openpgp.Entity) (*CalendarEventCard, error) { + var msg, armored bytes.Buffer + if signer != nil { + r = io.TeeReader(r, &msg) + } + + ciphertext, err := armor.Encode(&armored, "PGP MESSAGE", nil) + if err != nil { + return nil, err + } + + cleartext, err := openpgp.Encrypt(ciphertext, to, nil, nil, nil) + if err != nil { + return nil, err + } + if _, err := io.Copy(cleartext, r); err != nil { + return nil, err + } + if err := cleartext.Close(); err != nil { + return nil, err + } + if err := ciphertext.Close(); err != nil { + return nil, err + } + + card := &CalendarEventCard{ + Type: CalendarEventCardEncrypted, + Data: armored.String(), + } + + if signer != nil { + var sig bytes.Buffer + if err := openpgp.ArmoredDetachSignText(&sig, signer, &msg, nil); err != nil { + return nil, err + } + card.Type = CalendarEventCardEncryptedAndSigned + card.Signature = sig.String() + } + + return card, nil +} + +func NewSignedCalendarEventCard(r io.Reader, signer *openpgp.Entity) (*CalendarEventCard, error) { + var msg, sig bytes.Buffer + r = io.TeeReader(r, &msg) + if err := openpgp.ArmoredDetachSignText(&sig, signer, r, nil); err != nil { + return nil, err + } + + return &CalendarEventCard{ + Type: CalendarEventCardSigned, + Data: msg.String(), + Signature: sig.String(), + }, nil +} + +type CalendarEventImport struct { + Event *CalendarEventCardSet +} + +type CalendarEventCardSet struct { + Shared *CalendarEventCard + CalendarEvent *CalendarEventCard + PersonalEvent *CalendarEventCard +} + func (c *Client) ListCalendars(page, pageSize int) ([]*Calendar, error) { v := url.Values{} v.Set("Page", strconv.Itoa(page)) @@ -67,6 +211,54 @@ func (c *Client) ListCalendars(page, pageSize int) ([]*Calendar, error) { return respData.Calendars, nil } +func (c *Client) GetCalendar(id string) (*Calendar, error) { + req, err := c.newRequest(http.MethodGet, calendarPath+"/"+id, nil) + if err != nil { + return nil, err + } + + var respData struct { + resp + Calendar *Calendar + } + if err := c.doJSON(req, &respData); err != nil { + return nil, err + } + + return respData.Calendar, nil +} + +func (c *Client) CreateCalendar(calendar *Calendar) (*Calendar, error) { + req, err := c.newJSONRequest(http.MethodPost, calendarPath, calendar) + if err != nil { + return nil, err + } + + var respData struct { + resp + Calendar *Calendar + } + if err := c.doJSON(req, &respData); err != nil { + return nil, err + } + + return respData.Calendar, nil +} + +func (c *Client) DeleteCalendar(id string) error { + req, err := c.newRequest(http.MethodDelete, calendarPath+"/"+id, nil) + if err != nil { + return err + } + + var respData resp + if err := c.doJSON(req, &respData); err != nil { + return err + } + + return nil +} + type CalendarEventFilter struct { Start, End int64 Timezone string @@ -97,5 +289,69 @@ func (c *Client) ListCalendarEvents(calendarID string, filter *CalendarEventFilt } return respData.Events, nil +} + +func (c *Client) GetCalendarEvent(calendarID, eventID string) (*CalendarEvent, error) { + req, err := c.newRequest(http.MethodGet, calendarPath+"/"+calendarID+"/events/"+eventID, nil) + if err != nil { + return nil, err + } + + var respData struct { + resp + Event *CalendarEvent + } + if err := c.doJSON(req, &respData); err != nil { + return nil, err + } + return respData.Event, nil } + +func (c *Client) CreateCalendarEvent(calendarID string, event *CalendarEventImport) (*CalendarEvent, error) { + req, err := c.newJSONRequest(http.MethodPost, calendarPath+"/"+calendarID+"/events", event) + if err != nil { + return nil, err + } + + var respData struct { + resp + Event *CalendarEvent + } + if err := c.doJSON(req, &respData); err != nil { + return nil, err + } + + return respData.Event, nil +} + +func (c *Client) UpdateCalendarEvent(calendarID, eventID string, event *CalendarEventImport) (*CalendarEvent, error) { + req, err := c.newJSONRequest(http.MethodPut, calendarPath+"/"+calendarID+"/events/"+eventID, event) + if err != nil { + return nil, err + } + + var respData struct { + resp + Event *CalendarEvent + } + if err := c.doJSON(req, &respData); err != nil { + return nil, err + } + + return respData.Event, nil +} + +func (c *Client) DeleteCalendarEvent(calendarID, eventID string) error { + req, err := c.newRequest(http.MethodDelete, calendarPath+"/"+calendarID+"/events/"+eventID, nil) + if err != nil { + return err + } + + var respData resp + if err := c.doJSON(req, &respData); err != nil { + return err + } + + return nil +} \ No newline at end of file diff --git a/protonmail/events.go b/protonmail/events.go index 562b31f7..dda59491 100644 --- a/protonmail/events.go +++ b/protonmail/events.go @@ -10,6 +10,7 @@ type EventRefresh int const ( EventRefreshMail EventRefresh = 1 << iota EventRefreshContacts + EventRefreshCalendar ) type Event struct { @@ -17,6 +18,7 @@ type Event struct { Refresh EventRefresh Messages []*EventMessage Contacts []*EventContact + CalendarEvents []*EventCalendarEvent //ContactEmails //Labels //User @@ -160,6 +162,12 @@ type EventContact struct { Contact *Contact } +type EventCalendarEvent struct { + ID string + Action EventAction + *CalendarEvent +} + func (c *Client) GetEvent(last string) (*Event, error) { if last == "" { last = "latest"