feat: inital commit

This commit is contained in:
Marco98
2025-11-11 13:41:51 +01:00
commit b00c239a8b
20 changed files with 1020 additions and 0 deletions

170
cmd/mcbdd/caldav.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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)
}
})
}
}