Files
opencloud/services/groupware/pkg/groupware/groupware_api_emails.go

1924 lines
61 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package groupware
import (
"context"
"fmt"
"io"
"mime"
"net/http"
"slices"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/microcosm-cc/bluemonday"
"github.com/rs/zerolog"
"github.com/opencloud-eu/opencloud/pkg/jmap"
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/opencloud/pkg/structs"
"github.com/opencloud-eu/opencloud/services/groupware/pkg/metrics"
)
// When the request succeeds without a "since" query parameter.
// swagger:response GetAllEmailsInMailbox200
type SwaggerGetAllEmailsInMailbox200 struct {
// in: body
Body struct {
*jmap.Emails
}
}
// When the request succeeds with a "since" query parameter.
// swagger:response GetAllEmailsInMailboxSince200
type SwaggerGetAllEmailsInMailboxSince200 struct {
// in: body
Body struct {
*jmap.MailboxChanges
}
}
// swagger:route GET /groupware/accounts/{account}/mailboxes/{mailbox}/emails email get_all_emails_in_mailbox
// Get all the emails in a mailbox.
//
// Retrieve the list of all the emails that are in a given mailbox.
//
// The mailbox must be specified by its id, as part of the request URL path.
//
// A limit and an offset may be specified using the query parameters 'limit' and 'offset',
// respectively.
//
// When the query parameter 'since' or the 'if-none-match' header is specified, then the
// request behaves differently, performing a changes query to determine what has changed in
// that mailbox since a given state identifier.
//
// responses:
//
// 200: GetAllEmailsInMailbox200
// 200: GetAllEmailsInMailboxSince200
// 400: ErrorResponse400
// 404: ErrorResponse404
// 500: ErrorResponse500
func (g *Groupware) GetAllEmailsInMailbox(w http.ResponseWriter, r *http.Request) {
mailboxId := chi.URLParam(r, UriParamMailboxId)
since := r.Header.Get(HeaderSince)
if since != "" {
// ... then it's a completely different operation
maxChanges := uint(0)
g.respond(w, r, func(req Request) Response {
accountId, err := req.GetAccountIdForMail()
if err != nil {
return errorResponse(accountId, err)
}
if mailboxId == "" {
return req.parameterErrorResponse(accountId, UriParamMailboxId, fmt.Sprintf("Missing required mailbox ID path parameter '%v'", UriParamMailboxId))
}
logger := log.From(req.logger.With().Str(HeaderSince, log.SafeString(since)).Str(logAccountId, log.SafeString(accountId)))
changes, sessionState, state, lang, jerr := g.jmap.GetMailboxChanges(accountId, req.session, req.ctx, logger, req.language(), mailboxId, since, true, g.maxBodyValueBytes, maxChanges)
if jerr != nil {
return req.errorResponseFromJmap(accountId, jerr)
}
return etagResponse(accountId, changes, sessionState, EmailResponseObjectType, state, lang)
})
} else {
g.respond(w, r, func(req Request) Response {
l := req.logger.With()
accountId, err := req.GetAccountIdForMail()
if err != nil {
return errorResponse(accountId, err)
}
l = l.Str(logAccountId, accountId)
if mailboxId == "" {
return req.parameterErrorResponse(accountId, UriParamMailboxId, fmt.Sprintf("Missing required mailbox ID path parameter '%v'", UriParamMailboxId))
}
offset, ok, err := req.parseUIntParam(QueryParamOffset, 0)
if err != nil {
return errorResponse(accountId, err)
}
if ok {
l = l.Uint(QueryParamOffset, offset)
}
limit, ok, err := req.parseUIntParam(QueryParamLimit, g.defaultEmailLimit)
if err != nil {
return errorResponse(accountId, err)
}
if ok {
l = l.Uint(QueryParamLimit, limit)
}
logger := log.From(l)
collapseThreads := false
fetchBodies := false
withThreads := true
emails, sessionState, state, lang, jerr := g.jmap.GetAllEmailsInMailbox(accountId, req.session, req.ctx, logger, req.language(), mailboxId, offset, limit, collapseThreads, fetchBodies, g.maxBodyValueBytes, withThreads)
if jerr != nil {
return req.errorResponseFromJmap(accountId, jerr)
}
sanitized, err := req.sanitizeEmails(emails.Emails)
if err != nil {
return errorResponseWithSessionState(accountId, err, sessionState)
}
safe := jmap.Emails{
Emails: sanitized,
Total: emails.Total,
Limit: emails.Limit,
Offset: emails.Offset,
}
return etagResponse(accountId, safe, sessionState, EmailResponseObjectType, state, lang)
})
}
}
func (g *Groupware) GetEmailsById(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, UriParamEmailId)
ids := strings.Split(id, ",")
accept := r.Header.Get("Accept")
if accept == "message/rfc822" {
g.stream(w, r, func(req Request, w http.ResponseWriter) *Error {
if len(ids) != 1 {
return req.parameterError(UriParamEmailId, fmt.Sprintf("when the Accept header is set to '%s', the API only supports serving a single email id", accept))
}
accountId, err := req.GetAccountIdForMail()
if err != nil {
return err
}
_, ok, err := req.parseBoolParam(QueryParamMarkAsSeen, false)
if err != nil {
return err
}
if ok {
return req.parameterError(QueryParamMarkAsSeen, fmt.Sprintf("when the Accept header is set to '%s', the API does not support setting %s", accept, QueryParamMarkAsSeen))
}
logger := log.From(req.logger.With().Str(logAccountId, log.SafeString(accountId)).Str("id", log.SafeString(id)).Str("accept", log.SafeString(accept)))
blobId, _, _, _, jerr := g.jmap.GetEmailBlobId(accountId, req.session, req.ctx, logger, req.language(), id)
if jerr != nil {
return req.apiErrorFromJmap(req.observeJmapError(jerr))
}
if blobId == "" {
return nil
} else {
name := blobId + ".eml"
typ := accept
accountId, gwerr := req.GetAccountIdForBlob()
if gwerr != nil {
return gwerr
}
return req.serveBlob(blobId, name, typ, logger, accountId, w)
}
})
} else {
g.respond(w, r, func(req Request) Response {
accountId, err := req.GetAccountIdForMail()
if err != nil {
return errorResponse(accountId, err)
}
l := req.logger.With().Str(logAccountId, log.SafeString(accountId))
if len(ids) < 1 {
return req.parameterErrorResponse(accountId, UriParamEmailId, fmt.Sprintf("Invalid value for path parameter '%v': '%s': %s", UriParamEmailId, log.SafeString(id), "empty list of mail ids"))
}
markAsSeen, ok, err := req.parseBoolParam(QueryParamMarkAsSeen, false)
if err != nil {
return errorResponse(accountId, err)
}
if ok {
l = l.Bool(QueryParamMarkAsSeen, markAsSeen)
}
if len(ids) == 1 {
logger := log.From(l.Str("id", log.SafeString(id)))
emails, sessionState, state, lang, jerr := g.jmap.GetEmails(accountId, req.session, req.ctx, logger, req.language(), ids, true, g.maxBodyValueBytes, markAsSeen, true)
if jerr != nil {
return req.errorResponseFromJmap(accountId, jerr)
}
if len(emails) < 1 {
return notFoundResponse(accountId, sessionState)
} else {
sanitized, err := req.sanitizeEmail(emails[0])
if err != nil {
return errorResponseWithSessionState(accountId, err, sessionState)
}
return etagResponse(accountId, sanitized, sessionState, EmailResponseObjectType, state, lang)
}
} else {
logger := log.From(l.Array("ids", log.SafeStringArray(ids)))
emails, sessionState, state, lang, jerr := g.jmap.GetEmails(accountId, req.session, req.ctx, logger, req.language(), ids, true, g.maxBodyValueBytes, markAsSeen, false)
if jerr != nil {
return req.errorResponseFromJmap(accountId, jerr)
}
if len(emails) < 1 {
return notFoundResponse(accountId, sessionState)
} else {
sanitized, err := req.sanitizeEmails(emails)
if err != nil {
return errorResponseWithSessionState(accountId, err, sessionState)
}
return etagResponse(accountId, sanitized, sessionState, EmailResponseObjectType, state, lang)
}
}
})
}
}
func (g *Groupware) GetEmailAttachments(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, UriParamEmailId)
contextAppender := func(l zerolog.Context) zerolog.Context { return l }
q := r.URL.Query()
var attachmentSelector func(jmap.EmailBodyPart) bool = nil
if q.Has(QueryParamPartId) {
partId := q.Get(QueryParamPartId)
attachmentSelector = func(part jmap.EmailBodyPart) bool { return part.PartId == partId }
contextAppender = func(l zerolog.Context) zerolog.Context { return l.Str(QueryParamPartId, log.SafeString(partId)) }
}
if q.Has(QueryParamAttachmentName) {
name := q.Get(QueryParamAttachmentName)
attachmentSelector = func(part jmap.EmailBodyPart) bool { return part.Name == name }
contextAppender = func(l zerolog.Context) zerolog.Context { return l.Str(QueryParamAttachmentName, log.SafeString(name)) }
}
if q.Has(QueryParamAttachmentBlobId) {
blobId := q.Get(QueryParamAttachmentBlobId)
attachmentSelector = func(part jmap.EmailBodyPart) bool { return part.BlobId == blobId }
contextAppender = func(l zerolog.Context) zerolog.Context {
return l.Str(QueryParamAttachmentBlobId, log.SafeString(blobId))
}
}
if attachmentSelector == nil {
g.respond(w, r, func(req Request) Response {
accountId, err := req.GetAccountIdForMail()
if err != nil {
return errorResponse(accountId, err)
}
l := req.logger.With().Str(logAccountId, log.SafeString(accountId))
logger := log.From(l)
emails, sessionState, state, lang, jerr := g.jmap.GetEmails(accountId, req.session, req.ctx, logger, req.language(), []string{id}, false, 0, false, false)
if jerr != nil {
return req.errorResponseFromJmap(accountId, jerr)
}
if len(emails) < 1 {
return notFoundResponse(accountId, sessionState)
}
email, err := req.sanitizeEmail(emails[0])
if err != nil {
return errorResponseWithSessionState(accountId, err, sessionState)
}
return etagResponse(accountId, email.Attachments, sessionState, EmailResponseObjectType, state, lang)
})
} else {
g.stream(w, r, func(req Request, w http.ResponseWriter) *Error {
mailAccountId, gwerr := req.GetAccountIdForMail()
if gwerr != nil {
return gwerr
}
blobAccountId, gwerr := req.GetAccountIdForBlob()
if gwerr != nil {
return gwerr
}
l := req.logger.With().Str(logAccountId, log.SafeString(mailAccountId)).Str(logBlobAccountId, log.SafeString(blobAccountId))
l = contextAppender(l)
logger := log.From(l)
emails, _, _, lang, jerr := g.jmap.GetEmails(mailAccountId, req.session, req.ctx, logger, req.language(), []string{id}, false, 0, false, false)
if jerr != nil {
return req.apiErrorFromJmap(req.observeJmapError(jerr))
}
if len(emails) < 1 {
return nil
}
email, err := req.sanitizeEmail(emails[0])
if err != nil {
return err
}
var attachment *jmap.EmailBodyPart = nil
for _, part := range email.Attachments {
if attachmentSelector(part) {
attachment = &part
break
}
}
if attachment == nil {
return nil
}
blob, lang, jerr := g.jmap.DownloadBlobStream(blobAccountId, attachment.BlobId, attachment.Name, attachment.Type, req.session, req.ctx, logger, req.language())
if blob != nil && blob.Body != nil {
defer func(Body io.ReadCloser) {
err := Body.Close()
if err != nil {
logger.Error().Err(err).Msg("failed to close response body")
}
}(blob.Body)
}
if jerr != nil {
return req.apiErrorFromJmap(jerr)
}
if blob == nil {
w.WriteHeader(http.StatusNotFound)
return nil
}
if blob.Type != "" {
w.Header().Add("Content-Type", blob.Type)
}
if blob.CacheControl != "" {
w.Header().Add("Cache-Control", blob.CacheControl)
}
if blob.ContentDisposition != "" {
w.Header().Add("Content-Disposition", blob.ContentDisposition)
}
if blob.Size >= 0 {
w.Header().Add("Content-Size", strconv.Itoa(blob.Size))
}
if lang != "" {
w.Header().Add("Content-Language", string(lang))
}
_, cerr := io.Copy(w, blob.Body)
if cerr != nil {
return req.observedParameterError(ErrorStreamingResponse)
}
return nil
})
}
}
func (g *Groupware) getEmailsSince(w http.ResponseWriter, r *http.Request, since string) {
g.respond(w, r, func(req Request) Response {
l := req.logger.With().Str(QueryParamSince, log.SafeString(since))
accountId, err := req.GetAccountIdForMail()
if err != nil {
return errorResponse(accountId, err)
}
l = l.Str(logAccountId, log.SafeString(accountId))
maxChanges, ok, err := req.parseUIntParam(QueryParamMaxChanges, 0)
if err != nil {
return errorResponse(accountId, err)
}
if ok {
l = l.Uint(QueryParamMaxChanges, maxChanges)
}
logger := log.From(l)
changes, sessionState, state, lang, jerr := g.jmap.GetEmailsSince(accountId, req.session, req.ctx, logger, req.language(), since, true, g.maxBodyValueBytes, maxChanges)
if jerr != nil {
return req.errorResponseFromJmap(accountId, jerr)
}
return etagResponse(accountId, changes, sessionState, EmailResponseObjectType, state, lang)
})
}
type EmailSearchSnippetsResults struct {
Results []Snippet `json:"results,omitempty"`
Total uint `json:"total,omitzero"`
Limit uint `json:"limit,omitzero"`
QueryState jmap.State `json:"queryState,omitempty"`
}
type EmailWithSnippets struct {
AccountId string `json:"accountId,omitempty"`
jmap.Email
Snippets []SnippetWithoutEmailId `json:"snippets,omitempty"`
}
type Snippet struct {
AccountId string `json:"accountId,omitempty"`
jmap.SearchSnippetWithMeta
}
type SnippetWithoutEmailId struct {
Subject string `json:"subject,omitempty"`
Preview string `json:"preview,omitempty"`
}
type EmailWithSnippetsSearchResults struct {
Results []EmailWithSnippets `json:"results"`
Total uint `json:"total,omitzero"`
Limit uint `json:"limit,omitzero"`
QueryState jmap.State `json:"queryState,omitempty"`
}
type EmailSearchResults struct {
Results []jmap.Email `json:"results"`
Total uint `json:"total,omitzero"`
Limit uint `json:"limit,omitzero"`
QueryState jmap.State `json:"queryState,omitempty"`
}
func (g *Groupware) buildFilter(req Request) (bool, jmap.EmailFilterElement, bool, uint, uint, *log.Logger, *Error) {
q := req.r.URL.Query()
mailboxId := q.Get(QueryParamMailboxId)
notInMailboxIds := q[QueryParamNotInMailboxId]
text := q.Get(QueryParamSearchText)
from := q.Get(QueryParamSearchFrom)
to := q.Get(QueryParamSearchTo)
cc := q.Get(QueryParamSearchCc)
bcc := q.Get(QueryParamSearchBcc)
subject := q.Get(QueryParamSearchSubject)
body := q.Get(QueryParamSearchBody)
keywords := q[QueryParamSearchKeyword]
messageId := q.Get(QueryParamSearchMessageId)
snippets := false
l := req.logger.With()
offset, ok, err := req.parseUIntParam(QueryParamOffset, 0)
if err != nil {
return false, nil, snippets, 0, 0, nil, err
}
if ok {
l = l.Uint(QueryParamOffset, offset)
}
limit, ok, err := req.parseUIntParam(QueryParamLimit, g.defaultEmailLimit)
if err != nil {
return false, nil, snippets, 0, 0, nil, err
}
if ok {
l = l.Uint(QueryParamLimit, limit)
}
before, ok, err := req.parseDateParam(QueryParamSearchBefore)
if err != nil {
return false, nil, snippets, 0, 0, nil, err
}
if ok {
l = l.Time(QueryParamSearchBefore, before)
}
after, ok, err := req.parseDateParam(QueryParamSearchAfter)
if err != nil {
return false, nil, snippets, 0, 0, nil, err
}
if ok {
l = l.Time(QueryParamSearchAfter, after)
}
if mailboxId != "" {
l = l.Str(QueryParamMailboxId, log.SafeString(mailboxId))
}
if len(notInMailboxIds) > 0 {
l = l.Array(QueryParamNotInMailboxId, log.SafeStringArray(notInMailboxIds))
}
if text != "" {
l = l.Str(QueryParamSearchText, log.SafeString(text))
}
if from != "" {
l = l.Str(QueryParamSearchFrom, log.SafeString(from))
}
if to != "" {
l = l.Str(QueryParamSearchTo, log.SafeString(to))
}
if cc != "" {
l = l.Str(QueryParamSearchCc, log.SafeString(cc))
}
if bcc != "" {
l = l.Str(QueryParamSearchBcc, log.SafeString(bcc))
}
if subject != "" {
l = l.Str(QueryParamSearchSubject, log.SafeString(subject))
}
if body != "" {
l = l.Str(QueryParamSearchBody, log.SafeString(body))
}
if messageId != "" {
l = l.Str(QueryParamSearchMessageId, log.SafeString(messageId))
}
minSize, ok, err := req.parseIntParam(QueryParamSearchMinSize, 0)
if err != nil {
return false, nil, snippets, 0, 0, nil, err
}
if ok {
l = l.Int(QueryParamSearchMinSize, minSize)
}
maxSize, ok, err := req.parseIntParam(QueryParamSearchMaxSize, 0)
if err != nil {
return false, nil, snippets, 0, 0, nil, err
}
if ok {
l = l.Int(QueryParamSearchMaxSize, maxSize)
}
logger := log.From(l)
var filter jmap.EmailFilterElement
firstFilter := jmap.EmailFilterCondition{
Text: text,
InMailbox: mailboxId,
InMailboxOtherThan: notInMailboxIds,
From: from,
To: to,
Cc: cc,
Bcc: bcc,
Subject: subject,
Body: body,
Before: before,
After: after,
MinSize: minSize,
MaxSize: maxSize,
Header: []string{},
}
if messageId != "" {
// The array MUST contain either one or two elements.
// The first element is the name of the header field to match against.
// The second (optional) element is the text to look for in the header field value.
// If not supplied, the message matches simply if it has a header field of the given name.
firstFilter.Header = []string{"Message-ID", messageId}
}
filter = &firstFilter
if text != "" || subject != "" || body != "" {
snippets = true
}
if len(keywords) > 0 {
firstFilter.HasKeyword = keywords[0]
if len(keywords) > 1 {
firstFilter.HasKeyword = keywords[0]
filters := make([]jmap.EmailFilterElement, len(keywords)-1)
for i, keyword := range keywords[1:] {
filters[i] = jmap.EmailFilterCondition{HasKeyword: keyword}
}
filter = &jmap.EmailFilterOperator{
Operator: jmap.And,
Conditions: filters,
}
}
}
return true, filter, snippets, offset, limit, logger, nil
}
func (g *Groupware) searchEmails(w http.ResponseWriter, r *http.Request) {
g.respond(w, r, func(req Request) Response {
accountId, err := req.GetAccountIdForMail()
if err != nil {
return errorResponse(accountId, err)
}
ok, filter, makesSnippets, offset, limit, logger, err := g.buildFilter(req)
if !ok {
return errorResponse(accountId, err)
}
logger = log.From(req.logger.With().Str(logAccountId, log.SafeString(accountId)))
if !filter.IsNotEmpty() {
filter = nil
}
fetchEmails, ok, err := req.parseBoolParam(QueryParamSearchFetchEmails, false)
if err != nil {
return errorResponse(accountId, err)
}
if ok {
logger = log.From(logger.With().Bool(QueryParamSearchFetchEmails, fetchEmails))
}
if fetchEmails {
fetchBodies, ok, err := req.parseBoolParam(QueryParamSearchFetchBodies, false)
if err != nil {
return errorResponse(accountId, err)
}
if ok {
logger = log.From(logger.With().Bool(QueryParamSearchFetchBodies, fetchBodies))
}
resultsByAccount, sessionState, state, lang, jerr := g.jmap.QueryEmailsWithSnippets([]string{accountId}, filter, req.session, req.ctx, logger, req.language(), offset, limit, fetchBodies, g.maxBodyValueBytes)
if jerr != nil {
return req.errorResponseFromJmap(accountId, jerr)
}
if results, ok := resultsByAccount[accountId]; ok {
flattened := make([]EmailWithSnippets, len(results.Results))
for i, result := range results.Results {
var snippets []SnippetWithoutEmailId
if makesSnippets {
snippets := make([]SnippetWithoutEmailId, len(result.Snippets))
for j, snippet := range result.Snippets {
snippets[j] = SnippetWithoutEmailId{
Subject: snippet.Subject,
Preview: snippet.Preview,
}
}
} else {
snippets = nil
}
sanitized, err := req.sanitizeEmail(result.Email)
if err != nil {
return errorResponseWithSessionState(accountId, err, sessionState)
}
flattened[i] = EmailWithSnippets{
Email: sanitized,
Snippets: snippets,
}
}
return etagResponse(accountId, EmailWithSnippetsSearchResults{
Results: flattened,
Total: results.Total,
Limit: results.Limit,
QueryState: results.QueryState,
}, sessionState, EmailResponseObjectType, state, lang)
} else {
return notFoundResponse(accountId, sessionState)
}
} else {
resultsByAccountId, sessionState, state, lang, jerr := g.jmap.QueryEmailSnippets([]string{accountId}, filter, req.session, req.ctx, logger, req.language(), offset, limit)
if jerr != nil {
return req.errorResponseFromJmap(accountId, jerr)
}
if results, ok := resultsByAccountId[accountId]; ok {
return etagResponse(accountId, EmailSearchSnippetsResults{
Results: structs.Map(results.Snippets, func(s jmap.SearchSnippetWithMeta) Snippet { return Snippet{SearchSnippetWithMeta: s} }),
Total: results.Total,
Limit: results.Limit,
QueryState: results.QueryState,
}, sessionState, EmailResponseObjectType, state, lang)
} else {
return notFoundResponse(accountId, sessionState)
}
}
})
}
func (g *Groupware) GetEmails(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
since := q.Get(QueryParamSince)
if since == "" {
since = r.Header.Get(HeaderSince)
}
if since != "" {
// get email changes since a given state
g.getEmailsSince(w, r, since)
} else {
// do a search
g.searchEmails(w, r)
}
}
func (g *Groupware) GetEmailsForAllAccounts(w http.ResponseWriter, r *http.Request) {
g.respond(w, r, func(req Request) Response {
allAccountIds := req.AllAccountIds()
ok, filter, makesSnippets, offset, limit, logger, err := g.buildFilter(req)
if !ok {
return errorResponse(joinAccountIds(allAccountIds), err)
}
logger = log.From(req.logger.With().Array(logAccountId, log.SafeStringArray(allAccountIds)))
if !filter.IsNotEmpty() {
filter = nil
}
fetchEmails, ok, err := req.parseBoolParam(QueryParamSearchFetchEmails, false)
if err != nil {
return errorResponse(joinAccountIds(allAccountIds), err)
}
if ok {
logger = log.From(logger.With().Bool(QueryParamSearchFetchEmails, fetchEmails))
}
if fetchEmails {
fetchBodies, ok, err := req.parseBoolParam(QueryParamSearchFetchBodies, false)
if err != nil {
return errorResponse(joinAccountIds(allAccountIds), err)
}
if ok {
logger = log.From(logger.With().Bool(QueryParamSearchFetchBodies, fetchBodies))
}
if makesSnippets {
resultsByAccountId, sessionState, state, lang, jerr := g.jmap.QueryEmailsWithSnippets(allAccountIds, filter, req.session, req.ctx, logger, req.language(), offset, limit, fetchBodies, g.maxBodyValueBytes)
if jerr != nil {
return req.errorResponseFromJmap(joinAccountIds(allAccountIds), jerr)
}
flattenedByAccountId := make(map[string][]EmailWithSnippets, len(resultsByAccountId))
total := 0
var totalOverAllAccounts uint = 0
for accountId, results := range resultsByAccountId {
totalOverAllAccounts += results.Total
flattened := make([]EmailWithSnippets, len(results.Results))
for i, result := range results.Results {
snippets := structs.MapN(result.Snippets, func(s jmap.SearchSnippet) *SnippetWithoutEmailId {
if s.Subject != "" || s.Preview != "" {
return &SnippetWithoutEmailId{
Subject: s.Subject,
Preview: s.Preview,
}
} else {
return nil
}
})
sanitized, err := req.sanitizeEmail(result.Email)
if err != nil {
return errorResponseWithSessionState(accountId, err, sessionState)
}
flattened[i] = EmailWithSnippets{
AccountId: accountId,
Email: sanitized,
Snippets: snippets,
}
}
flattenedByAccountId[accountId] = flattened
total += len(flattened)
}
flattened := make([]EmailWithSnippets, total)
{
i := 0
for _, list := range flattenedByAccountId {
for _, e := range list {
flattened[i] = e
i++
}
}
}
slices.SortFunc(flattened, func(a, b EmailWithSnippets) int { return a.ReceivedAt.Compare(b.ReceivedAt) })
// TODO offset and limit over the aggregated results by account
return etagResponse(joinAccountIds(allAccountIds), EmailWithSnippetsSearchResults{
Results: flattened,
Total: totalOverAllAccounts,
Limit: limit,
QueryState: state,
}, sessionState, EmailResponseObjectType, state, lang)
} else {
resultsByAccountId, sessionState, state, lang, jerr := g.jmap.QueryEmails(allAccountIds, filter, req.session, req.ctx, logger, req.language(), offset, limit, fetchBodies, g.maxBodyValueBytes)
if jerr != nil {
return req.errorResponseFromJmap(joinAccountIds(allAccountIds), jerr)
}
total := 0
var totalOverAllAccounts uint = 0
for _, results := range resultsByAccountId {
totalOverAllAccounts += results.Total
total += len(results.Emails)
}
flattened := make([]jmap.Email, total)
{
i := 0
for _, list := range resultsByAccountId {
for _, e := range list.Emails {
sanitized, err := req.sanitizeEmail(e)
if err != nil {
return errorResponseWithSessionState(joinAccountIds(allAccountIds), err, sessionState)
}
flattened[i] = sanitized
i++
}
}
}
slices.SortFunc(flattened, func(a, b jmap.Email) int { return a.ReceivedAt.Compare(b.ReceivedAt) })
// TODO offset and limit over the aggregated results by account
return etagResponse(joinAccountIds(allAccountIds), EmailSearchResults{
Results: flattened,
Total: totalOverAllAccounts,
Limit: limit,
QueryState: state,
}, sessionState, EmailResponseObjectType, state, lang)
}
} else {
if makesSnippets {
resultsByAccountId, sessionState, state, lang, jerr := g.jmap.QueryEmailSnippets(allAccountIds, filter, req.session, req.ctx, logger, req.language(), offset, limit)
if jerr != nil {
return req.errorResponseFromJmap(joinAccountIds(allAccountIds), jerr)
}
var totalOverAllAccounts uint = 0
total := 0
for _, results := range resultsByAccountId {
totalOverAllAccounts += results.Total
total += len(results.Snippets)
}
flattened := make([]Snippet, total)
{
i := 0
for accountId, results := range resultsByAccountId {
for _, result := range results.Snippets {
flattened[i] = Snippet{
AccountId: accountId,
SearchSnippetWithMeta: result,
}
}
}
}
slices.SortFunc(flattened, func(a, b Snippet) int { return a.ReceivedAt.Compare(b.ReceivedAt) })
// TODO offset and limit over the aggregated results by account
return etagResponse(joinAccountIds(allAccountIds), EmailSearchSnippetsResults{
Results: flattened,
Total: totalOverAllAccounts,
Limit: limit,
QueryState: state,
}, sessionState, EmailResponseObjectType, state, lang)
} else {
// TODO implement search without email bodies (only retrieve a few chosen properties?) + without snippets
return notImplementesResponse()
}
}
})
}
func (g *Groupware) CreateEmail(w http.ResponseWriter, r *http.Request) {
g.respond(w, r, func(req Request) Response {
logger := req.logger
accountId, gwerr := req.GetAccountIdForMail()
if gwerr != nil {
return errorResponse(accountId, gwerr)
}
logger = log.From(logger.With().Str(logAccountId, log.SafeString(accountId)))
var body jmap.EmailCreate
err := req.body(&body)
if err != nil {
return errorResponse(accountId, err)
}
created, sessionState, state, lang, jerr := g.jmap.CreateEmail(accountId, body, "", req.session, req.ctx, logger, req.language())
if jerr != nil {
return req.errorResponseFromJmap(accountId, jerr)
}
return etagResponse(accountId, created, sessionState, EmailResponseObjectType, state, lang)
})
}
func (g *Groupware) ReplaceEmail(w http.ResponseWriter, r *http.Request) {
g.respond(w, r, func(req Request) Response {
logger := req.logger
accountId, gwerr := req.GetAccountIdForMail()
if gwerr != nil {
return errorResponse(accountId, gwerr)
}
replaceId := chi.URLParam(r, UriParamEmailId)
logger = log.From(logger.With().Str(logAccountId, log.SafeString(accountId)))
var body jmap.EmailCreate
err := req.body(&body)
if err != nil {
return errorResponse(accountId, err)
}
created, sessionState, state, lang, jerr := g.jmap.CreateEmail(accountId, body, replaceId, req.session, req.ctx, logger, req.language())
if jerr != nil {
return req.errorResponseFromJmap(accountId, jerr)
}
return etagResponse(accountId, created, sessionState, EmailResponseObjectType, state, lang)
})
}
// swagger:parameters update_email
type SwaggerUpdateEmailBody struct {
// List of identifiers of emails to delete.
// in: body
// example: ["caen3iujoo8u", "aec8phaetaiz", "bohna0me"]
Body map[string]string
}
func (g *Groupware) UpdateEmail(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))
accountId, gwerr := req.GetAccountIdForMail()
if gwerr != nil {
return errorResponse(accountId, gwerr)
}
l.Str(logAccountId, accountId)
logger := log.From(l)
var body map[string]any
err := req.body(&body)
if err != nil {
return errorResponse(accountId, err)
}
updates := map[string]jmap.EmailUpdate{
emailId: body,
}
result, sessionState, state, lang, jerr := g.jmap.UpdateEmails(accountId, updates, req.session, req.ctx, logger, req.language())
if jerr != nil {
return req.errorResponseFromJmap(accountId, jerr)
}
if result == nil {
return errorResponse(accountId, apiError(req.errorId(), ErrorApiInconsistency, withTitle("API Inconsistency: Missing Email Update Response",
"An internal API behaved unexpectedly: missing Email update response from JMAP endpoint")))
}
updatedEmail, ok := result[emailId]
if !ok {
return errorResponse(accountId, apiError(req.errorId(), ErrorApiInconsistency, withTitle("API Inconsistency: Wrong Email Update Response ID",
"An internal API behaved unexpectedly: wrong Email update ID response from JMAP endpoint")))
}
return etagResponse(accountId, updatedEmail, sessionState, EmailResponseObjectType, state, lang)
})
}
type emailKeywordUpdates struct {
Add []string `json:"add,omitempty"`
Remove []string `json:"remove,omitempty"`
}
func (e emailKeywordUpdates) IsEmpty() bool {
return len(e.Add) == 0 && len(e.Remove) == 0
}
func (g *Groupware) UpdateEmailKeywords(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))
accountId, gwerr := req.GetAccountIdForMail()
if gwerr != nil {
return errorResponse(accountId, gwerr)
}
l.Str(logAccountId, accountId)
logger := log.From(l)
var body emailKeywordUpdates
err := req.body(&body)
if err != nil {
return errorResponse(accountId, err)
}
if body.IsEmpty() {
return noContentResponse(accountId, req.session.State)
}
patch := jmap.EmailUpdate{}
for _, keyword := range body.Add {
patch["keywords/"+keyword] = true
}
for _, keyword := range body.Remove {
patch["keywords/"+keyword] = nil
}
patches := map[string]jmap.EmailUpdate{
emailId: patch,
}
result, sessionState, state, lang, jerr := g.jmap.UpdateEmails(accountId, patches, req.session, req.ctx, logger, req.language())
if jerr != nil {
return req.errorResponseFromJmap(accountId, jerr)
}
if result == nil {
return errorResponse(accountId, apiError(req.errorId(), ErrorApiInconsistency, withTitle("API Inconsistency: Missing Email Update Response",
"An internal API behaved unexpectedly: missing Email update response from JMAP endpoint")))
}
updatedEmail, ok := result[emailId]
if !ok {
return errorResponse(accountId, apiError(req.errorId(), ErrorApiInconsistency, withTitle("API Inconsistency: Wrong Email Update Response ID",
"An internal API behaved unexpectedly: wrong Email update ID response from JMAP endpoint")))
}
return etagResponse(accountId, updatedEmail, sessionState, EmailResponseObjectType, state, lang)
})
}
// swagger:route POST /groupware/accounts/{account}/emails/{emailid}/keywords email add_email_keywords
// Add keywords to an email by its unique identifier.
//
// responses:
//
// 204: Success204
// 400: ErrorResponse400
// 404: ErrorResponse404
// 500: ErrorResponse500
func (g *Groupware) AddEmailKeywords(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))
accountId, gwerr := req.GetAccountIdForMail()
if gwerr != nil {
return errorResponse(accountId, gwerr)
}
l.Str(logAccountId, accountId)
logger := log.From(l)
var body []string
err := req.body(&body)
if err != nil {
return errorResponse(accountId, err)
}
if len(body) < 1 {
return noContentResponse(accountId, req.session.State)
}
patch := jmap.EmailUpdate{}
for _, keyword := range body {
patch["keywords/"+keyword] = true
}
patches := map[string]jmap.EmailUpdate{
emailId: patch,
}
result, sessionState, state, lang, jerr := g.jmap.UpdateEmails(accountId, patches, req.session, req.ctx, logger, req.language())
if jerr != nil {
return req.errorResponseFromJmap(accountId, jerr)
}
if result == nil {
return errorResponse(accountId, apiError(req.errorId(), ErrorApiInconsistency, withTitle("API Inconsistency: Missing Email Update Response",
"An internal API behaved unexpectedly: missing Email update response from JMAP endpoint")))
}
updatedEmail, ok := result[emailId]
if !ok {
return errorResponse(accountId, apiError(req.errorId(), ErrorApiInconsistency, withTitle("API Inconsistency: Wrong Email Update Response ID",
"An internal API behaved unexpectedly: wrong Email update ID response from JMAP endpoint")))
}
if updatedEmail == nil {
return noContentResponseWithEtag(accountId, sessionState, EmailResponseObjectType, state)
} else {
return etagResponse(accountId, updatedEmail, sessionState, EmailResponseObjectType, state, lang)
}
})
}
// swagger:route DELETE /groupware/accounts/{account}/emails/{emailid}/keywords email remove_email_keywords
// Remove keywords of an email by its unique identifier.
//
// responses:
//
// 204: Success204
// 400: ErrorResponse400
// 404: ErrorResponse404
// 500: ErrorResponse500
func (g *Groupware) RemoveEmailKeywords(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))
accountId, gwerr := req.GetAccountIdForMail()
if gwerr != nil {
return errorResponse(accountId, gwerr)
}
l.Str(logAccountId, accountId)
logger := log.From(l)
var body []string
err := req.body(&body)
if err != nil {
return errorResponse(accountId, err)
}
if len(body) < 1 {
return noContentResponse(accountId, req.session.State)
}
patch := jmap.EmailUpdate{}
for _, keyword := range body {
patch["keywords/"+keyword] = nil
}
patches := map[string]jmap.EmailUpdate{
emailId: patch,
}
result, sessionState, state, lang, jerr := g.jmap.UpdateEmails(accountId, patches, req.session, req.ctx, logger, req.language())
if jerr != nil {
return req.errorResponseFromJmap(accountId, jerr)
}
if result == nil {
return errorResponse(accountId, apiError(req.errorId(), ErrorApiInconsistency, withTitle("API Inconsistency: Missing Email Update Response",
"An internal API behaved unexpectedly: missing Email update response from JMAP endpoint")))
}
updatedEmail, ok := result[emailId]
if !ok {
return errorResponse(accountId, apiError(req.errorId(), ErrorApiInconsistency, withTitle("API Inconsistency: Wrong Email Update Response ID",
"An internal API behaved unexpectedly: wrong Email update ID response from JMAP endpoint")))
}
if updatedEmail == nil {
return noContentResponseWithEtag(accountId, sessionState, EmailResponseObjectType, state)
} else {
return etagResponse(accountId, updatedEmail, sessionState, EmailResponseObjectType, state, lang)
}
})
}
// swagger:route DELETE /groupware/accounts/{account}/emails/{emailid} email delete_email
// Delete an email by its unique identifier.
//
// responses:
//
// 204: Success204
// 400: ErrorResponse400
// 404: ErrorResponse404
// 500: ErrorResponse500
func (g *Groupware) DeleteEmail(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, emailId)
accountId, gwerr := req.GetAccountIdForMail()
if gwerr != nil {
return errorResponse(accountId, gwerr)
}
l.Str(logAccountId, accountId)
logger := log.From(l)
resp, sessionState, state, _, jerr := g.jmap.DeleteEmails(accountId, []string{emailId}, req.session, req.ctx, logger, req.language())
if jerr != nil {
return req.errorResponseFromJmap(accountId, jerr)
}
for _, e := range resp {
desc := e.Description
if desc != "" {
return errorResponseWithSessionState(accountId, apiError(
req.errorId(),
ErrorFailedToDeleteEmail,
withDetail(e.Description),
), sessionState)
} else {
return errorResponseWithSessionState(accountId, apiError(
req.errorId(),
ErrorFailedToDeleteEmail,
), sessionState)
}
}
return noContentResponseWithEtag(accountId, sessionState, EmailResponseObjectType, state)
})
}
// swagger:parameters delete_emails
type SwaggerDeleteEmailsBody struct {
// List of identifiers of emails to delete.
// in: body
// example: ["caen3iujoo8u", "aec8phaetaiz", "bohna0me"]
Body []string
}
// swagger:route DELETE /groupware/accounts/{account}/emails email delete_emails
// Delete a set of emails by their unique identifiers.
//
// The identifiers of the emails to delete are specified as part of the request
// body, as an array of strings.
//
// responses:
//
// 204: Success204
// 400: ErrorResponse400
// 404: ErrorResponse404
// 500: ErrorResponse500
func (g *Groupware) DeleteEmails(w http.ResponseWriter, r *http.Request) {
g.respond(w, r, func(req Request) Response {
l := req.logger.With()
accountId, gwerr := req.GetAccountIdForMail()
if gwerr != nil {
return errorResponse(accountId, gwerr)
}
l.Str(logAccountId, accountId)
var emailIds []string
err := req.body(&emailIds)
if err != nil {
return errorResponse(accountId, err)
}
l.Array("emailIds", log.SafeStringArray(emailIds))
logger := log.From(l)
resp, sessionState, state, _, jerr := g.jmap.DeleteEmails(accountId, emailIds, req.session, req.ctx, logger, req.language())
if jerr != nil {
return req.errorResponseFromJmap(accountId, jerr)
}
if len(resp) > 0 {
meta := make(map[string]any, len(resp))
for emailId, e := range resp {
meta[emailId] = e.Description
}
return errorResponseWithSessionState(accountId, apiError(
req.errorId(),
ErrorFailedToDeleteEmail,
withMeta(meta),
), sessionState)
}
return noContentResponseWithEtag(accountId, sessionState, EmailResponseObjectType, state)
})
}
func (g *Groupware) SendEmail(w http.ResponseWriter, r *http.Request) {
g.respond(w, r, func(req Request) Response {
l := req.logger.With()
accountId, gwerr := req.GetAccountIdForMail()
if gwerr != nil {
return errorResponse(accountId, gwerr)
}
l.Str(logAccountId, accountId)
emailId := chi.URLParam(r, UriParamEmailId)
l.Str(UriParamEmailId, log.SafeString(emailId))
identityId, err := req.getMandatoryStringParam(QueryParamIdentityId)
if err != nil {
return errorResponse(accountId, 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(accountId, req.observedParameterError(ErrorMissingMandatoryRequestParameter,
withDetail(msg),
withSource(&ErrorSource{Parameter: missing})))
}
}
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(accountId, jerr)
}
return etagResponse(accountId, resp, sessionState, EmailResponseObjectType, state, lang)
})
}
type AboutEmailsEvent struct {
Id string `json:"id"`
Source string `json:"source"`
Emails []jmap.Email `json:"emails"`
Language jmap.Language `json:"lang"`
}
type AboutEmailResponse struct {
Email jmap.Email `json:"email"`
RequestId string `json:"requestId"`
Language jmap.Language `json:"lang"`
}
func relatedEmailsFilter(email jmap.Email, beacon time.Time, days uint) jmap.EmailFilterElement {
filters := []jmap.EmailFilterElement{}
for _, from := range email.From {
if from.Email != "" {
filters = append(filters, jmap.EmailFilterCondition{From: from.Email})
}
}
for _, sender := range email.Sender {
if sender.Email != "" {
filters = append(filters, jmap.EmailFilterCondition{From: sender.Email})
}
}
timeFilter := jmap.EmailFilterCondition{
Before: beacon.Add(time.Duration(days) * time.Hour * 24),
After: beacon.Add(time.Duration(-days) * time.Hour * 24),
}
var filter jmap.EmailFilterElement
if len(filters) > 0 {
filter = jmap.EmailFilterOperator{
Operator: jmap.And,
Conditions: []jmap.EmailFilterElement{
timeFilter,
jmap.EmailFilterOperator{
Operator: jmap.Or,
Conditions: filters,
},
},
}
} else {
filter = timeFilter
}
return filter
}
func (g *Groupware) RelatedToEmail(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, UriParamEmailId)
g.respond(w, r, func(req Request) Response {
l := req.logger.With().Str(logEmailId, log.SafeString(id))
accountId, gwerr := req.GetAccountIdForMail()
if gwerr != nil {
return errorResponse(accountId, gwerr)
}
l = l.Str(logAccountId, log.SafeString(accountId))
limit, ok, err := req.parseUIntParam(QueryParamLimit, 10) // TODO configurable default limit
if err != nil {
return errorResponse(accountId, err)
}
if ok {
l = l.Uint("limit", limit)
}
days, ok, err := req.parseUIntParam(QueryParamDays, 5) // TODO configurable default days
if err != nil {
return errorResponse(accountId, err)
}
if ok {
l = l.Uint("days", days)
}
logger := log.From(l)
reqId := req.GetRequestId()
getEmailsBefore := time.Now()
emails, sessionState, state, lang, jerr := g.jmap.GetEmails(accountId, req.session, req.ctx, logger, req.language(), []string{id}, true, g.maxBodyValueBytes, false, false)
getEmailsDuration := time.Since(getEmailsBefore)
if jerr != nil {
return req.errorResponseFromJmap(accountId, jerr)
}
if len(emails) < 1 {
req.observe(g.metrics.EmailByIdDuration.WithLabelValues(req.session.JmapEndpoint, metrics.Values.Result.NotFound), getEmailsDuration.Seconds())
logger.Trace().Msg("failed to find any emails matching id") // the id is already in the log field
return notFoundResponse(accountId, sessionState)
} else {
req.observe(g.metrics.EmailByIdDuration.WithLabelValues(req.session.JmapEndpoint, metrics.Values.Result.Found), getEmailsDuration.Seconds())
}
email := emails[0]
beacon := email.ReceivedAt // TODO configurable: either relative to when the email was received, or relative to now
//beacon := time.Now()
filter := relatedEmailsFilter(email, beacon, days)
// bgctx, _ := context.WithTimeout(context.Background(), time.Duration(30)*time.Second) // TODO configurable
bgctx := context.Background()
g.job(logger, RelationTypeSameSender, func(jobId uint64, l *log.Logger) {
before := time.Now()
resultsByAccountId, _, _, lang, jerr := g.jmap.QueryEmails([]string{accountId}, filter, req.session, bgctx, l, req.language(), 0, limit, false, g.maxBodyValueBytes)
if results, ok := resultsByAccountId[accountId]; ok {
duration := time.Since(before)
if jerr != nil {
req.observeJmapError(jerr)
l.Error().Err(jerr).Msgf("failed to query %v emails", RelationTypeSameSender)
} else {
req.observe(g.metrics.EmailSameSenderDuration.WithLabelValues(req.session.JmapEndpoint), duration.Seconds())
related, err := req.sanitizeEmails(filterEmails(results.Emails, email))
if err == nil {
l.Trace().Msgf("'%v' found %v other emails", RelationTypeSameSender, len(related))
if len(related) > 0 {
req.push(RelationEntityEmail, AboutEmailsEvent{Id: reqId, Emails: related, Source: RelationTypeSameSender, Language: lang})
}
}
}
}
})
g.job(logger, RelationTypeSameThread, func(jobId uint64, l *log.Logger) {
before := time.Now()
emails, _, _, _, jerr := g.jmap.EmailsInThread(accountId, email.ThreadId, req.session, bgctx, l, req.language(), false, g.maxBodyValueBytes)
duration := time.Since(before)
if jerr != nil {
req.observeJmapError(jerr)
l.Error().Err(jerr).Msgf("failed to list %v emails", RelationTypeSameThread)
} else {
req.observe(g.metrics.EmailSameThreadDuration.WithLabelValues(req.session.JmapEndpoint), duration.Seconds())
related, err := req.sanitizeEmails(filterEmails(emails, email))
if err == nil {
l.Trace().Msgf("'%v' found %v other emails", RelationTypeSameThread, len(related))
if len(related) > 0 {
req.push(RelationEntityEmail, AboutEmailsEvent{Id: reqId, Emails: related, Source: RelationTypeSameThread, Language: lang})
}
}
}
})
sanitized, err := req.sanitizeEmail(email)
if err != nil {
return errorResponseWithSessionState(accountId, err, sessionState)
}
return etagResponse(accountId, AboutEmailResponse{
Email: sanitized,
RequestId: reqId,
}, sessionState, EmailResponseObjectType, state, lang)
})
}
type EmailSummary struct {
// The id of the account this Email summary pertains to.
// required: true
// example: $accountId
AccountId string `json:"accountId,omitempty"`
// The id of the Email object.
//
// Note that this is the JMAP object id, NOT the Message-ID header field value of the message [RFC5322].
//
// [RFC5322]: https://www.rfc-editor.org/rfc/rfc5322.html
//
// required: true
// example: $emailId
Id string `json:"id,omitempty"`
// The id of the Thread to which this Email belongs.
//
// example: $threadId
ThreadId string `json:"threadId,omitempty"`
// The number of emails in the thread, including this one.
ThreadSize int `json:"threadSize,omitzero"`
// The set of Mailbox ids this Email belongs to.
//
// An Email in the mail store MUST belong to one or more Mailboxes at all times (until it is destroyed).
// The set is represented as an object, with each key being a Mailbox id.
//
// The value for each key in the object MUST be true.
//
// example: $mailboxIds
MailboxIds map[string]bool `json:"mailboxIds,omitempty"`
// A set of keywords that apply to the Email.
//
// The set is represented as an object, with the keys being the keywords.
//
// The value for each key in the object MUST be true.
//
// Keywords are shared with IMAP.
//
// The six system keywords from IMAP get special treatment.
//
// The following four keywords have their first character changed from \ in IMAP to $ in JMAP and have particular semantic meaning:
//
// - $draft: The Email is a draft the user is composing.
// - $seen: The Email has been read.
// - $flagged: The Email has been flagged for urgent/special attention.
// - $answered: The Email has been replied to.
//
// The IMAP \Recent keyword is not exposed via JMAP. The IMAP \Deleted keyword is also not present: IMAP uses a delete+expunge model,
// which JMAP does not. Any message with the \Deleted keyword MUST NOT be visible via JMAP (and so are not counted in the
// “totalEmails”, “unreadEmails”, “totalThreads”, and “unreadThreads” Mailbox properties).
//
// Users may add arbitrary keywords to an Email.
// For compatibility with IMAP, a keyword is a case-insensitive string of 1255 characters in the ASCII subset
// %x21%x7e (excludes control chars and space), and it MUST NOT include any of these characters:
//
// ( ) { ] % * " \
//
// Because JSON is case sensitive, servers MUST return keywords in lowercase.
//
// The [IMAP and JMAP Keywords] registry as established in [RFC5788] assigns semantic meaning to some other
// keywords in common use.
//
// New keywords may be established here in the future. In particular, note:
//
// - $forwarded: The Email has been forwarded.
// - $phishing: The Email is highly likely to be phishing.
// Clients SHOULD warn users to take care when viewing this Email and disable links and attachments.
// - $junk: The Email is definitely spam.
// Clients SHOULD set this flag when users report spam to help train automated spam-detection systems.
// - $notjunk: The Email is definitely not spam.
// Clients SHOULD set this flag when users indicate an Email is legitimate, to help train automated spam-detection systems.
//
// [IMAP and JMAP Keywords]: https://www.iana.org/assignments/imap-jmap-keywords/
// [RFC5788]: https://www.rfc-editor.org/rfc/rfc5788.html
//
// example: $emailKeywords
Keywords map[string]bool `json:"keywords,omitempty"`
// The size, in octets, of the raw data for the message [RFC5322]
// (as referenced by the blobId, i.e., the number of octets in the file the user would download).
//
// [RFC5322]: https://www.rfc-editor.org/rfc/rfc5322.html
Size int `json:"size"`
// The date the Email was received by the message store.
//
// This is the internal date in IMAP [RFC3501].
//
// [RFC3501]: https://www.rfc-editor.org/rfc/rfc3501.html
//
// example: $emailReceivedAt
ReceivedAt time.Time `json:"receivedAt,omitzero"`
// The value is identical to the value of header:Sender:asAddresses.
// example: $emailSenders
Sender []jmap.EmailAddress `json:"sender,omitempty"`
// The value is identical to the value of header:From:asAddresses.
// example: $emailFroms
From []jmap.EmailAddress `json:"from,omitempty"`
// The value is identical to the value of header:To:asAddresses.
// example: $emailTos
To []jmap.EmailAddress `json:"to,omitempty"`
// The value is identical to the value of header:Cc:asAddresses.
// example: $emailCCs
Cc []jmap.EmailAddress `json:"cc,omitempty"`
// The value is identical to the value of header:Bcc:asAddresses.
// example: $emailBCCs
Bcc []jmap.EmailAddress `json:"bcc,omitempty"`
// The value is identical to the value of header:Subject:asText.
// example: $emailSubject
Subject string `json:"subject,omitempty"`
// The value is identical to the value of header:Date:asDate.
// example: $emailSentAt
SentAt time.Time `json:"sentAt,omitzero"`
// This is true if there are one or more parts in the message that a client UI should offer as downloadable.
//
// A server SHOULD set hasAttachment to true if the attachments list contains at least one item that
// does not have Content-Disposition: inline.
//
// The server MAY ignore parts in this list that are processed automatically in some way or are referenced
// as embedded images in one of the text/html parts of the message.
//
// The server MAY set hasAttachment based on implementation-defined or site-configurable heuristics.
// example: true
HasAttachment bool `json:"hasAttachment,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
//
// example: $emailAttachments
Attachments []jmap.EmailBodyPart `json:"attachments,omitempty"`
// A plaintext fragment of the message body.
//
// This is intended to be shown as a preview line when listing messages in the mail store and may be truncated
// when shown.
//
// The server may choose which part of the message to include in the preview; skipping quoted sections and
// salutations and collapsing white space can result in a more useful preview.
//
// This MUST NOT be more than 256 characters in length.
//
// As this is derived from the message content by the server, and the algorithm for doing so could change over
// time, fetching this for an Email a second time MAY return a different result.
// However, the previous value is not considered incorrect, and the change SHOULD NOT cause the Email object
// to be considered as changed by the server.
//
// example: $emailPreview
Preview string `json:"preview,omitempty"`
}
func summarizeEmail(accountId string, email jmap.Email) EmailSummary {
return EmailSummary{
AccountId: accountId,
Id: email.Id,
ThreadId: email.ThreadId,
ThreadSize: email.ThreadSize,
MailboxIds: email.MailboxIds,
Keywords: email.Keywords,
Size: email.Size,
ReceivedAt: email.ReceivedAt,
Sender: email.Sender,
From: email.From,
To: email.To,
Cc: email.Cc,
Bcc: email.Bcc,
Subject: email.Subject,
SentAt: email.SentAt,
HasAttachment: email.HasAttachment,
Attachments: email.Attachments,
Preview: email.Preview,
}
}
type emailWithAccountId struct {
accountId string
email jmap.Email
}
// When the request succeeds.
// swagger:response GetLatestEmailsSummaryForAllAccounts200
type SwaggerGetLatestEmailsSummaryForAllAccounts200 struct {
// in: body
Body []EmailSummary
}
// swagger:parameters get_latest_emails_summary_for_all_accounts
type SwaggerGetLatestEmailsSummaryForAllAccountsParams struct {
// The maximum amount of email summaries to return.
// in: query
// example: 10
// default: 10
Limit uint `json:"limit"`
// Whether to include emails that have already been seen (read) or not.
// in: query
// example: true
// default: false
Seen bool `json:"seen"`
// Whether to include emails that have been flagged as junk or phishing.
// in: query
// example: false
// default: false
Undesirable bool `json:"undesirable"`
}
type EmailSummaries struct {
Emails []EmailSummary `json:"emails,omitempty"`
Total uint `json:"total,omitzero"`
Limit uint `json:"limit,omitzero"`
Offset uint `json:"offset,omitzero"`
State jmap.State `json:"state,omitempty"`
}
// swagger:route GET /groupware/accounts/all/emails/latest/summary email get_latest_emails_summary_for_all_accounts
// Get a summary of the latest emails across all the mailboxes, across all of a user's accounts.
//
// Retrieves summaries of the latest emails of a user, in all accounts, across all mailboxes.
//
// The number of total summaries to retrieve is specified using the query parameter `limit`.
//
// The following additional query parameters may be specified to further filter the emails to summarize:
//
// !- `seen`: when `true`, emails that have already been seen (read) will be included as well (default is to only include emails that have not been read yet)
// !- `undesirable`: when `true`, emails that are flagged as spam or phishing will also be summarized (default is to ignore those)
//
// responses:
//
// 200: GetLatestEmailsSummaryForAllAccounts200
// 400: ErrorResponse400
// 404: ErrorResponse404
// 500: ErrorResponse500
func (g *Groupware) GetLatestEmailsSummaryForAllAccounts(w http.ResponseWriter, r *http.Request) {
g.respond(w, r, func(req Request) Response {
l := req.logger.With()
allAccountIds := req.AllAccountIds()
l.Array(logAccountId, log.SafeStringArray(allAccountIds))
limit, ok, err := req.parseUIntParam(QueryParamLimit, 10) // TODO from configuration
if err != nil {
return errorResponse(joinAccountIds(allAccountIds), err)
}
if ok {
l = l.Uint(QueryParamLimit, limit)
}
offset, ok, err := req.parseUIntParam(QueryParamOffset, 0)
if err != nil {
return errorResponse(joinAccountIds(allAccountIds), err)
}
if offset > 0 {
return notImplementesResponse()
}
if ok {
l = l.Uint(QueryParamOffset, limit)
}
seen, ok, err := req.parseBoolParam(QueryParamSeen, false)
if err != nil {
return errorResponse(joinAccountIds(allAccountIds), err)
}
if ok {
l = l.Bool(QueryParamSeen, seen)
}
undesirable, ok, err := req.parseBoolParam(QueryParamUndesirable, false)
if err != nil {
return errorResponse(joinAccountIds(allAccountIds), err)
}
if ok {
l = l.Bool(QueryParamUndesirable, undesirable)
}
var filter jmap.EmailFilterElement = nil // all emails, read and unread
{
notKeywords := []string{}
if !seen {
notKeywords = append(notKeywords, jmap.JmapKeywordSeen)
}
if undesirable {
notKeywords = append(notKeywords, jmap.JmapKeywordJunk, jmap.JmapKeywordPhishing)
}
filter = filterFromNotKeywords(notKeywords)
}
logger := log.From(l)
emailsSummariesByAccount, sessionState, state, lang, jerr := g.jmap.QueryEmailSummaries(allAccountIds, req.session, req.ctx, logger, req.language(), filter, limit, true)
if jerr != nil {
return req.errorResponseFromJmap(joinAccountIds(allAccountIds), jerr)
}
// sort in memory to respect the overall limit
total := uint(0)
for _, emails := range emailsSummariesByAccount {
total += uint(max(len(emails.Emails), 0))
}
all := make([]emailWithAccountId, total)
i := uint(0)
for accountId, emails := range emailsSummariesByAccount {
for _, email := range emails.Emails {
all[i] = emailWithAccountId{accountId: accountId, email: email}
i++
}
}
slices.SortFunc(all, func(a, b emailWithAccountId) int { return -(a.email.ReceivedAt.Compare(b.email.ReceivedAt)) })
summaries := make([]EmailSummary, min(limit, total))
for i = 0; i < limit && i < total; i++ {
summaries[i] = summarizeEmail(all[i].accountId, all[i].email)
}
return etagResponse(joinAccountIds(allAccountIds), EmailSummaries{
Emails: summaries,
Total: total,
Limit: limit,
Offset: offset,
}, sessionState, EmailResponseObjectType, state, lang)
})
}
func filterEmails(all []jmap.Email, skip jmap.Email) []jmap.Email {
filtered := all[:0]
for _, email := range all {
if skip.Id != email.Id {
filtered = append(filtered, email)
}
}
return filtered
}
func filterFromNotKeywords(keywords []string) jmap.EmailFilterElement {
switch len(keywords) {
case 0:
return nil
case 1:
return jmap.EmailFilterCondition{NotKeyword: keywords[0]}
default:
conditions := make([]jmap.EmailFilterElement, len(keywords))
for i, keyword := range keywords {
conditions[i] = jmap.EmailFilterCondition{NotKeyword: keyword}
}
return jmap.EmailFilterOperator{Operator: jmap.And, Conditions: conditions}
}
}
var sanitizationPolicy *bluemonday.Policy = bluemonday.UGCPolicy()
var sanitizableMediaTypes = []string{
"text/html",
"text/xhtml",
}
func (req *Request) sanitizeEmail(source jmap.Email) (jmap.Email, *Error) {
if !req.g.sanitize {
return source, nil
}
memory := map[string]int{}
for _, ref := range []*[]jmap.EmailBodyPart{&source.HtmlBody, &source.TextBody} {
newBody := make([]jmap.EmailBodyPart, len(*ref))
for i, p := range *ref {
t, _, err := mime.ParseMediaType(p.Type)
if err != nil {
msg := fmt.Sprintf("failed to parse the mime type '%s'", p.Type)
req.logger.Error().Str("type", log.SafeString(p.Type)).Msg(msg)
return source, req.apiError(&ErrorFailedToSanitizeEmail, withDetail(msg))
}
if slices.Contains(sanitizableMediaTypes, t) {
if already, done := memory[p.PartId]; !done {
if part, ok := source.BodyValues[p.PartId]; ok {
safe := sanitizationPolicy.Sanitize(part.Value)
part.Value = safe
source.BodyValues[p.PartId] = part
newLen := len(safe)
memory[p.PartId] = newLen
p.Size = newLen
}
} else {
p.Size = already
}
}
newBody[i] = p
}
*ref = newBody
}
// we could post-process attachments as well:
/*
for _, part := range source.Attachments {
if part.Type == "" {
part.Type = "application/octet-stream"
}
if part.Name == "" {
part.Name = "unknown"
}
}
*/
return source, nil
}
func (req *Request) sanitizeEmails(source []jmap.Email) ([]jmap.Email, *Error) {
if !req.g.sanitize {
return source, nil
}
result := make([]jmap.Email, len(source))
for i, email := range source {
sanitized, gwerr := req.sanitizeEmail(email)
if gwerr != nil {
return nil, gwerr
}
result[i] = sanitized
}
return result, nil
}