feat: Jahrestage (ANNIVERSARY) und Emoji-Präfixe für Kalendereinträge
All checks were successful
Build Test Docker Image / docker-test (pull_request) Successful in 1m48s
Run Tests / test (pull_request) Successful in 5m12s

This commit is contained in:
2026-03-29 21:20:36 +02:00
parent fc673b09fb
commit e74d5f711a
5 changed files with 55 additions and 21 deletions

View File

@@ -179,8 +179,12 @@ func generateBirthdayEvents(birthdays []BirthdayContact) []birthdayEvent {
for _, v := range birthdays {
for year := cyear; year <= 10+cyear; year++ {
yearshift := year - v.Date.Year()
prefix := "\U0001F382 " // 🎂
if v.Type == ContactTypeAnniversary {
prefix = "\U0001F48D " // 💍
}
ev := birthdayEvent{
Summary: fmt.Sprintf("%s %s", v.GivenName, v.FamilyName),
Summary: fmt.Sprintf("%s%s %s", prefix, v.GivenName, v.FamilyName),
DateTimeStart: v.Date.AddDate(yearshift, 0, 0).Format("20060102"),
DateTimeEnd: v.Date.AddDate(yearshift, 0, 1).Format("20060102"),
}

View File

@@ -10,11 +10,20 @@ import (
"github.com/emersion/go-webdav/carddav"
)
// ContactType unterscheidet zwischen Geburtstagen und Jahrestagen.
type ContactType int
const (
ContactTypeBirthday ContactType = iota
ContactTypeAnniversary
)
type BirthdayContact struct {
FamilyName string
GivenName string
Date time.Time
YearKnown bool
Type ContactType
}
func (d *Daemon) getBirthdays(ctx context.Context, httpClient webdav.HTTPClient, user string) ([]BirthdayContact, error) {
@@ -41,20 +50,39 @@ func (d *Daemon) getBirthdays(ctx context.Context, httpClient webdav.HTTPClient,
}
for _, v := range oo {
nn := v.Card.Names()
bdayprop := v.Card.Value(vcard.FieldBirthday)
if len(nn) == 0 || len(bdayprop) == 0 {
if len(nn) == 0 {
continue
}
yyyy, mm, dd, err := sanitizeBirthday(bdayprop)
if err != nil {
return nil, err
bdayprop := v.Card.Value(vcard.FieldBirthday)
if len(bdayprop) > 0 {
yyyy, mm, dd, err := sanitizeDate(bdayprop)
if err != nil {
return nil, err
}
contacts = append(contacts, BirthdayContact{
GivenName: nn[0].GivenName,
FamilyName: nn[0].FamilyName,
Date: time.Date(int(yyyy), time.Month(int(mm)), int(dd), 0, 0, 0, 0, time.UTC),
YearKnown: yyyy != 0,
Type: ContactTypeBirthday,
})
}
{
annivprop := v.Card.Value("ANNIVERSARY")
if len(annivprop) > 0 {
yyyy, mm, dd, err := sanitizeDate(annivprop)
if err != nil {
return nil, err
}
contacts = append(contacts, BirthdayContact{
GivenName: nn[0].GivenName,
FamilyName: nn[0].FamilyName,
Date: time.Date(int(yyyy), time.Month(int(mm)), int(dd), 0, 0, 0, 0, time.UTC),
YearKnown: yyyy != 0,
Type: ContactTypeAnniversary,
})
}
}
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

View File

@@ -32,7 +32,7 @@ func randomPassword(length int) (string, error) {
return string(pass), nil
}
func sanitizeBirthday(input string) (uint16, uint16, uint16, error) {
func sanitizeDate(input string) (uint16, uint16, uint16, error) {
input = strings.ReplaceAll(input, "-", "")
switch len(input) {
case 4:
@@ -63,7 +63,7 @@ func sanitizeBirthday(input string) (uint16, uint16, uint16, error) {
}
return uint16(yyyy), uint16(mm), uint16(dd), nil
}
return 0, 0, 0, fmt.Errorf("birthday prop format unknown: %s", input)
return 0, 0, 0, fmt.Errorf("date prop format unknown: %s", input)
}
// parseNotificationTrigger konvertiert eine Uhrzeit im Format "HH:MM" in eine

View File

@@ -2,7 +2,7 @@ package main
import "testing"
func Test_sanitizeBirthday(t *testing.T) {
func Test_sanitizeDate(t *testing.T) {
tests := []struct {
name string // description of this test case
// Named input parameters for target function.
@@ -47,24 +47,24 @@ func Test_sanitizeBirthday(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, got2, got3, gotErr := sanitizeBirthday(tt.input)
got, got2, got3, gotErr := sanitizeDate(tt.input)
if gotErr != nil {
if !tt.wantErr {
t.Errorf("sanitizeBirthday() failed: %v", gotErr)
t.Errorf("sanitizeDate() failed: %v", gotErr)
}
return
}
if tt.wantErr {
t.Fatal("sanitizeBirthday() succeeded unexpectedly")
t.Fatal("sanitizeDate() succeeded unexpectedly")
}
if got != tt.want {
t.Errorf("sanitizeBirthday() = %v, want %v", got, tt.want)
t.Errorf("sanitizeDate() = %v, want %v", got, tt.want)
}
if got2 != tt.want2 {
t.Errorf("sanitizeBirthday() = %v, want %v", got2, tt.want2)
t.Errorf("sanitizeDate() = %v, want %v", got2, tt.want2)
}
if got3 != tt.want3 {
t.Errorf("sanitizeBirthday() = %v, want %v", got3, tt.want3)
t.Errorf("sanitizeDate() = %v, want %v", got3, tt.want3)
}
})
}

View File

@@ -12,6 +12,8 @@ Der Mailcow Birthday Daemon synchronisiert automatisch Geburtstagskalender für
## Kontakte und Geburtstage
- Alle Kontakte aus sämtlichen Adressbüchern werden abgerufen und die Geburtstagsinformationen je Benutzer extrahiert.
- Zusätzlich zu Geburtstagen werden auch Jahrestage (`ANNIVERSARY`-Feld nach vCard 4.0 / RFC 6350) ausgelesen. Dieses Feld wird von allen gängigen Clients unterstützt (Android, iOS, Thunderbird).
- Geburtstags-Events erhalten das Präfix 🎂, Jahrestags-Events das Präfix 💍 so sind beide Typen im Kalender sofort unterscheidbar.
- 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.