Compare commits
29 Commits
v0.1.1
...
release-v0
| Author | SHA1 | Date | |
|---|---|---|---|
| 6757362d8c | |||
| f4c92fa537 | |||
| 2498dee918 | |||
| ebe7e50691 | |||
| c6c22b1c62 | |||
| 1c70eb6643 | |||
| bde20643b0 | |||
| f0ba2ad36f | |||
| 27f17fa45e | |||
| f24047147b | |||
| 6db5113755 | |||
| be77b36596 | |||
| 2a6d9b2543 | |||
| e4e2f83d0a | |||
| c000664165 | |||
| 838e702cf0 | |||
| 28285b254e | |||
| fa2829811b | |||
| f70d089b5f | |||
| 3df0ab8de7 | |||
| 2900e5267c | |||
| f9bc388276 | |||
| aad37e3c40 | |||
| ceb5002bd6 | |||
| 4c496eee34 | |||
| adec0db9a1 | |||
|
|
3076c431e1 | ||
|
|
d9de8fa7a4 | ||
|
|
296815ee70 |
16
.env.example
16
.env.example
@@ -1,2 +1,16 @@
|
||||
# Basis-URL der Mailcow-Instanz
|
||||
MAILCOW_BASE=https://mailcow.host
|
||||
MAILCOW_APIKEY=YOUR-APIKEY-HERE
|
||||
|
||||
# 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) Pfad zur Zustandsdatei – Standard: state.json / im Container: /data/state.json
|
||||
# STATEFILE=/data/state.json
|
||||
|
||||
72
.gitea/workflows/docker-test.yaml
Normal file
72
.gitea/workflows/docker-test.yaml
Normal 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."
|
||||
@@ -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
|
||||
@@ -24,12 +19,10 @@ jobs:
|
||||
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,6 @@ jobs:
|
||||
version: '~> v2'
|
||||
args: release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||
GORELEASER_FORCE_TOKEN: gitea
|
||||
@@ -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
1
.gitignore
vendored
@@ -1,4 +1,5 @@
|
||||
/.vscode/
|
||||
/.ki-workspace/
|
||||
/dist/
|
||||
|
||||
.env
|
||||
|
||||
@@ -26,6 +26,9 @@ checksum:
|
||||
snapshot:
|
||||
name_template: "{{ incpatch .Version }}-next"
|
||||
release:
|
||||
gitea:
|
||||
owner: "{{ .Env.REGISTRY_USER }}"
|
||||
name: mailcow-birthday-daemon
|
||||
prerelease: auto
|
||||
changelog:
|
||||
sort: asc
|
||||
@@ -39,9 +42,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"
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
13
Dockerfile
Normal file
13
Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
||||
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" ]
|
||||
|
||||
COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
||||
COPY mailcow-birthday-daemon /mailcow-birthday-daemon
|
||||
21
LICENSE.md
21
LICENSE.md
@@ -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
|
||||
|
||||
50
README.md
50
README.md
@@ -1,37 +1,51 @@
|
||||
# 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`:
|
||||

