feat: inital commit

This commit is contained in:
Marco98
2025-11-11 13:41:51 +01:00
commit b00c239a8b
20 changed files with 1020 additions and 0 deletions

41
.github/workflows/release.yaml vendored Normal file
View 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
View 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
View File

@@ -0,0 +1,5 @@
/.vscode/
/dist/
.env
state.json

29
.golangci.yml Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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"`
}