mirror of
https://github.com/ghostersk/gowebmail.git
synced 2026-09-15 00:00:36 +01:00
158 lines
4.6 KiB
Go
158 lines
4.6 KiB
Go
// Package caldav pulls calendar events and contacts from a remote
|
|
// CalDAV/CardDAV server so they can be mirrored into gowebmail's local DB.
|
|
// One-way (server -> gowebmail) read sync only.
|
|
package caldav
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/emersion/go-ical"
|
|
"github.com/emersion/go-vcard"
|
|
dav "github.com/emersion/go-webdav"
|
|
"github.com/emersion/go-webdav/caldav"
|
|
"github.com/emersion/go-webdav/carddav"
|
|
|
|
"github.com/ghostersk/gowebmail/internal/models"
|
|
)
|
|
|
|
const timeout = 30 * time.Second
|
|
|
|
// SyncCalendar fetches all VEVENTs from the calendar collection at url
|
|
// (HTTP basic auth) and returns them as CalendarEvent rows tagged with accountID.
|
|
func SyncCalendar(ctx context.Context, url, user, pass string, accountID int64) ([]*models.CalendarEvent, error) {
|
|
hc := dav.HTTPClientWithBasicAuth(&http.Client{Timeout: timeout}, user, pass)
|
|
c, err := caldav.NewClient(hc, url)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("caldav client: %w", err)
|
|
}
|
|
objs, err := c.QueryCalendar(ctx, "", &caldav.CalendarQuery{
|
|
CompRequest: caldav.CalendarCompRequest{AllProps: true, AllComps: true},
|
|
CompFilter: caldav.CompFilter{Name: "VCALENDAR", Comps: []caldav.CompFilter{{Name: "VEVENT"}}},
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("caldav query: %w", err)
|
|
}
|
|
|
|
var out []*models.CalendarEvent
|
|
for _, obj := range objs {
|
|
if obj.Data == nil {
|
|
continue
|
|
}
|
|
for _, ev := range obj.Data.Events() {
|
|
ev := ev
|
|
out = append(out, eventFromICal(&ev, accountID))
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func eventFromICal(ev *ical.Event, accountID int64) *models.CalendarEvent {
|
|
uid, _ := ev.Props.Text(ical.PropUID)
|
|
summary, _ := ev.Props.Text(ical.PropSummary)
|
|
desc, _ := ev.Props.Text(ical.PropDescription)
|
|
loc, _ := ev.Props.Text(ical.PropLocation)
|
|
|
|
allDay := false
|
|
if p := ev.Props.Get(ical.PropDateTimeStart); p != nil {
|
|
allDay = p.ValueType() == ical.ValueDate
|
|
}
|
|
start, _ := ev.DateTimeStart(time.UTC)
|
|
end, _ := ev.DateTimeEnd(time.UTC)
|
|
if end.IsZero() {
|
|
end = start
|
|
}
|
|
|
|
status := ""
|
|
if s, err := ev.Status(); err == nil {
|
|
status = strings.ToLower(string(s))
|
|
}
|
|
|
|
organizer := ""
|
|
if p := ev.Props.Get(ical.PropOrganizer); p != nil {
|
|
organizer = strings.TrimPrefix(p.Value, "mailto:")
|
|
}
|
|
var attendees []string
|
|
for _, p := range ev.Props.Values(ical.PropAttendee) {
|
|
attendees = append(attendees, strings.TrimPrefix(p.Value, "mailto:"))
|
|
}
|
|
|
|
rrule := ""
|
|
if p := ev.Props.Get(ical.PropRecurrenceRule); p != nil {
|
|
rrule = p.Value
|
|
}
|
|
|
|
return &models.CalendarEvent{
|
|
AccountID: &accountID,
|
|
UID: uid,
|
|
Title: summary,
|
|
Description: desc,
|
|
Location: loc,
|
|
StartTime: formatEventTime(start, allDay),
|
|
EndTime: formatEventTime(end, allDay),
|
|
AllDay: allDay,
|
|
RecurrenceRule: rrule,
|
|
Status: status,
|
|
OrganizerEmail: organizer,
|
|
Attendees: strings.Join(attendees, ", "),
|
|
}
|
|
}
|
|
|
|
func formatEventTime(t time.Time, allDay bool) string {
|
|
if allDay {
|
|
return t.Format("2006-01-02")
|
|
}
|
|
return t.UTC().Format("2006-01-02T15:04:05Z")
|
|
}
|
|
|
|
// SyncContacts fetches all vCards from the address book collection at url
|
|
// (HTTP basic auth) and returns them as Contact rows tagged with accountID.
|
|
func SyncContacts(ctx context.Context, url, user, pass string, accountID int64) ([]*models.Contact, error) {
|
|
hc := dav.HTTPClientWithBasicAuth(&http.Client{Timeout: timeout}, user, pass)
|
|
c, err := carddav.NewClient(hc, url)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("carddav client: %w", err)
|
|
}
|
|
objs, err := c.QueryAddressBook(ctx, "", &carddav.AddressBookQuery{
|
|
DataRequest: carddav.AddressDataRequest{AllProp: true},
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("carddav query: %w", err)
|
|
}
|
|
|
|
var out []*models.Contact
|
|
for _, obj := range objs {
|
|
if obj.Card == nil {
|
|
continue
|
|
}
|
|
out = append(out, contactFromVCard(obj.Card, obj.Path, accountID))
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func contactFromVCard(card vcard.Card, path string, accountID int64) *models.Contact {
|
|
uid := card.PreferredValue(vcard.FieldUID)
|
|
if uid == "" {
|
|
// vCard UID is only a SHOULD in vCard 3.0 — fall back to the stable
|
|
// resource path so contacts without one don't collide on upsert.
|
|
uid = path
|
|
}
|
|
name := card.PreferredValue(vcard.FieldFormattedName)
|
|
org := card.PreferredValue(vcard.FieldOrganization)
|
|
if i := strings.Index(org, ";"); i >= 0 {
|
|
org = org[:i]
|
|
}
|
|
return &models.Contact{
|
|
AccountID: &accountID,
|
|
UID: uid,
|
|
DisplayName: name,
|
|
Email: card.PreferredValue(vcard.FieldEmail),
|
|
Phone: card.PreferredValue(vcard.FieldTelephone),
|
|
Company: org,
|
|
Notes: card.PreferredValue(vcard.FieldNote),
|
|
}
|
|
}
|