|
||||
|
||||
## 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
|
||||
|
||||
```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
|
||||
> Alle verfügbaren Image-Tags sind in der [Container Registry](https://git.techniverse.net/scriptos/-/packages/container/mailcow-birthday-daemon) einsehbar.
|
||||
|
||||
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".
|
||||
## Dokumentation
|
||||
|
||||
## How it works
|
||||
Die vollständige Dokumentation befindet sich im Ordner [`docs/`](docs/README.md).
|
||||
|
||||
- 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)
|
||||
<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>
|
||||
|
||||
10
Taskfile.yml
10
Taskfile.yml
@@ -1,10 +0,0 @@
|
||||
# https://taskfile.dev
|
||||
version: '3'
|
||||
|
||||
dotenv:
|
||||
- .env
|
||||
|
||||
tasks:
|
||||
run:
|
||||
cmds:
|
||||
- go run ./cmd/mcbdd
|
||||
BIN
assets/img/kalenderansicht.png
Normal file
BIN
assets/img/kalenderansicht.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 89 KiB |
@@ -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",
|
||||
@@ -153,7 +209,7 @@ func icalMatchesBev(ic *ical.Component, bev birthdayEvent) bool {
|
||||
func (bev birthdayEvent) generateICAL(calendar 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)
|
||||
|
||||
@@ -4,13 +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"
|
||||
)
|
||||
|
||||
@@ -25,13 +26,15 @@ var (
|
||||
)
|
||||
|
||||
type Daemon struct {
|
||||
httpClient *http.Client
|
||||
baseURL string
|
||||
mailcowClient mailcow.Client
|
||||
userTokens map[string]string
|
||||
userTokensLock *sync.RWMutex
|
||||
stateFilepath string
|
||||
stateUnsaved bool
|
||||
httpClient *http.Client
|
||||
baseURL string
|
||||
mailcowClient mailcow.Client
|
||||
userTokens map[string]string
|
||||
userTokensLock *sync.RWMutex
|
||||
stateFilepath string
|
||||
stateUnsaved bool
|
||||
calendarName string
|
||||
oldCalendarName string
|
||||
}
|
||||
|
||||
func main() {
|
||||
@@ -43,24 +46,40 @@ 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"
|
||||
}
|
||||
d := &Daemon{
|
||||
userTokens: make(map[string]string),
|
||||
userTokensLock: &sync.RWMutex{},
|
||||
baseURL: os.Getenv("MAILCOW_BASE"),
|
||||
baseURL: mailcowBase,
|
||||
stateFilepath: os.Getenv("STATEFILE"),
|
||||
httpClient: &http.Client{
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
},
|
||||
},
|
||||
httpClient: &http.Client{Transport: buildTransport()},
|
||||
calendarName: calendarName,
|
||||
}
|
||||
if len(d.stateFilepath) == 0 {
|
||||
d.stateFilepath = "state.json"
|
||||
}
|
||||
d.mailcowClient = mailcow.New(
|
||||
d.httpClient,
|
||||
d.baseURL,
|
||||
os.Getenv("MAILCOW_APIKEY"),
|
||||
mailcowBase,
|
||||
mailcowAPIKey,
|
||||
)
|
||||
if err := d.loadState(); err != nil {
|
||||
return err
|
||||
@@ -93,6 +112,7 @@ func (d *Daemon) daemonRun() error {
|
||||
})
|
||||
}
|
||||
eg.Wait()
|
||||
d.oldCalendarName = ""
|
||||
if d.stateUnsaved {
|
||||
slog.Info("saving tokens to disk", "count", len(d.userTokens))
|
||||
if err := d.saveState(); err != nil {
|
||||
@@ -112,6 +132,11 @@ 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: ") {
|
||||
@@ -131,3 +156,31 @@ func (d *Daemon) processUser(ctx context.Context, m mailcow.Mailbox) error {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ func (d *Daemon) loadState() error {
|
||||
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)
|
||||
@@ -38,6 +39,30 @@ func (d *Daemon) loadState() error {
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -48,11 +73,13 @@ func (d *Daemon) saveState() error {
|
||||
encTokens[k] = base64.StdEncoding.EncodeToString([]byte(v))
|
||||
}
|
||||
state := struct {
|
||||
Version int `json:"version"`
|
||||
UserTokens map[string]string `json:"userTokens"`
|
||||
Version int `json:"version"`
|
||||
UserTokens map[string]string `json:"userTokens"`
|
||||
CalendarName string `json:"calendarName"`
|
||||
}{
|
||||
Version: 1,
|
||||
UserTokens: encTokens,
|
||||
Version: 2,
|
||||
UserTokens: encTokens,
|
||||
CalendarName: d.calendarName,
|
||||
}
|
||||
return d.saveToDisk(state)
|
||||
}
|
||||
|
||||
8
docs/README.md
Normal file
8
docs/README.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# Dokumentation
|
||||
|
||||
Willkommen in der Dokumentation des **Mailcow Birthday Daemon** 🎂
|
||||
|
||||
## Inhaltsverzeichnis
|
||||
|
||||
- [Schnellstart](schnellstart.md) – Installation und erste Einrichtung
|
||||
- [Update](update.md) – Bestehende Installation aktualisieren
|
||||
82
docs/schnellstart.md
Normal file
82
docs/schnellstart.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# 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:
|
||||
|
||||
```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
|
||||
- MAILCOW_RESOLVE_HOST=nginx-mailcow
|
||||
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 `: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.
|
||||
|
||||
## Container starten
|
||||
|
||||
```bash
|
||||
cd /opt/mailcow-dockerized
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## 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 |
|
||||
| `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.
|
||||
|
||||
## Prüfen, ob alles läuft
|
||||
|
||||
```bash
|
||||
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).
|
||||
|
||||
## Funktionsweise
|
||||
|
||||
- Ü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.
|
||||
32
docs/update.md
Normal file
32
docs/update.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# Update
|
||||
|
||||
## 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).
|
||||
Reference in New Issue
Block a user