feat: inital commit
This commit is contained in:
170
cmd/mcbdd/caldav.go
Normal file
170
cmd/mcbdd/caldav.go
Normal file
@@ -0,0 +1,170 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-ical"
|
||||
"github.com/emersion/go-webdav"
|
||||
"github.com/emersion/go-webdav/caldav"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
ConstCalendarName = "Birthdays"
|
||||
)
|
||||
|
||||
func (d *Daemon) ensureBirthdayCal(ctx context.Context, httpClient webdav.HTTPClient, user string) error {
|
||||
endpoint, err := url.JoinPath(d.baseURL, "SOGo/dav", user, "Calendar/")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cl, err := caldav.NewClient(httpClient, endpoint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cc, err := cl.FindCalendars(ctx, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, c := range cc {
|
||||
if strings.HasSuffix(c.Path, fmt.Sprintf("/%s", ConstCalendarName)) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if err := cl.Mkdir(ctx, ConstCalendarName); err != nil {
|
||||
return err
|
||||
}
|
||||
slog.InfoContext(ctx, "created birthday calendar", "user", user)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Daemon) syncBirthdaysToCal(ctx context.Context, httpClient webdav.HTTPClient, user string, birthdays []BirthdayContact) error {
|
||||
endpoint, err := url.JoinPath(d.baseURL, "SOGo/dav", user, "Calendar/")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cl, err := caldav.NewClient(httpClient, endpoint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
calendarPath := fmt.Sprintf("/SOGo/dav/%s/Calendar/%s", user, ConstCalendarName)
|
||||
events, err := cl.QueryCalendar(ctx, calendarPath, &caldav.CalendarQuery{
|
||||
CompRequest: caldav.CalendarCompRequest{
|
||||
Name: "VCALENDAR",
|
||||
},
|
||||
CompFilter: caldav.CompFilter{
|
||||
Name: "VCALENDAR",
|
||||
Comps: []caldav.CompFilter{{
|
||||
Name: "VEVENT",
|
||||
}},
|
||||
Start: time.Now().Add(time.Hour * 24 * 365 * -1).UTC(),
|
||||
End: time.Now().Add(time.Hour * 24 * 365 * 100).UTC(),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bevs := generateBirthdayEvents(birthdays)
|
||||
bevsInSync := make([]int, 0)
|
||||
driftedEvents := make([]string, 0)
|
||||
for _, ev := range events {
|
||||
matchedBev := false
|
||||
for _, v := range ev.Data.Children {
|
||||
for i, bev := range bevs {
|
||||
if icalMatchesBev(v, bev) {
|
||||
bevsInSync = append(bevsInSync, i)
|
||||
matchedBev = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !matchedBev {
|
||||
driftedEvents = append(driftedEvents, ev.Path)
|
||||
}
|
||||
}
|
||||
counterDelete, counterAdded := 0, 0
|
||||
for _, v := range driftedEvents {
|
||||
if err := cl.RemoveAll(ctx, v); err != nil {
|
||||
return err
|
||||
}
|
||||
counterDelete++
|
||||
}
|
||||
for i, v := range bevs {
|
||||
if slices.Contains(bevsInSync, i) {
|
||||
continue
|
||||
}
|
||||
p, ic := v.generateICAL(calendarPath)
|
||||
_, err := cl.PutCalendarObject(ctx, p, ic)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
counterAdded++
|
||||
}
|
||||
if (counterAdded + counterDelete) > 0 {
|
||||
slog.InfoContext(ctx, "synchronized birthday events", "user", user, "added", counterAdded, "removed", counterDelete)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type birthdayEvent struct {
|
||||
Summary string
|
||||
DateTimeStart string
|
||||
DateTimeEnd string
|
||||
}
|
||||
|
||||
func generateBirthdayEvents(birthdays []BirthdayContact) []birthdayEvent {
|
||||
cyear := time.Now().Year()
|
||||
bb := make([]birthdayEvent, 0)
|
||||
for _, v := range birthdays {
|
||||
for year := cyear; year <= 10+cyear; year++ {
|
||||
yearshift := year - v.Date.Year()
|
||||
ev := birthdayEvent{
|
||||
Summary: fmt.Sprintf("%s %s", v.GivenName, v.FamilyName),
|
||||
DateTimeStart: v.Date.AddDate(yearshift, 0, 0).Format("20060102"),
|
||||
DateTimeEnd: v.Date.AddDate(yearshift, 0, 1).Format("20060102"),
|
||||
}
|
||||
if v.YearKnown {
|
||||
ev.Summary = fmt.Sprintf("%s (%d)", ev.Summary, yearshift)
|
||||
}
|
||||
bb = append(bb, ev)
|
||||
}
|
||||
}
|
||||
return bb
|
||||
}
|
||||
|
||||
func icalMatchesBev(ic *ical.Component, bev birthdayEvent) bool {
|
||||
if ic.Props.Get(ical.PropSummary) == nil || ic.Props.Get(ical.PropSummary).Value != bev.Summary {
|
||||
return false
|
||||
}
|
||||
if ic.Props.Get(ical.PropDateTimeStart) == nil || ic.Props.Get(ical.PropDateTimeStart).Value != bev.DateTimeStart {
|
||||
return false
|
||||
}
|
||||
if ic.Props.Get(ical.PropDateTimeEnd) == nil || ic.Props.Get(ical.PropDateTimeEnd).Value != bev.DateTimeEnd {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (bev birthdayEvent) generateICAL(calendar string) (string, *ical.Calendar) {
|
||||
id := uuid.New().String()
|
||||
cal := ical.NewCalendar()
|
||||
cal.Props.SetText(ical.PropProductID, "-//Marco98//MailcowBirthdayDaemon//EN")
|
||||
cal.Props.SetText(ical.PropVersion, "2.0")
|
||||
event := ical.NewComponent(ical.CompEvent)
|
||||
event.Props.SetText(ical.PropUID, id)
|
||||
event.Props.SetText(ical.PropSummary, bev.Summary)
|
||||
event.Props.SetDateTime(ical.PropDateTimeStamp, time.Now())
|
||||
start := ical.NewProp(ical.PropDateTimeStart)
|
||||
start.Value = bev.DateTimeStart
|
||||
end := ical.NewProp(ical.PropDateTimeEnd)
|
||||
end.Value = bev.DateTimeEnd
|
||||
event.Props.Set(start)
|
||||
event.Props.Set(end)
|
||||
cal.Children = append(cal.Children, event)
|
||||
return fmt.Sprintf("%s/%s.ics", calendar, id), cal
|
||||
}
|
||||
61
cmd/mcbdd/carddav.go
Normal file
61
cmd/mcbdd/carddav.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-vcard"
|
||||
"github.com/emersion/go-webdav"
|
||||
"github.com/emersion/go-webdav/carddav"
|
||||
)
|
||||
|
||||
type BirthdayContact struct {
|
||||
FamilyName string
|
||||
GivenName string
|
||||
Date time.Time
|
||||
YearKnown bool
|
||||
}
|
||||
|
||||
func (d *Daemon) getBirthdays(ctx context.Context, httpClient webdav.HTTPClient, user string) ([]BirthdayContact, error) {
|
||||
endpoint, err := url.JoinPath(d.baseURL, "SOGo/dav", user, "Contacts/")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cl, err := carddav.NewClient(httpClient, endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bb, err := cl.FindAddressBooks(ctx, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
contacts := make([]BirthdayContact, 0)
|
||||
for _, b := range bb {
|
||||
oo, err := cl.QueryAddressBook(ctx, b.Path, &carddav.AddressBookQuery{})
|
||||
if err != nil {
|
||||
if err.Error() == "501 Not Implemented" {
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
for _, v := range oo {
|
||||
nn := v.Card.Names()
|
||||
bdayprop := v.Card.Value(vcard.FieldBirthday)
|
||||
if len(nn) == 0 || len(bdayprop) == 0 {
|
||||
continue
|
||||
}
|
||||
yyyy, mm, dd, err := sanitizeBirthday(bdayprop)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
contacts = append(contacts, BirthdayContact{
|
||||
GivenName: v.Card.Names()[0].GivenName,
|
||||
FamilyName: v.Card.Names()[0].FamilyName,
|
||||
Date: time.Date(int(yyyy), time.Month(int(mm)), int(dd), 0, 0, 0, 0, time.UTC),
|
||||
YearKnown: yyyy != 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
return contacts, nil
|
||||
}
|
||||
42
cmd/mcbdd/mailcow.go
Normal file
42
cmd/mcbdd/mailcow.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
func (d *Daemon) getUserPass(ctx context.Context, username string) (string, error) {
|
||||
d.userTokensLock.RLock()
|
||||
pass, ok := d.userTokens[username]
|
||||
d.userTokensLock.RUnlock()
|
||||
if ok {
|
||||
return pass, nil
|
||||
}
|
||||
pp, err := d.mailcowClient.GetAppPasswords(ctx, username)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
oldIDs := make([]int, 0)
|
||||
for _, p := range pp {
|
||||
if p.Name == ConstUsertokenName {
|
||||
oldIDs = append(oldIDs, p.ID)
|
||||
}
|
||||
}
|
||||
if err := d.mailcowClient.DeleteAppPasswords(ctx, oldIDs); err != nil {
|
||||
return "", fmt.Errorf("error deleting app passwords: %w", err)
|
||||
}
|
||||
pass, err = randomPassword(32)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error generating password: %w", err)
|
||||
}
|
||||
if err := d.mailcowClient.CreateAppPassword(ctx, username, ConstUsertokenName, pass, "dav_access"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
slog.InfoContext(ctx, "created new app password", "user", username)
|
||||
d.userTokensLock.Lock()
|
||||
d.userTokens[username] = pass
|
||||
d.userTokensUnsaved = true
|
||||
d.userTokensLock.Unlock()
|
||||
return pass, nil
|
||||
}
|
||||
125
cmd/mcbdd/main.go
Normal file
125
cmd/mcbdd/main.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Marco98/mailcow-birthday-daemon/pkg/mailcow"
|
||||
"github.com/emersion/go-webdav"
|
||||
)
|
||||
|
||||
const (
|
||||
ConstUsertokenName = "Birthday Daemon"
|
||||
)
|
||||
|
||||
var (
|
||||
version = "dev"
|
||||
commit = "none"
|
||||
date = "unknown"
|
||||
)
|
||||
|
||||
type Daemon struct {
|
||||
userTokens map[string]string
|
||||
userTokensLock *sync.RWMutex
|
||||
userTokensUnsaved bool
|
||||
httpClient *http.Client
|
||||
baseURL string
|
||||
mailcowClient mailcow.Client
|
||||
statefile string
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
slog.Error("fatal error", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
slog.Info("starting mcbdd", "version", version, "commit", commit, "date", date)
|
||||
d := &Daemon{
|
||||
userTokens: make(map[string]string),
|
||||
userTokensLock: &sync.RWMutex{},
|
||||
baseURL: os.Getenv("MAILCOW_BASE"),
|
||||
statefile: os.Getenv("STATEFILE"),
|
||||
httpClient: &http.Client{
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
},
|
||||
},
|
||||
}
|
||||
if len(d.statefile) == 0 {
|
||||
d.statefile = "state.json"
|
||||
}
|
||||
d.mailcowClient = mailcow.New(
|
||||
d.httpClient,
|
||||
d.baseURL,
|
||||
os.Getenv("MAILCOW_APIKEY"),
|
||||
)
|
||||
if err := d.LoadFromDisk(); err != nil {
|
||||
return err
|
||||
}
|
||||
d.daemonLoop()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Daemon) daemonLoop() {
|
||||
for {
|
||||
if err := d.daemonRun(); err != nil {
|
||||
slog.Error("error while syncing birthdays", "err", err)
|
||||
}
|
||||
time.Sleep(time.Minute * 15)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Daemon) daemonRun() error {
|
||||
mb, err := d.mailcowClient.GetMailboxes(context.Background())
|
||||
if err != nil {
|
||||
return fmt.Errorf("error fetching mailboxes: %w", err)
|
||||
}
|
||||
eg := sync.WaitGroup{}
|
||||
for _, m := range mb {
|
||||
eg.Go(func() {
|
||||
ctx := context.Background()
|
||||
if err := d.processUser(ctx, m); err != nil {
|
||||
slog.ErrorContext(ctx, "error processing user", "err", err, "user", m.Username)
|
||||
}
|
||||
})
|
||||
}
|
||||
eg.Wait()
|
||||
if d.userTokensUnsaved {
|
||||
slog.Info("saving tokens to disk", "count", len(d.userTokens))
|
||||
if err := d.SaveToDisk(); err != nil {
|
||||
return err
|
||||
}
|
||||
d.userTokensUnsaved = false
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Daemon) processUser(ctx context.Context, m mailcow.Mailbox) error {
|
||||
if !m.IsActive() {
|
||||
return nil
|
||||
}
|
||||
pass, err := d.getUserPass(ctx, m.Username)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error getting userpass: %w", err)
|
||||
}
|
||||
davclient := webdav.HTTPClientWithBasicAuth(d.httpClient, m.Username, pass)
|
||||
bb, err := d.getBirthdays(ctx, davclient, m.Username)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error getting birthdays from carddav: %w", err)
|
||||
}
|
||||
if err := d.ensureBirthdayCal(ctx, davclient, m.Username); err != nil {
|
||||
return fmt.Errorf("error creating birthday calendar in caldav: %w", err)
|
||||
}
|
||||
if err := d.syncBirthdaysToCal(ctx, davclient, m.Username, bb); err != nil {
|
||||
return fmt.Errorf("error syncing birthday events to caldav: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
28
cmd/mcbdd/persist.go
Normal file
28
cmd/mcbdd/persist.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
)
|
||||
|
||||
func (d *Daemon) LoadFromDisk() error {
|
||||
f, err := os.OpenFile(d.statefile, os.O_RDONLY, 0o660)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
return json.NewDecoder(f).Decode(&d.userTokens)
|
||||
}
|
||||
|
||||
func (d *Daemon) SaveToDisk() error {
|
||||
f, err := os.OpenFile(d.statefile, os.O_CREATE|os.O_WRONLY, 0o660)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
return json.NewEncoder(f).Encode(d.userTokens)
|
||||
}
|
||||
67
cmd/mcbdd/util.go
Normal file
67
cmd/mcbdd/util.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
ConstPassgenChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%^&*()_+-=[]{}\\|;':\",.<>/?`~0123456789"
|
||||
)
|
||||
|
||||
func randomElement(s string) (string, error) {
|
||||
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(s))))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to generate random integer: %w", err)
|
||||
}
|
||||
return string(s[n.Int64()]), nil
|
||||
}
|
||||
|
||||
func randomPassword(length int) (string, error) {
|
||||
pass := make([]byte, length)
|
||||
for i := range pass {
|
||||
char, err := randomElement(ConstPassgenChars)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
pass[i] = []byte(char)[0]
|
||||
}
|
||||
return string(pass), nil
|
||||
}
|
||||
|
||||
func sanitizeBirthday(input string) (uint16, uint16, uint16, error) {
|
||||
input = strings.ReplaceAll(input, "-", "")
|
||||
switch len(input) {
|
||||
case 4:
|
||||
mm, err := strconv.ParseUint(input[0:2], 10, 16)
|
||||
if err != nil {
|
||||
return 0, 0, 0, err
|
||||
}
|
||||
dd, err := strconv.ParseUint(input[2:4], 10, 16)
|
||||
if err != nil {
|
||||
return 0, 0, 0, err
|
||||
}
|
||||
return 0, uint16(mm), uint16(dd), nil
|
||||
case 8:
|
||||
yyyy, err := strconv.ParseUint(input[0:4], 10, 16)
|
||||
if err != nil {
|
||||
return 0, 0, 0, err
|
||||
}
|
||||
mm, err := strconv.ParseUint(input[4:6], 10, 16)
|
||||
if err != nil {
|
||||
return 0, 0, 0, err
|
||||
}
|
||||
dd, err := strconv.ParseUint(input[6:8], 10, 16)
|
||||
if err != nil {
|
||||
return 0, 0, 0, err
|
||||
}
|
||||
if yyyy == 1604 {
|
||||
yyyy = 0
|
||||
}
|
||||
return uint16(yyyy), uint16(mm), uint16(dd), nil
|
||||
}
|
||||
return 0, 0, 0, fmt.Errorf("birthday prop format unknown: %s", input)
|
||||
}
|
||||
71
cmd/mcbdd/util_test.go
Normal file
71
cmd/mcbdd/util_test.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func Test_sanitizeBirthday(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string // description of this test case
|
||||
// Named input parameters for target function.
|
||||
input string
|
||||
want uint16
|
||||
want2 uint16
|
||||
want3 uint16
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "test normal date w/o dashes",
|
||||
input: "20001203",
|
||||
want: 2000,
|
||||
want2: 12,
|
||||
want3: 0o3,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "test normal date w/ dashes",
|
||||
input: "2005-05-16",
|
||||
want: 2005,
|
||||
want2: 5,
|
||||
want3: 16,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "test normal date w/o year w/o dashes",
|
||||
input: "1203",
|
||||
want: 0,
|
||||
want2: 12,
|
||||
want3: 0o3,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "test normal date w/o year w/ dashes",
|
||||
input: "-05-16",
|
||||
want: 0,
|
||||
want2: 5,
|
||||
want3: 16,
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, got2, got3, gotErr := sanitizeBirthday(tt.input)
|
||||
if gotErr != nil {
|
||||
if !tt.wantErr {
|
||||
t.Errorf("sanitizeBirthday() failed: %v", gotErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if tt.wantErr {
|
||||
t.Fatal("sanitizeBirthday() succeeded unexpectedly")
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("sanitizeBirthday() = %v, want %v", got, tt.want)
|
||||
}
|
||||
if got2 != tt.want2 {
|
||||
t.Errorf("sanitizeBirthday() = %v, want %v", got2, tt.want2)
|
||||
}
|
||||
if got3 != tt.want3 {
|
||||
t.Errorf("sanitizeBirthday() = %v, want %v", got3, tt.want3)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user