Compare commits
16 Commits
release-v0
...
syncinterv
| Author | SHA1 | Date | |
|---|---|---|---|
| 7448759732 | |||
| a799975c1f | |||
| b36ae57d48 | |||
| 67c3f10454 | |||
| 766b69aa4a | |||
| c623e39b4c | |||
| c5337d7d63 | |||
| efcbd04aa2 | |||
| dc01480b8b | |||
| 78ebe7a499 | |||
| b9c81bd04e | |||
| 2568258794 | |||
| 882fb6448d | |||
| a296efbb86 | |||
| cb8192640d | |||
| c35f86b7c4 |
@@ -29,11 +29,11 @@ release:
|
||||
gitea:
|
||||
owner: "{{ .Env.REGISTRY_USER }}"
|
||||
name: mailcow-birthday-daemon
|
||||
gitea_urls:
|
||||
api: "{{ .Env.REGISTRY_URL }}/api/v1/"
|
||||
download: "{{ .Env.REGISTRY_URL }}"
|
||||
skip_tls_verify: false
|
||||
prerelease: auto
|
||||
gitea_urls:
|
||||
api: "{{ .Env.REGISTRY_URL }}/api/v1/"
|
||||
download: "{{ .Env.REGISTRY_URL }}"
|
||||
skip_tls_verify: false
|
||||
changelog:
|
||||
sort: asc
|
||||
filters:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -12,7 +12,7 @@ Ein einfacher Daemon, der automatisch einen Geburtstagskalender für jede Mailco
|
||||
|
||||
- Liest Geburtstage aus allen CardDAV-Adressbüchern jeder Mailbox
|
||||
- Erstellt und synchronisiert automatisch einen Geburtstagskalender pro Benutzer
|
||||
- Synchronisation alle **15 Minuten**
|
||||
- Synchronisation standardmäßig alle **15 Minuten** (konfigurierbar über `SYNC_INTERVAL`)
|
||||
- Läuft als Docker-Container direkt im Mailcow-Stack
|
||||
|
||||
## Schnellstart
|
||||
@@ -40,6 +40,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
|
||||
|
||||
87
cmd/mcbdd/health.go
Normal file
87
cmd/mcbdd/health.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"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)
|
||||
os.WriteFile(h.filePath, data, 0644)
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
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 != "" {
|
||||
return fmt.Errorf("last sync failed: %s", s.LastError)
|
||||
}
|
||||
if time.Since(s.LastSync) > maxSyncAge() {
|
||||
return fmt.Errorf("last sync too old: %s ago", time.Since(s.LastSync).Round(time.Second))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -37,15 +37,26 @@ type Daemon struct {
|
||||
oldCalendarName string
|
||||
notificationEnabled bool
|
||||
notificationTrigger string
|
||||
syncInterval time.Duration
|
||||
health *healthState
|
||||
}
|
||||
|
||||
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)
|
||||
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)
|
||||
@@ -75,6 +86,12 @@ func run() error {
|
||||
calendarName = "Birthdays"
|
||||
}
|
||||
notificationEnabled := strings.EqualFold(os.Getenv("NOTIFICATION_ENABLED"), "true")
|
||||
syncInterval, err := parseSyncInterval()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
slog.Info("sync interval configured", "interval", syncInterval)
|
||||
|
||||
notificationTrigger := "PT8H"
|
||||
if notificationEnabled {
|
||||
notificationTime := os.Getenv("NOTIFICATION_TIME")
|
||||
@@ -97,10 +114,12 @@ func run() error {
|
||||
calendarName: calendarName,
|
||||
notificationEnabled: notificationEnabled,
|
||||
notificationTrigger: notificationTrigger,
|
||||
syncInterval: syncInterval,
|
||||
}
|
||||
if len(d.stateFilepath) == 0 {
|
||||
d.stateFilepath = "state.json"
|
||||
}
|
||||
d.health = newHealthState(d.stateFilepath)
|
||||
d.mailcowClient = mailcow.New(
|
||||
d.httpClient,
|
||||
mailcowBase,
|
||||
@@ -115,10 +134,12 @@ func run() error {
|
||||
|
||||
func (d *Daemon) daemonLoop() {
|
||||
for {
|
||||
if err := d.daemonRun(); err != nil {
|
||||
err := d.daemonRun()
|
||||
d.health.update(err)
|
||||
if err != nil {
|
||||
slog.Error("error while syncing birthdays", "err", err)
|
||||
}
|
||||
time.Sleep(time.Minute * 15)
|
||||
time.Sleep(d.syncInterval)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,6 +274,23 @@ func runCleanup() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -31,4 +31,11 @@ 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).
|
||||
|
||||
## 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,11 @@ services:
|
||||
environment:
|
||||
- MAILCOW_BASE=https://mail.example.com
|
||||
- MAILCOW_APIKEY=DEIN-APIKEY-HIER
|
||||
- CALENDAR_NAME=Birthdays
|
||||
# - MAILCOW_RESOLVE_HOST=nginx-mailcow
|
||||
# - NOTIFICATION_ENABLED=true
|
||||
# - NOTIFICATION_TIME=08:00
|
||||
# - SYNC_INTERVAL=15m
|
||||
volumes:
|
||||
- birthdaydaemon:/data
|
||||
|
||||
@@ -61,6 +63,7 @@ docker compose pull birthdaydaemon && docker compose up -d --no-deps birthdaydae
|
||||
| `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`. |
|
||||
|
||||
## API-Key erstellen
|
||||
|
||||
@@ -77,7 +80,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).
|
||||
|
||||
|
||||
@@ -30,8 +30,26 @@ docker compose exec birthdaydaemon /mailcow-birthday-daemon cleanup <alter-kalen
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user