mirror of
https://github.com/opencloud-eu/opencloud.git
synced 2026-02-06 12:21:21 -05:00
1788 lines
50 KiB
Go
1788 lines
50 KiB
Go
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")
|
||
}
|