64 Commits

Author SHA1 Message Date
67c3f10454 feat: Dateibasierten Docker-Healthcheck hinzugefügt
All checks were successful
Build Test Docker Image / docker-test (pull_request) Successful in 1m49s
Run Tests / test (pull_request) Successful in 4m45s
2026-03-29 20:41:51 +02:00
766b69aa4a Merge pull request 'docs' (#13) from docs into master
Reviewed-on: #13
2026-03-29 00:10:40 +00:00
c623e39b4c Merge branch 'master' into docs 2026-03-29 00:10:31 +00:00
c5337d7d63 Merge branch 'docs' of https://git.techniverse.net/scriptos/mailcow-birthday-daemon into docs 2026-03-29 01:10:05 +01:00
efcbd04aa2 docs: Hinweis auf Schnellstart für Umgebungsvariablen in README ergänzt 2026-03-29 01:09:58 +01:00
dc01480b8b Merge pull request 'docs: CALENDAR_NAME in Beispiel Compose ergänzt' (#12) from docs into master
Reviewed-on: #12
2026-03-29 00:01:51 +00:00
78ebe7a499 Merge branch 'master' into docs 2026-03-29 00:01:31 +00:00
b9c81bd04e docs: CALENDAR_NAME in Beispiel Compose ergänzt 2026-03-29 01:00:56 +01:00
2568258794 Merge pull request 'docs: Beispielausgabe für Cleanup-Befehl ergänzt' (#11) from cleanup-docs into master
Reviewed-on: #11
2026-03-28 23:54:39 +00:00
882fb6448d docs: Beispielausgabe für Cleanup-Befehl ergänzt 2026-03-29 00:52:07 +01:00
a296efbb86 Merge pull request 'fix(ci): gitea_urls auf Top-Level verschoben – YAML-Unmarshal-Fehler behoben' (#10) from release-v0.3.2 into master
Some checks failed
Run Tests / test (push) Successful in 4m39s
Make Release / release (push) Failing after 1m48s
Reviewed-on: #10
2026-03-28 23:36:59 +00:00
cb8192640d fix(ci): gitea_urls auf Top-Level verschoben – YAML-Unmarshal-Fehler behoben
All checks were successful
Build Test Docker Image / docker-test (pull_request) Successful in 1m38s
Run Tests / test (pull_request) Successful in 4m41s
2026-03-29 00:23:40 +01:00
c35f86b7c4 Merge pull request 'release-v0.3.1' (#9) from release-v0.3.1 into master
Some checks failed
Run Tests / test (push) Successful in 4m37s
Make Release / release (push) Failing after 29s
Reviewed-on: #9
2026-03-28 23:09:13 +00:00
a321bb6917 fix(ci): GoReleaser gitea_urls explizit konfiguriert – Release-URL-Fehler behoben
All checks were successful
Build Test Docker Image / docker-test (pull_request) Successful in 1m30s
Run Tests / test (pull_request) Successful in 4m44s
2026-03-29 00:03:52 +01:00
b346d58beb feat: Cleanup-Subcommand zum Entfernen doppelter Kalender hinzugefügt, Doku-Hinweis auf v0.2.0 präzisiert 2026-03-29 00:00:03 +01:00
818fc92ab0 Merge pull request 'Hinweis zur Repository-Spiegelung und Issue-Tracker in README ergänzt' (#8) from docs into master
Reviewed-on: #8
2026-03-28 22:40:39 +00:00
df397e5e3c Merge branch 'master' into docs 2026-03-28 22:40:32 +00:00
60ecd2352c Hinweis zur Repository-Spiegelung und Issue-Tracker in README ergänzt 2026-03-28 23:39:39 +01:00
d59576258d Merge pull request 'docs: Bildverweis für API-Key-Erstellung erstellt' (#7) from docs into master
Reviewed-on: #7
2026-03-28 21:59:40 +00:00
ff4dc27b6f Merge branch 'master' into docs 2026-03-28 21:59:30 +00:00
0a5f78ffa8 docs: Bildverweis für API-Key-Erstellung erstellt 2026-03-28 22:59:01 +01:00
2b7e92e3a4 Merge pull request 'Doku erweitert und in einzelne Dokumente aufgeteilt' (#6) from docs into master
Reviewed-on: #6
2026-03-28 21:48:05 +00:00
a2ca58e538 Doku erweitert und in einzelne Dokumente aufgeteilt 2026-03-28 22:47:45 +01:00
5892230fd8 Merge pull request 'release-v0.3.0' (#5) from release-v0.3.0 into master
Some checks failed
Run Tests / test (push) Successful in 4m40s
Make Release / release (push) Failing after 1m54s
Reviewed-on: #5
2026-03-28 21:29:42 +00:00
0767ad1a7e fix(ci): GITEA_SERVER_URL als Secret ausgelagert statt hartcodiert
All checks were successful
Build Test Docker Image / docker-test (pull_request) Successful in 1m24s
Run Tests / test (pull_request) Successful in 4m48s
2026-03-28 22:22:37 +01:00
772eaba37e feat: Optionale Kalender-Benachrichtigungen für Geburtstage 2026-03-28 22:06:49 +01:00
eb72cae10c Merge pull request 'release-v0.2.3' (#4) from release-v0.2.3 into master
Some checks failed
Run Tests / test (push) Successful in 4m57s
Make Release / release (push) Failing after 2m43s
Reviewed-on: #4
2026-03-28 20:18:42 +00:00
a6f8a42f97 Geburtstagstermine als Ganztags-Events (VALUE=DATE) erstellen; bestehende Termine werden automatisch migriert
All checks were successful
Build Test Docker Image / docker-test (pull_request) Successful in 1m47s
Run Tests / test (pull_request) Successful in 5m2s
2026-03-28 21:03:48 +01:00
8eb4afc6a1 fix(ci): setup-go von v6 auf v5 downgraden (Node-24-Inkompatibilität mit act_runner) 2026-03-28 20:56:12 +01:00
e3997d8f6f Merge pull request 'fix(ci): Gitea-Release-URL und Token-Typ für GoReleaser ergänzt' (#3) from release-v0.2.2 into master
Some checks failed
Run Tests / test (push) Successful in 4m46s
Make Release / release (push) Failing after 2m1s
Reviewed-on: #3
2026-03-28 16:42:43 +00:00
6757362d8c fix(ci): Gitea-Release-URL und Token-Typ für GoReleaser ergänzt
All checks were successful
Build Test Docker Image / docker-test (pull_request) Successful in 1m21s
Run Tests / test (pull_request) Successful in 4m50s
2026-03-28 17:35:07 +01:00
f4c92fa537 Merge pull request 'release-v0.2.1' (#2) from release-v0.2.1 into master
Some checks failed
Run Tests / test (push) Successful in 4m57s
Make Release / release (push) Failing after 1m58s
Reviewed-on: #2
2026-03-28 16:14:49 +00:00
2498dee918 fix: Gekürzte Docker-Tags (Major, Minor) entfernt, nur noch vollständiges Semver-Tag beibehalten
All checks were successful
Build Test Docker Image / docker-test (pull_request) Successful in 1m27s
Run Tests / test (pull_request) Successful in 4m48s
2026-03-28 17:04:48 +01:00
ebe7e50691 fix(ci): Docker-Login und Build auf CLI umgestellt, Actions-Expressions entfernt 2026-03-28 17:02:30 +01:00
c6c22b1c62 Merge pull request 'release-v0.2.0' (#1) from release-v0.2.0 into master
Some checks failed
Run Tests / test (push) Successful in 4m52s
Make Release / release (push) Failing after 2m27s
Reviewed-on: #1
2026-03-28 15:41:00 +00:00
1c70eb6643 'Cleanup old SHA-tagged images' hinzugefügt
All checks were successful
Build Test Docker Image / docker-test (pull_request) Successful in 1m50s
Run Tests / test (pull_request) Successful in 4m35s
2026-03-28 16:12:47 +01:00
bde20643b0 docs: Hinweis zur KI-gestützten Entwicklung in README ergänzt
All checks were successful
Build Test Docker Image / docker-test (pull_request) Successful in 1m57s
Run Tests / test (pull_request) Successful in 4m58s
2026-03-28 16:07:21 +01:00
f0ba2ad36f Edited Workflows
All checks were successful
Build Test Docker Image / docker-test (pull_request) Successful in 1m55s
Run Tests / test (pull_request) Successful in 5m6s
2026-03-28 16:00:42 +01:00
27f17fa45e chore: Taskfile.yml entfernt (nicht benötigter Task-Runner)
All checks were successful
Build Test Docker Image / docker-test (pull_request) Successful in 1m48s
Run Tests / test (pull_request) Successful in 4m50s
2026-03-28 15:55:25 +01:00
f24047147b docs: add image to README
All checks were successful
Build Test Docker Image / docker-test (pull_request) Successful in 1m48s
Run Tests / test (pull_request) Successful in 5m16s
2026-03-28 15:52:26 +01:00
6db5113755 Fehlenden Newline am Dateiende in README.md ergänzt (pre-commit fix)
All checks were successful
Build Test Docker Image / docker-test (pull_request) Successful in 1m37s
Run Tests / test (pull_request) Successful in 5m15s
2026-03-28 15:41:36 +01:00
be77b36596 Startup-Delay von 15 Sekunden hinzugefügt, damit nginx beim Stack-Neustart bereit ist
Some checks failed
Build Test Docker Image / docker-test (pull_request) Successful in 1m33s
Run Tests / test (pull_request) Failing after 1m11s
2026-03-28 15:40:06 +01:00
2a6d9b2543 Edited Docs
Some checks failed
Build Test Docker Image / docker-test (pull_request) Successful in 1m35s
Run Tests / test (pull_request) Failing after 1m20s
2026-03-28 15:37:03 +01:00
e4e2f83d0a docs: Neue Umgebungsvariablen in .env.example ergänzen
All checks were successful
Build Test Docker Image / docker-test (pull_request) Successful in 1m35s
Run Tests / test (pull_request) Successful in 4m47s
2026-03-28 15:05:31 +01:00
c000664165 feat: Hairpin-NAT-Lösung, CA-Zertifikate und konfigurierbarer Kalendername
All checks were successful
Build Test Docker Image / docker-test (pull_request) Successful in 2m1s
Run Tests / test (pull_request) Successful in 5m2s
2026-03-28 15:03:49 +01:00
838e702cf0 feat: MAILCOW_RESOLVE_HOST für internes DNS-Routing hinzufügen
All checks were successful
Build Test Docker Image / docker-test (pull_request) Successful in 1m54s
Run Tests / test (pull_request) Successful in 4m49s
2026-03-28 14:30:57 +01:00
28285b254e fix: LICENSE.md aus trailing-whitespace Hook ausschließen
All checks were successful
Build Test Docker Image / docker-test (pull_request) Successful in 45s
Run Tests / test (pull_request) Successful in 20m33s
2026-03-28 14:08:47 +01:00
fa2829811b CI: Docker-Test-Workflow für PR und manuellen Build hinzugefügt
Some checks failed
Build Test Docker Image / docker-test (pull_request) Successful in 6m16s
Run Tests / test (pull_request) Failing after 19m17s
2026-03-28 13:31:32 +01:00
f70d089b5f Containerfile in Dockerfile umbenennen 2026-03-28 13:27:41 +01:00
3df0ab8de7 fix: CA-Zertifikate zum Container-Image hinzufügen 2026-03-28 13:25:25 +01:00
2900e5267c ci: Workflows von .github nach .gitea verschoben 2026-03-28 13:23:09 +01:00
f9bc388276 docs: format LICENSE.md 2026-03-28 13:19:28 +01:00
aad37e3c40 CI: Workflows auf Gitea Actions umstellen, README aktualisieren 2026-03-28 13:16:16 +01:00
ceb5002bd6 Lizenz und Repository-Referenzen auf eigenen Fork aktualisiert 2026-03-28 13:00:04 +01:00
4c496eee34 .gitignore: KI-Arbeitsverzeichnis vom Tracking ausschließen 2026-03-28 12:55:00 +01:00
adec0db9a1 docs: Dokumentation auf Deutsch übersetzt 2026-03-28 12:50:33 +01:00
Marco98
3076c431e1 feat: add check if envs are set 2026-03-27 21:39:52 +01:00
Marco
d9de8fa7a4 Merge pull request #4 from zege-at/patch-1
Update image version to 0.1.1
2026-03-27 21:09:26 +01:00
Gerald Zehetner
296815ee70 Update image version to 0.1.1 2025-12-19 11:58:48 +01:00
Marco98
2de2b2460d fix: truncate state file 2025-11-17 16:38:57 +01:00
Marco98
40f7817226 fix: regenerate userpass if unauthorized 2025-11-17 16:35:54 +01:00
Marco98
898567679e feat: statefile versioning 2025-11-17 16:25:05 +01:00
Marco98
5e39f26252 docs: add Taskfile 2025-11-17 15:42:54 +01:00
Marco98
4dce7d2c1d docs: improve docs 2025-11-14 13:09:03 +01:00
25 changed files with 957 additions and 93 deletions

23
.env.example Normal file
View File

@@ -0,0 +1,23 @@
# Basis-URL der Mailcow-Instanz
MAILCOW_BASE=https://mailcow.host
# API-Key mit Lese-/Schreibzugriff (Admin-Panel > Konfiguration > Zugang > API)
MAILCOW_APIKEY=DEIN-APIKEY-HIER
# (Optional) 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.
# MAILCOW_RESOLVE_HOST=nginx-mailcow
# (Optional) Name des Geburtstagskalenders Standard: Birthdays
# Bei Änderung wird der alte Kalender automatisch entfernt und ein neuer erstellt.
# CALENDAR_NAME=Birthdays
# (Optional) Kalender-Benachrichtigungen für Geburtstage aktivieren Standard: false
# NOTIFICATION_ENABLED=true
# (Optional) Uhrzeit der Benachrichtigung im Format HH:MM Standard: 08:00
# Nur wirksam wenn NOTIFICATION_ENABLED=true
# NOTIFICATION_TIME=08:00
# (Optional) Pfad zur Zustandsdatei Standard: state.json / im Container: /data/state.json
# STATEFILE=/data/state.json

View File

@@ -0,0 +1,72 @@
name: Build Test Docker Image
on:
workflow_dispatch:
pull_request:
paths-ignore:
- 'docs/**'
- 'assets/img/**'
- 'README.md'
- 'LICENSE.md'
jobs:
docker-test:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
- name: Build Go binary
run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o mailcow-birthday-daemon ./cmd/mcbdd
- name: Login to Gitea Container Registry
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
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"
PACKAGE="mailcow-birthday-daemon"
KEEP=5
# Fetch all container package versions
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
SHA_VERSIONS=$(echo "$RESPONSE" | \
jq -r '[.[] | select(.name == "'"${PACKAGE}"'" and (.version | test("^[0-9a-f]{40}$")))] | sort_by(.created_at) | reverse | .[].version')
# Keep the newest $KEEP images, delete the rest
COUNT=0
for VERSION in $SHA_VERSIONS; do
COUNT=$((COUNT + 1))
if [ $COUNT -le $KEEP ]; then
echo "Keeping: ${VERSION:0:12}..."
continue
fi
echo "Deleting: ${VERSION:0:12}..."
curl -s -X DELETE \
-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."

View File

@@ -1,4 +1,3 @@
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
name: Make Release
on:
@@ -6,10 +5,6 @@ on:
tags:
- 'v*'
permissions:
contents: write
packages: write
jobs:
release:
runs-on: ubuntu-latest
@@ -20,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 GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Gitea Container Registry
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
@@ -38,4 +31,7 @@ jobs:
version: '~> v2'
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
REGISTRY_URL: ${{ secrets.REGISTRY_URL }}
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
GORELEASER_FORCE_TOKEN: gitea

View File

@@ -1,10 +1,19 @@
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
name: Run Tests
on:
pull_request:
paths-ignore:
- 'docs/**'
- 'assets/img/**'
- 'README.md'
- 'LICENSE.md'
push:
branches: [master]
paths-ignore:
- 'docs/**'
- 'assets/img/**'
- 'README.md'
- 'LICENSE.md'
jobs:
test:

1
.gitignore vendored
View File

@@ -1,4 +1,5 @@
/.vscode/
/.ki-workspace/
/dist/
.env

View File

@@ -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:
@@ -39,9 +46,7 @@ upx:
compress: best
lzma: true
dockers:
- dockerfile: Containerfile
- dockerfile: Dockerfile
image_templates:
- "ghcr.io/marco98/mailcow-birthday-daemon:{{ .Major }}"
- "ghcr.io/marco98/mailcow-birthday-daemon:{{ .Major }}.{{ .Minor }}"
- "ghcr.io/marco98/mailcow-birthday-daemon:{{ .Major }}.{{ .Minor }}.{{ .Patch }}"
- "ghcr.io/marco98/mailcow-birthday-daemon:latest"
- "git.techniverse.net/scriptos/mailcow-birthday-daemon:{{ .Major }}.{{ .Minor }}.{{ .Patch }}"
- "git.techniverse.net/scriptos/mailcow-birthday-daemon:latest"

View File

@@ -6,6 +6,7 @@ repos:
rev: v6.0.0
hooks:
- id: trailing-whitespace
exclude: LICENSE\.md
- id: end-of-file-fixer
- id: check-yaml
stages: [pre-commit]

View File

@@ -1,9 +0,0 @@
FROM scratch
LABEL org.opencontainers.image.source https://github.com/Marco98/mailcow-birthday-daemon
ENTRYPOINT ["/mailcow-birthday-daemon"]
ENV STATEFILE=/data/state.json
VOLUME [ "/data" ]
COPY mailcow-birthday-daemon /mailcow-birthday-daemon

16
Dockerfile Normal file
View File

@@ -0,0 +1,16 @@
FROM alpine:latest AS certs
RUN apk add --no-cache ca-certificates
FROM scratch
LABEL org.opencontainers.image.source https://git.techniverse.net/scriptos/mailcow-birthday-daemon
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

View File

@@ -1,6 +1,25 @@
MIT License
Copyright (c) 2025 Marco Steiger
Copyright © 2026 Patrick Asmus
---
Lizenzinhaber
Name: Patrick Asmus (scriptos)
Email: support@techniverse.net
Website: https://www.patrick-asmus.de
Blog: https://www.cleveradmin.de
---
Ursprüngliches Projekt
Dieses Projekt basiert auf der Arbeit von Marco Steiger (Marco98).
Original-Repository: https://github.com/Marco98/mailcow-birthday-daemon
Original-Lizenz: MIT License, Copyright (c) 2025 Marco Steiger
---
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@@ -1,36 +1,73 @@
# mailcow-birthday-daemon
# Mailcow Birthday Daemon 🎂
Very simple daemon that generates and synchronizes a Birthday Calendar for every Mailcow mailbox.
> **Fork-Hinweis:** Dieses Projekt ist ein Fork von [Marco98/mailcow-birthday-daemon](https://github.com/Marco98/mailcow-birthday-daemon) und wird hier eigenständig weiterentwickelt.
No user action is required. Everything is handled automatically.
> **Hinweis zur Entwicklung:** Dieses Projekt wird mit Unterstützung von KI-gestützten Entwicklungswerkzeugen gepflegt. Go ist nicht meine primäre Sprache umso wichtiger sind sauberer Code, Tests und transparente Entwicklung.
## Installation
Ein einfacher Daemon, der automatisch einen Geburtstagskalender für jede Mailcow-Mailbox erzeugt und synchronisiert. Es ist kein Benutzereingriff erforderlich alles läuft vollautomatisch.
Just add it to the `docker-compose.override.yml`:
![Kalenderansicht](assets/img/kalenderansicht.png)
## Kurzübersicht
- Liest Geburtstage aus allen CardDAV-Adressbüchern jeder Mailbox
- Erstellt und synchronisiert automatisch einen Geburtstagskalender pro Benutzer
- Synchronisation alle **15 Minuten**
- Läuft als Docker-Container direkt im Mailcow-Stack
## Schnellstart
Den folgenden Abschnitt in eine `docker-compose.override.yml` im Mailcow-Verzeichnis (z. B. `/opt/mailcow-dockerized`) einfügen:
> **Wichtig:** Da `mailcow-dockerized` die eigene `docker-compose.yml` bei Updates überschreibt, müssen eigene Anpassungen immer in der `docker-compose.override.yml` erfolgen. Docker Compose lädt diese Datei automatisch und mergt sie mit der Hauptkonfiguration eigene Änderungen gehen dadurch bei Mailcow-Updates nicht verloren.
```yaml
services:
birthdaydaemon:
image: ghcr.io/marco98/mailcow-birthday-daemon:0.1.0
image: git.techniverse.net/scriptos/mailcow-birthday-daemon:latest
restart: always
depends_on:
- nginx-mailcow
networks:
- mailcow-network
environment:
- MAILCOW_BASE=https://mailcow.host
- MAILCOW_APIKEY=YOUR-APIKEY-HERE
- MAILCOW_BASE=https://mail.example.com
- MAILCOW_APIKEY=DEIN-APIKEY-HIER
volumes:
- birthdaydaemon:/data
- birthdaydaemon:/data
volumes:
birthdaydaemon:
```
The API-Key can be obtained in the admin panel at Configuration > Access > Edit administrator details > API > Read-Write Access
> **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).
As the Mailcow API does not seem to be complete and looks more like a early access, i would strongly advice against enabling "Skip IP check for API".
Anschließend starten:
## How it works
```bash
cd /opt/mailcow-dockerized
docker compose up -d
```
- Via the mailcow API a app password with access to carddav and caldav in generated for every user
- As every app password in mailcow gets a global autoincrementing number, the app passwords are kept and saved to disk to avoid massively increasing this number
- All contacts of all address books are fetched and the birthday information is extracted per user
- The resulting events in the calendar are calculated in advance.
- currently hardcoded to: 1 year in past; 10 years in future
- Isolated per mailbox of course. A user will only see birthdays of his own contacts.
- The calculated events will get synchronized to a calendar in every mailbox called "Birthdays" (display name can be renamed by user in SOGo)
> 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).
<p align="center">
<img src="https://assets.techniverse.net/f1/git/graphics/gray0-catonline.svg" alt="">
</p>
<p align="center">
<img src="https://assets.techniverse.net/f1/logos/small/license.png" alt="License" width="15" height="15"> <a href="./LICENSE">License</a> | <img src="https://assets.techniverse.net/f1/logos/small/matrix2.svg" alt="Matrix" width="15" height="15"> <a href="https://matrix.to/#/#community:techniverse.net">Matrix</a> | <img src="https://assets.techniverse.net/f1/logos/small/mastodon2.svg" alt="Mastodon" width="15" height="15"> <a href="https://social.techniverse.net/@donnerwolke">Mastodon</a>
</p>

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

View File

@@ -16,9 +16,65 @@ import (
)
const (
ConstCalendarName = "Birthdays"
ConstProductID = "-//scriptos//MailcowBirthdayDaemon//EN"
)
// cleanupOldCalendar prüft, ob ein alter Daemon-Kalender existiert, und löscht
// 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 {
endpoint, err := url.JoinPath(d.baseURL, "SOGo/dav", user, "Calendar/")
if err != nil {
return err
}
cl, err := caldav.NewClient(httpClient, endpoint)
if err != nil {
return err
}
cc, err := cl.FindCalendars(ctx, "")
if err != nil {
return err
}
found := false
for _, c := range cc {
if strings.HasSuffix(c.Path, fmt.Sprintf("/%s", oldName)) {
found = true
break
}
}
if !found {
return nil
}
calendarPath := fmt.Sprintf("/SOGo/dav/%s/Calendar/%s", user, oldName)
events, err := cl.QueryCalendar(ctx, calendarPath, &caldav.CalendarQuery{
CompRequest: caldav.CalendarCompRequest{
Name: "VCALENDAR",
},
CompFilter: caldav.CompFilter{
Name: "VCALENDAR",
Comps: []caldav.CompFilter{{
Name: "VEVENT",
}},
},
})
if err != nil {
return err
}
for _, ev := range events {
prodID := ev.Data.Props.Get(ical.PropProductID)
if prodID == nil || prodID.Value != ConstProductID {
slog.WarnContext(ctx, "old calendar contains foreign events, skipping cleanup",
"user", user, "calendar", oldName)
return nil
}
}
if err := cl.RemoveAll(ctx, calendarPath); err != nil {
return fmt.Errorf("error removing old calendar: %w", err)
}
slog.InfoContext(ctx, "removed old birthday calendar", "user", user, "calendar", oldName)
return nil
}
func (d *Daemon) ensureBirthdayCal(ctx context.Context, httpClient webdav.HTTPClient, user string) error {
endpoint, err := url.JoinPath(d.baseURL, "SOGo/dav", user, "Calendar/")
if err != nil {
@@ -33,11 +89,11 @@ func (d *Daemon) ensureBirthdayCal(ctx context.Context, httpClient webdav.HTTPCl
return err
}
for _, c := range cc {
if strings.HasSuffix(c.Path, fmt.Sprintf("/%s", ConstCalendarName)) {
if strings.HasSuffix(c.Path, fmt.Sprintf("/%s", d.calendarName)) {
return nil
}
}
if err := cl.Mkdir(ctx, ConstCalendarName); err != nil {
if err := cl.Mkdir(ctx, d.calendarName); err != nil {
return err
}
slog.InfoContext(ctx, "created birthday calendar", "user", user)
@@ -53,7 +109,7 @@ func (d *Daemon) syncBirthdaysToCal(ctx context.Context, httpClient webdav.HTTPC
if err != nil {
return err
}
calendarPath := fmt.Sprintf("/SOGo/dav/%s/Calendar/%s", user, ConstCalendarName)
calendarPath := fmt.Sprintf("/SOGo/dav/%s/Calendar/%s", user, d.calendarName)
events, err := cl.QueryCalendar(ctx, calendarPath, &caldav.CalendarQuery{
CompRequest: caldav.CalendarCompRequest{
Name: "VCALENDAR",
@@ -77,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
}
@@ -98,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
@@ -137,34 +193,64 @@ 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, "-//Marco98//MailcowBirthdayDaemon//EN")
cal.Props.SetText(ical.PropProductID, ConstProductID)
cal.Props.SetText(ical.PropVersion, "2.0")
event := ical.NewComponent(ical.CompEvent)
event.Props.SetText(ical.PropUID, id)
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
}

80
cmd/mcbdd/health.go Normal file
View File

@@ -0,0 +1,80 @@
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
)
// maxSyncAge ist die maximale Dauer seit dem letzten Sync-Lauf, bevor der
// Healthcheck den Daemon als unhealthy meldet. Da der Sync alle 15 Minuten
// läuft, erlauben wir 20 Minuten Toleranz.
const maxSyncAge = 20 * 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
}

View File

@@ -36,7 +36,7 @@ func (d *Daemon) getUserPass(ctx context.Context, username string) (string, erro
slog.InfoContext(ctx, "created new app password", "user", username)
d.userTokensLock.Lock()
d.userTokens[username] = pass
d.userTokensUnsaved = true
d.stateUnsaved = true
d.userTokensLock.Unlock()
return pass, nil
}

View File

@@ -4,12 +4,14 @@ import (
"context"
"fmt"
"log/slog"
"net"
"net/http"
"os"
"strings"
"sync"
"time"
"github.com/Marco98/mailcow-birthday-daemon/pkg/mailcow"
"git.techniverse.net/scriptos/mailcow-birthday-daemon/pkg/mailcow"
"github.com/emersion/go-webdav"
)
@@ -24,16 +26,37 @@ var (
)
type Daemon struct {
userTokens map[string]string
userTokensLock *sync.RWMutex
userTokensUnsaved bool
httpClient *http.Client
baseURL string
mailcowClient mailcow.Client
statefile 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
health *healthState
}
func main() {
if len(os.Args) > 1 {
switch os.Args[1] {
case "cleanup":
if err := runCleanup(); err != nil {
slog.Error("cleanup failed", "err", err)
os.Exit(1)
}
return
case "healthcheck":
if err := runHealthcheck(); err != nil {
slog.Error("healthcheck failed", "err", err)
os.Exit(1)
}
return
}
}
if err := run(); err != nil {
slog.Error("fatal error", "err", err)
os.Exit(1)
@@ -42,26 +65,59 @@ func main() {
func run() error {
slog.Info("starting mcbdd", "version", version, "commit", commit, "date", date)
// 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)
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"
}
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: os.Getenv("MAILCOW_BASE"),
statefile: os.Getenv("STATEFILE"),
httpClient: &http.Client{
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
},
},
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.statefile) == 0 {
d.statefile = "state.json"
if len(d.stateFilepath) == 0 {
d.stateFilepath = "state.json"
}
d.health = newHealthState(d.stateFilepath)
d.mailcowClient = mailcow.New(
d.httpClient,
d.baseURL,
os.Getenv("MAILCOW_APIKEY"),
mailcowBase,
mailcowAPIKey,
)
if err := d.LoadFromDisk(); err != nil {
if err := d.loadState(); err != nil {
return err
}
d.daemonLoop()
@@ -70,7 +126,9 @@ 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)
@@ -92,12 +150,13 @@ func (d *Daemon) daemonRun() error {
})
}
eg.Wait()
if d.userTokensUnsaved {
d.oldCalendarName = ""
if d.stateUnsaved {
slog.Info("saving tokens to disk", "count", len(d.userTokens))
if err := d.SaveToDisk(); err != nil {
if err := d.saveState(); err != nil {
return err
}
d.userTokensUnsaved = false
d.stateUnsaved = false
}
return nil
}
@@ -111,8 +170,20 @@ func (d *Daemon) processUser(ctx context.Context, m mailcow.Mailbox) error {
return fmt.Errorf("error getting userpass: %w", err)
}
davclient := webdav.HTTPClientWithBasicAuth(d.httpClient, m.Username, pass)
if 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)
}
}
bb, err := d.getBirthdays(ctx, davclient, m.Username)
if err != nil {
if strings.HasPrefix(err.Error(), "401 Unauthorized: ") {
slog.WarnContext(ctx, "user password seems to be invalid and will be discarded", "user", m.Username)
d.userTokensLock.Lock()
delete(d.userTokens, m.Username)
d.stateUnsaved = true
d.userTokensLock.Unlock()
}
return fmt.Errorf("error getting birthdays from carddav: %w", err)
}
if err := d.ensureBirthdayCal(ctx, davclient, m.Username); err != nil {
@@ -123,3 +194,102 @@ 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
// Zertifikatsprüfung den Original-Hostnamen aus der URL verwenden.
// Damit wird das Hairpin-NAT-Problem in Docker-Netzen umgangen.
func buildTransport() *http.Transport {
resolveHost := os.Getenv("MAILCOW_RESOLVE_HOST")
t := &http.Transport{
Proxy: http.ProxyFromEnvironment,
}
if resolveHost != "" {
slog.Info("using internal resolve host for connections", "resolveHost", resolveHost)
dialer := &net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}
t.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
_, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
addr = net.JoinHostPort(resolveHost, port)
return dialer.DialContext(ctx, network, addr)
}
}
return t
}

View File

@@ -1,13 +1,91 @@
package main
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
)
func (d *Daemon) LoadFromDisk() error {
f, err := os.OpenFile(d.statefile, os.O_RDONLY, 0o660)
func (d *Daemon) loadState() error {
stateVer := struct {
Version int `json:"version"`
}{}
if err := d.loadFromDisk(&stateVer); err != nil {
return fmt.Errorf("cant detect state version: %w", err)
}
var storedCalendarName string
switch stateVer.Version {
case 0:
slog.Warn("loading old state version", "stateVer", stateVer.Version)
if err := d.loadFromDisk(&d.userTokens); err != nil {
return fmt.Errorf("cant load state v%d: %w", stateVer.Version, err)
}
d.stateUnsaved = true
case 1:
state := struct {
Version int `json:"version"`
UserTokens map[string]string `json:"userTokens"`
}{}
if err := d.loadFromDisk(&state); err != nil {
return fmt.Errorf("cant load state v%d: %w", stateVer.Version, err)
}
for k, v := range state.UserTokens {
dec, err := base64.StdEncoding.DecodeString(v)
if err != nil {
return fmt.Errorf("cant decode pass from %s: %w", k, err)
}
d.userTokens[k] = string(dec)
}
d.stateUnsaved = true
case 2:
state := struct {
Version int `json:"version"`
UserTokens map[string]string `json:"userTokens"`
CalendarName string `json:"calendarName"`
}{}
if err := d.loadFromDisk(&state); err != nil {
return fmt.Errorf("cant load state v%d: %w", stateVer.Version, err)
}
for k, v := range state.UserTokens {
dec, err := base64.StdEncoding.DecodeString(v)
if err != nil {
return fmt.Errorf("cant decode pass from %s: %w", k, err)
}
d.userTokens[k] = string(dec)
}
storedCalendarName = state.CalendarName
}
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
}
return nil
}
func (d *Daemon) saveState() error {
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: 2,
UserTokens: encTokens,
CalendarName: d.calendarName,
}
return d.saveToDisk(state)
}
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) {
return nil
@@ -15,14 +93,14 @@ func (d *Daemon) LoadFromDisk() error {
return err
}
defer f.Close()
return json.NewDecoder(f).Decode(&d.userTokens)
return json.NewDecoder(f).Decode(state)
}
func (d *Daemon) SaveToDisk() error {
f, err := os.OpenFile(d.statefile, os.O_CREATE|os.O_WRONLY, 0o660)
func (d *Daemon) saveToDisk(state any) error {
f, err := os.OpenFile(d.stateFilepath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o660)
if err != nil {
return err
}
defer f.Close()
return json.NewEncoder(f).Encode(d.userTokens)
return json.NewEncoder(f).Encode(state)
}

View File

@@ -65,3 +65,32 @@ func sanitizeBirthday(input string) (uint16, uint16, uint16, error) {
}
return 0, 0, 0, fmt.Errorf("birthday prop format unknown: %s", input)
}
// parseNotificationTrigger konvertiert eine Uhrzeit im Format "HH:MM" in eine
// iCal-Duration (z.B. "PT8H", "PT9H30M"), die als VALARM-Trigger relativ zum
// Start eines Ganztags-Events (Mitternacht) verwendet wird.
func parseNotificationTrigger(timeStr string) (string, error) {
parts := strings.Split(timeStr, ":")
if len(parts) != 2 {
return "", fmt.Errorf("invalid time format: %s (expected HH:MM)", timeStr)
}
hours, err := strconv.Atoi(parts[0])
if err != nil || hours < 0 || hours > 23 {
return "", fmt.Errorf("invalid hours in time: %s", timeStr)
}
minutes, err := strconv.Atoi(parts[1])
if err != nil || minutes < 0 || minutes > 59 {
return "", fmt.Errorf("invalid minutes in time: %s", timeStr)
}
if hours == 0 && minutes == 0 {
return "PT0S", nil
}
trigger := "PT"
if hours > 0 {
trigger += fmt.Sprintf("%dH", hours)
}
if minutes > 0 {
trigger += fmt.Sprintf("%dM", minutes)
}
return trigger, nil
}

18
docs/README.md Normal file
View File

@@ -0,0 +1,18 @@
# Dokumentation
Willkommen in der Dokumentation des **Mailcow Birthday Daemon** 🎂
## Inhaltsverzeichnis
- [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

41
docs/funktionsweise.md Normal file
View File

@@ -0,0 +1,41 @@
# 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.
## 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 20 Minuten zurückliegt. Während der Startphase (bevor der erste Sync abgeschlossen ist) gilt der Daemon als healthy.

87
docs/schnellstart.md Normal file
View File

@@ -0,0 +1,87 @@
# Schnellstart
## Voraussetzungen
- Eine laufende [Mailcow](https://mailcow.email/)-Instanz mit Docker Compose
- Ein API-Key mit **Lese-/Schreibzugriff** (Admin-Panel → Konfiguration → Zugang → Administratordetails bearbeiten → API)
## Installation
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:
birthdaydaemon:
image: git.techniverse.net/scriptos/mailcow-birthday-daemon:latest
restart: always
depends_on:
- nginx-mailcow
networks:
- mailcow-network
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
volumes:
- birthdaydaemon:/data
volumes:
birthdaydaemon:
```
> **Wichtig:** `mail.example.com` muss durch den tatsächlichen FQDN der eigenen Mailcow-Instanz ersetzt werden.
> **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 `: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 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 |
|---|---|---|---|
| `MAILCOW_BASE` | **Ja** | | Basis-URL der Mailcow-Instanz (z. B. `https://mailcow.example.com`) |
| `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.
![API-Key erstellen](../assets/img/apikey-erstellen.png)
> **Warnung:** Da die Mailcow-API derzeit nicht vollständig ist und sich eher im Early-Access-Stadium befindet, wird dringend davon abgeraten, die Option „IP-Prüfung für API überspringen" zu aktivieren. (siehe Bild)
## Prüfen, ob alles läuft
```bash
cd /opt/mailcow-dockerized
docker compose logs -f birthdaydaemon
```
Nach dem Start synchronisiert der Daemon automatisch alle 15 Minuten die Geburtstagskalender für jede Mailbox.
> **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).
> **Bei Problemen:** Siehe [Troubleshooting](troubleshooting.md).
> **Wie funktioniert der Daemon im Detail?** Siehe [Funktionsweise](funktionsweise.md).

55
docs/troubleshooting.md Normal file
View File

@@ -0,0 +1,55 @@
# 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`).
## 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.

50
docs/update.md Normal file
View File

@@ -0,0 +1,50 @@
# 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:
```bash
cd /opt/mailcow-dockerized
docker compose pull birthdaydaemon
docker compose up -d birthdaydaemon
```
## Auf eine bestimmte Version wechseln
1. Die gewünschte Version in der [Container Registry](https://git.techniverse.net/scriptos/-/packages/container/mailcow-birthday-daemon) auswählen.
2. Den Image-Tag in der `docker-compose.override.yml` anpassen:
```yaml
image: git.techniverse.net/scriptos/mailcow-birthday-daemon:1.0.0
```
3. Container neu starten:
```bash
docker compose up -d birthdaydaemon
```
## Hinweise
- 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
```

2
go.mod
View File

@@ -1,4 +1,4 @@
module github.com/Marco98/mailcow-birthday-daemon
module git.techniverse.net/scriptos/mailcow-birthday-daemon
go 1.25.4