Compare commits
35 Commits
cleanup-do
...
master
| 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 |
@@ -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
|
||||
@@ -32,6 +31,4 @@ jobs:
|
||||
args: release --clean
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
REGISTRY_URL: ${{ secrets.REGISTRY_URL }}
|
||||
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,20 +26,7 @@ checksum:
|
||||
snapshot:
|
||||
name_template: "{{ incpatch .Version }}-next"
|
||||
release:
|
||||
gitea:
|
||||
owner: "{{ .Env.REGISTRY_USER }}"
|
||||
name: mailcow-birthday-daemon
|
||||
prerelease: auto
|
||||
gitea_urls:
|
||||
api: "{{ .Env.REGISTRY_URL }}/api/v1/"
|
||||
download: "{{ .Env.REGISTRY_URL }}"
|
||||
skip_tls_verify: false
|
||||
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
|
||||
|
||||
14
README.md
14
README.md
@@ -10,9 +10,17 @@ 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
|
||||
@@ -40,6 +48,8 @@ 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
|
||||
|
||||
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,7 +134,9 @@ 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 {
|
||||
@@ -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,6 +165,7 @@ func (d *Daemon) syncBirthdaysToCal(ctx context.Context, httpClient webdav.HTTPC
|
||||
if slices.Contains(bevsInSync, i) {
|
||||
continue
|
||||
}
|
||||
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 {
|
||||
@@ -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"),
|
||||
}
|
||||
@@ -254,3 +272,156 @@ func (bev birthdayEvent) generateICAL(calendar string, notificationEnabled bool,
|
||||
cal.Children = append(cal.Children, event)
|
||||
return fmt.Sprintf("%s/%s.ics", calendar, id), cal
|
||||
}
|
||||
|
||||
// XML-Strukturen für das Parsen der PROPFIND-Antwort (calendar-color).
|
||||
type multistatusResponse struct {
|
||||
XMLName xml.Name `xml:"DAV: multistatus"`
|
||||
Responses []davResponse `xml:"DAV: response"`
|
||||
}
|
||||
|
||||
type davResponse struct {
|
||||
Href string `xml:"DAV: href"`
|
||||
PropStats []davPropStat `xml:"DAV: propstat"`
|
||||
}
|
||||
|
||||
type davPropStat struct {
|
||||
Prop davProp `xml:"DAV: prop"`
|
||||
Status string `xml:"DAV: status"`
|
||||
}
|
||||
|
||||
type davProp struct {
|
||||
CalendarColor string `xml:"http://apple.com/ns/ical/ calendar-color"`
|
||||
}
|
||||
|
||||
// normalizeColor bringt eine Kalenderfarbe in ein einheitliches Format (#rrggbb,
|
||||
// Kleinbuchstaben, ohne Alpha-Kanal), damit zuverlässig verglichen werden kann.
|
||||
func normalizeColor(c string) string {
|
||||
c = strings.TrimSpace(strings.ToLower(c))
|
||||
if len(c) == 9 && strings.HasPrefix(c, "#") {
|
||||
c = c[:7]
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// getCalendarColor liest die calendar-color-Property (Apple-Namespace) des
|
||||
// Geburtstagskalenders per PROPFIND aus und gibt sie normalisiert zurück.
|
||||
func (d *Daemon) getCalendarColor(ctx context.Context, httpClient webdav.HTTPClient, user string) (string, error) {
|
||||
calendarURL, err := url.JoinPath(d.baseURL, "SOGo/dav", user, "Calendar", d.calendarName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
body := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<d:propfind xmlns:d="DAV:" xmlns:A="http://apple.com/ns/ical/">
|
||||
<d:prop>
|
||||
<A:calendar-color/>
|
||||
</d:prop>
|
||||
</d:propfind>`
|
||||
req, err := http.NewRequestWithContext(ctx, "PROPFIND", calendarURL, strings.NewReader(body))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/xml; charset=utf-8")
|
||||
req.Header.Set("Depth", "0")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusMultiStatus {
|
||||
return "", fmt.Errorf("PROPFIND calendar-color returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var ms multistatusResponse
|
||||
if err := xml.NewDecoder(resp.Body).Decode(&ms); err != nil {
|
||||
return "", fmt.Errorf("error parsing PROPFIND response: %w", err)
|
||||
}
|
||||
|
||||
for _, r := range ms.Responses {
|
||||
for _, ps := range r.PropStats {
|
||||
if ps.Prop.CalendarColor != "" {
|
||||
return normalizeColor(ps.Prop.CalendarColor), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// setCalendarColor setzt die calendar-color-Property (Apple-Namespace) des
|
||||
// Geburtstagskalenders per PROPPATCH. Die Farbe wird mit Alpha-Kanal (#RRGGBBFF)
|
||||
// übertragen, da SOGo dieses Format erwartet.
|
||||
func (d *Daemon) setCalendarColor(ctx context.Context, httpClient webdav.HTTPClient, user, color string) error {
|
||||
calendarURL, err := url.JoinPath(d.baseURL, "SOGo/dav", user, "Calendar", d.calendarName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
colorValue := strings.ToUpper(color)
|
||||
if len(colorValue) == 7 {
|
||||
colorValue += "FF"
|
||||
}
|
||||
body := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<d:propertyupdate xmlns:d="DAV:" xmlns:A="http://apple.com/ns/ical/">
|
||||
<d:set>
|
||||
<d:prop>
|
||||
<A:calendar-color>%s</A:calendar-color>
|
||||
</d:prop>
|
||||
</d:set>
|
||||
</d:propertyupdate>`, colorValue)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "PROPPATCH", calendarURL, strings.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/xml; charset=utf-8")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusMultiStatus && resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("PROPPATCH calendar-color returned status %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureCalendarColor stellt sicher, dass der Geburtstagskalender die
|
||||
// konfigurierte Farbe hat, sofern der Benutzer sie nicht manuell geändert hat.
|
||||
// Die Erkennung manueller Änderungen basiert auf dem Vergleich zwischen der
|
||||
// aktuellen Kalenderfarbe und der zuletzt vom Daemon gesetzten Farbe (aus dem
|
||||
// State-File). Stimmen diese nicht überein, wurde die Farbe vom Benutzer
|
||||
// angepasst und wird nicht überschrieben.
|
||||
func (d *Daemon) ensureCalendarColor(ctx context.Context, httpClient webdav.HTTPClient, user string) error {
|
||||
if d.calendarColor == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
current, err := d.getCalendarColor(ctx, httpClient, user)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error reading calendar color: %w", err)
|
||||
}
|
||||
|
||||
desired := normalizeColor(d.calendarColor)
|
||||
stored := normalizeColor(d.storedCalendarColor)
|
||||
|
||||
if current == desired {
|
||||
slog.DebugContext(ctx, "calendar color already correct", "user", user, "color", desired)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Wenn der Benutzer die Farbe manuell geändert hat (current != stored && current != ""),
|
||||
// wird die Farbe nicht überschrieben.
|
||||
if current != "" && stored != "" && current != stored {
|
||||
slog.DebugContext(ctx, "calendar color was customized by user, skipping",
|
||||
"user", user, "current", current, "daemon", stored)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := d.setCalendarColor(ctx, httpClient, user, desired); err != nil {
|
||||
return fmt.Errorf("error setting calendar color: %w", err)
|
||||
}
|
||||
slog.InfoContext(ctx, "set calendar color", "user", user, "color", desired)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -35,17 +38,52 @@ type Daemon struct {
|
||||
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() {
|
||||
if len(os.Args) > 1 && os.Args[1] == "cleanup" {
|
||||
if err := runCleanup(); err != nil {
|
||||
slog.Error("cleanup failed", "err", err)
|
||||
os.Exit(1)
|
||||
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
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := run(); err != nil {
|
||||
slog.Error("fatal error", "err", err)
|
||||
@@ -55,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 == "" {
|
||||
@@ -74,7 +111,48 @@ 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")
|
||||
@@ -87,6 +165,8 @@ func run() error {
|
||||
}
|
||||
notificationTrigger = trigger
|
||||
slog.Info("birthday notifications enabled", "time", notificationTime, "trigger", notificationTrigger)
|
||||
} else {
|
||||
slog.Debug("birthday notifications disabled")
|
||||
}
|
||||
d := &Daemon{
|
||||
userTokens: make(map[string]string),
|
||||
@@ -95,12 +175,18 @@ func run() error {
|
||||
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,
|
||||
@@ -109,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() {
|
||||
@@ -148,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)
|
||||
}
|
||||
@@ -173,12 +307,17 @@ 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
|
||||
}
|
||||
|
||||
@@ -192,6 +331,7 @@ func runCleanup() error {
|
||||
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 == "" {
|
||||
@@ -229,8 +369,10 @@ func runCleanup() error {
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -242,9 +384,12 @@ func runCleanup() error {
|
||||
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++
|
||||
}
|
||||
@@ -253,6 +398,62 @@ func runCleanup() error {
|
||||
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,7 +63,7 @@ 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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -12,7 +12,9 @@ Willkommen in der Dokumentation des **Mailcow Birthday Daemon** 🎂
|
||||
## Kurzübersicht
|
||||
|
||||
- Automatischer Geburtstagskalender für jede Mailcow-Mailbox
|
||||
- Liest Geburtstage aus allen CardDAV-Adressbüchern
|
||||
- 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`
|
||||
- Synchronisation alle 15 Minuten, vollständig automatisch
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
|
||||
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.
|
||||
@@ -12,10 +16,20 @@ Der Mailcow Birthday Daemon synchronisiert automatisch Geburtstagskalender für
|
||||
## 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.
|
||||
- Aktuell fest eingestellt: 1 Jahr in der Vergangenheit, 10 Jahre in der Zukunft.
|
||||
|
||||
> **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.
|
||||
@@ -23,6 +37,13 @@ Der Mailcow Birthday Daemon synchronisiert automatisch Geburtstagskalender für
|
||||
|
||||
> **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.
|
||||
@@ -31,4 +52,21 @@ Der Mailcow Birthday Daemon synchronisiert automatisch Geburtstagskalender für
|
||||
|
||||
## Synchronisationsintervall
|
||||
|
||||
Der Synchronisationszyklus läuft alle **15 Minuten** automatisch.
|
||||
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.
|
||||
|
||||
@@ -23,9 +23,15 @@ services:
|
||||
environment:
|
||||
- MAILCOW_BASE=https://mail.example.com
|
||||
- MAILCOW_APIKEY=DEIN-APIKEY-HIER
|
||||
- CALENDAR_NAME=Birthdays
|
||||
# - CALENDAR_COLOR=#D01818
|
||||
# - MAILCOW_RESOLVE_HOST=nginx-mailcow
|
||||
# - NOTIFICATION_ENABLED=true
|
||||
# - NOTIFICATION_TIME=08:00
|
||||
# - SYNC_INTERVAL=15m
|
||||
# - EVENT_YEARS=10
|
||||
# - MAILBOX_EXCLUDE=user1@example.com,user2@example.com
|
||||
# - LOG_LEVEL=info
|
||||
volumes:
|
||||
- birthdaydaemon:/data
|
||||
|
||||
@@ -48,7 +54,7 @@ cd /opt/mailcow-dockerized
|
||||
docker compose pull birthdaydaemon && docker compose up -d --no-deps birthdaydaemon
|
||||
```
|
||||
|
||||
> **Hinweis:** Nach dem Start wartet der Daemon zunächst **15 Sekunden**, bevor die erste Synchronisation beginnt. Diese Verzögerung stellt sicher, dass abhängige Dienste (z. B. Nginx, SOGo) vollständig bereit sind.
|
||||
> **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
|
||||
|
||||
@@ -58,9 +64,14 @@ docker compose pull birthdaydaemon && docker compose up -d --no-deps birthdaydae
|
||||
| `MAILCOW_APIKEY` | **Ja** | – | API-Key mit Lese-/Schreibzugriff aus dem Mailcow-Admin-Panel |
|
||||
| `MAILCOW_RESOLVE_HOST` | Nein | – | Interner Hostname für TCP-Verbindungen (z. B. `nginx-mailcow`). Löst Hairpin-NAT-Probleme in Docker-Netzen. TLS nutzt weiterhin den Hostnamen aus `MAILCOW_BASE`. |
|
||||
| `CALENDAR_NAME` | Nein | `Birthdays` | Name des Geburtstagskalenders, der in jeder Mailbox erstellt wird |
|
||||
| `CALENDAR_COLOR` | Nein | `#D01818` | Farbe des Geburtstagskalenders im Hex-Format (z. B. `#FF6600`). Wird nur gesetzt, wenn der Benutzer die Farbe nicht manuell geändert hat. |
|
||||
| `NOTIFICATION_ENABLED` | Nein | `false` | Aktiviert Kalender-Benachrichtigungen (VALARM) für Geburtstags-Events (`true`/`false`) |
|
||||
| `NOTIFICATION_TIME` | Nein | `08:00` | Uhrzeit der Benachrichtigung im Format `HH:MM` (nur wirksam wenn `NOTIFICATION_ENABLED=true`) |
|
||||
| `STATEFILE` | Nein | `state.json` (im Container: `/data/state.json`) | Pfad zur Zustandsdatei, in der App-Passwörter und der aktuelle Kalendername gespeichert werden |
|
||||
| `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
|
||||
|
||||
@@ -77,7 +88,7 @@ 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).
|
||||
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
## Container startet, aber keine Kalender werden erstellt
|
||||
|
||||
1. Logs prüfen: `docker compose logs -f birthdaydaemon`
|
||||
2. Sicherstellen, dass der API-Key **Lese- und Schreibzugriff** hat (nicht nur Lesezugriff).
|
||||
3. Prüfen, ob die Mailbox aktiv ist – inaktive Mailboxen werden übersprungen.
|
||||
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
|
||||
|
||||
@@ -33,12 +34,12 @@ 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
|
||||
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.
|
||||
@@ -46,3 +47,10 @@ docker compose exec birthdaydaemon /mailcow-birthday-daemon cleanup Birthdays
|
||||
## 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.
|
||||
|
||||
Reference in New Issue
Block a user