feat: Optionale Kalender-Benachrichtigungen für Geburtstage

This commit is contained in:
2026-03-28 22:06:49 +01:00
parent eb72cae10c
commit 772eaba37e
5 changed files with 100 additions and 19 deletions

View File

@@ -12,5 +12,12 @@ MAILCOW_APIKEY=DEIN-APIKEY-HIER
# Bei Änderung wird der alte Kalender automatisch entfernt und ein neuer erstellt.
# CALENDAR_NAME=Birthdays
# (Optional) Kalender-Benachrichtigungen für Geburtstage aktivieren Standard: false
# NOTIFICATION_ENABLED=true
# (Optional) Uhrzeit der Benachrichtigung im Format HH:MM Standard: 08:00
# Nur wirksam wenn NOTIFICATION_ENABLED=true
# NOTIFICATION_TIME=08:00
# (Optional) Pfad zur Zustandsdatei Standard: state.json / im Container: /data/state.json
# STATEFILE=/data/state.json

View File

@@ -133,7 +133,7 @@ func (d *Daemon) syncBirthdaysToCal(ctx context.Context, httpClient webdav.HTTPC
matchedBev := false
for _, v := range ev.Data.Children {
for i, bev := range bevs {
if icalMatchesBev(v, bev) {
if icalMatchesBev(v, bev, d.notificationEnabled) {
bevsInSync = append(bevsInSync, i)
matchedBev = true
}
@@ -154,7 +154,7 @@ func (d *Daemon) syncBirthdaysToCal(ctx context.Context, httpClient webdav.HTTPC
if slices.Contains(bevsInSync, i) {
continue
}
p, ic := v.generateICAL(calendarPath)
p, ic := v.generateICAL(calendarPath, d.notificationEnabled, d.notificationTrigger)
_, err := cl.PutCalendarObject(ctx, p, ic)
if err != nil {
return err
@@ -193,7 +193,7 @@ func generateBirthdayEvents(birthdays []BirthdayContact) []birthdayEvent {
return bb
}
func icalMatchesBev(ic *ical.Component, bev birthdayEvent) bool {
func icalMatchesBev(ic *ical.Component, bev birthdayEvent, notificationEnabled bool) bool {
if ic.Props.Get(ical.PropSummary) == nil || ic.Props.Get(ical.PropSummary).Value != bev.Summary {
return false
}
@@ -211,10 +211,20 @@ func icalMatchesBev(ic *ical.Component, bev birthdayEvent) bool {
if dtEnd.Params.Get(ical.ParamValue) != string(ical.ValueDate) {
return false
}
hasAlarm := false
for _, child := range ic.Children {
if child.Name == ical.CompAlarm {
hasAlarm = true
break
}
}
if notificationEnabled != hasAlarm {
return false
}
return true
}
func (bev birthdayEvent) generateICAL(calendar string) (string, *ical.Calendar) {
func (bev birthdayEvent) generateICAL(calendar string, notificationEnabled bool, notificationTrigger string) (string, *ical.Calendar) {
id := uuid.New().String()
cal := ical.NewCalendar()
cal.Props.SetText(ical.PropProductID, ConstProductID)
@@ -231,6 +241,16 @@ func (bev birthdayEvent) generateICAL(calendar string) (string, *ical.Calendar)
end.Value = bev.DateTimeEnd
event.Props.Set(start)
event.Props.Set(end)
if notificationEnabled {
alarm := ical.NewComponent(ical.CompAlarm)
alarm.Props.SetText(ical.PropAction, "DISPLAY")
alarm.Props.SetText(ical.PropDescription, bev.Summary)
trigger := ical.NewProp(ical.PropTrigger)
trigger.Params.Set(ical.ParamValue, string(ical.ValueDuration))
trigger.Value = notificationTrigger
alarm.Props.Set(trigger)
event.Children = append(event.Children, alarm)
}
cal.Children = append(cal.Children, event)
return fmt.Sprintf("%s/%s.ics", calendar, id), cal
}

View File

@@ -26,15 +26,17 @@ var (
)
type Daemon struct {
httpClient *http.Client
baseURL string
mailcowClient mailcow.Client
userTokens map[string]string
userTokensLock *sync.RWMutex
stateFilepath string
stateUnsaved bool
calendarName string
oldCalendarName string
httpClient *http.Client
baseURL string
mailcowClient mailcow.Client
userTokens map[string]string
userTokensLock *sync.RWMutex
stateFilepath string
stateUnsaved bool
calendarName string
oldCalendarName string
notificationEnabled bool
notificationTrigger string
}
func main() {
@@ -65,13 +67,29 @@ func run() error {
if calendarName == "" {
calendarName = "Birthdays"
}
notificationEnabled := strings.EqualFold(os.Getenv("NOTIFICATION_ENABLED"), "true")
notificationTrigger := "PT8H"
if notificationEnabled {
notificationTime := os.Getenv("NOTIFICATION_TIME")
if notificationTime == "" {
notificationTime = "08:00"
}
trigger, err := parseNotificationTrigger(notificationTime)
if err != nil {
return fmt.Errorf("invalid NOTIFICATION_TIME: %w", err)
}
notificationTrigger = trigger
slog.Info("birthday notifications enabled", "time", notificationTime, "trigger", notificationTrigger)
}
d := &Daemon{
userTokens: make(map[string]string),
userTokensLock: &sync.RWMutex{},
baseURL: mailcowBase,
stateFilepath: os.Getenv("STATEFILE"),
httpClient: &http.Client{Transport: buildTransport()},
calendarName: calendarName,
userTokens: make(map[string]string),
userTokensLock: &sync.RWMutex{},
baseURL: mailcowBase,
stateFilepath: os.Getenv("STATEFILE"),
httpClient: &http.Client{Transport: buildTransport()},
calendarName: calendarName,
notificationEnabled: notificationEnabled,
notificationTrigger: notificationTrigger,
}
if len(d.stateFilepath) == 0 {
d.stateFilepath = "state.json"

View File

@@ -65,3 +65,32 @@ func sanitizeBirthday(input string) (uint16, uint16, uint16, error) {
}
return 0, 0, 0, fmt.Errorf("birthday prop format unknown: %s", input)
}
// parseNotificationTrigger konvertiert eine Uhrzeit im Format "HH:MM" in eine
// iCal-Duration (z.B. "PT8H", "PT9H30M"), die als VALARM-Trigger relativ zum
// Start eines Ganztags-Events (Mitternacht) verwendet wird.
func parseNotificationTrigger(timeStr string) (string, error) {
parts := strings.Split(timeStr, ":")
if len(parts) != 2 {
return "", fmt.Errorf("invalid time format: %s (expected HH:MM)", timeStr)
}
hours, err := strconv.Atoi(parts[0])
if err != nil || hours < 0 || hours > 23 {
return "", fmt.Errorf("invalid hours in time: %s", timeStr)
}
minutes, err := strconv.Atoi(parts[1])
if err != nil || minutes < 0 || minutes > 59 {
return "", fmt.Errorf("invalid minutes in time: %s", timeStr)
}
if hours == 0 && minutes == 0 {
return "PT0S", nil
}
trigger := "PT"
if hours > 0 {
trigger += fmt.Sprintf("%dH", hours)
}
if minutes > 0 {
trigger += fmt.Sprintf("%dM", minutes)
}
return trigger, nil
}

View File

@@ -22,6 +22,8 @@ services:
- MAILCOW_BASE=https://mail.example.com
- MAILCOW_APIKEY=DEIN-APIKEY-HIER
- MAILCOW_RESOLVE_HOST=nginx-mailcow
# - NOTIFICATION_ENABLED=true
# - NOTIFICATION_TIME=08:00
volumes:
- birthdaydaemon:/data
@@ -50,6 +52,8 @@ docker compose up -d
| `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 |
| `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 |
## API-Key erstellen
@@ -79,4 +83,7 @@ Nach dem Start synchronisiert der Daemon automatisch alle 15 Minuten die Geburts
- 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.
- Bei Änderung von `CALENDAR_NAME` wird der alte Kalender beim nächsten Start automatisch entfernt und ein neuer mit dem neuen Namen erstellt. Der alte Kalender wird dabei nur gelöscht, wenn er ausschließlich vom Daemon erstellte Einträge enthält manuell angelegte Kalender mit gleichem Namen bleiben unangetastet.
- **Wichtig:** Damit die Umbenennung korrekt erkannt wird, muss der Daemon **mindestens einmal** mit dem neuen Code 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 und muss manuell gelöscht werden.
- 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.
- Bestehende Events ohne VALARM werden beim nächsten Synchronisationszyklus automatisch neu erstellt keine manuelle Migration nötig.
- Falls die Benachrichtigungen wieder deaktiviert werden (`NOTIFICATION_ENABLED=false`), werden Events mit VALARM ebenfalls automatisch durch Events ohne VALARM ersetzt.
- Der Synchronisationszyklus läuft alle **15 Minuten** automatisch.