feat: Optionale Kalender-Benachrichtigungen für Geburtstage
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user