Skip to content

Commit 138f0c5

Browse files
committed
feat: Add Apple iCloud CalDAV adapter
- Implement CalDAV client for Apple iCloud calendars - Support basic auth with Apple ID + app-specific passwords - Dynamic calendar discovery and friendly name resolution - Full CRUD operations for calendar events - iCalendar parsing using go-ical library - Seamless integration with existing adapter pattern
1 parent a660ad9 commit 138f0c5

11 files changed

Lines changed: 895 additions & 32 deletions

File tree

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ documentation [here](./docs/adapters.md).
106106
- Google
107107
- Outlook
108108
- [ZEP](https://www.zep.de/en/)
109+
- Apple
109110
110111
## Sink
111112
@@ -128,6 +129,7 @@ documentation [here](./docs/adapters.md).
128129
129130
- Google
130131
- Outlook
132+
- Apple
131133
132134
## Transformers
133135
@@ -228,7 +230,7 @@ events which weren't synced with CalendarSync alone! :) )
228230
# Trademarks
229231

230232
GOOGLE is a trademark of GOOGLE INC. OUTLOOK is a trademark of Microsoft
231-
Corporation
233+
Corporation. APPLE is a trademark of Apple Inc.
232234

233235
# Relevant RFCs and Links
234236

docs/adapters.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,3 +88,53 @@ sink:
8888
```
8989

9090
If you want to use the created OAuth Application also with accounts outside of your Google Workspace, make sure to set the Usertype to `external` in the `OAuth Consent Screen` Menu.
91+
92+
## Apple CalDAV Adapter Setup
93+
94+
The Apple CalDAV adapter allows synchronization with iCloud calendars using the industry-standard CalDAV protocol. This adapter works with any Apple ID that has iCloud calendar access enabled.
95+
96+
### Prerequisites
97+
98+
Before setting up the adapter, you need:
99+
- An Apple ID with iCloud enabled
100+
- Access to Apple ID account settings (appleid.apple.com)
101+
- The calendar identifier from your Calendar.app
102+
103+
### Setting up App-Specific Password
104+
105+
Since Apple requires two-factor authentication for iCloud access, you cannot use your regular Apple ID password. Instead, you must generate an app-specific password:
106+
107+
1. Sign in to [appleid.apple.com](https://appleid.apple.com)
108+
2. Navigate to "Sign-In and Security"
109+
3. Select "App-Specific Passwords"
110+
4. Click "Generate an app-specific password"
111+
5. Enter a label like "CalendarSync"
112+
6. Copy the generated password (format: `xxxx-xxxx-xxxx-xxxx`)
113+
114+
**Important**: Save this password immediately — Apple will only show it once.
115+
116+
### Finding Your Calendar ID
117+
118+
The calendar ID is needed to specify which iCloud calendar to sync:
119+
120+
1. Open Calendar.app on macOS
121+
2. Right-click on the desired calendar in the sidebar
122+
3. Select "Get Info"
123+
4. The calendar ID is typically the calendar name in lowercase with special characters removed
124+
5. Common examples: `home`, `work`, `personal`
125+
126+
Alternatively, you can use CalDAV discovery tools or inspect the CalDAV URLs in Calendar.app's account settings.
127+
128+
### Configuration
129+
130+
```yaml
131+
source:
132+
adapter:
133+
type: "apple"
134+
calendar: "personal" # Your calendar identifier
135+
config: {}
136+
oAuth:
137+
clientId: "your-apple-id@icloud.com" # Your Apple ID email
138+
clientKey: "xxxx-xxxx-xxxx-xxxx" # App-specific password
139+
tenantId: "" # Leave empty for Apple CalDAV
140+
```

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ require (
77
github.com/aquilax/truncate v1.0.1
88
github.com/cenkalti/backoff/v4 v4.3.0
99
github.com/charmbracelet/log v0.4.2
10-
github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6
10+
github.com/emersion/go-ical v0.0.0-20250609112844-439c63cef608
1111
github.com/emersion/go-webdav v0.6.0
1212
github.com/microcosm-cc/bluemonday v1.0.27
1313
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
3636
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
3737
github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6 h1:kHoSgklT8weIDl6R6xFpBJ5IioRdBU1v2X2aCZRVCcM=
3838
github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6/go.mod h1:BEksegNspIkjCQfmzWgsgbu6KdeJ/4LwUZs7DMBzjzw=
39+
github.com/emersion/go-ical v0.0.0-20250609112844-439c63cef608 h1:5XWaET4YAcppq3l1/Yh2ay5VmQjUdq6qhJuucdGbmOY=
40+
github.com/emersion/go-ical v0.0.0-20250609112844-439c63cef608/go.mod h1:BEksegNspIkjCQfmzWgsgbu6KdeJ/4LwUZs7DMBzjzw=
3941
github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM=
4042
github.com/emersion/go-webdav v0.6.0 h1:rbnBUEXvUM2Zk65Him13LwJOBY0ISltgqM5k6T5Lq4w=
4143
github.com/emersion/go-webdav v0.6.0/go.mod h1:mI8iBx3RAODwX7PJJ7qzsKAKs/vY429YfS2/9wKnDbQ=

internal/adapter/adapter.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const (
1010
GoogleCalendarType Type = "google"
1111
ZepCalendarType Type = "zep"
1212
OutlookHttpCalendarType Type = "outlook_http"
13+
AppleCalendarType Type = "apple"
1314
)
1415

1516
// ConfigReader provides an interface for adapters to load their own configuration map.

internal/adapter/apple/adapter.go

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
package apple
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"time"
7+
8+
"github.com/charmbracelet/log"
9+
10+
"github.com/inovex/CalendarSync/internal/adapter/port"
11+
"github.com/inovex/CalendarSync/internal/auth"
12+
"github.com/inovex/CalendarSync/internal/models"
13+
)
14+
15+
const (
16+
baseUrl = "https://caldav.icloud.com"
17+
timeFormat = "20060102T150405Z"
18+
)
19+
20+
type AppleCalendarClient interface {
21+
ListEvents(ctx context.Context, starttime time.Time, endtime time.Time) ([]models.Event, error)
22+
CreateEvent(ctx context.Context, event models.Event) error
23+
UpdateEvent(ctx context.Context, event models.Event) error
24+
DeleteEvent(ctx context.Context, event models.Event) error
25+
ResolveCalendarID(ctx context.Context, calendarID string) (string, string, error)
26+
DiscoverCalendars(ctx context.Context) ([]string, error)
27+
GetCalendarHash() string
28+
}
29+
30+
type CalendarAPI struct {
31+
appleClient AppleCalendarClient
32+
calendarID string
33+
34+
username string
35+
appPassword string
36+
authenticated bool
37+
38+
logger *log.Logger
39+
40+
storage auth.Storage
41+
}
42+
43+
// Assert that the expected interfaces are implemented
44+
var _ port.Configurable = &CalendarAPI{}
45+
var _ port.LogSetter = &CalendarAPI{}
46+
47+
func (c *CalendarAPI) SetupAuth(ctx context.Context, credentials auth.Credentials, storage auth.Storage, bindPort uint) error {
48+
// Reuse OAuth fields for basic auth:
49+
// clientId = Apple ID
50+
// clientSecret = App-specific password
51+
// tenantId = unused (empty)
52+
53+
switch {
54+
case credentials.Client.Id == "":
55+
return fmt.Errorf("%s adapter 'clientId' (Apple ID) cannot be empty", c.Name())
56+
case credentials.Client.Secret == "":
57+
return fmt.Errorf("%s adapter 'clientSecret' (app-specific password) cannot be empty", c.Name())
58+
case credentials.CalendarId == "":
59+
return fmt.Errorf("%s adapter 'calendar' cannot be empty", c.Name())
60+
}
61+
62+
c.calendarID = credentials.CalendarId
63+
c.username = credentials.Client.Id
64+
c.appPassword = credentials.Client.Secret
65+
c.storage = storage
66+
c.authenticated = true
67+
return nil
68+
}
69+
70+
// Stub for interface compliance
71+
func (c *CalendarAPI) SetupOauth2(ctx context.Context, credentials auth.Credentials, storage auth.Storage, bindPort uint) error {
72+
return c.SetupAuth(ctx, credentials, storage, bindPort)
73+
}
74+
75+
func (c *CalendarAPI) SetupBasicAuth(ctx context.Context, credentials auth.Credentials, storage auth.Storage) error {
76+
switch {
77+
case credentials.Client.Id == "": // Using Client.Id as username for Apple ID
78+
return fmt.Errorf("%s adapter 'username' (Apple ID) cannot be empty", c.Name())
79+
case credentials.Client.Secret == "": // Using Client.Secret as app-specific password
80+
return fmt.Errorf("%s adapter 'appPassword' cannot be empty", c.Name())
81+
case credentials.CalendarId == "":
82+
return fmt.Errorf("%s adapter 'calendar' cannot be empty", c.Name())
83+
}
84+
85+
c.calendarID = credentials.CalendarId
86+
c.username = credentials.Client.Id
87+
c.appPassword = credentials.Client.Secret
88+
c.storage = storage
89+
90+
// For Apple CalDAV, we don't need OAuth2, just basic auth with app-specific password
91+
c.authenticated = true
92+
c.logger.Debug("Apple adapter configured with basic auth")
93+
94+
return nil
95+
}
96+
97+
func (c *CalendarAPI) Initialize(ctx context.Context, openBrowser bool, config map[string]interface{}) error {
98+
if !c.authenticated {
99+
return fmt.Errorf("Apple adapter not properly authenticated")
100+
}
101+
102+
c.appleClient = &ACalClient{
103+
Username: c.username,
104+
AppPassword: c.appPassword,
105+
CalendarID: c.calendarID,
106+
}
107+
108+
// Verify calendar resolution and discovery
109+
calendars, err := c.appleClient.DiscoverCalendars(ctx)
110+
if err != nil {
111+
log.Errorf("Calendar discovery failed: %v", err)
112+
} else {
113+
log.Infof("Discovered calendars: %v", calendars)
114+
115+
principalID, resolvedID, err := c.appleClient.ResolveCalendarID(ctx, c.calendarID)
116+
if err != nil {
117+
return fmt.Errorf("failed to resolve calendar '%s': %w", c.calendarID, err)
118+
}
119+
log.Infof("Successfully resolved calendar '%s' to %s/%s", c.calendarID, principalID, resolvedID)
120+
}
121+
122+
c.logger.Debug("Apple CalDAV client initialized")
123+
return nil
124+
}
125+
126+
func (c *CalendarAPI) EventsInTimeframe(ctx context.Context, start time.Time, end time.Time) ([]models.Event, error) {
127+
events, err := c.appleClient.ListEvents(ctx, start, end)
128+
if err != nil {
129+
return nil, err
130+
}
131+
132+
c.logger.Infof("loaded %d events between %s and %s.", len(events), start.Format(time.DateOnly), end.Format(time.DateOnly))
133+
134+
return events, nil
135+
}
136+
137+
func (c *CalendarAPI) CreateEvent(ctx context.Context, e models.Event) error {
138+
err := c.appleClient.CreateEvent(ctx, e)
139+
if err != nil {
140+
return err
141+
}
142+
143+
c.logger.Info("Event created", "title", e.ShortTitle(), "time", e.StartTime.String())
144+
145+
return nil
146+
}
147+
148+
func (c *CalendarAPI) UpdateEvent(ctx context.Context, e models.Event) error {
149+
err := c.appleClient.UpdateEvent(ctx, e)
150+
if err != nil {
151+
return err
152+
}
153+
154+
c.logger.Info("Event updated", "title", e.ShortTitle(), "time", e.StartTime.String())
155+
156+
return nil
157+
}
158+
159+
func (c *CalendarAPI) DeleteEvent(ctx context.Context, e models.Event) error {
160+
err := c.appleClient.DeleteEvent(ctx, e)
161+
if err != nil {
162+
return err
163+
}
164+
165+
c.logger.Info("Event deleted", "title", e.ShortTitle(), "time", e.StartTime.String())
166+
167+
return nil
168+
}
169+
170+
func (c *CalendarAPI) GetCalendarHash() string {
171+
return c.appleClient.GetCalendarHash()
172+
}
173+
174+
func (c *CalendarAPI) Name() string {
175+
return "Apple CalDAV"
176+
}
177+
178+
func (c *CalendarAPI) SetLogger(logger *log.Logger) {
179+
c.logger = logger
180+
}

0 commit comments

Comments
 (0)