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.