Files
opencloud/pkg/jmap/jmap_api_email.go
Pascal Bleser 5dc1f28e87 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
2025-12-09 09:15:39 +01:00

1106 lines
38 KiB
Go

package jmap
import (
"context"
"encoding/base64"
"fmt"
"time"
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/opencloud/pkg/structs"
"github.com/rs/zerolog"
)
type Emails struct {
Emails []Email `json:"emails,omitempty"`
Total uint `json:"total,omitzero"`
Limit uint `json:"limit,omitzero"`
Offset uint `json:"offset,omitzero"`
}
type getEmailsResult struct {
emails []Email
notFound []string
}
// Retrieve specific Emails by their id.
func (j *Client) GetEmails(accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, ids []string, fetchBodies bool, maxBodyValueBytes uint, markAsSeen bool, withThreads bool) ([]Email, []string, SessionState, State, Language, Error) {
logger = j.logger("GetEmails", session, logger)
get := EmailGetCommand{AccountId: accountId, Ids: ids, FetchAllBodyValues: fetchBodies}
if maxBodyValueBytes > 0 {
get.MaxBodyValueBytes = maxBodyValueBytes
}
invokeGet := invocation(CommandEmailGet, get, "1")
methodCalls := []Invocation{invokeGet}
if markAsSeen {
updates := make(map[string]EmailUpdate, len(ids))
for _, id := range ids {
updates[id] = EmailUpdate{EmailPropertyKeywords + "/" + JmapKeywordSeen: true}
}
mark := EmailSetCommand{AccountId: accountId, Update: updates}
methodCalls = []Invocation{invocation(CommandEmailSet, mark, "0"), invokeGet}
}
if withThreads {
threads := ThreadGetRefCommand{
AccountId: accountId,
IdsRef: &ResultReference{
ResultOf: "1",
Name: CommandEmailGet,
Path: "/list/*/" + EmailPropertyThreadId,
},
}
methodCalls = append(methodCalls, invocation(CommandThreadGet, threads, "2"))
}
cmd, err := j.request(session, logger, methodCalls...)
if err != nil {
logger.Error().Err(err).Send()
return nil, nil, "", "", "", simpleError(err, JmapErrorInvalidJmapRequestPayload)
}
result, sessionState, state, language, gwerr := command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (getEmailsResult, State, Error) {
if markAsSeen {
var markResponse EmailSetResponse
err = retrieveResponseMatchParameters(logger, body, CommandEmailSet, "0", &markResponse)
if err != nil {
return getEmailsResult{}, "", err
}
for _, seterr := range markResponse.NotUpdated {
// TODO we don't have a way to compose multiple set errors yet
return getEmailsResult{}, "", setErrorError(seterr, EmailType)
}
}
var response EmailGetResponse
err = retrieveResponseMatchParameters(logger, body, CommandEmailGet, "1", &response)
if err != nil {
return getEmailsResult{}, "", err
}
if withThreads {
var threads ThreadGetResponse
err = retrieveResponseMatchParameters(logger, body, CommandThreadGet, "2", &threads)
if err != nil {
return getEmailsResult{}, "", err
}
setThreadSize(&threads, response.List)
}
return getEmailsResult{emails: response.List, notFound: response.NotFound}, response.State, nil
})
return result.emails, result.notFound, sessionState, state, language, gwerr
}
func (j *Client) GetEmailBlobId(accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, id string) (string, SessionState, State, Language, Error) {
logger = j.logger("GetEmailBlobId", session, logger)
get := EmailGetCommand{AccountId: accountId, Ids: []string{id}, FetchAllBodyValues: false, Properties: []string{"blobId"}}
cmd, err := j.request(session, logger, invocation(CommandEmailGet, get, "0"))
if err != nil {
logger.Error().Err(err).Send()
return "", "", "", "", simpleError(err, JmapErrorInvalidJmapRequestPayload)
}
return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (string, State, Error) {
var response EmailGetResponse
err = retrieveResponseMatchParameters(logger, body, CommandEmailGet, "0", &response)
if err != nil {
return "", "", err
}
if len(response.List) != 1 {
return "", "", nil
}
email := response.List[0]
return email.BlobId, response.State, nil
})
}
// Retrieve all the Emails in a given Mailbox by its id.
func (j *Client) GetAllEmailsInMailbox(accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, mailboxId string, offset int, limit uint, collapseThreads bool, fetchBodies bool, maxBodyValueBytes uint, withThreads bool) (Emails, SessionState, State, Language, Error) {
logger = j.loggerParams("GetAllEmailsInMailbox", session, logger, func(z zerolog.Context) zerolog.Context {
return z.Bool(logFetchBodies, fetchBodies).Int(logOffset, offset).Uint(logLimit, limit)
})
query := EmailQueryCommand{
AccountId: accountId,
Filter: &EmailFilterCondition{InMailbox: mailboxId},
Sort: []EmailComparator{{Property: EmailPropertyReceivedAt, IsAscending: false}},
CollapseThreads: collapseThreads,
CalculateTotal: true,
}
if offset > 0 {
query.Position = offset
}
if limit > 0 {
query.Limit = &limit
}
get := EmailGetRefCommand{
AccountId: accountId,
FetchAllBodyValues: fetchBodies,
IdsRef: &ResultReference{Name: CommandEmailQuery, Path: "/ids/*", ResultOf: "0"},
}
if maxBodyValueBytes > 0 {
get.MaxBodyValueBytes = maxBodyValueBytes
}
invocations := []Invocation{
invocation(CommandEmailQuery, query, "0"),
invocation(CommandEmailGet, get, "1"),
}
if withThreads {
threads := ThreadGetRefCommand{
AccountId: accountId,
IdsRef: &ResultReference{
ResultOf: "1",
Name: CommandEmailGet,
Path: "/list/*/" + EmailPropertyThreadId,
},
}
invocations = append(invocations, invocation(CommandThreadGet, threads, "2"))
}
cmd, err := j.request(session, logger, invocations...)
if err != nil {
return Emails{}, "", "", "", err
}
return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (Emails, State, Error) {
var queryResponse EmailQueryResponse
err = retrieveResponseMatchParameters(logger, body, CommandEmailQuery, "0", &queryResponse)
if err != nil {
return Emails{}, "", err
}
var getResponse EmailGetResponse
err = retrieveResponseMatchParameters(logger, body, CommandEmailGet, "1", &getResponse)
if err != nil {
logger.Error().Err(err).Send()
return Emails{}, "", err
}
if withThreads {
var thread ThreadGetResponse
err = retrieveResponseMatchParameters(logger, body, CommandThreadGet, "2", &thread)
if err != nil {
return Emails{}, "", err
}
setThreadSize(&thread, getResponse.List)
}
return Emails{
Emails: getResponse.List,
Total: queryResponse.Total,
Limit: queryResponse.Limit,
Offset: queryResponse.Position,
}, queryResponse.QueryState, nil
})
}
func (j *Client) GetEmailChanges(accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, sinceState State, maxChanges uint) (EmailChangesResponse, SessionState, State, Language, Error) {
logger = j.loggerParams("GetEmailChanges", session, logger, func(z zerolog.Context) zerolog.Context {
return z.Str(logSinceState, string(sinceState))
})
changes := EmailChangesCommand{
AccountId: accountId,
SinceState: sinceState,
}
if maxChanges > 0 {
changes.MaxChanges = maxChanges
}
cmd, err := j.request(session, logger, invocation(CommandEmailChanges, changes, "0"))
if err != nil {
return EmailChangesResponse{}, "", "", "", simpleError(err, JmapErrorInvalidJmapRequestPayload)
}
return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (EmailChangesResponse, State, Error) {
var changesResponse EmailChangesResponse
err = retrieveResponseMatchParameters(logger, body, CommandEmailChanges, "0", &changesResponse)
if err != nil {
return EmailChangesResponse{}, "", err
}
return changesResponse, changesResponse.NewState, nil
})
}
// Get all the Emails that have been created, updated or deleted since a given state.
func (j *Client) GetEmailsSince(accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, sinceState State, fetchBodies bool, maxBodyValueBytes uint, maxChanges uint) (MailboxChanges, SessionState, State, Language, Error) {
logger = j.loggerParams("GetEmailsSince", session, logger, func(z zerolog.Context) zerolog.Context {
return z.Bool(logFetchBodies, fetchBodies).Str(logSinceState, string(sinceState))
})
changes := EmailChangesCommand{
AccountId: accountId,
SinceState: sinceState,
}
if maxChanges > 0 {
changes.MaxChanges = maxChanges
}
getCreated := EmailGetRefCommand{
AccountId: accountId,
FetchAllBodyValues: fetchBodies,
IdsRef: &ResultReference{Name: CommandEmailChanges, Path: "/created", ResultOf: "0"},
}
if maxBodyValueBytes > 0 {
getCreated.MaxBodyValueBytes = maxBodyValueBytes
}
getUpdated := EmailGetRefCommand{
AccountId: accountId,
FetchAllBodyValues: fetchBodies,
IdsRef: &ResultReference{Name: CommandEmailChanges, Path: "/updated", ResultOf: "0"},
}
if maxBodyValueBytes > 0 {
getUpdated.MaxBodyValueBytes = maxBodyValueBytes
}
cmd, err := j.request(session, logger,
invocation(CommandEmailChanges, changes, "0"),
invocation(CommandEmailGet, getCreated, "1"),
invocation(CommandEmailGet, getUpdated, "2"),
)
if err != nil {
return MailboxChanges{}, "", "", "", simpleError(err, JmapErrorInvalidJmapRequestPayload)
}
return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (MailboxChanges, State, Error) {
var changesResponse EmailChangesResponse
err = retrieveResponseMatchParameters(logger, body, CommandEmailChanges, "0", &changesResponse)
if err != nil {
return MailboxChanges{}, "", err
}
var createdResponse EmailGetResponse
err = retrieveResponseMatchParameters(logger, body, CommandEmailGet, "1", &createdResponse)
if err != nil {
logger.Error().Err(err).Send()
return MailboxChanges{}, "", err
}
var updatedResponse EmailGetResponse
err = retrieveResponseMatchParameters(logger, body, CommandEmailGet, "2", &updatedResponse)
if err != nil {
logger.Error().Err(err).Send()
return MailboxChanges{}, "", err
}
return MailboxChanges{
Destroyed: changesResponse.Destroyed,
HasMoreChanges: changesResponse.HasMoreChanges,
NewState: changesResponse.NewState,
Created: createdResponse.List,
Updated: updatedResponse.List,
}, updatedResponse.State, nil
})
}
type SearchSnippetWithMeta struct {
ReceivedAt time.Time `json:"receivedAt,omitzero"`
EmailId string `json:"emailId,omitempty"`
SearchSnippet
}
type EmailSnippetQueryResult struct {
Snippets []SearchSnippetWithMeta `json:"snippets,omitempty"`
Total uint `json:"total"`
Limit uint `json:"limit,omitzero"`
Position uint `json:"position,omitzero"`
QueryState State `json:"queryState"`
}
func (j *Client) QueryEmailSnippets(accountIds []string, filter EmailFilterElement, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, offset int, limit uint) (map[string]EmailSnippetQueryResult, SessionState, State, Language, Error) {
logger = j.loggerParams("QueryEmailSnippets", session, logger, func(z zerolog.Context) zerolog.Context {
return z.Uint(logLimit, limit).Int(logOffset, offset)
})
uniqueAccountIds := structs.Uniq(accountIds)
invocations := make([]Invocation, len(uniqueAccountIds)*3)
for i, accountId := range uniqueAccountIds {
query := EmailQueryCommand{
AccountId: accountId,
Filter: filter,
Sort: []EmailComparator{{Property: EmailPropertyReceivedAt, IsAscending: false}},
CollapseThreads: true,
CalculateTotal: true,
}
if offset > 0 {
query.Position = offset
}
if limit > 0 {
query.Limit = &limit
}
mails := EmailGetRefCommand{
AccountId: accountId,
IdsRef: &ResultReference{
ResultOf: mcid(accountId, "0"),
Name: CommandEmailQuery,
Path: "/ids/*",
},
FetchAllBodyValues: false,
MaxBodyValueBytes: 0,
Properties: []string{EmailPropertyId, EmailPropertyReceivedAt, EmailPropertySentAt},
}
snippet := SearchSnippetGetRefCommand{
AccountId: accountId,
Filter: filter,
EmailIdRef: &ResultReference{
ResultOf: mcid(accountId, "0"),
Name: CommandEmailQuery,
Path: "/ids/*",
},
}
invocations[i*3+0] = invocation(CommandEmailQuery, query, mcid(accountId, "0"))
invocations[i*3+1] = invocation(CommandEmailGet, mails, mcid(accountId, "1"))
invocations[i*3+2] = invocation(CommandSearchSnippetGet, snippet, mcid(accountId, "2"))
}
cmd, err := j.request(session, logger, invocations...)
if err != nil {
return nil, "", "", "", err
}
return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (map[string]EmailSnippetQueryResult, State, Error) {
results := make(map[string]EmailSnippetQueryResult, len(uniqueAccountIds))
for _, accountId := range uniqueAccountIds {
var queryResponse EmailQueryResponse
err = retrieveResponseMatchParameters(logger, body, CommandEmailQuery, mcid(accountId, "0"), &queryResponse)
if err != nil {
return nil, "", err
}
var mailResponse EmailGetResponse
err = retrieveResponseMatchParameters(logger, body, CommandEmailGet, mcid(accountId, "1"), &mailResponse)
if err != nil {
return nil, "", err
}
var snippetResponse SearchSnippetGetResponse
err = retrieveResponseMatchParameters(logger, body, CommandSearchSnippetGet, mcid(accountId, "2"), &snippetResponse)
if err != nil {
return nil, "", err
}
mailResponseById := structs.Index(mailResponse.List, func(e Email) string { return e.Id })
snippets := make([]SearchSnippetWithMeta, len(queryResponse.Ids))
if len(queryResponse.Ids) > len(snippetResponse.List) {
// TODO how do we handle this, if there are more email IDs than snippets?
}
i := 0
for _, id := range queryResponse.Ids {
if mail, ok := mailResponseById[id]; ok {
snippets[i] = SearchSnippetWithMeta{
EmailId: id,
ReceivedAt: mail.ReceivedAt,
SearchSnippet: snippetResponse.List[i],
}
} else {
// TODO how do we handle this, if there is no email result for that id?
}
i++
}
results[accountId] = EmailSnippetQueryResult{
Snippets: snippets,
Total: queryResponse.Total,
Limit: queryResponse.Limit,
Position: queryResponse.Position,
QueryState: queryResponse.QueryState,
}
}
return results, squashStateFunc(results, func(r EmailSnippetQueryResult) State { return r.QueryState }), nil
})
}
type EmailQueryResult struct {
Emails []Email `json:"emails"`
Total uint `json:"total"`
Limit uint `json:"limit,omitzero"`
Position uint `json:"position,omitzero"`
QueryState State `json:"queryState"`
}
func (j *Client) QueryEmails(accountIds []string, filter EmailFilterElement, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, offset int, limit uint, fetchBodies bool, maxBodyValueBytes uint) (map[string]EmailQueryResult, SessionState, State, Language, Error) {
logger = j.loggerParams("QueryEmails", session, logger, func(z zerolog.Context) zerolog.Context {
return z.Bool(logFetchBodies, fetchBodies)
})
uniqueAccountIds := structs.Uniq(accountIds)
invocations := make([]Invocation, len(uniqueAccountIds)*2)
for i, accountId := range uniqueAccountIds {
query := EmailQueryCommand{
AccountId: accountId,
Filter: filter,
Sort: []EmailComparator{{Property: EmailPropertyReceivedAt, IsAscending: false}},
CollapseThreads: true,
CalculateTotal: true,
}
if offset > 0 {
query.Position = offset
}
if limit > 0 {
query.Limit = &limit
}
mails := EmailGetRefCommand{
AccountId: accountId,
IdsRef: &ResultReference{
ResultOf: mcid(accountId, "0"),
Name: CommandEmailQuery,
Path: "/ids/*",
},
FetchAllBodyValues: fetchBodies,
MaxBodyValueBytes: maxBodyValueBytes,
}
invocations[i*2+0] = invocation(CommandEmailQuery, query, mcid(accountId, "0"))
invocations[i*2+1] = invocation(CommandEmailGet, mails, mcid(accountId, "1"))
}
cmd, err := j.request(session, logger, invocations...)
if err != nil {
return nil, "", "", "", err
}
return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (map[string]EmailQueryResult, State, Error) {
results := make(map[string]EmailQueryResult, len(uniqueAccountIds))
for _, accountId := range uniqueAccountIds {
var queryResponse EmailQueryResponse
err = retrieveResponseMatchParameters(logger, body, CommandEmailQuery, mcid(accountId, "0"), &queryResponse)
if err != nil {
return nil, "", err
}
var emailsResponse EmailGetResponse
err = retrieveResponseMatchParameters(logger, body, CommandEmailGet, mcid(accountId, "1"), &emailsResponse)
if err != nil {
return nil, "", err
}
results[accountId] = EmailQueryResult{
Emails: emailsResponse.List,
Total: queryResponse.Total,
Limit: queryResponse.Limit,
Position: queryResponse.Position,
QueryState: queryResponse.QueryState,
}
}
return results, squashStateFunc(results, func(r EmailQueryResult) State { return r.QueryState }), nil
})
}
type EmailWithSnippets struct {
Email Email `json:"email"`
Snippets []SearchSnippet `json:"snippets,omitempty"`
}
type EmailQueryWithSnippetsResult struct {
Results []EmailWithSnippets `json:"results"`
Total uint `json:"total"`
Limit uint `json:"limit,omitzero"`
Position uint `json:"position,omitzero"`
QueryState State `json:"queryState"`
}
func (j *Client) QueryEmailsWithSnippets(accountIds []string, filter EmailFilterElement, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, offset int, limit uint, fetchBodies bool, maxBodyValueBytes uint) (map[string]EmailQueryWithSnippetsResult, SessionState, State, Language, Error) {
logger = j.loggerParams("QueryEmailsWithSnippets", session, logger, func(z zerolog.Context) zerolog.Context {
return z.Bool(logFetchBodies, fetchBodies)
})
uniqueAccountIds := structs.Uniq(accountIds)
invocations := make([]Invocation, len(uniqueAccountIds)*3)
for i, accountId := range uniqueAccountIds {
query := EmailQueryCommand{
AccountId: accountId,
Filter: filter,
Sort: []EmailComparator{{Property: EmailPropertyReceivedAt, IsAscending: false}},
CollapseThreads: false,
CalculateTotal: true,
}
if offset > 0 {
query.Position = offset
}
if limit > 0 {
query.Limit = &limit
}
snippet := SearchSnippetGetRefCommand{
AccountId: accountId,
Filter: filter,
EmailIdRef: &ResultReference{
ResultOf: mcid(accountId, "0"),
Name: CommandEmailQuery,
Path: "/ids/*",
},
}
mails := EmailGetRefCommand{
AccountId: accountId,
IdsRef: &ResultReference{
ResultOf: mcid(accountId, "0"),
Name: CommandEmailQuery,
Path: "/ids/*",
},
FetchAllBodyValues: fetchBodies,
MaxBodyValueBytes: maxBodyValueBytes,
}
invocations[i*3+0] = invocation(CommandEmailQuery, query, mcid(accountId, "0"))
invocations[i*3+1] = invocation(CommandSearchSnippetGet, snippet, mcid(accountId, "1"))
invocations[i*3+2] = invocation(CommandEmailGet, mails, mcid(accountId, "2"))
}
cmd, err := j.request(session, logger, invocations...)
if err != nil {
logger.Error().Err(err).Send()
return nil, "", "", "", simpleError(err, JmapErrorInvalidJmapRequestPayload)
}
return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (map[string]EmailQueryWithSnippetsResult, State, Error) {
result := make(map[string]EmailQueryWithSnippetsResult, len(uniqueAccountIds))
for _, accountId := range uniqueAccountIds {
var queryResponse EmailQueryResponse
err = retrieveResponseMatchParameters(logger, body, CommandEmailQuery, mcid(accountId, "0"), &queryResponse)
if err != nil {
return nil, "", err
}
var snippetResponse SearchSnippetGetResponse
err = retrieveResponseMatchParameters(logger, body, CommandSearchSnippetGet, mcid(accountId, "1"), &snippetResponse)
if err != nil {
return nil, "", err
}
var emailsResponse EmailGetResponse
err = retrieveResponseMatchParameters(logger, body, CommandEmailGet, mcid(accountId, "2"), &emailsResponse)
if err != nil {
return nil, "", err
}
snippetsById := map[string][]SearchSnippet{}
for _, snippet := range snippetResponse.List {
list, ok := snippetsById[snippet.EmailId]
if !ok {
list = []SearchSnippet{}
}
snippetsById[snippet.EmailId] = append(list, snippet)
}
results := []EmailWithSnippets{}
for _, email := range emailsResponse.List {
snippets, ok := snippetsById[email.Id]
if !ok {
snippets = []SearchSnippet{}
}
results = append(results, EmailWithSnippets{
Email: email,
Snippets: snippets,
})
}
result[accountId] = EmailQueryWithSnippetsResult{
Results: results,
Total: queryResponse.Total,
Limit: queryResponse.Limit,
Position: queryResponse.Position,
QueryState: queryResponse.QueryState,
}
}
return result, squashStateFunc(result, func(r EmailQueryWithSnippetsResult) State { return r.QueryState }), nil
})
}
type UploadedEmail struct {
Id string `json:"id"`
Size int `json:"size"`
Type string `json:"type"`
Sha512 string `json:"sha:512"`
}
func (j *Client) ImportEmail(accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, data []byte) (UploadedEmail, SessionState, State, Language, Error) {
encoded := base64.StdEncoding.EncodeToString(data)
upload := BlobUploadCommand{
AccountId: accountId,
Create: map[string]UploadObject{
"0": {
Data: []DataSourceObject{{
DataAsBase64: encoded,
}},
Type: EmailMimeType,
},
},
}
getHash := BlobGetRefCommand{
AccountId: accountId,
IdRef: &ResultReference{
ResultOf: "0",
Name: CommandBlobUpload,
Path: "/ids",
},
Properties: []string{BlobPropertyDigestSha512},
}
cmd, err := j.request(session, logger,
invocation(CommandBlobUpload, upload, "0"),
invocation(CommandBlobGet, getHash, "1"),
)
if err != nil {
return UploadedEmail{}, "", "", "", simpleError(err, JmapErrorInvalidJmapRequestPayload)
}
return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (UploadedEmail, State, Error) {
var uploadResponse BlobUploadResponse
err = retrieveResponseMatchParameters(logger, body, CommandBlobUpload, "0", &uploadResponse)
if err != nil {
return UploadedEmail{}, "", err
}
var getResponse BlobGetResponse
err = retrieveResponseMatchParameters(logger, body, CommandBlobGet, "1", &getResponse)
if err != nil {
logger.Error().Err(err).Send()
return UploadedEmail{}, "", err
}
if len(uploadResponse.Created) != 1 {
logger.Error().Msgf("%T.Created has %v elements instead of 1", uploadResponse, len(uploadResponse.Created))
return UploadedEmail{}, "", simpleError(err, JmapErrorInvalidJmapResponsePayload)
}
upload, ok := uploadResponse.Created["0"]
if !ok {
logger.Error().Msgf("%T.Created has no element '0'", uploadResponse)
return UploadedEmail{}, "", simpleError(err, JmapErrorInvalidJmapResponsePayload)
}
if len(getResponse.List) != 1 {
logger.Error().Msgf("%T.List has %v elements instead of 1", getResponse, len(getResponse.List))
return UploadedEmail{}, "", simpleError(err, JmapErrorInvalidJmapResponsePayload)
}
get := getResponse.List[0]
return UploadedEmail{
Id: upload.Id,
Size: upload.Size,
Type: upload.Type,
Sha512: get.DigestSha512,
}, State(get.DigestSha256), nil
})
}
func (j *Client) CreateEmail(accountId string, email EmailCreate, replaceId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string) (*Email, SessionState, State, Language, Error) {
set := EmailSetCommand{
AccountId: accountId,
Create: map[string]EmailCreate{
"c": email,
},
}
if replaceId != "" {
set.Destroy = []string{replaceId}
}
cmd, err := j.request(session, logger,
invocation(CommandEmailSet, set, "0"),
)
if err != nil {
return nil, "", "", "", err
}
return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (*Email, State, Error) {
var setResponse EmailSetResponse
err = retrieveResponseMatchParameters(logger, body, CommandEmailSet, "0", &setResponse)
if err != nil {
return nil, "", err
}
if len(setResponse.NotCreated) > 0 {
// error occured
// TODO(pbleser-oc) handle submission errors
}
setErr, notok := setResponse.NotCreated["c"]
if notok {
logger.Error().Msgf("%T.NotCreated returned an error %v", setResponse, setErr)
return nil, "", setErrorError(setErr, EmailType)
}
created, ok := setResponse.Created["c"]
if !ok {
berr := fmt.Errorf("failed to find %s in %s response", string(EmailType), string(CommandEmailSet))
logger.Error().Err(berr)
return nil, "", simpleError(berr, JmapErrorInvalidJmapResponsePayload)
}
return created, setResponse.NewState, nil
})
}
// The Email/set method encompasses:
// - Changing the keywords of an Email (e.g., unread/flagged status)
// - Adding/removing an Email to/from Mailboxes (moving a message)
// - Deleting Emails
//
// To create drafts, use the CreateEmail function instead.
//
// To delete mails, use the DeleteEmails function instead.
func (j *Client) UpdateEmails(accountId string, updates map[string]EmailUpdate, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string) (map[string]*Email, SessionState, State, Language, Error) {
cmd, err := j.request(session, logger,
invocation(CommandEmailSet, EmailSetCommand{
AccountId: accountId,
Update: updates,
}, "0"),
)
if err != nil {
return nil, "", "", "", err
}
return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (map[string]*Email, State, Error) {
var setResponse EmailSetResponse
err = retrieveResponseMatchParameters(logger, body, CommandEmailSet, "0", &setResponse)
if err != nil {
return nil, "", err
}
if len(setResponse.NotUpdated) > 0 {
// TODO we don't have composite errors
for _, notUpdated := range setResponse.NotUpdated {
return nil, "", setErrorError(notUpdated, EmailType)
}
}
return setResponse.Updated, setResponse.NewState, nil
})
}
func (j *Client) DeleteEmails(accountId string, destroy []string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string) (map[string]SetError, SessionState, State, Language, Error) {
cmd, err := j.request(session, logger,
invocation(CommandEmailSet, EmailSetCommand{
AccountId: accountId,
Destroy: destroy,
}, "0"),
)
if err != nil {
return nil, "", "", "", err
}
return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (map[string]SetError, State, Error) {
var setResponse EmailSetResponse
err = retrieveResponseMatchParameters(logger, body, CommandEmailSet, "0", &setResponse)
if err != nil {
return nil, "", err
}
return setResponse.NotDestroyed, setResponse.NewState, nil
})
}
type SubmittedEmail struct {
Id string `json:"id"`
SendAt time.Time `json:"sendAt,omitzero"`
ThreadId string `json:"threadId,omitempty"`
UndoStatus EmailSubmissionUndoStatus `json:"undoStatus,omitempty"`
Envelope *Envelope `json:"envelope,omitempty"`
// A list of blob ids for DSNs [RFC3464] received for this submission,
// in order of receipt, oldest first.
//
// The blob is the whole MIME message (with a top-level content-type of multipart/report), as received.
//
// [RFC3464]: https://datatracker.ietf.org/doc/html/rfc3464
DsnBlobIds []string `json:"dsnBlobIds,omitempty"`
// A list of blob ids for MDNs [RFC8098] received for this submission,
// in order of receipt, oldest first.
//
// The blob is the whole MIME message (with a top-level content-type of multipart/report), as received.
//
// [RFC8098]: https://datatracker.ietf.org/doc/html/rfc8098
MdnBlobIds []string `json:"mdnBlobIds,omitempty"`
}
type MoveMail struct {
FromMailboxId string
ToMailboxId string
}
func (j *Client) SubmitEmail(accountId string, identityId string, emailId string, move *MoveMail, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string) (EmailSubmission, SessionState, State, Language, Error) {
logger = j.logger("SubmitEmail", session, logger)
update := map[string]any{
EmailPropertyKeywords + "/" + JmapKeywordDraft: nil, // unmark as draft
EmailPropertyKeywords + "/" + JmapKeywordSeen: true, // mark as seen (read)
}
if move != nil && move.FromMailboxId != "" && move.ToMailboxId != "" && move.FromMailboxId != move.ToMailboxId {
update[EmailPropertyMailboxIds+"/"+move.FromMailboxId] = nil
update[EmailPropertyMailboxIds+"/"+move.ToMailboxId] = true
}
id := "s0"
set := EmailSubmissionSetCommand{
AccountId: accountId,
Create: map[string]EmailSubmissionCreate{
id: {
IdentityId: identityId,
EmailId: emailId,
Envelope: nil,
},
},
OnSuccessUpdateEmail: map[string]PatchObject{
"#" + id: update,
},
}
get := EmailSubmissionGetCommand{
AccountId: accountId,
Ids: []string{"#" + id},
}
cmd, err := j.request(session, logger,
invocation(CommandEmailSubmissionSet, set, "0"),
invocation(CommandEmailSubmissionGet, get, "1"),
)
if err != nil {
return EmailSubmission{}, "", "", "", err
}
return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (EmailSubmission, State, Error) {
var submissionResponse EmailSubmissionSetResponse
err = retrieveResponseMatchParameters(logger, body, CommandEmailSubmissionSet, "0", &submissionResponse)
if err != nil {
return EmailSubmission{}, "", err
}
if len(submissionResponse.NotCreated) > 0 {
// error occured
// TODO(pbleser-oc) handle submission errors
}
// there is an implicit Email/set response:
// "After all create/update/destroy items in the EmailSubmission/set invocation have been processed,
// a single implicit Email/set call MUST be made to perform any changes requested in these two arguments.
// The response to this MUST be returned after the EmailSubmission/set response."
// from an example in the spec, it has the same tag as the EmailSubmission/set command ("0" in this case)
var setResponse EmailSetResponse
err = retrieveResponseMatchParameters(logger, body, CommandEmailSet, "0", &setResponse)
if err != nil {
return EmailSubmission{}, "", err
}
if len(setResponse.Updated) == 1 {
var getResponse EmailSubmissionGetResponse
err = retrieveResponseMatchParameters(logger, body, CommandEmailSubmissionGet, "1", &getResponse)
if err != nil {
return EmailSubmission{}, "", err
}
if len(getResponse.List) != 1 {
// for some reason (error?)...
// TODO(pbleser-oc) handle absence of emailsubmission
}
submission := getResponse.List[0]
return submission, setResponse.NewState, nil
} else {
err = simpleError(fmt.Errorf("failed to submit email: updated is empty"), 0) // TODO proper error handling
return EmailSubmission{}, "", err
}
})
}
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))
})
cmd, err := j.request(session, logger,
invocation(CommandThreadGet, ThreadGetCommand{
AccountId: accountId,
Ids: []string{threadId},
}, "0"),
invocation(CommandEmailGet, EmailGetRefCommand{
AccountId: accountId,
IdsRef: &ResultReference{
ResultOf: "0",
Name: CommandThreadGet,
Path: "/list/*/emailIds",
},
FetchAllBodyValues: fetchBodies,
MaxBodyValueBytes: maxBodyValueBytes,
}, "1"),
)
if err != nil {
return nil, "", "", "", err
}
return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) ([]Email, State, Error) {
var emailsResponse EmailGetResponse
err = retrieveResponseMatchParameters(logger, body, CommandEmailGet, "1", &emailsResponse)
if err != nil {
return nil, "", err
}
return emailsResponse.List, emailsResponse.State, nil
})
}
type EmailsSummary struct {
Emails []Email `json:"emails"`
Total uint `json:"total"`
Limit uint `json:"limit"`
Offset uint `json:"offset"`
State State `json:"state"`
}
var EmailSummaryProperties = []string{
EmailPropertyId,
EmailPropertyThreadId,
EmailPropertyMailboxIds,
EmailPropertyKeywords,
EmailPropertySize,
EmailPropertyReceivedAt,
EmailPropertySender,
EmailPropertyFrom,
EmailPropertyTo,
EmailPropertyCc,
EmailPropertyBcc,
EmailPropertySubject,
EmailPropertySentAt,
EmailPropertyHasAttachment,
EmailPropertyAttachments,
EmailPropertyPreview,
}
func (j *Client) QueryEmailSummaries(accountIds []string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, filter EmailFilterElement, limit uint, withThreads bool) (map[string]EmailsSummary, SessionState, State, Language, Error) {
logger = j.logger("QueryEmailSummaries", session, logger)
uniqueAccountIds := structs.Uniq(accountIds)
factor := 2
if withThreads {
factor++
}
invocations := make([]Invocation, len(uniqueAccountIds)*factor)
for i, accountId := range uniqueAccountIds {
get := EmailQueryCommand{
AccountId: accountId,
Filter: filter,
Sort: []EmailComparator{{Property: EmailPropertyReceivedAt, IsAscending: false}},
}
if limit > 0 {
get.Limit = &limit
}
invocations[i*factor+0] = invocation(CommandEmailQuery, get, mcid(accountId, "0"))
invocations[i*factor+1] = invocation(CommandEmailGet, EmailGetRefCommand{
AccountId: accountId,
IdsRef: &ResultReference{
Name: CommandEmailQuery,
Path: "/ids/*",
ResultOf: mcid(accountId, "0"),
},
Properties: EmailSummaryProperties,
}, mcid(accountId, "1"))
if withThreads {
invocations[i*factor+2] = invocation(CommandThreadGet, ThreadGetRefCommand{
AccountId: accountId,
IdsRef: &ResultReference{
Name: CommandEmailGet,
Path: "/list/*/" + EmailPropertyThreadId,
ResultOf: mcid(accountId, "1"),
},
}, mcid(accountId, "2"))
}
}
cmd, err := j.request(session, logger, invocations...)
if err != nil {
return nil, "", "", "", err
}
return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (map[string]EmailsSummary, State, Error) {
resp := map[string]EmailsSummary{}
for _, accountId := range uniqueAccountIds {
var queryResponse EmailQueryResponse
err = retrieveResponseMatchParameters(logger, body, CommandEmailQuery, mcid(accountId, "0"), &queryResponse)
if err != nil {
return nil, "", err
}
var response EmailGetResponse
err = retrieveResponseMatchParameters(logger, body, CommandEmailGet, mcid(accountId, "1"), &response)
if err != nil {
return nil, "", err
}
if len(response.NotFound) > 0 {
// TODO what to do when there are not-found emails here? potentially nothing, they could have been deleted between query and get?
}
if withThreads {
var thread ThreadGetResponse
err = retrieveResponseMatchParameters(logger, body, CommandThreadGet, mcid(accountId, "2"), &thread)
if err != nil {
return nil, "", err
}
setThreadSize(&thread, response.List)
}
resp[accountId] = EmailsSummary{
Emails: response.List,
Total: queryResponse.Total,
Limit: queryResponse.Limit,
Offset: queryResponse.Position,
State: response.State,
}
}
return resp, squashStateFunc(resp, func(s EmailsSummary) State { return s.State }), nil
})
}
func setThreadSize(threads *ThreadGetResponse, emails []Email) {
threadSizeById := make(map[string]int, len(threads.List))
for _, thread := range threads.List {
threadSizeById[thread.Id] = len(thread.EmailIds)
}
for i := range len(emails) {
ts, ok := threadSizeById[emails[i].ThreadId]
if !ok {
ts = 1
}
emails[i].ThreadSize = ts
}
}