35 Commits

Author SHA1 Message Date
14152524f3 Merge pull request 'fix: SOGo VLIST-Kontaktlisten aus CardDAV-Antworten filtern' (#20) from release-0.4.2 into master
All checks were successful
Run Tests / test (push) Successful in 4m33s
Build & Publish / build (release) Successful in 44s
Reviewed-on: #20
2026-03-30 09:05:26 +00:00
da699b2ce9 fix: SOGo VLIST-Kontaktlisten aus CardDAV-Antworten filtern
All checks were successful
Build Test Docker Image / docker-test (pull_request) Successful in 1m36s
Run Tests / test (pull_request) Successful in 4m59s
2026-03-30 10:44:22 +02:00
cdf713b5cf Merge pull request 'docs: Zeile aus Readme entfernt' (#19) from docs into master
Reviewed-on: #19
2026-03-29 22:52:36 +00:00
fbbdb63854 Merge branch 'master' into docs 2026-03-29 22:52:21 +00:00
d926174929 docs: Zeile aus Readme entfernt 2026-03-30 00:51:42 +02:00
7bcfb5d1a9 Merge pull request 'docs: Dokumentation auf Feature-Stand von master aktualisiert' (#18) from docs into master
Reviewed-on: #18
2026-03-29 22:47:30 +00:00
0d0b0bd124 merge: Konflikte mit master aufgelöst (docs-Inhalte beibehalten) 2026-03-30 00:46:00 +02:00
606c0ed0da docs: Dokumentation auf Feature-Stand von master aktualisiert 2026-03-30 00:41:27 +02:00
61d58b7462 Merge pull request 'fix(ci): Release-Workflow auf release:published Event umgestellt' (#17) from release-v0.4.1 into master
All checks were successful
Run Tests / test (push) Successful in 4m36s
Build & Publish / build (release) Successful in 2m3s
Reviewed-on: #17
2026-03-29 22:04:57 +00:00
b0ab2eb99a fix(ci): Release-Workflow auf release:published Event umgestellt
All checks were successful
Build Test Docker Image / docker-test (pull_request) Successful in 2m19s
Run Tests / test (pull_request) Successful in 4m50s
2026-03-30 00:04:13 +02:00
ba5935c891 Merge pull request 'release-v0.4.0' (#16) from release-v0.4.0 into master
Some checks failed
Run Tests / test (push) Has been cancelled
Reviewed-on: #16
2026-03-29 21:56:15 +00:00
cb1b420a3f fix(ci): Release-Workflow auf manuellen Trigger umgestellt, SCM-Release deaktiviert
All checks were successful
Build Test Docker Image / docker-test (pull_request) Successful in 1m36s
Run Tests / test (pull_request) Successful in 4m59s
2026-03-29 23:48:54 +02:00
3ede89322f fix: Überflüssige Leerzeilen am Ende von startup.go entfernt
All checks were successful
Build Test Docker Image / docker-test (pull_request) Successful in 1m26s
Run Tests / test (pull_request) Successful in 4m53s
2026-03-29 23:22:10 +02:00
0311c830bd Startup-Connectivity-Check: Fixen 15s-Delay durch aktive Erreichbarkeitsprüfung von API und SOGo ersetzt
Some checks failed
Build Test Docker Image / docker-test (pull_request) Successful in 1m26s
Run Tests / test (pull_request) Failing after 1m20s
2026-03-29 23:16:17 +02:00
93371e3afa feat: Kalenderfarbe (calendar-color) automatisch setzen
All checks were successful
Build Test Docker Image / docker-test (pull_request) Successful in 1m32s
Run Tests / test (pull_request) Successful in 4m53s
2026-03-29 23:06:09 +02:00
ea598a979b Umgebungsvariable EVENT_YEARS für konfigurierbaren Event-Horizont hinzugefügt
All checks were successful
Build Test Docker Image / docker-test (pull_request) Successful in 1m30s
Run Tests / test (pull_request) Successful in 4m46s
2026-03-29 22:47:56 +02:00
c1514330e3 docs: Hinweis zu fehlender Jahrestag-Unterstützung in SOGo-Weboberfläche + Kalenderansicht aktualisiert
All checks were successful
Build Test Docker Image / docker-test (pull_request) Successful in 1m43s
Run Tests / test (pull_request) Successful in 5m7s
2026-03-29 22:38:43 +02:00
05faa43541 feat: MAILBOX_EXCLUDE hinzugefügt – Mailboxen per Env von der Synchronisation ausschließen
All checks were successful
Build Test Docker Image / docker-test (pull_request) Successful in 1m39s
Run Tests / test (pull_request) Successful in 4m45s
2026-03-29 21:53:02 +02:00
1e51010f10 Log-Format auf [INFO]/[WARN]/[ERROR]/[DEBUG]-Tags umgestellt, Logging erweitert
All checks were successful
Build Test Docker Image / docker-test (pull_request) Successful in 1m39s
Run Tests / test (pull_request) Successful in 5m4s
2026-03-29 21:44:25 +02:00
58bb3b038e feat: Konfigurierbarer Log-Level über LOG_LEVEL-Umgebungsvariable (debug/info/warn/error) hinzugefügt
All checks were successful
Build Test Docker Image / docker-test (pull_request) Successful in 1m53s
Run Tests / test (pull_request) Successful in 5m0s
2026-03-29 21:29:52 +02:00
e74d5f711a feat: Jahrestage (ANNIVERSARY) und Emoji-Präfixe für Kalendereinträge
All checks were successful
Build Test Docker Image / docker-test (pull_request) Successful in 1m48s
Run Tests / test (pull_request) Successful in 5m12s
2026-03-29 21:20:36 +02:00
fc673b09fb Graceful Shutdown mit Signal-Handling hinzugefügt 2026-03-29 20:57:52 +02:00
092bd02977 Merge pull request 'syncintervall' (#15) from syncintervall into master
All checks were successful
Run Tests / test (push) Successful in 4m40s
Reviewed-on: #15
2026-03-29 18:48:35 +00:00
7448759732 Merge branch 'master' into syncintervall
All checks were successful
Build Test Docker Image / docker-test (pull_request) Successful in 1m20s
Run Tests / test (pull_request) Successful in 4m54s
2026-03-29 18:48:22 +00:00
a799975c1f Merge pull request 'feat: Dateibasierten Docker-Healthcheck hinzugefügt' (#14) from healtcheck into master
Some checks failed
Run Tests / test (push) Has been cancelled
Reviewed-on: #14
2026-03-29 18:48:13 +00:00
b36ae57d48 feat: Sync-Intervall über SYNC_INTERVAL konfigurierbar (Standard: 15m)
All checks were successful
Build Test Docker Image / docker-test (pull_request) Successful in 1m33s
Run Tests / test (pull_request) Successful in 4m57s
2026-03-29 20:46:56 +02:00
67c3f10454 feat: Dateibasierten Docker-Healthcheck hinzugefügt
All checks were successful
Build Test Docker Image / docker-test (pull_request) Successful in 1m49s
Run Tests / test (pull_request) Successful in 4m45s
2026-03-29 20:41:51 +02:00
766b69aa4a Merge pull request 'docs' (#13) from docs into master
Reviewed-on: #13
2026-03-29 00:10:40 +00:00
c623e39b4c Merge branch 'master' into docs 2026-03-29 00:10:31 +00:00
c5337d7d63 Merge branch 'docs' of https://git.techniverse.net/scriptos/mailcow-birthday-daemon into docs 2026-03-29 01:10:05 +01:00
efcbd04aa2 docs: Hinweis auf Schnellstart für Umgebungsvariablen in README ergänzt 2026-03-29 01:09:58 +01:00
dc01480b8b Merge pull request 'docs: CALENDAR_NAME in Beispiel Compose ergänzt' (#12) from docs into master
Reviewed-on: #12
2026-03-29 00:01:51 +00:00
78ebe7a499 Merge branch 'master' into docs 2026-03-29 00:01:31 +00:00
b9c81bd04e docs: CALENDAR_NAME in Beispiel Compose ergänzt 2026-03-29 01:00:56 +01:00
2568258794 Merge pull request 'docs: Beispielausgabe für Cleanup-Befehl ergänzt' (#11) from cleanup-docs into master
Reviewed-on: #11
2026-03-28 23:54:39 +00:00
20 changed files with 1027 additions and 89 deletions

View File

@@ -1,12 +1,11 @@
name: Make Release
name: Build & Publish
on:
push:
tags:
- 'v*'
release:
types: [published]
jobs:
release:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
@@ -32,6 +31,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

1
.gitignore vendored
View File

@@ -1,6 +1,7 @@
/.vscode/
/.ki-workspace/
/dist/
*.exe
.env
state.json

View File

@@ -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]

View File

@@ -9,5 +9,8 @@ ENTRYPOINT ["/mailcow-birthday-daemon"]
ENV STATEFILE=/data/state.json
VOLUME [ "/data" ]
HEALTHCHECK --interval=60s --timeout=5s --start-period=30s --retries=3 \
CMD ["/mailcow-birthday-daemon", "healthcheck"]
COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY mailcow-birthday-daemon /mailcow-birthday-daemon

View File

@@ -10,9 +10,17 @@ Ein einfacher Daemon, der automatisch einen Geburtstagskalender für jede Mailco
## Kurzübersicht
- Liest Geburtstage aus allen CardDAV-Adressbüchern jeder Mailbox
- Liest Geburtstage und Jahrestage aus allen CardDAV-Adressbüchern jeder Mailbox
- Erstellt und synchronisiert automatisch einen Geburtstagskalender pro Benutzer
- Synchronisation alle **15 Minuten**
- Konfigurierbarer Kalendername (`CALENDAR_NAME`) und Kalenderfarbe (`CALENDAR_COLOR`)
- Optionale Benachrichtigungen (VALARM) zur konfigurierbarer Uhrzeit
- Konfigurierbares Sync-Intervall (`SYNC_INTERVAL`) und Event-Horizont (`EVENT_YEARS`)
- Mailboxen einzeln ausschließbar via `MAILBOX_EXCLUDE`
- Automatische App-Passwort-Verwaltung (persistent im State-File)
- Integrierter Cleanup-Befehl bei doppelten Kalendern nach Umbenennung
- Startup-Connectivity-Check, Graceful Shutdown und Docker-Healthcheck
- Hairpin-NAT-Lösung via `MAILCOW_RESOLVE_HOST` für Docker-Netze
- Strukturiertes Logging mit konfigurierbarem Log-Level
- Läuft als Docker-Container direkt im Mailcow-Stack
## Schnellstart
@@ -40,6 +48,8 @@ volumes:
birthdaydaemon:
```
> **Hinweis:** Das obige Beispiel zeigt nur die minimal nötigen Umgebungsvariablen. Eine vollständige Übersicht aller verfügbaren Umgebungsvariablen findest du im [Schnellstart](docs/schnellstart.md).
Anschließend starten:
```bash

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 66 KiB

View File

@@ -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 := `<?xml version="1.0" encoding="UTF-8"?>
<d:propfind xmlns:d="DAV:" xmlns:A="http://apple.com/ns/ical/">
<d:prop>
<A:calendar-color/>
</d:prop>
</d:propfind>`
req, err := http.NewRequestWithContext(ctx, "PROPFIND", calendarURL, strings.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/xml; charset=utf-8")
req.Header.Set("Depth", "0")
resp, err := httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusMultiStatus {
return "", fmt.Errorf("PROPFIND calendar-color returned status %d", resp.StatusCode)
}
var ms multistatusResponse
if err := xml.NewDecoder(resp.Body).Decode(&ms); err != nil {
return "", fmt.Errorf("error parsing PROPFIND response: %w", err)
}
for _, r := range ms.Responses {
for _, ps := range r.PropStats {
if ps.Prop.CalendarColor != "" {
return normalizeColor(ps.Prop.CalendarColor), nil
}
}
}
return "", nil
}
// setCalendarColor setzt die calendar-color-Property (Apple-Namespace) des
// Geburtstagskalenders per PROPPATCH. Die Farbe wird mit Alpha-Kanal (#RRGGBBFF)
// übertragen, da SOGo dieses Format erwartet.
func (d *Daemon) setCalendarColor(ctx context.Context, httpClient webdav.HTTPClient, user, color string) error {
calendarURL, err := url.JoinPath(d.baseURL, "SOGo/dav", user, "Calendar", d.calendarName)
if err != nil {
return err
}
colorValue := strings.ToUpper(color)
if len(colorValue) == 7 {
colorValue += "FF"
}
body := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
<d:propertyupdate xmlns:d="DAV:" xmlns:A="http://apple.com/ns/ical/">
<d:set>
<d:prop>
<A:calendar-color>%s</A:calendar-color>
</d:prop>
</d:set>
</d:propertyupdate>`, colorValue)
req, err := http.NewRequestWithContext(ctx, "PROPPATCH", calendarURL, strings.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/xml; charset=utf-8")
resp, err := httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusMultiStatus && resp.StatusCode != http.StatusOK {
return fmt.Errorf("PROPPATCH calendar-color returned status %d", resp.StatusCode)
}
return nil
}
// ensureCalendarColor stellt sicher, dass der Geburtstagskalender die
// konfigurierte Farbe hat, sofern der Benutzer sie nicht manuell geändert hat.
// Die Erkennung manueller Änderungen basiert auf dem Vergleich zwischen der
// aktuellen Kalenderfarbe und der zuletzt vom Daemon gesetzten Farbe (aus dem
// State-File). Stimmen diese nicht überein, wurde die Farbe vom Benutzer
// angepasst und wird nicht überschrieben.
func (d *Daemon) ensureCalendarColor(ctx context.Context, httpClient webdav.HTTPClient, user string) error {
if d.calendarColor == "" {
return nil
}
current, err := d.getCalendarColor(ctx, httpClient, user)
if err != nil {
return fmt.Errorf("error reading calendar color: %w", err)
}
desired := normalizeColor(d.calendarColor)
stored := normalizeColor(d.storedCalendarColor)
if current == desired {
slog.DebugContext(ctx, "calendar color already correct", "user", user, "color", desired)
return nil
}
// Wenn der Benutzer die Farbe manuell geändert hat (current != stored && current != ""),
// wird die Farbe nicht überschrieben.
if current != "" && stored != "" && current != stored {
slog.DebugContext(ctx, "calendar color was customized by user, skipping",
"user", user, "current", current, "daemon", stored)
return nil
}
if err := d.setCalendarColor(ctx, httpClient, user, desired); err != nil {
return fmt.Errorf("error setting calendar color: %w", err)
}
slog.InfoContext(ctx, "set calendar color", "user", user, "color", desired)
return nil
}

View File

@@ -1,7 +1,13 @@
package main
import (
"bytes"
"context"
"encoding/xml"
"errors"
"io"
"log/slog"
"net/http"
"net/url"
"time"
@@ -10,11 +16,131 @@ import (
"github.com/emersion/go-webdav/carddav"
)
// vlistFilterClient wraps a webdav.HTTPClient and removes DAV <response>
// elements whose address-data contains a BEGIN:VLIST block before the
// carddav/vcard decoder processes them.
type vlistFilterClient struct {
inner webdav.HTTPClient
}
func (c *vlistFilterClient) Do(req *http.Request) (*http.Response, error) {
resp, err := c.inner.Do(req)
if err != nil {
return resp, err
}
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
return nil, err
}
body, err = stripVListResponses(body)
if err != nil {
return nil, err
}
resp.Body = io.NopCloser(bytes.NewReader(body))
resp.ContentLength = int64(len(body))
return resp, nil
}
// stripVListResponses scans a WebDAV multistatus XML body and removes any
// <D:response> elements whose <C:address-data> chardata contains BEGIN:VLIST.
// It uses the XML decoder only to find byte offsets; it never re-encodes, so
// namespace declarations and formatting are preserved exactly.
// Non-multistatus bodies (no VLIST present) are returned unchanged.
func stripVListResponses(body []byte) ([]byte, error) {
if !bytes.Contains(body, []byte("BEGIN:VLIST")) {
return body, nil
}
const davNS = "DAV:"
const cardNS = "urn:ietf:params:xml:ns:carddav"
dec := xml.NewDecoder(bytes.NewReader(body))
// ranges holds [start, end) byte offsets of <D:response> blocks to drop.
type byteRange struct{ start, end int64 }
var drop []byteRange
var responseStart int64
responseDepth := 0
inAddrData := false
addrDataDepth := 0
isVList := false
for {
offset := dec.InputOffset()
tok, err := dec.Token()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return body, nil // unparseable — return original unchanged
}
switch t := tok.(type) {
case xml.StartElement:
if t.Name.Space == davNS && t.Name.Local == "response" && responseDepth == 0 {
responseStart = offset
responseDepth = 1
isVList = false
} else if responseDepth > 0 {
// Increment depth before checking the element name so that
// addrDataDepth records the post-increment value, matching the
// depth at which the corresponding EndElement will fire.
responseDepth++
if t.Name.Space == cardNS && t.Name.Local == "address-data" {
inAddrData = true
addrDataDepth = responseDepth
}
}
case xml.EndElement:
if responseDepth > 0 {
if inAddrData && responseDepth == addrDataDepth {
inAddrData = false
}
responseDepth--
if responseDepth == 0 {
if isVList {
drop = append(drop, byteRange{responseStart, dec.InputOffset()})
}
}
}
case xml.CharData:
if inAddrData && bytes.Contains(t, []byte("BEGIN:VLIST")) {
isVList = true
}
}
}
if len(drop) == 0 {
return body, nil
}
var out bytes.Buffer
out.Grow(len(body))
pos := int64(0)
for _, r := range drop {
out.Write(body[pos:r.start])
pos = r.end
}
out.Write(body[pos:])
return out.Bytes(), nil
}
// 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) {
@@ -22,7 +148,7 @@ func (d *Daemon) getBirthdays(ctx context.Context, httpClient webdav.HTTPClient,
if err != nil {
return nil, err
}
cl, err := carddav.NewClient(httpClient, endpoint)
cl, err := carddav.NewClient(&vlistFilterClient{inner: httpClient}, endpoint)
if err != nil {
return nil, err
}
@@ -30,8 +156,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 +169,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
}

96
cmd/mcbdd/health.go Normal file
View File

@@ -0,0 +1,96 @@
package main
import (
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"sync"
"time"
)
// maxSyncAge berechnet die maximale Dauer seit dem letzten Sync-Lauf, bevor
// der Healthcheck den Daemon als unhealthy meldet. Die Toleranz beträgt
// 5 Minuten über dem konfigurierten Sync-Intervall.
func maxSyncAge() time.Duration {
syncInterval, err := parseSyncInterval()
if err != nil {
// Fallback: 20 Minuten (15m Standard-Intervall + 5m Toleranz).
return 20 * time.Minute
}
return syncInterval + 5*time.Minute
}
// healthFile ist der Dateiname der Healthcheck-Statusdatei, die neben dem
// State-File abgelegt wird.
const healthFile = "health.json"
// healthStatus wird als JSON in die Healthcheck-Datei geschrieben.
type healthStatus struct {
LastSync time.Time `json:"last_sync"`
LastError string `json:"last_error,omitempty"`
}
// healthState hält den aktuellen Health-Status im Speicher und schreibt
// ihn nach jedem Sync-Lauf in eine Datei.
type healthState struct {
mu sync.Mutex
filePath string
}
func newHealthState(stateFilepath string) *healthState {
dir := filepath.Dir(stateFilepath)
return &healthState{
filePath: filepath.Join(dir, healthFile),
}
}
// update wird nach jedem Sync-Lauf aufgerufen und schreibt den Status
// in die Health-Datei.
func (h *healthState) update(err error) {
h.mu.Lock()
defer h.mu.Unlock()
s := healthStatus{
LastSync: time.Now(),
}
if err != nil {
s.LastError = err.Error()
}
data, _ := json.Marshal(s)
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.
// Exit-Code 0 = healthy, 1 = unhealthy. Wird von Docker HEALTHCHECK aufgerufen.
func runHealthcheck() error {
stateFilepath := os.Getenv("STATEFILE")
if stateFilepath == "" {
stateFilepath = "state.json"
}
healthPath := filepath.Join(filepath.Dir(stateFilepath), healthFile)
slog.Debug("running healthcheck", "healthPath", healthPath)
data, err := os.ReadFile(healthPath)
if err != nil {
return fmt.Errorf("health file not found: %w (daemon may still be starting)", err)
}
var s healthStatus
if err := json.Unmarshal(data, &s); err != nil {
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
}

115
cmd/mcbdd/logger.go Normal file
View File

@@ -0,0 +1,115 @@
package main
import (
"context"
"fmt"
"io"
"log/slog"
"strings"
"sync"
"time"
)
// bracketHandler ist ein slog.Handler, der Log-Nachrichten im Format
// "2006/01/02 15:04:05 [LEVEL] message key=value" ausgibt.
// Die Level-Tags [INFO], [WARN], [ERROR] und [DEBUG] ermöglichen eine
// schnelle visuelle Zuordnung in Container-Logs.
type bracketHandler struct {
level slog.Level
out io.Writer
mu *sync.Mutex
attrs []slog.Attr
}
func newBracketHandler(out io.Writer, level slog.Level) *bracketHandler {
return &bracketHandler{
level: level,
out: out,
mu: &sync.Mutex{},
}
}
func (h *bracketHandler) Enabled(_ context.Context, level slog.Level) bool {
return level >= h.level
}
func (h *bracketHandler) Handle(_ context.Context, r slog.Record) error {
var levelTag string
switch {
case r.Level >= slog.LevelError:
levelTag = "[ERROR]"
case r.Level >= slog.LevelWarn:
levelTag = "[WARN]"
case r.Level >= slog.LevelInfo:
levelTag = "[INFO]"
default:
levelTag = "[DEBUG]"
}
var b strings.Builder
b.WriteString(r.Time.Format("2006/01/02 15:04:05"))
b.WriteByte(' ')
b.WriteString(levelTag)
b.WriteByte(' ')
b.WriteString(r.Message)
writeAttr := func(a slog.Attr) bool {
a.Value = a.Value.Resolve()
if a.Equal(slog.Attr{}) {
return true
}
b.WriteByte(' ')
b.WriteString(a.Key)
b.WriteByte('=')
b.WriteString(formatAttrValue(a.Value))
return true
}
for _, a := range h.attrs {
writeAttr(a)
}
r.Attrs(writeAttr)
b.WriteByte('\n')
h.mu.Lock()
defer h.mu.Unlock()
_, err := h.out.Write([]byte(b.String()))
return err
}
func (h *bracketHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
newAttrs := make([]slog.Attr, len(h.attrs)+len(attrs))
copy(newAttrs, h.attrs)
copy(newAttrs[len(h.attrs):], attrs)
return &bracketHandler{
level: h.level,
out: h.out,
mu: h.mu,
attrs: newAttrs,
}
}
func (h *bracketHandler) WithGroup(_ string) slog.Handler {
// Gruppen werden für dieses Projekt nicht benötigt.
return h
}
// formatAttrValue formatiert einen slog.Value als String.
// String-Werte mit Leerzeichen oder Sonderzeichen werden in Anführungszeichen gesetzt.
func formatAttrValue(v slog.Value) string {
switch v.Kind() {
case slog.KindString:
s := v.String()
if s == "" || strings.ContainsAny(s, " \t\n\"\\") {
return fmt.Sprintf("%q", s)
}
return s
case slog.KindTime:
return v.Time().Format(time.RFC3339)
case slog.KindDuration:
return v.Duration().String()
default:
return fmt.Sprintf("%v", v.Any())
}
}

View File

@@ -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)
}

View File

@@ -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,17 +38,52 @@ 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() {
if len(os.Args) > 1 && os.Args[1] == "cleanup" {
if err := runCleanup(); err != nil {
slog.Error("cleanup failed", "err", err)
os.Exit(1)
initLogLevel()
if len(os.Args) > 1 {
switch os.Args[1] {
case "cleanup":
if err := runCleanup(); err != nil {
slog.Error("cleanup failed", "err", err)
os.Exit(1)
}
return
case "healthcheck":
if err := runHealthcheck(); err != nil {
slog.Error("healthcheck failed", "err", err)
os.Exit(1)
}
return
}
return
}
if err := run(); err != nil {
slog.Error("fatal error", "err", err)
@@ -55,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 == "" {
@@ -74,7 +111,48 @@ 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 {
notificationTime := os.Getenv("NOTIFICATION_TIME")
@@ -87,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),
@@ -95,12 +175,18 @@ 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,
mailcowBase,
@@ -109,24 +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 {
if err := d.daemonRun(); err != nil {
slog.Error("error while syncing birthdays", "err", err)
// 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(time.Minute * 15)
}
}
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() {
@@ -148,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)
}
@@ -173,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
}
@@ -192,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 == "" {
@@ -229,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()
@@ -242,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++
}
@@ -253,6 +398,62 @@ 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) {
raw := os.Getenv("SYNC_INTERVAL")
if raw == "" {
return 15 * time.Minute, nil
}
d, err := time.ParseDuration(raw)
if err != nil {
return 0, fmt.Errorf("invalid SYNC_INTERVAL %q: %w", raw, err)
}
if d < 1*time.Minute {
return 0, fmt.Errorf("SYNC_INTERVAL must be at least 1m, got %s", d)
}
return d, nil
}
// parseEventYears liest EVENT_YEARS aus der Umgebung und gibt die Anzahl
// der Jahre zurück, für die Geburtstags-Events im Voraus erzeugt werden.
// Standard: 10. Gültige Werte: 130.
func parseEventYears() (int, error) {
raw := os.Getenv("EVENT_YEARS")
if raw == "" {
return 10, nil
}
n, err := strconv.Atoi(raw)
if err != nil {
return 0, fmt.Errorf("invalid EVENT_YEARS %q: %w", raw, err)
}
if n < 1 || n > 30 {
return 0, fmt.Errorf("EVENT_YEARS must be between 1 and 30, got %d", n)
}
return n, nil
}
// buildTransport erstellt einen http.Transport.
// 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

View File

@@ -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

119
cmd/mcbdd/startup.go Normal file
View File

@@ -0,0 +1,119 @@
package main
import (
"context"
"log/slog"
"net/http"
"net/url"
"time"
"git.techniverse.net/scriptos/mailcow-birthday-daemon/pkg/mailcow"
)
const (
// startupBackoffInit ist das anfängliche Warteintervall zwischen
// Erreichbarkeitsprüfungen (2 Sekunden).
startupBackoffInit = 2 * time.Second
// startupBackoffMax begrenzt das maximale Warteintervall auf 30 Sekunden.
startupBackoffMax = 30 * time.Second
// startupRequestTimeout begrenzt einzelne HTTP-Anfragen während der
// Startphase auf 10 Sekunden.
startupRequestTimeout = 10 * time.Second
)
// waitForServices prüft in einer Schleife, ob die Mailcow-API und SOGo
// erreichbar sind. Erst wenn beide Dienste antworten, kehrt die Funktion
// zurück. Bei einem Shutdown-Signal (ctx.Done) wird sofort nil
// zurückgegeben.
func waitForServices(ctx context.Context, httpClient *http.Client, baseURL, apiKey string) error {
slog.Info("checking connectivity to mailcow API and SOGo")
backoff := startupBackoffInit
for {
apiOK := checkMailcowAPI(ctx, httpClient, baseURL, apiKey)
sogoOK := checkSOGo(ctx, httpClient, baseURL)
if apiOK && sogoOK {
slog.Info("all services reachable, proceeding with initialization")
return nil
}
// Klare Meldung, welcher Dienst noch nicht bereit ist.
if !apiOK && !sogoOK {
slog.Warn("mailcow API and SOGo not reachable yet, retrying", "backoff", backoff)
} else if !apiOK {
slog.Warn("mailcow API not reachable yet, retrying", "backoff", backoff)
} else {
slog.Warn("SOGo not reachable yet, retrying", "backoff", backoff)
}
// Auf nächsten Versuch oder Shutdown-Signal warten.
timer := time.NewTimer(backoff)
select {
case <-timer.C:
case <-ctx.Done():
timer.Stop()
slog.Warn("shutdown signal received during startup connectivity check, exiting")
return nil
}
// Exponentielles Backoff verdoppeln, aber nicht über das Maximum.
backoff *= 2
if backoff > startupBackoffMax {
backoff = startupBackoffMax
}
}
}
// checkMailcowAPI führt einen einfachen API-Aufruf (GetMailboxes) durch,
// um zu prüfen, ob die Mailcow-API erreichbar ist und der API-Key gültig
// ist. Bei Erfolg wird true zurückgegeben.
func checkMailcowAPI(ctx context.Context, httpClient *http.Client, baseURL, apiKey string) bool {
reqCtx, cancel := context.WithTimeout(ctx, startupRequestTimeout)
defer cancel()
mc := mailcow.New(httpClient, baseURL, apiKey)
_, err := mc.GetMailboxes(reqCtx)
if err != nil {
slog.Debug("mailcow API check failed", "err", err)
return false
}
slog.Debug("mailcow API is reachable")
return true
}
// checkSOGo prüft die Erreichbarkeit von SOGo über einen HTTP-GET auf den
// SOGo-Basispfad. Jeder HTTP-Statuscode (auch Redirects oder 401) gilt als
// "erreichbar", da SOGo in diesem Fall antwortet. Nur Netzwerkfehler
// (connection refused, timeout) gelten als "nicht erreichbar".
func checkSOGo(ctx context.Context, httpClient *http.Client, baseURL string) bool {
reqCtx, cancel := context.WithTimeout(ctx, startupRequestTimeout)
defer cancel()
sogoURL, err := url.JoinPath(baseURL, "SOGo/")
if err != nil {
slog.Debug("SOGo URL construction failed", "err", err)
return false
}
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, sogoURL, nil)
if err != nil {
slog.Debug("SOGo request creation failed", "err", err)
return false
}
resp, err := httpClient.Do(req)
if err != nil {
slog.Debug("SOGo check failed", "err", err)
return false
}
defer resp.Body.Close()
// SOGo ist erreichbar der konkrete Statuscode ist irrelevant,
// solange eine HTTP-Antwort zurückkommt.
slog.Debug("SOGo is reachable", "status", resp.StatusCode)
return true
}

View File

@@ -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

View File

@@ -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)
}
})
}

View File

@@ -12,7 +12,9 @@ Willkommen in der Dokumentation des **Mailcow Birthday Daemon** 🎂
## Kurzübersicht
- Automatischer Geburtstagskalender für jede Mailcow-Mailbox
- Liest Geburtstage aus allen CardDAV-Adressbüchern
- Liest Geburtstage und Jahrestage aus allen CardDAV-Adressbüchern
- Optionale Benachrichtigungen (VALARM) zur konfigurierbaren Uhrzeit
- Konfigurierbares Sync-Intervall, Event-Horizont und Kalenderfarbe
- Mailboxen einzeln ausschließbar, strukturiertes Logging mit Log-Level
- Docker-Healthcheck, Graceful Shutdown und Startup-Connectivity-Check
- Läuft als Docker-Container im Mailcow-Stack via `docker-compose.override.yml`
- Synchronisation alle 15 Minuten, vollständig automatisch

View File

@@ -4,6 +4,10 @@
Der Mailcow Birthday Daemon synchronisiert automatisch Geburtstagskalender für jede aktive Mailbox. Der gesamte Prozess läuft ohne Benutzereingriff ab.
## 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.
@@ -31,4 +52,21 @@ Der Mailcow Birthday Daemon synchronisiert automatisch Geburtstagskalender für
## Synchronisationsintervall
Der Synchronisationszyklus läuft alle **15 Minuten** automatisch.
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.
- Nach jedem Sync-Lauf schreibt der Daemon eine kleine Statusdatei (`health.json`) neben das State-File. Der `healthcheck`-Subcommand liest diese Datei und prüft, ob der letzte Sync aktuell und fehlerfrei war.
- Docker zeigt den Status in `docker ps` als `(healthy)` oder `(unhealthy)` an.
- Der Healthcheck meldet **unhealthy**, wenn der letzte Sync-Lauf fehlgeschlagen ist oder länger als das konfigurierte Sync-Intervall plus 5 Minuten Toleranz zurückliegt. Während der Startphase (bevor der erste Sync abgeschlossen ist) gilt der Daemon als healthy.

View File

@@ -23,9 +23,15 @@ services:
environment:
- 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
@@ -48,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
@@ -58,9 +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`. |
| `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
@@ -77,7 +88,7 @@ cd /opt/mailcow-dockerized
docker compose logs -f birthdaydaemon
```
Nach dem Start synchronisiert der Daemon automatisch alle 15 Minuten die Geburtstagskalender für jede Mailbox.
Nach dem Start synchronisiert der Daemon automatisch alle 15 Minuten (konfigurierbar über `SYNC_INTERVAL`) die Geburtstagskalender für jede Mailbox.
> **Hinweis für bestehende Installationen:** Falls der Daemon die Mailcow-API wegen Hairpin-NAT nicht erreichen kann, muss lediglich `MAILCOW_RESOLVE_HOST=nginx-mailcow` als Umgebungsvariable ergänzt werden. Details siehe [Installationsabschnitt](#installation).

View File

@@ -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.
@@ -46,3 +47,10 @@ docker compose exec birthdaydaemon /mailcow-birthday-daemon cleanup Birthdays
## Kalender erscheint nicht in SOGo
SOGo zeigt neue Kalender manchmal erst nach einem Neuladen der Seite (Strg+Shift+R) oder nach dem nächsten Login an. Der Kalender wird unter dem Namen erstellt, der in `CALENDAR_NAME` konfiguriert ist (Standard: `Birthdays`).
## Healthcheck meldet `unhealthy`
1. Logs prüfen: `docker compose logs -f birthdaydaemon`
2. Status manuell abfragen: `docker compose exec birthdaydaemon /mailcow-birthday-daemon healthcheck`
3. Der Healthcheck meldet `unhealthy`, wenn der letzte Sync-Lauf fehlgeschlagen ist oder länger als 20 Minuten zurückliegt.
4. Falls der Container gerade erst gestartet wurde, kann es bis zu 2 Minuten dauern, bis der erste Sync abgeschlossen und der Status `healthy` ist.