From 0efbebe8beac42b769d9faec932614bc7b66a239 Mon Sep 17 00:00:00 2001
From: Pascal Bleser
Date: Mon, 13 Apr 2026 16:36:17 +0200
Subject: [PATCH] groupware: improve tests
* jmap: add UpdateContactCard and UpdateCalendarEvent funcs
* use JSON marshalling and unmarshalling into a map for toPatch()
implementations
* add updating ContactCards and CalendarEvents to tests
* add deleting ContactCards and CalendarEvents to tests
* make query response totals work when the value is 0
* tests: use CreateContactCard and CreateCalendarEvent funcs to create
objects in tests instead of using a different JMAP stack that works
with untyped maps
---
pkg/jmap/api_contact.go | 17 +-
pkg/jmap/api_event.go | 17 +-
pkg/jmap/error.go | 1 +
pkg/jmap/integration_calendar_test.go | 254 ++++++---------
pkg/jmap/integration_contact_test.go | 321 ++++++++-----------
pkg/jmap/integration_test.go | 179 ++++-------
pkg/jmap/model.go | 430 ++++++++++++++++++++------
pkg/jmap/templates.go | 12 +-
pkg/jmap/tools.go | 8 +
pkg/jscalendar/model.go | 105 +++++++
pkg/jscalendar/model_test.go | 30 ++
11 files changed, 805 insertions(+), 569 deletions(-)
diff --git a/pkg/jmap/api_contact.go b/pkg/jmap/api_contact.go
index 9d036e8da1..d684978597 100644
--- a/pkg/jmap/api_contact.go
+++ b/pkg/jmap/api_contact.go
@@ -76,7 +76,7 @@ func (j *Client) QueryContactCards(accountIds []string,
Results: get.List,
CanCalculateChanges: query.CanCalculateChanges,
Position: query.Position,
- Total: uintPtrIf(query.Total, calculateTotal),
+ Total: uintPtrIfPtr(query.Total, calculateTotal),
Limit: query.Limit,
}
},
@@ -114,3 +114,18 @@ func (j *Client) DeleteContactCard(accountId string, destroyIds []string, ctx Co
ctx,
)
}
+
+func (j *Client) UpdateContactCard(accountId string, id string, changes ContactCardChange, ctx Context) (ContactCard, SessionState, State, Language, Error) {
+ return update(j, "UpdateContactCard", ContactCardType,
+ func(update map[string]PatchObject) ContactCardSetCommand {
+ return ContactCardSetCommand{AccountId: accountId, Update: update}
+ },
+ func(id string) ContactCardGetCommand {
+ return ContactCardGetCommand{AccountId: accountId, Ids: []string{id}}
+ },
+ func(resp ContactCardSetResponse) map[string]SetError { return resp.NotUpdated },
+ func(resp ContactCardGetResponse) ContactCard { return resp.List[0] },
+ id, changes,
+ ctx,
+ )
+}
diff --git a/pkg/jmap/api_event.go b/pkg/jmap/api_event.go
index 470e2958ee..1fabadbe14 100644
--- a/pkg/jmap/api_event.go
+++ b/pkg/jmap/api_event.go
@@ -29,7 +29,7 @@ func (j *Client) QueryCalendarEvents(accountIds []string, //NOSONAR
Results: get.List,
CanCalculateChanges: query.CanCalculateChanges,
Position: query.Position,
- Total: uintPtrIf(query.Total, calculateTotal),
+ Total: uintPtrIfPtr(query.Total, calculateTotal),
Limit: query.Limit,
}
},
@@ -103,3 +103,18 @@ func (j *Client) DeleteCalendarEvent(accountId string, destroyIds []string, ctx
ctx,
)
}
+
+func (j *Client) UpdateCalendarEvent(accountId string, id string, changes CalendarEventChange, ctx Context) (CalendarEvent, SessionState, State, Language, Error) {
+ return update(j, "UpdateCalendarEvent", CalendarEventType,
+ func(update map[string]PatchObject) CalendarEventSetCommand {
+ return CalendarEventSetCommand{AccountId: accountId, Update: update}
+ },
+ func(id string) CalendarEventGetCommand {
+ return CalendarEventGetCommand{AccountId: accountId, Ids: []string{id}}
+ },
+ func(resp CalendarEventSetResponse) map[string]SetError { return resp.NotUpdated },
+ func(resp CalendarEventGetResponse) CalendarEvent { return resp.List[0] },
+ id, changes,
+ ctx,
+ )
+}
diff --git a/pkg/jmap/error.go b/pkg/jmap/error.go
index 63b7c9e0dc..6ea5f84a46 100644
--- a/pkg/jmap/error.go
+++ b/pkg/jmap/error.go
@@ -39,6 +39,7 @@ const (
JmapErrorSocketPushUnsupported
JmapErrorMissingCreatedObject
JmapInvalidObjectState
+ JmapPatchObjectSerialization
)
var (
diff --git a/pkg/jmap/integration_calendar_test.go b/pkg/jmap/integration_calendar_test.go
index cac25266fa..51b2dab0b5 100644
--- a/pkg/jmap/integration_calendar_test.go
+++ b/pkg/jmap/integration_calendar_test.go
@@ -2,11 +2,12 @@ package jmap
import (
"encoding/base64"
- "encoding/json"
"fmt"
golog "log"
+ "maps"
"math"
"math/rand"
+ "slices"
"strconv"
"strings"
"testing"
@@ -77,7 +78,7 @@ func TestEvents(t *testing.T) {
session := s.Session(user.name)
ctx := s.Context(session)
- accountId, calendarId, expectedEventsById, boxes, err := s.fillEvents(t, count, session, user)
+ accountId, calendarId, expectedEventsById, boxes, err := s.fillEvents(t, count, ctx, user)
require.NoError(err)
require.NotEmpty(accountId)
require.NotEmpty(calendarId)
@@ -89,8 +90,10 @@ func TestEvents(t *testing.T) {
{Property: CalendarEventPropertyStart, IsAscending: true},
}
+ ss := EmptySessionState
+ os := EmptyState
{
- resultsByAccount, _, _, _, err := s.client.QueryCalendarEvents([]string{accountId}, filter, sortBy, 0, 0, true, ctx)
+ resultsByAccount, sessionState, state, _, err := s.client.QueryCalendarEvents([]string{accountId}, filter, sortBy, 0, 0, true, ctx)
require.NoError(err)
require.Len(resultsByAccount, 1)
@@ -108,6 +111,9 @@ func TestEvents(t *testing.T) {
require.True(ok, "failed to find created contact by its id")
matchEvent(t, actual, expected)
}
+
+ ss = sessionState
+ os = state
}
{
@@ -118,8 +124,7 @@ func TestEvents(t *testing.T) {
for i := range slices {
position := int(i * limit)
page := min(remainder, limit)
- m, _, _, _, err := s.client.QueryCalendarEvents([]string{accountId}, filter, sortBy, position, limit, true, ctx)
- fmt.Printf("=== i=%d | limit=%d | remainder=%d | position=%d | limit=%d | results=%d\n", i, limit, remainder, position, limit, len(m[accountId].Results))
+ m, sessionState, _, _, err := s.client.QueryCalendarEvents([]string{accountId}, filter, sortBy, position, limit, true, ctx)
require.NoError(err)
require.Len(m, 1)
require.Contains(m, accountId)
@@ -131,9 +136,54 @@ func TestEvents(t *testing.T) {
require.NotNil(results.Total)
require.Equal(count, *results.Total)
remainder -= uint(len(results.Results))
+
+ require.Equal(ss, sessionState)
}
}
+ for _, event := range expectedEventsById {
+ change := CalendarEventChange{
+ EventChange: jscalendar.EventChange{
+ Status: ptr(jscalendar.StatusCancelled),
+ ObjectChange: jscalendar.ObjectChange{
+ Sequence: uintPtr(99),
+ ShowWithoutTime: boolPtr(true),
+ },
+ },
+ }
+ changed, sessionState, state, _, err := s.client.UpdateCalendarEvent(accountId, event.Id, change, ctx)
+ require.NoError(err)
+ require.Equal(jscalendar.StatusCancelled, changed.Status)
+ require.Equal(uint(99), changed.Sequence)
+ require.Equal(true, changed.ShowWithoutTime)
+ require.Equal(ss, sessionState)
+ require.NotEqual(os, state)
+ os = state
+ }
+
+ {
+ ids := structs.Map(slices.Collect(maps.Values(expectedEventsById)), func(e CalendarEvent) string { return e.Id })
+ errMap, sessionState, state, _, err := s.client.DeleteCalendarEvent(accountId, ids, ctx)
+ require.NoError(err)
+ require.Empty(errMap)
+
+ require.Equal(ss, sessionState)
+ require.NotEqual(os, state)
+ os = state
+ }
+
+ {
+ shouldBeEmpty, sessionState, state, _, err := s.client.QueryCalendarEvents([]string{accountId}, filter, sortBy, 0, 0, true, ctx)
+ require.NoError(err)
+ require.Contains(shouldBeEmpty, accountId)
+ resp := shouldBeEmpty[accountId]
+ require.Empty(resp.Results)
+ require.NotNil(resp.Total)
+ require.Equal(uint(0), *resp.Total)
+ require.Equal(ss, sessionState)
+ require.Equal(os, state)
+ }
+
exceptions := []string{}
if !EnableEventMayInviteFields {
exceptions = append(exceptions, "mayInvite")
@@ -165,7 +215,7 @@ func (s *StalwartTest) fillCalendar( //NOSONAR
boxes := CalendarBoxes{}
created := []Calendar{}
- ss := SessionState("")
+ ss := EmptySessionState
as := EmptyState
printer := func(s string) { golog.Println(s) }
@@ -269,7 +319,7 @@ func (s *StalwartTest) fillCalendar( //NOSONAR
}
require.NotEmpty(sessionState)
require.NotEmpty(state)
- if ss != SessionState("") {
+ if ss != EmptySessionState {
require.Equal(ss, sessionState)
}
if as != EmptyState {
@@ -294,11 +344,11 @@ type EventsBoxes struct {
func (s *StalwartTest) fillEvents( //NOSONAR
t *testing.T,
count uint,
- session *Session,
+ ctx Context,
user User,
) (string, string, map[string]CalendarEvent, EventsBoxes, error) {
require := require.New(t)
- c, err := NewTestJmapClient(session, user.name, user.password, true, true)
+ c, err := NewTestJmapClient(ctx.Session, user.name, user.password, true, true)
require.NoError(err)
defer c.Close()
@@ -332,7 +382,6 @@ func (s *StalwartTest) fillEvents( //NOSONAR
isDraft := false
mainLocationId := ""
locationIds := []string{}
- locationMaps := map[string]map[string]any{}
locationObjs := map[string]jscalendar.Location{}
{
n := 1
@@ -340,8 +389,7 @@ func (s *StalwartTest) fillEvents( //NOSONAR
n++
}
for range n {
- locationId, locationMap, locationObj := pickLocation()
- locationMaps[locationId] = locationMap
+ locationId, locationObj := pickLocation()
locationObjs[locationId] = locationObj
locationIds = append(locationIds, locationId)
if n > 0 && mainLocationId == "" {
@@ -349,8 +397,8 @@ func (s *StalwartTest) fillEvents( //NOSONAR
}
}
}
- virtualLocationId, virtualLocationMap, virtualLocationObj := pickVirtualLocation()
- participantMaps, participantObjs, organizerEmail := createParticipants(uid, locationIds, []string{virtualLocationId})
+ virtualLocationId, virtualLocationObj := pickVirtualLocation()
+ participantObjs, organizerEmail := createParticipants(uid, locationIds, []string{virtualLocationId})
duration := pickRandom("PT30M", "PT45M", "PT1H", "PT90M")
tz := pickRandom(timezones...)
daysDiff := rand.Intn(31) - 15
@@ -379,52 +427,10 @@ func (s *StalwartTest) fillEvents( //NOSONAR
alertId := id()
alertOffset := pickRandom("-PT5M", "-PT10M", "-PT15M")
- event := map[string]any{
- "@type": "Event",
- "calendarIds": toBoolMapS(calendarId),
- "isDraft": isDraft,
- "start": start,
- "duration": duration,
- "status": string(status),
- "uid": uid,
- "prodId": productName,
- "title": title,
- "description": description,
- "descriptionContentType": descriptionFormat,
- "locale": locale,
- "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, //NOSONAR
- },
- "locations": locationMaps,
- "virtualLocations": map[string]any{
- virtualLocationId: virtualLocationMap,
- },
- "alerts": map[string]map[string]any{
- alertId: {
- "@type": "Alert",
- "trigger": map[string]any{
- "@type": "OffsetTrigger",
- "offset": alertOffset,
- "relativeTo": "start",
- },
- },
- },
- }
-
obj := CalendarEvent{
Id: "",
CalendarIds: toBoolMapS(calendarId),
IsDraft: isDraft,
- IsOrigin: true,
Event: jscalendar.Event{
Type: jscalendar.EventType,
Start: jscalendar.LocalDateTime(start),
@@ -449,7 +455,7 @@ func (s *StalwartTest) fillEvents( //NOSONAR
TimeZone: tz,
HideAttendees: false,
ReplyTo: map[jscalendar.ReplyMethod]string{
- jscalendar.ReplyMethodImip: "mailto:" + organizerEmail,
+ jscalendar.ReplyMethodImip: "mailto:" + organizerEmail, //NOSONAR
},
Locations: locationObjs,
VirtualLocations: map[string]jscalendar.VirtualLocation{
@@ -470,31 +476,26 @@ func (s *StalwartTest) fillEvents( //NOSONAR
}
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 != "" {
- event["mainLocationId"] = mainLocationId
obj.MainLocationId = mainLocationId
}
- err = propmap(i%2 == 0, 1, 1, event, "links", &obj.Links, func(int, string) (map[string]any, jscalendar.Link, error) {
+ err = propmap(i%2 == 0, 1, 1, &obj.Links, func(int, string) (jscalendar.Link, error) {
mime := ""
uri := ""
rel := jscalendar.RelAbout
@@ -508,17 +509,12 @@ func (s *StalwartTest) fillEvents( //NOSONAR
mime = "image/jpeg" //NOSONAR
uri = externalImageUri()
}
- return map[string]any{
- "@type": "Link",
- "href": uri,
- "contentType": mime,
- "rel": string(rel),
- }, jscalendar.Link{
- Type: jscalendar.LinkType,
- Href: uri,
- ContentType: mime,
- Rel: rel,
- }, nil
+ return jscalendar.Link{
+ Type: jscalendar.LinkType,
+ Href: uri,
+ ContentType: mime,
+ Rel: rel,
+ }, nil
})
if rand.Intn(10) > 7 {
@@ -530,15 +526,6 @@ func (s *StalwartTest) fillEvents( //NOSONAR
} else {
count = 1 + rand.Intn(4)
}
- event["recurrenceRule"] = map[string]any{
- "@type": "RecurrenceRule",
- "frequency": string(frequency),
- "interval": interval,
- "rscale": string(jscalendar.RscaleIso8601),
- "skip": string(jscalendar.SkipOmit),
- "firstDayOfWeek": string(jscalendar.DayOfWeekMonday),
- "count": count,
- }
rr := jscalendar.RecurrenceRule{
Type: jscalendar.RecurrenceRuleType,
Frequency: frequency,
@@ -551,23 +538,18 @@ func (s *StalwartTest) fillEvents( //NOSONAR
obj.RecurrenceRule = &rr
}
- id, err := s.CreateEvent(c, accountId, event)
+ created, _, _, _, err := s.client.CreateCalendarEvent(accountId, obj, ctx)
if err != nil {
return accountId, calendarId, nil, boxes, err
}
- obj.Id = id
- filled[id] = obj
+ filled[created.Id] = *created
printer(fmt.Sprintf("📅 created %*s/%v id=%v", int(math.Log10(float64(count))+1), strconv.Itoa(int(i+1)), count, uid))
}
return accountId, calendarId, filled, boxes, nil
}
-func (s *StalwartTest) CreateEvent(j *TestJmapClient, accountId string, event map[string]any) (string, error) {
- return j.create1(accountId, CalendarEventType, event)
-}
-
var rooms = []jscalendar.Location{
{
Type: jscalendar.LocationType,
@@ -630,56 +612,35 @@ var virtualRooms = []jscalendar.VirtualLocation{
},
}
-func pickLocation() (string, map[string]any, jscalendar.Location) {
+func pickLocation() (string, jscalendar.Location) {
locationId := id()
room := rooms[rand.Intn(len(rooms))]
- b, err := json.Marshal(room)
- if err != nil {
- panic(err)
- }
- var m map[string]any
- err = json.Unmarshal(b, &m)
- if err != nil {
- panic(err)
- }
- return locationId, m, room
+ return locationId, room
}
-func pickVirtualLocation() (string, map[string]any, jscalendar.VirtualLocation) {
+func pickVirtualLocation() (string, jscalendar.VirtualLocation) {
locationId := id()
vroom := virtualRooms[rand.Intn(len(virtualRooms))]
- b, err := json.Marshal(vroom)
- if err != nil {
- panic(err)
- }
- var m map[string]any
- err = json.Unmarshal(b, &m)
- if err != nil {
- panic(err)
- }
- return locationId, m, vroom
+ return locationId, vroom
}
var ChairRoles = toBoolMapS(jscalendar.RoleChair, jscalendar.RoleOwner)
var RegularRoles = toBoolMapS(jscalendar.RoleOptional)
-func createParticipants(uid string, locationIds []string, virtualLocationIds []string) (map[string]map[string]any, map[string]jscalendar.Participant, string) {
+func createParticipants(uid string, locationIds []string, virtualLocationIds []string) (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...), "", "")
- maps[organizerId] = organizerMap
+ organizerId, organizerEmail, organizerObj := createParticipant(0, uid, pickRandom(options...), "", "")
objs[organizerId] = organizerObj
for i := 1; i < n; i++ {
- id, _, participantMap, participantObj := createParticipant(i, uid, pickRandom(options...), organizerId, organizerEmail)
- maps[id] = participantMap
+ id, _, participantObj := createParticipant(i, uid, pickRandom(options...), organizerId, organizerEmail)
objs[id] = participantObj
}
- return maps, objs, organizerEmail
+ return objs, organizerEmail
}
-func createParticipant(i int, uid string, locationId string, organizerEmail string, organizerId string) (string, string, map[string]any, jscalendar.Participant) {
+func createParticipant(i int, uid string, locationId string, organizerEmail string, organizerId string) (string, string, jscalendar.Participant) {
participantId := id()
person := gofakeit.Person()
roles := RegularRoles
@@ -731,26 +692,6 @@ func createParticipant(i int, uid string, locationId string, organizerEmail stri
}
}
- m := map[string]any{
- "@type": "Participant",
- "name": name,
- "email": email,
- "calendarAddress": calendarAddress,
- "kind": "individual",
- "roles": structs.MapKeys(roles, func(r jscalendar.Role) string { return string(r) }),
- "locationId": locationId,
- "language": language,
- "participationStatus": string(status),
- "participationComment": statusComment,
- "expectReply": true,
- "scheduleAgent": "server",
- "scheduleSequence": 1,
- "scheduleStatus": []string{"1.0"},
- "scheduleUpdated": updated,
- "sentBy": organizerEmail,
- "invitedBy": organizerId,
- "scheduleId": "mailto:" + email,
- }
o := jscalendar.Participant{
Type: jscalendar.ParticipantType,
Name: name,
@@ -773,36 +714,27 @@ func createParticipant(i int, uid string, locationId string, organizerEmail stri
}
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) {
+ err = propmap(i%2 == 0, 1, 2, &o.Links, func(int, string) (jscalendar.Link, error) {
href := externalImageUri()
title := person.FirstName + "'s Cake Day pick"
- return map[string]any{
- "@type": "Link",
- "href": href,
- "contentType": "image/jpeg",
- "rel": "icon",
- "display": "badge",
- "title": title,
- }, jscalendar.Link{
- Type: jscalendar.LinkType,
- Href: href,
- ContentType: "image/jpeg",
- Rel: jscalendar.RelIcon,
- Display: jscalendar.DisplayBadge,
- Title: title,
- }, nil
+ return jscalendar.Link{
+ Type: jscalendar.LinkType,
+ Href: href,
+ ContentType: "image/jpeg",
+ Rel: jscalendar.RelIcon,
+ Display: jscalendar.DisplayBadge,
+ Title: title,
+ }, nil
})
if err != nil {
panic(err)
}
- return participantId, person.Contact.Email, m, o
+ return participantId, person.Contact.Email, o
}
var Keywords = []string{
@@ -825,3 +757,7 @@ var Categories = []string{
func pickCategories() map[string]bool {
return toBoolMap(pickRandoms(Categories...))
}
+
+func ptr[T any](t T) *T {
+ return &t
+}
diff --git a/pkg/jmap/integration_contact_test.go b/pkg/jmap/integration_contact_test.go
index 7f7ed5975f..6ae7236ccf 100644
--- a/pkg/jmap/integration_contact_test.go
+++ b/pkg/jmap/integration_contact_test.go
@@ -2,10 +2,12 @@ package jmap
import (
golog "log"
+ "maps"
"math/rand"
"regexp"
"slices"
"testing"
+ "time"
"github.com/stretchr/testify/require"
@@ -86,7 +88,7 @@ func TestContacts(t *testing.T) {
session := s.Session(user.name)
ctx := s.Context(session)
- accountId, addressbookId, expectedContactCardsById, boxes, err := s.fillContacts(t, count, session, user)
+ accountId, addressbookId, expectedContactCardsById, boxes, err := s.fillContacts(t, count, session, ctx, user)
require.NoError(err)
require.NotEmpty(accountId)
require.NotEmpty(addressbookId)
@@ -98,7 +100,7 @@ func TestContacts(t *testing.T) {
{Property: ContactCardPropertyCreated, IsAscending: true},
}
- contactsByAccount, _, _, _, err := s.client.QueryContactCards([]string{accountId}, filter, sortBy, 0, 0, true, ctx)
+ contactsByAccount, ss, os, _, err := s.client.QueryContactCards([]string{accountId}, filter, sortBy, 0, 0, true, ctx)
require.NoError(err)
require.Len(contactsByAccount, 1)
@@ -140,6 +142,44 @@ func TestContacts(t *testing.T) {
matchContact(t, fetched.List[0], actual)
}
+ {
+ now := time.Now().Truncate(time.Duration(1) * time.Second).UTC()
+ for _, event := range expectedContactCardsById {
+ change := ContactCardChange{
+ Language: strPtr("xyz"),
+ Updated: ptr(now),
+ }
+ changed, sessionState, state, _, err := s.client.UpdateContactCard(accountId, event.Id, change, ctx)
+ require.NoError(err)
+ require.Equal("xyz", changed.Language)
+ require.Equal(now, changed.Updated)
+ require.Equal(ss, sessionState)
+ require.NotEqual(os, state)
+ os = state
+ }
+ }
+ {
+ ids := structs.Map(slices.Collect(maps.Values(expectedContactCardsById)), func(e ContactCard) string { return e.Id })
+ errMap, sessionState, state, _, err := s.client.DeleteContactCard(accountId, ids, ctx)
+ require.NoError(err)
+ require.Empty(errMap)
+
+ require.Equal(ss, sessionState)
+ require.NotEqual(os, state)
+ os = state
+ }
+ {
+ shouldBeEmpty, sessionState, state, _, err := s.client.QueryContactCards([]string{accountId}, filter, sortBy, 0, 0, true, ctx)
+ require.NoError(err)
+ require.Contains(shouldBeEmpty, accountId)
+ resp := shouldBeEmpty[accountId]
+ require.Empty(resp.Results)
+ require.NotNil(resp.Total)
+ require.Equal(uint(0), *resp.Total)
+ require.Equal(ss, sessionState)
+ require.Equal(os, state)
+ }
+
exceptions := []string{}
if !EnableMediaWithBlobId {
exceptions = append(exceptions, "mediaWithBlobId")
@@ -181,7 +221,7 @@ func (s *StalwartTest) fillAddressBook( //NOSONAR
boxes := AddressBookBoxes{}
created := []AddressBook{}
- ss := SessionState("")
+ ss := EmptySessionState
as := EmptyState
printer := func(s string) { golog.Println(s) }
@@ -228,7 +268,7 @@ func (s *StalwartTest) fillAddressBook( //NOSONAR
}
require.NotEmpty(sessionState)
require.NotEmpty(state)
- if ss != SessionState("") {
+ if ss != EmptySessionState {
require.Equal(ss, sessionState)
}
if as != EmptyState {
@@ -248,6 +288,7 @@ func (s *StalwartTest) fillContacts( //NOSONAR
t *testing.T,
count uint,
session *Session,
+ ctx Context,
user User,
) (string, string, map[string]ContactCard, ContactsBoxes, error) {
require := require.New(t)
@@ -288,17 +329,9 @@ func (s *StalwartTest) fillContacts( //NOSONAR
filled := map[string]ContactCard{}
for i := range count {
person := gofakeit.Person()
- nameMap, nameObj := createName(person)
+ nameObj := createName(person)
language := pickLanguage()
- contact := map[string]any{
- "@type": "Card",
- "version": "1.0",
- "addressBookIds": toBoolMap([]string{addressbookId}),
- "prodId": productName,
- "language": language,
- "kind": "individual",
- "name": nameMap,
- }
+
card := ContactCard{
Type: jscontact.ContactCardType,
Version: "1.0",
@@ -310,34 +343,29 @@ func (s *StalwartTest) fillContacts( //NOSONAR
}
if i%3 == 0 {
- nicknameMap, nicknameObj := createNickName(person)
+ nicknameObj := createNickName(person)
id := id()
- contact["nicknames"] = map[string]map[string]any{id: nicknameMap}
card.Nicknames = map[string]jscontact.Nickname{id: nicknameObj}
boxes.nicknames = true
}
{
- emailMaps := map[string]map[string]any{}
emailObjs := map[string]jscontact.EmailAddress{}
emailId := id()
- emailMap, emailObj := createEmail(person, 10)
- emailMaps[emailId] = emailMap
+ emailObj := createEmail(person, 10)
emailObjs[emailId] = emailObj
for i := range rand.Intn(3) {
id := id()
- m, o := createSecondaryEmail(gofakeit.Email(), i*100)
- emailMaps[id] = m
+ o := createSecondaryEmail(gofakeit.Email(), i*100)
emailObjs[id] = o
boxes.secondaryEmails = true
}
- if len(emailMaps) > 0 {
- contact["emails"] = emailMaps
+ if len(emailObjs) > 0 {
card.Emails = emailObjs
}
}
- if err := propmap(i%2 == 0, 1, 2, contact, "phones", &card.Phones, func(i int, id string) (map[string]any, jscontact.Phone, error) {
+ if err := propmap(i%2 == 0, 1, 2, &card.Phones, func(i int, id string) (jscontact.Phone, error) {
boxes.phones = true
num := person.Contact.Phone
if i > 0 {
@@ -355,21 +383,16 @@ func (s *StalwartTest) fillContacts( //NOSONAR
contexts[jscontact.PhoneContextPrivate] = true
}
tel := "tel:" + "+1" + num
- return map[string]any{
- "@type": "Phone",
- "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) }),
- }, jscontact.Phone{
- Type: jscontact.PhoneType,
- Number: tel,
- Features: features,
- Contexts: contexts,
- }, nil
+ return jscontact.Phone{
+ Type: jscontact.PhoneType,
+ Number: tel,
+ Features: features,
+ Contexts: contexts,
+ }, nil
}); err != nil {
return "", "", nil, boxes, err
}
- if err := propmap(i%5 < 4, 1, 2, contact, "addresses", &card.Addresses, func(i int, id string) (map[string]any, jscontact.Address, error) {
+ if err := propmap(i%5 < 4, 1, 2, &card.Addresses, func(i int, id string) (jscontact.Address, error) {
var source *gofakeit.AddressInfo
if i == 0 {
source = person.Address
@@ -392,108 +415,70 @@ func (s *StalwartTest) fillContacts( //NOSONAR
jscontact.AddressComponent{Type: jscontact.AddressComponentType, Kind: jscontact.AddressComponentKindPostcode, Value: source.Zip},
)
tz := pickRandom(timezones...)
- return map[string]any{
- "@type": "Address",
- "components": structs.Map(components, func(c jscontact.AddressComponent) map[string]string {
- return map[string]string{"kind": string(c.Kind), "value": c.Value}
- }),
- "defaultSeparator": ", ",
- "isOrdered": true,
- "timeZone": tz,
- }, jscontact.Address{
- Type: jscontact.AddressType,
- Components: components,
- DefaultSeparator: ", ",
- IsOrdered: true,
- TimeZone: tz,
- }, nil
+ return jscontact.Address{
+ Type: jscontact.AddressType,
+ Components: components,
+ DefaultSeparator: ", ",
+ IsOrdered: true,
+ TimeZone: tz,
+ }, nil
}); err != nil {
return "", "", nil, boxes, err
}
- if err := propmap(i%2 == 0, 1, 2, contact, "onlineServices", &card.OnlineServices, func(i int, id string) (map[string]any, jscontact.OnlineService, error) {
+ if err := propmap(i%2 == 0, 1, 2, &card.OnlineServices, func(i int, id string) (jscontact.OnlineService, error) {
boxes.onlineService = true
switch rand.Intn(3) {
case 0:
- return map[string]any{
- "@type": "OnlineService",
- "service": "Mastodon",
- "user": "@" + person.Contact.Email,
- "uri": "https://mastodon.example.com/@" + strings.ToLower(person.FirstName),
- }, jscontact.OnlineService{
- Type: jscontact.OnlineServiceType,
- Service: "Mastodon",
- User: "@" + person.Contact.Email,
- Uri: "https://mastodon.example.com/@" + strings.ToLower(person.FirstName),
- }, nil
+ return jscontact.OnlineService{
+ Type: jscontact.OnlineServiceType,
+ Service: "Mastodon",
+ User: "@" + person.Contact.Email,
+ Uri: "https://mastodon.example.com/@" + strings.ToLower(person.FirstName),
+ }, nil
case 1:
- return map[string]any{
- "@type": "OnlineService",
- "uri": "xmpp:" + person.Contact.Email,
- }, jscontact.OnlineService{
- Type: jscontact.OnlineServiceType,
- Uri: "xmpp:" + person.Contact.Email,
- }, nil
+ return jscontact.OnlineService{
+ Type: jscontact.OnlineServiceType,
+ Uri: "xmpp:" + person.Contact.Email,
+ }, nil
default:
- return map[string]any{
- "@type": "OnlineService",
- "service": "Discord",
- "user": person.Contact.Email,
- "uri": "https://discord.example.com/user/" + person.Contact.Email,
- }, jscontact.OnlineService{
- Type: jscontact.OnlineServiceType,
- Service: "Discord",
- User: person.Contact.Email,
- Uri: "https://discord.example.com/user/" + person.Contact.Email,
- }, nil
+ return jscontact.OnlineService{
+ Type: jscontact.OnlineServiceType,
+ Service: "Discord",
+ User: person.Contact.Email,
+ Uri: "https://discord.example.com/user/" + person.Contact.Email,
+ }, nil
}
}); err != nil {
return "", "", nil, boxes, err
}
- if err := propmap(i%3 == 0, 1, 2, contact, "preferredLanguages", &card.PreferredLanguages, func(i int, id string) (map[string]any, jscontact.LanguagePref, error) {
+ if err := propmap(i%3 == 0, 1, 2, &card.PreferredLanguages, func(i int, id string) (jscontact.LanguagePref, error) {
boxes.preferredLanguage = true
lang := pickRandom("en", "fr", "de", "es", "it")
contexts := pickRandoms1("work", "private")
- return map[string]any{
- "@type": "LanguagePref",
- "language": lang,
- "contexts": toBoolMap(contexts),
- "pref": i + 1,
- }, 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),
- }, nil
+ return 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),
+ }, nil
}); err != nil {
return "", "", nil, boxes, err
}
if i%2 == 0 {
- organizationMaps := map[string]map[string]any{}
organizationObjs := map[string]jscontact.Organization{}
- titleMaps := map[string]map[string]any{}
titleObjs := map[string]jscontact.Title{}
for range 1 + rand.Intn(2) {
boxes.organization = true
orgId := id()
titleId := id()
- organizationMaps[orgId] = map[string]any{
- "@type": "Organization",
- "name": person.Job.Company,
- "contexts": toBoolMapS("work"),
- }
organizationObjs[orgId] = jscontact.Organization{
Type: jscontact.OrganizationType,
Name: person.Job.Company,
Contexts: toBoolMapS(jscontact.OrganizationContextWork),
}
- titleMaps[titleId] = map[string]any{
- "@type": "Title",
- "kind": "title",
- "name": person.Job.Title,
- "organizationId": orgId,
- }
+
titleObjs[titleId] = jscontact.Title{
Type: jscontact.TitleType,
Kind: jscontact.TitleKindTitle,
@@ -501,38 +486,32 @@ func (s *StalwartTest) fillContacts( //NOSONAR
OrganizationId: orgId,
}
}
- contact["organizations"] = organizationMaps
- contact["titles"] = titleMaps
card.Organizations = organizationObjs
card.Titles = titleObjs
}
- if err := propmap(i%2 == 0, 1, 1, contact, "cryptoKeys", &card.CryptoKeys, func(i int, id string) (map[string]any, jscontact.CryptoKey, error) {
+ if err := propmap(i%2 == 0, 1, 1, &card.CryptoKeys, func(i int, id string) (jscontact.CryptoKey, error) {
boxes.cryptoKey = true
entity, err := openpgp.NewEntity(person.FirstName+" "+person.LastName, "test", person.Contact.Email, nil)
if err != nil {
- return nil, jscontact.CryptoKey{}, err
+ return jscontact.CryptoKey{}, err
}
var b bytes.Buffer
err = entity.PrimaryKey.Serialize(&b)
if err != nil {
- return nil, jscontact.CryptoKey{}, err
+ return jscontact.CryptoKey{}, err
}
encoded := base64.RawStdEncoding.EncodeToString(b.Bytes())
- return map[string]any{
- "@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
+ return 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) {
+ if err := propmap(i%2 == 0, 1, 2, &card.Media, func(i int, id string) (jscontact.Media, error) {
label := fmt.Sprintf("photo-%d", 1000+rand.Intn(9000))
r := 0
@@ -552,40 +531,27 @@ func (s *StalwartTest) fillContacts( //NOSONAR
mime := "image/png"
uri := "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(img)
contexts := toBoolMapS(jscontact.MediaContextPrivate)
- return map[string]any{
- "@type": "Media",
- "kind": string(jscontact.MediaKindPhoto),
- "uri": uri,
- "mediaType": mime,
- "contexts": structs.MapKeys(contexts, func(c jscontact.MediaContext) string { return string(c) }),
- "label": label,
- }, jscontact.Media{
- Type: jscontact.MediaType,
- Kind: jscontact.MediaKindPhoto,
- Uri: uri,
- MediaType: mime,
- Contexts: contexts,
- Label: label,
- }, nil
+ return jscontact.Media{
+ Type: jscontact.MediaType,
+ Kind: jscontact.MediaKindPhoto,
+ Uri: uri,
+ MediaType: mime,
+ Contexts: contexts,
+ Label: label,
+ }, 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
+ return jscontact.Media{
+ Type: jscontact.MediaType,
+ Kind: jscontact.MediaKindPhoto,
+ Uri: uri,
+ Contexts: contexts,
+ Label: label,
+ }, nil
default:
boxes.mediaWithBlobId = true
@@ -593,56 +559,41 @@ func (s *StalwartTest) fillContacts( //NOSONAR
img := gofakeit.ImageJpeg(size, size)
blob, err := c.uploadBlob(accountId, img, "image/jpeg")
if err != nil {
- return nil, jscontact.Media{}, err
+ return jscontact.Media{}, err
}
contexts := toBoolMapS(jscontact.MediaContextPrivate)
- return map[string]any{
- "@type": "Media",
- "kind": string(jscontact.MediaKindPhoto),
- "blobId": blob.BlobId,
- "contexts": structs.MapKeys(contexts, func(c jscontact.MediaContext) string { return string(c) }),
- "label": label,
- }, jscontact.Media{
- Type: jscontact.MediaType,
- Kind: jscontact.MediaKindPhoto,
- BlobId: blob.BlobId,
- MediaType: blob.Type,
- Contexts: contexts,
- Label: label,
- }, nil
+ return jscontact.Media{
+ Type: jscontact.MediaType,
+ Kind: jscontact.MediaKindPhoto,
+ BlobId: blob.BlobId,
+ MediaType: blob.Type,
+ Contexts: contexts,
+ Label: label,
+ }, nil
}
}); err != nil {
return "", "", nil, boxes, err
}
- if err := propmap(i%2 == 0, 1, 1, contact, "links", &card.Links, func(i int, id string) (map[string]any, jscontact.Link, error) {
+ if err := propmap(i%2 == 0, 1, 1, &card.Links, func(i int, id string) (jscontact.Link, error) {
boxes.link = true
- return map[string]any{
- "@type": "Link",
- "kind": "contact",
- "uri": "mailto:" + person.Contact.Email,
- "pref": (i + 1) * 10,
- }, jscontact.Link{
- Type: jscontact.LinkType,
- Kind: jscontact.LinkKindContact,
- Uri: "mailto:" + person.Contact.Email,
- Pref: uint((i + 1) * 10),
- }, nil
+ return jscontact.Link{
+ Type: jscontact.LinkType,
+ Kind: jscontact.LinkKindContact,
+ Uri: "mailto:" + person.Contact.Email,
+ Pref: uint((i + 1) * 10),
+ }, nil
}); err != nil {
return "", "", nil, boxes, err
}
- id, err := s.CreateContact(c, accountId, contact)
+ created, _, _, _, err := s.client.CreateContactCard(accountId, card, ctx)
if err != nil {
- return "", "", nil, boxes, err
+ return accountId, addressbookId, filled, boxes, err
}
- card.Id = id
- filled[id] = card
- printer(fmt.Sprintf("🧑🏻 created %*s/%v id=%v", int(math.Log10(float64(count))+1), strconv.Itoa(int(i+1)), count, id))
+ require.NotNil(created)
+ filled[created.Id] = *created
+ printer(fmt.Sprintf("🧑🏻 created %*s/%v id=%v", int(math.Log10(float64(count))+1), strconv.Itoa(int(i+1)), count, created.Id))
}
return accountId, addressbookId, filled, boxes, nil
}
-
-func (s *StalwartTest) CreateContact(j *TestJmapClient, accountId string, contact map[string]any) (string, error) {
- return j.create1(accountId, ContactCardType, contact)
-}
diff --git a/pkg/jmap/integration_test.go b/pkg/jmap/integration_test.go
index f277b44f91..2bc0b2b5aa 100644
--- a/pkg/jmap/integration_test.go
+++ b/pkg/jmap/integration_test.go
@@ -707,48 +707,6 @@ func (c Commander[T]) command(body map[string]any) (T, error) {
return c.closure(methodResponses)
}
-func (j *TestJmapClient) create(id string, objectType ObjectType, body map[string]any) (string, error) {
- return newCommander(j, func(methodResponses []any) (string, error) {
- z := methodResponses[0].([]any)
- f := z[1].(map[string]any)
- if x, ok := f["created"]; ok {
- created := x.(map[string]any)
- if c, ok := created[id].(map[string]any); ok {
- return c["id"].(string), nil
- } else {
- return "", fmt.Errorf("failed to create %v", objectType)
- }
- } else {
- if ncx, ok := f["notCreated"]; ok {
- nc := ncx.(map[string]any)
- c := nc[id].(map[string]any)
- return "", fmt.Errorf("failed to create %v: %v", objectType, c["description"])
- } else {
- return "", fmt.Errorf("failed to create %v", objectType)
- }
- }
- }).command(body)
-}
-
-func (j *TestJmapClient) create1(accountId string, objectType ObjectType, obj map[string]any) (string, error) {
- body := map[string]any{
- "using": structs.Map(objectType.Namespaces, func(n JmapNamespace) string { return string(n) }),
- "methodCalls": []any{
- []any{
- objectType.Name + "/set",
- map[string]any{
- "accountId": accountId,
- "create": map[string]any{
- "c": obj,
- },
- },
- "0",
- },
- },
- }
- return j.create("c", objectType, body)
-}
-
func (j *TestJmapClient) objectsById(accountId string, objectType ObjectType) (map[string]map[string]any, error) {
m := map[string]map[string]any{}
{
@@ -785,91 +743,60 @@ func (j *TestJmapClient) objectsById(accountId string, objectType ObjectType) (m
return m, nil
}
-func createName(person *gofakeit.PersonInfo) (map[string]any, jscontact.Name) {
- o := jscontact.Name{
+func createName(person *gofakeit.PersonInfo) jscontact.Name {
+ name := jscontact.Name{
Type: jscontact.NameType,
}
- m := map[string]any{
- "@type": "Name",
- }
- mComps := make([]map[string]string, 2)
- oComps := make([]jscontact.NameComponent, 2)
- mComps[0] = map[string]string{
- "kind": "given",
- "value": person.FirstName,
- }
- oComps[0] = jscontact.NameComponent{
+ comps := make([]jscontact.NameComponent, 2)
+ comps[0] = jscontact.NameComponent{
Type: jscontact.NameComponentType,
Kind: jscontact.NameComponentKindGiven,
Value: person.FirstName,
}
- mComps[1] = map[string]string{
- "kind": "surname",
- "value": person.LastName,
- }
- oComps[1] = jscontact.NameComponent{
+ comps[1] = jscontact.NameComponent{
Type: jscontact.NameComponentType,
Kind: jscontact.NameComponentKindSurname,
Value: person.LastName,
}
- m["components"] = mComps
- o.Components = oComps
- m["isOrdered"] = true
- o.IsOrdered = true
- m["defaultSeparator"] = " "
- o.DefaultSeparator = " "
+ name.Components = comps
+ name.IsOrdered = true
+ name.DefaultSeparator = " "
full := fmt.Sprintf("%s %s", person.FirstName, person.LastName)
- m["full"] = full
- o.Full = full
- return m, o
+ name.Full = full
+ return name
}
-func createNickName(_ *gofakeit.PersonInfo) (map[string]any, jscontact.Nickname) {
+func createNickName(_ *gofakeit.PersonInfo) 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) })),
- }, jscontact.Nickname{
- Type: jscontact.NicknameType,
- Name: name,
- Contexts: orNilMap(toBoolMap(contexts)),
- }
+ return jscontact.Nickname{
+ Type: jscontact.NicknameType,
+ Name: name,
+ Contexts: orNilMap(toBoolMap(contexts)),
+ }
}
-func createEmail(person *gofakeit.PersonInfo, pref int) (map[string]any, jscontact.EmailAddress) {
+func createEmail(person *gofakeit.PersonInfo, pref int) jscontact.EmailAddress {
email := person.Contact.Email
contexts := pickRandoms1(jscontact.EmailAddressContextWork, jscontact.EmailAddressContextPrivate)
label := strings.ToLower(person.FirstName)
- return map[string]any{
- "@type": "EmailAddress",
- "address": email,
- "contexts": toBoolMap(structs.Map(contexts, func(s jscontact.EmailAddressContext) string { return string(s) })),
- "label": label,
- "pref": pref,
- }, jscontact.EmailAddress{
- Type: jscontact.EmailAddressType,
- Address: email,
- Contexts: orNilMap(toBoolMap(contexts)),
- Label: label,
- Pref: uint(pref),
- }
+ return jscontact.EmailAddress{
+ Type: jscontact.EmailAddressType,
+ Address: email,
+ Contexts: orNilMap(toBoolMap(contexts)),
+ Label: label,
+ Pref: uint(pref),
+ }
}
-func createSecondaryEmail(email string, pref int) (map[string]any, jscontact.EmailAddress) {
+func createSecondaryEmail(email string, pref int) 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,
- }, jscontact.EmailAddress{
- Type: jscontact.EmailAddressType,
- Address: email,
- Contexts: orNilMap(toBoolMap(contexts)),
- Pref: uint(pref),
- }
+ return jscontact.EmailAddress{
+ Type: jscontact.EmailAddressType,
+ Address: email,
+ Contexts: orNilMap(toBoolMap(contexts)),
+ Pref: uint(pref),
+ }
}
var idFirstLetters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
@@ -1087,27 +1014,22 @@ var extendedColors = []string{
}
*/
-func propmap[T any](enabled bool, min int, max int, container map[string]any, name string, cardProperty *map[string]T, generator func(int, string) (map[string]any, T, error)) error {
+func propmap[T any](enabled bool, min int, max int, cardProperty *map[string]T, generator func(int, string) (T, error)) error {
if !enabled {
return nil
}
n := min + rand.Intn(max-min+1)
- m := make(map[string]map[string]any, n)
o := make(map[string]T, n)
for i := range n {
id := id()
- itemForMap, itemForCard, err := generator(i, id)
+ itemForCard, err := generator(i, id)
if err != nil {
return err
}
- if itemForMap != nil {
- m[id] = itemForMap
- o[id] = itemForCard
- }
+ o[id] = itemForCard
}
- if len(m) > 0 {
- container[name] = m
+ if len(o) > 0 {
*cardProperty = o
}
return nil
@@ -1255,19 +1177,18 @@ func containerTest[OBJ Idable, RESP GetResponse[OBJ], BOXES any, CHANGE Change](
principalIds = structs.Map(principals.List, func(p Principal) string { return p.Id })
}
- ss := SessionState("")
+ ss := EmptySessionState
as := EmptyState
// we need to fetch the ID of the default object that automatically exists for each user, in order to exclude it
// from the tests below
- defaultContainerId := ""
+ preExistingIds := []string{}
{
resp, sessionState, state, _, err := get(s, accountId, []string{}, ctx)
require.NoError(err)
require.Empty(resp.GetNotFound())
objs := obj(resp)
- require.Len(objs, 1) // the personal calendar that exists by default
- defaultContainerId = id(objs[0])
+ preExistingIds = structs.Map(objs, id)
ss = sessionState
as = state
}
@@ -1287,8 +1208,8 @@ func containerTest[OBJ Idable, RESP GetResponse[OBJ], BOXES any, CHANGE Change](
require.NoError(err)
require.Empty(resp.GetNotFound())
objs := obj(resp)
- // lets skip the default object since we did not create that one
- found := structs.Filter(objs, func(a OBJ) bool { return id(a) != defaultContainerId })
+ // lets skip the objects that already exist since we did not create those
+ found := structs.Filter(objs, func(a OBJ) bool { return !slices.Contains(preExistingIds, id(a)) })
require.Len(found, int(num))
m := structs.Index(found, id)
require.Len(m, int(num))
@@ -1320,7 +1241,25 @@ func containerTest[OBJ Idable, RESP GetResponse[OBJ], BOXES any, CHANGE Change](
require.Equal(objs[0], a)
}
- // lets modify each AddressBook
+ // let's retrieve them all by their IDs, but this time all at once
+ {
+ ids := structs.Map(all, id)
+ resp, sessionState, state, _, err := get(s, accountId, ids, ctx)
+ require.NoError(err)
+ require.Empty(resp.GetNotFound())
+ objs := obj(resp)
+ require.Len(objs, len(all))
+ require.Equal(sessionState, ss)
+ require.Equal(state, as)
+ allById := structs.Index(all, id)
+ for _, r := range resp.GetList() {
+ a, ok := allById[r.GetId()]
+ require.True(ok, "failed to find object that was retrieved in mass ID request in the list of objects that were created")
+ require.Equal(a, r)
+ }
+ }
+
+ // lets modify each object
for _, a := range all {
i := id(a)
ch := change(a)
diff --git a/pkg/jmap/model.go b/pkg/jmap/model.go
index 2c70155fef..075b08ee0a 100644
--- a/pkg/jmap/model.go
+++ b/pkg/jmap/model.go
@@ -1,6 +1,7 @@
package jmap
import (
+ "encoding/json"
"io"
"time"
@@ -930,6 +931,8 @@ type SessionPrimaryAccounts struct {
type SessionState string
+const EmptySessionState = SessionState("")
+
type State string
const EmptyState = State("")
@@ -1258,6 +1261,19 @@ type Idable interface {
// ```
type PatchObject map[string]any
+func toPatchObject[T any](value T) (PatchObject, error) {
+ b, err := json.Marshal(value)
+ if err != nil {
+ return PatchObject{}, err
+ }
+ var target PatchObject
+ err = json.Unmarshal(b, &target)
+ if err != nil {
+ return PatchObject{}, err
+ }
+ return target, nil
+}
+
// Reference to Previous Method Results
//
// To allow clients to make more efficient use of the network and avoid round trips, an argument to one method
@@ -1336,7 +1352,7 @@ type SetResponse[T Foo] interface {
}
type Change interface {
- AsPatch() PatchObject
+ AsPatch() (PatchObject, error)
}
type ChangesCommand[T Foo] interface {
@@ -1659,24 +1675,8 @@ type MailboxChange struct {
var _ Change = MailboxChange{}
-func (m MailboxChange) AsPatch() PatchObject {
- p := PatchObject{}
- if m.Name != "" {
- p["name"] = m.Name
- }
- if m.ParentId != "" {
- p["parentId"] = m.ParentId
- }
- if m.Role != "" {
- p["role"] = m.Role
- }
- if m.SortOrder != nil {
- p["sortOrder"] = m.SortOrder
- }
- if m.IsSubscribed != nil {
- p["isSubscribed"] = m.IsSubscribed
- }
- return p
+func (m MailboxChange) AsPatch() (PatchObject, error) {
+ return toPatchObject(m)
}
type MailboxGetCommand struct {
@@ -3953,27 +3953,8 @@ type IdentityChange struct {
var _ Change = IdentityChange{}
-func (i IdentityChange) AsPatch() PatchObject {
- p := PatchObject{}
- if i.Name != "" {
- p["name"] = i.Name
- }
- if i.Email != "" {
- p["email"] = i.Email
- }
- if i.ReplyTo != "" {
- p["replyTo"] = i.ReplyTo
- }
- if i.Bcc != nil {
- p["bcc"] = i.Bcc
- }
- if i.TextSignature != nil {
- p["textSignature"] = i.TextSignature
- }
- if i.HtmlSignature != nil {
- p["htmlSignature"] = i.HtmlSignature
- }
- return p
+func (i IdentityChange) AsPatch() (PatchObject, error) {
+ return toPatchObject(i)
}
type IdentityGetResponse struct {
@@ -4794,6 +4775,222 @@ var ContactCardProperties = []string{
ContactCardPropertyPersonalInfo,
}
+type ContactCardChange struct {
+ // The set of AddressBook ids this Card belongs to.
+ //
+ // A card MUST belong to at least one AddressBook at all times (until it is destroyed).
+ //
+ // The set is represented as an object, with each key being an AddressBook id.
+ //
+ // The value for each key in the object MUST be true.
+ //
+ // This is a JMAP extension and not part of [RFC9553].
+ AddressBookIds map[string]bool `json:"addressBookIds,omitempty"`
+
+ // The JSContact type of the Card object: the value MUST be "Card".
+ Type jscontact.TypeOfContactCard `json:"@type,omitempty"`
+
+ // The JSContact version of this Card.
+ //
+ // The value MUST be one of the IANA-registered JSContact Version values for the version property.
+ Version *jscontact.JSContactVersion `json:"version,omitzero"`
+
+ // The kind of the entity the Card represents (default: `individual`).
+ //
+ // Values are:
+ // * `individual`: a single person
+ // * `group`: a group of people or entities
+ // * `org`: an organization
+ // * `location`: a named location
+ // * `device`: a device such as an appliance, a computer, or a network element
+ // * `application`: a software application
+ Kind *jscontact.ContactCardKind `json:"kind,omitempty"`
+
+ // The language tag, as defined in [RFC5646].
+ //
+ // The language tag that best describes the language used for text in the Card, optionally including
+ // additional information such as the script.
+ //
+ // Note that values MAY be localized in the `localizations` property.
+ Language *string `json:"language,omitempty"`
+
+ // The set of Cards that are members of this group Card.
+ //
+ // Each key in the set is the uid property value of the member, and each boolean value MUST be `true`.
+ // If this property is set, then the value of the kind property MUST be `group`.
+ //
+ // The opposite is not true. A group Card will usually contain the members property to specify the members
+ // of the group, but it is not required to.
+ //
+ // A group Card without the members property can be considered an abstract grouping or one whose members
+ // are known empirically (e.g., `IETF Participants`).
+ Members map[string]bool `json:"members,omitempty"`
+
+ // The identifier for the product that created the Card.
+ //
+ // If set, the value MUST be at least one character long.
+ ProdId *string `json:"prodId,omitempty"`
+
+ // The set of Card objects that relate to the Card.
+ //
+ // The value is a map, where each key is the uid property value of the related Card, and the value
+ // defines the relation
+ //
+ // ```json
+ // {
+ // "relatedTo": {
+ // "urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6": {
+ // "relation": {"friend": true}
+ // },
+ // "8cacdfb7d1ffdb59@example.com": {
+ // "relation": {}
+ // }
+ // }
+ // }
+ // ```
+ RelatedTo map[string]jscontact.Relation `json:"relatedTo,omitempty"`
+
+ // The date and time when the data in the Card was last modified (UTCDateTime).
+ Updated *time.Time `json:"updated,omitzero"`
+
+ // The name of the entity represented by the Card.
+ //
+ // This can be any type of name, e.g., it can, but need not, be the legal name of a person.
+ Name *jscontact.Name `json:"name,omitempty"`
+
+ // The nicknames of the entity represented by the Card.
+ Nicknames map[string]jscontact.Nickname `json:"nicknames,omitempty"`
+
+ // The company or organization names and units associated with the Card.
+ Organizations map[string]jscontact.Organization `json:"organizations,omitempty"`
+
+ // The information that directs how to address, speak to, or refer to the entity that is represented by the Card.
+ SpeakToAs *jscontact.SpeakToAs `json:"speakToAs,omitempty"`
+
+ // The job titles or functional positions of the entity represented by the Card.
+ Titles map[string]jscontact.Title `json:"titles,omitempty"`
+
+ // The email addresses in which to contact the entity represented by the Card.
+ Emails map[string]jscontact.EmailAddress `json:"emails,omitempty"`
+
+ // The online services that are associated with the entity represented by the Card.
+ //
+ // This can be messaging services, social media profiles, and other.
+ OnlineServices map[string]jscontact.OnlineService `json:"onlineServices,omitempty"`
+
+ // The phone numbers by which to contact the entity represented by the Card.
+ Phones map[string]jscontact.Phone `json:"phones,omitempty"`
+
+ // The preferred languages for contacting the entity associated with the Card.
+ PreferredLanguages map[string]jscontact.LanguagePref `json:"preferredLanguages,omitempty"`
+
+ // The calendaring resources of the entity represented by the Card, such as to look up free-busy information.
+ //
+ // A Calendar object has all properties of the Resource data type, with the following additional definitions:
+ // * The `@type` property value MUST be `Calendar`, if set
+ // * The `kind` property is mandatory. Its enumerated values are:
+ // * `calendar`: The resource is a calendar that contains entries such as calendar events or tasks
+ // * `freeBusy`: The resource allows for free-busy lookups, for example, to schedule group events
+ Calendars map[string]jscontact.Calendar `json:"calendars,omitempty"`
+
+ // The scheduling addresses by which the entity may receive calendar scheduling invitations.
+ SchedulingAddresses map[string]jscontact.SchedulingAddress `json:"schedulingAddresses,omitempty"`
+
+ // The addresses of the entity represented by the Card, such as postal addresses or geographic locations.
+ Addresses map[string]jscontact.Address `json:"addresses,omitempty"`
+
+ // The cryptographic resources such as public keys and certificates associated with the entity represented by the Card.
+ //
+ // A CryptoKey object has all properties of the `Resource` data type, with the following additional definition:
+ // the `@type` property value MUST be `CryptoKey`, if set.
+ //
+ // The following example shows how to refer to an external cryptographic resource:
+ // ```json
+ // "cryptoKeys": {
+ // "mykey1": {
+ // "uri": "https://www.example.com/keys/jdoe.cer"
+ // }
+ // }
+ // ```
+ CryptoKeys map[string]jscontact.CryptoKey `json:"cryptoKeys,omitempty"`
+
+ // The directories containing information about the entity represented by the Card.
+ //
+ // A Directory object has all properties of the `Resource` data type, with the following additional definitions:
+ // * The `@type` property value MUST be `Directory`, if set
+ // * The `kind` property is mandatory; tts enumerated values are:
+ // * `directory`: the resource is a directory service that the entity represented by the Card is a part of; this
+ // typically is an organizational directory that also contains associated entities, e.g., co-workers and management
+ // in a company directory
+ // * `entry`: the resource is a directory entry of the entity represented by the Card; in contrast to the `directory`
+ // type, this is the specific URI for the entity within a directory
+ Directories map[string]jscontact.Directory `json:"directories,omitempty"`
+
+ // The links to resources that do not fit any of the other use-case-specific resource properties.
+ //
+ // A Link object has all properties of the `Resource` data type, with the following additional definitions:
+ // * The `@type` property value MUST be `Link`, if set
+ // * The `kind` property is optional; tts enumerated values are:
+ // * `contact`: the resource is a URI by which the entity represented by the Card may be contacted;
+ // this includes web forms or other media that require user interaction
+ Links map[string]jscontact.Link `json:"links,omitempty"`
+
+ // The media resources such as photographs, avatars, or sounds that are associated with the entity represented by the Card.
+ //
+ // A Media object has all properties of the Resource data type, with the following additional definitions:
+ // * the `@type` property value MUST be `Media`, if set
+ // * the `kind` property is mandatory; its enumerated values are:
+ // * `photo`: the resource is a photograph or avatar
+ // * `sound`: the resource is audio media, e.g., to specify the proper pronunciation of the name property contents
+ // * `logo`: the resource is a graphic image or logo associated with the entity represented by the Card
+ Media map[string]jscontact.Media `json:"media,omitempty"`
+
+ // The property values localized to languages other than the main `language` of the Card.
+ //
+ // Localizations provide language-specific alternatives for existing property values and SHOULD NOT add new properties.
+ //
+ // The keys in the localizations property value are language tags [RFC5646]; the values are of type `PatchObject` and
+ // localize the Card in that language tag.
+ //
+ // The paths in the `PatchObject` are relative to the Card that includes the localizations property.
+ //
+ // A patch MUST NOT target the localizations property.
+ //
+ // Conceptually, a Card is localized as follows:
+ // * Determine the language tag in which the Card should be localized.
+ // * If the localizations property includes a key for that language, obtain the PatchObject value;
+ // if there is no such key, stop.
+ // * Create a copy of the Card, but do not copy the localizations property.
+ // * Apply all patches in the PatchObject to the copy of the Card.
+ // * Optionally, set the language property in the copy of the Card.
+ // * Use the patched copy of the Card as the localized variant of the original Card.
+ //
+ // A patch in the `PatchObject` may contain any value type.
+ //
+ // Its value MUST be a valid value according to the definition of the patched property.
+ Localizations map[string]jscontact.PatchObject `json:"localizations,omitempty"`
+
+ // The memorable dates and events for the entity represented by the Card.
+ Anniversaries map[string]jscontact.Anniversary `json:"anniversaries,omitempty"`
+
+ // The set of free-text keywords, also known as tags.
+ //
+ // Each key in the set is a keyword, and each boolean value MUST be `true`.
+ Keywords map[string]bool `json:"keywords,omitempty"`
+
+ // The free-text notes that are associated with the Card.
+ Notes map[string]jscontact.Note `json:"notes,omitempty"`
+
+ // The personal information of the entity represented by the Card.
+ PersonalInfo map[string]jscontact.PersonalInfo `json:"personalInfo,omitempty"`
+}
+
+var _ Change = ContactCardChange{}
+
+func (e ContactCardChange) AsPatch() (PatchObject, error) {
+ return toPatchObject(e)
+}
+
type CalendarRights struct {
// The user may read the free-busy information for this calendar.
MayReadFreeBusy bool `json:"mayReadFreeBusy"`
@@ -5016,11 +5213,11 @@ type CalendarChange struct {
// The user-visible name of the calendar.
//
// This may be any UTF-8 string of at least 1 character in length and maximum 255 octets in size.
- Name *string `json:"name"`
+ Name *string `json:"name,omitzero"`
// An optional longer-form description of the calendar, to provide context in shared environments
// where users need more than just the name.
- Description *string `json:"description,omitempty"`
+ Description *string `json:"description,omitzero"`
// A color to be used when displaying events associated with the calendar.
//
@@ -5029,7 +5226,7 @@ type CalendarChange struct {
// notation, as defined in Section 4.2.1 of CSS Color Module Level 3.
//
// The color SHOULD have sufficient contrast to be used as text on a white background.
- Color *string `json:"color,omitempty"`
+ Color *string `json:"color,omitzero"`
// Defines the sort order of calendars when presented in the client’s UI, so it is consistent
// between devices.
@@ -5055,7 +5252,7 @@ type CalendarChange struct {
// For example, a company may have a large number of shared calendars which all employees have
// permission to access, but you would only subscribe to the ones you care about and want to be able
// to have normally accessible.
- IsSubscribed *bool `json:"isSubscribed"`
+ IsSubscribed *bool `json:"isSubscribed,omitempty"`
// Should the calendar’s events be displayed to the user at the moment?
//
@@ -5063,7 +5260,9 @@ type CalendarChange struct {
//
// If an event is in multiple calendars, it should be displayed if `isVisible` is `true`
// for any of those calendars.
- IsVisible *bool `json:"isVisible" default:"true" doc:"opt"`
+ //
+ // Currently unsupported in Stalwart when modifying existing objects.
+ IsVisible *bool `json:"isVisible,omitempty" default:"true" doc:"opt"`
// Should the calendar’s events be used as part of availability calculation?
//
@@ -5138,35 +5337,8 @@ type CalendarChange struct {
var _ Change = CalendarChange{}
-func (a CalendarChange) AsPatch() PatchObject {
- p := PatchObject{}
- if a.Name != nil {
- p["name"] = *a.Name
- }
- if a.Description != nil {
- p["description"] = *a.Description
- }
- if a.Color != nil {
- p["color"] = *a.Color
- }
- if a.SortOrder != nil {
- p["sortOrder"] = *a.SortOrder
- }
- if a.IsSubscribed != nil {
- p["isSubscribed"] = *a.IsSubscribed
- }
- if a.IsVisible != nil {
- p["isVisible"] = *a.IsVisible
- }
- if a.IncludeInAvailability != nil {
- p["includeInAvailability"] = *a.IncludeInAvailability
- }
- // TODO DefaultAlertsWithTime
- // TODO DefaultAlertsWithoutTime
- // TODO TimeZone
- // TODO ShareWith
- // TODO MyRights
- return p
+func (c CalendarChange) AsPatch() (PatchObject, error) {
+ return toPatchObject(c)
}
// A CalendarEvent object contains information about an event, or recurring series of events,
@@ -5252,6 +5424,79 @@ type CalendarEvent struct {
jscalendar.Event
}
+type CalendarEventChange struct {
+ // The set of Calendar ids this event belongs to.
+ //
+ // An event MUST belong to one or more Calendars at all times (until it is destroyed).
+ //
+ // The set is represented as an object, with each key being a Calendar id.
+ //
+ // The value for each key in the object MUST be `true`.
+ CalendarIds map[string]bool `json:"calendarIds,omitempty"`
+
+ // If true, this event is to be considered a draft.
+ //
+ // The server will not send any scheduling messages to participants or send push notifications
+ // for alerts.
+ //
+ // This may only be set to `true` upon creation.
+ //
+ // Once set to `false`, the value cannot be updated to `true`.
+ //
+ // This property MUST NOT appear in `recurrenceOverrides`.
+ IsDraft *bool `json:"isDraft,omitzero"`
+
+ // Is this the authoritative source for this event (i.e., does it control scheduling for
+ // this event; the event has not been added as a result of an invitation from another calendar system)?
+ //
+ // This is true if, and only if:
+ // * the event’s `replyTo` property is null; or
+ // * the account will receive messages sent to at least one of the methods specified in the `replyTo` property of the event.
+ IsOrigin *bool `json:"isOrigin,omitzero"`
+
+ // For simple clients that do not implement time zone support.
+ //
+ // Clients should only use this if also asking the server to expand recurrences, as you cannot accurately
+ // expand a recurrence without the original time zone.
+ //
+ // This property is calculated at fetch time by the server.
+ //
+ // Time zones are political and they can and do change at any time.
+ //
+ // Fetching exactly the same property again may return a different results if the time zone data has been updated on the server.
+ //
+ // Time zone data changes are not considered `updates` to the event.
+ //
+ // If set, the server will convert the UTC date to the event's current time zone and store the local time.
+ //
+ // This property is not included in `CalendarEvent/get` responses by default and must be requested explicitly.
+ //
+ // Floating events (events without a time zone) will be interpreted as per the time zone given as a `CalendarEvent/get` argument.
+ //
+ // Note that it is not possible to accurately calculate the expansion of recurrence rules or recurrence overrides with the
+ // `utcStart` property rather than the local start time. Even simple recurrences such as "repeat weekly" may cross a
+ // daylight-savings boundary and end up at a different UTC time. Clients that wish to use "utcStart" are RECOMMENDED to
+ // request the server expand recurrences.
+ UtcStart UTCDate `json:"utcStart,omitzero"`
+
+ // The server calculates the end time in UTC from the start/timeZone/duration properties of the event.
+ //
+ // This property is not included by default and must be requested explicitly.
+ //
+ // Like `utcStart`, it is calculated at fetch time if requested and may change due to time zone data changes.
+ //
+ // Floating events will be interpreted as per the time zone given as a `CalendarEvent/get` argument.
+ UtcEnd UTCDate `json:"utcEnd,omitzero"`
+
+ jscalendar.EventChange
+}
+
+var _ Change = CalendarEventChange{}
+
+func (e CalendarEventChange) AsPatch() (PatchObject, error) {
+ return toPatchObject(e)
+}
+
var _ Idable = &CalendarEvent{}
func (f CalendarEvent) GetObjectType() ObjectType { return CalendarEventType }
@@ -6356,7 +6601,7 @@ type AddressBookChange struct {
// The user-visible name of the AddressBook.
//
// This may be any UTF-8 string of at least 1 character in length and maximum 255 octets in size.
- Name *string `json:"name"`
+ Name *string `json:"name,omitempty"`
// An optional longer-form description of the AddressBook, to provide context in shared environments
// where users need more than just the name.
@@ -6381,7 +6626,7 @@ type AddressBookChange struct {
//
// If false, the AddressBook and its contents SHOULD only be displayed when the user explicitly requests it
// or to offer it for the user to subscribe to.
- IsSubscribed *bool `json:"isSubscribed"`
+ IsSubscribed *bool `json:"isSubscribed,omitzero"`
// A map of Principal id to rights for principals this AddressBook is shared with.
//
@@ -6398,21 +6643,8 @@ type AddressBookChange struct {
var _ Change = AddressBookChange{}
-func (a AddressBookChange) AsPatch() PatchObject {
- p := PatchObject{}
- if a.Name != nil {
- p["name"] = *a.Name
- }
- if a.Description != nil {
- p["description"] = *a.Description
- }
- if a.IsSubscribed != nil {
- p["isSubscribed"] = *a.IsSubscribed
- }
- if a.ShareWith != nil {
- p["shareWith"] = a.ShareWith
- }
- return p
+func (a AddressBookChange) AsPatch() (PatchObject, error) {
+ return toPatchObject(a)
}
type AddressBookSetCommand struct {
@@ -6842,12 +7074,12 @@ type ContactCardQueryResponse struct {
// Only if requested.
//
// This argument MUST be omitted if the calculateTotal request argument is not true.
- Total uint `json:"total,omitempty,omitzero"`
+ Total *uint `json:"total,omitempty"`
// The limit enforced by the server on the maximum number of results to return (if set by the server).
//
// This is only returned if the server set a limit or used a different limit than that given in the request.
- Limit uint `json:"limit,omitempty,omitzero"`
+ Limit uint `json:"limit,omitzero"`
}
var _ QueryResponse[ContactCard] = &ContactCardQueryResponse{}
@@ -6999,8 +7231,6 @@ func (r ContactCardChangesResponse) GetUpdated() []string { return r.Updated
func (r ContactCardChangesResponse) GetDestroyed() []string { return r.Destroyed }
func (r ContactCardChangesResponse) GetMarker() ContactCard { return ContactCard{} }
-type ContactCardUpdate map[string]any
-
type ContactCardSetCommand struct {
// The id of the account to use.
AccountId string `json:"accountId"`
@@ -7048,7 +7278,7 @@ type ContactCardSetCommand struct {
//
// The client may choose to optimise network usage by just sending the diff or may send the whole object; the server
// processes it the same either way.
- Update map[string]ContactCardUpdate `json:"update,omitempty"`
+ Update map[string]PatchObject `json:"update,omitempty"`
// A list of ids for ContactCard objects to permanently delete, or null if no objects are to be destroyed.
Destroy []string `json:"destroy,omitempty"`
@@ -7572,12 +7802,12 @@ type CalendarEventQueryResponse struct {
// Only if requested.
//
// This argument MUST be omitted if the calculateTotal request argument is not true.
- Total uint `json:"total,omitempty,omitzero"`
+ Total *uint `json:"total,omitempty"`
// The limit enforced by the server on the maximum number of results to return (if set by the server).
//
// This is only returned if the server set a limit or used a different limit than that given in the request.
- Limit uint `json:"limit,omitempty,omitzero"`
+ Limit uint `json:"limit,omitzero"`
}
var _ QueryResponse[CalendarEvent] = &CalendarEventQueryResponse{}
@@ -7735,8 +7965,6 @@ func (r CalendarEventChangesResponse) GetUpdated() []string { return r.Updat
func (r CalendarEventChangesResponse) GetDestroyed() []string { return r.Destroyed }
func (r CalendarEventChangesResponse) GetMarker() CalendarEvent { return CalendarEvent{} }
-type CalendarEventUpdate map[string]any
-
type CalendarEventSetCommand struct {
// The id of the account to use.
AccountId string `json:"accountId"`
@@ -7784,7 +8012,7 @@ type CalendarEventSetCommand struct {
//
// The client may choose to optimise network usage by just sending the diff or may send the whole object; the server
// processes it the same either way.
- Update map[string]CalendarEventUpdate `json:"update,omitempty"`
+ Update map[string]PatchObject `json:"update,omitempty"`
// A list of ids for CalendarEvent objects to permanently delete, or null if no objects are to be destroyed.
Destroy []string `json:"destroy,omitempty"`
diff --git a/pkg/jmap/templates.go b/pkg/jmap/templates.go
index 421d249a1e..5d97a23b31 100644
--- a/pkg/jmap/templates.go
+++ b/pkg/jmap/templates.go
@@ -398,10 +398,18 @@ func update[T Foo, CHANGES Change, SET SetCommand[T], GET GetCommand[T], RESP an
logger := client.logger(name, ctx)
ctx = ctx.WithLogger(logger)
- update := setCommandFactory(map[string]PatchObject{id: changes.AsPatch()})
+ var zero RESP
+
+ var update SET
+ {
+ patch, err := changes.AsPatch()
+ if err != nil {
+ return zero, "", "", "", jmapError(err, JmapPatchObjectSerialization)
+ }
+ update = setCommandFactory(map[string]PatchObject{id: patch})
+ }
get := getCommandFactory(id)
cmd, err := client.request(ctx, objType.Namespaces, invocation(update, "0"), invocation(get, "1"))
- var zero RESP
if err != nil {
return zero, "", "", "", err
}
diff --git a/pkg/jmap/tools.go b/pkg/jmap/tools.go
index bf17907daa..06ac08a701 100644
--- a/pkg/jmap/tools.go
+++ b/pkg/jmap/tools.go
@@ -423,6 +423,14 @@ func uintPtrIf(i uint, condition bool) *uint {
}
}
+func uintPtrIfPtr(i *uint, condition bool) *uint {
+ if condition {
+ return i
+ } else {
+ return nil
+ }
+}
+
func ns(namespaces ...JmapNamespace) []JmapNamespace {
result := make([]JmapNamespace, len(namespaces)+1)
result[0] = JmapCore
diff --git a/pkg/jscalendar/model.go b/pkg/jscalendar/model.go
index 3cb12bde3a..e27d703062 100644
--- a/pkg/jscalendar/model.go
+++ b/pkg/jscalendar/model.go
@@ -790,6 +790,19 @@ func (t *LocalDateTime) UnmarshalJSON(b []byte) error {
// automatically still inherit this.
type PatchObject map[string]any
+func toPatchObject[T any](value T) (PatchObject, error) {
+ b, err := json.Marshal(value)
+ if err != nil {
+ return PatchObject{}, err
+ }
+ var target PatchObject
+ err = json.Unmarshal(b, &target)
+ if err != nil {
+ return PatchObject{}, err
+ }
+ return target, nil
+}
+
// A Relation object defines the relation to other objects, using a possibly empty set of relation types.
//
// The object that defines this relation is the linking object, while the other object is the linked
@@ -1740,6 +1753,25 @@ type CommonObject struct {
Color string `json:"color,omitempty"`
}
+type CommonObjectChange struct {
+ Uid *string `json:"uid,omitempty"`
+ ProdId *string `json:"prodId,omitempty"`
+ Created UTCDateTime `json:"created,omitzero"`
+ Updated UTCDateTime `json:"updated,omitzero"`
+ Title *string `json:"title,omitempty"`
+ Description *string `json:"description,omitempty"`
+ DescriptionContentType *string `json:"descriptionContentType,omitempty" doc:"opt" default:"text/plain"`
+ Links map[string]Link `json:"links,omitempty"`
+ Locale *string `json:"locale,omitempty"`
+ Keywords map[string]bool `json:"keywords,omitempty"`
+ Categories map[string]bool `json:"categories,omitempty"`
+ Color *string `json:"color,omitempty"`
+}
+
+func (m CommonObjectChange) AsPatch() (PatchObject, error) {
+ return toPatchObject(m)
+}
+
// ### Recurrence Properties
//
// Some events and tasks occur at regular or irregular intervals. Rather than having to copy the data for every occurrence,
@@ -2100,6 +2132,40 @@ type Object struct {
HideAttendees bool `json:"hideAttendees,omitzero" doc:"opt" default:"false"`
}
+type ObjectChange struct {
+ CommonObjectChange
+ RelatedTo map[string]Relation `json:"relatedTo,omitempty"`
+ Sequence *uint `json:"sequence,omitzero"`
+ ShowWithoutTime *bool `json:"showWithoutTime,omitzero" doc:"opt" default:"false"`
+ Locations map[string]Location `json:"locations,omitempty"`
+ MainLocationId *string `json:"mainLocationId,omitempty"`
+ VirtualLocations map[string]VirtualLocation `json:"virtualLocations,omitempty"`
+ RecurrenceId *LocalDateTime `json:"recurrenceId,omitempty"`
+ RecurrenceIdTimeZone string `json:"recurrenceIdTimeZone,omitempty"`
+ RecurrenceRule *RecurrenceRule `json:"recurrenceRule,omitempty"`
+ ExcludedRecurrenceRules []RecurrenceRule `json:"excludedRecurrenceRules,omitempty"`
+ RecurrenceOverrides map[LocalDateTime]PatchObject `json:"recurrenceOverrides,omitempty"`
+ Excluded *bool `json:"excluded,omitzero"`
+ Priority *int `json:"priority,omitzero"`
+ FreeBusyStatus *FreeBusyStatus `json:"freeBusyStatus,omitempty" doc:"opt" default:"busy"`
+ Privacy *Privacy `json:"privacy,omitempty"`
+ ReplyTo map[ReplyMethod]string `json:"replyTo,omitempty"`
+ SentBy string `json:"sentBy,omitempty"`
+ Participants map[string]Participant `json:"participants,omitempty"`
+ RequestStatus string `json:"requestStatus,omitempty"`
+ UseDefaultAlerts *bool `json:"useDefaultAlerts,omitzero" doc:"opt" default:"false"`
+ Alerts map[string]Alert `json:"alerts,omitempty"`
+ Localizations map[string]PatchObject `json:"localizations,omitempty"`
+ TimeZone *string `json:"timeZone,omitempty"`
+ MayInviteSelf *bool `json:"mayInviteSelf,omitzero" doc:"opt" default:"false"`
+ MayInviteOthers *bool `json:"mayInviteOthers,omitzero" doc:"opt" default:"false"`
+ HideAttendees *bool `json:"hideAttendees,omitzero" doc:"opt" default:"false"`
+}
+
+func (m ObjectChange) AsPatch() (PatchObject, error) {
+ return toPatchObject(m)
+}
+
type Event struct {
Type TypeOfEvent `json:"@type,omitempty"`
@@ -2135,6 +2201,45 @@ type Event struct {
Status Status `json:"status,omitempty"`
}
+type EventChange struct {
+ Type TypeOfEvent `json:"@type,omitempty"`
+
+ ObjectChange
+
+ // This is the date/time the event starts in the event's time zone (as specified in the timeZone property, see Section 4.7.1).
+ Start LocalDateTime `json:"start,omitempty"`
+
+ // This is the zero or positive duration of the event in the event's start time zone.
+ //
+ // The end time of an event can be found by adding the duration to the event's start time.
+ //
+ // An Event MAY involve start and end locations that are in different time zones
+ // (e.g., a transcontinental flight). This can be expressed using the `relativeTo` and `timeZone` properties of
+ // the `Event`'s Location objects (see Section 4.2.5).
+ Duration *Duration `json:"duration,omitempty"`
+
+ // This identifies the time zone in which this event ends, for cases where the start and time zones of the event differ
+ // (e.g., a transcontinental flight).
+ //
+ // If this property is not set, then the event starts and ends in the same time zone.
+ //
+ // This property MUST NOT be set if the timeZone property value is null or not set.
+ EndTimeZone string `json:"endTimeZone,omitempty"`
+
+ // This is the scheduling status (Section 4.4) of an Event.
+ //
+ // If set, it MUST be one of the following values, another value registered in the IANA
+ // "JSCalendar Enum Values" registry, or a vendor-specific value (see Section 3.3):
+ // * `confirmed`: indicates the event is definitely happening
+ // * `cancelled`: indicates the event has been cancelled
+ // * `tentative`: indicates the event may happen
+ Status *Status `json:"status,omitempty"`
+}
+
+func (e EventChange) AsPatch() (PatchObject, error) {
+ return toPatchObject(e)
+}
+
type Task struct {
Type TypeOfTask `json:"@type,omitempty"`
diff --git a/pkg/jscalendar/model_test.go b/pkg/jscalendar/model_test.go
index d21403dfe9..c9ad8151e8 100644
--- a/pkg/jscalendar/model_test.go
+++ b/pkg/jscalendar/model_test.go
@@ -714,3 +714,33 @@ func TestEvent(t *testing.T) {
},
})
}
+
+func TestPatch(t *testing.T) {
+ require := require.New(t)
+ for _, tt := range []struct {
+ change ObjectChange
+ expected PatchObject
+ }{
+ {ObjectChange{}, PatchObject{}},
+ {ObjectChange{
+ CommonObjectChange: CommonObjectChange{
+ Uid: strPtr("e9787e0b-e824-4284-964e-6b5d77af4bc9"),
+ },
+ }, PatchObject{
+ "uid": "e9787e0b-e824-4284-964e-6b5d77af4bc9",
+ }},
+ } {
+ b, err := json.Marshal(tt.expected)
+ require.NoError(err)
+ title := string(b)
+ t.Run(title, func(t *testing.T) {
+ patch, err := tt.change.AsPatch()
+ require.NoError(err)
+ require.Equal(tt.expected, patch)
+ })
+ }
+}
+
+func strPtr(s string) *string {
+ return &s
+}