diff --git a/pkg/jmap/jmap_api.go b/pkg/jmap/jmap_api.go index cede3f1904..c5578b28fc 100644 --- a/pkg/jmap/jmap_api.go +++ b/pkg/jmap/jmap_api.go @@ -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" ) diff --git a/pkg/jmap/jmap_api_email.go b/pkg/jmap/jmap_api_email.go index e548b83444..c728dead77 100644 --- a/pkg/jmap/jmap_api_email.go +++ b/pkg/jmap/jmap_api_email.go @@ -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)) diff --git a/pkg/jmap/jmap_integration_contact_test.go b/pkg/jmap/jmap_integration_contact_test.go index 20a8c771bc..decdbf3e8f 100644 --- a/pkg/jmap/jmap_integration_contact_test.go +++ b/pkg/jmap/jmap_integration_contact_test.go @@ -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() diff --git a/pkg/jmap/jmap_integration_email_test.go b/pkg/jmap/jmap_integration_email_test.go index 2a53261bfb..433eb5cf3a 100644 --- a/pkg/jmap/jmap_integration_email_test.go +++ b/pkg/jmap/jmap_integration_email_test.go @@ -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" diff --git a/pkg/jmap/jmap_integration_event_test.go b/pkg/jmap/jmap_integration_event_test.go index 72c64fbd44..af014efa16 100644 --- a/pkg/jmap/jmap_integration_event_test.go +++ b/pkg/jmap/jmap_integration_event_test.go @@ -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, diff --git a/pkg/jmap/jmap_integration_test.go b/pkg/jmap/jmap_integration_test.go index 80edf70cae..15691dc453 100644 --- a/pkg/jmap/jmap_integration_test.go +++ b/pkg/jmap/jmap_integration_test.go @@ -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 { diff --git a/pkg/jmap/jmap_integration_ws_test.go b/pkg/jmap/jmap_integration_ws_test.go index e01f63e0c6..673949775d 100644 --- a/pkg/jmap/jmap_integration_ws_test.go +++ b/pkg/jmap/jmap_integration_ws_test.go @@ -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) diff --git a/pkg/jmap/jmap_model.go b/pkg/jmap/jmap_model.go index 603493b868..3863b61bf8 100644 --- a/pkg/jmap/jmap_model.go +++ b/pkg/jmap/jmap_model.go @@ -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 //