release-v0.4.0 #16

Merged
scriptos merged 11 commits from release-v0.4.0 into master 2026-03-29 21:56:16 +00:00
16 changed files with 763 additions and 76 deletions
+26 -5
View File
@@ -1,19 +1,42 @@
name: Make Release name: Make Release
on: on:
push: workflow_dispatch:
tags: inputs:
- 'v*' version:
description: 'Release-Version (z.B. 0.3.3 ohne v-Prefix)'
required: true
type: string
jobs: jobs:
release: release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Validate version format
run: |
VERSION="${{ github.event.inputs.version }}"
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "::error::Ungültiges Versionsformat: '$VERSION'. Erwartet: X.Y.Z (z.B. 0.3.3)"
exit 1
fi
echo "VERSION=v${VERSION}" >> $GITHUB_ENV
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Create and push tag
run: |
git config user.name "Gitea Actions"
git config user.email "actions@git.techniverse.net"
if git rev-parse "$VERSION" >/dev/null 2>&1; then
echo "::error::Tag $VERSION existiert bereits!"
exit 1
fi
git tag -a "$VERSION" -m "Release $VERSION"
git push origin "$VERSION"
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v5 uses: actions/setup-go@v5
with: with:
@@ -32,6 +55,4 @@ jobs:
args: release --clean args: release --clean
env: env:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }} GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
REGISTRY_URL: ${{ secrets.REGISTRY_URL }}
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
GORELEASER_FORCE_TOKEN: gitea GORELEASER_FORCE_TOKEN: gitea
+1 -14
View File
@@ -26,20 +26,7 @@ checksum:
snapshot: snapshot:
name_template: "{{ incpatch .Version }}-next" name_template: "{{ incpatch .Version }}-next"
release: release:
gitea: disable: true
owner: "{{ .Env.REGISTRY_USER }}"
name: mailcow-birthday-daemon
prerelease: auto
gitea_urls:
api: "{{ .Env.REGISTRY_URL }}/api/v1/"
download: "{{ .Env.REGISTRY_URL }}"
skip_tls_verify: false
changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
upx: upx:
- enabled: true - enabled: true
goos: [linux] goos: [linux]
Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 66 KiB

