Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 14152524f3 | |||
| da699b2ce9 | |||
| cdf713b5cf | |||
| fbbdb63854 | |||
| d926174929 | |||
| 7bcfb5d1a9 | |||
| 0d0b0bd124 | |||
| 606c0ed0da | |||
| 61d58b7462 | |||
| b0ab2eb99a | |||
| ba5935c891 | |||
| cb1b420a3f | |||
| 3ede89322f | |||
| 0311c830bd | |||
| 93371e3afa | |||
| ea598a979b | |||
| c1514330e3 | |||
| 05faa43541 | |||
| 1e51010f10 | |||
| 58bb3b038e | |||
| e74d5f711a | |||
| fc673b09fb | |||
| 092bd02977 | |||
| 7448759732 | |||
| a799975c1f | |||
| b36ae57d48 | |||
| 67c3f10454 | |||
| 766b69aa4a | |||
| c623e39b4c | |||
| c5337d7d63 | |||
| efcbd04aa2 | |||
| dc01480b8b | |||
| 78ebe7a499 | |||
| b9c81bd04e | |||
| 2568258794 | |||
| 882fb6448d | |||
| a296efbb86 | |||
| cb8192640d | |||
| c35f86b7c4 | |||
| a321bb6917 | |||
| b346d58beb | |||
| 818fc92ab0 | |||
| df397e5e3c | |||
| 60ecd2352c | |||
| d59576258d | |||
| ff4dc27b6f | |||
| 0a5f78ffa8 | |||
| 2b7e92e3a4 | |||
| a2ca58e538 | |||
| 5892230fd8 | |||
| 0767ad1a7e | |||
| 772eaba37e | |||
| eb72cae10c | |||
| a6f8a42f97 | |||
| 8eb4afc6a1 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -15,7 +14,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,5 +31,4 @@ jobs:
|
||||
args: release --clean
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||
GORELEASER_FORCE_TOKEN: gitea
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,6 +1,7 @@
|
||||
/.vscode/
|
||||
/.ki-workspace/
|
||||
/dist/
|
||||
*.exe
|
||||
|
||||
.env
|
||||
state.json
|
||||
|
||||
@@ -26,16 +26,7 @@ checksum:
|
||||
snapshot:
|
||||
name_template: "{{ incpatch .Version }}-next"
|
||||
release:
|
||||
gitea:
|
||||
owner: "{{ .Env.REGISTRY_USER }}"
|
||||
name: mailcow-birthday-daemon
|
||||
prerelease: auto
|
||||
changelog:
|
||||
sort: asc
|
||||
filters:
|
||||
exclude:
|
||||
- "^docs:"
|
||||
- "^test:"
|
||||
disable: true
|
||||
upx:
|
||||
- enabled: true
|
||||
goos: [linux]
|
||||
|
||||
@@ -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
|
||||
|
||||
34
README.md
34
README.md
@@ -10,13 +10,25 @@ 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
|
||||
|
||||
Den folgenden Abschnitt in eine `docker-compose.override.yml` im Mailcow-Verzeichnis (z. B. `/opt/mailcow-dockerized`) einfügen:
|
||||
|
||||
> **Wichtig:** Da `mailcow-dockerized` die eigene `docker-compose.yml` bei Updates überschreibt, müssen eigene Anpassungen immer in der `docker-compose.override.yml` erfolgen. Docker Compose lädt diese Datei automatisch und mergt sie mit der Hauptkonfiguration – eigene Änderungen gehen dadurch bei Mailcow-Updates nicht verloren.
|
||||
|
||||
```yaml
|
||||
services:
|
||||
birthdaydaemon:
|
||||
@@ -36,8 +48,26 @@ 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
|
||||
cd /opt/mailcow-dockerized
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
> Alle verfügbaren Image-Tags sind in der [Container Registry](https://git.techniverse.net/scriptos/-/packages/container/mailcow-birthday-daemon) einsehbar.
|
||||
|
||||
## Repository-Spiegel
|
||||
|
||||
| Rolle | URL |
|
||||
|-------|-----|
|
||||
| **Master** | https://git.techniverse.net/scriptos/mailcow-birthday-daemon.git |
|
||||
| **Spiegel** | https://github.com/pscriptos/mailcow-birthday-daemon.git |
|
||||
|
||||
> **Hinweis:** Die Entwicklung findet im Master-Repository statt. Der GitHub-Spiegel wird automatisch synchronisiert. Issues und Feature-Requests können sowohl auf [Gitea](https://git.techniverse.net/scriptos/mailcow-birthday-daemon/issues) als auch auf [GitHub](https://github.com/pscriptos/mailcow-birthday-daemon/issues) eingereicht werden.
|
||||
|
||||
## Dokumentation
|
||||
|
||||
Die vollständige Dokumentation befindet sich im Ordner [`docs/`](docs/README.md).
|
||||
|
||||
BIN
assets/img/apikey-erstellen.png
Normal file
BIN
assets/img/apikey-erstellen.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 66 KiB |
@@ -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,14 +134,16 @@ 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 {
|
||||
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
|
||||
}
|
||||
@@ -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,7 +165,8 @@ func (d *Daemon) syncBirthdaysToCal(ctx context.Context, httpClient webdav.HTTPC
|
||||
if slices.Contains(bevsInSync, i) {
|
||||
continue
|
||||
}
|
||||
p, ic := v.generateICAL(calendarPath)
|
||||
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 {
|
||||
return err
|
||||
@@ -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"),
|
||||
}
|
||||
@@ -193,20 +211,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 +252,176 @@ 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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -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
96
cmd/mcbdd/health.go
Normal 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
115
cmd/mcbdd/logger.go
Normal 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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -26,18 +29,62 @@ 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
|
||||
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() {
|
||||
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
|
||||
}
|
||||
}
|
||||
if err := run(); err != nil {
|
||||
slog.Error("fatal error", "err", err)
|
||||
os.Exit(1)
|
||||
@@ -46,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 == "" {
|
||||
@@ -65,17 +111,82 @@ 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")
|
||||
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)
|
||||
} else {
|
||||
slog.Debug("birthday notifications disabled")
|
||||
}
|
||||
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,
|
||||
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,
|
||||
@@ -84,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() {
|
||||
@@ -123,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)
|
||||
}
|
||||
@@ -148,15 +307,153 @@ 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
|
||||
}
|
||||
|
||||
func runCleanup() error {
|
||||
if len(os.Args) < 3 {
|
||||
fmt.Fprintf(os.Stderr, "Verwendung: %s cleanup <alter-kalendername>\n", os.Args[0])
|
||||
fmt.Fprintf(os.Stderr, "\nEntfernt einen automatisch erstellten Geburtstagskalender aus allen Mailboxen.\n")
|
||||
fmt.Fprintf(os.Stderr, "Nur Kalender, deren Einträge ausschließlich vom Daemon erstellt wurden, werden gelöscht.\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
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 == "" {
|
||||
return fmt.Errorf("MAILCOW_BASE environment variable is not set")
|
||||
}
|
||||
mailcowAPIKey := os.Getenv("MAILCOW_APIKEY")
|
||||
if mailcowAPIKey == "" {
|
||||
return fmt.Errorf("MAILCOW_APIKEY environment variable is not set")
|
||||
}
|
||||
calendarName := os.Getenv("CALENDAR_NAME")
|
||||
if calendarName == "" {
|
||||
calendarName = "Birthdays"
|
||||
}
|
||||
|
||||
d := &Daemon{
|
||||
userTokens: make(map[string]string),
|
||||
userTokensLock: &sync.RWMutex{},
|
||||
baseURL: mailcowBase,
|
||||
stateFilepath: os.Getenv("STATEFILE"),
|
||||
httpClient: &http.Client{Transport: buildTransport()},
|
||||
calendarName: calendarName,
|
||||
}
|
||||
if len(d.stateFilepath) == 0 {
|
||||
d.stateFilepath = "state.json"
|
||||
}
|
||||
d.mailcowClient = mailcow.New(d.httpClient, mailcowBase, mailcowAPIKey)
|
||||
|
||||
if err := d.loadState(); err != nil {
|
||||
return fmt.Errorf("error loading state: %w", err)
|
||||
}
|
||||
|
||||
mb, err := d.mailcowClient.GetMailboxes(context.Background())
|
||||
if err != nil {
|
||||
return fmt.Errorf("error fetching mailboxes: %w", err)
|
||||
}
|
||||
|
||||
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()
|
||||
pass, ok := d.userTokens[m.Username]
|
||||
d.userTokensLock.RUnlock()
|
||||
if !ok {
|
||||
slog.Warn("no stored password for user, skipping", "user", m.Username)
|
||||
skipped++
|
||||
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++
|
||||
}
|
||||
|
||||
slog.Info("cleanup finished", "processed", processed, "skipped", skipped)
|
||||
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: 1–30.
|
||||
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
|
||||
|
||||
@@ -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
119
cmd/mcbdd/startup.go
Normal 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
|
||||
}
|
||||
@@ -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,5 +63,34 @@ 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
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,3 +6,15 @@ Willkommen in der Dokumentation des **Mailcow Birthday Daemon** 🎂
|
||||
|
||||
- [Schnellstart](schnellstart.md) – Installation und erste Einrichtung
|
||||
- [Update](update.md) – Bestehende Installation aktualisieren
|
||||
- [Funktionsweise](funktionsweise.md) – Technische Details zum Synchronisationsprozess
|
||||
- [Troubleshooting](troubleshooting.md) – Häufige Probleme und Lösungen
|
||||
|
||||
## Kurzübersicht
|
||||
|
||||
- Automatischer Geburtstagskalender für jede Mailcow-Mailbox
|
||||
- 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`
|
||||
|
||||
72
docs/funktionsweise.md
Normal file
72
docs/funktionsweise.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# Funktionsweise
|
||||
|
||||
## Übersicht
|
||||
|
||||
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.
|
||||
- Da jedes App-Passwort in Mailcow eine global hochzählende Nummer erhält, werden die Passwörter auf der Festplatte gespeichert, um das unnötige Ansteigen dieser Nummer zu vermeiden.
|
||||
|
||||
## 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.
|
||||
|
||||
> **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.
|
||||
- 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 (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.
|
||||
- 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.
|
||||
|
||||
## Synchronisationsintervall
|
||||
|
||||
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.
|
||||
@@ -7,7 +7,9 @@
|
||||
|
||||
## Installation
|
||||
|
||||
Den folgenden Abschnitt in die `docker-compose.override.yml` der Mailcow-Installation einfügen:
|
||||
Den folgenden Abschnitt in die `docker-compose.override.yml` der Mailcow-Installation einfügen (z. B. `/opt/mailcow-dockerized/docker-compose.override.yml`):
|
||||
|
||||
> **Warum `docker-compose.override.yml`?** Das Projekt `mailcow-dockerized` verwaltet seine eigene `docker-compose.yml` und überschreibt diese bei Updates. Eigene Ergänzungen in der Hauptdatei würden dadurch verloren gehen. Docker Compose erkennt eine `docker-compose.override.yml` im selben Verzeichnis automatisch und mergt deren Inhalte mit der Hauptkonfiguration. Der Birthday Daemon wird so sauber in den Mailcow-Stack integriert, ohne die originale Konfiguration zu verändern.
|
||||
|
||||
```yaml
|
||||
services:
|
||||
@@ -21,7 +23,15 @@ services:
|
||||
environment:
|
||||
- MAILCOW_BASE=https://mail.example.com
|
||||
- MAILCOW_APIKEY=DEIN-APIKEY-HIER
|
||||
- MAILCOW_RESOLVE_HOST=nginx-mailcow
|
||||
- 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
|
||||
|
||||
@@ -33,15 +43,19 @@ volumes:
|
||||
|
||||
> **Hinweis zu `MAILCOW_RESOLVE_HOST`:** Innerhalb eines Docker-Netzes kann der Container die öffentliche Domain (z. B. `mail.example.com`) oft nicht über die externe IP erreichen – ein typisches **Hairpin-NAT-Problem**. Die Variable `MAILCOW_RESOLVE_HOST=nginx-mailcow` sorgt dafür, dass TCP-Verbindungen direkt an den Mailcow-Nginx-Container im selben Docker-Netz aufgebaut werden, anstatt den Umweg über die öffentliche IP zu nehmen. TLS-SNI und die Zertifikatsprüfung verwenden dabei weiterhin den Hostnamen aus `MAILCOW_BASE`, sodass die Verbindung korrekt verschlüsselt bleibt.
|
||||
|
||||
> **Tipp:** Statt `:latest` kann auch eine feste Version wie `:1.0.0` verwendet werden. Alle verfügbaren Tags sind in der [Container Registry](https://git.techniverse.net/scriptos/-/packages/container/mailcow-birthday-daemon) einsehbar.
|
||||
> **Tipp:** Statt `:latest` kann auch eine feste Version wie `:0.3.0` verwendet werden. Alle verfügbaren Tags sind in der [Container Registry](https://git.techniverse.net/scriptos/-/packages/container/mailcow-birthday-daemon) einsehbar.
|
||||
|
||||
## Container starten
|
||||
|
||||
Nach der Konfiguration der Variablen kann der Container initial gestartet werden.
|
||||
|
||||
```bash
|
||||
cd /opt/mailcow-dockerized
|
||||
docker compose up -d
|
||||
docker compose pull birthdaydaemon && docker compose up -d --no-deps birthdaydaemon
|
||||
```
|
||||
|
||||
> **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
|
||||
|
||||
| Variable | Pflicht | Standardwert | Beschreibung |
|
||||
@@ -50,33 +64,34 @@ 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 |
|
||||
| `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
|
||||
|
||||
Den API-Key findet man im Admin-Panel unter Konfiguration → Zugang → Administratordetails bearbeiten → API → Lese-/Schreibzugriff.
|
||||
|
||||
> **Warnung:** Da die Mailcow-API derzeit nicht vollständig ist und sich eher im Early-Access-Stadium befindet, wird dringend davon abgeraten, die Option „IP-Prüfung für API überspringen" zu aktivieren.
|
||||

|
||||
|
||||
> **Warnung:** Da die Mailcow-API derzeit nicht vollständig ist und sich eher im Early-Access-Stadium befindet, wird dringend davon abgeraten, die Option „IP-Prüfung für API überspringen" zu aktivieren. (siehe Bild)
|
||||
|
||||
## Prüfen, ob alles läuft
|
||||
|
||||
```bash
|
||||
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).
|
||||
|
||||
## Funktionsweise
|
||||
> **Bei Problemen:** Siehe [Troubleshooting](troubleshooting.md).
|
||||
|
||||
- Über die Mailcow-API wird für jeden aktiven Benutzer ein App-Passwort mit Zugriff auf CardDAV und CalDAV erzeugt.
|
||||
- Da jedes App-Passwort in Mailcow eine global hochzählende Nummer erhält, werden die Passwörter auf der Festplatte gespeichert, um das unnötige Ansteigen dieser Nummer zu vermeiden.
|
||||
- Alle Kontakte aus sämtlichen Adressbüchern werden abgerufen und die Geburtstagsinformationen je Benutzer extrahiert.
|
||||
- Die daraus resultierenden Kalendereinträge werden im Voraus berechnet.
|
||||
- Aktuell fest eingestellt: 1 Jahr in der Vergangenheit, 10 Jahre in der Zukunft.
|
||||
- Selbstverständlich pro Mailbox isoliert – ein Benutzer sieht nur die Geburtstage seiner eigenen Kontakte.
|
||||
- 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.
|
||||
- Der Synchronisationszyklus läuft alle **15 Minuten** automatisch.
|
||||
> **Wie funktioniert der Daemon im Detail?** Siehe [Funktionsweise](funktionsweise.md).
|
||||
|
||||
56
docs/troubleshooting.md
Normal file
56
docs/troubleshooting.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# Troubleshooting
|
||||
|
||||
## Container startet, aber keine Kalender werden erstellt
|
||||
|
||||
1. Logs prüfen: `docker compose logs -f birthdaydaemon`
|
||||
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
|
||||
|
||||
- Typisches Hairpin-NAT-Problem: `MAILCOW_RESOLVE_HOST=nginx-mailcow` als Umgebungsvariable hinzufügen.
|
||||
- Sicherstellen, dass der Container im Netzwerk `mailcow-network` ist.
|
||||
|
||||
## `401 Unauthorized` in den Logs
|
||||
|
||||
Das gespeicherte App-Passwort für den betroffenen Benutzer ist ungültig (z. B. manuell in Mailcow gelöscht). Der Daemon verwirft das alte Passwort automatisch und erstellt beim nächsten Zyklus ein neues.
|
||||
|
||||
## Doppelte Kalender nach Umbenennung von `CALENDAR_NAME`
|
||||
|
||||
Wurde `CALENDAR_NAME` geändert, bevor der alte Name im State-File gespeichert war (z. B. bei einem Update von vor v0.2.0), existieren pro Mailbox zwei Geburtstagskalender. Der integrierte Cleanup-Befehl entfernt den alten Kalender aus allen Mailboxen – aber nur, wenn alle enthaltenen Einträge vom Daemon erstellt wurden. Manuell angelegte Kalender mit gleichem Namen bleiben unangetastet.
|
||||
|
||||
```bash
|
||||
cd /opt/mailcow-dockerized
|
||||
docker compose exec birthdaydaemon /mailcow-birthday-daemon cleanup <alter-kalendername>
|
||||
```
|
||||
|
||||
**Beispiel:** Der alte Kalender hieß `Birthdays` und wurde auf `Geburtstage` umgestellt:
|
||||
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
> **Hinweis:** Der Daemon muss vorher mindestens einmal gelaufen sein, damit App-Passwörter im State-File vorhanden sind. Benutzer ohne gespeichertes Passwort werden übersprungen.
|
||||
|
||||
## 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.
|
||||
@@ -1,5 +1,14 @@
|
||||
# Update
|
||||
|
||||
## Vor dem Update
|
||||
|
||||
Das Docker-Volume `birthdaydaemon` enthält die Zustandsdatei mit allen App-Passwörtern. Es empfiehlt sich, vor größeren Versionssprüngen ein Backup anzulegen:
|
||||
|
||||
```bash
|
||||
cd /opt/mailcow-dockerized
|
||||
docker compose cp birthdaydaemon:/data/state.json ./state.json.bak
|
||||
```
|
||||
|
||||
## Image aktualisieren
|
||||
|
||||
Um den Mailcow Birthday Daemon auf die neueste Version zu aktualisieren, genügen folgende Schritte im Mailcow-Verzeichnis:
|
||||
@@ -30,3 +39,12 @@ docker compose up -d birthdaydaemon
|
||||
- Die Zustandsdatei (`/data/state.json`) im Volume `birthdaydaemon` bleibt bei Updates erhalten. Gespeicherte App-Passwörter werden weiterverwendet.
|
||||
- Ein Neustart des Containers löst sofort einen Synchronisationszyklus aus.
|
||||
- Falls sich der Standard-Kalendername (`CALENDAR_NAME`) mit einem Update ändert, siehe den Abschnitt zur Kalender-Umbenennung in der [Schnellstart-Dokumentation](schnellstart.md#funktionsweise).
|
||||
|
||||
## Nach dem Update
|
||||
|
||||
Nach dem Neustart die Logs prüfen, um sicherzustellen, dass alles korrekt funktioniert:
|
||||
|
||||
```bash
|
||||
cd /opt/mailcow-dockerized
|
||||
docker compose logs -f birthdaydaemon
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user