Files
opencloud/pkg/jmap/jmap_integration_test.go

1788 lines
50 KiB
Go
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package jmap
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"
"regexp"
"slices"
"strconv"
"strings"
"testing"
"text/template"
"time"
"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"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/imapclient"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
"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 (
domains = [...]string{"earth.gov", "mars.mil", "opa.org", "acme.com"}
people = [...]string{
"Camina Drummer",
"Amos Burton",
"James Holden",
"Anderson Dawes",
"Naomi Nagata",
"Klaes Ashford",
"Fred Johnson",
"Chrisjen Avasarala",
"Bobby Draper",
}
)
const (
stalwartImage = "ghcr.io/stalwartlabs/stalwart:v0.14.0-alpine"
httpPort = "8080"
imapsPort = "993"
configTemplate = `
authentication.fallback-admin.secret = "$6$4qPYDVhaUHkKcY7s$bB6qhcukb9oFNYRIvaDZgbwxrMa2RvF5dumCjkBFdX19lSNqrgKltf3aPrFMuQQKkZpK2YNuQ83hB1B3NiWzj."
authentication.fallback-admin.user = "mailadmin"
authentication.master.secret = "{{.masterpassword}}"
authentication.master.user = "{{.masterusername}}"
directory.memory.principals.0000.class = "admin"
directory.memory.principals.0000.description = "Superuser"
directory.memory.principals.0000.email.0000 = "admin@example.org"
directory.memory.principals.0000.name = "admin"
directory.memory.principals.0000.secret = "secret"
directory.memory.principals.0001.class = "individual"
directory.memory.principals.0001.description = "{{.description}}"
directory.memory.principals.0001.email.0000 = "{{.email}}"
directory.memory.principals.0001.name = "{{.username}}"
directory.memory.principals.0001.secret = "{{.password}}"
directory.memory.principals.0001.storage.directory = "memory"
directory.memory.type = "memory"
metrics.prometheus.enable = false
server.listener.http.bind = "[::]:{{.httpPort}}"
server.listener.http.protocol = "http"
server.listener.imaptls.bind = "[::]:{{.imapsPort}}"
server.listener.imaptls.protocol = "imap"
server.listener.imaptls.tls.implicit = true
server.hostname = "{{.hostname}}"
server.max-connections = 8192
server.socket.backlog = 1024
server.socket.nodelay = true
server.socket.reuse-addr = true
server.socket.reuse-port = true
storage.blob = "rocksdb"
storage.data = "rocksdb"
storage.directory = "memory"
storage.fts = "rocksdb"
storage.lookup = "rocksdb"
store.rocksdb.compression = "lz4"
store.rocksdb.path = "/opt/stalwart/data"
store.rocksdb.type = "rocksdb"
tracer.log.ansi = false
tracer.log.buffered = false
tracer.log.enable = true
tracer.log.level = "trace"
tracer.log.lossy = false
tracer.log.multiline = false
tracer.log.type = "stdout"
`
)
func htmlJoin(parts []string) []string {
var result []string
for i := range parts {
result = append(result, fmt.Sprintf("<p>%v</p>", parts[i]))
}
return result
}
var paraSplitter = regexp.MustCompile("[\r\n]+")
var emailSplitter = regexp.MustCompile("(.+)@(.+)$")
func htmlFormat(body string, msg enmime.MailBuilder) enmime.MailBuilder {
return msg.HTML([]byte(strings.Join(htmlJoin(paraSplitter.Split(body, -1)), "\n")))
}
func textFormat(body string, msg enmime.MailBuilder) enmime.MailBuilder {
return msg.Text([]byte(body))
}
func bothFormat(body string, msg enmime.MailBuilder) enmime.MailBuilder {
msg = htmlFormat(body, msg)
msg = textFormat(body, msg)
return msg
}
var formats = []func(string, enmime.MailBuilder) enmime.MailBuilder{
htmlFormat,
textFormat,
bothFormat,
}
type sender struct {
first string
last string
from string
sender string
}
func (s sender) inject(b enmime.MailBuilder) enmime.MailBuilder {
return b.From(s.first+" "+s.last, s.from).Header("Sender", s.sender)
}
type senderGenerator struct {
senders []sender
}
func newSenderGenerator(numSenders int) senderGenerator {
senders := make([]sender, numSenders)
for i := range numSenders {
person := gofakeit.Person()
senders[i] = sender{
first: person.FirstName,
last: person.LastName,
from: person.Contact.Email,
sender: person.FirstName + " " + person.LastName + "<" + person.Contact.Email + ">",
}
}
return senderGenerator{
senders: senders,
}
}
func (s senderGenerator) nextSender() *sender {
if len(s.senders) < 1 {
panic("failed to determine a sender to use")
} else {
return &s.senders[rand.Intn(len(s.senders))]
}
}
func fakeFilename(extension string) string {
return strings.ReplaceAll(gofakeit.Product().Name, " ", "_") + extension
}
func mailboxId(role string, mailboxes []Mailbox) string {
for _, m := range mailboxes {
if m.Role == role {
return m.Id
}
}
return ""
}
func skip(t *testing.T) bool {
if os.Getenv("CI") == "woodpecker" {
t.Skip("Skipping tests because CI==wookpecker")
return true
}
if os.Getenv("CI_SYSTEM_NAME") == "woodpecker" {
t.Skip("Skipping tests because CI_SYSTEM_NAME==wookpecker")
return true
}
if os.Getenv("USE_TESTCONTAINERS") == "false" {
t.Skip("Skipping tests because USE_TESTCONTAINERS==false")
return true
}
return false
}
type StalwartTest struct {
t *testing.T
ip string
imapPort int
container *testcontainers.DockerContainer
ctx context.Context
cancelCtx context.CancelFunc
client *Client
session *Session
username string
password string
logger *clog.Logger
userPersonName string
userEmail string
io.Closer
}
func (s *StalwartTest) Close() error {
if s.container != nil {
var c testcontainers.Container = s.container
testcontainers.CleanupContainer(s.t, c)
}
if s.cancelCtx != nil {
s.cancelCtx()
}
return nil
}
func newStalwartTest(t *testing.T) (*StalwartTest, error) {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
var _ context.CancelFunc = cancel // ignore context leak warning: it is passed in the struct and called in Close()
// A master user name different from "master" does not seem to work as of the current Stalwart version
//masterUsernameSuffix, err := pw.Generate(4+rand.Intn(28), 2, 0, false, true)
//require.NoError(err)
masterUsername := "master" //"master_" + masterUsernameSuffix
masterPassword, err := pw.Generate(4+rand.Intn(28), 2, 0, false, true)
if err != nil {
return nil, err
}
masterPasswordHash := ""
{
hasher, err := shacrypt.New(shacrypt.WithSHA512(), shacrypt.WithIterations(shacrypt.IterationsDefaultOmitted))
if err != nil {
return nil, err
}
digest, err := hasher.Hash(masterPassword)
if err != nil {
return nil, err
}
masterPasswordHash = digest.Encode()
}
usernameSuffix, err := pw.Generate(8, 2, 0, true, true)
if err != nil {
return nil, err
}
username := "user_" + usernameSuffix
password, err := pw.Generate(4+rand.Intn(28), 2, 0, false, true)
if err != nil {
return nil, err
}
hostname := "localhost"
userPersonName := people[rand.Intn(len(people))]
var userEmail string
{
domain := domains[rand.Intn(len(domains))]
userEmail = strings.Join(strings.Split(cases.Lower(language.English).String(userPersonName), " "), ".") + "@" + domain
}
configBuf := bytes.NewBufferString("")
template.Must(template.New("").Parse(configTemplate)).Execute(configBuf, map[string]any{
"hostname": hostname,
"password": password,
"username": username,
"description": userPersonName,
"email": userEmail,
"masterusername": masterUsername,
"masterpassword": masterPasswordHash,
"httpPort": httpPort,
"imapsPort": imapsPort,
})
config := configBuf.String()
configReader := strings.NewReader(config)
container, err := testcontainers.Run(
ctx,
stalwartImage,
testcontainers.WithExposedPorts(httpPort+"/tcp", imapsPort+"/tcp"),
testcontainers.WithFiles(testcontainers.ContainerFile{
Reader: configReader,
ContainerFilePath: "/opt/stalwart/etc/config.toml",
FileMode: 0o700,
}),
testcontainers.WithWaitStrategyAndDeadline(
30*time.Second,
wait.ForLog(`Network listener started (network.listen-start) listenerId = "imaptls"`),
wait.ForLog(`Network listener started (network.listen-start) listenerId = "http"`),
),
)
success := false
defer func() {
if !success {
testcontainers.CleanupContainer(t, container)
}
}()
ip, err := container.Host(ctx)
if err != nil {
return nil, err
}
imapPort, err := container.MappedPort(ctx, "993")
if err != nil {
return nil, err
}
tlsConfig := &tls.Config{InsecureSkipVerify: true}
loggerImpl := clog.NewLogger(clog.Level("trace"))
logger := &loggerImpl
var j Client
var session *Session
{
tr := http.DefaultTransport.(*http.Transport).Clone()
tr.ResponseHeaderTimeout = time.Duration(30 * time.Second)
tr.TLSClientConfig = tlsConfig
jh := *http.DefaultClient
jh.Transport = tr
wsd := &websocket.Dialer{
TLSClientConfig: tlsConfig,
HandshakeTimeout: time.Duration(10) * time.Second,
}
jmapPort, err := container.MappedPort(ctx, httpPort)
if err != nil {
return nil, err
}
jmapBaseUrl := url.URL{
Scheme: "http",
Host: ip + ":" + jmapPort.Port(),
Path: "/",
}
sessionUrl := jmapBaseUrl.JoinPath(".well-known", "jmap")
api := NewHttpJmapClient(
&jh,
masterUsername,
masterPassword,
nullHttpJmapApiClientEventListener{},
)
wscf, err := NewHttpWsClientFactory(wsd, masterUsername, masterPassword, logger)
if err != nil {
return nil, err
}
j = NewClient(api, api, api, wscf)
s, err := j.FetchSession(sessionUrl, username, logger)
if err != nil {
return nil, err
}
// we have to overwrite the hostname in JMAP URL because the container
// will know its name to be a random Docker container identifier, or
// "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,
ip: ip,
imapPort: imapPort.Int(),
container: container,
ctx: ctx,
cancelCtx: cancel,
client: &j,
session: session,
username: username,
password: password,
logger: logger,
userPersonName: userPersonName,
userEmail: userEmail,
}, 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
mimeType string
disposition string
}
type filledMail struct {
uid int
attachments []filledAttachment
subject string
testId string
messageId string
keywords []string
}
var allKeywords = map[string]imap.Flag{
JmapKeywordAnswered: imap.FlagAnswered,
JmapKeywordDraft: imap.FlagDraft,
JmapKeywordFlagged: imap.FlagFlagged,
JmapKeywordForwarded: imap.FlagForwarded,
JmapKeywordJunk: imap.FlagJunk,
JmapKeywordMdnSent: imap.FlagMDNSent,
JmapKeywordNotJunk: imap.FlagNotJunk,
JmapKeywordPhishing: imap.FlagPhishing,
JmapKeywordSeen: imap.FlagSeen,
}
/*
func pickOneRandomlyFromMap[K comparable, V any](m map[K]V) (K, V) {
l := rand.Intn(len(m))
i := 0
for k, v := range m {
if i == l {
return k, v
}
i++
}
panic("map is empty")
}
*/
func pickRandomlyFromMap[K comparable, V any](m map[K]V, min int, max int) map[K]V {
if min < 0 || max < 0 {
panic("min and max must be >= 0")
}
l := len(m)
if min > l || max > l {
panic(fmt.Sprintf("min and max must be <= %d", l))
}
n := min + rand.Intn(max-min+1)
if n == l {
return m
}
// let's use a deep copy so we can remove elements as we pick them
c := make(map[K]V, l)
maps.Copy(c, m)
// r will hold the results
r := make(map[K]V, n)
for range n {
pick := rand.Intn(len(c))
j := 0
for k, v := range m {
if j == pick {
delete(c, k)
r[k] = v
break
}
j++
}
}
return r
}
func (s *StalwartTest) fillEmailsWithImap(folder string, count int) ([]filledMail, int, error) {
to := fmt.Sprintf("%s <%s>", s.userPersonName, s.userEmail)
ccEvery := 2
bccEvery := 3
attachmentEvery := 2
senders := max(count/4, 1)
maxThreadSize := 6
maxAttachments := 4
tlsConfig := &tls.Config{InsecureSkipVerify: true}
c, err := imapclient.DialTLS(net.JoinHostPort(s.ip, strconv.Itoa(s.imapPort)), &imapclient.Options{TLSConfig: tlsConfig})
if err != nil {
return nil, 0, err
}
defer func(imap *imapclient.Client) {
err := imap.Close()
if err != nil {
log.Fatal(err)
}
}(c)
if err = c.Login(s.username, s.password).Wait(); err != nil {
return nil, 0, err
}
if _, err = c.Select(folder, &imap.SelectOptions{ReadOnly: false}).Wait(); err != nil {
return nil, 0, err
}
if ids, err := c.Search(&imap.SearchCriteria{}, nil).Wait(); err != nil {
return nil, 0, err
} else {
if len(ids.AllSeqNums()) > 0 {
storeFlags := imap.StoreFlags{
Op: imap.StoreFlagsAdd,
Flags: []imap.Flag{imap.FlagDeleted},
Silent: true,
}
if err = c.Store(ids.All, &storeFlags, nil).Close(); err != nil {
return nil, 0, err
}
if err = c.Expunge().Close(); err != nil {
return nil, 0, err
}
log.Printf("🗑️ deleted %d messages in %s", len(ids.AllSeqNums()), folder)
} else {
log.Printf(" did not delete any messages, %s is empty", folder)
}
}
address, err := mail.ParseAddress(to)
if err != nil {
return nil, 0, err
}
displayName := address.Name
addressParts := emailSplitter.FindAllStringSubmatch(address.Address, 3)
if len(addressParts) != 1 {
return nil, 0, fmt.Errorf("address does not have one part: '%v' -> %v", address.Address, addressParts)
}
if len(addressParts[0]) != 3 {
return nil, 0, fmt.Errorf("first address part does not have a size of 3: '%v'", addressParts[0])
}
domain := addressParts[0][2]
toName := displayName
toAddress := fmt.Sprintf("%s@%s", s.username, domain)
ccName1 := "Team Lead"
ccAddress1 := fmt.Sprintf("lead@%s", domain)
ccName2 := "Coworker"
ccAddress2 := fmt.Sprintf("coworker@%s", domain)
bccName := "HR"
bccAddress := fmt.Sprintf("corporate@%s", domain)
sg := newSenderGenerator(senders)
thread := 0
mails := make([]filledMail, count)
for i := 0; i < count; thread++ {
threadMessageId := fmt.Sprintf("%d.%d@%s", time.Now().Unix(), 1000000+rand.Intn(8999999), domain)
threadSubject := strings.Trim(gofakeit.SentenceSimple(), ".") // remove the . at the end, looks weird
threadSize := 1 + rand.Intn(maxThreadSize)
lastMessageId := ""
lastSubject := ""
for t := 0; i < count && t < threadSize; t++ {
sender := sg.nextSender()
format := formats[i%len(formats)]
text := gofakeit.Paragraph(2+rand.Intn(9), 1+rand.Intn(4), 1+rand.Intn(32), "\n")
msg := sender.inject(enmime.Builder().To(toName, toAddress))
messageId := ""
if lastMessageId == "" {
// start a new thread
msg = msg.Header("Message-ID", threadMessageId).Subject(threadSubject)
lastMessageId = threadMessageId
lastSubject = threadSubject
messageId = threadMessageId
} else {
// we're continuing a thread
messageId = fmt.Sprintf("%d.%d@%s", time.Now().Unix(), 1000000+rand.Intn(8999999), domain)
inReplyTo := ""
subject := ""
switch rand.Intn(2) {
case 0:
// reply to first post in thread
subject = "Re: " + threadSubject
inReplyTo = threadMessageId
default:
// reply to last addition to thread
subject = "Re: " + lastSubject
inReplyTo = lastMessageId
}
msg = msg.Header("Message-ID", messageId).Header("In-Reply-To", inReplyTo).Subject(subject)
lastMessageId = messageId
lastSubject = subject
}
if i%ccEvery == 0 {
msg = msg.CCAddrs([]mail.Address{{Name: ccName1, Address: ccAddress1}, {Name: ccName2, Address: ccAddress2}})
}
if i%bccEvery == 0 {
msg = msg.BCC(bccName, bccAddress)
}
numAttachments := 0
attachments := []filledAttachment{}
if maxAttachments > 0 && i%attachmentEvery == 0 {
numAttachments = rand.Intn(maxAttachments)
for a := range numAttachments {
switch rand.Intn(2) {
case 0:
filename := fakeFilename(".txt")
attachment := gofakeit.Paragraph(2+rand.Intn(4), 1+rand.Intn(4), 1+rand.Intn(32), "\n")
data := []byte(attachment)
msg = msg.AddAttachment(data, "text/plain", filename)
attachments = append(attachments, filledAttachment{
name: filename,
size: len(data),
mimeType: "text/plain",
disposition: "attachment",
})
default:
filename := ""
mimetype := ""
var image []byte = nil
switch rand.Intn(2) {
case 0:
filename = fakeFilename(".png")
mimetype = "image/png"
image = gofakeit.ImagePng(512, 512)
default:
filename = fakeFilename(".jpg")
mimetype = "image/jpeg"
image = gofakeit.ImageJpeg(400, 200)
}
disposition := ""
switch rand.Intn(2) {
case 0:
msg = msg.AddAttachment(image, mimetype, filename)
disposition = "attachment"
default:
msg = msg.AddInline(image, mimetype, filename, "c"+strconv.Itoa(a))
disposition = "inline"
}
attachments = append(attachments, filledAttachment{
name: filename,
size: len(image),
mimeType: mimetype,
disposition: disposition,
})
}
}
}
msg = format(text, msg)
flags := []imap.Flag{}
keywords := pickRandomlyFromMap(allKeywords, 0, len(allKeywords))
for _, f := range keywords {
flags = append(flags, f)
}
buf := new(bytes.Buffer)
part, _ := msg.Build()
part.Encode(buf)
mail := buf.String()
var options *imap.AppendOptions = nil
if len(flags) > 0 {
options = &imap.AppendOptions{Flags: flags}
}
size := int64(len(mail))
appendCmd := c.Append(folder, size, options)
if _, err := appendCmd.Write([]byte(mail)); err != nil {
return nil, 0, err
}
if err := appendCmd.Close(); err != nil {
return nil, 0, err
}
if appendData, err := appendCmd.Wait(); err != nil {
return nil, 0, err
} else {
attachmentStr := ""
if numAttachments > 0 {
attachmentStr = " " + strings.Repeat("📎", numAttachments)
}
log.Printf(" appended %v/%v [in thread %v] uid=%v%s", i+1, count, thread+1, appendData.UID, attachmentStr)
mails[i] = filledMail{
uid: int(appendData.UID),
attachments: attachments,
subject: msg.GetSubject(),
messageId: messageId,
keywords: slices.Collect(maps.Keys(keywords)),
}
}
i++
}
}
listCmd := c.List("", "%", &imap.ListOptions{
ReturnStatus: &imap.StatusOptions{
NumMessages: true,
NumUnseen: true,
},
})
countMap := map[string]int{}
for {
mbox := listCmd.Next()
if mbox == nil {
break
}
countMap[mbox.Mailbox] = int(*mbox.Status.NumMessages)
}
inboxCount := -1
for f, i := range countMap {
if strings.Compare(strings.ToLower(f), strings.ToLower(folder)) == 0 {
inboxCount = i
break
}
}
if err = listCmd.Close(); err != nil {
return nil, 0, err
}
if inboxCount == -1 {
return nil, 0, fmt.Errorf("failed to find folder '%v' via IMAP", folder)
}
if count != inboxCount {
return nil, 0, fmt.Errorf("wrong number of emails in the inbox after filling, expecting %v, has %v", count, inboxCount)
}
return mails, thread, nil
}
var productName = "jmaptest"
type ContactsBoxes struct {
nicknames bool
secondaryEmails bool
secondaryAddress bool
phones bool
onlineService bool
preferredLanguage bool
mediaWithBlobId bool
mediaWithDataUri bool
mediaWithExternalUri bool
organization bool
cryptoKey bool
link bool
}
func (s *StalwartTest) fillContacts(
t *testing.T,
count uint,
) (string, string, map[string]jscontact.ContactCard, map[string]map[string]any, ContactsBoxes, error) {
require := require.New(t)
c, err := NewTestJmapClient(s.session, s.username, s.password, true, true)
require.NoError(err)
defer c.Close()
boxes := ContactsBoxes{}
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{}
sent := map[string]map[string]any{}
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 i%3 == 0 {
nicknameMap, 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
emailObjs[emailId] = emailObj
for i := range rand.Intn(3) {
id := id()
m, o := createSecondaryEmail(gofakeit.Email(), i*100)
emailMaps[id] = m
emailObjs[id] = o
boxes.secondaryEmails = true
}
if len(emailMaps) > 0 {
contact["emails"] = emailMaps
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) {
boxes.phones = true
num := person.Contact.Phone
if i > 0 {
num = gofakeit.Phone()
}
var features map[jscontact.PhoneFeature]bool = nil
if rand.Intn(3) < 2 {
features = toBoolMapS(jscontact.PhoneFeatureMobile, jscontact.PhoneFeatureVoice, jscontact.PhoneFeatureVideo, jscontact.PhoneFeatureText)
} else {
features = toBoolMapS(jscontact.PhoneFeatureVoice, jscontact.PhoneFeatureMainNumber)
}
contexts := map[jscontact.PhoneContext]bool{jscontact.PhoneContextWork: true}
if rand.Intn(2) < 1 {
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
}); err != nil {
return "", "", nil, 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) {
var source *gofakeit.AddressInfo
if i == 0 {
source = person.Address
} else {
source = gofakeit.Address()
boxes.secondaryAddress = true
}
components := []jscontact.AddressComponent{}
m := streetNumberRegex.FindAllStringSubmatch(source.Street, -1)
if m != nil {
components = append(components, jscontact.AddressComponent{ /*Type: jscontact.AddressComponentType,*/ Kind: jscontact.AddressComponentKindName, Value: m[0][2]})
components = append(components, jscontact.AddressComponent{ /*Type: jscontact.AddressComponentType,*/ Kind: jscontact.AddressComponentKindNumber, Value: m[0][1]})
} else {
components = append(components, jscontact.AddressComponent{ /*Type: jscontact.AddressComponentType,*/ Kind: jscontact.AddressComponentKindName, Value: source.Street})
}
components = append(components,
jscontact.AddressComponent{ /*Type: jscontact.AddressComponentType,*/ Kind: jscontact.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": 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
}); err != nil {
return "", "", nil, 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) {
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
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, 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) {
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
}); err != nil {
return "", "", nil, 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,
Name: person.Job.Title,
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) {
boxes.cryptoKey = true
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, nil, boxes, err
}
if err := propmap(i%2 == 0, 1, 2, contact, "media", &card.Media, func(i int, id string) (map[string]any, jscontact.Media, error) {
label := fmt.Sprintf("photo-%d", 1000+rand.Intn(9000))
switch rand.Intn(3) {
case 0:
boxes.mediaWithDataUri = true
// use data uri
//size := 16 + rand.Intn(512-16+1) // <- let's not do that right now, makes debugging errors very difficult due to the ASCII wall noise
size := pickRandom(16, 24, 32, 48, 64)
img := gofakeit.ImagePng(size, size)
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
// currently does not work, reported as https://github.com/stalwartlabs/stalwart/issues/2431
case 99: // change this to 1 to enable it again
boxes.mediaWithBlobId = true
size := pickRandom(16, 24, 32, 48, 64)
img := gofakeit.ImageJpeg(size, size)
blob, err := c.uploadBlob(accountId, img, "image/jpeg")
if err != nil {
return nil, 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
default:
boxes.mediaWithExternalUri = true
// use external uri
uri := picsum(128, 128)
contexts := toBoolMapS(jscontact.MediaContextWork)
return map[string]any{
"@type": "Media",
"kind": string(jscontact.MediaKindPhoto),
"uri": uri,
"contexts": structs.MapKeys(contexts, func(c jscontact.MediaContext) string { return string(c) }),
"label": label,
}, jscontact.Media{
// Type: jscontact.MediaType,
Kind: jscontact.MediaKindPhoto,
Uri: uri,
Contexts: contexts,
Label: label,
}, nil
}
}); err != nil {
return "", "", nil, 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) {
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
}); err != nil {
return "", "", nil, nil, boxes, err
}
id, err := s.CreateContact(c, accountId, contact)
if err != nil {
return "", "", nil, nil, boxes, err
}
card.Id = id
filled[id] = card
sent[id] = contact
printer(fmt.Sprintf("🧑🏻 created %*s/%v uid=%v", int(math.Log10(float64(count))+1), strconv.Itoa(int(i+1)), count, id))
}
return accountId, addressbookId, filled, sent, boxes, 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](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 {
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)
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")
}