5 Commits

Author SHA1 Message Date
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
6 changed files with 94 additions and 22 deletions

2
.env.example Normal file
View File

@@ -0,0 +1,2 @@
MAILCOW_BASE=https://mailcow.host
MAILCOW_APIKEY=YOUR-APIKEY-HERE

View File

@@ -1,4 +1,4 @@
# mailcow-birthday-daemon
# Mailcow Birthday Daemon 🎂
Very simple daemon that generates and synchronizes a Birthday Calendar for every Mailcow mailbox.
@@ -12,6 +12,7 @@ Just add it to the `docker-compose.override.yml`:
services:
birthdaydaemon:
image: ghcr.io/marco98/mailcow-birthday-daemon:0.1.0
restart: always
environment:
- MAILCOW_BASE=https://mailcow.host
- MAILCOW_APIKEY=YOUR-APIKEY-HERE

10
Taskfile.yml Normal file
View File

@@ -0,0 +1,10 @@
# https://taskfile.dev
version: '3'
dotenv:
- .env
tasks:
run:
cmds:
- go run ./cmd/mcbdd

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

@@ -6,6 +6,7 @@ import (
"log/slog"
"net/http"
"os"
"strings"
"sync"
"time"
@@ -24,13 +25,13 @@ 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
}
func main() {
@@ -46,22 +47,22 @@ func run() error {
userTokens: make(map[string]string),
userTokensLock: &sync.RWMutex{},
baseURL: os.Getenv("MAILCOW_BASE"),
statefile: os.Getenv("STATEFILE"),
stateFilepath: os.Getenv("STATEFILE"),
httpClient: &http.Client{
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
},
},
}
if len(d.statefile) == 0 {
d.statefile = "state.json"
if len(d.stateFilepath) == 0 {
d.stateFilepath = "state.json"
}
d.mailcowClient = mailcow.New(
d.httpClient,
d.baseURL,
os.Getenv("MAILCOW_APIKEY"),
)
if err := d.LoadFromDisk(); err != nil {
if err := d.loadState(); err != nil {
return err
}
d.daemonLoop()
@@ -92,12 +93,12 @@ func (d *Daemon) daemonRun() error {
})
}
eg.Wait()
if d.userTokensUnsaved {
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
}
@@ -113,6 +114,13 @@ func (d *Daemon) processUser(ctx context.Context, m mailcow.Mailbox) error {
davclient := webdav.HTTPClientWithBasicAuth(d.httpClient, m.Username, pass)
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 {

View File

@@ -1,13 +1,64 @@
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)
}
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)
}
}
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"`
}{
Version: 1,
UserTokens: encTokens,
}
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 +66,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)
}