+176 -5
View File
@@ -2,8 +2,10 @@ package main
import ( import (
"context" "context"
"encoding/xml"
"fmt" "fmt"
"log/slog" "log/slog"
"net/http"
"net/url" "net/url"
"slices" "slices"
"strings" "strings"
@@ -23,6 +25,7 @@ const (
// ihn nur, wenn alle enthaltenen Events die Daemon-PRODID tragen. Enthält der // ihn nur, wenn alle enthaltenen Events die Daemon-PRODID tragen. Enthält der
// Kalender fremde Events, wird er nicht gelöscht und eine Warnung geloggt. // Kalender fremde Events, wird er nicht gelöscht und eine Warnung geloggt.
func (d *Daemon) cleanupOldCalendar(ctx context.Context, httpClient webdav.HTTPClient, user, oldName string) error { func (d *Daemon) cleanupOldCalendar(ctx context.Context, httpClient webdav.HTTPClient, user, oldName string) error {
slog.DebugContext(ctx, "checking for old calendar to clean up", "user", user, "oldName", oldName)
endpoint, err := url.JoinPath(d.baseURL, "SOGo/dav", user, "Calendar/") endpoint, err := url.JoinPath(d.baseURL, "SOGo/dav", user, "Calendar/")
if err != nil { if err != nil {
return err return err
@@ -35,6 +38,7 @@ func (d *Daemon) cleanupOldCalendar(ctx context.Context, httpClient webdav.HTTPC
if err != nil { if err != nil {
return err return err
} }
slog.DebugContext(ctx, "found calendars", "user", user, "count", len(cc))
found := false found := false
for _, c := range cc { for _, c := range cc {
if strings.HasSuffix(c.Path, fmt.Sprintf("/%s", oldName)) { if strings.HasSuffix(c.Path, fmt.Sprintf("/%s", oldName)) {
@@ -43,8 +47,10 @@ func (d *Daemon) cleanupOldCalendar(ctx context.Context, httpClient webdav.HTTPC
} }
} }
if !found { if !found {
slog.DebugContext(ctx, "old calendar not found, nothing to clean up", "user", user, "oldName", oldName)
return nil return nil
} }
slog.DebugContext(ctx, "old calendar found, checking events", "user", user, "oldName", oldName)
calendarPath := fmt.Sprintf("/SOGo/dav/%s/Calendar/%s", user, oldName) calendarPath := fmt.Sprintf("/SOGo/dav/%s/Calendar/%s", user, oldName)
events, err := cl.QueryCalendar(ctx, calendarPath, &caldav.CalendarQuery{ events, err := cl.QueryCalendar(ctx, calendarPath, &caldav.CalendarQuery{
CompRequest: caldav.CalendarCompRequest{ CompRequest: caldav.CalendarCompRequest{
@@ -76,6 +82,7 @@ func (d *Daemon) cleanupOldCalendar(ctx context.Context, httpClient webdav.HTTPC
} }
func (d *Daemon) ensureBirthdayCal(ctx context.Context, httpClient webdav.HTTPClient, user string) error { func (d *Daemon) ensureBirthdayCal(ctx context.Context, httpClient webdav.HTTPClient, user string) error {
slog.DebugContext(ctx, "ensuring birthday calendar exists", "user", user, "calendar", d.calendarName)
endpoint, err := url.JoinPath(d.baseURL, "SOGo/dav", user, "Calendar/") endpoint, err := url.JoinPath(d.baseURL, "SOGo/dav", user, "Calendar/")
if err != nil { if err != nil {
return err return err
@@ -90,13 +97,14 @@ func (d *Daemon) ensureBirthdayCal(ctx context.Context, httpClient webdav.HTTPCl
} }
for _, c := range cc { for _, c := range cc {
if strings.HasSuffix(c.Path, fmt.Sprintf("/%s", d.calendarName)) { if strings.HasSuffix(c.Path, fmt.Sprintf("/%s", d.calendarName)) {
slog.DebugContext(ctx, "birthday calendar already exists", "user", user)
return nil return nil
} }
} }
if err := cl.Mkdir(ctx, d.calendarName); err != nil { if err := cl.Mkdir(ctx, d.calendarName); err != nil {
return err return err
} }
slog.InfoContext(ctx, "created birthday calendar", "user", user) slog.InfoContext(ctx, "created birthday calendar", "user", user, "calendar", d.calendarName)
return nil return nil
} }
@@ -126,7 +134,9 @@ func (d *Daemon) syncBirthdaysToCal(ctx context.Context, httpClient webdav.HTTPC
if err != nil { if err != nil {
return err return err
} }
bevs := generateBirthdayEvents(birthdays) slog.DebugContext(ctx, "queried existing calendar events", "user", user, "count", len(events))
bevs := generateBirthdayEvents(birthdays, d.eventYears)
slog.DebugContext(ctx, "generated birthday events", "user", user, "count", len(bevs))
bevsInSync := make([]int, 0) bevsInSync := make([]int, 0)
driftedEvents := make([]string, 0) driftedEvents := make([]string, 0)
for _, ev := range events { for _, ev := range events {
@@ -145,6 +155,7 @@ func (d *Daemon) syncBirthdaysToCal(ctx context.Context, httpClient webdav.HTTPC
} }
counterDelete, counterAdded := 0, 0 counterDelete, counterAdded := 0, 0
for _, v := range driftedEvents { for _, v := range driftedEvents {
slog.DebugContext(ctx, "removing drifted calendar event", "user", user, "path", v)
if err := cl.RemoveAll(ctx, v); err != nil { if err := cl.RemoveAll(ctx, v); err != nil {
return err return err
} }
@@ -154,6 +165,7 @@ func (d *Daemon) syncBirthdaysToCal(ctx context.Context, httpClient webdav.HTTPC
if slices.Contains(bevsInSync, i) { if slices.Contains(bevsInSync, i) {
continue continue
} }
slog.DebugContext(ctx, "adding calendar event", "user", user, "summary", v.Summary, "start", v.DateTimeStart)
p, ic := v.generateICAL(calendarPath, d.notificationEnabled, d.notificationTrigger) p, ic := v.generateICAL(calendarPath, d.notificationEnabled, d.notificationTrigger)
_, err := cl.PutCalendarObject(ctx, p, ic) _, err := cl.PutCalendarObject(ctx, p, ic)
if err != nil { if err != nil {
@@ -163,6 +175,8 @@ func (d *Daemon) syncBirthdaysToCal(ctx context.Context, httpClient webdav.HTTPC
} }
if (counterAdded + counterDelete) > 0 { if (counterAdded + counterDelete) > 0 {
slog.InfoContext(ctx, "synchronized birthday events", "user", user, "added", counterAdded, "removed", counterDelete) slog.InfoContext(ctx, "synchronized birthday events", "user", user, "added", counterAdded, "removed", counterDelete)
} else {
slog.DebugContext(ctx, "birthday events already in sync", "user", user, "total", len(bevs))
} }
return nil return nil
} }
@@ -173,14 +187,18 @@ type birthdayEvent struct {
DateTimeEnd string DateTimeEnd string
} }
func generateBirthdayEvents(birthdays []BirthdayContact) []birthdayEvent { func generateBirthdayEvents(birthdays []BirthdayContact, eventYears int) []birthdayEvent {
cyear := time.Now().Year() cyear := time.Now().Year()
bb := make([]birthdayEvent, 0) bb := make([]birthdayEvent, 0)
for _, v := range birthdays { for _, v := range birthdays {
for year := cyear; year <= 10+cyear; year++ { for year := cyear; year <= eventYears+cyear; year++ {
yearshift := year - v.Date.Year() yearshift := year - v.Date.Year()
prefix := "\U0001F382 " // 🎂
if v.Type == ContactTypeAnniversary {
prefix = "\U0001F48D " // 💍
}
ev := birthdayEvent{ ev := birthdayEvent{
Summary: fmt.Sprintf("%s %s", v.GivenName, v.FamilyName), Summary: fmt.Sprintf("%s%s %s", prefix, v.GivenName, v.FamilyName),
DateTimeStart: v.Date.AddDate(yearshift, 0, 0).Format("20060102"), DateTimeStart: v.Date.AddDate(yearshift, 0, 0).Format("20060102"),
DateTimeEnd: v.Date.AddDate(yearshift, 0, 1).Format("20060102"), DateTimeEnd: v.Date.AddDate(yearshift, 0, 1).Format("20060102"),
} }
@@ -254,3 +272,156 @@ func (bev birthdayEvent) generateICAL(calendar string, notificationEnabled bool,
cal.Children = append(cal.Children, event) cal.Children = append(cal.Children, event)
return fmt.Sprintf("%s/%s.ics", calendar, id), cal return fmt.Sprintf("%s/%s.ics", calendar, id), cal
} }
// XML-Strukturen für das Parsen der PROPFIND-Antwort (calendar-color).
type multistatusResponse struct {
XMLName xml.Name `xml:"DAV: multistatus"`
Responses []davResponse `xml:"DAV: response"`
}
type davResponse struct {
Href string `xml:"DAV: href"`
PropStats []davPropStat `xml:"DAV: propstat"`
}
type davPropStat struct {
Prop davProp `xml:"DAV: prop"`
Status string `xml:"DAV: status"`
}
type davProp struct {
CalendarColor string `xml:"http://apple.com/ns/ical/ calendar-color"`
}
// normalizeColor bringt eine Kalenderfarbe in ein einheitliches Format (#rrggbb,
// Kleinbuchstaben, ohne Alpha-Kanal), damit zuverlässig verglichen werden kann.
func normalizeColor(c string) string {
c = strings.TrimSpace(strings.ToLower(c))
if len(c) == 9 && strings.HasPrefix(c, "#") {
c = c[:7]
}
return c
}
// getCalendarColor liest die calendar-color-Property (Apple-Namespace) des
// Geburtstagskalenders per PROPFIND aus und gibt sie normalisiert zurück.
func (d *Daemon) getCalendarColor(ctx context.Context, httpClient webdav.HTTPClient, user string) (string, error) {
calendarURL, err := url.JoinPath(d.baseURL, "SOGo/dav", user, "Calendar", d.calendarName)
if err != nil {
return "", err
}
body := `<?xml version="1.0" encoding="UTF-8"?>
<d:propfind xmlns:d="DAV:" xmlns:A="http://apple.com/ns/ical/">
<d:prop>
<A:calendar-color/>
</d:prop>
</d:propfind>`
req, err := http.NewRequestWithContext(ctx, "PROPFIND", calendarURL, strings.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/xml; charset=utf-8")
req.Header.Set("Depth", "0")
resp, err := httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusMultiStatus {
return "", fmt.Errorf("PROPFIND calendar-color returned status %d", resp.StatusCode)
}
var ms multistatusResponse
if err := xml.NewDecoder(resp.Body).Decode(&ms); err != nil {
return "", fmt.Errorf("error parsing PROPFIND response: %w", err)
}
for _, r := range ms.Responses {
for _, ps := range r.PropStats {
if ps.Prop.CalendarColor != "" {
return normalizeColor(ps.Prop.CalendarColor), nil
}
}
}
return "", nil
}
// setCalendarColor setzt die calendar-color-Property (Apple-Namespace) des
// Geburtstagskalenders per PROPPATCH. Die Farbe wird mit Alpha-Kanal (#RRGGBBFF)
// übertragen, da SOGo dieses Format erwartet.
func (d *Daemon) setCalendarColor(ctx context.Context, httpClient webdav.HTTPClient, user, color string) error {
calendarURL, err := url.JoinPath(d.baseURL, "SOGo/dav", user, "Calendar", d.calendarName)
if err != nil {
return err
}
colorValue := strings.ToUpper(color)
if len(colorValue) == 7 {
colorValue += "FF"
}
body := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
<d:propertyupdate xmlns:d="DAV:" xmlns:A="http://apple.com/ns/ical/">
<d:set>
<d:prop>
<A:calendar-color>%s</A:calendar-color>
</d:prop>
</d:set>
</d:propertyupdate>`, colorValue)
req, err := http.NewRequestWithContext(ctx, "PROPPATCH", calendarURL, strings.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/xml; charset=utf-8")
resp, err := httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusMultiStatus && resp.StatusCode != http.StatusOK {
return fmt.Errorf("PROPPATCH calendar-color returned status %d", resp.StatusCode)
}
return nil
}
// ensureCalendarColor stellt sicher, dass der Geburtstagskalender die
// konfigurierte Farbe hat, sofern der Benutzer sie nicht manuell geändert hat.
// Die Erkennung manueller Änderungen basiert auf dem Vergleich zwischen der
// aktuellen Kalenderfarbe und der zuletzt vom Daemon gesetzten Farbe (aus dem
// State-File). Stimmen diese nicht überein, wurde die Farbe vom Benutzer
// angepasst und wird nicht überschrieben.
func (d *Daemon) ensureCalendarColor(ctx context.Context, httpClient webdav.HTTPClient, user string) error {
if d.calendarColor == "" {
return nil
}
current, err := d.getCalendarColor(ctx, httpClient, user)
if err != nil {
return fmt.Errorf("error reading calendar color: %w", err)
}
desired := normalizeColor(d.calendarColor)
stored := normalizeColor(d.storedCalendarColor)
if current == desired {
slog.DebugContext(ctx, "calendar color already correct", "user", user, "color", desired)
return nil
}
// Wenn der Benutzer die Farbe manuell geändert hat (current != stored && current != ""),
// wird die Farbe nicht überschrieben.
if current != "" && stored != "" && current != stored {
slog.DebugContext(ctx, "calendar color was customized by user, skipping",
"user", user, "current", current, "daemon", stored)
return nil
}
if err := d.setCalendarColor(ctx, httpClient, user, desired); err != nil {
return fmt.Errorf("error setting calendar color: %w", err)
}
slog.InfoContext(ctx, "set calendar color", "user", user, "color", desired)
return nil
}
+45 -11
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"context" "context"
"log/slog"
"net/url" "net/url"
"time" "time"
@@ -10,11 +11,20 @@ import (
"github.com/emersion/go-webdav/carddav" "github.com/emersion/go-webdav/carddav"
) )
// ContactType unterscheidet zwischen Geburtstagen und Jahrestagen.
type ContactType int
const (
ContactTypeBirthday ContactType = iota
ContactTypeAnniversary
)
type BirthdayContact struct { type BirthdayContact struct {
FamilyName string FamilyName string
GivenName string GivenName string
Date time.Time Date time.Time
YearKnown bool YearKnown bool
Type ContactType
} }
func (d *Daemon) getBirthdays(ctx context.Context, httpClient webdav.HTTPClient, user string) ([]BirthdayContact, error) { func (d *Daemon) getBirthdays(ctx context.Context, httpClient webdav.HTTPClient, user string) ([]BirthdayContact, error) {
@@ -30,8 +40,10 @@ func (d *Daemon) getBirthdays(ctx context.Context, httpClient webdav.HTTPClient,
if err != nil { if err != nil {
return nil, err return nil, err
} }
slog.DebugContext(ctx, "found address books", "user", user, "count", len(bb))
contacts := make([]BirthdayContact, 0) contacts := make([]BirthdayContact, 0)
for _, b := range bb { for _, b := range bb {
slog.DebugContext(ctx, "querying address book", "user", user, "path", b.Path)
oo, err := cl.QueryAddressBook(ctx, b.Path, &carddav.AddressBookQuery{}) oo, err := cl.QueryAddressBook(ctx, b.Path, &carddav.AddressBookQuery{})
if err != nil { if err != nil {
if err.Error() == "501 Not Implemented" { if err.Error() == "501 Not Implemented" {
@@ -41,21 +53,43 @@ func (d *Daemon) getBirthdays(ctx context.Context, httpClient webdav.HTTPClient,
} }
for _, v := range oo { for _, v := range oo {
nn := v.Card.Names() nn := v.Card.Names()
bdayprop := v.Card.Value(vcard.FieldBirthday) if len(nn) == 0 {
if len(nn) == 0 || len(bdayprop) == 0 {
continue continue
} }
yyyy, mm, dd, err := sanitizeBirthday(bdayprop) bdayprop := v.Card.Value(vcard.FieldBirthday)
if err != nil { if len(bdayprop) > 0 {
return nil, err yyyy, mm, dd, err := sanitizeDate(bdayprop)
if err != nil {
return nil, err
}
contacts = append(contacts, BirthdayContact{
GivenName: nn[0].GivenName,
FamilyName: nn[0].FamilyName,
Date: time.Date(int(yyyy), time.Month(int(mm)), int(dd), 0, 0, 0, 0, time.UTC),
YearKnown: yyyy != 0,
Type: ContactTypeBirthday,
})
slog.DebugContext(ctx, "found birthday contact", "user", user, "name", nn[0].GivenName+" "+nn[0].FamilyName, "date", bdayprop)
}
{
annivprop := v.Card.Value("ANNIVERSARY")
if len(annivprop) > 0 {
yyyy, mm, dd, err := sanitizeDate(annivprop)
if err != nil {
return nil, err
}
contacts = append(contacts, BirthdayContact{
GivenName: nn[0].GivenName,
FamilyName: nn[0].FamilyName,
Date: time.Date(int(yyyy), time.Month(int(mm)), int(dd), 0, 0, 0, 0, time.UTC),
YearKnown: yyyy != 0,
Type: ContactTypeAnniversary,
})
slog.DebugContext(ctx, "found anniversary contact", "user", user, "name", nn[0].GivenName+" "+nn[0].FamilyName, "date", annivprop)
}
} }
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,
})
} }
} }
slog.DebugContext(ctx, "collected contacts with dates", "user", user, "count", len(contacts))
return contacts, nil return contacts, nil
} }
+10 -1
View File
@@ -3,6 +3,7 @@ package main
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"log/slog"
"os" "os"
"path/filepath" "path/filepath"
"sync" "sync"
@@ -57,7 +58,11 @@ func (h *healthState) update(err error) {
s.LastError = err.Error() s.LastError = err.Error()
} }
data, _ := json.Marshal(s) data, _ := json.Marshal(s)
os.WriteFile(h.filePath, data, 0644) if writeErr := os.WriteFile(h.filePath, data, 0644); writeErr != nil {
slog.Error("failed to write health file", "path", h.filePath, "err", writeErr)
} else {
slog.Debug("health status updated", "path", h.filePath, "lastError", s.LastError != "")
}
} }
// runHealthcheck liest die Health-Datei und prüft, ob der Daemon healthy ist. // runHealthcheck liest die Health-Datei und prüft, ob der Daemon healthy ist.
@@ -68,6 +73,7 @@ func runHealthcheck() error {
stateFilepath = "state.json" stateFilepath = "state.json"
} }
healthPath := filepath.Join(filepath.Dir(stateFilepath), healthFile) healthPath := filepath.Join(filepath.Dir(stateFilepath), healthFile)
slog.Debug("running healthcheck", "healthPath", healthPath)
data, err := os.ReadFile(healthPath) data, err := os.ReadFile(healthPath)
if err != nil { if err != nil {
@@ -78,10 +84,13 @@ func runHealthcheck() error {
return fmt.Errorf("invalid health file: %w", err) return fmt.Errorf("invalid health file: %w", err)
} }
if s.LastError != "" { if s.LastError != "" {
slog.Warn("healthcheck: last sync had an error", "lastError", s.LastError)
return fmt.Errorf("last sync failed: %s", s.LastError) return fmt.Errorf("last sync failed: %s", s.LastError)
} }
if time.Since(s.LastSync) > maxSyncAge() { if time.Since(s.LastSync) > maxSyncAge() {
slog.Warn("healthcheck: last sync too old", "age", time.Since(s.LastSync).Round(time.Second))
return fmt.Errorf("last sync too old: %s ago", time.Since(s.LastSync).Round(time.Second)) return fmt.Errorf("last sync too old: %s ago", time.Since(s.LastSync).Round(time.Second))
} }
slog.Debug("healthcheck passed", "lastSync", s.LastSync.Format("2006/01/02 15:04:05"))
return nil return nil
} }
+115
View File
@@ -0,0 +1,115 @@
package main
import (
"context"
"fmt"
"io"
"log/slog"
"strings"
"sync"
"time"
)
// bracketHandler ist ein slog.Handler, der Log-Nachrichten im Format
// "2006/01/02 15:04:05 [LEVEL] message key=value" ausgibt.
// Die Level-Tags [INFO], [WARN], [ERROR] und [DEBUG] ermöglichen eine
// schnelle visuelle Zuordnung in Container-Logs.
type bracketHandler struct {
level slog.Level
out io.Writer
mu *sync.Mutex
attrs []slog.Attr
}
func newBracketHandler(out io.Writer, level slog.Level) *bracketHandler {
return &bracketHandler{
level: level,
out: out,
mu: &sync.Mutex{},
}
}
func (h *bracketHandler) Enabled(_ context.Context, level slog.Level) bool {
return level >= h.level
}
func (h *bracketHandler) Handle(_ context.Context, r slog.Record) error {
var levelTag string
switch {
case r.Level >= slog.LevelError:
levelTag = "[ERROR]"
case r.Level >= slog.LevelWarn:
levelTag = "[WARN]"
case r.Level >= slog.LevelInfo:
levelTag = "[INFO]"
default:
levelTag = "[DEBUG]"
}
var b strings.Builder
b.WriteString(r.Time.Format("2006/01/02 15:04:05"))
b.WriteByte(' ')
b.WriteString(levelTag)
b.WriteByte(' ')
b.WriteString(r.Message)
writeAttr := func(a slog.Attr) bool {
a.Value = a.Value.Resolve()
if a.Equal(slog.Attr{}) {
return true
}
b.WriteByte(' ')
b.WriteString(a.Key)
b.WriteByte('=')
b.WriteString(formatAttrValue(a.Value))
return true
}
for _, a := range h.attrs {
writeAttr(a)
}
r.Attrs(writeAttr)
b.WriteByte('\n')
h.mu.Lock()
defer h.mu.Unlock()
_, err := h.out.Write([]byte(b.String()))
return err
}
func (h *bracketHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
newAttrs := make([]slog.Attr, len(h.attrs)+len(attrs))
copy(newAttrs, h.attrs)
copy(newAttrs[len(h.attrs):], attrs)
return &bracketHandler{
level: h.level,
out: h.out,
mu: h.mu,
attrs: newAttrs,
}
}
func (h *bracketHandler) WithGroup(_ string) slog.Handler {
// Gruppen werden für dieses Projekt nicht benötigt.
return h
}
// formatAttrValue formatiert einen slog.Value als String.
// String-Werte mit Leerzeichen oder Sonderzeichen werden in Anführungszeichen gesetzt.
func formatAttrValue(v slog.Value) string {
switch v.Kind() {
case slog.KindString:
s := v.String()
if s == "" || strings.ContainsAny(s, " \t\n\"\\") {
return fmt.Sprintf("%q", s)
}
return s
case slog.KindTime:
return v.Time().Format(time.RFC3339)
case slog.KindDuration:
return v.Duration().String()
default:
return fmt.Sprintf("%v", v.Any())
}
}
+6
View File
@@ -11,18 +11,24 @@ func (d *Daemon) getUserPass(ctx context.Context, username string) (string, erro
pass, ok := d.userTokens[username] pass, ok := d.userTokens[username]
d.userTokensLock.RUnlock() d.userTokensLock.RUnlock()
if ok { if ok {
slog.DebugContext(ctx, "using cached app password", "user", username)
return pass, nil return pass, nil
} }
slog.DebugContext(ctx, "no cached password found, creating new app password", "user", username)
pp, err := d.mailcowClient.GetAppPasswords(ctx, username) pp, err := d.mailcowClient.GetAppPasswords(ctx, username)
if err != nil { if err != nil {
return "", err return "", err
} }
slog.DebugContext(ctx, "retrieved existing app passwords", "user", username, "total", len(pp))
oldIDs := make([]int, 0) oldIDs := make([]int, 0)
for _, p := range pp { for _, p := range pp {
if p.Name == ConstUsertokenName { if p.Name == ConstUsertokenName {
oldIDs = append(oldIDs, p.ID) oldIDs = append(oldIDs, p.ID)
} }
} }
if len(oldIDs) > 0 {
slog.DebugContext(ctx, "removing old app passwords", "user", username, "count", len(oldIDs))
}
if err := d.mailcowClient.DeleteAppPasswords(ctx, oldIDs); err != nil { if err := d.mailcowClient.DeleteAppPasswords(ctx, oldIDs); err != nil {
return "", fmt.Errorf("error deleting app passwords: %w", err) return "", fmt.Errorf("error deleting app passwords: %w", err)
} }
+171 -8
View File
@@ -7,8 +7,11 @@ import (
"net" "net"
"net/http" "net/http"
"os" "os"
"os/signal"
"strconv"
"strings" "strings"
"sync" "sync"
"syscall"
"time" "time"
"git.techniverse.net/scriptos/mailcow-birthday-daemon/pkg/mailcow" "git.techniverse.net/scriptos/mailcow-birthday-daemon/pkg/mailcow"
@@ -35,13 +38,37 @@ type Daemon struct {
stateUnsaved bool stateUnsaved bool
calendarName string calendarName string
oldCalendarName string oldCalendarName string
calendarColor string
storedCalendarColor string
notificationEnabled bool notificationEnabled bool
notificationTrigger string notificationTrigger string
eventYears int
syncInterval time.Duration syncInterval time.Duration
excludeMailboxes map[string]bool
health *healthState health *healthState
} }
// initLogLevel liest die Umgebungsvariable LOG_LEVEL und konfiguriert den
// globalen slog-Logger entsprechend. Gültige Werte: debug, info, warn, error.
// Standard: info.
func initLogLevel() {
var level slog.Level
switch strings.ToLower(os.Getenv("LOG_LEVEL")) {
case "debug":
level = slog.LevelDebug
case "warn":
level = slog.LevelWarn
case "error":
level = slog.LevelError
default:
level = slog.LevelInfo
}
slog.SetDefault(slog.New(newBracketHandler(os.Stderr, level)))
}
func main() { func main() {
initLogLevel()
if len(os.Args) > 1 { if len(os.Args) > 1 {
switch os.Args[1] { switch os.Args[1] {
case "cleanup": case "cleanup":
@@ -66,12 +93,11 @@ func main() {
func run() error { func run() error {
slog.Info("starting mcbdd", "version", version, "commit", commit, "date", date) slog.Info("starting mcbdd", "version", version, "commit", commit, "date", date)
slog.Debug("log level configured", "LOG_LEVEL", os.Getenv("LOG_LEVEL"))
// Kurze Wartezeit beim Start, damit abhängige Dienste (z. B. nginx) // Signal-Handling für Graceful Shutdown (SIGTERM, SIGINT).
// vollständig hochgefahren sind, bevor Verbindungen aufgebaut werden. ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
const startupDelay = 15 * time.Second defer cancel()
slog.Info("waiting for dependent services to become ready", "delay", startupDelay)
time.Sleep(startupDelay)
mailcowBase := os.Getenv("MAILCOW_BASE") mailcowBase := os.Getenv("MAILCOW_BASE")
if mailcowBase == "" { if mailcowBase == "" {
@@ -85,12 +111,47 @@ func run() error {
if calendarName == "" { if calendarName == "" {
calendarName = "Birthdays" calendarName = "Birthdays"
} }
calendarColor := os.Getenv("CALENDAR_COLOR")
if calendarColor == "" {
calendarColor = "#D01818"
}
notificationEnabled := strings.EqualFold(os.Getenv("NOTIFICATION_ENABLED"), "true") notificationEnabled := strings.EqualFold(os.Getenv("NOTIFICATION_ENABLED"), "true")
eventYears, err := parseEventYears()
if err != nil {
return err
}
slog.Info("event horizon configured", "years", eventYears)
syncInterval, err := parseSyncInterval() syncInterval, err := parseSyncInterval()
if err != nil { if err != nil {
return err return err
} }
slog.Info("sync interval configured", "interval", syncInterval) slog.Info("sync interval configured", "interval", syncInterval)
slog.Debug("configuration summary",
"MAILCOW_BASE", mailcowBase,
"CALENDAR_NAME", calendarName,
"CALENDAR_COLOR", calendarColor,
"NOTIFICATION_ENABLED", notificationEnabled,
"EVENT_YEARS", eventYears,
"MAILCOW_RESOLVE_HOST", os.Getenv("MAILCOW_RESOLVE_HOST"),
"STATEFILE", os.Getenv("STATEFILE"),
"MAILBOX_EXCLUDE", os.Getenv("MAILBOX_EXCLUDE"),
)
// Aktive Erreichbarkeitsprüfung: Mailcow-API und SOGo werden wiederholt
// geprüft, bevor der erste Sync startet. Damit entfällt der frühere
// fixe 15-Sekunden-Delay. Der Check nutzt exponentielles Backoff
// (2 s → 4 s → … → max 30 s) und bricht bei Shutdown-Signal sofort ab.
if err := waitForServices(ctx, &http.Client{Transport: buildTransport()}, mailcowBase, mailcowAPIKey); err != nil {
return err
}
excludeMailboxes := parseMailboxExclude()
if len(excludeMailboxes) > 0 {
slog.Info("mailbox exclusion configured", "count", len(excludeMailboxes))
for addr := range excludeMailboxes {
slog.Debug("excluding mailbox", "address", addr)
}
}
notificationTrigger := "PT8H" notificationTrigger := "PT8H"
if notificationEnabled { if notificationEnabled {
@@ -104,6 +165,8 @@ func run() error {
} }
notificationTrigger = trigger notificationTrigger = trigger
slog.Info("birthday notifications enabled", "time", notificationTime, "trigger", notificationTrigger) slog.Info("birthday notifications enabled", "time", notificationTime, "trigger", notificationTrigger)
} else {
slog.Debug("birthday notifications disabled")
} }
d := &Daemon{ d := &Daemon{
userTokens: make(map[string]string), userTokens: make(map[string]string),
@@ -112,13 +175,17 @@ func run() error {
stateFilepath: os.Getenv("STATEFILE"), stateFilepath: os.Getenv("STATEFILE"),
httpClient: &http.Client{Transport: buildTransport()}, httpClient: &http.Client{Transport: buildTransport()},
calendarName: calendarName, calendarName: calendarName,
calendarColor: calendarColor,
notificationEnabled: notificationEnabled, notificationEnabled: notificationEnabled,
notificationTrigger: notificationTrigger, notificationTrigger: notificationTrigger,
eventYears: eventYears,
syncInterval: syncInterval, syncInterval: syncInterval,
excludeMailboxes: excludeMailboxes,
} }
if len(d.stateFilepath) == 0 { if len(d.stateFilepath) == 0 {
d.stateFilepath = "state.json" d.stateFilepath = "state.json"
} }
slog.Debug("state file path", "path", d.stateFilepath)
d.health = newHealthState(d.stateFilepath) d.health = newHealthState(d.stateFilepath)
d.mailcowClient = mailcow.New( d.mailcowClient = mailcow.New(
d.httpClient, d.httpClient,
@@ -128,26 +195,56 @@ func run() error {
if err := d.loadState(); err != nil { if err := d.loadState(); err != nil {
return err return err
} }
d.daemonLoop() slog.Info("initialization complete, entering daemon loop")
d.daemonLoop(ctx)
return nil return nil
} }
func (d *Daemon) daemonLoop() { func (d *Daemon) daemonLoop(ctx context.Context) {
for { for {
// Vor jedem Sync prüfen, ob ein Shutdown angefordert wurde.
select {
case <-ctx.Done():
slog.Info("shutdown signal received, exiting")
return
default:
}
slog.Debug("starting sync cycle")
start := time.Now()
err := d.daemonRun() err := d.daemonRun()
d.health.update(err) d.health.update(err)
if err != nil { if err != nil {
slog.Error("error while syncing birthdays", "err", err) slog.Error("error while syncing birthdays", "err", err)
} else {
slog.Info("sync cycle completed", "duration", time.Since(start).Round(time.Millisecond))
}
slog.Debug("waiting for next sync cycle", "interval", d.syncInterval)
// Auf nächsten Zyklus oder Shutdown-Signal warten.
timer := time.NewTimer(d.syncInterval)
select {
case <-timer.C:
case <-ctx.Done():
timer.Stop()
slog.Info("shutdown signal received, saving state and exiting")
if d.stateUnsaved {
if err := d.saveState(); err != nil {
slog.Error("error saving state during shutdown", "err", err)
}
}
return
} }
time.Sleep(d.syncInterval)
} }
} }
func (d *Daemon) daemonRun() error { func (d *Daemon) daemonRun() error {
slog.Debug("fetching mailboxes from mailcow API")
mb, err := d.mailcowClient.GetMailboxes(context.Background()) mb, err := d.mailcowClient.GetMailboxes(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("error fetching mailboxes: %w", err) return fmt.Errorf("error fetching mailboxes: %w", err)
} }
slog.Info("fetched mailboxes", "count", len(mb))
eg := sync.WaitGroup{} eg := sync.WaitGroup{}
for _, m := range mb { for _, m := range mb {
eg.Go(func() { eg.Go(func() {
@@ -169,16 +266,32 @@ func (d *Daemon) daemonRun() error {
return nil return nil
} }
// isMailboxExcluded prüft, ob eine Mailbox über MAILBOX_EXCLUDE
// von der Synchronisation ausgeschlossen ist.
func (d *Daemon) isMailboxExcluded(username string) bool {
if len(d.excludeMailboxes) == 0 {
return false
}
return d.excludeMailboxes[strings.ToLower(username)]
}
func (d *Daemon) processUser(ctx context.Context, m mailcow.Mailbox) error { func (d *Daemon) processUser(ctx context.Context, m mailcow.Mailbox) error {
if !m.IsActive() { if !m.IsActive() {
slog.DebugContext(ctx, "skipping inactive mailbox", "user", m.Username)
return nil return nil
} }
if d.isMailboxExcluded(m.Username) {
slog.DebugContext(ctx, "skipping excluded mailbox", "user", m.Username)
return nil
}
slog.DebugContext(ctx, "processing user", "user", m.Username)
pass, err := d.getUserPass(ctx, m.Username) pass, err := d.getUserPass(ctx, m.Username)
if err != nil { if err != nil {
return fmt.Errorf("error getting userpass: %w", err) return fmt.Errorf("error getting userpass: %w", err)
} }
davclient := webdav.HTTPClientWithBasicAuth(d.httpClient, m.Username, pass) davclient := webdav.HTTPClientWithBasicAuth(d.httpClient, m.Username, pass)
if d.oldCalendarName != "" { if d.oldCalendarName != "" {
slog.DebugContext(ctx, "cleaning up old calendar", "user", m.Username, "oldName", d.oldCalendarName)
if err := d.cleanupOldCalendar(ctx, davclient, m.Username, d.oldCalendarName); err != nil { if err := d.cleanupOldCalendar(ctx, davclient, m.Username, d.oldCalendarName); err != nil {
slog.WarnContext(ctx, "error cleaning up old calendar", "err", err, "user", m.Username) slog.WarnContext(ctx, "error cleaning up old calendar", "err", err, "user", m.Username)
} }
@@ -194,12 +307,17 @@ func (d *Daemon) processUser(ctx context.Context, m mailcow.Mailbox) error {
} }
return fmt.Errorf("error getting birthdays from carddav: %w", err) return fmt.Errorf("error getting birthdays from carddav: %w", err)
} }
slog.DebugContext(ctx, "found birthday contacts", "user", m.Username, "count", len(bb))
if err := d.ensureBirthdayCal(ctx, davclient, m.Username); err != nil { if err := d.ensureBirthdayCal(ctx, davclient, m.Username); err != nil {
return fmt.Errorf("error creating birthday calendar in caldav: %w", err) return fmt.Errorf("error creating birthday calendar in caldav: %w", err)
} }
if err := d.ensureCalendarColor(ctx, davclient, m.Username); err != nil {
slog.WarnContext(ctx, "error setting calendar color", "user", m.Username, "err", err)
}
if err := d.syncBirthdaysToCal(ctx, davclient, m.Username, bb); err != nil { if err := d.syncBirthdaysToCal(ctx, davclient, m.Username, bb); err != nil {
return fmt.Errorf("error syncing birthday events to caldav: %w", err) return fmt.Errorf("error syncing birthday events to caldav: %w", err)
} }
slog.DebugContext(ctx, "user processing complete", "user", m.Username)
return nil return nil
} }
@@ -213,6 +331,7 @@ func runCleanup() error {
oldCalendarName := os.Args[2] oldCalendarName := os.Args[2]
slog.Info("starting calendar cleanup", "calendarName", oldCalendarName) slog.Info("starting calendar cleanup", "calendarName", oldCalendarName)
slog.Debug("loading environment configuration for cleanup")
mailcowBase := os.Getenv("MAILCOW_BASE") mailcowBase := os.Getenv("MAILCOW_BASE")
if mailcowBase == "" { if mailcowBase == "" {
@@ -250,8 +369,10 @@ func runCleanup() error {
} }
processed, skipped := 0, 0 processed, skipped := 0, 0
slog.Debug("starting cleanup for all mailboxes", "totalMailboxes", len(mb))
for _, m := range mb { for _, m := range mb {
if !m.IsActive() { if !m.IsActive() {
slog.Debug("skipping inactive mailbox during cleanup", "user", m.Username)
continue continue
} }
d.userTokensLock.RLock() d.userTokensLock.RLock()
@@ -263,9 +384,12 @@ func runCleanup() error {
continue continue
} }
ctx := context.Background() ctx := context.Background()
slog.Debug("cleaning up calendar for user", "user", m.Username, "calendar", oldCalendarName)
davclient := webdav.HTTPClientWithBasicAuth(d.httpClient, m.Username, pass) davclient := webdav.HTTPClientWithBasicAuth(d.httpClient, m.Username, pass)
if err := d.cleanupOldCalendar(ctx, davclient, m.Username, oldCalendarName); err != nil { if err := d.cleanupOldCalendar(ctx, davclient, m.Username, oldCalendarName); err != nil {
slog.Error("error cleaning up calendar", "user", m.Username, "err", err) slog.Error("error cleaning up calendar", "user", m.Username, "err", err)
} else {
slog.Debug("calendar cleanup successful for user", "user", m.Username)
} }
processed++ processed++
} }
@@ -274,6 +398,27 @@ func runCleanup() error {
return nil return nil
} }
// parseMailboxExclude liest MAILBOX_EXCLUDE aus der Umgebung und gibt eine
// Map mit den ausgeschlossenen Mailadressen (lowercase) zurück.
// Mehrere Adressen werden durch Komma getrennt.
func parseMailboxExclude() map[string]bool {
raw := os.Getenv("MAILBOX_EXCLUDE")
if raw == "" {
return nil
}
exclude := make(map[string]bool)
for _, addr := range strings.Split(raw, ",") {
addr = strings.TrimSpace(addr)
if addr != "" {
exclude[strings.ToLower(addr)] = true
}
}
if len(exclude) == 0 {
return nil
}
return exclude
}
// parseSyncInterval liest SYNC_INTERVAL aus der Umgebung und gibt die // parseSyncInterval liest SYNC_INTERVAL aus der Umgebung und gibt die
// geparste Dauer zurück. Standard: 15m. // geparste Dauer zurück. Standard: 15m.
func parseSyncInterval() (time.Duration, error) { func parseSyncInterval() (time.Duration, error) {
@@ -291,6 +436,24 @@ func parseSyncInterval() (time.Duration, error) {
return d, nil return d, nil
} }
// parseEventYears liest EVENT_YEARS aus der Umgebung und gibt die Anzahl
// der Jahre zurück, für die Geburtstags-Events im Voraus erzeugt werden.
// Standard: 10. Gültige Werte: 130.
func parseEventYears() (int, error) {
raw := os.Getenv("EVENT_YEARS")
if raw == "" {
return 10, nil
}
n, err := strconv.Atoi(raw)
if err != nil {
return 0, fmt.Errorf("invalid EVENT_YEARS %q: %w", raw, err)
}
if n < 1 || n > 30 {
return 0, fmt.Errorf("EVENT_YEARS must be between 1 and 30, got %d", n)
}
return n, nil
}
// buildTransport erstellt einen http.Transport. // buildTransport erstellt einen http.Transport.
// Wenn MAILCOW_RESOLVE_HOST gesetzt ist (z. B. "nginx-mailcow"), wird der // Wenn MAILCOW_RESOLVE_HOST gesetzt ist (z. B. "nginx-mailcow"), wird der
// tatsächliche TCP-Connect auf diesen Host umgeleitet, während TLS-SNI und // tatsächliche TCP-Connect auf diesen Host umgeleitet, während TLS-SNI und
+34 -11
View File
@@ -10,21 +10,25 @@ import (
) )
func (d *Daemon) loadState() error { func (d *Daemon) loadState() error {
slog.Debug("loading state file", "path", d.stateFilepath)
stateVer := struct { stateVer := struct {
Version int `json:"version"` Version int `json:"version"`
}{} }{}
if err := d.loadFromDisk(&stateVer); err != nil { if err := d.loadFromDisk(&stateVer); err != nil {
return fmt.Errorf("cant detect state version: %w", err) return fmt.Errorf("cant detect state version: %w", err)
} }
slog.Debug("detected state version", "version", stateVer.Version)
var storedCalendarName string var storedCalendarName string
switch stateVer.Version { switch stateVer.Version {
case 0: case 0:
slog.Warn("loading old state version", "stateVer", stateVer.Version) slog.Warn("loading old state version, migration required", "stateVer", stateVer.Version)
if err := d.loadFromDisk(&d.userTokens); err != nil { if err := d.loadFromDisk(&d.userTokens); err != nil {
return fmt.Errorf("cant load state v%d: %w", stateVer.Version, err) return fmt.Errorf("cant load state v%d: %w", stateVer.Version, err)
} }
slog.Debug("loaded state", "version", stateVer.Version, "users", len(d.userTokens))
d.stateUnsaved = true d.stateUnsaved = true
case 1: case 1:
slog.Warn("loading state v1, migration to v2 required", "stateVer", stateVer.Version)
state := struct { state := struct {
Version int `json:"version"` Version int `json:"version"`
UserTokens map[string]string `json:"userTokens"` UserTokens map[string]string `json:"userTokens"`
@@ -41,10 +45,12 @@ func (d *Daemon) loadState() error {
} }
d.stateUnsaved = true d.stateUnsaved = true
case 2: case 2:
slog.Debug("loading current state format", "stateVer", stateVer.Version)
state := struct { state := struct {
Version int `json:"version"` Version int `json:"version"`
UserTokens map[string]string `json:"userTokens"` UserTokens map[string]string `json:"userTokens"`
CalendarName string `json:"calendarName"` CalendarName string `json:"calendarName"`
CalendarColor string `json:"calendarColor,omitempty"`
}{} }{}
if err := d.loadFromDisk(&state); err != nil { if err := d.loadFromDisk(&state); err != nil {
return fmt.Errorf("cant load state v%d: %w", stateVer.Version, err) return fmt.Errorf("cant load state v%d: %w", stateVer.Version, err)
@@ -57,37 +63,54 @@ func (d *Daemon) loadState() error {
d.userTokens[k] = string(dec) d.userTokens[k] = string(dec)
} }
storedCalendarName = state.CalendarName storedCalendarName = state.CalendarName
d.storedCalendarColor = state.CalendarColor
} }
slog.Info("state loaded", "users", len(d.userTokens))
if storedCalendarName != "" && storedCalendarName != d.calendarName { if storedCalendarName != "" && storedCalendarName != d.calendarName {
slog.Info("calendar name changed, old calendars will be cleaned up", slog.Info("calendar name changed, old calendars will be cleaned up",
"old", storedCalendarName, "new", d.calendarName) "old", storedCalendarName, "new", d.calendarName)
d.oldCalendarName = storedCalendarName d.oldCalendarName = storedCalendarName
d.stateUnsaved = true d.stateUnsaved = true
} }
if normalizeColor(d.storedCalendarColor) != normalizeColor(d.calendarColor) {
if d.storedCalendarColor != "" {
slog.Info("calendar color configuration changed",
"old", d.storedCalendarColor, "new", d.calendarColor)
}
d.stateUnsaved = true
}
return nil return nil
} }
func (d *Daemon) saveState() error { func (d *Daemon) saveState() error {
slog.Debug("saving state to disk", "path", d.stateFilepath, "users", len(d.userTokens))
encTokens := make(map[string]string, len(d.userTokens)) encTokens := make(map[string]string, len(d.userTokens))
for k, v := range d.userTokens { for k, v := range d.userTokens {
encTokens[k] = base64.StdEncoding.EncodeToString([]byte(v)) encTokens[k] = base64.StdEncoding.EncodeToString([]byte(v))
} }
state := struct { state := struct {
Version int `json:"version"` Version int `json:"version"`
UserTokens map[string]string `json:"userTokens"` UserTokens map[string]string `json:"userTokens"`
CalendarName string `json:"calendarName"` CalendarName string `json:"calendarName"`
CalendarColor string `json:"calendarColor,omitempty"`
}{ }{
Version: 2, Version: 2,
UserTokens: encTokens, UserTokens: encTokens,
CalendarName: d.calendarName, CalendarName: d.calendarName,
CalendarColor: d.calendarColor,
} }
return d.saveToDisk(state) if err := d.saveToDisk(state); err != nil {
return err
}
slog.Debug("state saved successfully")
return nil
} }
func (d *Daemon) loadFromDisk(state any) error { func (d *Daemon) loadFromDisk(state any) error {
f, err := os.OpenFile(d.stateFilepath, os.O_RDONLY, 0o660) f, err := os.OpenFile(d.stateFilepath, os.O_RDONLY, 0o660)
if err != nil { if err != nil {
if errors.Is(err, os.ErrNotExist) { if errors.Is(err, os.ErrNotExist) {
slog.Debug("state file does not exist, starting fresh", "path", d.stateFilepath)
return nil return nil
} }
return err return err
+119
View File
@@ -0,0 +1,119 @@
package main
import (
"context"
"log/slog"
"net/http"
"net/url"
"time"
"git.techniverse.net/scriptos/mailcow-birthday-daemon/pkg/mailcow"
)
const (
// startupBackoffInit ist das anfängliche Warteintervall zwischen
// Erreichbarkeitsprüfungen (2 Sekunden).
startupBackoffInit = 2 * time.Second
// startupBackoffMax begrenzt das maximale Warteintervall auf 30 Sekunden.
startupBackoffMax = 30 * time.Second
// startupRequestTimeout begrenzt einzelne HTTP-Anfragen während der
// Startphase auf 10 Sekunden.
startupRequestTimeout = 10 * time.Second
)
// waitForServices prüft in einer Schleife, ob die Mailcow-API und SOGo
// erreichbar sind. Erst wenn beide Dienste antworten, kehrt die Funktion
// zurück. Bei einem Shutdown-Signal (ctx.Done) wird sofort nil
// zurückgegeben.
func waitForServices(ctx context.Context, httpClient *http.Client, baseURL, apiKey string) error {
slog.Info("checking connectivity to mailcow API and SOGo")
backoff := startupBackoffInit
for {
apiOK := checkMailcowAPI(ctx, httpClient, baseURL, apiKey)
sogoOK := checkSOGo(ctx, httpClient, baseURL)
if apiOK && sogoOK {
slog.Info("all services reachable, proceeding with initialization")
return nil
}
// Klare Meldung, welcher Dienst noch nicht bereit ist.
if !apiOK && !sogoOK {
slog.Warn("mailcow API and SOGo not reachable yet, retrying", "backoff", backoff)
} else if !apiOK {
slog.Warn("mailcow API not reachable yet, retrying", "backoff", backoff)
} else {
slog.Warn("SOGo not reachable yet, retrying", "backoff", backoff)
}
// Auf nächsten Versuch oder Shutdown-Signal warten.
timer := time.NewTimer(backoff)
select {
case <-timer.C:
case <-ctx.Done():
timer.Stop()
slog.Warn("shutdown signal received during startup connectivity check, exiting")
return nil
}
// Exponentielles Backoff verdoppeln, aber nicht über das Maximum.
backoff *= 2
if backoff > startupBackoffMax {
backoff = startupBackoffMax
}
}
}
// checkMailcowAPI führt einen einfachen API-Aufruf (GetMailboxes) durch,
// um zu prüfen, ob die Mailcow-API erreichbar ist und der API-Key gültig
// ist. Bei Erfolg wird true zurückgegeben.
func checkMailcowAPI(ctx context.Context, httpClient *http.Client, baseURL, apiKey string) bool {
reqCtx, cancel := context.WithTimeout(ctx, startupRequestTimeout)
defer cancel()
mc := mailcow.New(httpClient, baseURL, apiKey)
_, err := mc.GetMailboxes(reqCtx)
if err != nil {
slog.Debug("mailcow API check failed", "err", err)
return false
}
slog.Debug("mailcow API is reachable")
return true
}
// checkSOGo prüft die Erreichbarkeit von SOGo über einen HTTP-GET auf den
// SOGo-Basispfad. Jeder HTTP-Statuscode (auch Redirects oder 401) gilt als
// "erreichbar", da SOGo in diesem Fall antwortet. Nur Netzwerkfehler
// (connection refused, timeout) gelten als "nicht erreichbar".
func checkSOGo(ctx context.Context, httpClient *http.Client, baseURL string) bool {
reqCtx, cancel := context.WithTimeout(ctx, startupRequestTimeout)
defer cancel()
sogoURL, err := url.JoinPath(baseURL, "SOGo/")
if err != nil {
slog.Debug("SOGo URL construction failed", "err", err)
return false
}
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, sogoURL, nil)
if err != nil {
slog.Debug("SOGo request creation failed", "err", err)
return false
}
resp, err := httpClient.Do(req)
if err != nil {
slog.Debug("SOGo check failed", "err", err)
return false
}
defer resp.Body.Close()
// SOGo ist erreichbar der konkrete Statuscode ist irrelevant,
// solange eine HTTP-Antwort zurückkommt.
slog.Debug("SOGo is reachable", "status", resp.StatusCode)
return true
}
+2 -2
View File
@@ -32,7 +32,7 @@ func randomPassword(length int) (string, error) {
return string(pass), nil return string(pass), nil
} }
func sanitizeBirthday(input string) (uint16, uint16, uint16, error) { func sanitizeDate(input string) (uint16, uint16, uint16, error) {
input = strings.ReplaceAll(input, "-", "") input = strings.ReplaceAll(input, "-", "")
switch len(input) { switch len(input) {
case 4: case 4:
@@ -63,7 +63,7 @@ func sanitizeBirthday(input string) (uint16, uint16, uint16, error) {
} }
return uint16(yyyy), uint16(mm), uint16(dd), nil return uint16(yyyy), uint16(mm), uint16(dd), nil
} }
return 0, 0, 0, fmt.Errorf("birthday prop format unknown: %s", input) return 0, 0, 0, fmt.Errorf("date prop format unknown: %s", input)
} }
// parseNotificationTrigger konvertiert eine Uhrzeit im Format "HH:MM" in eine // parseNotificationTrigger konvertiert eine Uhrzeit im Format "HH:MM" in eine
+7 -7
View File
@@ -2,7 +2,7 @@ package main
import "testing" import "testing"
func Test_sanitizeBirthday(t *testing.T) { func Test_sanitizeDate(t *testing.T) {
tests := []struct { tests := []struct {
name string // description of this test case name string // description of this test case
// Named input parameters for target function. // Named input parameters for target function.
@@ -47,24 +47,24 @@ func Test_sanitizeBirthday(t *testing.T) {
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
got, got2, got3, gotErr := sanitizeBirthday(tt.input) got, got2, got3, gotErr := sanitizeDate(tt.input)
if gotErr != nil { if gotErr != nil {
if !tt.wantErr { if !tt.wantErr {
t.Errorf("sanitizeBirthday() failed: %v", gotErr) t.Errorf("sanitizeDate() failed: %v", gotErr)
} }
return return
} }
if tt.wantErr { if tt.wantErr {
t.Fatal("sanitizeBirthday() succeeded unexpectedly") t.Fatal("sanitizeDate() succeeded unexpectedly")
} }
if got != tt.want { if got != tt.want {
t.Errorf("sanitizeBirthday() = %v, want %v", got, tt.want) t.Errorf("sanitizeDate() = %v, want %v", got, tt.want)
} }
if got2 != tt.want2 { if got2 != tt.want2 {
t.Errorf("sanitizeBirthday() = %v, want %v", got2, tt.want2) t.Errorf("sanitizeDate() = %v, want %v", got2, tt.want2)
} }
if got3 != tt.want3 { if got3 != tt.want3 {
t.Errorf("sanitizeBirthday() = %v, want %v", got3, tt.want3) t.Errorf("sanitizeDate() = %v, want %v", got3, tt.want3)
} }
}) })
} }
+32 -1
View File
@@ -4,6 +4,10 @@
Der Mailcow Birthday Daemon synchronisiert automatisch Geburtstagskalender für jede aktive Mailbox. Der gesamte Prozess läuft ohne Benutzereingriff ab. Der Mailcow Birthday Daemon synchronisiert automatisch Geburtstagskalender für jede aktive Mailbox. Der gesamte Prozess läuft ohne Benutzereingriff ab.
## Startup-Connectivity-Check
Beim Start prüft der Daemon aktiv, ob die Mailcow-API und SOGo erreichbar sind, bevor die erste Synchronisation beginnt. Die Prüfung nutzt exponentielles Backoff (2 s → 4 s → … → max 30 s) und wiederholt sich, bis beide Dienste antworten. Im Log wird klar angegeben, welcher Dienst noch nicht bereit ist. Ein Shutdown-Signal bricht den Check sofort ab.
## App-Passwörter ## App-Passwörter
- Über die Mailcow-API wird für jeden aktiven Benutzer ein App-Passwort mit Zugriff auf CardDAV und CalDAV erzeugt. - Über die Mailcow-API wird für jeden aktiven Benutzer ein App-Passwort mit Zugriff auf CardDAV und CalDAV erzeugt.
@@ -12,10 +16,20 @@ Der Mailcow Birthday Daemon synchronisiert automatisch Geburtstagskalender für
## Kontakte und Geburtstage ## Kontakte und Geburtstage
- Alle Kontakte aus sämtlichen Adressbüchern werden abgerufen und die Geburtstagsinformationen je Benutzer extrahiert. - Alle Kontakte aus sämtlichen Adressbüchern werden abgerufen und die Geburtstagsinformationen je Benutzer extrahiert.
- Zusätzlich zu Geburtstagen werden auch Jahrestage (`ANNIVERSARY`-Feld nach vCard 4.0 / RFC 6350) ausgelesen. Dieses Feld wird von allen gängigen Clients unterstützt (Android, iOS, Thunderbird).
- Geburtstags-Events erhalten das Präfix 🎂, Jahrestags-Events das Präfix 💍 so sind beide Typen im Kalender sofort unterscheidbar.
- Die daraus resultierenden Kalendereinträge werden im Voraus berechnet. - Die daraus resultierenden Kalendereinträge werden im Voraus berechnet.
- Aktuell fest eingestellt: 1 Jahr in der Vergangenheit, 10 Jahre in der Zukunft.
> **Hinweis zu Jahrestagen:** Die SOGo-Weboberfläche bietet kein Feld zum Anzeigen oder Bearbeiten von Jahrestagen. Das `ANNIVERSARY`-Feld muss über einen externen Client (z. B. Thunderbird, Android- oder iOS-Kontakte-App) gepflegt und per CardDAV synchronisiert werden. Das SOGo-CardDAV-Backend speichert und liefert das Feld korrekt es fehlt lediglich die Unterstützung in der Web-UI.
- Aktuell standardmäßig: 1 Jahr in der Vergangenheit, 10 Jahre in der Zukunft (konfigurierbar über `EVENT_YEARS`).
- Selbstverständlich pro Mailbox isoliert ein Benutzer sieht nur die Geburtstage seiner eigenen Kontakte. - Selbstverständlich pro Mailbox isoliert ein Benutzer sieht nur die Geburtstage seiner eigenen Kontakte.
## Mailbox-Filter
Standardmäßig erhalten alle aktiven Mailboxen einen Geburtstagskalender. Über die Umgebungsvariable `MAILBOX_EXCLUDE` können einzelne Mailboxen von der Synchronisation ausgeschlossen werden (z. B. Service-Accounts oder Shared Mailboxen). Ausgeschlossene Mailboxen werden beim Sync-Zyklus übersprungen. Bereits vorhandene Kalender in diesen Mailboxen werden dabei nicht automatisch entfernt dafür kann der `cleanup`-Befehl verwendet werden (siehe [Troubleshooting](troubleshooting.md#doppelte-kalender-nach-umbenennung-von-calendar_name)).
Die Variable kann jederzeit auch bei bestehenden Installationen per Update hinzugefügt oder geändert werden.
## Kalendersynchronisation ## Kalendersynchronisation
- Die berechneten Ereignisse werden in einen Kalender synchronisiert, dessen Name über `CALENDAR_NAME` konfigurierbar ist (Standard: „Birthdays"). Der Anzeigename kann vom Benutzer in SOGo zusätzlich umbenannt werden. - Die berechneten Ereignisse werden in einen Kalender synchronisiert, dessen Name über `CALENDAR_NAME` konfigurierbar ist (Standard: „Birthdays"). Der Anzeigename kann vom Benutzer in SOGo zusätzlich umbenannt werden.
@@ -23,6 +37,13 @@ Der Mailcow Birthday Daemon synchronisiert automatisch Geburtstagskalender für
> **Wichtig (betrifft nur Updates von vor v0.2.0):** Damit die Umbenennung korrekt erkannt wird, muss der Daemon **mindestens einmal** mit dem neuen Code (ab v0.2.0) und dem **alten** Kalendernamen gelaufen sein, damit der Name im State-File gespeichert wird. Erst danach `CALENDAR_NAME` ändern und erneut starten. Wird der Name geändert, bevor der State aktualisiert wurde, kann der alte Kalender nicht automatisch entfernt werden. In diesem Fall kann der integrierte Cleanup-Befehl verwendet werden (siehe [Troubleshooting](troubleshooting.md#doppelte-kalender-nach-umbenennung-von-calendar_name)). > **Wichtig (betrifft nur Updates von vor v0.2.0):** Damit die Umbenennung korrekt erkannt wird, muss der Daemon **mindestens einmal** mit dem neuen Code (ab v0.2.0) und dem **alten** Kalendernamen gelaufen sein, damit der Name im State-File gespeichert wird. Erst danach `CALENDAR_NAME` ändern und erneut starten. Wird der Name geändert, bevor der State aktualisiert wurde, kann der alte Kalender nicht automatisch entfernt werden. In diesem Fall kann der integrierte Cleanup-Befehl verwendet werden (siehe [Troubleshooting](troubleshooting.md#doppelte-kalender-nach-umbenennung-von-calendar_name)).
## Kalenderfarbe
- Der Daemon setzt automatisch die CalDAV-Property `calendar-color` (Apple-Namespace) auf dem Geburtstagskalender. Dadurch hebt sich der Kalender im Client farblich sofort ab.
- Die Farbe ist über `CALENDAR_COLOR` konfigurierbar (Standard: `#D01818`). Das Format ist `#RRGGBB`.
- Hat ein Benutzer die Farbe seines Kalenders manuell geändert (z. B. in SOGo oder einem anderen Client), wird diese Anpassung respektiert und nicht überschrieben.
- Bei einer Änderung von `CALENDAR_COLOR` werden alle Kalender aktualisiert, deren Farbe nicht manuell angepasst wurde.
## Benachrichtigungen ## Benachrichtigungen
- Wenn `NOTIFICATION_ENABLED=true` gesetzt ist, erhält jedes Geburtstags-Event einen **VALARM** (iCal-Alarm). Kalender-Clients (SOGo, iOS, Android, Thunderbird) zeigen dann zur konfigurierten Uhrzeit eine Benachrichtigung an. Das Event bleibt weiterhin ein Ganztags-Event. - Wenn `NOTIFICATION_ENABLED=true` gesetzt ist, erhält jedes Geburtstags-Event einen **VALARM** (iCal-Alarm). Kalender-Clients (SOGo, iOS, Android, Thunderbird) zeigen dann zur konfigurierten Uhrzeit eine Benachrichtigung an. Das Event bleibt weiterhin ein Ganztags-Event.
@@ -33,6 +54,16 @@ Der Mailcow Birthday Daemon synchronisiert automatisch Geburtstagskalender für
Der Synchronisationszyklus läuft standardmäßig alle **15 Minuten** automatisch. Das Intervall kann über die Umgebungsvariable `SYNC_INTERVAL` angepasst werden (z. B. `SYNC_INTERVAL=30m`). Details zu den möglichen Werten finden sich in der [Umgebungsvariablen-Tabelle](schnellstart.md#umgebungsvariablen). Der Synchronisationszyklus läuft standardmäßig alle **15 Minuten** automatisch. Das Intervall kann über die Umgebungsvariable `SYNC_INTERVAL` angepasst werden (z. B. `SYNC_INTERVAL=30m`). Details zu den möglichen Werten finden sich in der [Umgebungsvariablen-Tabelle](schnellstart.md#umgebungsvariablen).
## Graceful Shutdown
Der Daemon reagiert auf `SIGTERM` und `SIGINT` (z. B. durch `docker stop`) und beendet sich sauber:
1. Der aktuelle Synchronisationszyklus wird noch vollständig abgeschlossen.
2. Ungespeicherte App-Passwörter werden in die Zustandsdatei geschrieben.
3. Erst danach beendet sich der Prozess.
Dadurch wird sichergestellt, dass das State-File konsistent bleibt und keine Daten verloren gehen. Auch der Startup-Connectivity-Check wird bei einem Shutdown-Signal sofort abgebrochen.
## Healthcheck ## Healthcheck
- Das Dockerfile enthält eine `HEALTHCHECK`-Anweisung, die den eingebauten Subcommand `healthcheck` nutzt es werden keine externen Tools wie `curl` oder `wget` benötigt und kein Port wird geöffnet. - Das Dockerfile enthält eine `HEALTHCHECK`-Anweisung, die den eingebauten Subcommand `healthcheck` nutzt es werden keine externen Tools wie `curl` oder `wget` benötigt und kein Port wird geöffnet.
+10 -3
View File
@@ -24,10 +24,14 @@ services:
- MAILCOW_BASE=https://mail.example.com - MAILCOW_BASE=https://mail.example.com
- MAILCOW_APIKEY=DEIN-APIKEY-HIER - MAILCOW_APIKEY=DEIN-APIKEY-HIER
- CALENDAR_NAME=Birthdays - CALENDAR_NAME=Birthdays
# - CALENDAR_COLOR=#D01818
# - MAILCOW_RESOLVE_HOST=nginx-mailcow # - MAILCOW_RESOLVE_HOST=nginx-mailcow
# - NOTIFICATION_ENABLED=true # - NOTIFICATION_ENABLED=true
# - NOTIFICATION_TIME=08:00 # - NOTIFICATION_TIME=08:00
# - SYNC_INTERVAL=15m # - SYNC_INTERVAL=15m
# - EVENT_YEARS=10
# - MAILBOX_EXCLUDE=user1@example.com,user2@example.com
# - LOG_LEVEL=info
volumes: volumes:
- birthdaydaemon:/data - birthdaydaemon:/data
@@ -50,7 +54,7 @@ cd /opt/mailcow-dockerized
docker compose pull birthdaydaemon && docker compose up -d --no-deps birthdaydaemon docker compose pull birthdaydaemon && docker compose up -d --no-deps birthdaydaemon
``` ```
> **Hinweis:** Nach dem Start wartet der Daemon zunächst **15 Sekunden**, bevor die erste Synchronisation beginnt. Diese Verzögerung stellt sicher, dass abhängige Dienste (z. B. Nginx, SOGo) vollständig bereit sind. > **Hinweis:** Nach dem Start prüft der Daemon aktiv die Erreichbarkeit der Mailcow-API und SOGo, bevor die erste Synchronisation beginnt. Die Prüfung wiederholt sich mit steigendem Intervall (2 s → 4 s → … → max 30 s), bis beide Dienste antworten. Im Log wird klar angezeigt, welcher Dienst noch nicht bereit ist.
## Umgebungsvariablen ## Umgebungsvariablen
@@ -60,11 +64,14 @@ docker compose pull birthdaydaemon && docker compose up -d --no-deps birthdaydae
| `MAILCOW_APIKEY` | **Ja** | | API-Key mit Lese-/Schreibzugriff aus dem Mailcow-Admin-Panel | | `MAILCOW_APIKEY` | **Ja** | | API-Key mit Lese-/Schreibzugriff aus dem Mailcow-Admin-Panel |
| `MAILCOW_RESOLVE_HOST` | Nein | | Interner Hostname für TCP-Verbindungen (z. B. `nginx-mailcow`). Löst Hairpin-NAT-Probleme in Docker-Netzen. TLS nutzt weiterhin den Hostnamen aus `MAILCOW_BASE`. | | `MAILCOW_RESOLVE_HOST` | Nein | | Interner Hostname für TCP-Verbindungen (z. B. `nginx-mailcow`). Löst Hairpin-NAT-Probleme in Docker-Netzen. TLS nutzt weiterhin den Hostnamen aus `MAILCOW_BASE`. |
| `CALENDAR_NAME` | Nein | `Birthdays` | Name des Geburtstagskalenders, der in jeder Mailbox erstellt wird | | `CALENDAR_NAME` | Nein | `Birthdays` | Name des Geburtstagskalenders, der in jeder Mailbox erstellt wird |
| `CALENDAR_COLOR` | Nein | `#D01818` | Farbe des Geburtstagskalenders im Hex-Format (z. B. `#FF6600`). Wird nur gesetzt, wenn der Benutzer die Farbe nicht manuell geändert hat. |
| `NOTIFICATION_ENABLED` | Nein | `false` | Aktiviert Kalender-Benachrichtigungen (VALARM) für Geburtstags-Events (`true`/`false`) | | `NOTIFICATION_ENABLED` | Nein | `false` | Aktiviert Kalender-Benachrichtigungen (VALARM) für Geburtstags-Events (`true`/`false`) |
| `NOTIFICATION_TIME` | Nein | `08:00` | Uhrzeit der Benachrichtigung im Format `HH:MM` (nur wirksam wenn `NOTIFICATION_ENABLED=true`) | | `NOTIFICATION_TIME` | Nein | `08:00` | Uhrzeit der Benachrichtigung im Format `HH:MM` (nur wirksam wenn `NOTIFICATION_ENABLED=true`) |
| `STATEFILE` | Nein | `state.json` (im Container: `/data/state.json`) | Pfad zur Zustandsdatei, in der App-Passwörter und der aktuelle Kalendername gespeichert werden | | `STATEFILE` | Nein | `state.json` (im Container: `/data/state.json`) | Pfad zur Zustandsdatei, in der App-Passwörter und der aktuelle Kalendername gespeichert werden |
| `SYNC_INTERVAL` | Nein | `15m` | Intervall zwischen den Synchronisationsläufen im Go-Duration-Format (z. B. `10m`, `30m`, `1h`). Mindestwert: `1m`. | | `SYNC_INTERVAL` | Nein | `15m` | Intervall zwischen den Synchronisationsläufen im Go-Duration-Format (z. B. `10m`, `30m`, `1h`). Mindestwert: `1m`. |
| `EVENT_YEARS` | Nein | `10` | Anzahl der Jahre, für die Geburtstags-Events im Voraus erzeugt werden. Gültige Werte: `1``30`. Kleinere Werte können die Performance in Kalender-Clients verbessern. |
| `MAILBOX_EXCLUDE` | Nein | | Kommagetrennte Liste von Mailadressen, die von der Synchronisation ausgeschlossen werden (z. B. `admin@example.com,noreply@example.com`). Standardmäßig erhalten alle aktiven Mailboxen einen Geburtstagskalender. |
| `LOG_LEVEL` | Nein | `info` | Log-Verbosity: `debug`, `info`, `warn` oder `error`. Im `debug`-Modus werden einzelne Kalendereinträge (hinzugefügt/entfernt) und Kontaktdetails geloggt. |
## API-Key erstellen ## API-Key erstellen
Den API-Key findet man im Admin-Panel unter Konfiguration → Zugang → Administratordetails bearbeiten → API → Lese-/Schreibzugriff. Den API-Key findet man im Admin-Panel unter Konfiguration → Zugang → Administratordetails bearbeiten → API → Lese-/Schreibzugriff.
+9 -8
View File
@@ -3,8 +3,9 @@
## Container startet, aber keine Kalender werden erstellt ## Container startet, aber keine Kalender werden erstellt
1. Logs prüfen: `docker compose logs -f birthdaydaemon` 1. Logs prüfen: `docker compose logs -f birthdaydaemon`
2. Sicherstellen, dass der API-Key **Lese- und Schreibzugriff** hat (nicht nur Lesezugriff). 2. Für detaillierte Ausgaben `LOG_LEVEL=debug` als Umgebungsvariable setzen damit werden einzelne Kontakte und Kalendereinträge sichtbar.
3. Prüfen, ob die Mailbox aktiv ist inaktive Mailboxen werden übersprungen. 3. Sicherstellen, dass der API-Key **Lese- und Schreibzugriff** hat (nicht nur Lesezugriff).
4. Prüfen, ob die Mailbox aktiv ist inaktive Mailboxen werden übersprungen.
## Verbindungsfehler / Timeout ## Verbindungsfehler / Timeout
@@ -33,12 +34,12 @@ docker compose exec birthdaydaemon /mailcow-birthday-daemon cleanup Birthdays
**Beispielausgabe:** **Beispielausgabe:**
``` ```
2026/03/28 23:46:22 INFO starting calendar cleanup calendarName=Birthdays 2026/03/28 23:46:22 [INFO] starting calendar cleanup calendarName=Birthdays
2026/03/28 23:46:22 INFO using internal resolve host for connections resolveHost=nginx-mailcow 2026/03/28 23:46:22 [INFO] using internal resolve host for connections resolveHost=nginx-mailcow
2026/03/28 23:46:22 INFO removed old birthday calendar user=user1@example.com calendar=Birthdays 2026/03/28 23:46:22 [INFO] removed old birthday calendar user=user1@example.com calendar=Birthdays
2026/03/28 23:46:23 INFO removed old birthday calendar user=user2@example.com calendar=Birthdays 2026/03/28 23:46:23 [INFO] removed old birthday calendar user=user2@example.com calendar=Birthdays
2026/03/28 23:46:23 INFO removed old birthday calendar user=user3@example.com calendar=Birthdays 2026/03/28 23:46:23 [INFO] removed old birthday calendar user=user3@example.com calendar=Birthdays
2026/03/28 23:46:24 INFO cleanup finished processed=5 skipped=0 2026/03/28 23:46:24 [INFO] cleanup finished processed=5 skipped=0
``` ```
> **Hinweis:** Der Daemon muss vorher mindestens einmal gelaufen sein, damit App-Passwörter im State-File vorhanden sind. Benutzer ohne gespeichertes Passwort werden übersprungen. > **Hinweis:** Der Daemon muss vorher mindestens einmal gelaufen sein, damit App-Passwörter im State-File vorhanden sind. Benutzer ohne gespeichertes Passwort werden übersprungen.