feat: inital commit
This commit is contained in:
41
.github/workflows/release.yaml
vendored
Normal file
41
.github/workflows/release.yaml
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
|
||||
name: Make Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
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: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v6
|
||||
with:
|
||||
distribution: goreleaser
|
||||
version: '~> v2'
|
||||
args: release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
30
.github/workflows/test.yaml
vendored
Normal file
30
.github/workflows/test.yaml
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
|
||||
name: Run Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [master]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v3
|
||||
|
||||
- name: Run Pre-Commit
|
||||
uses: pre-commit/action@v3.0.1
|
||||
|
||||
- name: Run Go Tests
|
||||
run: go test ./...
|
||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
/.vscode/
|
||||
/dist/
|
||||
|
||||
.env
|
||||
state.json
|
||||
29
.golangci.yml
Normal file
29
.golangci.yml
Normal file
@@ -0,0 +1,29 @@
|
||||
version: "2"
|
||||
run:
|
||||
modules-download-mode: readonly
|
||||
linters:
|
||||
enable:
|
||||
- gocritic
|
||||
- gosec
|
||||
- revive
|
||||
exclusions:
|
||||
generated: lax
|
||||
presets:
|
||||
- comments
|
||||
- common-false-positives
|
||||
- legacy
|
||||
- std-error-handling
|
||||
paths:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
formatters:
|
||||
enable:
|
||||
- gci
|
||||
- gofumpt
|
||||
exclusions:
|
||||
generated: lax
|
||||
paths:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
47
.goreleaser.yaml
Normal file
47
.goreleaser.yaml
Normal file
@@ -0,0 +1,47 @@
|
||||
# yaml-language-server: $schema=https://goreleaser.com/static/schema.json
|
||||
version: 2
|
||||
builds:
|
||||
- main: ./cmd/mcbdd
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
goos:
|
||||
- linux
|
||||
- windows
|
||||
goarch:
|
||||
- amd64
|
||||
archives:
|
||||
- format: tar.gz
|
||||
name_template: >-
|
||||
{{ .ProjectName }}_
|
||||
{{- title .Os }}_
|
||||
{{- if eq .Arch "amd64" }}x86_64
|
||||
{{- else if eq .Arch "386" }}i386
|
||||
{{- else }}{{ .Arch }}{{ end }}
|
||||
{{- if .Arm }}v{{ .Arm }}{{ end }}
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
format: zip
|
||||
checksum:
|
||||
name_template: "checksums.txt"
|
||||
snapshot:
|
||||
name_template: "{{ incpatch .Version }}-next"
|
||||
release:
|
||||
prerelease: auto
|
||||
changelog:
|
||||
sort: asc
|
||||
filters:
|
||||
exclude:
|
||||
- "^docs:"
|
||||
- "^test:"
|
||||
upx:
|
||||
- enabled: true
|
||||
goos: [linux]
|
||||
compress: best
|
||||
lzma: true
|
||||
dockers:
|
||||
- dockerfile: Containerfile
|
||||
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"
|
||||
31
.pre-commit-config.yaml
Normal file
31
.pre-commit-config.yaml
Normal file
@@ -0,0 +1,31 @@
|
||||
# yaml-language-server: $schema=https://json.schemastore.org/pre-commit-config.json
|
||||
fail_fast: true
|
||||
default_install_hook_types: [pre-commit, commit-msg]
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v6.0.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
- id: end-of-file-fixer
|
||||
- id: check-yaml
|
||||
stages: [pre-commit]
|
||||
- id: check-added-large-files
|
||||
|
||||
- repo: https://github.com/compilerla/conventional-pre-commit
|
||||
rev: v4.3.0
|
||||
hooks:
|
||||
- id: conventional-pre-commit
|
||||
stages: [commit-msg]
|
||||
args: []
|
||||
|
||||
- repo: https://github.com/golangci/golangci-lint
|
||||
rev: v2.6.1
|
||||
hooks:
|
||||
- id: golangci-lint
|
||||
stages: [pre-commit]
|
||||
|
||||
- repo: https://github.com/trufflesecurity/trufflehog
|
||||
rev: v3.90.13
|
||||
hooks:
|
||||
- id: trufflehog
|
||||
stages: [pre-commit]
|
||||
9
Containerfile
Normal file
9
Containerfile
Normal file
@@ -0,0 +1,9 @@
|
||||
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
|
||||
21
LICENSE.md
Normal file
21
LICENSE.md
Normal file
@@ -0,0 +1,21 @@
|
||||
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
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
36
README.md
Normal file
36
README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# mailcow-birthday-daemon
|
||||
|
||||
Very simple daemon that generates and synchronizes a Birthday Calendar for every Mailcow mailbox.
|
||||
|
||||
No user action is required. Everything is handled automatically.
|
||||
|
||||
## Installation
|
||||
|
||||
Just add it to the `docker-compose.override.yml`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
birthdaydaemon:
|
||||
image: ghcr.io/marco98/mailcow-birthday-daemon:0.1.0
|
||||
environment:
|
||||
- MAILCOW_BASE=https://mailcow.host
|
||||
- MAILCOW_APIKEY=YOUR-APIKEY-HERE
|
||||
volumes:
|
||||
- birthdaydaemon:/data
|
||||
volumes:
|
||||
birthdaydaemon:
|
||||
```
|
||||
|
||||
The API-Key can be obtained in the admin panel at Configuration > Access > Edit administrator details > API > Read-Write Access
|
||||
|
||||
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".
|
||||
|
||||
## How it works
|
||||
|
||||
- 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)
|
||||
170
cmd/mcbdd/caldav.go
Normal file
170
cmd/mcbdd/caldav.go
Normal file
@@ -0,0 +1,170 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-ical"
|
||||
"github.com/emersion/go-webdav"
|
||||
"github.com/emersion/go-webdav/caldav"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
ConstCalendarName = "Birthdays"
|
||||
)
|
||||
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
cl, err := caldav.NewClient(httpClient, endpoint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cc, err := cl.FindCalendars(ctx, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, c := range cc {
|
||||
if strings.HasSuffix(c.Path, fmt.Sprintf("/%s", ConstCalendarName)) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if err := cl.Mkdir(ctx, ConstCalendarName); err != nil {
|
||||
return err
|
||||
}
|
||||
slog.InfoContext(ctx, "created birthday calendar", "user", user)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Daemon) syncBirthdaysToCal(ctx context.Context, httpClient webdav.HTTPClient, user string, birthdays []BirthdayContact) 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
|
||||
}
|
||||
calendarPath := fmt.Sprintf("/SOGo/dav/%s/Calendar/%s", user, ConstCalendarName)
|
||||
events, err := cl.QueryCalendar(ctx, calendarPath, &caldav.CalendarQuery{
|
||||
CompRequest: caldav.CalendarCompRequest{
|
||||
Name: "VCALENDAR",
|
||||
},
|
||||
CompFilter: caldav.CompFilter{
|
||||
Name: "VCALENDAR",
|
||||
Comps: []caldav.CompFilter{{
|
||||
Name: "VEVENT",
|
||||
}},
|
||||
Start: time.Now().Add(time.Hour * 24 * 365 * -1).UTC(),
|
||||
End: time.Now().Add(time.Hour * 24 * 365 * 100).UTC(),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bevs := generateBirthdayEvents(birthdays)
|
||||
bevsInSync := make([]int, 0)
|
||||
driftedEvents := make([]string, 0)
|
||||
for _, ev := range events {
|
||||
matchedBev := false
|
||||
for _, v := range ev.Data.Children {
|
||||
for i, bev := range bevs {
|
||||
if icalMatchesBev(v, bev) {
|
||||
bevsInSync = append(bevsInSync, i)
|
||||
matchedBev = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !matchedBev {
|
||||
driftedEvents = append(driftedEvents, ev.Path)
|
||||
}
|
||||
}
|
||||
counterDelete, counterAdded := 0, 0
|
||||
for _, v := range driftedEvents {
|
||||
if err := cl.RemoveAll(ctx, v); err != nil {
|
||||
return err
|
||||
}
|
||||
counterDelete++
|
||||
}
|
||||
for i, v := range bevs {
|
||||
if slices.Contains(bevsInSync, i) {
|
||||
continue
|
||||
}
|
||||
p, ic := v.generateICAL(calendarPath)
|
||||
_, err := cl.PutCalendarObject(ctx, p, ic)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
counterAdded++
|
||||
}
|
||||
if (counterAdded + counterDelete) > 0 {
|
||||
slog.InfoContext(ctx, "synchronized birthday events", "user", user, "added", counterAdded, "removed", counterDelete)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type birthdayEvent struct {
|
||||
Summary string
|
||||
DateTimeStart string
|
||||
DateTimeEnd string
|
||||
}
|
||||
|
||||
func generateBirthdayEvents(birthdays []BirthdayContact) []birthdayEvent {
|
||||
cyear := time.Now().Year()
|
||||
bb := make([]birthdayEvent, 0)
|
||||
for _, v := range birthdays {
|
||||
for year := cyear; year <= 10+cyear; year++ {
|
||||
yearshift := year - v.Date.Year()
|
||||
ev := birthdayEvent{
|
||||
Summary: fmt.Sprintf("%s %s", v.GivenName, v.FamilyName),
|
||||
DateTimeStart: v.Date.AddDate(yearshift, 0, 0).Format("20060102"),
|
||||
DateTimeEnd: v.Date.AddDate(yearshift, 0, 1).Format("20060102"),
|
||||
}
|
||||
if v.YearKnown {
|
||||
ev.Summary = fmt.Sprintf("%s (%d)", ev.Summary, yearshift)
|
||||
}
|
||||
bb = append(bb, ev)
|
||||
}
|
||||
}
|
||||
return bb
|
||||
}
|
||||
|
||||
func icalMatchesBev(ic *ical.Component, bev birthdayEvent) 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 {
|
||||
return false
|
||||
}
|
||||
if ic.Props.Get(ical.PropDateTimeEnd) == nil || ic.Props.Get(ical.PropDateTimeEnd).Value != bev.DateTimeEnd {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
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.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.Value = bev.DateTimeStart
|
||||
end := ical.NewProp(ical.PropDateTimeEnd)
|
||||
end.Value = bev.DateTimeEnd
|
||||
event.Props.Set(start)
|
||||
event.Props.Set(end)
|
||||
cal.Children = append(cal.Children, event)
|
||||
return fmt.Sprintf("%s/%s.ics", calendar, id), cal
|
||||
}
|
||||
61
cmd/mcbdd/carddav.go
Normal file
61
cmd/mcbdd/carddav.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-vcard"
|
||||
"github.com/emersion/go-webdav"
|
||||
"github.com/emersion/go-webdav/carddav"
|
||||
)
|
||||
|
||||
type BirthdayContact struct {
|
||||
FamilyName string
|
||||
GivenName string
|
||||
Date time.Time
|
||||
YearKnown bool
|
||||
}
|
||||
|
||||
func (d *Daemon) getBirthdays(ctx context.Context, httpClient webdav.HTTPClient, user string) ([]BirthdayContact, error) {
|
||||
endpoint, err := url.JoinPath(d.baseURL, "SOGo/dav", user, "Contacts/")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cl, err := carddav.NewClient(httpClient, endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bb, err := cl.FindAddressBooks(ctx, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
contacts := make([]BirthdayContact, 0)
|
||||
for _, b := range bb {
|
||||
oo, err := cl.QueryAddressBook(ctx, b.Path, &carddav.AddressBookQuery{})
|
||||
if err != nil {
|
||||
if err.Error() == "501 Not Implemented" {
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
for _, v := range oo {
|
||||
nn := v.Card.Names()
|
||||
bdayprop := v.Card.Value(vcard.FieldBirthday)
|
||||
if len(nn) == 0 || len(bdayprop) == 0 {
|
||||
continue
|
||||
}
|
||||
yyyy, mm, dd, err := sanitizeBirthday(bdayprop)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
contacts = append(contacts, BirthdayContact{
|
||||
GivenName: v.Card.Names()[0].GivenName,
|
||||
FamilyName: v.Card.Names()[0].FamilyName,
|
||||
Date: time.Date(int(yyyy), time.Month(int(mm)), int(dd), 0, 0, 0, 0, time.UTC),
|
||||
YearKnown: yyyy != 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
return contacts, nil
|
||||
}
|
||||
42
cmd/mcbdd/mailcow.go
Normal file
42
cmd/mcbdd/mailcow.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
func (d *Daemon) getUserPass(ctx context.Context, username string) (string, error) {
|
||||
d.userTokensLock.RLock()
|
||||
pass, ok := d.userTokens[username]
|
||||
d.userTokensLock.RUnlock()
|
||||
if ok {
|
||||
return pass, nil
|
||||
}
|
||||
pp, err := d.mailcowClient.GetAppPasswords(ctx, username)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
oldIDs := make([]int, 0)
|
||||
for _, p := range pp {
|
||||
if p.Name == ConstUsertokenName {
|
||||
oldIDs = append(oldIDs, p.ID)
|
||||
}
|
||||
}
|
||||
if err := d.mailcowClient.DeleteAppPasswords(ctx, oldIDs); err != nil {
|
||||
return "", fmt.Errorf("error deleting app passwords: %w", err)
|
||||
}
|
||||
pass, err = randomPassword(32)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error generating password: %w", err)
|
||||
}
|
||||
if err := d.mailcowClient.CreateAppPassword(ctx, username, ConstUsertokenName, pass, "dav_access"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
slog.InfoContext(ctx, "created new app password", "user", username)
|
||||
d.userTokensLock.Lock()
|
||||
d.userTokens[username] = pass
|
||||
d.userTokensUnsaved = true
|
||||
d.userTokensLock.Unlock()
|
||||
return pass, nil
|
||||
}
|
||||
125
cmd/mcbdd/main.go
Normal file
125
cmd/mcbdd/main.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Marco98/mailcow-birthday-daemon/pkg/mailcow"
|
||||
"github.com/emersion/go-webdav"
|
||||
)
|
||||
|
||||
const (
|
||||
ConstUsertokenName = "Birthday Daemon"
|
||||
)
|
||||
|
||||
var (
|
||||
version = "dev"
|
||||
commit = "none"
|
||||
date = "unknown"
|
||||
)
|
||||
|
||||
type Daemon struct {
|
||||
userTokens map[string]string
|
||||
userTokensLock *sync.RWMutex
|
||||
userTokensUnsaved bool
|
||||
httpClient *http.Client
|
||||
baseURL string
|
||||
mailcowClient mailcow.Client
|
||||
statefile string
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
slog.Error("fatal error", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
slog.Info("starting mcbdd", "version", version, "commit", commit, "date", date)
|
||||
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,
|
||||
},
|
||||
},
|
||||
}
|
||||
if len(d.statefile) == 0 {
|
||||
d.statefile = "state.json"
|
||||
}
|
||||
d.mailcowClient = mailcow.New(
|
||||
d.httpClient,
|
||||
d.baseURL,
|
||||
os.Getenv("MAILCOW_APIKEY"),
|
||||
)
|
||||
if err := d.LoadFromDisk(); err != nil {
|
||||
return err
|
||||
}
|
||||
d.daemonLoop()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Daemon) daemonLoop() {
|
||||
for {
|
||||
if err := d.daemonRun(); err != nil {
|
||||
slog.Error("error while syncing birthdays", "err", err)
|
||||
}
|
||||
time.Sleep(time.Minute * 15)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Daemon) daemonRun() error {
|
||||
mb, err := d.mailcowClient.GetMailboxes(context.Background())
|
||||
if err != nil {
|
||||
return fmt.Errorf("error fetching mailboxes: %w", err)
|
||||
}
|
||||
eg := sync.WaitGroup{}
|
||||
for _, m := range mb {
|
||||
eg.Go(func() {
|
||||
ctx := context.Background()
|
||||
if err := d.processUser(ctx, m); err != nil {
|
||||
slog.ErrorContext(ctx, "error processing user", "err", err, "user", m.Username)
|
||||
}
|
||||
})
|
||||
}
|
||||
eg.Wait()
|
||||
if d.userTokensUnsaved {
|
||||
slog.Info("saving tokens to disk", "count", len(d.userTokens))
|
||||
if err := d.SaveToDisk(); err != nil {
|
||||
return err
|
||||
}
|
||||
d.userTokensUnsaved = false
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Daemon) processUser(ctx context.Context, m mailcow.Mailbox) error {
|
||||
if !m.IsActive() {
|
||||
return nil
|
||||
}
|
||||
pass, err := d.getUserPass(ctx, m.Username)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error getting userpass: %w", err)
|
||||
}
|
||||
davclient := webdav.HTTPClientWithBasicAuth(d.httpClient, m.Username, pass)
|
||||
bb, err := d.getBirthdays(ctx, davclient, m.Username)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error getting birthdays from carddav: %w", err)
|
||||
}
|
||||
if err := d.ensureBirthdayCal(ctx, davclient, m.Username); err != nil {
|
||||
return fmt.Errorf("error creating birthday calendar in caldav: %w", err)
|
||||
}
|
||||
if err := d.syncBirthdaysToCal(ctx, davclient, m.Username, bb); err != nil {
|
||||
return fmt.Errorf("error syncing birthday events to caldav: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
28
cmd/mcbdd/persist.go
Normal file
28
cmd/mcbdd/persist.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
)
|
||||
|
||||
func (d *Daemon) LoadFromDisk() error {
|
||||
f, err := os.OpenFile(d.statefile, os.O_RDONLY, 0o660)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
return json.NewDecoder(f).Decode(&d.userTokens)
|
||||
}
|
||||
|
||||
func (d *Daemon) SaveToDisk() error {
|
||||
f, err := os.OpenFile(d.statefile, os.O_CREATE|os.O_WRONLY, 0o660)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
return json.NewEncoder(f).Encode(d.userTokens)
|
||||
}
|
||||
67
cmd/mcbdd/util.go
Normal file
67
cmd/mcbdd/util.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
ConstPassgenChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%^&*()_+-=[]{}\\|;':\",.<>/?`~0123456789"
|
||||
)
|
||||
|
||||
func randomElement(s string) (string, error) {
|
||||
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(s))))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to generate random integer: %w", err)
|
||||
}
|
||||
return string(s[n.Int64()]), nil
|
||||
}
|
||||
|
||||
func randomPassword(length int) (string, error) {
|
||||
pass := make([]byte, length)
|
||||
for i := range pass {
|
||||
char, err := randomElement(ConstPassgenChars)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
pass[i] = []byte(char)[0]
|
||||
}
|
||||
return string(pass), nil
|
||||
}
|
||||
|
||||
func sanitizeBirthday(input string) (uint16, uint16, uint16, error) {
|
||||
input = strings.ReplaceAll(input, "-", "")
|
||||
switch len(input) {
|
||||
case 4:
|
||||
mm, err := strconv.ParseUint(input[0:2], 10, 16)
|
||||
if err != nil {
|
||||
return 0, 0, 0, err
|
||||
}
|
||||
dd, err := strconv.ParseUint(input[2:4], 10, 16)
|
||||
if err != nil {
|
||||
return 0, 0, 0, err
|
||||
}
|
||||
return 0, uint16(mm), uint16(dd), nil
|
||||
case 8:
|
||||
yyyy, err := strconv.ParseUint(input[0:4], 10, 16)
|
||||
if err != nil {
|
||||
return 0, 0, 0, err
|
||||
}
|
||||
mm, err := strconv.ParseUint(input[4:6], 10, 16)
|
||||
if err != nil {
|
||||
return 0, 0, 0, err
|
||||
}
|
||||
dd, err := strconv.ParseUint(input[6:8], 10, 16)
|
||||
if err != nil {
|
||||
return 0, 0, 0, err
|
||||
}
|
||||
if yyyy == 1604 {
|
||||
yyyy = 0
|
||||
}
|
||||
return uint16(yyyy), uint16(mm), uint16(dd), nil
|
||||
}
|
||||
return 0, 0, 0, fmt.Errorf("birthday prop format unknown: %s", input)
|
||||
}
|
||||
71
cmd/mcbdd/util_test.go
Normal file
71
cmd/mcbdd/util_test.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func Test_sanitizeBirthday(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string // description of this test case
|
||||
// Named input parameters for target function.
|
||||
input string
|
||||
want uint16
|
||||
want2 uint16
|
||||
want3 uint16
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "test normal date w/o dashes",
|
||||
input: "20001203",
|
||||
want: 2000,
|
||||
want2: 12,
|
||||
want3: 0o3,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "test normal date w/ dashes",
|
||||
input: "2005-05-16",
|
||||
want: 2005,
|
||||
want2: 5,
|
||||
want3: 16,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "test normal date w/o year w/o dashes",
|
||||
input: "1203",
|
||||
want: 0,
|
||||
want2: 12,
|
||||
want3: 0o3,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "test normal date w/o year w/ dashes",
|
||||
input: "-05-16",
|
||||
want: 0,
|
||||
want2: 5,
|
||||
want3: 16,
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, got2, got3, gotErr := sanitizeBirthday(tt.input)
|
||||
if gotErr != nil {
|
||||
if !tt.wantErr {
|
||||
t.Errorf("sanitizeBirthday() failed: %v", gotErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if tt.wantErr {
|
||||
t.Fatal("sanitizeBirthday() succeeded unexpectedly")
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("sanitizeBirthday() = %v, want %v", got, tt.want)
|
||||
}
|
||||
if got2 != tt.want2 {
|
||||
t.Errorf("sanitizeBirthday() = %v, want %v", got2, tt.want2)
|
||||
}
|
||||
if got3 != tt.want3 {
|
||||
t.Errorf("sanitizeBirthday() = %v, want %v", got3, tt.want3)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
12
go.mod
Normal file
12
go.mod
Normal file
@@ -0,0 +1,12 @@
|
||||
module github.com/Marco98/mailcow-birthday-daemon
|
||||
|
||||
go 1.25.4
|
||||
|
||||
require (
|
||||
github.com/emersion/go-ical v0.0.0-20250609112844-439c63cef608
|
||||
github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9
|
||||
github.com/emersion/go-webdav v0.7.0
|
||||
github.com/google/uuid v1.6.0
|
||||
)
|
||||
|
||||
require github.com/teambition/rrule-go v1.8.2 // indirect
|
||||
11
go.sum
Normal file
11
go.sum
Normal file
@@ -0,0 +1,11 @@
|
||||
github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6/go.mod h1:BEksegNspIkjCQfmzWgsgbu6KdeJ/4LwUZs7DMBzjzw=
|
||||
github.com/emersion/go-ical v0.0.0-20250609112844-439c63cef608 h1:5XWaET4YAcppq3l1/Yh2ay5VmQjUdq6qhJuucdGbmOY=
|
||||
github.com/emersion/go-ical v0.0.0-20250609112844-439c63cef608/go.mod h1:BEksegNspIkjCQfmzWgsgbu6KdeJ/4LwUZs7DMBzjzw=
|
||||
github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9 h1:ATgqloALX6cHCranzkLb8/zjivwQ9DWWDCQRnxTPfaA=
|
||||
github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM=
|
||||
github.com/emersion/go-webdav v0.7.0 h1:cp6aBWXBf8Sjzguka9VJarr4XTkGc2IHxXI1Gq3TKpA=
|
||||
github.com/emersion/go-webdav v0.7.0/go.mod h1:mI8iBx3RAODwX7PJJ7qzsKAKs/vY429YfS2/9wKnDbQ=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/teambition/rrule-go v1.8.2 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8=
|
||||
github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4=
|
||||
163
pkg/mailcow/main.go
Normal file
163
pkg/mailcow/main.go
Normal file
@@ -0,0 +1,163 @@
|
||||
package mailcow
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
ConstAPIKeyHeaderName = "X-API-Key" //nolint:gosec
|
||||
)
|
||||
|
||||
type client struct {
|
||||
httpClient *http.Client
|
||||
baseURL string
|
||||
apiKey string
|
||||
}
|
||||
|
||||
func New(
|
||||
httpClient *http.Client,
|
||||
baseURL,
|
||||
apiKey string,
|
||||
) Client {
|
||||
return &client{
|
||||
baseURL: baseURL,
|
||||
apiKey: apiKey,
|
||||
httpClient: httpClient,
|
||||
}
|
||||
}
|
||||
|
||||
type Client interface {
|
||||
GetMailboxes(ctx context.Context) ([]Mailbox, error)
|
||||
GetAppPasswords(ctx context.Context, username string) ([]AppPassword, error)
|
||||
DeleteAppPasswords(ctx context.Context, ids []int) error
|
||||
CreateAppPassword(ctx context.Context, username, appname, password, protocols string) error
|
||||
}
|
||||
|
||||
func (c *client) GetMailboxes(ctx context.Context) ([]Mailbox, error) {
|
||||
reqURL, err := url.JoinPath(c.baseURL, "api/v1/get/mailbox/all")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Add(ConstAPIKeyHeaderName, c.apiKey)
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("unexpected status: %s", resp.Status)
|
||||
}
|
||||
mb := make([]Mailbox, 0)
|
||||
if err := json.NewDecoder(resp.Body).Decode(&mb); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return mb, nil
|
||||
}
|
||||
|
||||
func (c *client) GetAppPasswords(ctx context.Context, username string) ([]AppPassword, error) {
|
||||
reqURL, err := url.JoinPath(c.baseURL, "/api/v1/get/app-passwd/all", username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Add(ConstAPIKeyHeaderName, c.apiKey)
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("unexpected status: %s", resp.Status)
|
||||
}
|
||||
ap := make([]AppPassword, 0)
|
||||
if err := json.NewDecoder(resp.Body).Decode(&ap); err != nil {
|
||||
if strings.HasPrefix(err.Error(), "json: cannot unmarshal object into Go value of type []") {
|
||||
return []AppPassword{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return ap, nil
|
||||
}
|
||||
|
||||
func (c *client) DeleteAppPasswords(ctx context.Context, ids []int) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
reqURL, err := url.JoinPath(c.baseURL, "api/v1/delete/app-passwd")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reqBytes, err := json.Marshal(ids)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewBuffer(reqBytes))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Add(ConstAPIKeyHeaderName, c.apiKey)
|
||||
req.Header.Add("Content-Type", "application/json")
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("unexpected status: %s", resp.Status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *client) CreateAppPassword(ctx context.Context, username, appname, password, protocols string) error {
|
||||
reqURL, err := url.JoinPath(c.baseURL, "api/v1/add/app-passwd")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reqStruct := struct {
|
||||
Username string `json:"username"`
|
||||
AppName string `json:"app_name"`
|
||||
Password string `json:"app_passwd"`
|
||||
Password2 string `json:"app_passwd2"`
|
||||
Active string `json:"active"`
|
||||
Protocols string `json:"protocols"`
|
||||
}{
|
||||
Username: username,
|
||||
AppName: appname,
|
||||
Password: password,
|
||||
Password2: password,
|
||||
Active: "1",
|
||||
Protocols: protocols,
|
||||
}
|
||||
reqBytes, err := json.Marshal(reqStruct)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewBuffer(reqBytes))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Add(ConstAPIKeyHeaderName, c.apiKey)
|
||||
req.Header.Add("Content-Type", "application/json")
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("unexpected status: %s", resp.Status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
21
pkg/mailcow/structs.go
Normal file
21
pkg/mailcow/structs.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package mailcow
|
||||
|
||||
type Mailbox struct {
|
||||
Username string `json:"username"`
|
||||
Active int `json:"active"`
|
||||
ActiveInt int `json:"active_int"`
|
||||
Domain string `json:"domain"`
|
||||
Name string `json:"name"`
|
||||
LocalPart string `json:"local_part"`
|
||||
}
|
||||
|
||||
func (mb *Mailbox) IsActive() bool {
|
||||
return mb.Active == 1 && mb.ActiveInt == 1
|
||||
}
|
||||
|
||||
type AppPassword struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Mailbox string `json:"mailbox"`
|
||||
Domain string `json:"domain"`
|
||||
}
|
||||
Reference in New Issue
Block a user