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

This commit is contained in:
2026-03-29 23:06:09 +02:00
parent ea598a979b
commit 93371e3afa
5 changed files with 195 additions and 9 deletions

View File

@@ -2,8 +2,10 @@ package main
import (
"context"
"encoding/xml"
"fmt"
"log/slog"
"net/http"
"net/url"
"slices"
"strings"
@@ -270,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

@@ -38,6 +38,8 @@ type Daemon struct {
stateUnsaved bool
calendarName string
oldCalendarName string
calendarColor string
storedCalendarColor string
notificationEnabled bool
notificationTrigger string
eventYears int
@@ -121,6 +123,10 @@ 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 {
@@ -135,6 +141,7 @@ func run() error {
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"),
@@ -172,6 +179,7 @@ func run() error {
stateFilepath: os.Getenv("STATEFILE"),
httpClient: &http.Client{Transport: buildTransport()},
calendarName: calendarName,
calendarColor: calendarColor,
notificationEnabled: notificationEnabled,
notificationTrigger: notificationTrigger,
eventYears: eventYears,
@@ -307,6 +315,9 @@ func (d *Daemon) processUser(ctx context.Context, m mailcow.Mailbox) error {
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)
}

View File

@@ -47,9 +47,10 @@ func (d *Daemon) loadState() error {
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)
@@ -62,6 +63,7 @@ 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 {
@@ -70,6 +72,13 @@ func (d *Daemon) loadState() error {
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
}
@@ -80,13 +89,15 @@ func (d *Daemon) saveState() error {
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,
}
if err := d.saveToDisk(state); err != nil {
return err

View File

@@ -33,6 +33,13 @@ Die Variable kann jederzeit auch bei bestehenden Installationen per Upda
> **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.

View File

@@ -24,6 +24,7 @@ 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
@@ -63,6 +64,7 @@ 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 |