groupware: implement/fix email submission

This commit is contained in:
Pascal Bleser
2025-10-29 19:05:00 +01:00
parent 3f8d1d708b
commit 64af4c6f33
7 changed files with 260 additions and 119 deletions

View File

@@ -730,9 +730,11 @@ func (j *Client) UpdateEmails(accountId string, updates map[string]EmailUpdate,
if err != nil {
return nil, "", err
}
if len(setResponse.NotUpdated) != len(updates) {
// error occured
// TODO(pbleser-oc) handle submission errors
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
})
@@ -783,29 +785,49 @@ type SubmittedEmail struct {
MdnBlobIds []string `json:"mdnBlobIds,omitempty"`
}
func (j *Client) SubmitEmail(accountId string, identityId string, emailId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, data []byte) (SubmittedEmail, SessionState, State, Language, Error) {
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{
"s0": {
id: {
IdentityId: identityId,
EmailId: emailId,
// leaving Envelope empty
},
},
OnSuccessUpdateEmail: map[string]PatchObject{
"#s0": {
EmailPropertyKeywords + "/" + JmapKeywordDraft: nil,
},
"#" + id: update,
},
}
get := EmailSubmissionGetRefCommand{
get := EmailSubmissionGetCommand{
AccountId: accountId,
IdRef: &ResultReference{
ResultOf: "0",
Name: CommandEmailSubmissionSet,
Path: "/created/s0/" + EmailPropertyId,
},
Ids: []string{"#" + id},
/*
IdRef: &ResultReference{
ResultOf: "0",
Name: CommandEmailSubmissionSet,
Path: ["#"]"/created/" + "#" + id + "/" + EmailPropertyId,
},
*/
}
cmd, err := j.request(session, logger,
@@ -813,14 +835,14 @@ func (j *Client) SubmitEmail(accountId string, identityId string, emailId string
invocation(CommandEmailSubmissionGet, get, "1"),
)
if err != nil {
return SubmittedEmail{}, "", "", "", err
return EmailSubmission{}, "", "", "", err
}
return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (SubmittedEmail, State, Error) {
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 SubmittedEmail{}, "", err
return EmailSubmission{}, "", err
}
if len(submissionResponse.NotCreated) > 0 {
@@ -836,31 +858,28 @@ func (j *Client) SubmitEmail(accountId string, identityId string, emailId string
var setResponse EmailSetResponse
err = retrieveResponseMatchParameters(logger, body, CommandEmailSet, "0", &setResponse)
if err != nil {
return SubmittedEmail{}, "", err
return EmailSubmission{}, "", err
}
var getResponse EmailSubmissionGetResponse
err = retrieveResponseMatchParameters(logger, body, CommandEmailSubmissionGet, "1", &getResponse)
if err != nil {
return SubmittedEmail{}, "", err
if emailId := structs.FirstKey(setResponse.Updated); emailId != nil && 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
}
if len(getResponse.List) != 1 {
// for some reason (error?)...
// TODO(pbleser-oc) handle absence of emailsubmission
}
submission := getResponse.List[0]
return SubmittedEmail{
Id: submission.Id,
SendAt: submission.SendAt,
ThreadId: submission.ThreadId,
UndoStatus: submission.UndoStatus,
Envelope: submission.Envelope,
DsnBlobIds: submission.DsnBlobIds,
MdnBlobIds: submission.MdnBlobIds,
}, setResponse.NewState, nil
})
}

View File

@@ -2540,6 +2540,24 @@ type EmailSubmission struct {
MdnBlobIds []string `json:"mdnBlobIds,omitempty"`
}
type EmailSubmissionGetCommand struct {
// The id of the account to use.
AccountId string `json:"accountId"`
// The ids of the EmailSubmission objects to return.
//
// If null, then all records of the data type are returned, if this is supported for that data
// type and the number of records does not exceed the maxObjectsInGet limit.
Ids []string `json:"ids,omitempty"`
// If supplied, only the properties listed in the array are returned for each EmailSubmission object.
//
// If null, all properties of the object are returned. The id property of the object is always returned,
// even if not explicitly requested. If an invalid property is requested, the call MUST be rejected
// with an invalidArguments error.
Properties []string `json:"properties,omitempty"`
}
type EmailSubmissionGetRefCommand struct {
// The id of the account to use.
AccountId string `json:"accountId"`
@@ -2952,6 +2970,18 @@ type EmailCreate struct {
// ["From:" field]: https://www.rfc-editor.org/rfc/rfc5322.html#section-3.6.2
From []EmailAddress `json:"from,omitempty"`
// The value is identical to the value of header:To:asAddresses.
To []EmailAddress `json:"to,omitempty"`
// The value is identical to the value of header:Cc:asAddresses.
Cc []EmailAddress `json:"cc,omitempty"`
// The value is identical to the value of header:Bcc:asAddresses.
Bcc []EmailAddress `json:"bcc,omitempty"`
// The value is identical to the value of header:Reply-To:asAddresses.
ReplyTo []EmailAddress `json:"replyTo,omitempty"`
// The "Subject:" field contains a short string identifying the topic of the message.
Subject string `json:"subject,omitempty"`
@@ -2982,6 +3012,31 @@ type EmailCreate struct {
// This is a map of partId to an EmailBodyValue object for none, some, or all text/* parts.
BodyValues map[string]EmailBodyValue `json:"bodyValues,omitempty"`
// A list of text/plain, text/html, image/*, audio/*, and/or video/* parts to display (sequentially) as the
// message body, with a preference for text/plain when alternative versions are available.
TextBody []EmailBodyPart `json:"textBody,omitempty"`
// A list of text/plain, text/html, image/*, audio/*, and/or video/* parts to display (sequentially) as the
// message body, with a preference for text/html when alternative versions are available.
HtmlBody []EmailBodyPart `json:"htmlBody,omitempty"`
// A list, traversing depth-first, of all parts in bodyStructure.
//
// They must satisfy either of the following conditions:
//
// - not of type multipart/* and not included in textBody or htmlBody
// - of type image/*, audio/*, or video/* and not in both textBody and htmlBody
//
// None of these parts include subParts, including message/* types.
//
// Attached messages may be fetched using the Email/parse method and the blobId.
//
// Note that a text/html body part HTML may reference image parts in attachments by using cid:
// links to reference the Content-Id, as defined in [RFC2392], or by referencing the Content-Location.
//
// [RFC2392]: https://www.rfc-editor.org/rfc/rfc2392.html
Attachments []EmailBodyPart `json:"attachments,omitempty"`
}
type EmailUpdate map[string]any

View File

@@ -137,3 +137,10 @@ func Missing[E comparable](expected, actual []E) []E {
}
return missing
}
func FirstKey[K comparable, V any](m map[K]V) *K {
for k := range m {
return &k
}
return nil
}

View File

@@ -887,24 +887,13 @@ func (g *Groupware) CreateEmail(w http.ResponseWriter, r *http.Request) {
}
logger = log.From(logger.With().Str(logAccountId, log.SafeString(accountId)))
var body jmap.Email
var body jmap.EmailCreate
err := req.body(&body)
if err != nil {
return errorResponse(err)
}
create := jmap.EmailCreate{
MailboxIds: body.MailboxIds,
Keywords: body.Keywords,
From: body.From,
Subject: body.Subject,
ReceivedAt: body.ReceivedAt,
SentAt: body.SentAt,
BodyStructure: body.BodyStructure,
BodyValues: body.BodyValues,
}
created, sessionState, state, lang, jerr := g.jmap.CreateEmail(accountId, create, "", req.session, req.ctx, logger, req.language())
created, sessionState, state, lang, jerr := g.jmap.CreateEmail(accountId, body, "", req.session, req.ctx, logger, req.language())
if jerr != nil {
return req.errorResponseFromJmap(jerr)
}
@@ -926,24 +915,13 @@ func (g *Groupware) ReplaceEmail(w http.ResponseWriter, r *http.Request) {
logger = log.From(logger.With().Str(logAccountId, log.SafeString(accountId)))
var body jmap.Email
var body jmap.EmailCreate
err := req.body(&body)
if err != nil {
return errorResponse(err)
}
create := jmap.EmailCreate{
MailboxIds: body.MailboxIds,
Keywords: body.Keywords,
From: body.From,
Subject: body.Subject,
ReceivedAt: body.ReceivedAt,
SentAt: body.SentAt,
BodyStructure: body.BodyStructure,
BodyValues: body.BodyValues,
}
created, sessionState, state, lang, jerr := g.jmap.CreateEmail(accountId, create, replaceId, req.session, req.ctx, logger, req.language())
created, sessionState, state, lang, jerr := g.jmap.CreateEmail(accountId, body, replaceId, req.session, req.ctx, logger, req.language())
if jerr != nil {
return req.errorResponseFromJmap(jerr)
}
@@ -1305,6 +1283,58 @@ func (g *Groupware) DeleteEmails(w http.ResponseWriter, r *http.Request) {
})
}
func (g *Groupware) SendEmail(w http.ResponseWriter, r *http.Request) {
g.respond(w, r, func(req Request) Response {
emailId := chi.URLParam(r, UriParamEmailId)
l := req.logger.With()
l.Str(UriParamEmailId, log.SafeString(emailId))
identityId, err := req.getMandatoryStringParam(QueryParamIdentityId)
if err != nil {
return errorResponse(err)
}
l.Str(QueryParamIdentityId, log.SafeString(identityId))
var move *jmap.MoveMail = nil
{
moveFromMailboxId, _ := req.getStringParam(QueryParamMoveFromMailboxId, "")
moveToMailboxId, _ := req.getStringParam(QueryParamMoveToMailboxId, "")
if moveFromMailboxId != "" && moveToMailboxId != "" {
move = &jmap.MoveMail{FromMailboxId: moveFromMailboxId, ToMailboxId: moveToMailboxId}
l.Str(QueryParamMoveFromMailboxId, log.SafeString(moveFromMailboxId)).Str(QueryParamMoveToMailboxId, log.SafeString(moveFromMailboxId))
} else if moveFromMailboxId == "" && moveToMailboxId == "" {
// nothing to change
} else {
missing := moveFromMailboxId
if moveFromMailboxId == "" {
missing = moveFromMailboxId
}
// only one is set
msg := fmt.Sprintf("Missing required value for query parameter '%v'", missing)
return errorResponse(req.observedParameterError(ErrorMissingMandatoryRequestParameter,
withDetail(msg),
withSource(&ErrorSource{Parameter: missing})))
}
}
accountId, gwerr := req.GetAccountIdForMail()
if gwerr != nil {
return errorResponse(gwerr)
}
l.Str(logAccountId, accountId)
logger := log.From(l)
resp, sessionState, state, lang, jerr := g.jmap.SubmitEmail(accountId, identityId, emailId, move, req.session, req.ctx, logger, req.language())
if jerr != nil {
return req.errorResponseFromJmap(jerr)
}
return etagResponse(resp, sessionState, state, lang)
})
}
type AboutEmailsEvent struct {
Id string `json:"id"`
Source string `json:"source"`
@@ -1833,35 +1863,6 @@ func filterFromNotKeywords(keywords []string) jmap.EmailFilterElement {
}
}
func squashQueryState[V any](all map[string]V, mapper func(V) jmap.State) jmap.State {
n := len(all)
if n == 0 {
return jmap.State("")
}
if n == 1 {
for _, v := range all {
return mapper(v)
}
}
parts := make([]string, n)
sortedKeys := make([]string, n)
i := 0
for k := range all {
sortedKeys[i] = k
i++
}
slices.Sort(sortedKeys)
for i, k := range sortedKeys {
if v, ok := all[k]; ok {
parts[i] = k + ":" + string(mapper(v))
} else {
parts[i] = k + ":"
}
}
return jmap.State(strings.Join(parts, ","))
}
var sanitizationPolicy *bluemonday.Policy = bluemonday.UGCPolicy()
var sanitizableMediaTypes = []string{

View File

@@ -174,6 +174,7 @@ const (
ErrorCodeInvalidRequestPayload = "INVRQP"
ErrorCodeInvalidResponsePayload = "INVRSP"
ErrorCodeInvalidRequestParameter = "INVPAR"
ErrorCodeMissingMandatoryRequestParameter = "MISMPA"
ErrorCodeInvalidRequestBody = "INVBDY"
ErrorCodeNonExistingAccount = "INVACC"
ErrorCodeIndeterminateAccount = "INDACC"
@@ -297,6 +298,12 @@ var (
Title: "Invalid Request Parameter",
Detail: "At least one of the parameters in the request is invalid.",
}
ErrorMissingMandatoryRequestParameter = GroupwareError{
Status: http.StatusBadRequest,
Code: ErrorCodeMissingMandatoryRequestParameter,
Title: "Missing Mandatory Request Parameter",
Detail: "A mandatory request parameter is missing.",
}
ErrorInvalidRequestBody = GroupwareError{
Status: http.StatusBadRequest,
Code: ErrorCodeInvalidRequestBody,

View File

@@ -168,6 +168,34 @@ func (r Request) parameterErrorResponse(param string, detail string) Response {
return errorResponse(r.parameterError(param, detail))
}
func (r Request) getStringParam(param string, defaultValue string) (string, bool) {
q := r.r.URL.Query()
if !q.Has(param) {
return defaultValue, false
}
str := q.Get(param)
if str == "" {
return defaultValue, false
}
return str, true
}
func (r Request) getMandatoryStringParam(param string) (string, *Error) {
str := ""
q := r.r.URL.Query()
if q.Has(param) {
str = q.Get(param)
}
if str == "" {
msg := fmt.Sprintf("Missing required value for query parameter '%v'", param)
return "", r.observedParameterError(ErrorMissingMandatoryRequestParameter,
withDetail(msg),
withSource(&ErrorSource{Parameter: param}),
)
}
return str, nil
}
func (r Request) parseIntParam(param string, defaultValue int) (int, bool, *Error) {
q := r.r.URL.Query()
if !q.Has(param) {

View File

@@ -30,6 +30,9 @@ const (
QueryParamSince = "since"
QueryParamMaxChanges = "maxchanges"
QueryParamMailboxId = "mailbox"
QueryParamIdentityId = "identity"
QueryParamMoveFromMailboxId = "move-from"
QueryParamMoveToMailboxId = "move-to"
QueryParamNotInMailboxId = "notmailbox"
QueryParamSearchText = "text"
QueryParamSearchFrom = "from"
@@ -74,43 +77,58 @@ func (g *Groupware) Route(r chi.Router) {
r.Get("/", g.GetEmailsForAllAccounts)
r.Get("/latest/summary", g.GetLatestEmailsSummaryForAllAccounts) // ?limit=10&seen=true&undesirable=true
})
r.Get("/quota", g.GetQuotaForAllAccounts)
r.Route("/quota", func(r chi.Router) {
r.Get("/", g.GetQuotaForAllAccounts)
})
})
r.Route("/{accountid}", func(r chi.Router) {
r.Get("/", g.GetAccount)
r.Route("/identities", func(r chi.Router) {
r.Get("/", g.GetIdentities)
r.Get("/{identityid}", g.GetIdentityById)
r.Post("/", g.AddIdentity)
r.Patch("/{identityid}", g.ModifyIdentity)
r.Delete("/{identityid}", g.DeleteIdentity)
r.Route("/{identityid}", func(r chi.Router) {
r.Get("/", g.GetIdentityById)
r.Patch("/", g.ModifyIdentity)
r.Delete("/", g.DeleteIdentity)
})
})
r.Get("/vacation", g.GetVacation)
r.Put("/vacation", g.SetVacation)
r.Get("/quota", g.GetQuota)
r.Route("/mailboxes", func(r chi.Router) {
r.Get("/", g.GetMailboxes) // ?name=&role=&subcribed=
r.Get("/{mailboxid}", g.GetMailbox)
r.Get("/{mailboxid}/emails", g.GetAllEmailsInMailbox)
r.Get("/{mailboxid}/changes", g.GetMailboxChanges)
r.Post("/", g.CreateMailbox)
r.Patch("/{mailboxid}", g.UpdateMailbox)
r.Delete("/{mailboxid}", g.DeleteMailbox)
r.Route("/{mailboxid}", func(r chi.Router) {
r.Get("/", g.GetMailbox)
r.Get("/emails", g.GetAllEmailsInMailbox)
r.Get("/changes", g.GetMailboxChanges)
r.Patch("/", g.UpdateMailbox)
r.Delete("/", g.DeleteMailbox)
})
})
r.Route("/emails", func(r chi.Router) {
r.Get("/", g.GetEmails) // ?fetchemails=true&fetchbodies=true&text=&subject=&body=&keyword=&keyword=&...
r.Post("/", g.CreateEmail)
r.Delete("/", g.DeleteEmails)
r.Get("/{emailid}", g.GetEmailsById) // Accept:message/rfc822
r.Put("/{emailid}", g.ReplaceEmail)
r.Patch("/{emailid}", g.UpdateEmail)
r.Patch("/{emailid}/keywords", g.UpdateEmailKeywords)
r.Post("/{emailid}/keywords", g.AddEmailKeywords)
r.Delete("/{emailid}/keywords", g.RemoveEmailKeywords)
r.Delete("/{emailid}", g.DeleteEmail)
Report(r, "/{emailid}", g.RelatedToEmail)
r.Get("/{emailid}/related", g.RelatedToEmail)
r.Get("/{emailid}/attachments", g.GetEmailAttachments) // ?partId=&name=?&blobId=?
r.Route("/{emailid}", func(r chi.Router) {
r.Get("/", g.GetEmailsById) // Accept:message/rfc822
r.Put("/", g.ReplaceEmail)
r.Post("/", g.SendEmail)
r.Patch("/", g.UpdateEmail)
r.Delete("/", g.DeleteEmail)
Report(r, "/", g.RelatedToEmail)
r.Route("/related", func(r chi.Router) {
r.Get("/", g.RelatedToEmail)
})
r.Route("/keywords", func(r chi.Router) {
r.Patch("/", g.UpdateEmailKeywords)
r.Post("/", g.AddEmailKeywords)
r.Delete("/", g.RemoveEmailKeywords)
})
r.Route("/attachments", func(r chi.Router) {
r.Get("/", g.GetEmailAttachments) // ?partId=&name=?&blobId=?
})
})
})
r.Route("/blobs", func(r chi.Router) {
r.Get("/{blobid}", g.GetBlobMeta)
@@ -121,8 +139,10 @@ func (g *Groupware) Route(r chi.Router) {
})
r.Route("/addressbooks", func(r chi.Router) {
r.Get("/", g.GetAddressbooks)
r.Get("/{addressbookid}", g.GetAddressbook)
r.Get("/{addressbookid}/contacts", g.GetContactsInAddressbook)
r.Route("/{addressbookid}", func(r chi.Router) {
r.Get("/", g.GetAddressbook)
r.Get("/contacts", g.GetContactsInAddressbook)
})
r.Route("/contacts", func(r chi.Router) {
r.Post("/", g.CreateContact)
r.Delete("/{contactid}", g.DeleteContact)
@@ -130,13 +150,17 @@ func (g *Groupware) Route(r chi.Router) {
})
r.Route("/calendars", func(r chi.Router) {
r.Get("/", g.GetCalendars)
r.Get("/{calendarid}", g.GetCalendarById)
r.Get("/{calendarid}/events", g.GetEventsInCalendar)
r.Route("/{calendarid}", func(r chi.Router) {
r.Get("/", g.GetCalendarById)
r.Get("/events", g.GetEventsInCalendar)
})
})
r.Route("/tasklists", func(r chi.Router) {
r.Get("/", g.GetTaskLists)
r.Get("/{tasklistid}", g.GetTaskListById)
r.Get("/{tasklistid}/tasks", g.GetTasksInTaskList)
r.Route("/{tasklistid}", func(r chi.Router) {
r.Get("/", g.GetTaskListById)
r.Get("/tasks", g.GetTasksInTaskList)
})
})
})
})