7 Commits

Author SHA1 Message Date
0767ad1a7e fix(ci): GITEA_SERVER_URL als Secret ausgelagert statt hartcodiert
All checks were successful
Build Test Docker Image / docker-test (pull_request) Successful in 1m24s
Run Tests / test (pull_request) Successful in 4m48s
2026-03-28 22:22:37 +01:00
772eaba37e feat: Optionale Kalender-Benachrichtigungen für Geburtstage 2026-03-28 22:06:49 +01:00
eb72cae10c Merge pull request 'release-v0.2.3' (#4) from release-v0.2.3 into master
Some checks failed
Run Tests / test (push) Successful in 4m57s
Make Release / release (push) Failing after 2m43s
Reviewed-on: #4
2026-03-28 20:18:42 +00:00
a6f8a42f97 Geburtstagstermine als Ganztags-Events (VALUE=DATE) erstellen; bestehende Termine werden automatisch migriert
All checks were successful
Build Test Docker Image / docker-test (pull_request) Successful in 1m47s
Run Tests / test (pull_request) Successful in 5m2s
2026-03-28 21:03:48 +01:00
8eb4afc6a1 fix(ci): setup-go von v6 auf v5 downgraden (Node-24-Inkompatibilität mit act_runner) 2026-03-28 20:56:12 +01:00
e3997d8f6f Merge pull request 'fix(ci): Gitea-Release-URL und Token-Typ für GoReleaser ergänzt' (#3) from release-v0.2.2 into master
Some checks failed
Run Tests / test (push) Successful in 4m46s
Make Release / release (push) Failing after 2m1s
Reviewed-on: #3
2026-03-28 16:42:43 +00:00
6757362d8c fix(ci): Gitea-Release-URL und Token-Typ für GoReleaser ergänzt
All checks were successful
Build Test Docker Image / docker-test (pull_request) Successful in 1m21s
Run Tests / test (pull_request) Successful in 4m50s
2026-03-28 17:35:07 +01:00
7 changed files with 119 additions and 22 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

@@ -15,7 +15,7 @@ jobs:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@v5
with:
go-version-file: 'go.mod'
@@ -32,3 +32,6 @@ jobs:
args: release --clean
env:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
GITEA_SERVER_URL: ${{ secrets.REGISTRY_URL }}
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
GORELEASER_FORCE_TOKEN: gitea

View File

@@ -26,6 +26,9 @@ checksum:
snapshot:
name_template: "{{ incpatch .Version }}-next"
release:
gitea:
owner: "{{ .Env.REGISTRY_USER }}"
name: mailcow-birthday-daemon
prerelease: auto
changelog:
sort: asc

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,20 +193,38 @@ 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
}
if ic.Props.Get(ical.PropDateTimeStart) == nil || ic.Props.Get(ical.PropDateTimeStart).Value != bev.DateTimeStart {
dtStart := ic.Props.Get(ical.PropDateTimeStart)
if dtStart == nil || dtStart.Value != bev.DateTimeStart {
return false
}
if ic.Props.Get(ical.PropDateTimeEnd) == nil || ic.Props.Get(ical.PropDateTimeEnd).Value != bev.DateTimeEnd {
if dtStart.Params.Get(ical.ParamValue) != string(ical.ValueDate) {
return false
}
dtEnd := ic.Props.Get(ical.PropDateTimeEnd)
if dtEnd == nil || dtEnd.Value != bev.DateTimeEnd {
return false
}
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)
@@ -216,11 +234,23 @@ func (bev birthdayEvent) generateICAL(calendar string) (string, *ical.Calendar)
event.Props.SetText(ical.PropSummary, bev.Summary)
event.Props.SetDateTime(ical.PropDateTimeStamp, time.Now())
start := ical.NewProp(ical.PropDateTimeStart)
start.SetValueType(ical.ValueDate)
start.Value = bev.DateTimeStart
end := ical.NewProp(ical.PropDateTimeEnd)
end.SetValueType(ical.ValueDate)
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.