diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml index 121ca28..d1ad9a3 100644 --- a/.gitea/workflows/release.yaml +++ b/.gitea/workflows/release.yaml @@ -1,19 +1,42 @@ name: Make Release on: - push: - tags: - - 'v*' + workflow_dispatch: + inputs: + version: + description: 'Release-Version (z.B. 0.3.3 – ohne v-Prefix)' + required: true + type: string jobs: release: runs-on: ubuntu-latest 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 uses: actions/checkout@v4 with: 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 uses: actions/setup-go@v5 with: @@ -32,6 +55,4 @@ jobs: args: release --clean env: GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }} - REGISTRY_URL: ${{ secrets.REGISTRY_URL }} - REGISTRY_USER: ${{ secrets.REGISTRY_USER }} GORELEASER_FORCE_TOKEN: gitea diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 8897d6f..fa16831 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -26,20 +26,7 @@ checksum: snapshot: name_template: "{{ incpatch .Version }}-next" release: - gitea: - 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:" + disable: true upx: - enabled: true goos: [linux] diff --git a/assets/img/kalenderansicht.png b/assets/img/kalenderansicht.png index 574fe5b..825ff32 100644 Binary files a/assets/img/kalenderansicht.png and b/assets/img/kalenderansicht.png differ diff --git a/cmd/mcbdd/caldav.go b/cmd/mcbdd/caldav.go index 8504e14..ecc1ede 100644 --- a/cmd/mcbdd/caldav.go +++ b/cmd/mcbdd/caldav.go @@ -2,8 +2,10 @@ package main import ( "context" + "encoding/xml" "fmt" "log/slog" + "net/http" "net/url" "slices" "strings" @@ -23,6 +25,7 @@ const ( // 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. 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/") if err != nil { return err @@ -35,6 +38,7 @@ func (d *Daemon) cleanupOldCalendar(ctx context.Context, httpClient webdav.HTTPC if err != nil { return err } + slog.DebugContext(ctx, "found calendars", "user", user, "count", len(cc)) found := false for _, c := range cc { 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 { + slog.DebugContext(ctx, "old calendar not found, nothing to clean up", "user", user, "oldName", oldName) return nil } + slog.DebugContext(ctx, "old calendar found, checking events", "user", user, "oldName", oldName) calendarPath := fmt.Sprintf("/SOGo/dav/%s/Calendar/%s", user, oldName) events, err := cl.QueryCalendar(ctx, calendarPath, &caldav.CalendarQuery{ 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 { + slog.DebugContext(ctx, "ensuring birthday calendar exists", "user", user, "calendar", d.calendarName) endpoint, err := url.JoinPath(d.baseURL, "SOGo/dav", user, "Calendar/") if err != nil { return err @@ -90,13 +97,14 @@ func (d *Daemon) ensureBirthdayCal(ctx context.Context, httpClient webdav.HTTPCl } for _, c := range cc { if strings.HasSuffix(c.Path, fmt.Sprintf("/%s", d.calendarName)) { + slog.DebugContext(ctx, "birthday calendar already exists", "user", user) return nil } } if err := cl.Mkdir(ctx, d.calendarName); err != nil { return err } - slog.InfoContext(ctx, "created birthday calendar", "user", user) + slog.InfoContext(ctx, "created birthday calendar", "user", user, "calendar", d.calendarName) return nil } @@ -126,7 +134,9 @@ func (d *Daemon) syncBirthdaysToCal(ctx context.Context, httpClient webdav.HTTPC if err != nil { 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) driftedEvents := make([]string, 0) for _, ev := range events { @@ -145,6 +155,7 @@ func (d *Daemon) syncBirthdaysToCal(ctx context.Context, httpClient webdav.HTTPC } counterDelete, counterAdded := 0, 0 for _, v := range driftedEvents { + slog.DebugContext(ctx, "removing drifted calendar event", "user", user, "path", v) if err := cl.RemoveAll(ctx, v); err != nil { return err } @@ -154,6 +165,7 @@ func (d *Daemon) syncBirthdaysToCal(ctx context.Context, httpClient webdav.HTTPC if slices.Contains(bevsInSync, i) { continue } + slog.DebugContext(ctx, "adding calendar event", "user", user, "summary", v.Summary, "start", v.DateTimeStart) p, ic := v.generateICAL(calendarPath, d.notificationEnabled, d.notificationTrigger) _, err := cl.PutCalendarObject(ctx, p, ic) if err != nil { @@ -163,6 +175,8 @@ func (d *Daemon) syncBirthdaysToCal(ctx context.Context, httpClient webdav.HTTPC } if (counterAdded + counterDelete) > 0 { 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 } @@ -173,14 +187,18 @@ type birthdayEvent struct { DateTimeEnd string } -func generateBirthdayEvents(birthdays []BirthdayContact) []birthdayEvent { +func generateBirthdayEvents(birthdays []BirthdayContact, eventYears int) []birthdayEvent { cyear := time.Now().Year() bb := make([]birthdayEvent, 0) for _, v := range birthdays { - for year := cyear; year <= 10+cyear; year++ { + for year := cyear; year <= eventYears+cyear; year++ { yearshift := year - v.Date.Year() + prefix := "\U0001F382 " // 🎂 + if v.Type == ContactTypeAnniversary { + prefix = "\U0001F48D " // 💍 + } 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"), 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) 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 := ` + + + + +` + 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(` + + + + %s + + +`, 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 +} diff --git a/cmd/mcbdd/carddav.go b/cmd/mcbdd/carddav.go index 8089b24..9a411d8 100644 --- a/cmd/mcbdd/carddav.go +++ b/cmd/mcbdd/carddav.go @@ -2,6 +2,7 @@ package main import ( "context" + "log/slog" "net/url" "time" @@ -10,11 +11,20 @@ import ( "github.com/emersion/go-webdav/carddav" ) +// ContactType unterscheidet zwischen Geburtstagen und Jahrestagen. +type ContactType int + +const ( + ContactTypeBirthday ContactType = iota + ContactTypeAnniversary +) + type BirthdayContact struct { FamilyName string GivenName string Date time.Time YearKnown bool + Type ContactType } 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 { return nil, err } + slog.DebugContext(ctx, "found address books", "user", user, "count", len(bb)) contacts := make([]BirthdayContact, 0) for _, b := range bb { + slog.DebugContext(ctx, "querying address book", "user", user, "path", b.Path) oo, err := cl.QueryAddressBook(ctx, b.Path, &carddav.AddressBookQuery{}) if err != nil { if err.Error() == "501 Not Implemented" { @@ -41,21 +53,43 @@ func (d *Daemon) getBirthdays(ctx context.Context, httpClient webdav.HTTPClient, } for _, v := range oo { nn := v.Card.Names() - bdayprop := v.Card.Value(vcard.FieldBirthday) - if len(nn) == 0 || len(bdayprop) == 0 { + if len(nn) == 0 { continue } - yyyy, mm, dd, err := sanitizeBirthday(bdayprop) - if err != nil { - return nil, err + bdayprop := v.Card.Value(vcard.FieldBirthday) + if len(bdayprop) > 0 { + 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 } diff --git a/cmd/mcbdd/health.go b/cmd/mcbdd/health.go index e421101..96fc165 100644 --- a/cmd/mcbdd/health.go +++ b/cmd/mcbdd/health.go @@ -3,6 +3,7 @@ package main import ( "encoding/json" "fmt" + "log/slog" "os" "path/filepath" "sync" @@ -57,7 +58,11 @@ func (h *healthState) update(err error) { s.LastError = err.Error() } 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. @@ -68,6 +73,7 @@ func runHealthcheck() error { stateFilepath = "state.json" } healthPath := filepath.Join(filepath.Dir(stateFilepath), healthFile) + slog.Debug("running healthcheck", "healthPath", healthPath) data, err := os.ReadFile(healthPath) if err != nil { @@ -78,10 +84,13 @@ func runHealthcheck() error { return fmt.Errorf("invalid health file: %w", err) } if s.LastError != "" { + slog.Warn("healthcheck: last sync had an error", "lastError", s.LastError) return fmt.Errorf("last sync failed: %s", s.LastError) } 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)) } + slog.Debug("healthcheck passed", "lastSync", s.LastSync.Format("2006/01/02 15:04:05")) return nil } diff --git a/cmd/mcbdd/logger.go b/cmd/mcbdd/logger.go new file mode 100644 index 0000000..e37eff0 --- /dev/null +++ b/cmd/mcbdd/logger.go @@ -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()) + } +} diff --git a/cmd/mcbdd/mailcow.go b/cmd/mcbdd/mailcow.go index 65c18df..7b86703 100644 --- a/cmd/mcbdd/mailcow.go +++ b/cmd/mcbdd/mailcow.go @@ -11,18 +11,24 @@ func (d *Daemon) getUserPass(ctx context.Context, username string) (string, erro pass, ok := d.userTokens[username] d.userTokensLock.RUnlock() if ok { + slog.DebugContext(ctx, "using cached app password", "user", username) return pass, nil } + slog.DebugContext(ctx, "no cached password found, creating new app password", "user", username) pp, err := d.mailcowClient.GetAppPasswords(ctx, username) if err != nil { return "", err } + slog.DebugContext(ctx, "retrieved existing app passwords", "user", username, "total", len(pp)) oldIDs := make([]int, 0) for _, p := range pp { if p.Name == ConstUsertokenName { 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 { return "", fmt.Errorf("error deleting app passwords: %w", err) } diff --git a/cmd/mcbdd/main.go b/cmd/mcbdd/main.go index ea3bc59..c9584c9 100644 --- a/cmd/mcbdd/main.go +++ b/cmd/mcbdd/main.go @@ -7,8 +7,11 @@ import ( "net" "net/http" "os" + "os/signal" + "strconv" "strings" "sync" + "syscall" "time" "git.techniverse.net/scriptos/mailcow-birthday-daemon/pkg/mailcow" @@ -35,13 +38,37 @@ type Daemon struct { stateUnsaved bool calendarName string oldCalendarName string + calendarColor string + storedCalendarColor string notificationEnabled bool notificationTrigger string + eventYears int syncInterval time.Duration + excludeMailboxes map[string]bool 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() { + initLogLevel() + if len(os.Args) > 1 { switch os.Args[1] { case "cleanup": @@ -66,12 +93,11 @@ func main() { func run() error { 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) - // vollständig hochgefahren sind, bevor Verbindungen aufgebaut werden. - const startupDelay = 15 * time.Second - slog.Info("waiting for dependent services to become ready", "delay", startupDelay) - time.Sleep(startupDelay) + // Signal-Handling für Graceful Shutdown (SIGTERM, SIGINT). + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() mailcowBase := os.Getenv("MAILCOW_BASE") if mailcowBase == "" { @@ -85,12 +111,47 @@ func run() error { if calendarName == "" { calendarName = "Birthdays" } + calendarColor := os.Getenv("CALENDAR_COLOR") + if calendarColor == "" { + calendarColor = "#D01818" + } 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() if err != nil { return err } 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" if notificationEnabled { @@ -104,6 +165,8 @@ func run() error { } notificationTrigger = trigger slog.Info("birthday notifications enabled", "time", notificationTime, "trigger", notificationTrigger) + } else { + slog.Debug("birthday notifications disabled") } d := &Daemon{ userTokens: make(map[string]string), @@ -112,13 +175,17 @@ func run() error { stateFilepath: os.Getenv("STATEFILE"), httpClient: &http.Client{Transport: buildTransport()}, calendarName: calendarName, + calendarColor: calendarColor, notificationEnabled: notificationEnabled, notificationTrigger: notificationTrigger, + eventYears: eventYears, syncInterval: syncInterval, + excludeMailboxes: excludeMailboxes, } if len(d.stateFilepath) == 0 { d.stateFilepath = "state.json" } + slog.Debug("state file path", "path", d.stateFilepath) d.health = newHealthState(d.stateFilepath) d.mailcowClient = mailcow.New( d.httpClient, @@ -128,26 +195,56 @@ func run() error { if err := d.loadState(); err != nil { return err } - d.daemonLoop() + slog.Info("initialization complete, entering daemon loop") + d.daemonLoop(ctx) return nil } -func (d *Daemon) daemonLoop() { +func (d *Daemon) daemonLoop(ctx context.Context) { 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() d.health.update(err) if err != nil { 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 { + slog.Debug("fetching mailboxes from mailcow API") mb, err := d.mailcowClient.GetMailboxes(context.Background()) if err != nil { return fmt.Errorf("error fetching mailboxes: %w", err) } + slog.Info("fetched mailboxes", "count", len(mb)) eg := sync.WaitGroup{} for _, m := range mb { eg.Go(func() { @@ -169,16 +266,32 @@ func (d *Daemon) daemonRun() error { 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 { if !m.IsActive() { + slog.DebugContext(ctx, "skipping inactive mailbox", "user", m.Username) 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) if err != nil { return fmt.Errorf("error getting userpass: %w", err) } davclient := webdav.HTTPClientWithBasicAuth(d.httpClient, m.Username, pass) 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 { 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) } + slog.DebugContext(ctx, "found birthday contacts", "user", m.Username, "count", len(bb)) if err := d.ensureBirthdayCal(ctx, davclient, m.Username); err != nil { 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 { return fmt.Errorf("error syncing birthday events to caldav: %w", err) } + slog.DebugContext(ctx, "user processing complete", "user", m.Username) return nil } @@ -213,6 +331,7 @@ func runCleanup() error { oldCalendarName := os.Args[2] slog.Info("starting calendar cleanup", "calendarName", oldCalendarName) + slog.Debug("loading environment configuration for cleanup") mailcowBase := os.Getenv("MAILCOW_BASE") if mailcowBase == "" { @@ -250,8 +369,10 @@ func runCleanup() error { } processed, skipped := 0, 0 + slog.Debug("starting cleanup for all mailboxes", "totalMailboxes", len(mb)) for _, m := range mb { if !m.IsActive() { + slog.Debug("skipping inactive mailbox during cleanup", "user", m.Username) continue } d.userTokensLock.RLock() @@ -263,9 +384,12 @@ func runCleanup() error { continue } ctx := context.Background() + slog.Debug("cleaning up calendar for user", "user", m.Username, "calendar", oldCalendarName) davclient := webdav.HTTPClientWithBasicAuth(d.httpClient, m.Username, pass) if err := d.cleanupOldCalendar(ctx, davclient, m.Username, oldCalendarName); err != nil { slog.Error("error cleaning up calendar", "user", m.Username, "err", err) + } else { + slog.Debug("calendar cleanup successful for user", "user", m.Username) } processed++ } @@ -274,6 +398,27 @@ func runCleanup() error { 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 // geparste Dauer zurück. Standard: 15m. func parseSyncInterval() (time.Duration, error) { @@ -291,6 +436,24 @@ func parseSyncInterval() (time.Duration, error) { 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: 1–30. +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. // 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 diff --git a/cmd/mcbdd/persist.go b/cmd/mcbdd/persist.go index 483e010..3861c27 100644 --- a/cmd/mcbdd/persist.go +++ b/cmd/mcbdd/persist.go @@ -10,21 +10,25 @@ import ( ) func (d *Daemon) loadState() error { + slog.Debug("loading state file", "path", d.stateFilepath) stateVer := struct { Version int `json:"version"` }{} if err := d.loadFromDisk(&stateVer); err != nil { return fmt.Errorf("cant detect state version: %w", err) } + slog.Debug("detected state version", "version", stateVer.Version) var storedCalendarName string switch stateVer.Version { 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 { 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 case 1: + slog.Warn("loading state v1, migration to v2 required", "stateVer", stateVer.Version) state := struct { Version int `json:"version"` UserTokens map[string]string `json:"userTokens"` @@ -41,10 +45,12 @@ func (d *Daemon) loadState() error { } d.stateUnsaved = true case 2: + slog.Debug("loading current state format", "stateVer", stateVer.Version) state := struct { - Version int `json:"version"` - UserTokens map[string]string `json:"userTokens"` - CalendarName string `json:"calendarName"` + Version int `json:"version"` + UserTokens map[string]string `json:"userTokens"` + CalendarName string `json:"calendarName"` + CalendarColor string `json:"calendarColor,omitempty"` }{} if err := d.loadFromDisk(&state); err != nil { 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) } storedCalendarName = state.CalendarName + d.storedCalendarColor = state.CalendarColor } + slog.Info("state loaded", "users", len(d.userTokens)) if storedCalendarName != "" && storedCalendarName != d.calendarName { slog.Info("calendar name changed, old calendars will be cleaned up", "old", storedCalendarName, "new", d.calendarName) d.oldCalendarName = storedCalendarName 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 } 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)) for k, v := range d.userTokens { encTokens[k] = base64.StdEncoding.EncodeToString([]byte(v)) } state := struct { - Version int `json:"version"` - UserTokens map[string]string `json:"userTokens"` - CalendarName string `json:"calendarName"` + Version int `json:"version"` + UserTokens map[string]string `json:"userTokens"` + CalendarName string `json:"calendarName"` + CalendarColor string `json:"calendarColor,omitempty"` }{ - Version: 2, - UserTokens: encTokens, - CalendarName: d.calendarName, + Version: 2, + UserTokens: encTokens, + 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 { f, err := os.OpenFile(d.stateFilepath, os.O_RDONLY, 0o660) if err != nil { if errors.Is(err, os.ErrNotExist) { + slog.Debug("state file does not exist, starting fresh", "path", d.stateFilepath) return nil } return err diff --git a/cmd/mcbdd/startup.go b/cmd/mcbdd/startup.go new file mode 100644 index 0000000..8ea958f --- /dev/null +++ b/cmd/mcbdd/startup.go @@ -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 +} diff --git a/cmd/mcbdd/util.go b/cmd/mcbdd/util.go index 86d7aea..20b0734 100644 --- a/cmd/mcbdd/util.go +++ b/cmd/mcbdd/util.go @@ -32,7 +32,7 @@ func randomPassword(length int) (string, error) { return string(pass), nil } -func sanitizeBirthday(input string) (uint16, uint16, uint16, error) { +func sanitizeDate(input string) (uint16, uint16, uint16, error) { input = strings.ReplaceAll(input, "-", "") switch len(input) { case 4: @@ -63,7 +63,7 @@ func sanitizeBirthday(input string) (uint16, uint16, uint16, error) { } 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 diff --git a/cmd/mcbdd/util_test.go b/cmd/mcbdd/util_test.go index 55e94fc..c2112cd 100644 --- a/cmd/mcbdd/util_test.go +++ b/cmd/mcbdd/util_test.go @@ -2,7 +2,7 @@ package main import "testing" -func Test_sanitizeBirthday(t *testing.T) { +func Test_sanitizeDate(t *testing.T) { tests := []struct { name string // description of this test case // Named input parameters for target function. @@ -47,24 +47,24 @@ func Test_sanitizeBirthday(t *testing.T) { } for _, tt := range tests { 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 !tt.wantErr { - t.Errorf("sanitizeBirthday() failed: %v", gotErr) + t.Errorf("sanitizeDate() failed: %v", gotErr) } return } if tt.wantErr { - t.Fatal("sanitizeBirthday() succeeded unexpectedly") + t.Fatal("sanitizeDate() succeeded unexpectedly") } 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 { - t.Errorf("sanitizeBirthday() = %v, want %v", got2, tt.want2) + t.Errorf("sanitizeDate() = %v, want %v", got2, tt.want2) } if got3 != tt.want3 { - t.Errorf("sanitizeBirthday() = %v, want %v", got3, tt.want3) + t.Errorf("sanitizeDate() = %v, want %v", got3, tt.want3) } }) } diff --git a/docs/funktionsweise.md b/docs/funktionsweise.md index 44e2bcd..a8dffaa 100644 --- a/docs/funktionsweise.md +++ b/docs/funktionsweise.md @@ -4,6 +4,10 @@ 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 - Ü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 - 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. - - 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. +## 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 - 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)). +## 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 - 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). +## 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 - 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. diff --git a/docs/schnellstart.md b/docs/schnellstart.md index 630ef2e..146c685 100644 --- a/docs/schnellstart.md +++ b/docs/schnellstart.md @@ -24,10 +24,14 @@ services: - MAILCOW_BASE=https://mail.example.com - MAILCOW_APIKEY=DEIN-APIKEY-HIER - CALENDAR_NAME=Birthdays + # - CALENDAR_COLOR=#D01818 # - MAILCOW_RESOLVE_HOST=nginx-mailcow # - NOTIFICATION_ENABLED=true # - NOTIFICATION_TIME=08:00 # - SYNC_INTERVAL=15m + # - EVENT_YEARS=10 + # - MAILBOX_EXCLUDE=user1@example.com,user2@example.com + # - LOG_LEVEL=info volumes: - birthdaydaemon:/data @@ -50,7 +54,7 @@ cd /opt/mailcow-dockerized 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 @@ -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_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_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_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 | -| `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 Den API-Key findet man im Admin-Panel unter Konfiguration → Zugang → Administratordetails bearbeiten → API → Lese-/Schreibzugriff. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 0b3c154..7eea469 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -3,8 +3,9 @@ ## Container startet, aber keine Kalender werden erstellt 1. Logs prüfen: `docker compose logs -f birthdaydaemon` -2. Sicherstellen, dass der API-Key **Lese- und Schreibzugriff** hat (nicht nur Lesezugriff). -3. Prüfen, ob die Mailbox aktiv ist – inaktive Mailboxen werden übersprungen. +2. Für detaillierte Ausgaben `LOG_LEVEL=debug` als Umgebungsvariable setzen – damit werden einzelne Kontakte und Kalendereinträge sichtbar. +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 @@ -33,12 +34,12 @@ docker compose exec birthdaydaemon /mailcow-birthday-daemon cleanup Birthdays **Beispielausgabe:** ``` -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 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=user3@example.com calendar=Birthdays -2026/03/28 23:46:24 INFO cleanup finished processed=5 skipped=0 +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] 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=user3@example.com calendar=Birthdays +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.