From d87b3dd708cedcae09c0004cf86591b81eb6fe70 Mon Sep 17 00:00:00 2001 From: Pascal Bleser Date: Mon, 24 Nov 2025 09:32:53 +0100 Subject: [PATCH] groupware: feature test improvements and upgrade to Stalwart 0.14.1 * upgrade Stalwart image for devtools/full to 0.14.1 * re-assert which features are implemented or not in 0.14.1 * refactor the integration tests yet again to make it clearer and easier to see those "features-or-not" * get rid of old tests that are now better covered by integration tests * rewrite how we compare expected and actual objects in integration tests, finally having found a way to ignore the @type attribute properly instead of having to mutate all objects to remove it --- pkg/jmap/jmap_integration_contact_test.go | 156 ++-- pkg/jmap/jmap_integration_event_test.go | 140 ++-- pkg/jmap/jmap_integration_test.go | 81 +- pkg/jmap/jmap_test.go | 772 ------------------ pkg/jmap/jmap_tools_test.go | 81 -- pkg/jmap/testdata/mailboxes1.json | 121 --- pkg/jmap/testdata/mails1.json | 277 ------- pkg/jscalendar/jscalendar_model.go | 30 +- .../pkg/groupware/groupware_mock_tasks.go | 5 +- 9 files changed, 240 insertions(+), 1423 deletions(-) delete mode 100644 pkg/jmap/jmap_test.go delete mode 100644 pkg/jmap/testdata/mailboxes1.json delete mode 100644 pkg/jmap/testdata/mails1.json diff --git a/pkg/jmap/jmap_integration_contact_test.go b/pkg/jmap/jmap_integration_contact_test.go index 6ee16f3161..20a8c771bc 100644 --- a/pkg/jmap/jmap_integration_contact_test.go +++ b/pkg/jmap/jmap_integration_contact_test.go @@ -21,6 +21,11 @@ import ( "github.com/opencloud-eu/opencloud/pkg/structs" ) +const ( + // currently not supported, reported as https://github.com/stalwartlabs/stalwart/issues/2431 + EnableMediaWithBlobId = false +) + func TestContacts(t *testing.T) { if skip(t) { return @@ -39,8 +44,6 @@ func TestContacts(t *testing.T) { require.NotEmpty(accountId) require.NotEmpty(addressbookId) - allTrue(t, boxes, "mediaWithBlobId") - filter := ContactCardFilterCondition{ InAddressBook: addressbookId, } @@ -61,10 +64,17 @@ func TestContacts(t *testing.T) { require.True(ok, "failed to find created contact by its id") matchContact(t, actual, expected) } + + exceptions := []string{} + if !EnableMediaWithBlobId { + exceptions = append(exceptions, "mediaWithBlobId") + } + allBoxesAreTicked(t, boxes, exceptions...) } func matchContact(t *testing.T, actual jscontact.ContactCard, expected jscontact.ContactCard) { - require.Equal(t, expected, actual) + // require.Equal(t, expected, actual) + deepEqual(t, expected, actual) } type ContactsBoxes struct { @@ -116,33 +126,32 @@ func (s *StalwartTest) fillContacts( } require.NotEmpty(addressbookId) - u := true - filled := map[string]jscontact.ContactCard{} for i := range count { person := gofakeit.Person() - nameMap, nameObj := createName(person, u) + nameMap, nameObj := createName(person) + language := pickLanguage() contact := map[string]any{ "@type": "Card", "version": "1.0", "addressBookIds": toBoolMap([]string{addressbookId}), "prodId": productName, - "language": pickLanguage(), + "language": language, "kind": "individual", "name": nameMap, } card := jscontact.ContactCard{ - //Type: jscontact.ContactCardType, + Type: jscontact.ContactCardType, Version: "1.0", AddressBookIds: toBoolMap([]string{addressbookId}), ProdId: productName, - Language: contact["language"].(string), + Language: language, Kind: jscontact.ContactCardKindIndividual, Name: &nameObj, } if i%3 == 0 { - nicknameMap, nicknameObj := createNickName(person, u) + nicknameMap, nicknameObj := createNickName(person) id := id() contact["nicknames"] = map[string]map[string]any{id: nicknameMap} card.Nicknames = map[string]jscontact.Nickname{id: nicknameObj} @@ -153,13 +162,13 @@ func (s *StalwartTest) fillContacts( emailMaps := map[string]map[string]any{} emailObjs := map[string]jscontact.EmailAddress{} emailId := id() - emailMap, emailObj := createEmail(person, 10, u) + emailMap, emailObj := createEmail(person, 10) emailMaps[emailId] = emailMap emailObjs[emailId] = emailObj for i := range rand.Intn(3) { id := id() - m, o := createSecondaryEmail(gofakeit.Email(), i*100, u) + m, o := createSecondaryEmail(gofakeit.Email(), i*100) emailMaps[id] = m emailObjs[id] = o boxes.secondaryEmails = true @@ -192,12 +201,12 @@ func (s *StalwartTest) fillContacts( "number": tel, "features": structs.MapKeys(features, func(f jscontact.PhoneFeature) string { return string(f) }), "contexts": structs.MapKeys(contexts, func(c jscontact.PhoneContext) string { return string(c) }), - }, untype(jscontact.Phone{ + }, jscontact.Phone{ Type: jscontact.PhoneType, Number: tel, Features: features, Contexts: contexts, - }, u), nil + }, nil }); err != nil { return "", "", nil, boxes, err } @@ -212,16 +221,16 @@ func (s *StalwartTest) fillContacts( components := []jscontact.AddressComponent{} m := streetNumberRegex.FindAllStringSubmatch(source.Street, -1) if m != nil { - components = append(components, jscontact.AddressComponent{ /*Type: jscontact.AddressComponentType,*/ Kind: jscontact.AddressComponentKindName, Value: m[0][2]}) - components = append(components, jscontact.AddressComponent{ /*Type: jscontact.AddressComponentType,*/ Kind: jscontact.AddressComponentKindNumber, Value: m[0][1]}) + components = append(components, jscontact.AddressComponent{Type: jscontact.AddressComponentType, Kind: jscontact.AddressComponentKindName, Value: m[0][2]}) + components = append(components, jscontact.AddressComponent{Type: jscontact.AddressComponentType, Kind: jscontact.AddressComponentKindNumber, Value: m[0][1]}) } else { - components = append(components, jscontact.AddressComponent{ /*Type: jscontact.AddressComponentType,*/ Kind: jscontact.AddressComponentKindName, Value: source.Street}) + components = append(components, jscontact.AddressComponent{Type: jscontact.AddressComponentType, Kind: jscontact.AddressComponentKindName, Value: source.Street}) } components = append(components, - jscontact.AddressComponent{ /*Type: jscontact.AddressComponentType,*/ Kind: jscontact.AddressComponentKindLocality, Value: source.City}, - jscontact.AddressComponent{ /*Type: jscontact.AddressComponentType,*/ Kind: jscontact.AddressComponentKindCountry, Value: source.Country}, - jscontact.AddressComponent{ /*Type: jscontact.AddressComponentType,*/ Kind: jscontact.AddressComponentKindRegion, Value: source.State}, - jscontact.AddressComponent{ /*Type: jscontact.AddressComponentType,*/ Kind: jscontact.AddressComponentKindPostcode, Value: source.Zip}, + jscontact.AddressComponent{Type: jscontact.AddressComponentType, Kind: jscontact.AddressComponentKindLocality, Value: source.City}, + jscontact.AddressComponent{Type: jscontact.AddressComponentType, Kind: jscontact.AddressComponentKindCountry, Value: source.Country}, + jscontact.AddressComponent{Type: jscontact.AddressComponentType, Kind: jscontact.AddressComponentKindRegion, Value: source.State}, + jscontact.AddressComponent{Type: jscontact.AddressComponentType, Kind: jscontact.AddressComponentKindPostcode, Value: source.Zip}, ) tz := pickRandom(timezones...) return map[string]any{ @@ -232,13 +241,13 @@ func (s *StalwartTest) fillContacts( "defaultSeparator": ", ", "isOrdered": true, "timeZone": tz, - }, untype(jscontact.Address{ + }, jscontact.Address{ Type: jscontact.AddressType, Components: components, DefaultSeparator: ", ", IsOrdered: true, TimeZone: tz, - }, u), nil + }, nil }); err != nil { return "", "", nil, boxes, err } @@ -251,32 +260,32 @@ func (s *StalwartTest) fillContacts( "service": "Mastodon", "user": "@" + person.Contact.Email, "uri": "https://mastodon.example.com/@" + strings.ToLower(person.FirstName), - }, untype(jscontact.OnlineService{ + }, jscontact.OnlineService{ Type: jscontact.OnlineServiceType, Service: "Mastodon", User: "@" + person.Contact.Email, Uri: "https://mastodon.example.com/@" + strings.ToLower(person.FirstName), - }, u), nil + }, nil case 1: return map[string]any{ "@type": "OnlineService", "uri": "xmpp:" + person.Contact.Email, - }, untype(jscontact.OnlineService{ + }, jscontact.OnlineService{ Type: jscontact.OnlineServiceType, Uri: "xmpp:" + person.Contact.Email, - }, u), nil + }, nil default: return map[string]any{ "@type": "OnlineService", "service": "Discord", "user": person.Contact.Email, "uri": "https://discord.example.com/user/" + person.Contact.Email, - }, untype(jscontact.OnlineService{ + }, jscontact.OnlineService{ Type: jscontact.OnlineServiceType, Service: "Discord", User: person.Contact.Email, Uri: "https://discord.example.com/user/" + person.Contact.Email, - }, u), nil + }, nil } }); err != nil { return "", "", nil, boxes, err @@ -291,12 +300,12 @@ func (s *StalwartTest) fillContacts( "language": lang, "contexts": toBoolMap(contexts), "pref": i + 1, - }, untype(jscontact.LanguagePref{ + }, jscontact.LanguagePref{ Type: jscontact.LanguagePrefType, Language: lang, Contexts: toBoolMap(structs.Map(contexts, func(s string) jscontact.LanguagePrefContext { return jscontact.LanguagePrefContext(s) })), Pref: uint(i + 1), - }, u), nil + }, nil }); err != nil { return "", "", nil, boxes, err } @@ -315,23 +324,23 @@ func (s *StalwartTest) fillContacts( "name": person.Job.Company, "contexts": toBoolMapS("work"), } - organizationObjs[orgId] = untype(jscontact.Organization{ + organizationObjs[orgId] = jscontact.Organization{ Type: jscontact.OrganizationType, Name: person.Job.Company, Contexts: toBoolMapS(jscontact.OrganizationContextWork), - }, u) + } titleMaps[titleId] = map[string]any{ "@type": "Title", "kind": "title", "name": person.Job.Title, "organizationId": orgId, } - titleObjs[titleId] = untype(jscontact.Title{ + titleObjs[titleId] = jscontact.Title{ Type: jscontact.TitleType, Kind: jscontact.TitleKindTitle, Name: person.Job.Title, OrganizationId: orgId, - }, u) + } } contact["organizations"] = organizationMaps contact["titles"] = titleMaps @@ -352,19 +361,29 @@ func (s *StalwartTest) fillContacts( } encoded := base64.RawStdEncoding.EncodeToString(b.Bytes()) return map[string]any{ - "@type": "CryptoKey", - "uri": "data:application/pgp-keys;base64," + encoded, - }, untype(jscontact.CryptoKey{ - Type: jscontact.CryptoKeyType, - Uri: "data:application/pgp-keys;base64," + encoded, - }, u), nil + "@type": "CryptoKey", + "uri": "data:application/pgp-keys;base64," + encoded, + "mediaType": "application/pgp-keys", + }, jscontact.CryptoKey{ + Type: jscontact.CryptoKeyType, + Uri: "data:application/pgp-keys;base64," + encoded, + MediaType: "application/pgp-keys", + }, nil }); err != nil { return "", "", nil, boxes, err } if err := propmap(i%2 == 0, 1, 2, contact, "media", &card.Media, func(i int, id string) (map[string]any, jscontact.Media, error) { label := fmt.Sprintf("photo-%d", 1000+rand.Intn(9000)) - switch rand.Intn(3) { + + r := 0 + if EnableMediaWithBlobId { + r = rand.Intn(3) + } else { + r = rand.Intn(2) + } + + switch r { case 0: boxes.mediaWithDataUri = true // use data uri @@ -381,16 +400,35 @@ func (s *StalwartTest) fillContacts( "mediaType": mime, "contexts": structs.MapKeys(contexts, func(c jscontact.MediaContext) string { return string(c) }), "label": label, - }, untype(jscontact.Media{ + }, jscontact.Media{ Type: jscontact.MediaType, Kind: jscontact.MediaKindPhoto, Uri: uri, MediaType: mime, Contexts: contexts, Label: label, - }, u), nil - // currently not supported, reported as https://github.com/stalwartlabs/stalwart/issues/2431 - case -1: // change this to 1 to enable it again + }, nil + + case 1: + boxes.mediaWithExternalUri = true + // use external uri + uri := externalImageUri() + contexts := toBoolMapS(jscontact.MediaContextWork) + return map[string]any{ + "@type": "Media", + "kind": string(jscontact.MediaKindPhoto), + "uri": uri, + "contexts": structs.MapKeys(contexts, func(c jscontact.MediaContext) string { return string(c) }), + "label": label, + }, jscontact.Media{ + Type: jscontact.MediaType, + Kind: jscontact.MediaKindPhoto, + Uri: uri, + Contexts: contexts, + Label: label, + }, nil + + default: boxes.mediaWithBlobId = true size := pickRandom(16, 24, 32, 48, 64) img := gofakeit.ImageJpeg(size, size) @@ -405,33 +443,15 @@ func (s *StalwartTest) fillContacts( "blobId": blob.BlobId, "contexts": structs.MapKeys(contexts, func(c jscontact.MediaContext) string { return string(c) }), "label": label, - }, untype(jscontact.Media{ + }, jscontact.Media{ Type: jscontact.MediaType, Kind: jscontact.MediaKindPhoto, BlobId: blob.BlobId, MediaType: blob.Type, Contexts: contexts, Label: label, - }, u), nil + }, nil - default: - boxes.mediaWithExternalUri = true - // use external uri - uri := picsum(128, 128) - contexts := toBoolMapS(jscontact.MediaContextWork) - return map[string]any{ - "@type": "Media", - "kind": string(jscontact.MediaKindPhoto), - "uri": uri, - "contexts": structs.MapKeys(contexts, func(c jscontact.MediaContext) string { return string(c) }), - "label": label, - }, untype(jscontact.Media{ - Type: jscontact.MediaType, - Kind: jscontact.MediaKindPhoto, - Uri: uri, - Contexts: contexts, - Label: label, - }, u), nil } }); err != nil { return "", "", nil, boxes, err @@ -443,12 +463,12 @@ func (s *StalwartTest) fillContacts( "kind": "contact", "uri": "mailto:" + person.Contact.Email, "pref": (i + 1) * 10, - }, untype(jscontact.Link{ + }, jscontact.Link{ Type: jscontact.LinkType, Kind: jscontact.LinkKindContact, Uri: "mailto:" + person.Contact.Email, Pref: uint((i + 1) * 10), - }, u), nil + }, nil }); err != nil { return "", "", nil, boxes, err } diff --git a/pkg/jmap/jmap_integration_event_test.go b/pkg/jmap/jmap_integration_event_test.go index c394ea10b3..72c64fbd44 100644 --- a/pkg/jmap/jmap_integration_event_test.go +++ b/pkg/jmap/jmap_integration_event_test.go @@ -19,6 +19,12 @@ import ( "github.com/opencloud-eu/opencloud/pkg/structs" ) +// fields that are currently unsupported in Stalwart +const ( + EnableEventMayInviteFields = false + EnableEventParticipantDescriptionFields = false +) + func TestEvents(t *testing.T) { if skip(t) { return @@ -37,8 +43,6 @@ func TestEvents(t *testing.T) { require.NotEmpty(accountId) require.NotEmpty(calendarId) - allTrue(t, boxes) - filter := CalendarEventFilterCondition{ InCalendar: calendarId, } @@ -59,13 +63,23 @@ func TestEvents(t *testing.T) { require.True(ok, "failed to find created contact by its id") matchEvent(t, actual, expected) } + + exceptions := []string{} + if !EnableEventMayInviteFields { + exceptions = append(exceptions, "mayInvite") + } + allBoxesAreTicked(t, boxes, exceptions...) } func matchEvent(t *testing.T, actual CalendarEvent, expected CalendarEvent) { - require.Equal(t, expected, actual) + //require.Equal(t, expected, actual) + deepEqual(t, expected, actual) } type EventsBoxes struct { + categories bool + keywords bool + mayInvite bool } func (s *StalwartTest) fillEvents( @@ -100,8 +114,6 @@ func (s *StalwartTest) fillEvents( } require.NotEmpty(calendarId) - u := true - filled := map[string]CalendarEvent{} for i := range count { uid := gofakeit.UUID() @@ -119,7 +131,7 @@ func (s *StalwartTest) fillEvents( for range n { locationId, locationMap, locationObj := pickLocation() locationMaps[locationId] = locationMap - locationObjs[locationId] = untype(locationObj, u) + locationObjs[locationId] = locationObj locationIds = append(locationIds, locationId) if n > 0 && mainLocationId == "" { mainLocationId = locationId @@ -127,7 +139,7 @@ func (s *StalwartTest) fillEvents( } } virtualLocationId, virtualLocationMap, virtualLocationObj := pickVirtualLocation() - participantMaps, participantObjs, organizerEmail := createParticipants(uid, locationIds, []string{virtualLocationId}, u) + participantMaps, participantObjs, organizerEmail := createParticipants(uid, locationIds, []string{virtualLocationId}) duration := pickRandom("PT30M", "PT45M", "PT1H", "PT90M") tz := pickRandom(timezones...) daysDiff := rand.Intn(31) - 15 @@ -148,9 +160,9 @@ func (s *StalwartTest) fillEvents( privacy := pickRandom(jscalendar.Privacies...) color := pickRandom(basicColors...) locale := pickLocale() - keywords := keywords() - categories := categories() - var _ = categories // currently not used because it's unsupported in Stalwart + keywords := pickKeywords() + categories := pickCategories() + sequence := 0 alertId := id() @@ -169,18 +181,15 @@ func (s *StalwartTest) fillEvents( "description": description, "descriptionContentType": descriptionFormat, "locale": locale, - // "categories": categories, // currently unsupported in Stalwart - "color": color, - "sequence": sequence, - "showWithoutTime": false, - "freeBusyStatus": string(freeBusy), - "privacy": string(privacy), - "sentBy": organizerEmail, - "participants": participantMaps, - "timeZone": tz, - // "mayInviteSelf": true, // currently unsupported in Stalwart - // "mayInviteOthers": true, // currently unsupported in Stalwart - "hideAttendees": false, + "color": color, + "sequence": sequence, + "showWithoutTime": false, + "freeBusyStatus": string(freeBusy), + "privacy": string(privacy), + "sentBy": organizerEmail, + "participants": participantMaps, + "timeZone": tz, + "hideAttendees": false, "replyTo": map[string]string{ "imip": "mailto:" + organizerEmail, }, @@ -199,12 +208,13 @@ func (s *StalwartTest) fillEvents( }, }, } + obj := CalendarEvent{ Id: "", CalendarIds: toBoolMapS(calendarId), IsDraft: isDraft, IsOrigin: true, - Event: untype(jscalendar.Event{ + Event: jscalendar.Event{ Type: jscalendar.EventType, Start: jscalendar.LocalDateTime(start), Duration: jscalendar.Duration(duration), @@ -217,8 +227,7 @@ func (s *StalwartTest) fillEvents( Description: description, DescriptionContentType: descriptionFormat, Locale: locale, - // Categories: categories, // currently unsupported in Stalwart - Color: color, + Color: color, }, Sequence: uint(sequence), ShowWithoutTime: false, @@ -227,33 +236,46 @@ func (s *StalwartTest) fillEvents( SentBy: organizerEmail, Participants: participantObjs, TimeZone: tz, - // MayInviteSelf: true, // currently unsupported in Stalwart - // MayInviteOthers: true, // currently unsupported in Stalwart - HideAttendees: false, + HideAttendees: false, ReplyTo: map[jscalendar.ReplyMethod]string{ jscalendar.ReplyMethodImip: "mailto:" + organizerEmail, }, Locations: locationObjs, VirtualLocations: map[string]jscalendar.VirtualLocation{ - virtualLocationId: untype(virtualLocationObj, u), + virtualLocationId: virtualLocationObj, }, Alerts: map[string]jscalendar.Alert{ - alertId: untype(jscalendar.Alert{ + alertId: jscalendar.Alert{ Type: jscalendar.AlertType, Trigger: jscalendar.OffsetTrigger{ Type: jscalendar.OffsetTriggerType, Offset: jscalendar.SignedDuration(alertOffset), RelativeTo: jscalendar.RelativeToStart, }, - }, u), + }, }, }, - }, u), + }, + } + + if EnableEventMayInviteFields { + event["mayInviteSelf"] = true + event["mayInviteOthers"] = true + obj.MayInviteSelf = true + obj.MayInviteOthers = true + boxes.mayInvite = true } if len(keywords) > 0 { event["keywords"] = keywords obj.Keywords = keywords + boxes.keywords = true + } + + if len(categories) > 0 { + event["categories"] = categories + obj.Categories = categories + boxes.categories = true } if mainLocationId != "" { @@ -273,19 +295,19 @@ func (s *StalwartTest) fillEvents( uri = "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(img) default: mime = "image/jpeg" - uri = "https://picsum.photos/id/" + strconv.Itoa(1+rand.Intn(200)) + "/200/300" + uri = externalImageUri() } return map[string]any{ "@type": "Link", "href": uri, "contentType": mime, "rel": string(rel), - }, untype(jscalendar.Link{ + }, jscalendar.Link{ Type: jscalendar.LinkType, Href: uri, ContentType: mime, Rel: rel, - }, u), nil + }, nil }) if rand.Intn(10) > 7 { @@ -306,7 +328,7 @@ func (s *StalwartTest) fillEvents( "firstDayOfWeek": string(jscalendar.DayOfWeekMonday), "count": count, } - rr := untype(jscalendar.RecurrenceRule{ + rr := jscalendar.RecurrenceRule{ Type: jscalendar.RecurrenceRuleType, Frequency: frequency, Interval: uint(interval), @@ -314,7 +336,7 @@ func (s *StalwartTest) fillEvents( Skip: jscalendar.SkipOmit, FirstDayOfWeek: jscalendar.DayOfWeekMonday, Count: uint(count), - }, u) + } obj.RecurrenceRule = &rr } @@ -400,7 +422,6 @@ var virtualRooms = []jscalendar.VirtualLocation{ func pickLocation() (string, map[string]any, jscalendar.Location) { locationId := id() room := rooms[rand.Intn(len(rooms))] - room.Links = nil // currently unsupported in Stalwart b, err := json.Marshal(room) if err != nil { panic(err) @@ -431,23 +452,23 @@ func pickVirtualLocation() (string, map[string]any, jscalendar.VirtualLocation) var ChairRoles = toBoolMapS(jscalendar.RoleChair, jscalendar.RoleOwner) var RegularRoles = toBoolMapS(jscalendar.RoleOptional) -func createParticipants(uid string, locationIds []string, virtualLocationIds []string, u bool) (map[string]map[string]any, map[string]jscalendar.Participant, string) { +func createParticipants(uid string, locationIds []string, virtualLocationIds []string) (map[string]map[string]any, map[string]jscalendar.Participant, string) { options := structs.Concat(locationIds, virtualLocationIds) n := 1 + rand.Intn(4) maps := map[string]map[string]any{} objs := map[string]jscalendar.Participant{} - organizerId, organizerEmail, organizerMap, organizerObj := createParticipant(0, uid, pickRandom(options...), "", "", u) + organizerId, organizerEmail, organizerMap, organizerObj := createParticipant(0, uid, pickRandom(options...), "", "") maps[organizerId] = organizerMap objs[organizerId] = organizerObj for i := 1; i < n; i++ { - id, _, participantMap, participantObj := createParticipant(i, uid, pickRandom(options...), organizerId, organizerEmail, u) + id, _, participantMap, participantObj := createParticipant(i, uid, pickRandom(options...), organizerId, organizerEmail) maps[id] = participantMap objs[id] = participantObj } return maps, objs, organizerEmail } -func createParticipant(i int, uid string, locationId string, organizerEmail string, organizerId string, u bool) (string, string, map[string]any, jscalendar.Participant) { +func createParticipant(i int, uid string, locationId string, organizerEmail string, organizerId string) (string, string, map[string]any, jscalendar.Participant) { participantId := id() person := gofakeit.Person() roles := RegularRoles @@ -500,11 +521,9 @@ func createParticipant(i int, uid string, locationId string, organizerEmail stri } m := map[string]any{ - "@type": "Participant", - "name": name, - "email": email, - // "description": description, // currently not supported in Stalwart - // "descriptionContentType": descriptionContentType, // currently not supported in Stalwart + "@type": "Participant", + "name": name, + "email": email, "calendarAddress": calendarAddress, "kind": "individual", "roles": structs.MapKeys(roles, func(r jscalendar.Role) string { return string(r) }), @@ -522,11 +541,9 @@ func createParticipant(i int, uid string, locationId string, organizerEmail stri "scheduleId": "mailto:" + email, } o := jscalendar.Participant{ - Type: jscalendar.ParticipantType, - Name: name, - Email: email, - // Description: description, // currently not supported in Stalwart - // DescriptionContentType: descriptionContentType, // currently not supported in Stalwart + Type: jscalendar.ParticipantType, + Name: name, + Email: email, Kind: jscalendar.ParticipantKindIndividual, CalendarAddress: calendarAddress, Roles: roles, @@ -544,8 +561,15 @@ func createParticipant(i int, uid string, locationId string, organizerEmail stri ScheduleId: "mailto:" + email, } + if EnableEventParticipantDescriptionFields { + m["description"] = description + m["descriptionContentType"] = descriptionContentType + o.Description = description + o.DescriptionContentType = descriptionContentType + } + err = propmap(i%2 == 0, 1, 2, m, "links", &o.Links, func(int, string) (map[string]any, jscalendar.Link, error) { - href := "https://picsum.photos/id/" + strconv.Itoa(1+rand.Intn(200)) + "/200/300" + href := externalImageUri() title := person.FirstName + "'s Cake Day pick" return map[string]any{ "@type": "Link", @@ -554,20 +578,20 @@ func createParticipant(i int, uid string, locationId string, organizerEmail stri "rel": "icon", "display": "badge", "title": title, - }, untype(jscalendar.Link{ + }, jscalendar.Link{ Type: jscalendar.LinkType, Href: href, ContentType: "image/jpeg", Rel: jscalendar.RelIcon, Display: jscalendar.DisplayBadge, Title: title, - }, u), nil + }, nil }) if err != nil { panic(err) } - return participantId, person.Contact.Email, m, untype(o, u) + return participantId, person.Contact.Email, m, o } var Keywords = []string{ @@ -578,7 +602,7 @@ var Keywords = []string{ "decision", } -func keywords() map[string]bool { +func pickKeywords() map[string]bool { return toBoolMap(pickRandoms(Keywords...)) } @@ -587,6 +611,6 @@ var Categories = []string{ "http://opencloud.eu/categories/internal", } -func categories() map[string]bool { +func pickCategories() map[string]bool { return toBoolMap(pickRandoms(Categories...)) } diff --git a/pkg/jmap/jmap_integration_test.go b/pkg/jmap/jmap_integration_test.go index a8783608c0..198a327bf1 100644 --- a/pkg/jmap/jmap_integration_test.go +++ b/pkg/jmap/jmap_integration_test.go @@ -22,6 +22,7 @@ import ( "text/template" "time" + "github.com/google/go-cmp/cmp" "github.com/stretchr/testify/require" "github.com/gorilla/websocket" @@ -42,6 +43,10 @@ import ( "github.com/go-crypt/crypt/algorithm/shacrypt" ) +const ( + EnableTypes = false +) + var ( domains = [...]string{"earth.gov", "mars.mil", "opa.org", "acme.com"} people = [...]string{ @@ -58,7 +63,7 @@ var ( ) const ( - stalwartImage = "ghcr.io/stalwartlabs/stalwart:v0.14.0-alpine" + stalwartImage = "ghcr.io/stalwartlabs/stalwart:v0.14.1-alpine" httpPort = "8080" imapsPort = "993" configTemplate = ` @@ -105,6 +110,7 @@ tracer.log.level = "trace" tracer.log.lossy = false tracer.log.multiline = false tracer.log.type = "stdout" +sharing.allow-directory-query = false ` ) @@ -393,13 +399,6 @@ func pickRandomlyFromMap[K comparable, V any](m map[K]V, min int, max int) map[K var productName = "jmaptest" -func untype[S any](s S, t bool) S { - if t { - reflect.ValueOf(&s).Elem().FieldByName("Type").SetString("") - } - return s -} - type TestJmapClient struct { h *http.Client username string @@ -645,7 +644,7 @@ func (j *TestJmapClient) objectsById(accountId string, objectType ObjectType, sc return m, nil } -func createName(person *gofakeit.PersonInfo, u bool) (map[string]any, jscontact.Name) { +func createName(person *gofakeit.PersonInfo) (map[string]any, jscontact.Name) { o := jscontact.Name{ Type: jscontact.NameType, } @@ -658,20 +657,20 @@ func createName(person *gofakeit.PersonInfo, u bool) (map[string]any, jscontact. "kind": "given", "value": person.FirstName, } - oComps[0] = untype(jscontact.NameComponent{ + oComps[0] = jscontact.NameComponent{ Type: jscontact.NameComponentType, Kind: jscontact.NameComponentKindGiven, Value: person.FirstName, - }, u) + } mComps[1] = map[string]string{ "kind": "surname", "value": person.LastName, } - oComps[1] = untype(jscontact.NameComponent{ + oComps[1] = jscontact.NameComponent{ Type: jscontact.NameComponentType, Kind: jscontact.NameComponentKindSurname, Value: person.LastName, - }, u) + } m["components"] = mComps o.Components = oComps m["isOrdered"] = true @@ -681,24 +680,24 @@ func createName(person *gofakeit.PersonInfo, u bool) (map[string]any, jscontact. full := fmt.Sprintf("%s %s", person.FirstName, person.LastName) m["full"] = full o.Full = full - return m, untype(o, u) + return m, o } -func createNickName(_ *gofakeit.PersonInfo, u bool) (map[string]any, jscontact.Nickname) { +func createNickName(_ *gofakeit.PersonInfo) (map[string]any, jscontact.Nickname) { name := gofakeit.PetName() contexts := pickRandoms(jscontact.NicknameContextPrivate, jscontact.NicknameContextWork) return map[string]any{ "@type": "Nickname", "name": name, "contexts": toBoolMap(structs.Map(contexts, func(s jscontact.NicknameContext) string { return string(s) })), - }, untype(jscontact.Nickname{ + }, jscontact.Nickname{ Type: jscontact.NicknameType, Name: name, Contexts: orNilMap(toBoolMap(contexts)), - }, u) + } } -func createEmail(person *gofakeit.PersonInfo, pref int, u bool) (map[string]any, jscontact.EmailAddress) { +func createEmail(person *gofakeit.PersonInfo, pref int) (map[string]any, jscontact.EmailAddress) { email := person.Contact.Email contexts := pickRandoms1(jscontact.EmailAddressContextWork, jscontact.EmailAddressContextPrivate) label := strings.ToLower(person.FirstName) @@ -708,28 +707,28 @@ func createEmail(person *gofakeit.PersonInfo, pref int, u bool) (map[string]any, "contexts": toBoolMap(structs.Map(contexts, func(s jscontact.EmailAddressContext) string { return string(s) })), "label": label, "pref": pref, - }, untype(jscontact.EmailAddress{ + }, jscontact.EmailAddress{ Type: jscontact.EmailAddressType, Address: email, Contexts: orNilMap(toBoolMap(contexts)), Label: label, Pref: uint(pref), - }, u) + } } -func createSecondaryEmail(email string, pref int, u bool) (map[string]any, jscontact.EmailAddress) { +func createSecondaryEmail(email string, pref int) (map[string]any, jscontact.EmailAddress) { contexts := pickRandoms(jscontact.EmailAddressContextWork, jscontact.EmailAddressContextPrivate) return map[string]any{ "@type": "EmailAddress", "address": email, "contexts": toBoolMap(structs.Map(contexts, func(s jscontact.EmailAddressContext) string { return string(s) })), "pref": pref, - }, untype(jscontact.EmailAddress{ + }, jscontact.EmailAddress{ Type: jscontact.EmailAddressType, Address: email, Contexts: orNilMap(toBoolMap(contexts)), Pref: uint(pref), - }, u) + } } var idFirstLetters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") @@ -971,8 +970,8 @@ func propmap[T any](enabled bool, min int, max int, container map[string]any, na return nil } -func picsum(w, h int) string { - return fmt.Sprintf("https://picsum.photos/id/%d/%d/%d", 1+rand.Intn(200), h, w) +func externalImageUri() string { + return fmt.Sprintf("https://picsum.photos/id/%d/%d/%d", 1+rand.Intn(200), 200, 300) } func orNilMap[K comparable, V any](m map[K]V) map[K]V { @@ -983,14 +982,6 @@ func orNilMap[K comparable, V any](m map[K]V) map[K]V { } } -func orNilSlice[E any](s []E) []E { - if len(s) < 1 { - return nil - } else { - return s - } -} - func toBoolMap[K comparable](s []K) map[K]bool { m := make(map[K]bool, len(s)) for _, e := range s { @@ -1046,15 +1037,37 @@ func pickLocale() string { return pickRandom("en", "fr", "de") } -func allTrue[S any](t *testing.T, s S, exceptions ...string) { +func allBoxesAreTicked[S any](t *testing.T, s S, exceptions ...string) { v := reflect.ValueOf(s) typ := v.Type() for i := range v.NumField() { name := typ.Field(i).Name if slices.Contains(exceptions, name) { + log.Printf("(/) %s\n", name) continue } value := v.Field(i).Bool() + if value { + log.Printf("(X) %s\n", name) + } else { + log.Printf("( ) %s\n", name) + } require.True(t, value, "should be true: %v", name) } } + +func deepEqual[T any](t *testing.T, expected, actual T) { + diff := "" + if EnableTypes { + diff = cmp.Diff(expected, actual) + } else { + diff = cmp.Diff(expected, actual, cmp.FilterPath(func(p cmp.Path) bool { + switch sf := p.Last().(type) { + case cmp.StructField: + return sf.String() == ".Type" + } + return false + }, cmp.Ignore())) + } + require.Empty(t, diff) +} diff --git a/pkg/jmap/jmap_test.go b/pkg/jmap/jmap_test.go deleted file mode 100644 index f01171f7ba..0000000000 --- a/pkg/jmap/jmap_test.go +++ /dev/null @@ -1,772 +0,0 @@ -package jmap - -import ( - "context" - "encoding/json" - "fmt" - "io" - "math/rand" - "net/url" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/google/uuid" - "github.com/opencloud-eu/opencloud/pkg/jscalendar" - "github.com/opencloud-eu/opencloud/pkg/jscontact" - "github.com/opencloud-eu/opencloud/pkg/log" - "github.com/stretchr/testify/require" -) - -func jsoneq[X any](t *testing.T, expected string, object X) { - data, err := json.MarshalIndent(object, "", "") - require.NoError(t, err) - require.JSONEq(t, expected, string(data)) - - var rec X - err = json.Unmarshal(data, &rec) - require.NoError(t, err) - require.Equal(t, object, rec) -} - -func TestEmptySessionCapabilitiesMarshalling(t *testing.T) { - jsoneq(t, `{}`, SessionCapabilities{}) -} - -func TestSessionCapabilitiesMarshalling(t *testing.T) { - jsoneq(t, `{ - "urn:ietf:params:jmap:core": { - "maxSizeUpload": 123, - "maxConcurrentUpload": 4, - "maxSizeRequest": 1000, - "maxConcurrentRequests": 8, - "maxCallsInRequest": 16, - "maxObjectsInGet": 32, - "maxObjectsInSet": 8 - }, - "urn:ietf:params:jmap:tasks": { - } - }`, SessionCapabilities{ - Core: &SessionCoreCapabilities{ - MaxSizeUpload: 123, - MaxConcurrentUpload: 4, - MaxSizeRequest: 1000, - MaxConcurrentRequests: 8, - MaxCallsInRequest: 16, - MaxObjectsInGet: 32, - MaxObjectsInSet: 8, - }, - Tasks: &SessionTasksCapabilities{}, - }) -} - -type TestJmapWellKnownClient struct { - t *testing.T -} - -func NewTestJmapWellKnownClient(t *testing.T) SessionClient { - return &TestJmapWellKnownClient{t: t} -} - -func (t *TestJmapWellKnownClient) Close() error { - return nil -} - -func (t *TestJmapWellKnownClient) GetSession(sessionUrl *url.URL, username string, logger *log.Logger) (SessionResponse, Error) { - pa := generateRandomString(2 + seededRand.Intn(10)) - return SessionResponse{ - Username: generateRandomString(8), - ApiUrl: "test://", - PrimaryAccounts: SessionPrimaryAccounts{ - Core: pa, - Mail: pa, - Submission: pa, - VacationResponse: pa, - Sieve: pa, - Blob: pa, - Quota: pa, - Websocket: pa, - }, - Capabilities: SessionCapabilities{ - Core: &SessionCoreCapabilities{ - MaxCallsInRequest: 64, - }, - }, - }, nil -} - -type TestJmapApiClient struct { - t *testing.T -} - -func NewTestJmapApiClient(t *testing.T) ApiClient { - return &TestJmapApiClient{t: t} -} - -func (t TestJmapApiClient) Close() error { - return nil -} - -type TestJmapBlobClient struct { - t *testing.T -} - -func NewTestJmapBlobClient(t *testing.T) BlobClient { - return &TestJmapBlobClient{t: t} -} - -func (t TestJmapBlobClient) UploadBinary(ctx context.Context, logger *log.Logger, session *Session, uploadUrl string, endpoint string, contentType string, acceptLanguage string, body io.Reader) (UploadedBlob, Language, Error) { - bytes, err := io.ReadAll(body) - if err != nil { - return UploadedBlob{}, "", SimpleError{code: 0, err: err} - } - return UploadedBlob{ - BlobId: uuid.NewString(), - Size: len(bytes), - Type: contentType, - }, "", nil -} - -func (h *TestJmapBlobClient) DownloadBinary(ctx context.Context, logger *log.Logger, session *Session, downloadUrl string, endpoint string, acceptLanguage string) (*BlobDownload, Language, Error) { - return &BlobDownload{ - Body: io.NopCloser(strings.NewReader("")), - Size: -1, - Type: "text/plain", - ContentDisposition: "attachment; filename=\"file.txt\"", - CacheControl: "", - }, "", nil -} - -func (t TestJmapBlobClient) Close() error { - return nil -} - -type TestWsClientFactory struct { - WsClientFactory -} - -var _ WsClientFactory = &TestWsClientFactory{} - -func NewTestWsClientFactory(t *testing.T) WsClientFactory { - return TestWsClientFactory{} -} - -func (t TestWsClientFactory) EnableNotifications(pushState string, sessionProvider func() (*Session, error), listener WsPushListener) (WsClient, Error) { - return nil, nil // TODO -} - -func (t TestWsClientFactory) Close() error { - return nil -} - -func serveTestFile(t *testing.T, name string) ([]byte, Language, Error) { - cwd, _ := os.Getwd() - p := filepath.Join(cwd, "testdata", name) - bytes, err := os.ReadFile(p) - if err != nil { - return bytes, "", SimpleError{code: 0, err: err} - } - // try to parse it first to avoid any deeper issues that are caused by the test tools - var target map[string]any - err = json.Unmarshal(bytes, &target) - if err != nil { - t.Errorf("failed to parse JSON test data file '%v': %v", p, err) - return nil, "", SimpleError{code: 0, err: err} - } - return bytes, "", nil -} - -func (t *TestJmapApiClient) Command(ctx context.Context, logger *log.Logger, session *Session, request Request, acceptLanguage string) ([]byte, Language, Error) { - command := request.MethodCalls[0].Command - switch command { - case CommandMailboxGet: - return serveTestFile(t.t, "mailboxes1.json") - case CommandEmailQuery: - return serveTestFile(t.t, "mails1.json") - default: - require.Fail(t.t, "TestJmapApiClient: unsupported jmap command: %v", command) - return nil, "", SimpleError{code: 0, err: fmt.Errorf("TestJmapApiClient: unsupported jmap command: %v", command)} - } -} - -const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" - -var seededRand *rand.Rand = rand.New(rand.NewSource(time.Now().UnixNano())) - -func generateRandomString(length int) string { - b := make([]byte, length) - for i := range b { - b[i] = charset[seededRand.Intn(len(charset))] - } - return string(b) -} - -func TestRequests(t *testing.T) { - require := require.New(t) - apiClient := NewTestJmapApiClient(t) - wkClient := NewTestJmapWellKnownClient(t) - blobClient := NewTestJmapBlobClient(t) - wsClientFactory := NewTestWsClientFactory(t) - logger := log.NopLogger() - ctx := context.Background() - client := NewClient(wkClient, apiClient, blobClient, wsClientFactory) - - jmapUrl, err := url.Parse("http://localhost/jmap") - require.NoError(err) - - session := Session{ - Username: "user123", - JmapUrl: *jmapUrl, - SessionResponse: SessionResponse{ - Capabilities: SessionCapabilities{ - Core: &SessionCoreCapabilities{ - MaxCallsInRequest: 10, - }, - }, - }, - } - - foldersByAccountId, sessionState, _, _, err := client.GetAllMailboxes([]string{"a"}, &session, ctx, &logger, "") - require.NoError(err) - require.Len(foldersByAccountId, 1) - require.Contains(foldersByAccountId, "a") - folders := foldersByAccountId["a"] - require.Len(folders, 5) - require.NotEmpty(sessionState) - - emails, sessionState, _, _, err := client.GetAllEmailsInMailbox("a", &session, ctx, &logger, "", "Inbox", 0, 0, false, true, 0, true) - require.NoError(err) - require.Len(emails.Emails, 3) - require.NotEmpty(sessionState) - - { - email := emails.Emails[0] - require.Equal("Ornare Senectus Ultrices Elit", email.Subject) - require.Equal(false, email.HasAttachment) - } - { - email := emails.Emails[1] - require.Equal("Lorem Tortor Eros Blandit Adipiscing Scelerisque Fermentum", email.Subject) - require.Equal(false, email.HasAttachment) - } -} - -func TestEmailFilterSerialization(t *testing.T) { - expectedFilterJson := ` -{"operator":"AND","conditions":[{"hasKeyword":"seen","text":"sample"},{"hasKeyword":"draft"}]} -` - - require := require.New(t) - - text := "sample" - mailboxId := "" - notInMailboxIds := []string{} - from := "" - to := "" - cc := "" - bcc := "" - subject := "" - body := "" - before := time.Time{} - after := time.Time{} - minSize := 0 - maxSize := 0 - keywords := []string{"seen", "draft"} - - var filter EmailFilterElement - - firstFilter := EmailFilterCondition{ - Text: text, - InMailbox: mailboxId, - InMailboxOtherThan: notInMailboxIds, - From: from, - To: to, - Cc: cc, - Bcc: bcc, - Subject: subject, - Body: body, - Before: before, - After: after, - MinSize: minSize, - MaxSize: maxSize, - } - filter = &firstFilter - - if len(keywords) > 0 { - firstFilter.HasKeyword = keywords[0] - if len(keywords) > 1 { - firstFilter.HasKeyword = keywords[0] - filters := make([]EmailFilterElement, len(keywords)) - filters[0] = firstFilter - for i, keyword := range keywords[1:] { - filters[i+1] = EmailFilterCondition{ - HasKeyword: keyword, - } - } - filter = &EmailFilterOperator{ - Operator: And, - Conditions: filters, - } - } - } - - b, err := json.Marshal(filter) - require.NoError(err) - json := string(b) - require.Equal(strings.TrimSpace(expectedFilterJson), json) -} - -func TestUnmarshallingCalendarEvent(t *testing.T) { - payload := ` -{ - "locale" : "en-US", - "description" : "Internal meeting about the grand strategy for the future", - "locations" : { - "ux1uokie" : { - "links" : { - "eefe2pax" : { - "@type" : "Link", - "href" : "https://example.com/office" - } - }, - "iCalComponent" : { - "name" : "vlocation" - }, - "@type" : "Location", - "description" : "Office meeting room upstairs", - "name" : "Office", - "locationTypes" : { - "office" : true - }, - "coordinates" : "geo:52.5334956,13.4079872", - "relativeTo" : "start", - "timeZone" : "CEST" - } - }, - "replyTo" : { - "imip" : "mailto:organizer@example.com" - }, - "links" : { - "cai0thoh" : { - "href" : "https://example.com/9a7ab91a-edca-4988-886f-25e00743430d", - "rel" : "about", - "contentType" : "text/html", - "@type" : "Link" - } - }, - "prodId" : "Mock 0.0", - "@type" : "Event", - "keywords" : { - "secret" : true, - "meeting" : true - }, - "status" : "confirmed", - "freeBusyStatus" : "busy", - "categories" : { - "internal" : true, - "secret" : true - }, - "duration" : "PT30M", - "calendarIds" : { - "b" : true - }, - "alerts" : { - "ahqu4xi0" : { - "@type" : "Alert" - } - }, - "start" : "2025-09-30T12:00:00", - "privacy" : "public", - "isDraft" : false, - "id" : "c", - "isOrigin" : true, - "sentBy" : "organizer@example.com", - "descriptionContentType" : "text/plain", - "updated" : "2025-09-29T16:17:18Z", - "created" : "2025-09-29T16:17:18Z", - "color" : "purple", - "recurrenceRule" : { - "skip" : "omit", - "count" : 4, - "firstDayOfWeek" : "monday", - "rscale" : "iso8601", - "interval" : 1, - "frequency" : "weekly" - }, - "timeZone" : "Etc/UTC", - "title" : "Meeting of the Minds", - "participants" : { - "xeikie9p" : { - "name" : "Klaes Ashford", - "locationId" : "em4eal0o", - "language" : "en-GB", - "description" : "As the first officer on the Behemoth", - "invitedBy" : "eegh7uph", - "@type" : "Participant", - "links" : { - "oifooj6g" : { - "@type" : "Link", - "contentType" : "image/png", - "display" : "badge", - "href" : "https://static.wikia.nocookie.net/expanse/images/0/02/Klaes_Ashford_-_Expanse_season_4_promotional_2.png/revision/latest?cb=20191206012007", - "rel" : "icon", - "title" : "Ashford on Medina Station" - } - }, - "iCalComponent" : { - "name" : "participant" - }, - "scheduleAgent" : "server", - "scheduleId" : "mailto:ashford@opa.org" - }, - "eegh7uph" : { - "description" : "Called the meeting", - "language" : "en-GB", - "locationId" : "ux1uokie", - "name" : "Anderson Dawes", - "scheduleAgent" : "server", - "scheduleUpdated" : "2025-10-01T11:59:12Z", - "links" : { - "ieni5eiw" : { - "href" : "https://static.wikia.nocookie.net/expanse/images/1/1e/OPA_leader.png/revision/latest?cb=20250121103410", - "display" : "badge", - "rel" : "icon", - "title" : "Anderson Dawes", - "contentType" : "image/png", - "@type" : "Link" - } - }, - "iCalComponent" : { - "name" : "participant" - }, - "@type" : "Participant", - "invitedBy" : "eegh7uph", - "scheduleSequence" : 1, - "sendTo" : { - "imip" : "mailto:adawes@opa.org" - }, - "scheduleStatus" : [ - "1.0" - ], - "scheduleId" : "mailto:adawes@opa.org" - } - }, - "uid" : "9a7ab91a-edca-4988-886f-25e00743430d", - "virtualLocations" : { - "em4eal0o" : { - "@type" : "VirtualLocation", - "description" : "The opentalk Conference Room", - "uri" : "https://meet.opentalk.eu", - "features" : { - "audio" : true, - "screen" : true, - "chat" : true, - "video" : true - }, - "name" : "opentalk" - } - } -} - ` - require := require.New(t) - var result CalendarEvent - err := json.Unmarshal([]byte(payload), &result) - require.NoError(err) - - require.Len(result.VirtualLocations, 1) - require.Len(result.Locations, 1) - require.Equal("9a7ab91a-edca-4988-886f-25e00743430d", result.Uid) - require.Equal(jscalendar.PrivacyPublic, result.Privacy) -} - -func TestUnmarshallingCalendarEventGetResponse(t *testing.T) { - payload := ` -{ - "sessionState" : "7d3cae5b", - "methodResponses" : [ - [ - "CalendarEvent/query", - { - "position" : 0, - "queryState" : "s2yba", - "accountId" : "b", - "canCalculateChanges" : true, - "ids" : [ - "c" - ] - }, - "b:0" - ], - [ - "CalendarEvent/get", - { - "state" : "s2yba", - "list" : [ - { - "links" : { - "cai0thoh" : { - "contentType" : "text/html", - "href" : "https://example.com/9a7ab91a-edca-4988-886f-25e00743430d", - "@type" : "Link", - "rel" : "about" - } - }, - "freeBusyStatus" : "busy", - "color" : "purple", - "isDraft" : false, - "calendarIds" : { - "b" : true - }, - "updated" : "2025-09-29T16:17:18Z", - "locations" : { - "ux1uokie" : { - "relativeTo" : "start", - "description" : "Office meeting room upstairs", - "coordinates" : "geo:52.5334956,13.4079872", - "name" : "Office", - "locationTypes" : { - "office" : true - }, - "links" : { - "eefe2pax" : { - "href" : "https://example.com/office", - "@type" : "Link" - } - }, - "iCalComponent" : { - "name" : "vlocation" - }, - "@type" : "Location", - "timeZone" : "CEST" - } - }, - "virtualLocations" : { - "em4eal0o" : { - "name" : "opentalk", - "@type" : "VirtualLocation", - "features" : { - "screen" : true, - "chat" : true, - "audio" : true, - "video" : true - }, - "uri" : "https://meet.opentalk.eu", - "description" : "The opentalk Conference Room" - } - }, - "uid" : "9a7ab91a-edca-4988-886f-25e00743430d", - "categories" : { - "secret" : true, - "internal" : true - }, - "keywords" : { - "secret" : true, - "meeting" : true - }, - "replyTo" : { - "imip" : "mailto:organizer@example.com" - }, - "duration" : "PT30M", - "created" : "2025-09-29T16:17:18Z", - "start" : "2025-09-30T12:00:00", - "id" : "c", - "sentBy" : "organizer@example.com", - "timeZone" : "Etc/UTC", - "@type" : "Event", - "title" : "Meeting of the Minds", - "alerts" : { - "ahqu4xi0" : { - "@type" : "Alert" - } - }, - "participants" : { - "xeikie9p" : { - "@type" : "Participant", - "scheduleId" : "mailto:ashford@opa.org", - "invitedBy" : "eegh7uph", - "language" : "en-GB", - "links" : { - "oifooj6g" : { - "contentType" : "image/png", - "display" : "badge", - "title" : "Ashford on Medina Station", - "@type" : "Link", - "href" : "https://static.wikia.nocookie.net/expanse/images/0/02/Klaes_Ashford_-_Expanse_season_4_promotional_2.png/revision/latest?cb=20191206012007", - "rel" : "icon" - } - }, - "iCalComponent" : { - "name" : "participant" - }, - "scheduleAgent" : "server", - "name" : "Klaes Ashford", - "locationId" : "em4eal0o", - "description" : "As the first officer on the Behemoth" - }, - "eegh7uph" : { - "description" : "Called the meeting", - "locationId" : "ux1uokie", - "scheduleUpdated" : "2025-10-01T11:59:12Z", - "sendTo" : { - "imip" : "mailto:adawes@opa.org" - }, - "scheduleAgent" : "server", - "scheduleStatus" : [ - "1.0" - ], - "name" : "Anderson Dawes", - "invitedBy" : "eegh7uph", - "language" : "en-GB", - "links" : { - "ieni5eiw" : { - "rel" : "icon", - "display" : "badge", - "@type" : "Link", - "title" : "Anderson Dawes", - "href" : "https://static.wikia.nocookie.net/expanse/images/1/1e/OPA_leader.png/revision/latest?cb=20250121103410", - "contentType" : "image/png" - } - }, - "iCalComponent" : { - "name" : "participant" - }, - "scheduleSequence" : 1, - "scheduleId" : "mailto:adawes@opa.org", - "@type" : "Participant" - } - }, - "status" : "confirmed", - "description" : "Internal meeting about the grand strategy for the future", - "locale" : "en-US", - "recurrenceRule" : { - "count" : 4, - "rscale" : "iso8601", - "frequency" : "weekly", - "interval" : 1, - "firstDayOfWeek" : "monday", - "skip" : "omit" - }, - "descriptionContentType" : "text/plain", - "isOrigin" : true, - "prodId" : "Mock 0.0", - "privacy" : "public" - } - ], - "accountId" : "b", - "notFound" : [] - }, - "b:1" - ] - ] -} - ` - - require := require.New(t) - var response Response - err := json.Unmarshal([]byte(payload), &response) - require.NoError(err) - r1 := response.MethodResponses[1] - require.Equal(CommandCalendarEventGet, r1.Command) - get := r1.Parameters.(CalendarEventGetResponse) - require.Len(get.List, 1) - result := get.List[0] - require.Len(result.VirtualLocations, 1) - require.Len(result.Locations, 1) - require.Equal("9a7ab91a-edca-4988-886f-25e00743430d", result.Uid) - require.Equal(jscalendar.PrivacyPublic, result.Privacy) -} - -func TestAlertWithOffsetTriggerInResponse(t *testing.T) { - require := require.New(t) - - text := `{ - "methodResponses":[ - ["CalendarEvent/get",{ - "accountId":"b", - "state":"ssecq", - "list":[{ - "@type": "Event", - "start":"2025-11-01T14:30:00", - "alerts": { - "M87fT82": { - "@type": "Alert", - "trigger": { - "@type": "OffsetTrigger", - "offset": "-PT15M", - "relativeTo": "start" - } - } - } - }], - "notFound":[] - },"1"] - ],"sessionState":"7d3cae5b" -}` - - var response Response - err := json.Unmarshal([]byte(text), &response) - require.NoError(err) - - resp := response.MethodResponses[0] - require.Equal(CommandCalendarEventGet, resp.Command) - require.IsType(CalendarEventGetResponse{}, resp.Parameters) - params := resp.Parameters.(CalendarEventGetResponse) - require.Len(params.List, 1) - event := params.List[0] - require.Contains(event.Alerts, "M87fT82") - alert := event.Alerts["M87fT82"] - require.NotNil(alert) - trigger := alert.Trigger - require.NotNil(trigger) - require.IsType(jscalendar.OffsetTrigger{}, trigger) - offsetTrigger := trigger.(jscalendar.OffsetTrigger) - require.Equal(jscalendar.SignedDuration("-PT15M"), offsetTrigger.Offset) - require.Equal(jscalendar.RelativeToStart, offsetTrigger.RelativeTo) -} - -func TestParseContactCardMedia(t *testing.T) { - require := require.New(t) - - text := `{ - "methodResponses":[ - ["ContactCard/get",{ - "accountId":"b", - "state":"ssecq", - "list":[{ - "prodId": "jmaptest", - "@type": "Card", - "kind": "individual", - "media": { - "aT8afd59": { - "uri": "https://picsum.photos/id/96/128/128", - "kind": "photo", - "@type": "Media", - "contexts": { - "work": true - } - } - } - }], - "notFound":[] - },"1"] - ],"sessionState":"7d3cae5b" -}` - - var response Response - err := json.Unmarshal([]byte(text), &response) - require.NoError(err) - - resp := response.MethodResponses[0] - require.Equal(CommandContactCardGet, resp.Command) - require.IsType(ContactCardGetResponse{}, resp.Parameters) - params := resp.Parameters.(ContactCardGetResponse) - require.Len(params.List, 1) - contact := params.List[0] - require.Contains(contact.Media, "aT8afd59") - media := contact.Media["aT8afd59"] - require.NotNil(media) - require.Equal("https://picsum.photos/id/96/128/128", media.Uri) - require.Equal(jscontact.MediaKindPhoto, media.Kind) -} diff --git a/pkg/jmap/jmap_tools_test.go b/pkg/jmap/jmap_tools_test.go index 2d20576790..dcdbf2ffa4 100644 --- a/pkg/jmap/jmap_tools_test.go +++ b/pkg/jmap/jmap_tools_test.go @@ -7,87 +7,6 @@ import ( "github.com/stretchr/testify/require" ) -func TestDeserializeMailboxGetResponse(t *testing.T) { - require := require.New(t) - jsonBytes, _, jmapErr := serveTestFile(t, "mailboxes1.json") - require.NoError(jmapErr) - var data Response - err := json.Unmarshal(jsonBytes, &data) - require.NoError(err) - require.Empty(data.CreatedIds) - require.Equal("3e25b2a0", data.SessionState) - require.Len(data.MethodResponses, 1) - resp := data.MethodResponses[0] - require.Equal(CommandMailboxGet, resp.Command) - require.Equal("0", resp.Tag) - require.IsType(MailboxGetResponse{}, resp.Parameters) - mgr := resp.Parameters.(MailboxGetResponse) - require.Equal("cs", mgr.AccountId) - require.Len(mgr.List, 5) - require.Equal("n", mgr.State) - require.Empty(mgr.NotFound) - var folders = []struct { - id string - name string - role string - total int - unread int - }{ - {"a", "Inbox", "inbox", 10, 8}, - {"b", "Deleted Items", "trash", 20, 0}, - {"c", "Junk Mail", "junk", 0, 0}, - {"d", "Drafts", "drafts", 0, 0}, - {"e", "Sent Items", "sent", 0, 0}, - } - for i, expected := range folders { - folder := mgr.List[i] - require.Equal(expected.id, folder.Id) - require.Equal(expected.name, folder.Name) - require.Equal(expected.role, folder.Role) - require.Equal(expected.total, folder.TotalEmails) - require.Equal(expected.total, folder.TotalThreads) - require.Equal(expected.unread, folder.UnreadEmails) - require.Equal(expected.unread, folder.UnreadThreads) - require.Empty(folder.ParentId) - require.Zero(folder.SortOrder) - require.Nil(folder.IsSubscribed) - - require.True(folder.MyRights.MayReadItems) - require.True(folder.MyRights.MayAddItems) - require.True(folder.MyRights.MayRemoveItems) - require.True(folder.MyRights.MaySetSeen) - require.True(folder.MyRights.MaySetKeywords) - require.True(folder.MyRights.MayCreateChild) - require.True(folder.MyRights.MayRename) - require.True(folder.MyRights.MayDelete) - require.True(folder.MyRights.MaySubmit) - } -} - -func TestDeserializeEmailGetResponse(t *testing.T) { - require := require.New(t) - jsonBytes, _, jmapErr := serveTestFile(t, "mails1.json") - require.NoError(jmapErr) - var data Response - err := json.Unmarshal(jsonBytes, &data) - require.NoError(err) - require.Empty(data.CreatedIds) - require.Equal("3e25b2a0", data.SessionState) - require.Len(data.MethodResponses, 2) - resp := data.MethodResponses[1] - require.Equal(CommandEmailGet, resp.Command) - require.Equal("1", resp.Tag) - require.IsType(EmailGetResponse{}, resp.Parameters) - egr := resp.Parameters.(EmailGetResponse) - require.Equal("d", egr.AccountId) - require.Len(egr.List, 3) - require.Equal("suqmq", egr.State) - require.Empty(egr.NotFound) - email := egr.List[0] - require.Equal("moyaaaddw", email.Id) - require.Equal("cbejozsk1fgcviw7thwzsvtgmf1ep0a3izjoimj02jmtsunpeuwmsaya1yma", email.BlobId) -} - func TestUnmarshallingError(t *testing.T) { require := require.New(t) diff --git a/pkg/jmap/testdata/mailboxes1.json b/pkg/jmap/testdata/mailboxes1.json deleted file mode 100644 index 28437a7636..0000000000 --- a/pkg/jmap/testdata/mailboxes1.json +++ /dev/null @@ -1,121 +0,0 @@ -{"methodResponses": [ - ["Mailbox/get", { - "accountId":"cs", - "state":"n", - "list": [ - { - "id":"a", - "name":"Inbox", - "parentId":null, - "role":"inbox", - "sortOrder":0, - "isSubscribed":true, - "totalEmails":10, - "unreadEmails":8, - "totalThreads":10, - "unreadThreads":8, - "myRights":{ - "mayReadItems":true, - "mayAddItems":true, - "mayRemoveItems":true, - "maySetSeen":true, - "maySetKeywords":true, - "mayCreateChild":true, - "mayRename":true, - "mayDelete":true, - "maySubmit":true - } - },{ - "id":"b", - "name":"Deleted Items", - "parentId":null, - "role":"trash", - "sortOrder":0, - "isSubscribed":true, - "totalEmails":20, - "unreadEmails":0, - "totalThreads":20, - "unreadThreads":0, - "myRights":{ - "mayReadItems":true, - "mayAddItems":true, - "mayRemoveItems":true, - "maySetSeen":true, - "maySetKeywords":true, - "mayCreateChild":true, - "mayRename":true, - "mayDelete":true, - "maySubmit":true - } - },{ - "id":"c", - "name":"Junk Mail", - "parentId":null, - "role":"junk", - "sortOrder":0, - "isSubscribed":true, - "totalEmails":0, - "unreadEmails":0, - "totalThreads":0, - "unreadThreads":0, - "myRights":{ - "mayReadItems":true, - "mayAddItems":true, - "mayRemoveItems":true, - "maySetSeen":true, - "maySetKeywords":true, - "mayCreateChild":true, - "mayRename":true, - "mayDelete":true, - "maySubmit":true - } - },{ - "id":"d", - "name":"Drafts", - "parentId":null, - "role":"drafts", - "sortOrder":0, - "isSubscribed":true, - "totalEmails":0, - "unreadEmails":0, - "totalThreads":0, - "unreadThreads":0, - "myRights":{ - "mayReadItems":true, - "mayAddItems":true, - "mayRemoveItems":true, - "maySetSeen":true, - "maySetKeywords":true, - "mayCreateChild":true, - "mayRename":true, - "mayDelete":true, - "maySubmit":true - } - },{ - "id":"e", - "name":"Sent Items", - "parentId":null, - "role":"sent", - "sortOrder":0, - "isSubscribed":true, - "totalEmails":0, - "unreadEmails":0, - "totalThreads":0, - "unreadThreads":0, - "myRights":{ - "mayReadItems":true, - "mayAddItems":true, - "mayRemoveItems":true, - "maySetSeen":true, - "maySetKeywords":true, - "mayCreateChild":true, - "mayRename":true, - "mayDelete":true, - "maySubmit":true - } - } - ], - "notFound":[] - },"a:0"] -], "sessionState":"3e25b2a0" -} diff --git a/pkg/jmap/testdata/mails1.json b/pkg/jmap/testdata/mails1.json deleted file mode 100644 index 21a1d08cba..0000000000 --- a/pkg/jmap/testdata/mails1.json +++ /dev/null @@ -1,277 +0,0 @@ -{ - "methodResponses": [ - [ - "Email/query", - { - "accountId": "d", - "queryState": "suqmq", - "canCalculateChanges": true, - "position": 0, - "ids": [ - "moyaaaddw", - "mouaaaddv", - "moqaaaddu" - ], - "limit": 3 - }, - "0" - ], - [ - "Email/get", - { - "accountId": "d", - "state": "suqmq", - "list": [ - { - "id": "moyaaaddw", - "blobId": "cbejozsk1fgcviw7thwzsvtgmf1ep0a3izjoimj02jmtsunpeuwmsaya1yma", - "threadId": "ddw", - "mailboxIds": { - "a": true - }, - "keywords": {}, - "size": 1842, - "receivedAt": "2025-06-02T09:31:01Z", - "messageId": [ - "1748856661.7568858@example.com" - ], - "inReplyTo": null, - "references": null, - "sender": [ - { - "name": "Beloved Deadly", - "email": "beloved.deadly@example.com" - } - ], - "from": [ - { - "name": "Beloved Deadly", - "email": "beloved.deadly@example.com" - } - ], - "to": [ - { - "name": "alan", - "email": "alan@example.com" - } - ], - "cc": [ - { - "name": "Team Lead", - "email": "lead@example.com" - }, - { - "name": "Coworker", - "email": "coworker@example.com" - } - ], - "bcc": null, - "replyTo": null, - "subject": "Ornare Senectus Ultrices Elit", - "sentAt": "2025-06-02T11:31:01+02:00", - "hasAttachment": false, - "preview": "Lorem tortor eros blandit adipiscing scelerisque fermentum fames himenaeos varius pulvinar nascetur erat turpis risus sagittis felis augue efficitur proin ante id suspendisse eu mattis.\nEst sociosqu arcu elit penatibus vehicula magnis senectus maximus m...", - "bodyValues": { - "0": { - "isEncodingProblem": false, - "isTruncated": false, - "value": "

Lorem tortor eros blandit adipiscing scelerisque fermentum fames himenaeos varius pulvinar nascetur erat turpis risus sagittis felis augue efficitur proin ante id suspendisse eu mattis.

\n

Est sociosqu arcu elit penatibus vehicula magnis senectus maximus massa nisl praesent viverra malesuada sapien. Semper tempus ridiculus habitasse pharetra molestie vehicula class, placerat pulvinar interdum viverra nam feugiat blandit urna inceptos lobortis odio imperdiet ante neque. Dui vulputate finibus lacinia non ultricies conubia velit vestibulum eleifend venenatis nec quam justo magnis urna aliquet commodo senectus montes lectus blandit tortor sollicitudin elit curae natoque tempus imperdiet curabitur. Phasellus imperdiet mollis ultrices tellus pellentesque sodales malesuada sociosqu curae finibus nostra taciti ultricies duis quam ex habitant fusce vitae torquent felis vulputate. Urna non litora adipiscing mollis hac consectetur fusce duis sit imperdiet pretium arcu malesuada magna faucibus ad himenaeos consequat etiam fermentum. Elit ac placerat conubia dis malesuada dui torquent odio convallis pulvinar, netus lectus natoque taciti ultrices nostra vivamus fringilla lacinia feugiat aenean at ultricies mi fusce lobortis amet est nec phasellus. Dapibus mus venenatis cursus maecenas ultrices rutrum convallis velit pretium sodales mi lorem cras hac semper nibh laoreet curabitur sem est ultricies in lacus ornare senectus blandit.

" - } - }, - "textBody": [ - { - "partId": "0", - "blobId": "cfejozsk1fgcviw7thwzsvtgmf1ep0a3izjoimj02jmtsunpeuwmsaya1ymiqa0kbm", - "size": 1450, - "name": null, - "type": "text/html", - "charset": "utf-8", - "disposition": null, - "cid": null, - "language": null, - "location": null - } - ], - "htmlBody": [ - { - "partId": "0", - "blobId": "cfejozsk1fgcviw7thwzsvtgmf1ep0a3izjoimj02jmtsunpeuwmsaya1ymiqa0kbm", - "size": 1450, - "name": null, - "type": "text/html", - "charset": "utf-8", - "disposition": null, - "cid": null, - "language": null, - "location": null - } - ], - "attachments": [] - }, - { - "id": "mouaaaddv", - "blobId": "can2gyjt2yc1s0jo7zvikomhq0bhh7atp177v1l039kqtdqyicpiuaya1uma", - "threadId": "ddv", - "mailboxIds": { - "a": true - }, - "keywords": {}, - "size": 4603, - "receivedAt": "2025-06-02T09:31:01Z", - "messageId": [ - "1748856661.8998663@example.com" - ], - "inReplyTo": null, - "references": null, - "sender": [ - { - "name": "Helped Notably", - "email": "helped.notably@example.com" - } - ], - "from": [ - { - "name": "Helped Notably", - "email": "helped.notably@example.com" - } - ], - "to": [ - { - "name": "alan", - "email": "alan@example.com" - } - ], - "cc": null, - "bcc": null, - "replyTo": null, - "subject": "Lorem Tortor Eros Blandit Adipiscing Scelerisque Fermentum", - "sentAt": "2025-06-02T11:31:01+02:00", - "hasAttachment": false, - "preview": "Consectetur facilisi suscipit ex ultrices nibh torquent fermentum urna et, aptent nostra euismod tempus scelerisque inceptos quis aenean magna nec tellus sociosqu est mauris commodo congue blandit cursus cubilia. Metus congue magna imperdiet tempor dign...", - "bodyValues": { - "1": { - "isEncodingProblem": false, - "isTruncated": false, - "value": "Consectetur facilisi suscipit ex ultrices nibh torquent fermentum urna et, aptent nostra euismod tempus scelerisque inceptos quis aenean magna nec tellus sociosqu est mauris commodo congue blandit cursus cubilia. Metus congue magna imperdiet tempor dignissim phasellus quam per ridiculus curabitur taciti potenti tellus faucibus morbi erat aliquam euismod nascetur mattis. Potenti mauris sollicitudin netus a neque nascetur auctor aptent purus sodales ultricies finibus euismod dolor vivamus vestibulum congue dis id leo inceptos natoque torquent quis libero. Venenatis pharetra pellentesque sit quisque quis posuere efficitur imperdiet, ullamcorper ornare augue a porttitor fermentum ad phasellus quam proin dolor mollis vestibulum eu inceptos nostra. Vestibulum orci consequat arcu eros pharetra nunc blandit nibh ligula risus porta auctor diam lectus maximus commodo interdum dapibus morbi duis. Efficitur vehicula at lacus augue sit dapibus non suspendisse laoreet lacinia metus nam sollicitudin luctus sagittis leo ullamcorper tempus platea mi aptent aliquet primis. Ipsum erat cursus ad fusce sagittis dui convallis magnis mi porttitor aliquam quis efficitur inceptos commodo conubia vivamus sociosqu interdum himenaeos penatibus mollis platea in fames auctor.\nLobortis netus arcu malesuada finibus euismod massa ut fames mattis dignissim leo suspendisse purus parturient iaculis consectetur hac imperdiet commodo dui. Ante efficitur ut amet aenean gravida eleifend justo ipsum suspendisse bibendum dignissim leo pharetra sit tincidunt vel aliquam turpis elit feugiat facilisis quis tellus aliquet class magna consequat. Gravida felis elit ipsum convallis ornare integer orci lectus semper mattis sodales fusce sed in hendrerit pellentesque himenaeos aenean velit. Sed dui facilisi morbi nullam per sollicitudin ligula taciti tellus quisque faucibus sapien penatibus maecenas mattis consequat tempor litora volutpat posuere nisi lacinia luctus interdum nam tortor dictum." - }, - "2": { - "isEncodingProblem": false, - "isTruncated": false, - "value": "

Consectetur facilisi suscipit ex ultrices nibh torquent fermentum urna et, aptent nostra euismod tempus scelerisque inceptos quis aenean magna nec tellus sociosqu est mauris commodo congue blandit cursus cubilia. Metus congue magna imperdiet tempor dignissim phasellus quam per ridiculus curabitur taciti potenti tellus faucibus morbi erat aliquam euismod nascetur mattis. Potenti mauris sollicitudin netus a neque nascetur auctor aptent purus sodales ultricies finibus euismod dolor vivamus vestibulum congue dis id leo inceptos natoque torquent quis libero. Venenatis pharetra pellentesque sit quisque quis posuere efficitur imperdiet, ullamcorper ornare augue a porttitor fermentum ad phasellus quam proin dolor mollis vestibulum eu inceptos nostra. Vestibulum orci consequat arcu eros pharetra nunc blandit nibh ligula risus porta auctor diam lectus maximus commodo interdum dapibus morbi duis. Efficitur vehicula at lacus augue sit dapibus non suspendisse laoreet lacinia metus nam sollicitudin luctus sagittis leo ullamcorper tempus platea mi aptent aliquet primis. Ipsum erat cursus ad fusce sagittis dui convallis magnis mi porttitor aliquam quis efficitur inceptos commodo conubia vivamus sociosqu interdum himenaeos penatibus mollis platea in fames auctor.

\n

Lobortis netus arcu malesuada finibus euismod massa ut fames mattis dignissim leo suspendisse purus parturient iaculis consectetur hac imperdiet commodo dui. Ante efficitur ut amet aenean gravida eleifend justo ipsum suspendisse bibendum dignissim leo pharetra sit tincidunt vel aliquam turpis elit feugiat facilisis quis tellus aliquet class magna consequat. Gravida felis elit ipsum convallis ornare integer orci lectus semper mattis sodales fusce sed in hendrerit pellentesque himenaeos aenean velit. Sed dui facilisi morbi nullam per sollicitudin ligula taciti tellus quisque faucibus sapien penatibus maecenas mattis consequat tempor litora volutpat posuere nisi lacinia luctus interdum nam tortor dictum.

" - } - }, - "textBody": [ - { - "partId": "1", - "blobId": "cen2gyjt2yc1s0jo7zvikomhq0bhh7atp177v1l039kqtdqyicpiuaya1umo7a0zb2", - "size": 1977, - "name": null, - "type": "text/plain", - "charset": "utf-8", - "disposition": null, - "cid": null, - "language": null, - "location": null - } - ], - "htmlBody": [ - { - "partId": "2", - "blobId": "cen2gyjt2yc1s0jo7zvikomhq0bhh7atp177v1l039kqtdqyicpiuaya1umicfghb2", - "size": 1991, - "name": null, - "type": "text/html", - "charset": "utf-8", - "disposition": null, - "cid": null, - "language": null, - "location": null - } - ], - "attachments": [] - }, - { - "id": "moqaaaddu", - "blobId": "cbnhjfwus1qzaro9g77ccattplywro3h209ajriudofqma00u2eo1aya1qma", - "threadId": "ddu", - "mailboxIds": { - "a": true - }, - "keywords": {}, - "size": 10654, - "receivedAt": "2025-06-02T09:31:01Z", - "messageId": [ - "1748856661.8411591@example.com" - ], - "inReplyTo": null, - "references": null, - "sender": [ - { - "name": "Endless Virtually", - "email": "endless.virtually@example.com" - } - ], - "from": [ - { - "name": "Endless Virtually", - "email": "endless.virtually@example.com" - } - ], - "to": [ - { - "name": "alan", - "email": "alan@example.com" - } - ], - "cc": null, - "bcc": null, - "replyTo": null, - "subject": "Consectetur Facilisi Suscipit Ex Ultrices Nibh Torquent Fermentum Urna", - "sentAt": "2025-06-02T11:31:01+02:00", - "hasAttachment": false, - "preview": "Congue a rutrum vestibulum finibus dictum pharetra vehicula tortor ultrices rhoncus, litora tempus phasellus sapien class cursus gravida justo inceptos eleifend nisl ad posuere et magnis ullamcorper vitae porta suspendisse amet. Fringilla turpis ultrici...", - "bodyValues": { - "0": { - "isEncodingProblem": false, - "isTruncated": false, - "value": "Congue a rutrum vestibulum finibus dictum pharetra vehicula tortor ultrices rhoncus, litora tempus phasellus sapien class cursus gravida justo inceptos eleifend nisl ad posuere et magnis ullamcorper vitae porta suspendisse amet. Fringilla turpis ultricies non senectus mi lectus lacus, consequat finibus risus ligula semper laoreet malesuada sociosqu natoque fames lobortis libero ex curae interdum. Placerat etiam viverra odio posuere sodales ullamcorper penatibus nisl pretium sociosqu tortor montes netus tristique porttitor mattis varius facilisi dui neque mollis vivamus metus platea. Consectetur nascetur laoreet commodo aliquet amet bibendum lacus mattis mollis suspendisse himenaeos inceptos adipiscing montes sodales viverra a elementum dignissim. Luctus netus laoreet dis magnis cursus ligula phasellus interdum conubia senectus rutrum efficitur dolor maximus torquent odio velit fames potenti adipiscing sit metus lacus tortor. Ac imperdiet torquent nam natoque placerat faucibus tempor finibus ante integer at pellentesque nascetur sodales morbi leo eleifend ultricies euismod luctus eros tempus varius habitasse tortor erat laoreet sed et interdum. Augue mollis sodales dictumst sem eros sollicitudin imperdiet fusce vitae diam libero ullamcorper tortor accumsan pulvinar platea elit velit praesent potenti.\nPotenti nam diam quam senectus mus condimentum torquent, posuere hendrerit netus habitant vestibulum sagittis dignissim montes risus neque etiam proin ante elementum purus. Torquent porta nibh phasellus arcu maecenas curae elit sit habitant ultrices mus nisi metus ridiculus venenatis montes nulla senectus enim mauris semper. Laoreet suscipit lectus conubia aptent a felis ultricies cras platea amet sapien proin potenti luctus quam nisl sollicitudin blandit dignissim mollis. Quam maximus dolor accumsan ad quisque dictum ornare tellus fusce congue aptent tristique eros rhoncus nibh aenean parturient ipsum nostra ultrices hac.\nTurpis porttitor rhoncus pellentesque tempus praesent auctor orci tristique suspendisse bibendum class mollis sollicitudin pulvinar mi augue maximus aliquam conubia odio imperdiet. Dignissim potenti penatibus imperdiet quisque morbi cubilia lorem quis aenean adipiscing consequat sociosqu est aliquam ut phasellus ullamcorper nisi nisl mollis tortor proin gravida commodo ligula laoreet ex eget. Sem cursus sapien iaculis nulla orci vehicula varius, hac efficitur integer vestibulum fringilla quisque facilisis habitant id nam accumsan primis enim nibh odio. Iaculis dapibus est luctus dis euismod purus sociosqu magna felis vitae scelerisque curae tempor nisl primis dictumst dignissim non sit taciti suscipit pretium duis elit quisque suspendisse penatibus donec egestas.\nSenectus etiam tempus sollicitudin vivamus id quam eu, proin semper sodales volutpat velit pharetra ut gravida nec leo nascetur sapien commodo accumsan lacus. Dolor felis sagittis elementum sodales pulvinar mauris consequat eleifend, torquent taciti diam lobortis malesuada tristique ad convallis ornare aenean facilisi posuere hendrerit non montes. Hac suspendisse amet netus lorem erat ex vulputate quisque placerat ultrices tincidunt morbi dictum eleifend ut curabitur fames tortor sem. Est ante ut ex nunc tincidunt taciti eget ipsum imperdiet aliquam placerat class sociosqu cras at a eleifend per himenaeos sagittis quisque interdum dictum convallis suspendisse. Nibh sed vivamus ad magnis fermentum lorem fames ultricies quis curae, amet pulvinar nisi commodo maximus tellus sapien ante mattis purus tincidunt mi hendrerit morbi tristique adipiscing ipsum himenaeos ligula et. Neque tempus amet condimentum commodo euismod leo dis lacus nisl dignissim, hendrerit mi quisque facilisi massa habitant nec enim felis integer dolor sapien vitae parturient est nostra curae ornare mus etiam dui. Habitant vel integer fringilla curae auctor quisque porta lectus sodales lorem lobortis taciti, dignissim maecenas aptent sit curabitur velit ridiculus fames dolor sollicitudin condimentum purus arcu cursus vehicula adipiscing consequat amet facilisis turpis ultrices porttitor vivamus. Bibendum aliquet sem felis magnis nascetur gravida dolor porttitor augue lobortis risus blandit habitasse auctor nam litora convallis morbi ullamcorper nibh odio pharetra ornare per netus tristique volutpat enim.\nEuismod nibh elementum arcu etiam vel orci egestas cursus ultricies, integer turpis vitae porta dolor dignissim libero tristique vestibulum a est proin nec vivamus suspendisse rutrum semper inceptos. Mi consequat odio litora lorem pulvinar blandit ac lectus pharetra dolor, dignissim sociosqu ad rhoncus duis bibendum interdum primis montes morbi ex vestibulum est adipiscing potenti conubia eros ullamcorper dui. Velit libero sem arcu eros dapibus nostra ante cursus aliquam aliquet convallis congue sociosqu magnis malesuada rhoncus et amet lobortis bibendum pulvinar molestie. Ut ipsum ligula sapien viverra sociosqu habitant habitasse nam himenaeos dis facilisis id semper aliquet mauris magnis consectetur diam volutpat. Ante amet vel felis diam sagittis ullamcorper ultrices cursus enim conubia primis fermentum cras quam fringilla vehicula aliquam duis consectetur augue velit praesent quis eleifend. Vestibulum condimentum eleifend sociosqu efficitur habitant ultricies porta donec aliquet enim, penatibus nullam dis nulla pharetra rutrum laoreet tellus nisl lacus primis nam tempus ultrices pretium suscipit vivamus rhoncus arcu. Senectus non neque ac pellentesque amet felis id dolor porta luctus elementum nisl eleifend risus laoreet bibendum facilisi litora. Rhoncus quam odio tristique mi taciti phasellus egestas lacus senectus sodales nullam himenaeos tellus diam facilisi nunc consequat sollicitudin dolor semper enim nulla justo.\nQuisque sem lectus himenaeos ac aliquam nibh ultrices bibendum nullam ipsum viverra euismod etiam senectus commodo pretium tincidunt volutpat non nostra torquent cras. Nibh congue rhoncus lobortis taciti diam proin lacus nisl dis sodales euismod finibus felis suscipit viverra commodo laoreet molestie rutrum nisi ex maecenas. Ridiculus venenatis justo mattis sit fringilla nisi arcu aptent primis quis ipsum habitant tincidunt vel adipiscing. Bibendum rhoncus convallis proin sollicitudin praesent dui maximus at hendrerit turpis ultrices fames enim vel orci molestie conubia himenaeos vestibulum dolor mauris ut lobortis hac rutrum. Potenti varius aliquam volutpat neque bibendum pretium mus justo natoque ac turpis montes sollicitudin orci cubilia posuere parturient. Aenean nisl montes dignissim at vitae nec vel nam nunc lectus iaculis augue nibh dictumst.\nHimenaeos quis primis elementum scelerisque fringilla litora proin eu porttitor duis magnis urna mi maecenas vestibulum curae phasellus amet facilisi augue. Vel enim facilisis vehicula elit eleifend ut natoque lobortis, dolor pretium egestas non nascetur habitant condimentum odio dapibus porttitor rutrum sem nec fermentum luctus consequat. Netus natoque fames ligula iaculis duis quam facilisis cras, nam felis per eleifend himenaeos senectus maximus suscipit quis amet est tempor sem nascetur orci dictum. Nunc inceptos habitasse nisl est id semper elit lacus, adipiscing interdum viverra primis dis porta pulvinar montes class ad duis netus potenti per rutrum nostra. Primis sit gravida consequat fermentum platea curabitur orci dis senectus id iaculis pellentesque ad elit duis feugiat lorem vitae sodales nisi mus euismod.\nLigula amet interdum fringilla mauris hendrerit semper justo nisl aenean facilisis sed tellus venenatis lorem sapien. Condimentum et pulvinar tempus aenean scelerisque aliquet donec inceptos lacinia nulla, penatibus ultrices sapien nec volutpat quisque ridiculus natoque habitant ultricies nibh feugiat accumsan a arcu lorem pharetra faucibus taciti posuere. Malesuada amet suscipit dictum nam id varius taciti eu sollicitudin erat convallis nostra scelerisque neque justo finibus. Eros hac ultrices mus felis pellentesque maecenas platea tellus est interdum accumsan tempus adipiscing nostra nec neque vitae suspendisse quam. Velit imperdiet diam senectus dictum metus ipsum nisl quam habitasse nibh commodo convallis integer netus magna lectus ante lacus per. Inceptos commodo sed ullamcorper volutpat libero at varius lacus ad ac facilisis posuere accumsan diam fames pretium penatibus mauris ornare mollis aenean gravida finibus quis curae.\nFinibus libero conubia lobortis netus at nisl sed leo amet consectetur laoreet phasellus fusce nascetur dis molestie dolor quis duis nibh primis cubilia curabitur hac potenti. Himenaeos erat etiam libero commodo auctor sit duis tortor mi rhoncus eu ridiculus sem nulla amet ornare potenti enim feugiat mus placerat posuere pretium curabitur sociosqu. Phasellus fames massa non est ultrices aenean velit, tempor ipsum felis interdum fringilla pretium magna nisi dignissim ante ridiculus volutpat vestibulum. Per vulputate quam maximus fermentum conubia ullamcorper lorem integer eu bibendum auctor tortor sodales congue morbi semper nullam himenaeos pulvinar facilisi senectus dignissim.\nLobortis ullamcorper est ultrices dis tempus accumsan ex felis, fusce velit efficitur mattis ac facilisi bibendum suspendisse augue tellus fermentum eleifend aenean ante mollis aliquam. Ornare himenaeos nostra gravida etiam fermentum vehicula morbi odio vestibulum tempor, est porttitor congue habitant neque facilisis fames eleifend dapibus varius ex justo blandit purus leo aenean litora viverra scelerisque. Et consequat aliquam class mi ultricies nunc sagittis turpis lorem odio nullam est suspendisse ad mattis dis id posuere aptent proin maecenas pharetra fringilla. Per nulla volutpat luctus vestibulum cubilia ullamcorper quisque venenatis aptent enim curae aenean nullam dis conubia ligula maecenas phasellus curabitur. Platea aptent vehicula urna est nisl porttitor ornare at fusce, vestibulum fames torquent potenti lectus cras suspendisse tincidunt ipsum natoque commodo nibh quisque praesent ultrices facilisi egestas dis turpis. Eleifend nulla fusce neque vel egestas congue libero ridiculus urna curabitur placerat sapien sociosqu nunc convallis nascetur dapibus ultricies tincidunt pellentesque aliquet duis malesuada laoreet condimentum venenatis erat potenti turpis." - } - }, - "textBody": [ - { - "partId": "0", - "blobId": "cfnhjfwus1qzaro9g77ccattplywro3h209ajriudofqma00u2eo1aya1qmpsavfka", - "size": 10277, - "name": null, - "type": "text/plain", - "charset": "utf-8", - "disposition": null, - "cid": null, - "language": null, - "location": null - } - ], - "htmlBody": [ - { - "partId": "0", - "blobId": "cfnhjfwus1qzaro9g77ccattplywro3h209ajriudofqma00u2eo1aya1qmpsavfka", - "size": 10277, - "name": null, - "type": "text/plain", - "charset": "utf-8", - "disposition": null, - "cid": null, - "language": null, - "location": null - } - ], - "attachments": [] - } - ], - "notFound": [] - }, - "1" - ] - ], - "sessionState": "3e25b2a0" -} \ No newline at end of file diff --git a/pkg/jscalendar/jscalendar_model.go b/pkg/jscalendar/jscalendar_model.go index 53bdb640aa..9bc86711ce 100644 --- a/pkg/jscalendar/jscalendar_model.go +++ b/pkg/jscalendar/jscalendar_model.go @@ -1410,8 +1410,8 @@ func MapstructTriggerHook() mapstructure.DecodeHookFunc { return data, nil } m := data.(map[string]any) - if typ, ok := m["@type"]; ok { - switch typ { + if t, ok := m["@type"]; ok { + switch t { case string(OffsetTriggerType): return mapOffsetTrigger(m) case string(AbsoluteTriggerType): @@ -1517,25 +1517,37 @@ type Alert struct { } func (a *Alert) UnmarshalJSON(b []byte) error { - var typ struct { + // use a simplified Trigger structure to only deserialize some of + // its fields, only in order to detect which Trigger type it actually + // is (Offset, Absolute, or Unknown) + var peekAlert struct { Trigger struct { - Type string `json:"@type"` + Type string `json:"@type"` + Offset string `json:"offset"` + When string `json:"when"` } `json:"trigger"` } - if err := json.Unmarshal(b, &typ); err != nil { + if err := json.Unmarshal(b, &peekAlert); err != nil { return err } - switch typ.Trigger.Type { + switch peekAlert.Trigger.Type { case string(OffsetTriggerType): a.Trigger = new(OffsetTrigger) case string(AbsoluteTriggerType): a.Trigger = new(AbsoluteTrigger) default: - a.Trigger = new(UnknownTrigger) + // it could still be a Trigger without its optional @type field + if peekAlert.Trigger.Offset != "" { // if "offset" is set, it's an OffsetTrigger + a.Trigger = new(OffsetTrigger) + } else if peekAlert.Trigger.When != "" { // if "when" is set, it's an AbsoluteTrigger + a.Trigger = new(AbsoluteTrigger) + } else { // it's neither -> UnknownTrigger + a.Trigger = new(UnknownTrigger) + } } - type tmp Alert - return json.Unmarshal(b, (*tmp)(a)) + type tmpAlert Alert // alias Alert to avoid infinite recursion into this func + return json.Unmarshal(b, (*tmpAlert)(a)) } // A `TimeZoneRule` object maps a `STANDARD` or `DAYLIGHT` sub-component from iCalendar, diff --git a/services/groupware/pkg/groupware/groupware_mock_tasks.go b/services/groupware/pkg/groupware/groupware_mock_tasks.go index 049270503b..386c958ab2 100644 --- a/services/groupware/pkg/groupware/groupware_mock_tasks.go +++ b/services/groupware/pkg/groupware/groupware_mock_tasks.go @@ -113,9 +113,8 @@ var T1 = jmap.Task{ ShowWithoutTime: false, Locations: map[string]jscalendar.Location{ "ruoth5uu": { - Type: jscalendar.LocationType, - Name: "Sol Gate", - Description: "We meet at the Sol gate", + Type: jscalendar.LocationType, + Name: "Sol Gate", LocationTypes: map[jscalendar.LocationTypeOption]bool{ jscalendar.LocationTypeOptionLandmarkAddress: true, },