Compare commits
26 Commits
release-v0
...
cleanup-do
| Author | SHA1 | Date | |
|---|---|---|---|
| 882fb6448d | |||
| a296efbb86 | |||
| cb8192640d | |||
| c35f86b7c4 | |||
| a321bb6917 | |||
| b346d58beb | |||
| 818fc92ab0 | |||
| df397e5e3c | |||
| 60ecd2352c | |||
| d59576258d | |||
| ff4dc27b6f | |||
| 0a5f78ffa8 | |||
| 2b7e92e3a4 | |||
| a2ca58e538 | |||
| 5892230fd8 | |||
| 0767ad1a7e | |||
| 772eaba37e | |||
| eb72cae10c | |||
| a6f8a42f97 | |||
| 8eb4afc6a1 | |||
| e3997d8f6f | |||
| 6757362d8c | |||
| f4c92fa537 | |||
| 2498dee918 | |||
| ebe7e50691 | |||
| c6c22b1c62 |
@@ -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
|
||||
|
||||
@@ -27,23 +27,21 @@ jobs:
|
||||
run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o mailcow-birthday-daemon ./cmd/mcbdd
|
||||
|
||||
- name: Login to Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.techniverse.net
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: echo "${REGISTRY_TOKEN}" | docker login git.techniverse.net -u "${GITHUB_ACTOR}" --password-stdin
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: |
|
||||
git.techniverse.net/scriptos/mailcow-birthday-daemon:dev
|
||||
git.techniverse.net/scriptos/mailcow-birthday-daemon:${{ gitea.sha }}
|
||||
run: |
|
||||
IMAGE="git.techniverse.net/scriptos/mailcow-birthday-daemon"
|
||||
docker build -t "${IMAGE}:dev" -t "${IMAGE}:${GITHUB_SHA}" .
|
||||
docker push "${IMAGE}:dev"
|
||||
docker push "${IMAGE}:${GITHUB_SHA}"
|
||||
|
||||
- name: Cleanup old SHA-tagged images
|
||||
if: success()
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
API="https://git.techniverse.net/api/v1"
|
||||
OWNER="scriptos"
|
||||
@@ -51,7 +49,7 @@ jobs:
|
||||
KEEP=5
|
||||
|
||||
# Fetch all container package versions
|
||||
RESPONSE=$(curl -s -H "Authorization: token ${{ secrets.REGISTRY_TOKEN }}" \
|
||||
RESPONSE=$(curl -s -H "Authorization: token ${REGISTRY_TOKEN}" \
|
||||
"${API}/packages/${OWNER}?type=container&q=${PACKAGE}&limit=50")
|
||||
|
||||
# Extract only SHA-tagged versions (40-char hex), sorted newest first
|
||||
@@ -68,7 +66,7 @@ jobs:
|
||||
fi
|
||||
echo "Deleting: ${VERSION:0:12}..."
|
||||
curl -s -X DELETE \
|
||||
-H "Authorization: token ${{ secrets.REGISTRY_TOKEN }}" \
|
||||
-H "Authorization: token ${REGISTRY_TOKEN}" \
|
||||
"${API}/packages/${OWNER}/container/${PACKAGE}/${VERSION}"
|
||||
done
|
||||
echo "Cleanup done. Kept $((COUNT < KEEP ? COUNT : KEEP)) of $COUNT SHA-tagged images."
|
||||
|
||||
@@ -15,16 +15,14 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
|
||||
- name: Login to Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.techniverse.net
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: echo "${REGISTRY_TOKEN}" | docker login git.techniverse.net -u "${GITHUB_ACTOR}" --password-stdin
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v6
|
||||
@@ -34,3 +32,6 @@ jobs:
|
||||
args: release --clean
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
REGISTRY_URL: ${{ secrets.REGISTRY_URL }}
|
||||
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||
GORELEASER_FORCE_TOKEN: gitea
|
||||
|
||||
@@ -26,7 +26,14 @@ 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:
|
||||
@@ -41,7 +48,5 @@ upx:
|
||||
dockers:
|
||||
- dockerfile: Dockerfile
|
||||
image_templates:
|
||||
- "git.techniverse.net/scriptos/mailcow-birthday-daemon:{{ .Major }}"
|
||||
- "git.techniverse.net/scriptos/mailcow-birthday-daemon:{{ .Major }}.{{ .Minor }}"
|
||||
- "git.techniverse.net/scriptos/mailcow-birthday-daemon:{{ .Major }}.{{ .Minor }}.{{ .Patch }}"
|
||||
- "git.techniverse.net/scriptos/mailcow-birthday-daemon:latest"
|
||||
|
||||
20
README.md
20
README.md
@@ -17,6 +17,10 @@ Ein einfacher Daemon, der automatisch einen Geburtstagskalender für jede Mailco
|
||||
|
||||
## 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 +40,24 @@ volumes:
|
||||
birthdaydaemon:
|
||||
```
|
||||
|
||||
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 |
@@ -133,7 +133,7 @@ func (d *Daemon) syncBirthdaysToCal(ctx context.Context, httpClient webdav.HTTPC
|
||||
matchedBev := false
|
||||
for _, v := range ev.Data.Children {
|
||||
for i, bev := range bevs {
|
||||
if icalMatchesBev(v, bev) {
|
||||
if icalMatchesBev(v, bev, d.notificationEnabled) {
|
||||
bevsInSync = append(bevsInSync, i)
|
||||
matchedBev = true
|
||||
}
|
||||
@@ -154,7 +154,7 @@ func (d *Daemon) syncBirthdaysToCal(ctx context.Context, httpClient webdav.HTTPC
|
||||
if slices.Contains(bevsInSync, i) {
|
||||
continue
|
||||
}
|
||||
p, ic := v.generateICAL(calendarPath)
|
||||
p, ic := v.generateICAL(calendarPath, d.notificationEnabled, d.notificationTrigger)
|
||||
_, err := cl.PutCalendarObject(ctx, p, ic)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -193,20 +193,38 @@ func generateBirthdayEvents(birthdays []BirthdayContact) []birthdayEvent {
|
||||
return bb
|
||||
}
|
||||
|
||||
func icalMatchesBev(ic *ical.Component, bev birthdayEvent) bool {
|
||||
func icalMatchesBev(ic *ical.Component, bev birthdayEvent, notificationEnabled bool) bool {
|
||||
if ic.Props.Get(ical.PropSummary) == nil || ic.Props.Get(ical.PropSummary).Value != bev.Summary {
|
||||
return false
|
||||
}
|
||||
if ic.Props.Get(ical.PropDateTimeStart) == nil || ic.Props.Get(ical.PropDateTimeStart).Value != bev.DateTimeStart {
|
||||
dtStart := ic.Props.Get(ical.PropDateTimeStart)
|
||||
if dtStart == nil || dtStart.Value != bev.DateTimeStart {
|
||||
return false
|
||||
}
|
||||
if ic.Props.Get(ical.PropDateTimeEnd) == nil || ic.Props.Get(ical.PropDateTimeEnd).Value != bev.DateTimeEnd {
|
||||
if dtStart.Params.Get(ical.ParamValue) != string(ical.ValueDate) {
|
||||
return false
|
||||
}
|
||||
dtEnd := ic.Props.Get(ical.PropDateTimeEnd)
|
||||
if dtEnd == nil || dtEnd.Value != bev.DateTimeEnd {
|
||||
return false
|
||||
}
|
||||
if dtEnd.Params.Get(ical.ParamValue) != string(ical.ValueDate) {
|
||||
return false
|
||||
}
|
||||
hasAlarm := false
|
||||
for _, child := range ic.Children {
|
||||
if child.Name == ical.CompAlarm {
|
||||
hasAlarm = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if notificationEnabled != hasAlarm {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (bev birthdayEvent) generateICAL(calendar string) (string, *ical.Calendar) {
|
||||
func (bev birthdayEvent) generateICAL(calendar string, notificationEnabled bool, notificationTrigger string) (string, *ical.Calendar) {
|
||||
id := uuid.New().String()
|
||||
cal := ical.NewCalendar()
|
||||
cal.Props.SetText(ical.PropProductID, ConstProductID)
|
||||
@@ -216,11 +234,23 @@ func (bev birthdayEvent) generateICAL(calendar string) (string, *ical.Calendar)
|
||||
event.Props.SetText(ical.PropSummary, bev.Summary)
|
||||
event.Props.SetDateTime(ical.PropDateTimeStamp, time.Now())
|
||||
start := ical.NewProp(ical.PropDateTimeStart)
|
||||
start.SetValueType(ical.ValueDate)
|
||||
start.Value = bev.DateTimeStart
|
||||
end := ical.NewProp(ical.PropDateTimeEnd)
|
||||
end.SetValueType(ical.ValueDate)
|
||||
end.Value = bev.DateTimeEnd
|
||||
event.Props.Set(start)
|
||||
event.Props.Set(end)
|
||||
if notificationEnabled {
|
||||
alarm := ical.NewComponent(ical.CompAlarm)
|
||||
alarm.Props.SetText(ical.PropAction, "DISPLAY")
|
||||
alarm.Props.SetText(ical.PropDescription, bev.Summary)
|
||||
trigger := ical.NewProp(ical.PropTrigger)
|
||||
trigger.Params.Set(ical.ParamValue, string(ical.ValueDuration))
|
||||
trigger.Value = notificationTrigger
|
||||
alarm.Props.Set(trigger)
|
||||
event.Children = append(event.Children, alarm)
|
||||
}
|
||||
cal.Children = append(cal.Children, event)
|
||||
return fmt.Sprintf("%s/%s.ics", calendar, id), cal
|
||||
}
|
||||
|
||||
@@ -26,18 +26,27 @@ var (
|
||||
)
|
||||
|
||||
type Daemon struct {
|
||||
httpClient *http.Client
|
||||
baseURL string
|
||||
mailcowClient mailcow.Client
|
||||
userTokens map[string]string
|
||||
userTokensLock *sync.RWMutex
|
||||
stateFilepath string
|
||||
stateUnsaved bool
|
||||
calendarName string
|
||||
oldCalendarName string
|
||||
httpClient *http.Client
|
||||
baseURL string
|
||||
mailcowClient mailcow.Client
|
||||
userTokens map[string]string
|
||||
userTokensLock *sync.RWMutex
|
||||
stateFilepath string
|
||||
stateUnsaved bool
|
||||
calendarName string
|
||||
oldCalendarName string
|
||||
notificationEnabled bool
|
||||
notificationTrigger string
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) > 1 && os.Args[1] == "cleanup" {
|
||||
if err := runCleanup(); err != nil {
|
||||
slog.Error("cleanup failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := run(); err != nil {
|
||||
slog.Error("fatal error", "err", err)
|
||||
os.Exit(1)
|
||||
@@ -65,13 +74,29 @@ func run() error {
|
||||
if calendarName == "" {
|
||||
calendarName = "Birthdays"
|
||||
}
|
||||
notificationEnabled := strings.EqualFold(os.Getenv("NOTIFICATION_ENABLED"), "true")
|
||||
notificationTrigger := "PT8H"
|
||||
if notificationEnabled {
|
||||
notificationTime := os.Getenv("NOTIFICATION_TIME")
|
||||
if notificationTime == "" {
|
||||
notificationTime = "08:00"
|
||||
}
|
||||
trigger, err := parseNotificationTrigger(notificationTime)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid NOTIFICATION_TIME: %w", err)
|
||||
}
|
||||
notificationTrigger = trigger
|
||||
slog.Info("birthday notifications enabled", "time", notificationTime, "trigger", notificationTrigger)
|
||||
}
|
||||
d := &Daemon{
|
||||
userTokens: make(map[string]string),
|
||||
userTokensLock: &sync.RWMutex{},
|
||||
baseURL: mailcowBase,
|
||||
stateFilepath: os.Getenv("STATEFILE"),
|
||||
httpClient: &http.Client{Transport: buildTransport()},
|
||||
calendarName: calendarName,
|
||||
userTokens: make(map[string]string),
|
||||
userTokensLock: &sync.RWMutex{},
|
||||
baseURL: mailcowBase,
|
||||
stateFilepath: os.Getenv("STATEFILE"),
|
||||
httpClient: &http.Client{Transport: buildTransport()},
|
||||
calendarName: calendarName,
|
||||
notificationEnabled: notificationEnabled,
|
||||
notificationTrigger: notificationTrigger,
|
||||
}
|
||||
if len(d.stateFilepath) == 0 {
|
||||
d.stateFilepath = "state.json"
|
||||
@@ -157,6 +182,77 @@ func (d *Daemon) processUser(ctx context.Context, m mailcow.Mailbox) error {
|
||||
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)
|
||||
|
||||
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
|
||||
for _, m := range mb {
|
||||
if !m.IsActive() {
|
||||
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()
|
||||
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)
|
||||
}
|
||||
processed++
|
||||
}
|
||||
|
||||
slog.Info("cleanup finished", "processed", processed, "skipped", skipped)
|
||||
return 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
|
||||
|
||||
@@ -65,3 +65,32 @@ func sanitizeBirthday(input string) (uint16, uint16, uint16, error) {
|
||||
}
|
||||
return 0, 0, 0, fmt.Errorf("birthday prop format unknown: %s", input)
|
||||
}
|
||||
|
||||
// parseNotificationTrigger konvertiert eine Uhrzeit im Format "HH:MM" in eine
|
||||
// iCal-Duration (z.B. "PT8H", "PT9H30M"), die als VALARM-Trigger relativ zum
|
||||
// Start eines Ganztags-Events (Mitternacht) verwendet wird.
|
||||
func parseNotificationTrigger(timeStr string) (string, error) {
|
||||
parts := strings.Split(timeStr, ":")
|
||||
if len(parts) != 2 {
|
||||
return "", fmt.Errorf("invalid time format: %s (expected HH:MM)", timeStr)
|
||||
}
|
||||
hours, err := strconv.Atoi(parts[0])
|
||||
if err != nil || hours < 0 || hours > 23 {
|
||||
return "", fmt.Errorf("invalid hours in time: %s", timeStr)
|
||||
}
|
||||
minutes, err := strconv.Atoi(parts[1])
|
||||
if err != nil || minutes < 0 || minutes > 59 {
|
||||
return "", fmt.Errorf("invalid minutes in time: %s", timeStr)
|
||||
}
|
||||
if hours == 0 && minutes == 0 {
|
||||
return "PT0S", nil
|
||||
}
|
||||
trigger := "PT"
|
||||
if hours > 0 {
|
||||
trigger += fmt.Sprintf("%dH", hours)
|
||||
}
|
||||
if minutes > 0 {
|
||||
trigger += fmt.Sprintf("%dM", minutes)
|
||||
}
|
||||
return trigger, nil
|
||||
}
|
||||
|
||||
@@ -6,3 +6,13 @@ 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 aus allen CardDAV-Adressbüchern
|
||||
- Optionale Benachrichtigungen (VALARM) zur konfigurierbaren Uhrzeit
|
||||
- Läuft als Docker-Container im Mailcow-Stack via `docker-compose.override.yml`
|
||||
- Synchronisation alle 15 Minuten, vollständig automatisch
|
||||
|
||||
34
docs/funktionsweise.md
Normal file
34
docs/funktionsweise.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Funktionsweise
|
||||
|
||||
## Übersicht
|
||||
|
||||
Der Mailcow Birthday Daemon synchronisiert automatisch Geburtstagskalender für jede aktive Mailbox. Der gesamte Prozess läuft ohne Benutzereingriff 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.
|
||||
- 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.
|
||||
|
||||
## 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)).
|
||||
|
||||
## 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 alle **15 Minuten** automatisch.
|
||||
@@ -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,9 @@ services:
|
||||
environment:
|
||||
- MAILCOW_BASE=https://mail.example.com
|
||||
- MAILCOW_APIKEY=DEIN-APIKEY-HIER
|
||||
- MAILCOW_RESOLVE_HOST=nginx-mailcow
|
||||
# - MAILCOW_RESOLVE_HOST=nginx-mailcow
|
||||
# - NOTIFICATION_ENABLED=true
|
||||
# - NOTIFICATION_TIME=08:00
|
||||
volumes:
|
||||
- birthdaydaemon:/data
|
||||
|
||||
@@ -33,15 +37,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 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.
|
||||
|
||||
## Umgebungsvariablen
|
||||
|
||||
| Variable | Pflicht | Standardwert | Beschreibung |
|
||||
@@ -50,17 +58,22 @@ docker compose up -d
|
||||
| `MAILCOW_APIKEY` | **Ja** | – | API-Key mit Lese-/Schreibzugriff aus dem Mailcow-Admin-Panel |
|
||||
| `MAILCOW_RESOLVE_HOST` | Nein | – | Interner Hostname für TCP-Verbindungen (z. B. `nginx-mailcow`). Löst Hairpin-NAT-Probleme in Docker-Netzen. TLS nutzt weiterhin den Hostnamen aus `MAILCOW_BASE`. |
|
||||
| `CALENDAR_NAME` | Nein | `Birthdays` | Name des Geburtstagskalenders, der in jeder Mailbox erstellt wird |
|
||||
| `NOTIFICATION_ENABLED` | Nein | `false` | Aktiviert Kalender-Benachrichtigungen (VALARM) für Geburtstags-Events (`true`/`false`) |
|
||||
| `NOTIFICATION_TIME` | Nein | `08:00` | Uhrzeit der Benachrichtigung im Format `HH:MM` (nur wirksam wenn `NOTIFICATION_ENABLED=true`) |
|
||||
| `STATEFILE` | Nein | `state.json` (im Container: `/data/state.json`) | Pfad zur Zustandsdatei, in der App-Passwörter und der aktuelle Kalendername gespeichert werden |
|
||||
|
||||
## API-Key erstellen
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
@@ -68,15 +81,6 @@ Nach dem Start synchronisiert der Daemon automatisch alle 15 Minuten die Geburts
|
||||
|
||||
> **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).
|
||||
|
||||
48
docs/troubleshooting.md
Normal file
48
docs/troubleshooting.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# Troubleshooting
|
||||
|
||||
## 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.
|
||||
|
||||
## 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`).
|
||||
@@ -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