Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Outlook Published Calendar Adapter #228

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added assets/outlook_published_devtools.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
32 changes: 32 additions & 0 deletions docs/adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,35 @@ and a `clientSecret`. Make sure to add the `clientSecret` to the `clientKey`
setting in your CalendarSync configuration.

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.


## Outlook Published Calendar Adapter Setup
The Outlook Published Calendar adapter allows you to sync with publicly shared Outlook calendars without OAuth authentication.

### Configuration
```yaml
source:
adapter:
type: "outlook_published"
config:
url: "<url>"
postData: "<url-post-data>"
```

### How to Get Configuration Values

3. Get the URL and postData:
- Open the calendar.html URL in your browser with Developer Tools open (F12)
- In the Network tab, look for a request to `service.svc` with parameters `action=FindItem&app=PublishedCalendar`
- From this request:
- For `url`: Copy the full request URL (example format: `https://outlook.office365.com/owa/published/<calendar-id>@<domain>/<unique-identifier>/service.svc?action=FindItem&app=PublishedCalendar&n=2`)
- For `postData`: In the request headers, find and copy the value of the `x-owa-urlpostdata` header

![](../assets/outlook_published_devtools.png)

Note: The postData value contains time range information that the adapter will automatically update during synchronization.

### Limitations
- Read-only access (cannot create, update, or delete events)
- Requires the calendar to be publicly accessible
- Some event details might be limited compared to the OAuth-based adapter
12 changes: 12 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4
cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg=
filippo.io/age v1.2.0 h1:vRDp7pUMaAJzXNIWJVAZnEf/Dyi4Vu4wI8S1LBzufhE=
filippo.io/age v1.2.0/go.mod h1:JL9ew2lTN+Pyft4RiNGguFfOpewKwSHm5ayKD/A4004=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/aquilax/truncate v1.0.0 h1:UgIGS8U/aZ4JyOJ2h3xcF5cSQ06+gGBnjxH2RUHJe0U=
github.com/aquilax/truncate v1.0.0/go.mod h1:BeMESIDMlvlS3bmg4BVvBbbZUNwWtS8uzYPAKXwwhLw=
github.com/arran4/golang-ical v0.3.1 h1:v13B3eQZ9VDHTAvT6M11vVzxYgcYmjyPBE2eAZl3VZk=
github.com/arran4/golang-ical v0.3.1/go.mod h1:LZWxF8ZIu/sjBVUCV0udiVPrQAgq3V0aa0RfbO99Qkk=
github.com/aquilax/truncate v1.0.1 h1:+hqGSRxnQ0F5wdPCGbi1XW4ipQ6vzpli23V9Rd+I/mc=
github.com/aquilax/truncate v1.0.1/go.mod h1:BeMESIDMlvlS3bmg4BVvBbbZUNwWtS8uzYPAKXwwhLw=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
Expand All @@ -24,6 +29,7 @@ github.com/charmbracelet/log v0.4.0 h1:G9bQAcx8rWA2T3pWvx7YtPTPwgqpk7D68BX21IRW8
github.com/charmbracelet/log v0.4.0/go.mod h1:63bXt/djrizTec0l11H20t8FDSvA4CRZJ1KH22MdptM=
github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc=
github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
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-ical v0.0.0-20240127095438-fc1c9d8fb2b6 h1:kHoSgklT8weIDl6R6xFpBJ5IioRdBU1v2X2aCZRVCcM=
Expand Down Expand Up @@ -52,6 +58,9 @@ github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrk
github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98=
Expand All @@ -65,6 +74,7 @@ github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU=
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
Expand Down Expand Up @@ -125,5 +135,7 @@ google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/g
google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
7 changes: 4 additions & 3 deletions internal/adapter/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ import (
type Type string

const (
GoogleCalendarType Type = "google"
ZepCalendarType Type = "zep"
OutlookHttpCalendarType Type = "outlook_http"
GoogleCalendarType Type = "google"
ZepCalendarType Type = "zep"
OutlookHttpCalendarType Type = "outlook_http"
OutlookPublishedCalendarType Type = "outlook_published"
)

// ConfigReader provides an interface for adapters to load their own configuration map.
Expand Down
2 changes: 1 addition & 1 deletion internal/adapter/google/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ func (c *CalendarAPI) Initialize(ctx context.Context, openBrowser bool, config m
if !c.authenticated {
c.oAuthUrl = c.oAuthHandler.Configuration().AuthCodeURL("state", oauth2.AccessTypeOffline)
if openBrowser {
c.logger.Infof("opening browser window for authentication of %s\n", c.Name())
c.logger.Infof("opening browser window for authentication of %s - %s\n", c.Name(), c.calendarID)
err := browser.OpenURL(c.oAuthUrl)
if err != nil {
c.logger.Infof("browser did not open, please authenticate adapter %s:\n\n %s\n\n\n", c.Name(), c.oAuthUrl)
Expand Down
2 changes: 1 addition & 1 deletion internal/adapter/outlook_http/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ func (c *CalendarAPI) Initialize(ctx context.Context, openBrowser bool, config m
c.oAuthUrl = c.oAuthHandler.Configuration().AuthCodeURL("state", oauth2.AccessTypeOffline)

if openBrowser {
c.logger.Infof("opening browser window for authentication of %s\n", c.Name())
c.logger.Infof("opening browser window for authentication of %s - %s\n", c.Name(), c.calendarID)
err := browser.OpenURL(c.oAuthUrl)
if err != nil {
c.logger.Infof("browser did not open, please authenticate adapter %s:\n\n %s\n\n\n", c.Name(), c.oAuthUrl)
Expand Down
50 changes: 50 additions & 0 deletions internal/adapter/outlook_published/adapter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package outlook_published

import (
"context"
"github.com/charmbracelet/log"
"github.com/inovex/CalendarSync/internal/models"
"time"
)

type OutlookPublishedClient interface {
ListEvents(ctx context.Context, starttime time.Time, enddtime time.Time) ([]models.Event, error)
}

type CalendarAPI struct {
opb OutlookPublishedClient
calendarUrl string
urlPostData string
logger *log.Logger
}

func (c *CalendarAPI) EventsInTimeframe(ctx context.Context, start time.Time, end time.Time) ([]models.Event, error) {
events, err := c.opb.ListEvents(ctx, start, end)
if err != nil {
return nil, err
}

c.logger.Infof("loaded %d events between %s and %s.", len(events), start.Format(time.RFC1123), end.Format(time.RFC1123))

return events, nil
}

func (c *CalendarAPI) Name() string {
return "outlook_published"
}

func (c *CalendarAPI) GetCalendarID() string {
return c.calendarUrl
}

func (c *CalendarAPI) Initialize(ctx context.Context, openBrowser bool, config map[string]interface{}) error {
c.calendarUrl = config["url"].(string)
c.urlPostData = config["postData"].(string)

c.opb = &OutlookPubClient{url: c.calendarUrl, urlPostData: c.urlPostData}
return nil
}

func (c *CalendarAPI) SetLogger(logger *log.Logger) {
c.logger = logger
}
113 changes: 113 additions & 0 deletions internal/adapter/outlook_published/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package outlook_published

import (
"context"
"encoding/json"
"fmt"
"github.com/inovex/CalendarSync/internal/models"
"io"
"net/http"
"net/url"
"strings"
"time"
)

type OutlookPubClient struct {
url string
urlPostData string
}

func (opb *OutlookPubClient) ListEvents(ctx context.Context, starttime time.Time, enddtime time.Time) ([]models.Event, error) {
var loadedEvents []models.Event
var filteredEvents []models.Event

decodedUrlPostData, _ := url.QueryUnescape(opb.urlPostData)

startTimeFormatted := starttime.Format("2006-01-02T15:04:05.000")
endTimeFormatted := enddtime.Format("2006-01-02T15:04:05.000")

decodedUrlPostData = strings.Replace(decodedUrlPostData, "2024-11-01T00:00:00.000", startTimeFormatted, 1)
decodedUrlPostData = strings.Replace(decodedUrlPostData, "2025-01-05T23:59:59.999", endTimeFormatted, 1)

// Encode the modified URL post data
encodedUrlPostData := url.QueryEscape(decodedUrlPostData)
encodedUrlPostData = strings.Replace(encodedUrlPostData, "+", "%20", -1)

req, err := http.NewRequestWithContext(ctx, "POST", opb.url, nil)
if err != nil {
return nil, err
}

req.Header.Add("action", "FindItem")
req.Header.Add("content-length", "0")
req.Header.Add("user-agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36")
req.Header.Add("x-owa-urlpostdata", encodedUrlPostData)

client := http.Client{}
resp, err := client.Do(req)
if err != nil {
return nil, err
}

body, err := io.ReadAll(resp.Body) // Read the body from the response.
if err != nil {
return nil, fmt.Errorf("error reading response body: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to fetch events: %s", resp.Status)
}

var response Response
err = json.Unmarshal([]byte(body), &response)
if err != nil {
return nil, err
}

for _, event := range response.Body.ResponseMessages.Items[0].RootFolder.Items {
loadedEvents = append(loadedEvents, opb.calendarEventToEvent(event))
}

for _, loadedEvent := range loadedEvents {
if loadedEvent.StartTime.After(starttime) && loadedEvent.EndTime.Before(enddtime) {
filteredEvents = append(filteredEvents, loadedEvent)
}
}

return filteredEvents, nil
}

func (opb *OutlookPubClient) calendarEventToEvent(e ItemRootFolder) models.Event {
var metadata models.Metadata
metadata = models.Metadata{SyncID: e.ItemId.Id, SourceID: opb.url}

var dateStart time.Time
var dateEnd time.Time

dateStart, err := time.Parse(time.RFC3339, e.Start)
if err != nil {
fmt.Printf("error parsing start date: %v\n", err)
}

dateEnd, err = time.Parse(time.RFC3339, e.End)
if err != nil {
fmt.Printf("error parsing end date: %v\n", err)
}

return models.Event{
ICalUID: e.ItemId.Id,
ID: e.ItemId.Id,
Title: e.Subject,
Description: "",
Location: "",
AllDay: false,
StartTime: dateStart,
EndTime: dateEnd,
Metadata: &metadata,
//Attendees:
//Reminders:
//MeetingLink:
Accepted: true,
}
}
33 changes: 33 additions & 0 deletions internal/adapter/outlook_published/outlook_json_event.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package outlook_published

type Response struct {
Body Body `json:"Body"`
}

type Body struct {
ResponseMessages ResponseMessages `json:"ResponseMessages"`
}

type ResponseMessages struct {
Items []Item `json:"Items"`
}

type Item struct {
RootFolder RootFolder `json:"RootFolder"`
}

type RootFolder struct {
Items []ItemRootFolder `json:"Items"`
}

type ItemRootFolder struct {
Subject string `json:"Subject"`
Start string `json:"Start"`
End string `json:"End"`
CalendarItemType string `json:"CalendarItemType"`
ItemId ItemId `json:"ItemId"`
}

type ItemId struct {
Id string `json:"Id"`
}
3 changes: 3 additions & 0 deletions internal/adapter/source_adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package adapter
import (
"context"
"fmt"
"github.com/inovex/CalendarSync/internal/adapter/outlook_published"
"time"

"github.com/charmbracelet/log"
Expand All @@ -22,6 +23,8 @@ func SourceClientFactory(typ Type) (sync.Source, error) {
switch typ {
case GoogleCalendarType:
return new(google.CalendarAPI), nil
case OutlookPublishedCalendarType:
return new(outlook_published.CalendarAPI), nil
case ZepCalendarType:
return new(zep.CalendarAPI), nil
case OutlookHttpCalendarType:
Expand Down