diff --git a/pkg/jmap/jmap_api_contact.go b/pkg/jmap/jmap_api_contact.go index 7e5e92fa7f..a09584fbf4 100644 --- a/pkg/jmap/jmap_api_contact.go +++ b/pkg/jmap/jmap_api_contact.go @@ -37,6 +37,32 @@ func (j *Client) GetAddressbooks(accountId string, session *Session, ctx context }) } +func (j *Client) GetContactCardsById(accountId string, session *Session, ctx context.Context, logger *log.Logger, + acceptLanguage string, contactIds []string) (map[string]jscontact.ContactCard, SessionState, State, Language, Error) { + logger = j.logger("GetContactCardsById", session, logger) + + cmd, err := j.request(session, logger, invocation(CommandContactCardGet, ContactCardGetCommand{ + Ids: contactIds, + AccountId: accountId, + }, "0")) + if err != nil { + return nil, "", "", "", err + } + + return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (map[string]jscontact.ContactCard, State, Error) { + var response ContactCardGetResponse + err = retrieveResponseMatchParameters(logger, body, CommandContactCardGet, "0", &response) + if err != nil { + return nil, "", err + } + m := map[string]jscontact.ContactCard{} + for _, contact := range response.List { + m[contact.Id] = contact + } + return m, response.State, nil + }) +} + func (j *Client) QueryContactCards(accountIds []string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, filter ContactCardFilterElement, sortBy []ContactCardComparator, position uint, limit uint) (map[string][]jscontact.ContactCard, SessionState, State, Language, Error) { diff --git a/pkg/jmap/jmap_integration_contact_test.go b/pkg/jmap/jmap_integration_contact_test.go new file mode 100644 index 0000000000..a0596f3023 --- /dev/null +++ b/pkg/jmap/jmap_integration_contact_test.go @@ -0,0 +1,85 @@ +package jmap + +import ( + "math/rand" + "reflect" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/opencloud-eu/opencloud/pkg/jscontact" +) + +func TestContacts(t *testing.T) { + if skip(t) { + return + } + + count := uint(15 + rand.Intn(20)) + + require := require.New(t) + + s, err := newStalwartTest(t) + require.NoError(err) + defer s.Close() + + accountId, addressbookId, cardsById, err := s.fillContacts(t, count) + require.NoError(err) + require.NotEmpty(accountId) + require.NotEmpty(addressbookId) + + filter := ContactCardFilterCondition{ + InAddressBook: addressbookId, + } + sortBy := []ContactCardComparator{ + {Property: jscontact.ContactCardPropertyCreated, IsAscending: true}, + } + + contactsByAccount, _, _, _, err := s.client.QueryContactCards([]string{accountId}, s.session, t.Context(), s.logger, "", filter, sortBy, 0, 0) + require.NoError(err) + + require.Len(contactsByAccount, 1) + require.Contains(contactsByAccount, accountId) + contacts := contactsByAccount[accountId] + require.Len(contacts, int(count)) + + for _, actual := range contacts { + expected, ok := cardsById[actual.Id] + require.True(ok, "failed to find created contact by its id") + expected.Id = actual.Id + matchContact(t, actual, expected) + } +} + +func matchContact(t *testing.T, actual jscontact.ContactCard, expected jscontact.ContactCard) { + require := require.New(t) + + //a := actual + //unType(t, &a) + + require.Equal(expected, actual) +} + +func unType(t *testing.T, s any) { + ty := reflect.TypeOf(s) + + switch ty.Kind() { + case reflect.Map, reflect.Array, reflect.Pointer, reflect.Slice: + ty = ty.Elem() + } + + switch ty.Kind() { + case reflect.Struct: + for i := range ty.NumField() { + f := ty.Field(i) + n := f.Name + if n == "Type" { + v := reflect.ValueOf(s).Field(i) + require.True(t, v.Elem().CanSet(), "cannot set the Type field") + v.SetString("") + } else { + //unType(t, f) + } + } + } +} diff --git a/pkg/jmap/jmap_integration_email_test.go b/pkg/jmap/jmap_integration_email_test.go index 66faf2b463..2e2f00c859 100644 --- a/pkg/jmap/jmap_integration_email_test.go +++ b/pkg/jmap/jmap_integration_email_test.go @@ -58,7 +58,7 @@ func TestEmails(t *testing.T) { var threads int = 0 var mails []filledMail = nil { - mails, threads, err = s.fill(inboxFolder, count) + mails, threads, err = s.fillEmailsWithImap(inboxFolder, count) require.NoError(err) } mailsByMessageId := structs.Index(mails, func(mail filledMail) string { return mail.messageId }) @@ -119,11 +119,11 @@ func TestEmails(t *testing.T) { } } -func matchEmail(t *testing.T, actual Email, e filledMail, hasBodies bool) { +func matchEmail(t *testing.T, actual Email, expected filledMail, hasBodies bool) { require := require.New(t) require.Len(actual.MessageId, 1) - require.Equal(e.messageId, actual.MessageId[0]) - require.Equal(e.subject, actual.Subject) + require.Equal(expected.messageId, actual.MessageId[0]) + require.Equal(expected.subject, actual.Subject) require.NotEmpty(actual.Preview) if hasBodies { require.Len(actual.TextBody, 1) @@ -135,7 +135,7 @@ func matchEmail(t *testing.T, actual Email, e filledMail, hasBodies bool) { } else { require.Empty(actual.BodyValues) } - require.ElementsMatch(slices.Collect(maps.Keys(actual.Keywords)), e.keywords) + require.ElementsMatch(slices.Collect(maps.Keys(actual.Keywords)), expected.keywords) { list := make([]filledAttachment, len(actual.Attachments)) @@ -150,6 +150,6 @@ func matchEmail(t *testing.T, actual Email, e filledMail, hasBodies bool) { require.NotEmpty(a.PartId) } - require.ElementsMatch(list, e.attachments) + require.ElementsMatch(list, expected.attachments) } } diff --git a/pkg/jmap/jmap_integration_test.go b/pkg/jmap/jmap_integration_test.go index 99877d3569..d41ad2732d 100644 --- a/pkg/jmap/jmap_integration_test.go +++ b/pkg/jmap/jmap_integration_test.go @@ -4,13 +4,17 @@ import ( "bytes" "context" "crypto/tls" + "encoding/base64" + "encoding/json" "fmt" "io" "log" "maps" + "math" "math/rand" "net" "net/http" + "net/http/httputil" "net/mail" "net/url" "os" @@ -24,6 +28,8 @@ import ( "github.com/gorilla/websocket" "github.com/jhillyerd/enmime/v2" + "github.com/test-go/testify/require" + "github.com/tidwall/pretty" "golang.org/x/text/cases" "golang.org/x/text/language" @@ -35,9 +41,14 @@ import ( "github.com/brianvoe/gofakeit/v7" pw "github.com/sethvargo/go-password/password" + "github.com/opencloud-eu/opencloud/pkg/jscalendar" + "github.com/opencloud-eu/opencloud/pkg/jscontact" clog "github.com/opencloud-eu/opencloud/pkg/log" + "github.com/opencloud-eu/opencloud/pkg/structs" "github.com/go-crypt/crypt/algorithm/shacrypt" + + "github.com/ProtonMail/go-crypto/openpgp" ) var ( @@ -56,7 +67,7 @@ var ( ) const ( - stalwartImage = "ghcr.io/stalwartlabs/stalwart:v0.13.4-alpine" + stalwartImage = "ghcr.io/stalwartlabs/stalwart:v0.14.0-alpine" httpPort = "8080" imapsPort = "993" configTemplate = ` @@ -354,6 +365,7 @@ func newStalwartTest(t *testing.T) (*StalwartTest, error) { jmapBaseUrl := url.URL{ Scheme: "http", Host: ip + ":" + jmapPort.Port(), + Path: "/", } sessionUrl := jmapBaseUrl.JoinPath(".well-known", "jmap") @@ -380,9 +392,23 @@ func newStalwartTest(t *testing.T) (*StalwartTest, error) { // "localhost" as we defined the hostname in the Stalwart configuration, // and we also need to overwrite the port number as its not mapped s.JmapUrl.Host = jmapBaseUrl.Host + s.WebsocketUrl.Host = jmapBaseUrl.Host + //s.JmapEndpoint = jmapBaseUrl.Host + s.ApiUrl, err = replaceHost(s.ApiUrl, jmapBaseUrl.Host) + require.NoError(t, err) + s.DownloadUrl, err = replaceHost(s.DownloadUrl, jmapBaseUrl.Host) + require.NoError(t, err) + s.UploadUrl, err = replaceHost(s.UploadUrl, jmapBaseUrl.Host) + require.NoError(t, err) + s.EventSourceUrl, err = replaceHost(s.EventSourceUrl, jmapBaseUrl.Host) + require.NoError(t, err) session = &s } + require.NotNil(t, session.Capabilities.Mail) + require.NotNil(t, session.Capabilities.Calendars) + require.NotNil(t, session.Capabilities.Contacts) + success = true return &StalwartTest{ t: t, @@ -401,6 +427,16 @@ func newStalwartTest(t *testing.T) (*StalwartTest, error) { }, nil } +var urlHostRegex = regexp.MustCompile(`^(https?://)(.+?)/(.+)$`) + +func replaceHost(u string, host string) (string, error) { + if m := urlHostRegex.FindAllStringSubmatch(u, -1); m != nil { + return fmt.Sprintf("%s%s/%s", m[0][1], host, m[0][3]), nil + } else { + return "", fmt.Errorf("'%v' does not match '%v'", u, urlHostRegex) + } +} + type filledAttachment struct { name string size int @@ -475,7 +511,7 @@ func pickRandomlyFromMap[K comparable, V any](m map[K]V, min int, max int) map[K return r } -func (s *StalwartTest) fill(folder string, count int) ([]filledMail, int, error) { +func (s *StalwartTest) fillEmailsWithImap(folder string, count int) ([]filledMail, int, error) { to := fmt.Sprintf("%s <%s>", s.userPersonName, s.userEmail) ccEvery := 2 bccEvery := 3 @@ -735,3 +771,967 @@ func (s *StalwartTest) fill(folder string, count int) ([]filledMail, int, error) return mails, thread, nil } + +var productName = "jmaptest" + +func (s *StalwartTest) fillContacts( + t *testing.T, + count uint, +) (string, string, map[string]jscontact.ContactCard, error) { + require := require.New(t) + c, err := NewTestJmapClient(s.session, s.username, s.password, true, true) + require.NoError(err) + defer c.Close() + + printer := func(s string) { log.Println(s) } + + accountId := c.session.PrimaryAccounts.Contacts + require.NotEmpty(accountId, "no primary account for contacts in session") + + addressbookId := "" + { + addressBooksById, err := testObjectsById(c, accountId, AddressBookType, JmapContacts) + require.NoError(err) + + for id, addressbook := range addressBooksById { + if isDefault, ok := addressbook["isDefault"]; ok { + if isDefault.(bool) { + addressbookId = id + break + } + } + } + } + require.NotEmpty(addressbookId) + + filled := map[string]jscontact.ContactCard{} + for i := range count { + person := gofakeit.Person() + nameMap, nameObj := createName(person) + contact := map[string]any{ + "@type": "Card", + "version": "1.0", + "addressBookIds": toBoolMap([]string{addressbookId}), + "prodId": productName, + "language": pickLanguage(), + "kind": "individual", + "name": nameMap, + } + card := jscontact.ContactCard{ + //Type: jscontact.ContactCardType, + Version: "1.0", + AddressBookIds: toBoolMap([]string{addressbookId}), + ProdId: productName, + Language: contact["language"].(string), + Kind: jscontact.ContactCardKindIndividual, + Name: &nameObj, + } + + if rand.Intn(3) < 1 { + nicknameMap, nicknameObj := createNickName(person) + id := id() + contact["nicknames"] = map[string]map[string]any{id: nicknameMap} + card.Nicknames = map[string]jscontact.Nickname{id: nicknameObj} + } + + { + emailMaps := map[string]map[string]any{} + emailObjs := map[string]jscontact.EmailAddress{} + emailId := id() + 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) + emailMaps[id] = m + emailObjs[id] = o + } + if len(emailMaps) > 0 { + contact["emails"] = emailMaps + card.Emails = emailObjs + } + } + if err := propmap(contact, "phones", &card.Phones, 0, 2, func(i int, id string) (map[string]any, jscontact.Phone, error) { + num := person.Contact.Phone + if i > 0 { + num = gofakeit.Phone() + } + var mapFeatures map[string]bool = nil + var objFeatures map[jscontact.PhoneFeature]bool = nil + if rand.Intn(3) < 2 { + mapFeatures = toBoolMapS("mobile", "voice", "video", "text") + objFeatures = toBoolMapS(jscontact.PhoneFeatureMobile, jscontact.PhoneFeatureVoice, jscontact.PhoneFeatureVideo, jscontact.PhoneFeatureText) + } else { + mapFeatures = toBoolMapS("voice", "main-number") + objFeatures = toBoolMapS(jscontact.PhoneFeatureVoice, jscontact.PhoneFeatureMainNumber) + } + mapContexts := map[string]bool{} + objContexts := map[jscontact.PhoneContext]bool{} + mapContexts["work"] = true + objContexts[jscontact.PhoneContextWork] = true + if rand.Intn(2) < 1 { + mapContexts["private"] = true + objContexts[jscontact.PhoneContextPrivate] = true + } + tel := "tel:" + "+1" + num + return map[string]any{ + "@type": "Phone", + "number": tel, + "features": mapFeatures, + "contexts": mapContexts, + }, jscontact.Phone{ + //Type: jscontact.PhoneType, + Number: tel, + Features: objFeatures, + Contexts: objContexts, + }, nil + }); err != nil { + return "", "", nil, err + } + if err := propmap(contact, "addresses", &card.Addresses, 1, 2, func(i int, id string) (map[string]any, jscontact.Address, error) { + var source *gofakeit.AddressInfo + if i == 0 { + source = person.Address + } else { + source = gofakeit.Address() + } + mComps := []map[string]string{} + oComps := []jscontact.AddressComponent{} + m := streetNumberRegex.FindAllStringSubmatch(source.Street, -1) + if m != nil { + mComps = append(mComps, map[string]string{"kind": "name", "value": m[0][2]}) + mComps = append(mComps, map[string]string{"kind": "number", "value": m[0][1]}) + oComps = append(oComps, jscontact.AddressComponent{ /*Type: jscontact.AddressComponentType,*/ Kind: jscontact.AddressComponentKindName, Value: m[0][2]}) + oComps = append(oComps, jscontact.AddressComponent{ /*Type: jscontact.AddressComponentType,*/ Kind: jscontact.AddressComponentKindNumber, Value: m[0][1]}) + } else { + mComps = append(mComps, map[string]string{"kind": "name", "value": source.Street}) + oComps = append(oComps, jscontact.AddressComponent{ /*Type: jscontact.AddressComponentType,*/ Kind: jscontact.AddressComponentKindName, Value: source.Street}) + } + mComps = append(mComps, + map[string]string{"kind": "locality", "value": source.City}, + map[string]string{"kind": "country", "value": source.Country}, + map[string]string{"kind": "region", "value": source.State}, + map[string]string{"kind": "postcode", "value": source.Zip}, + ) + oComps = append(oComps, + 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{ + "@type": "Address", + "components": mComps, + "defaultSeparator": ", ", + "isOrdered": true, + "timeZone": tz, + }, jscontact.Address{ + //Type: jscontact.AddressType, + Components: oComps, + DefaultSeparator: ", ", + IsOrdered: true, + TimeZone: tz, + }, nil + }); err != nil { + return "", "", nil, err + } + if err := propmap(contact, "onlineServices", &card.OnlineServices, 0, 2, func(i int, id string) (map[string]any, jscontact.OnlineService, error) { + 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 + case 1: + return map[string]any{ + "@type": "OnlineService", + "uri": "xmpp:" + person.Contact.Email, + }, 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 + } + }); err != nil { + return "", "", nil, err + } + + if err := propmap(contact, "preferredLanguages", &card.PreferredLanguages, 0, 2, func(i int, id string) (map[string]any, jscontact.LanguagePref, error) { + 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 + }); err != nil { + return "", "", nil, err + } + + { + organizationMaps := map[string]map[string]any{} + organizationObjs := map[string]jscontact.Organization{} + titleMaps := map[string]map[string]any{} + titleObjs := map[string]jscontact.Title{} + for range rand.Intn(2) { + 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, + Name: person.Job.Title, + OrganizationId: orgId, + } + } + if len(organizationMaps) > 0 { + contact["organizations"] = organizationMaps + contact["titles"] = titleMaps + card.Organizations = organizationObjs + card.Titles = titleObjs + } + } + + if err := propmap(contact, "cryptoKeys", &card.CryptoKeys, 0, 1, func(i int, id string) (map[string]any, jscontact.CryptoKey, error) { + entity, err := openpgp.NewEntity(person.FirstName+" "+person.LastName, "test", person.Contact.Email, nil) + if err != nil { + return nil, jscontact.CryptoKey{}, err + } + var b bytes.Buffer + err = entity.PrimaryKey.Serialize(&b) + if err != nil { + return nil, jscontact.CryptoKey{}, err + } + encoded := base64.RawStdEncoding.EncodeToString(b.Bytes()) + return map[string]any{ + "@type": "CryptoKey", + "uri": "data:application/pgp-keys;base64," + encoded, + }, jscontact.CryptoKey{ + // Type: jscontact.CryptoKeyType, + Uri: "data:application/pgp-keys;base64," + encoded, + }, nil + }); err != nil { + return "", "", nil, err + } + + if err := propmap(contact, "media", &card.Media, 0, 1, func(i int, id string) (map[string]any, jscontact.Media, error) { + if rand.Intn(2) < 1 { + img := gofakeit.ImageJpeg(128, 128) + blob, err := c.uploadBlob(accountId, img, "image/jpeg") + if err != nil { + return nil, jscontact.Media{}, err + } + return map[string]any{ + "@type": "Media", + "kind": "photo", + "blobId": blob.BlobId, + "contexts": toBoolMapS("private"), + }, jscontact.Media{ + // Type: jscontact.MediaType, + Kind: jscontact.MediaKindPhoto, + BlobId: blob.BlobId, + MediaType: blob.Type, + Contexts: toBoolMapS(jscontact.MediaContextPrivate), + }, nil + + } else { + uri := picsum(128, 128) + return map[string]any{ + "@type": "Media", + "kind": "photo", + "uri": uri, + "contexts": toBoolMapS("work"), + }, jscontact.Media{ + // Type: jscontact.MediaType, + Kind: jscontact.MediaKindPhoto, + Uri: uri, + Contexts: toBoolMapS(jscontact.MediaContextWork), + }, nil + } + }); err != nil { + return "", "", nil, err + } + if err := propmap(contact, "links", &card.Links, 0, 1, func(i int, id string) (map[string]any, jscontact.Link, error) { + 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 + }); err != nil { + return "", "", nil, err + } + + uid, err := s.CreateContact(c, accountId, contact) + if err != nil { + return "", "", nil, err + } + filled[uid] = card + printer(fmt.Sprintf("🧑🏻 created %*s/%v uid=%v", int(math.Log10(float64(count))+1), strconv.Itoa(int(i+1)), count, uid)) + } + return accountId, addressbookId, filled, nil +} + +func (s *StalwartTest) CreateContact(j *TestJmapClient, accountId string, contact map[string]any) (string, error) { + body := map[string]any{ + "using": []string{JmapCore, JmapContacts}, + "methodCalls": []any{ + []any{ + ContactCardType + "/set", + map[string]any{ + "accountId": accountId, + "create": map[string]any{ + "c": contact, + }, + }, + "0", + }, + }, + } + return testCreate(j, "c", ContactCardType, body) +} + +var streetNumberRegex = regexp.MustCompile(`^(\d+)\s+(.+)$`) + +type TestJmapClient struct { + h *http.Client + username string + password string + session *Session + u *url.URL + trace bool + color bool +} + +func NewTestJmapClient(session *Session, username string, password string, trace bool, color bool) (*TestJmapClient, error) { + httpTransport := http.DefaultTransport.(*http.Transport).Clone() + tlsConfig := &tls.Config{InsecureSkipVerify: true} + httpTransport.TLSClientConfig = tlsConfig + h := http.DefaultClient + h.Transport = httpTransport + + u, err := url.Parse(session.ApiUrl) + if err != nil { + return nil, err + } + + return &TestJmapClient{ + h: h, + trace: trace, + color: color, + username: username, + password: password, + session: session, + u: u, + }, nil +} + +func (j *TestJmapClient) Close() error { + return nil +} + +type uploadedBlob struct { + BlobId string `json:"blobId"` + Size int `json:"size"` + Type string `json:"type"` + Sha512 string `json:"sha:512"` +} + +func (j *TestJmapClient) uploadBlob(accountId string, data []byte, mimetype string) (uploadedBlob, error) { + uploadUrl := strings.ReplaceAll(j.session.UploadUrl, "{accountId}", accountId) + req, err := http.NewRequest(http.MethodPost, uploadUrl, bytes.NewReader(data)) + if err != nil { + return uploadedBlob{}, err + } + req.Header.Add("Content-Type", mimetype) + req.SetBasicAuth(j.username, j.password) + res, err := j.h.Do(req) + if err != nil { + return uploadedBlob{}, err + } + defer res.Body.Close() + var response []byte = nil + if j.trace { + if b, err := httputil.DumpResponse(res, false); err == nil { + response, err = io.ReadAll(res.Body) + if err != nil { + return uploadedBlob{}, err + } + p := pretty.Pretty(response) + if j.color { + p = pretty.Color(p, nil) + } + log.Printf("<== %s%s\n", b, p) + } + } + if res.StatusCode < 200 || res.StatusCode > 299 { + return uploadedBlob{}, fmt.Errorf("blob uploading to '%v': status is %s", uploadUrl, res.Status) + } + if response == nil { + response, err = io.ReadAll(res.Body) + if err != nil { + return uploadedBlob{}, err + } + } + + var result uploadedBlob + err = json.Unmarshal(response, &result) + if err != nil { + return uploadedBlob{}, err + } + + return result, nil +} + +func testCommand[T any](j *TestJmapClient, body map[string]any, closure func([]any) (T, error)) (T, error) { + var zero T + + payload, err := json.Marshal(body) + if err != nil { + return zero, err + } + req, err := http.NewRequest(http.MethodPost, j.u.String(), bytes.NewReader(payload)) + if err != nil { + return zero, err + } + + if j.trace { + if b, err := httputil.DumpRequestOut(req, false); err == nil { + p := pretty.Pretty(payload) + if j.color { + p = pretty.Color(p, nil) + } + log.Printf("==> %s%s\n", b, p) + } + } + + req.SetBasicAuth(j.username, j.password) + resp, err := j.h.Do(req) + if err != nil { + return zero, err + } + defer resp.Body.Close() + var response []byte = nil + if j.trace { + if b, err := httputil.DumpResponse(resp, false); err == nil { + response, err = io.ReadAll(resp.Body) + if err != nil { + return zero, err + } + p := pretty.Pretty(response) + if j.color { + p = pretty.Color(p, nil) + } + log.Printf("<== %s%s\n", b, p) + } + } + if resp.StatusCode >= 300 { + return zero, fmt.Errorf("JMAP command HTTP response status is %s", resp.Status) + } + if response == nil { + response, err = io.ReadAll(resp.Body) + if err != nil { + return zero, err + } + } + + r := map[string]any{} + err = json.Unmarshal(response, &r) + if err != nil { + return zero, err + } + + methodResponses := r["methodResponses"].([]any) + return closure(methodResponses) +} + +func testCreate(j *TestJmapClient, id string, objectType ObjectType, body map[string]any) (string, error) { + return testCommand(j, body, 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) + } + } + }) +} + +func testObjectsById(j *TestJmapClient, accountId string, objectType ObjectType, scope string) (map[string]map[string]any, error) { + m := map[string]map[string]any{} + { + body := map[string]any{ + "using": []string{JmapCore, scope}, + "methodCalls": []any{ + []any{ + objectType + "/get", + map[string]any{ + "accountId": accountId, + }, + "0", + }, + }, + } + result, err := testCommand(j, body, func(methodResponses []any) ([]any, error) { + z := methodResponses[0].([]any) + f := z[1].(map[string]any) + if list, ok := f["list"]; ok { + return list.([]any), nil + } else { + return nil, fmt.Errorf("methodResponse[1] has no 'list' attribute: %v", f) + } + }) + if err != nil { + return nil, err + } + for _, a := range result { + obj := a.(map[string]any) + id := obj["id"].(string) + m[id] = obj + } + } + return m, nil +} + +func createName(person *gofakeit.PersonInfo) (map[string]any, jscontact.Name) { + o := 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{ + // Type: jscontact.NameComponentType, + Kind: jscontact.NameComponentKindGiven, + Value: person.FirstName, + } + mComps[1] = map[string]string{ + "kind": "surname", + "value": person.LastName, + } + oComps[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 = " " + full := fmt.Sprintf("%s %s", person.FirstName, person.LastName) + m["full"] = full + o.Full = full + return m, o +} + +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) })), + }, jscontact.Nickname{ + // Type: jscontact.NicknameType, + Name: name, + Contexts: orNilMap(toBoolMap(contexts)), + } +} + +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) + 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), + } +} + +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, + }, jscontact.EmailAddress{ + // Type: jscontact.EmailAddressType, + Address: email, + Contexts: orNilMap(toBoolMap(contexts)), + Pref: uint(pref), + } +} + +var idFirstLetters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") +var idOtherLetters = append(idFirstLetters, []rune("0123456789")...) + +func id() string { + n := 4 + rand.Intn(12-4+1) + b := make([]rune, n) + b[0] = idFirstLetters[rand.Intn(len(idFirstLetters))] + for i := 1; i < n; i++ { + b[i] = idOtherLetters[rand.Intn(len(idOtherLetters))] + } + return string(b) +} + +var timezones = []string{ + "America/Adak", + "America/Anchorage", + "America/Chicago", + "America/Denver", + "America/Detroit", + "America/Indiana/Knox", + "America/Kentucky/Louisville", + "America/Los_Angeles", + "America/New_York", +} + +var rooms = []jscalendar.Location{ + { + Type: "Location", + Name: "office-upstairs", + Description: "Office meeting room upstairs", + LocationTypes: toBoolMapS(jscalendar.LocationTypeOptionOffice), + Coordinates: "geo:52.5335389,13.4103296", + Links: map[string]jscalendar.Link{ + id(): {Href: "https://www.heinlein-support.de/"}, + }, + }, + { + Type: "Location", + Name: "office-nue", + Description: "", + LocationTypes: toBoolMapS(jscalendar.LocationTypeOptionOffice), + Coordinates: "geo:49.4723337,11.1042282", + Links: map[string]jscalendar.Link{ + id(): {Href: "https://www.workandpepper.de/"}, + }, + }, + { + Type: "Location", + Name: "Meetingraum Prenzlauer Berg", + Description: "This is a Hero Space with great reviews, fast response-time and good quality service", + LocationTypes: toBoolMapS(jscalendar.LocationTypeOptionOffice, jscalendar.LocationTypeOptionPublic), + Coordinates: "geo:52.554222,13.4142387", + Links: map[string]jscalendar.Link{ + id(): {Href: "https://www.spacebase.com/en/venue/meeting-room-prenzlauer-be-11499/"}, + }, + }, + { + Type: "Location", + Name: "Meetingraum LIANE 1", + Description: "Ecofriendly Bright Urban Jungle", + LocationTypes: toBoolMapS(jscalendar.LocationTypeOptionOffice, jscalendar.LocationTypeOptionLibrary), + Coordinates: "geo:52.4854301,13.4224763", + Links: map[string]jscalendar.Link{ + id(): {Href: "https://www.spacebase.com/en/venue/rent-a-jungle-8372/"}, + }, + }, + { + Type: "Location", + Name: "Dark Horse", + Description: "Collaboration and event spaces from the authors of the Workspace and Digital Innovation Playbooks.", + LocationTypes: toBoolMapS(jscalendar.LocationTypeOptionOffice), + Coordinates: "geo:52.4942254,13.4346015", + Links: map[string]jscalendar.Link{ + id(): {Href: "https://www.spacebase.com/en/event-venue/workshop-white-space-2667/"}, + }, + }, +} + +var virtualRooms = []jscalendar.VirtualLocation{ + { + Type: "VirtualLocation", + Name: "opentalk", + Description: "the main room in our opentalk instance", + Uri: "https://meet.opentalk.eu/fake/room/" + gofakeit.UUID(), + Features: toBoolMapS( + jscalendar.VirtualLocationFeatureAudio, + jscalendar.VirtualLocationFeatureChat, + jscalendar.VirtualLocationFeatureVideo, + jscalendar.VirtualLocationFeatureScreen, + ), + }, +} + +func createLocation() (string, jscalendar.Location) { + locationId := id() + room := rooms[rand.Intn(len(rooms))] + return locationId, room +} + +func createVirtualLocation() (string, jscalendar.VirtualLocation) { + locationId := id() + return locationId, virtualRooms[rand.Intn(len(virtualRooms))] +} + +var ChairRoles = toBoolMapS("attendee", "chair", "owner") +var RegularRoles = toBoolMapS("attendee") + +func createParticipants(locationId string, virtualLocationid string) (map[string]map[string]any, string) { + n := 1 + rand.Intn(4) + participants := map[string]map[string]any{} + organizerId, organizerEmail, organizer := createParticipant(0, pickRandom(locationId, virtualLocationid), "", "") + participants[organizerId] = organizer + for i := 1; i < n; i++ { + id, _, participant := createParticipant(i, pickRandom(locationId, virtualLocationid), organizerId, organizerEmail) + participants[id] = participant + } + return participants, organizerEmail +} + +func createParticipant(i int, locationId string, organizerEmail string, organizerId string) (string, string, map[string]any) { + participantId := id() + person := gofakeit.Person() + roles := RegularRoles + if i == 0 { + roles = ChairRoles + } + status := "accepted" + if i != 0 { + status = pickRandom("needs-action", "accepted", "declined", "tentative") //, delegated + set "delegatedTo" + } + statusComment := "" + if rand.Intn(5) >= 3 { + statusComment = gofakeit.HipsterSentence(1 + rand.Intn(5)) + } + if i == 0 { + organizerEmail = person.Contact.Email + organizerId = participantId + } + m := map[string]any{ + "@type": "Participant", + "name": person.FirstName + " " + person.LastName, + "email": person.Contact.Email, + "description": person.Job.Title, + "sendTo": map[string]string{ + "imip": "mailto:" + person.Contact.Email, + }, + "kind": "individual", + "roles": roles, + "locationId": locationId, + "language": pickLanguage(), + "participationStatus": status, + "participationComment": statusComment, + "expectReply": true, + "scheduleAgent": "server", + "scheduleSequence": 1, + "scheduleStatus": []string{"1.0"}, + "scheduleUpdated": "2025-10-01T1:59:12Z", + "sentBy": organizerEmail, + "invitedBy": organizerId, + "scheduleId": "mailto:" + person.Contact.Email, + } + + links := map[string]map[string]any{} + for range rand.Intn(3) { + links[id()] = map[string]any{ + "@type": "Link", + "href": "https://picsum.photos/id/" + strconv.Itoa(1+rand.Intn(200)) + "/200/300", + "contentType": "image/jpeg", + "rel": "icon", + "display": "badge", + "title": person.FirstName + "'s Cake Day pick", + } + } + if len(links) > 0 { + m["links"] = links + } + + return participantId, person.Contact.Email, m +} + +var Keywords = []string{ + "office", + "important", + "sales", + "coordination", + "decision", +} + +func keywords() map[string]bool { + return toBoolMap(pickRandoms(Keywords...)) +} + +var Categories = []string{ + "secret", + "internal", +} + +func categories() map[string]bool { + return toBoolMap(pickRandoms(Categories...)) +} + +func propmap[T any](container map[string]any, name string, cardProperty *map[string]T, min int, max int, generator func(int, string) (map[string]any, T, error)) error { + n := min + rand.Intn(max-min+1) + if n < 1 { + return nil + } + + 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) + if err != nil { + return err + } + if itemForMap != nil { + m[id] = itemForMap + o[id] = itemForCard + } + } + if len(m) > 0 { + container[name] = m + *cardProperty = o + } + 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 orNilMap[K comparable, V any](m map[K]V) map[K]V { + if len(m) < 1 { + return nil + } else { + return m + } +} + +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 { + m[e] = true + } + return m +} + +func toBoolMapS[K comparable](s ...K) map[K]bool { + m := make(map[K]bool, len(s)) + for _, e := range s { + m[e] = true + } + return m +} + +func pickRandom[T any](s ...T) T { + return s[rand.Intn(len(s))] +} + +func pickRandoms[T any](s ...T) []T { + n := rand.Intn(len(s)) + if n == 0 { + return []T{} + } + result := make([]T, n) + o := make([]T, len(s)) + copy(o, s) + for i := range n { + p := rand.Intn(len(o)) + result[i] = slices.Delete(o, p, p)[0] + } + return result +} + +func pickRandoms1[T any](s ...T) []T { + n := 1 + rand.Intn(len(s)-1) + result := make([]T, n) + o := make([]T, len(s)) + copy(o, s) + for i := range n { + p := rand.Intn(len(o)) + result[i] = slices.Delete(o, p, p)[0] + } + return result +} + +func pickLanguage() string { + return pickRandom("en-US", "en-GB", "en-AU") +} diff --git a/pkg/jmap/jmap_model.go b/pkg/jmap/jmap_model.go index e1ec26b136..b9dd5238a0 100644 --- a/pkg/jmap/jmap_model.go +++ b/pkg/jmap/jmap_model.go @@ -918,8 +918,10 @@ type SessionPrimaryAccounts struct { Quota string `json:"urn:ietf:params:jmap:quota,omitempty"` Websocket string `json:"urn:ietf:params:jmap:websocket,omitempty"` Task string `json:"urn:ietf:params:jmap:task,omitempty"` - Calendar string `json:"urn:ietf:params:jmap:calendar,omitempty"` - Contact string `json:"urn:ietf:params:jmap:contact,omitempty"` + Calendars string `json:"urn:ietf:params:jmap:calendars,omitempty"` + CalendarsParse string `json:"urn:ietf:params:jmap:calendars:parse,omitempty"` + Contacts string `json:"urn:ietf:params:jmap:contacts,omitempty"` + ContactsParse string `json:"urn:ietf:params:jmap:contacts:parse,omitempty"` } type SessionState string diff --git a/pkg/jscontact/jscontact_model.go b/pkg/jscontact/jscontact_model.go index 36cfbd8739..bbf5e52015 100644 --- a/pkg/jscontact/jscontact_model.go +++ b/pkg/jscontact/jscontact_model.go @@ -587,7 +587,7 @@ const ( // PhoneFeatures. - PhoneFeatureMobile = PhoneFeature("mobile") + PhoneFeatureMobile = PhoneFeature("cell") // TODO the spec says 'mobile', but Stalwart only supports 'cell' PhoneFeatureVoice = PhoneFeature("voice") PhoneFeatureText = PhoneFeature("text") PhoneFeatureVideo = PhoneFeature("video") diff --git a/pkg/structs/structs.go b/pkg/structs/structs.go index e9e888e467..aa48d14e35 100644 --- a/pkg/structs/structs.go +++ b/pkg/structs/structs.go @@ -55,18 +55,50 @@ func Index[K comparable, V any](source []V, indexer func(V) K) map[K]V { return result } -func Map[E any, R any](source []E, indexer func(E) R) []R { +func Map[E any, R any](source []E, mapper func(E) R) []R { if source == nil { var zero []R return zero } result := make([]R, len(source)) for i, e := range source { - result[i] = indexer(e) + result[i] = mapper(e) } return result } +func MapValues[K comparable, S any, T any](m map[K]S, mapper func(S) T) map[K]T { + r := make(map[K]T, len(m)) + for k, s := range m { + r[k] = mapper(s) + } + return r +} + +func MapValues2[K comparable, S any, T any](m map[K]S, mapper func(K, S) T) map[K]T { + r := make(map[K]T, len(m)) + for k, s := range m { + r[k] = mapper(k, s) + } + return r +} + +func MapKeys[S comparable, T comparable, V any](m map[S]V, mapper func(S) T) map[T]V { + r := make(map[T]V, len(m)) + for s, v := range m { + r[mapper(s)] = v + } + return r +} + +func MapKeys2[S comparable, T comparable, V any](m map[S]V, mapper func(S, V) T) map[T]V { + r := make(map[T]V, len(m)) + for s, v := range m { + r[mapper(s, v)] = v + } + return r +} + func ToBoolMap[E comparable](source []E) map[E]bool { m := make(map[E]bool, len(source)) for _, v := range source { diff --git a/services/groupware/pkg/groupware/groupware_api_contacts.go b/services/groupware/pkg/groupware/groupware_api_contacts.go index e93327c8b1..b2df845f67 100644 --- a/services/groupware/pkg/groupware/groupware_api_contacts.go +++ b/services/groupware/pkg/groupware/groupware_api_contacts.go @@ -148,6 +148,32 @@ func (g *Groupware) GetContactsInAddressbook(w http.ResponseWriter, r *http.Requ }) } +func (g *Groupware) GetContactById(w http.ResponseWriter, r *http.Request) { + g.respond(w, r, func(req Request) Response { + ok, accountId, resp := req.needContactWithAccount() + if !ok { + return resp + } + + l := req.logger.With() + + contactId := chi.URLParam(r, UriParamContactId) + l = l.Str(UriParamContactId, log.SafeString(contactId)) + + logger := log.From(l) + contactsById, sessionState, state, lang, jerr := g.jmap.GetContactCardsById(accountId, req.session, req.ctx, logger, req.language(), []string{contactId}) + if jerr != nil { + return req.errorResponseFromJmap(jerr) + } + + if contact, ok := contactsById[contactId]; ok { + return etagResponse(contact, sessionState, state, lang) + } else { + return notFoundResponse(sessionState) + } + }) +} + func (g *Groupware) CreateContact(w http.ResponseWriter, r *http.Request) { g.respond(w, r, func(req Request) Response { ok, accountId, resp := req.needContactWithAccount() diff --git a/services/groupware/pkg/groupware/groupware_route.go b/services/groupware/pkg/groupware/groupware_route.go index ffe49a63a2..97bc256877 100644 --- a/services/groupware/pkg/groupware/groupware_route.go +++ b/services/groupware/pkg/groupware/groupware_route.go @@ -148,6 +148,7 @@ func (g *Groupware) Route(r chi.Router) { r.Route("/contacts", func(r chi.Router) { r.Post("/", g.CreateContact) r.Delete("/{contactid}", g.DeleteContact) + r.Get("/{contactid}", g.GetContactById) }) r.Route("/calendars", func(r chi.Router) { r.Get("/", g.GetCalendars)