groupware: improve email submission and testing

* jmap/EmailCreate: add more attributes that were omitted: Headers,
   InReplyTo, References, Sender

 * add jmap GetEmailSubmissionStatus

 * improve email integration tests by adding a thorough test for email
   submission

 * jmap integration tests: provision principals and domains using the
   Stalwart Management API, switching from an in-memory to an internal
   directory
This commit is contained in:
Pascal Bleser
2025-12-08 17:58:47 +01:00
parent ddbfef3ce3
commit 77dd2d6979
8 changed files with 482 additions and 209 deletions

View File

@@ -40,12 +40,10 @@ type BlobClient interface {
const (
logOperation = "operation"
logMailboxId = "mailbox-id"
logFetchBodies = "fetch-bodies"
logOffset = "offset"
logLimit = "limit"
logDownloadUrl = "download-url"
logBlobId = "blob-id"
logUploadUrl = "download-url"
logSinceState = "since-state"
)

View File

@@ -845,7 +845,7 @@ func (j *Client) SubmitEmail(accountId string, identityId string, emailId string
id: {
IdentityId: identityId,
EmailId: emailId,
// leaving Envelope empty
Envelope: nil,
},
},
OnSuccessUpdateEmail: map[string]PatchObject{
@@ -856,13 +856,6 @@ func (j *Client) SubmitEmail(accountId string, identityId string, emailId string
get := EmailSubmissionGetCommand{
AccountId: accountId,
Ids: []string{"#" + id},
/*
IdRef: &ResultReference{
ResultOf: "0",
Name: CommandEmailSubmissionSet,
Path: ["#"]"/created/" + "#" + id + "/" + EmailPropertyId,
},
*/
}
cmd, err := j.request(session, logger,
@@ -918,6 +911,38 @@ func (j *Client) SubmitEmail(accountId string, identityId string, emailId string
})
}
type emailSubmissionResult struct {
submissions map[string]EmailSubmission
notFound []string
}
func (j *Client) GetEmailSubmissionStatus(accountId string, submissionIds []string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string) (map[string]EmailSubmission, []string, SessionState, State, Language, Error) {
logger = j.logger("GetEmailSubmissionStatus", session, logger)
cmd, err := j.request(session, logger, invocation(CommandEmailSubmissionGet, EmailSubmissionGetCommand{
AccountId: accountId,
Ids: submissionIds,
}, "0"))
if err != nil {
return nil, nil, "", "", "", err
}
result, sessionState, state, lang, err := command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (emailSubmissionResult, State, Error) {
var response EmailSubmissionGetResponse
err = retrieveResponseMatchParameters(logger, body, CommandEmailSubmissionGet, "0", &response)
if err != nil {
return emailSubmissionResult{}, "", err
}
m := make(map[string]EmailSubmission, len(response.List))
for _, s := range response.List {
m[s.Id] = s
}
return emailSubmissionResult{submissions: m, notFound: response.NotFound}, response.State, nil
})
return result.submissions, result.notFound, sessionState, state, lang, err
}
func (j *Client) EmailsInThread(accountId string, threadId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, fetchBodies bool, maxBodyValueBytes uint) ([]Email, SessionState, State, Language, Error) {
logger = j.loggerParams("EmailsInThread", session, logger, func(z zerolog.Context) zerolog.Context {
return z.Bool(logFetchBodies, fetchBodies).Str("threadId", log.SafeString(threadId))

View File

@@ -39,7 +39,10 @@ func TestContacts(t *testing.T) {
require.NoError(err)
defer s.Close()
accountId, addressbookId, expectedContactCardsById, boxes, err := s.fillContacts(t, count)
user := pickUser()
session := s.Session(user.name)
accountId, addressbookId, expectedContactCardsById, boxes, err := s.fillContacts(t, count, session, user)
require.NoError(err)
require.NotEmpty(accountId)
require.NotEmpty(addressbookId)
@@ -51,7 +54,7 @@ func TestContacts(t *testing.T) {
{Property: jscontact.ContactCardPropertyCreated, IsAscending: true},
}
contactsByAccount, _, _, _, err := s.client.QueryContactCards([]string{accountId}, s.session, t.Context(), s.logger, "", filter, sortBy, 0, 0)
contactsByAccount, _, _, _, err := s.client.QueryContactCards([]string{accountId}, session, t.Context(), s.logger, "", filter, sortBy, 0, 0)
require.NoError(err)
require.Len(contactsByAccount, 1)
@@ -97,9 +100,11 @@ var streetNumberRegex = regexp.MustCompile(`^(\d+)\s+(.+)$`)
func (s *StalwartTest) fillContacts(
t *testing.T,
count uint,
session *Session,
user User,
) (string, string, map[string]jscontact.ContactCard, ContactsBoxes, error) {
require := require.New(t)
c, err := NewTestJmapClient(s.session, s.username, s.password, true, true)
c, err := NewTestJmapClient(session, user.name, user.password, true, true)
require.NoError(err)
defer c.Close()

View File

@@ -25,34 +25,6 @@ import (
"github.com/stretchr/testify/require"
)
func (s *StalwartTest) findInbox(t *testing.T, accountId string) (string, string) {
require := require.New(t)
respByAccountId, sessionState, _, _, err := s.client.GetAllMailboxes([]string{accountId}, s.session, s.ctx, s.logger, "")
require.NoError(err)
require.Equal(s.session.State, sessionState)
require.Len(respByAccountId, 1)
require.Contains(respByAccountId, accountId)
resp := respByAccountId[accountId]
mailboxesNameByRole := map[string]string{}
mailboxesUnreadByRole := map[string]int{}
for _, m := range resp {
if m.Role != "" {
mailboxesNameByRole[m.Role] = m.Name
mailboxesUnreadByRole[m.Role] = m.UnreadEmails
}
}
require.Contains(mailboxesNameByRole, "inbox")
require.Contains(mailboxesUnreadByRole, "inbox")
require.Zero(mailboxesUnreadByRole["inbox"])
inboxId := mailboxId("inbox", resp)
require.NotEmpty(inboxId)
inboxFolder := mailboxesNameByRole["inbox"]
require.NotEmpty(inboxFolder)
return inboxId, inboxFolder
}
func TestEmails(t *testing.T) {
if skip(t) {
return
@@ -66,32 +38,35 @@ func TestEmails(t *testing.T) {
require.NoError(err)
defer s.Close()
accountId := s.session.PrimaryAccounts.Mail
user := pickUser()
session := s.Session(user.name)
inboxId, inboxFolder := s.findInbox(t, accountId)
accountId := session.PrimaryAccounts.Mail
inboxId, inboxFolder := s.findInbox(t, accountId, session)
var threads int = 0
var mails []filledMail = nil
{
mails, threads, err = s.fillEmailsWithImap(inboxFolder, count, false)
mails, threads, err = s.fillEmailsWithImap(inboxFolder, count, false, user)
require.NoError(err)
}
mailsByMessageId := structs.Index(mails, func(mail filledMail) string { return mail.messageId })
{
{
resp, sessionState, _, _, err := s.client.GetAllIdentities(accountId, s.session, s.ctx, s.logger, "")
resp, sessionState, _, _, err := s.client.GetAllIdentities(accountId, session, s.ctx, s.logger, "")
require.NoError(err)
require.Equal(s.session.State, sessionState)
require.Equal(session.State, sessionState)
require.Len(resp, 1)
require.Equal(s.userEmail, resp[0].Email)
require.Equal(s.userPersonName, resp[0].Name)
require.Equal(user.email, resp[0].Email)
require.Equal(user.description, resp[0].Name)
}
{
respByAccountId, sessionState, _, _, err := s.client.GetAllMailboxes([]string{accountId}, s.session, s.ctx, s.logger, "")
respByAccountId, sessionState, _, _, err := s.client.GetAllMailboxes([]string{accountId}, session, s.ctx, s.logger, "")
require.NoError(err)
require.Equal(s.session.State, sessionState)
require.Equal(session.State, sessionState)
require.Len(respByAccountId, 1)
require.Contains(respByAccountId, accountId)
resp := respByAccountId[accountId]
@@ -105,9 +80,9 @@ func TestEmails(t *testing.T) {
}
{
resp, sessionState, _, _, err := s.client.GetAllEmailsInMailbox(accountId, s.session, s.ctx, s.logger, "", inboxId, 0, 0, true, false, 0, true)
resp, sessionState, _, _, err := s.client.GetAllEmailsInMailbox(accountId, session, s.ctx, s.logger, "", inboxId, 0, 0, true, false, 0, true)
require.NoError(err)
require.Equal(s.session.State, sessionState)
require.Equal(session.State, sessionState)
require.Equalf(threads, len(resp.Emails), "the number of collapsed emails in the inbox is expected to be %v, but is actually %v", threads, len(resp.Emails))
for _, e := range resp.Emails {
@@ -119,9 +94,9 @@ func TestEmails(t *testing.T) {
}
{
resp, sessionState, _, _, err := s.client.GetAllEmailsInMailbox(accountId, s.session, s.ctx, s.logger, "", inboxId, 0, 0, false, false, 0, true)
resp, sessionState, _, _, err := s.client.GetAllEmailsInMailbox(accountId, session, s.ctx, s.logger, "", inboxId, 0, 0, false, false, 0, true)
require.NoError(err)
require.Equal(s.session.State, sessionState)
require.Equal(session.State, sessionState)
require.Equalf(count, len(resp.Emails), "the number of emails in the inbox is expected to be %v, but is actually %v", count, len(resp.Emails))
for _, e := range resp.Emails {
@@ -145,11 +120,29 @@ func TestSendingEmails(t *testing.T) {
require.NoError(err)
defer s.Close()
accountId := s.session.PrimaryAccounts.Mail
from := pickUser()
session := s.Session(from.name)
accountId := session.PrimaryAccounts.Mail
var to User
{
others := structs.Filter(users[:], func(u User) bool { return u.name != from.name })
to = others[rand.Intn(len(others))]
}
toSession := s.Session(to.name)
toAccountId := toSession.PrimaryAccounts.Mail
var cc User
{
others := structs.Filter(users[:], func(u User) bool { return u.name != from.name && u.name != to.name })
cc = others[rand.Intn(len(others))]
}
ccSession := s.Session(cc.name)
ccAccountId := ccSession.PrimaryAccounts.Mail
var mailboxPerRole map[string]Mailbox
{
mailboxes, _, _, _, err := s.client.GetAllMailboxes([]string{accountId}, s.session, s.ctx, s.logger, "")
mailboxes, _, _, _, err := s.client.GetAllMailboxes([]string{accountId}, session, s.ctx, s.logger, "")
require.NoError(err)
mailboxPerRole = structs.Index(mailboxes[accountId], func(m Mailbox) string { return m.Role })
require.Contains(mailboxPerRole, JmapMailboxRoleInbox)
@@ -159,7 +152,7 @@ func TestSendingEmails(t *testing.T) {
}
{
roles := []string{JmapMailboxRoleDrafts, JmapMailboxRoleSent, JmapMailboxRoleInbox}
m, _, _, _, err := s.client.SearchMailboxIdsPerRole([]string{accountId}, s.session, s.ctx, s.logger, "", roles)
m, _, _, _, err := s.client.SearchMailboxIdsPerRole([]string{accountId}, session, s.ctx, s.logger, "", roles)
require.NoError(err)
require.Contains(m, accountId)
a := m[accountId]
@@ -168,10 +161,26 @@ func TestSendingEmails(t *testing.T) {
}
}
// let's ensure that the recipients have zero emails in their mailboxes before we send them any
for _, u := range []struct {
accountId string
session *Session
}{{toAccountId, toSession}, {ccAccountId, ccSession}} {
mailboxes, _, _, _, err := s.client.GetAllMailboxes([]string{u.accountId}, u.session, s.ctx, s.logger, "")
require.NoError(err)
for _, mailbox := range mailboxes[u.accountId] {
require.Equal(0, mailbox.TotalEmails)
}
}
subject := fmt.Sprintf("Test Subject %d", 10000+rand.Intn(90000))
fromName := fmt.Sprintf("%s (test %d)", from.name, 1000+rand.Intn(9000))
sender := EmailAddress{Email: from.email, Name: from.description}
{
var identity Identity
{
identities, _, _, _, err := s.client.GetAllIdentities(accountId, s.session, s.ctx, s.logger, "")
identities, _, _, _, err := s.client.GetAllIdentities(accountId, session, s.ctx, s.logger, "")
require.NoError(err)
require.NotEmpty(identities)
identity = identities[0]
@@ -179,15 +188,15 @@ func TestSendingEmails(t *testing.T) {
create := EmailCreate{
Keywords: toBoolMapS("test"),
Subject: "testing 123",
Subject: subject,
MailboxIds: toBoolMapS(mailboxPerRole[JmapMailboxRoleDrafts].Id),
}
created, _, _, _, err := s.client.CreateEmail(accountId, create, "", s.session, s.ctx, s.logger, "")
created, _, _, _, err := s.client.CreateEmail(accountId, create, "", session, s.ctx, s.logger, "")
require.NoError(err)
require.NotEmpty(created.Id)
{
emails, notFound, _, _, _, err := s.client.GetEmails(accountId, s.session, s.ctx, s.logger, "", []string{created.Id}, true, 0, false, false)
emails, notFound, _, _, _, err := s.client.GetEmails(accountId, session, s.ctx, s.logger, "", []string{created.Id}, true, 0, false, false)
require.NoError(err)
require.Len(emails, 1)
require.Empty(notFound)
@@ -198,19 +207,22 @@ func TestSendingEmails(t *testing.T) {
}
update := EmailCreate{
To: []EmailAddress{{Name: identity.Name, Email: identity.Email}},
From: []EmailAddress{{Name: fromName, Email: from.email}},
To: []EmailAddress{{Name: to.description, Email: to.email}},
Cc: []EmailAddress{{Name: cc.description, Email: cc.email}},
Sender: []EmailAddress{sender},
Keywords: toBoolMapS("test"),
Subject: "testing 1234",
Subject: subject,
MailboxIds: toBoolMapS(mailboxPerRole[JmapMailboxRoleDrafts].Id),
}
updated, _, _, _, err := s.client.CreateEmail(accountId, update, created.Id, s.session, s.ctx, s.logger, "")
updated, _, _, _, err := s.client.CreateEmail(accountId, update, created.Id, session, s.ctx, s.logger, "")
require.NoError(err)
require.NotEmpty(updated.Id)
require.NotEqual(created.Id, updated.Id)
var updatedMailboxId string
{
emails, notFound, _, _, _, err := s.client.GetEmails(accountId, s.session, s.ctx, s.logger, "", []string{created.Id, updated.Id}, true, 0, false, false)
emails, notFound, _, _, _, err := s.client.GetEmails(accountId, session, s.ctx, s.logger, "", []string{created.Id, updated.Id}, true, 0, false, false)
require.NoError(err)
require.Len(emails, 1)
require.Len(notFound, 1)
@@ -229,8 +241,81 @@ func TestSendingEmails(t *testing.T) {
ToMailboxId: mailboxPerRole[JmapMailboxRoleSent].Id,
}
sub, _, _, _, err := s.client.SubmitEmail(accountId, identity.Id, updated.Id, &move, s.session, s.ctx, s.logger, "")
fmt.Printf("sub: %v\n", sub)
sub, _, _, _, err := s.client.SubmitEmail(accountId, identity.Id, updated.Id, &move, session, s.ctx, s.logger, "")
require.NoError(err)
require.NotEmpty(sub.Id)
require.NotEmpty(sub.ThreadId)
require.Equal(updated.Id, sub.EmailId)
require.Equal(identity.Id, sub.IdentityId)
require.Equal(sub.UndoStatus, UndoStatusPending) // this *might* be fragile: if the server is fast enough, would we get "final" here?
require.Empty(sub.DsnBlobIds)
require.Empty(sub.MdnBlobIds)
require.Equal(from.email, sub.Envelope.MailFrom.Email)
require.Nil(sub.Envelope.MailFrom.Parameters)
require.Len(sub.Envelope.RcptTo, 2)
require.Contains(sub.Envelope.RcptTo, Address{Email: to.email})
require.Contains(sub.Envelope.RcptTo, Address{Email: cc.email})
require.NotZero(sub.SendAt)
require.Len(sub.DeliveryStatus, 2)
require.Contains(sub.DeliveryStatus, to.email)
require.Contains(sub.DeliveryStatus, cc.email)
a := 0
maxAttempts := 3
delivery := sub.DeliveryStatus[to.email].Delivered
for delivery != DeliveredYes {
require.NotEqual(DeliveredNo, delivery)
a++
if a >= maxAttempts {
break
}
time.Sleep(1 * time.Second)
subs, notFound, _, _, _, err := s.client.GetEmailSubmissionStatus(accountId, []string{sub.Id}, session, s.ctx, s.logger, "")
require.NoError(err)
require.Empty(notFound)
require.Contains(subs, sub.Id)
delivery = subs[sub.Id].DeliveryStatus[to.email].Delivered
}
require.Contains([]DeliveryStatusDelivered{DeliveredYes, DeliveredUnknown}, delivery)
for _, r := range []struct {
user User
accountId string
session *Session
}{{to, toAccountId, toSession}, {cc, ccAccountId, ccSession}} {
mailboxes, _, _, _, err := s.client.GetAllMailboxes([]string{r.accountId}, r.session, s.ctx, s.logger, "")
require.NoError(err)
inboxId := ""
for _, mailbox := range mailboxes[r.accountId] {
if mailbox.Role == JmapMailboxRoleInbox {
inboxId = mailbox.Id
require.Equal(1, mailbox.TotalEmails)
}
}
require.NotEmpty(inboxId, "failed to find the Mailbox with the 'inbox' role for %v", r.user.name)
emails, _, _, _, err := s.client.QueryEmails([]string{r.accountId}, EmailFilterCondition{InMailbox: inboxId}, r.session, s.ctx, s.logger, "", 0, 0, true, 0)
require.NoError(err)
require.Contains(emails, r.accountId)
require.Len(emails[r.accountId].Emails, 1)
received := emails[r.accountId].Emails[0]
require.Len(received.From, 1)
require.Equal(from.email, received.From[0].Email)
require.Equal(fromName, received.From[0].Name)
require.Len(received.Sender, 1)
require.Equal(from.email, received.Sender[0].Email)
require.Equal(from.description, received.Sender[0].Name)
require.Len(received.To, 1)
require.Equal(to.email, received.To[0].Email)
require.Equal(to.description, received.To[0].Name)
require.Len(received.Cc, 1)
require.Equal(cc.email, received.Cc[0].Email)
require.Equal(cc.description, received.Cc[0].Name)
require.Equal(subject, received.Subject)
}
}
}
@@ -269,6 +354,34 @@ func matchEmail(t *testing.T, actual Email, expected filledMail, hasBodies bool)
}
}
func (s *StalwartTest) findInbox(t *testing.T, accountId string, session *Session) (string, string) {
require := require.New(t)
respByAccountId, sessionState, _, _, err := s.client.GetAllMailboxes([]string{accountId}, session, s.ctx, s.logger, "")
require.NoError(err)
require.Equal(session.State, sessionState)
require.Len(respByAccountId, 1)
require.Contains(respByAccountId, accountId)
resp := respByAccountId[accountId]
mailboxesNameByRole := map[string]string{}
mailboxesUnreadByRole := map[string]int{}
for _, m := range resp {
if m.Role != "" {
mailboxesNameByRole[m.Role] = m.Name
mailboxesUnreadByRole[m.Role] = m.UnreadEmails
}
}
require.Contains(mailboxesNameByRole, "inbox")
require.Contains(mailboxesUnreadByRole, "inbox")
require.Zero(mailboxesUnreadByRole["inbox"])
inboxId := mailboxId("inbox", resp)
require.NotEmpty(inboxId)
inboxFolder := mailboxesNameByRole["inbox"]
require.NotEmpty(inboxFolder)
return inboxId, inboxFolder
}
var emailSplitter = regexp.MustCompile("(.+)@(.+)$")
func htmlFormat(body string, msg enmime.MailBuilder) enmime.MailBuilder {
@@ -371,8 +484,8 @@ var allKeywords = map[string]imap.Flag{
JmapKeywordSeen: imap.FlagSeen,
}
func (s *StalwartTest) fillEmailsWithImap(folder string, count int, empty bool) ([]filledMail, int, error) {
to := fmt.Sprintf("%s <%s>", s.userPersonName, s.userEmail)
func (s *StalwartTest) fillEmailsWithImap(folder string, count int, empty bool, user User) ([]filledMail, int, error) {
to := fmt.Sprintf("%s <%s>", user.description, user.email)
ccEvery := 2
bccEvery := 3
attachmentEvery := 2
@@ -394,7 +507,7 @@ func (s *StalwartTest) fillEmailsWithImap(folder string, count int, empty bool)
}
}(c)
if err = c.Login(s.username, s.password).Wait(); err != nil {
if err = c.Login(user.name, user.password).Wait(); err != nil {
return nil, 0, err
}
@@ -442,7 +555,7 @@ func (s *StalwartTest) fillEmailsWithImap(folder string, count int, empty bool)
domain := addressParts[0][2]
toName := displayName
toAddress := fmt.Sprintf("%s@%s", s.username, domain)
toAddress := fmt.Sprintf("%s@%s", user.name, domain)
ccName1 := "Team Lead"
ccAddress1 := fmt.Sprintf("lead@%s", domain)
ccName2 := "Coworker"

View File

@@ -38,7 +38,10 @@ func TestEvents(t *testing.T) {
require.NoError(err)
defer s.Close()
accountId, calendarId, expectedEventsById, boxes, err := s.fillEvents(t, count)
user := pickUser()
session := s.Session(user.name)
accountId, calendarId, expectedEventsById, boxes, err := s.fillEvents(t, count, session, user)
require.NoError(err)
require.NotEmpty(accountId)
require.NotEmpty(calendarId)
@@ -50,7 +53,7 @@ func TestEvents(t *testing.T) {
{Property: CalendarEventPropertyCreated, IsAscending: true},
}
contactsByAccount, _, _, _, err := s.client.QueryCalendarEvents([]string{accountId}, s.session, t.Context(), s.logger, "", filter, sortBy, 0, 0)
contactsByAccount, _, _, _, err := s.client.QueryCalendarEvents([]string{accountId}, session, t.Context(), s.logger, "", filter, sortBy, 0, 0)
require.NoError(err)
require.Len(contactsByAccount, 1)
@@ -85,9 +88,11 @@ type EventsBoxes struct {
func (s *StalwartTest) fillEvents(
t *testing.T,
count uint,
session *Session,
user User,
) (string, string, map[string]CalendarEvent, EventsBoxes, error) {
require := require.New(t)
c, err := NewTestJmapClient(s.session, s.username, s.password, true, true)
c, err := NewTestJmapClient(session, user.name, user.password, true, true)
require.NoError(err)
defer c.Close()
@@ -245,7 +250,7 @@ func (s *StalwartTest) fillEvents(
virtualLocationId: virtualLocationObj,
},
Alerts: map[string]jscalendar.Alert{
alertId: jscalendar.Alert{
alertId: {
Type: jscalendar.AlertType,
Trigger: jscalendar.OffsetTrigger{
Type: jscalendar.OffsetTriggerType,

View File

@@ -27,8 +27,6 @@ import (
"github.com/gorilla/websocket"
"github.com/tidwall/pretty"
"golang.org/x/text/cases"
"golang.org/x/text/language"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
@@ -50,18 +48,33 @@ const (
Wireshark = ""
)
type User struct {
name string
description string
email string
password string
}
func userpassword() string {
password, err := pw.Generate(4+rand.Intn(28), 2, 0, false, true)
if err != nil {
panic(err)
}
return password
}
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",
domains = [...]string{"earth.gov", "mars.mil", "opa.org"}
users = [...]User{
{"cdrummer", "Camina Drummer", "camina.drummer@opa.org", userpassword()},
{"aburton", "Amos Burton", "amos.burton@earth.gov", userpassword()},
{"jholden", "James Holden", "james.holden@earth.gov", userpassword()},
{"adawes", "Anderson Dawes", "anderson.dawes@opa.org", userpassword()},
{"nnagata", "Naomi Nagata", "naomi.nagata@opa.org", userpassword()},
{"kashford", "Klaes Ashford", "klaes.ashford@opa.org", userpassword()},
{"fjohnson", "Fred Johnson", "fred.johnson@opa.org", userpassword()},
{"cavasarala", "Chrisjen Avasarala}", "chrissy@earth.gov", userpassword()},
{"bdraper", "Roberta Draper", "bobby@mars.mil", userpassword()},
}
)
@@ -70,22 +83,16 @@ const (
httpPort = "8080"
imapsPort = "993"
configTemplate = `
authentication.fallback-admin.secret = "$6$4qPYDVhaUHkKcY7s$bB6qhcukb9oFNYRIvaDZgbwxrMa2RvF5dumCjkBFdX19lSNqrgKltf3aPrFMuQQKkZpK2YNuQ83hB1B3NiWzj."
authentication.fallback-admin.secret = "secret"
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"
directory.test.bind.auth.method = "default"
directory.test.cache.size = 1048576
directory.test.cache.ttl.negative = "10m"
directory.test.cache.ttl.positive = "1h"
directory.test.store = "rocksdb"
directory.test.type = "internal"
metrics.prometheus.enable = false
server.listener.http.bind = "[::]:{{.httpPort}}"
server.listener.http.protocol = "http"
@@ -100,7 +107,7 @@ server.socket.reuse-addr = true
server.socket.reuse-port = true
storage.blob = "rocksdb"
storage.data = "rocksdb"
storage.directory = "memory"
storage.directory = "test"
storage.fts = "rocksdb"
storage.lookup = "rocksdb"
store.rocksdb.compression = "lz4"
@@ -114,6 +121,14 @@ tracer.log.lossy = false
tracer.log.multiline = false
tracer.log.type = "stdout"
sharing.allow-directory-query = false
auth.dkim.sign = false
auth.dkim.verify = "disable"
auth.spf.verify.ehlo = "disable"
auth.spf.verify.mail-from = "disable"
auth.arc.verify = "disable"
auth.arc.seal = false
auth.dmarc.verify = "disable"
auth.iprev.verify = "disable"
`
)
@@ -134,19 +149,16 @@ func skip(t *testing.T) bool {
}
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
t *testing.T
ip string
imapPort int
container *testcontainers.DockerContainer
ctx context.Context
cancelCtx context.CancelFunc
client *Client
logger *clog.Logger
jmapBaseUrl *url.URL
sessionUrl *url.URL
io.Closer
}
@@ -162,6 +174,38 @@ func (s *StalwartTest) Close() error {
return nil
}
func (s *StalwartTest) Session(username string) *Session {
session, jerr := s.client.FetchSession(s.sessionUrl, username, s.logger)
require.NoError(s.t, jerr)
require.NotNil(s.t, session.Capabilities.Mail)
require.NotNil(s.t, session.Capabilities.Calendars)
require.NotNil(s.t, session.Capabilities.Contacts)
// 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
session.JmapUrl.Host = s.jmapBaseUrl.Host
session.WebsocketUrl.Host = s.jmapBaseUrl.Host
var err error
session.ApiUrl, err = replaceHost(session.ApiUrl, s.jmapBaseUrl.Host)
require.NoError(s.t, err)
session.DownloadUrl, err = replaceHost(session.DownloadUrl, s.jmapBaseUrl.Host)
require.NoError(s.t, err)
session.UploadUrl, err = replaceHost(session.UploadUrl, s.jmapBaseUrl.Host)
require.NoError(s.t, err)
session.EventSourceUrl, err = replaceHost(session.EventSourceUrl, s.jmapBaseUrl.Host)
require.NoError(s.t, err)
return &session
}
type stalwartTestLogConsumer struct{}
func (lc *stalwartTestLogConsumer) Accept(l testcontainers.Log) {
fmt.Print("STALWART: " + string(l.Content))
}
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()
@@ -189,33 +233,11 @@ func newStalwartTest(t *testing.T) (*StalwartTest, error) {
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,
@@ -227,6 +249,7 @@ func newStalwartTest(t *testing.T) (*StalwartTest, error) {
container, err := testcontainers.Run(
ctx,
stalwartImage,
testcontainers.WithLogConsumers(&stalwartTestLogConsumer{}),
testcontainers.WithExposedPorts(httpPort+"/tcp", imapsPort+"/tcp"),
testcontainers.WithFiles(testcontainers.ContainerFile{
Reader: configReader,
@@ -262,7 +285,8 @@ func newStalwartTest(t *testing.T) (*StalwartTest, error) {
loggerImpl := clog.NewLogger(clog.Level("trace"))
logger := &loggerImpl
var j Client
var session *Session
var jmapBaseUrl *url.URL
var sessionUrl *url.URL
{
tr := http.DefaultTransport.(*http.Transport).Clone()
tr.ResponseHeaderTimeout = time.Duration(30 * time.Second)
@@ -279,11 +303,12 @@ func newStalwartTest(t *testing.T) (*StalwartTest, error) {
if err != nil {
return nil, err
}
jmapBaseUrl := url.URL{
jmapBaseUrl = &url.URL{
Scheme: "http",
Host: ip + ":" + jmapPort.Port(),
Path: "/",
}
sessionUrl = jmapBaseUrl.JoinPath(".well-known", "jmap")
if Wireshark != "" {
fmt.Printf("\x1b[45;37;1m Starting Wireshark on port %v \x1b[0m\n", jmapPort)
@@ -301,8 +326,6 @@ func newStalwartTest(t *testing.T) (*StalwartTest, error) {
time.Sleep(10 * time.Second)
}
sessionUrl := jmapBaseUrl.JoinPath(".well-known", "jmap")
eventListener := nullHttpJmapApiClientEventListener{}
api := NewHttpJmapClient(
@@ -318,47 +341,139 @@ func newStalwartTest(t *testing.T) (*StalwartTest, error) {
}
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)
// provision some things using Stalwart's Management API
{
var h http.Client
{
tr := http.DefaultTransport.(*http.Transport).Clone()
tr.ResponseHeaderTimeout = time.Duration(30 * time.Second)
tr.TLSClientConfig = tlsConfig
h = *http.DefaultClient
h.Transport = tr
}
apiPort, err := container.MappedPort(ctx, httpPort)
require.NoError(t, err)
url := fmt.Sprintf("http://%s:%d/api/principal", ip, apiPort.Int())
for _, domain := range domains {
fmt.Printf("Creating domain '%v'\n", domain)
bb, err := json.Marshal(map[string]any{
"type": "domain",
"name": domain,
"description": domain,
})
require.NoError(t, err)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBuffer(bb))
require.NoError(t, err)
req.SetBasicAuth("mailadmin", "secret")
resp, err := h.Do(req)
require.NoError(t, err)
require.Equal(t, "200 OK", resp.Status)
}
for _, user := range users {
fmt.Printf("Creating individual '%v'\n", user.name)
bb, err := json.Marshal(map[string]any{
"type": "individual",
"name": user.name,
"description": user.description,
"emails": user.email,
"roles": []string{"user"},
"secrets": user.password,
"quota": 20000000000,
})
require.NoError(t, err)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBuffer(bb))
require.NoError(t, err)
req.SetBasicAuth("mailadmin", "secret")
resp, err := h.Do(req)
require.NoError(t, err)
require.Equal(t, "200 OK", resp.Status)
// fetch the user once with the superadmin credentials to "activate" it,
// it is unclear why that is needed, but without that, we get errors back
// that we are not allowed to access that resource
{
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
require.NoError(t, err)
req.SetBasicAuth("mailadmin", "secret")
resp, err := h.Do(req)
require.NoError(t, err)
require.Equal(t, "200 OK", resp.Status)
}
}
{
require.NoError(t, err)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
require.NoError(t, err)
req.SetBasicAuth("mailadmin", "secret")
resp, err := h.Do(req)
require.NoError(t, err)
require.Equal(t, "200 OK", resp.Status)
var list struct {
Data struct {
Total int `json:"total"`
Items []struct {
Type string `json:"type"`
Id int `json:"id"`
Name string `json:"name"`
Emails []string `json:"emails"`
Roles []string `json:"roles"`
} `json:"items"`
} `json:"data"`
}
bb, err := io.ReadAll(resp.Body)
require.NoError(t, err)
defer resp.Body.Close()
err = json.Unmarshal(bb, &list)
require.NoError(t, err)
individuals := []struct {
Id int
Name string
Emails []string
Roles []string
}{}
for _, p := range list.Data.Items {
if p.Type == "individual" {
individuals = append(individuals, struct {
Id int
Name string
Emails []string
Roles []string
}{p.Id, p.Name, p.Emails, p.Roles})
}
}
require.Equal(t, len(users), len(individuals))
}
{
// check whether we can fetch a session for the provisioned users
for _, user := range users {
session, err := j.FetchSession(sessionUrl, user.name, logger)
require.NoError(t, err, "failed to retrieve JMAP session for newly created principal '%s'", user.name)
require.Equal(t, user.name, session.Username)
}
}
}
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,
t: t,
ip: ip,
imapPort: imapPort.Int(),
container: container,
ctx: ctx,
cancelCtx: cancel,
client: &j,
logger: logger,
jmapBaseUrl: jmapBaseUrl,
sessionUrl: sessionUrl,
}, nil
}
@@ -372,20 +487,6 @@ func replaceHost(u string, host string) (string, error) {
}
}
/*
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")
@@ -1023,6 +1124,10 @@ func pickRandom[T any](s ...T) T {
return s[rand.Intn(len(s))]
}
func pickUser() User {
return users[rand.Intn(len(users))]
}
func pickRandoms[T any](s ...T) []T {
n := rand.Intn(len(s))
if n == 0 {

View File

@@ -64,13 +64,16 @@ func TestWs(t *testing.T) {
require.NoError(err)
defer s.Close()
mailAccountId := s.session.PrimaryAccounts.Mail
user := pickUser()
session := s.Session(user.name)
mailAccountId := session.PrimaryAccounts.Mail
inboxFolder := ""
{
_, inboxFolder = s.findInbox(t, mailAccountId)
_, inboxFolder = s.findInbox(t, mailAccountId, session)
}
l := &testWsPushListener{t: t, username: s.username, logger: s.logger, mailAccountId: mailAccountId}
l := &testWsPushListener{t: t, username: user.name, logger: s.logger, mailAccountId: mailAccountId}
s.client.AddWsPushListener(l)
require.Equal(uint32(0), l.calls.Load())
@@ -84,9 +87,9 @@ func TestWs(t *testing.T) {
var initialState State
{
changes, sessionState, state, _, err := s.client.GetEmailChanges(mailAccountId, s.session, s.ctx, s.logger, "", "", 0)
changes, sessionState, state, _, err := s.client.GetEmailChanges(mailAccountId, session, s.ctx, s.logger, "", "", 0)
require.NoError(err)
require.Equal(s.session.State, sessionState)
require.Equal(session.State, sessionState)
require.NotEmpty(state)
//fmt.Printf("\x1b[45;1;4mChanges [%s]:\x1b[0m\n", state)
//for _, c := range changes.Created { fmt.Printf("%s %s\n", c.Id, c.Subject) }
@@ -98,9 +101,9 @@ func TestWs(t *testing.T) {
require.NotEmpty(initialState)
{
changes, sessionState, state, _, err := s.client.GetEmailChanges(mailAccountId, s.session, s.ctx, s.logger, "", initialState, 0)
changes, sessionState, state, _, err := s.client.GetEmailChanges(mailAccountId, session, s.ctx, s.logger, "", initialState, 0)
require.NoError(err)
require.Equal(s.session.State, sessionState)
require.Equal(session.State, sessionState)
require.Equal(initialState, state)
require.Equal(initialState, changes.NewState)
require.Empty(changes.Created)
@@ -108,7 +111,7 @@ func TestWs(t *testing.T) {
require.Empty(changes.Updated)
}
wsc, err := s.client.EnablePushNotifications(initialState, func() (*Session, error) { return s.session, nil })
wsc, err := s.client.EnablePushNotifications(initialState, func() (*Session, error) { return session, nil })
require.NoError(err)
defer wsc.Close()
@@ -124,7 +127,7 @@ func TestWs(t *testing.T) {
emailIds := []string{}
{
_, n, err := s.fillEmailsWithImap(inboxFolder, 1, false)
_, n, err := s.fillEmailsWithImap(inboxFolder, 1, false, user)
require.NoError(err)
require.Equal(1, n)
}
@@ -141,9 +144,9 @@ func TestWs(t *testing.T) {
}
var lastState State
{
changes, sessionState, state, _, err := s.client.GetEmailChanges(mailAccountId, s.session, s.ctx, s.logger, "", initialState, 0)
changes, sessionState, state, _, err := s.client.GetEmailChanges(mailAccountId, session, s.ctx, s.logger, "", initialState, 0)
require.NoError(err)
require.Equal(s.session.State, sessionState)
require.Equal(session.State, sessionState)
require.NotEqual(initialState, state)
require.NotEqual(initialState, changes.NewState)
require.Equal(state, changes.NewState)
@@ -156,7 +159,7 @@ func TestWs(t *testing.T) {
}
{
_, n, err := s.fillEmailsWithImap(inboxFolder, 1, false)
_, n, err := s.fillEmailsWithImap(inboxFolder, 1, false, user)
require.NoError(err)
require.Equal(1, n)
}
@@ -175,9 +178,9 @@ func TestWs(t *testing.T) {
l.m.Unlock()
}
{
changes, sessionState, state, _, err := s.client.GetEmailChanges(mailAccountId, s.session, s.ctx, s.logger, "", lastState, 0)
changes, sessionState, state, _, err := s.client.GetEmailChanges(mailAccountId, session, s.ctx, s.logger, "", lastState, 0)
require.NoError(err)
require.Equal(s.session.State, sessionState)
require.Equal(session.State, sessionState)
require.NotEqual(lastState, state)
require.NotEqual(lastState, changes.NewState)
require.Equal(state, changes.NewState)
@@ -190,7 +193,7 @@ func TestWs(t *testing.T) {
}
{
_, n, err := s.fillEmailsWithImap(inboxFolder, 0, true)
_, n, err := s.fillEmailsWithImap(inboxFolder, 0, true, user)
require.NoError(err)
require.Equal(0, n)
}
@@ -209,9 +212,9 @@ func TestWs(t *testing.T) {
l.m.Unlock()
}
{
changes, sessionState, state, _, err := s.client.GetEmailChanges(mailAccountId, s.session, s.ctx, s.logger, "", lastState, 0)
changes, sessionState, state, _, err := s.client.GetEmailChanges(mailAccountId, session, s.ctx, s.logger, "", lastState, 0)
require.NoError(err)
require.Equal(s.session.State, sessionState)
require.Equal(session.State, sessionState)
require.NotEqual(lastState, state)
require.NotEqual(lastState, changes.NewState)
require.Equal(state, changes.NewState)

View File

@@ -2358,6 +2358,11 @@ type Email struct {
Preview string `json:"preview,omitempty"`
}
type AddressParameters struct {
HoldUntil time.Time `json:"HOLDUNTIL,omitzero"`
HoldForSeconds uint `json:"HOLDFOR,omitzero"`
}
type Address struct {
// The email address being represented by the object.
//
@@ -2373,7 +2378,7 @@ type Address struct {
// or null if the parameter does not take a value.
//
// [RFC5321]: https://datatracker.ietf.org/doc/html/rfc5321
Parameters map[string]any `json:"parameters,omitempty"` // TODO RFC5321
Parameters *AddressParameters `json:"parameters,omitempty"`
}
// Information for use when sending via SMTP.
@@ -2987,6 +2992,20 @@ type EmailCreate struct {
// The value for each key in the object MUST be true.
Keywords map[string]bool `json:"keywords,omitempty"`
// This is a list of all header fields [RFC5322], in the same order they appear in the message.
//
// [RFC5322]: https://www.rfc-editor.org/rfc/rfc5322.html
Headers []EmailHeader `json:"headers,omitempty"`
// The value is identical to the value of header:In-Reply-To:asMessageIds.
InReplyTo []string `json:"inReplyTo,omitempty"`
// The value is identical to the value of header:References:asMessageIds.
References []string `json:"references,omitempty"`
// The value is identical to the value of header:Sender:asAddresses.
Sender []EmailAddress `json:"sender,omitempty"`
// The ["From:" field] specifies the author(s) of the message, that is, the mailbox(es)
// of the person(s) or system(s) responsible for the writing of the message
//