groupware: implement message search with snippets

This commit is contained in:
Pascal Bleser
2025-08-05 16:28:31 +02:00
parent 9342db5efb
commit b7a7c9526b
6 changed files with 363 additions and 62 deletions

View File

@@ -250,10 +250,10 @@ func (j *Client) GetAllMailboxes(accountId string, session *Session, ctx context
}
// https://jmap.io/spec-mail.html#mailboxquery
func (j *Client) QueryMailbox(accountId string, session *Session, ctx context.Context, logger *log.Logger, filter MailboxFilterCondition) (MailboxQueryResponse, Error) {
func (j *Client) QueryMailbox(accountId string, session *Session, ctx context.Context, logger *log.Logger, filter MailboxFilterElement) (MailboxQueryResponse, Error) {
aid := session.MailAccountId(accountId)
logger = j.logger(aid, "QueryMailbox", session, logger)
cmd, err := request(invocation(MailboxQuery, SimpleMailboxQueryCommand{AccountId: aid, Filter: filter}, "0"))
cmd, err := request(invocation(MailboxQuery, MailboxQueryCommand{AccountId: aid, Filter: filter}, "0"))
if err != nil {
return MailboxQueryResponse{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err}
}
@@ -269,12 +269,12 @@ type Mailboxes struct {
State string `json:"state,omitempty"`
}
func (j *Client) SearchMailboxes(accountId string, session *Session, ctx context.Context, logger *log.Logger, filter MailboxFilterCondition) (Mailboxes, Error) {
func (j *Client) SearchMailboxes(accountId string, session *Session, ctx context.Context, logger *log.Logger, filter MailboxFilterElement) (Mailboxes, Error) {
aid := session.MailAccountId(accountId)
logger = j.logger(aid, "SearchMailboxes", session, logger)
cmd, err := request(
invocation(MailboxQuery, SimpleMailboxQueryCommand{AccountId: aid, Filter: filter}, "0"),
invocation(MailboxQuery, MailboxQueryCommand{AccountId: aid, Filter: filter}, "0"),
invocation(MailboxGet, MailboxGetRefCommand{
AccountId: aid,
IdRef: &ResultReference{Name: MailboxQuery, Path: "/ids/*", ResultOf: "0"},
@@ -330,7 +330,7 @@ func (j *Client) GetAllEmails(accountId string, session *Session, ctx context.Co
query := EmailQueryCommand{
AccountId: aid,
Filter: &MessageFilter{InMailbox: mailboxId},
Filter: &EmailFilterCondition{InMailbox: mailboxId},
Sort: []Sort{{Property: emailSortByReceivedAt, IsAscending: false}},
CollapseThreads: true,
CalculateTotal: false,
@@ -518,6 +518,77 @@ func (j *Client) GetEmailsSince(accountId string, session *Session, ctx context.
})
}
type EmailQueryResult struct {
Snippets []SearchSnippet `json:"snippets,omitempty"`
QueryState string `json:"queryState"`
Total int `json:"total"`
Limit int `json:"limit,omitzero"`
Position int `json:"position,omitzero"`
}
func (j *Client) QueryEmails(accountId string, filter EmailFilterElement, session *Session, ctx context.Context, logger *log.Logger, offset int, limit int, fetchBodies bool, maxBodyValueBytes int) (EmailQueryResult, Error) {
aid := session.MailAccountId(accountId)
logger = j.loggerParams(aid, "QueryEmails", session, logger, func(z zerolog.Context) zerolog.Context {
return z.Bool(logFetchBodies, fetchBodies)
})
query := EmailQueryCommand{
AccountId: aid,
Filter: filter,
Sort: []Sort{{Property: emailSortByReceivedAt, IsAscending: false}},
CollapseThreads: true,
CalculateTotal: true,
}
if offset >= 0 {
query.Position = offset
}
if limit >= 0 {
query.Limit = limit
}
snippet := SearchSnippetRefCommand{
AccountId: aid,
Filter: filter,
EmailIdRef: &ResultReference{
ResultOf: "0",
Name: EmailQuery,
Path: "/ids/*",
},
}
cmd, err := request(
invocation(EmailQuery, query, "0"),
invocation(SearchSnippetGet, snippet, "1"),
)
if err != nil {
return EmailQueryResult{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err}
}
return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (EmailQueryResult, Error) {
var queryResponse EmailQueryResponse
err = retrieveResponseMatchParameters(body, EmailQuery, "0", &queryResponse)
if err != nil {
return EmailQueryResult{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
}
var snippetResponse SearchSnippetGetResponse
err = retrieveResponseMatchParameters(body, SearchSnippetGet, "1", &snippetResponse)
if err != nil {
return EmailQueryResult{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
}
return EmailQueryResult{
Snippets: snippetResponse.List,
QueryState: queryResponse.QueryState,
Total: queryResponse.Total,
Limit: queryResponse.Limit,
Position: queryResponse.Position,
}, nil
})
}
func (j *Client) GetBlob(accountId string, session *Session, ctx context.Context, logger *log.Logger, id string) (*Blob, Error) {
aid := session.BlobAccountId(accountId)

View File

@@ -244,6 +244,14 @@ type SetError struct {
Description string `json:"description,omitempty"`
}
type FilterOperatorTerm string
const (
And FilterOperatorTerm = "AND"
Or FilterOperatorTerm = "OR"
Not FilterOperatorTerm = "NOT"
)
type Mailbox struct {
Id string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
@@ -274,7 +282,12 @@ type MailboxChangesCommand struct {
MaxChanges int `json:"maxChanges,omitzero"`
}
type MailboxFilterElement interface {
_isAMailboxFilterElement() // marker method
}
type MailboxFilterCondition struct {
MailboxFilterElement
ParentId string `json:"parentId,omitempty"`
Name string `json:"name,omitempty"`
Role string `json:"role,omitempty"`
@@ -282,11 +295,16 @@ type MailboxFilterCondition struct {
IsSubscribed *bool `json:"isSubscribed,omitempty"`
}
var _ MailboxFilterElement = &MailboxFilterCondition{}
type MailboxFilterOperator struct {
Operator string `json:"operator"`
Conditions []MailboxFilterCondition `json:"conditions"`
MailboxFilterElement
Operator FilterOperatorTerm `json:"operator"`
Conditions []MailboxFilterElement `json:"conditions,omitempty"`
}
var _ MailboxFilterElement = &MailboxFilterOperator{}
type MailboxComparator struct {
Property string `json:"property"`
IsAscending bool `json:"isAscending,omitempty"`
@@ -294,15 +312,20 @@ type MailboxComparator struct {
CalculateTotal bool `json:"calculateTotal,omitempty"`
}
type SimpleMailboxQueryCommand struct {
AccountId string `json:"accountId"`
Filter MailboxFilterCondition `json:"filter,omitempty"`
Sort []MailboxComparator `json:"sort,omitempty"`
SortAsTree bool `json:"sortAsTree,omitempty"`
FilterAsTree bool `json:"filterAsTree,omitempty"`
type MailboxQueryCommand struct {
AccountId string `json:"accountId"`
Filter MailboxFilterElement `json:"filter,omitempty"`
Sort []MailboxComparator `json:"sort,omitempty"`
SortAsTree bool `json:"sortAsTree,omitempty"`
FilterAsTree bool `json:"filterAsTree,omitempty"`
}
type MessageFilter struct {
type EmailFilterElement interface {
_isAnEmailFilterElement() // marker method
}
type EmailFilterCondition struct {
EmailFilterElement
InMailbox string `json:"inMailbox,omitempty"`
InMailboxOtherThan []string `json:"inMailboxOtherThan,omitempty"`
Before time.Time `json:"before,omitzero"` // omitzero requires Go 1.24
@@ -316,8 +339,25 @@ type MessageFilter struct {
NotKeyword string `json:"notKeyword,omitempty"`
HasAttachment bool `json:"hasAttachment,omitempty"`
Text string `json:"text,omitempty"`
From string `json:"from,omitempty"`
To string `json:"to,omitempty"`
Cc string `json:"cc,omitempty"`
Bcc string `json:"bcc,omitempty"`
Subject string `json:"subject,omitempty"`
Body string `json:"body,omitempty"`
Header []string `json:"header,omitempty"`
}
var _ EmailFilterElement = &EmailFilterCondition{}
type EmailFilterOperator struct {
EmailFilterElement
Operator FilterOperatorTerm `json:"operator"`
Conditions []EmailFilterElement `json:"conditions,omitempty"`
}
var _ EmailFilterElement = &EmailFilterOperator{}
type Sort struct {
Property string `json:"property,omitempty"`
IsAscending bool `json:"isAscending,omitempty"`
@@ -326,13 +366,13 @@ type Sort struct {
}
type EmailQueryCommand struct {
AccountId string `json:"accountId"`
Filter *MessageFilter `json:"filter,omitempty"`
Sort []Sort `json:"sort,omitempty"`
CollapseThreads bool `json:"collapseThreads,omitempty"`
Position int `json:"position,omitempty"`
Limit int `json:"limit,omitempty"`
CalculateTotal bool `json:"calculateTotal,omitempty"`
AccountId string `json:"accountId"`
Filter EmailFilterElement `json:"filter,omitempty"`
Sort []Sort `json:"sort,omitempty"`
CollapseThreads bool `json:"collapseThreads,omitempty"`
Position int `json:"position,omitempty"`
Limit int `json:"limit,omitempty"`
CalculateTotal bool `json:"calculateTotal,omitempty"`
}
type EmailGetCommand struct {
@@ -1306,6 +1346,24 @@ type BlobDownload struct {
CacheControl string
}
type SearchSnippet struct {
EmailId string `json:"emailId"`
Subject string `json:"subject,omitempty"`
Preview string `json:"preview,omitempty"`
}
type SearchSnippetRefCommand struct {
AccountId string `json:"accountId"`
Filter EmailFilterElement `json:"filter,omitempty"`
EmailIdRef *ResultReference `json:"#emailIds,omitempty"`
}
type SearchSnippetGetResponse struct {
AccountId string `json:"accountId"`
List []SearchSnippet `json:"list,omitempty"`
NotFound []string `json:"notFound,omitempty"`
}
const (
BlobGet Command = "Blob/get"
BlobUpload Command = "Blob/upload"
@@ -1320,6 +1378,7 @@ const (
MailboxChanges Command = "Mailbox/changes"
IdentityGet Command = "Identity/get"
VacationResponseGet Command = "VacationResponse/get"
SearchSnippetGet Command = "SearchSnippet/get"
)
var CommandResponseTypeMap = map[Command]func() any{
@@ -1334,4 +1393,5 @@ var CommandResponseTypeMap = map[Command]func() any{
ThreadGet: func() any { return ThreadGetResponse{} },
IdentityGet: func() any { return IdentityGetResponse{} },
VacationResponseGet: func() any { return VacationResponseGetResponse{} },
SearchSnippetGet: func() any { return SearchSnippetGetResponse{} },
}

View File

@@ -51,15 +51,15 @@ func command[T any](api ApiClient,
return zero, jmapErr
}
var data Response
err := json.Unmarshal(responseBody, &data)
var response Response
err := json.Unmarshal(responseBody, &response)
if err != nil {
logger.Error().Err(err).Msg("failed to deserialize body JSON payload")
var zero T
return zero, SimpleError{code: JmapErrorDecodingResponseBody, err: err}
}
if data.SessionState != session.State {
if response.SessionState != session.State {
if sessionOutdatedHandler != nil {
sessionOutdatedHandler(session)
}
@@ -67,7 +67,7 @@ func command[T any](api ApiClient,
// search for an "error" response
// https://jmap.io/spec-core.html#method-level-errors
for _, mr := range data.MethodResponses {
for _, mr := range response.MethodResponses {
if mr.Command == "error" {
err := fmt.Errorf("found method level error in response '%v'", mr.Tag)
if payload, ok := mr.Parameters.(map[string]any); ok {
@@ -80,7 +80,7 @@ func command[T any](api ApiClient,
}
}
return mapper(&data)
return mapper(&response)
}
func mapstructStringToTimeHook() mapstructure.DecodeHookFunc {

View File

@@ -7,6 +7,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/opencloud-eu/opencloud/pkg/jmap"
"github.com/opencloud-eu/opencloud/pkg/log"
)
@@ -37,6 +38,7 @@ func (g Groupware) GetAllMessages(w http.ResponseWriter, r *http.Request) {
})
} else {
g.respond(w, r, func(req Request) (any, string, *Error) {
l := req.logger.With()
if mailboxId == "" {
errorId := req.errorId()
msg := fmt.Sprintf("Missing required mailbox ID path parameter '%v'", UriParamMailboxId)
@@ -45,28 +47,23 @@ func (g Groupware) GetAllMessages(w http.ResponseWriter, r *http.Request) {
withSource(&ErrorSource{Parameter: UriParamMailboxId}),
)
}
page, ok, err := req.parseNumericParam(QueryParamPage, -1)
if err != nil {
return nil, "", err
}
logger := req.logger
if ok {
logger = &log.Logger{Logger: logger.With().Int(QueryParamPage, page).Logger()}
}
size, ok, err := req.parseNumericParam(QueryParamSize, -1)
offset, ok, err := req.parseNumericParam(QueryParamOffset, 0)
if err != nil {
return nil, "", err
}
if ok {
logger = &log.Logger{Logger: logger.With().Int(QueryParamSize, size).Logger()}
l = l.Int(QueryParamOffset, offset)
}
offset := page * size
limit := size
if limit < 0 {
limit = g.defaultEmailLimit
limit, ok, err := req.parseNumericParam(QueryParamLimit, g.defaultEmailLimit)
if err != nil {
return nil, "", err
}
if ok {
l = l.Int(QueryParamLimit, limit)
}
logger := &log.Logger{Logger: l.Logger()}
emails, jerr := g.jmap.GetAllEmails(req.GetAccountId(), req.session, req.ctx, logger, mailboxId, offset, limit, true, g.maxBodyValueBytes)
if jerr != nil {
@@ -101,21 +98,141 @@ func (g Groupware) GetMessagesById(w http.ResponseWriter, r *http.Request) {
})
}
func (g Groupware) GetMessageUpdates(w http.ResponseWriter, r *http.Request) {
func (g Groupware) GetMessages(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
since := q.Get(QueryParamSince)
if since == "" {
since = r.Header.Get("If-None-Match")
}
maxChanges := -1
g.respond(w, r, func(req Request) (any, string, *Error) {
logger := &log.Logger{Logger: req.logger.With().Str(HeaderSince, since).Logger()}
if since != "" {
// get messages changes since a given state
maxChanges := -1
g.respond(w, r, func(req Request) (any, string, *Error) {
logger := &log.Logger{Logger: req.logger.With().Str(HeaderSince, since).Logger()}
emails, jerr := g.jmap.GetEmailsSince(req.GetAccountId(), req.session, req.ctx, logger, since, true, g.maxBodyValueBytes, maxChanges)
if jerr != nil {
return nil, "", req.apiErrorFromJmap(jerr)
}
emails, jerr := g.jmap.GetEmailsSince(req.GetAccountId(), req.session, req.ctx, logger, since, true, g.maxBodyValueBytes, maxChanges)
if jerr != nil {
return nil, "", req.apiErrorFromJmap(jerr)
}
return emails, emails.State, nil
})
return emails, emails.State, nil
})
} else {
// do a search
g.respond(w, r, func(req Request) (any, string, *Error) {
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)
l := req.logger.With()
offset, ok, err := req.parseNumericParam(QueryParamOffset, 0)
if err != nil {
return nil, "", err
}
if ok {
l = l.Int(QueryParamOffset, offset)
}
limit, ok, err := req.parseNumericParam(QueryParamLimit, g.defaultEmailLimit)
if err != nil {
return nil, "", err
}
if ok {
l = l.Int(QueryParamLimit, limit)
}
before, ok, err := req.parseDateParam(QueryParamSearchBefore)
if err != nil {
return nil, "", err
}
if ok {
l = l.Time(QueryParamSearchBefore, before)
}
after, ok, err := req.parseDateParam(QueryParamSearchAfter)
if err != nil {
return nil, "", err
}
if ok {
l = l.Time(QueryParamSearchAfter, after)
}
if mailboxId != "" {
l = l.Str(QueryParamMailboxId, logstr(mailboxId))
}
if len(notInMailboxIds) > 0 {
l = l.Array(QueryParamNotInMailboxId, logstrarray(notInMailboxIds))
}
if text != "" {
l = l.Str(QueryParamSearchText, logstr(text))
}
if from != "" {
l = l.Str(QueryParamSearchFrom, logstr(from))
}
if to != "" {
l = l.Str(QueryParamSearchTo, logstr(to))
}
if cc != "" {
l = l.Str(QueryParamSearchCc, logstr(cc))
}
if bcc != "" {
l = l.Str(QueryParamSearchBcc, logstr(bcc))
}
if subject != "" {
l = l.Str(QueryParamSearchSubject, logstr(subject))
}
if body != "" {
l = l.Str(QueryParamSearchBody, logstr(body))
}
minSize, ok, err := req.parseNumericParam(QueryParamSearchMinSize, 0)
if err != nil {
return nil, "", err
}
if ok {
l = l.Int(QueryParamSearchMinSize, minSize)
}
maxSize, ok, err := req.parseNumericParam(QueryParamSearchMaxSize, 0)
if err != nil {
return nil, "", err
}
if ok {
l = l.Int(QueryParamSearchMaxSize, maxSize)
}
logger := &log.Logger{Logger: l.Logger()}
filter := 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,
//HasKeyword: "",
}
emails, jerr := g.jmap.QueryEmails(req.GetAccountId(), &filter, req.session, req.ctx, logger, offset, limit, false, 0)
if jerr != nil {
return nil, "", req.apiErrorFromJmap(jerr)
}
return emails, emails.QueryState, nil
})
}
}

View File

@@ -7,6 +7,7 @@ import (
"net/http"
"net/url"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
@@ -222,6 +223,29 @@ func (r Request) parseNumericParam(param string, defaultValue int) (int, bool, *
return int(value), true, nil
}
func (r Request) parseDateParam(param string) (time.Time, bool, *Error) {
q := r.r.URL.Query()
if !q.Has(param) {
return time.Time{}, false, nil
}
str := q.Get(param)
if str == "" {
return time.Time{}, false, nil
}
t, err := time.Parse(time.RFC3339, str)
if err != nil {
errorId := r.errorId()
msg := fmt.Sprintf("Invalid RFC3339 value for query parameter '%v': '%s': %s", param, logstr(str), err.Error())
return time.Time{}, true, apiError(errorId, ErrorInvalidRequestParameter,
withDetail(msg),
withSource(&ErrorSource{Parameter: param}),
)
}
return t, true, nil
}
// Safely caps a string to a given size to avoid log bombing.
// Use this function to wrap strings that are user input (HTTP headers, path parameters, URI parameters, HTTP body, ...).
func logstr(text string) string {
@@ -234,6 +258,22 @@ func logstr(text string) string {
}
}
type SafeLogStringArrayMarshaller struct {
array []string
}
func (m SafeLogStringArrayMarshaller) MarshalZerologArray(a *zerolog.Array) {
for _, elem := range m.array {
a.Str(logstr(elem))
}
}
var _ zerolog.LogArrayMarshaler = SafeLogStringArrayMarshaller{}
func logstrarray(array []string) SafeLogStringArrayMarshaller {
return SafeLogStringArrayMarshaller{array: array}
}
func (g Groupware) log(error *Error) {
var level *zerolog.Event
if error.NumStatus < 300 {

View File

@@ -5,16 +5,29 @@ import (
)
const (
UriParamAccount = "account"
UriParamMailboxId = "mailbox"
QueryParamPage = "page"
QueryParamSize = "size"
UriParamMessagesId = "id"
UriParamBlobId = "blobid"
UriParamBlobName = "blobname"
QueryParamBlobType = "type"
QueryParamSince = "since"
HeaderSince = "if-none-match"
UriParamAccount = "account"
UriParamMailboxId = "mailbox"
UriParamMessagesId = "id"
UriParamBlobId = "blobid"
UriParamBlobName = "blobname"
QueryParamBlobType = "type"
QueryParamSince = "since"
QueryParamMailboxId = "mailbox"
QueryParamNotInMailboxId = "notmailbox"
QueryParamSearchText = "text"
QueryParamSearchFrom = "from"
QueryParamSearchTo = "to"
QueryParamSearchCc = "cc"
QueryParamSearchBcc = "bcc"
QueryParamSearchSubject = "subject"
QueryParamSearchBody = "body"
QueryParamSearchBefore = "before"
QueryParamSearchAfter = "after"
QueryParamSearchMinSize = "minsize"
QueryParamSearchMaxSize = "maxsize"
QueryParamOffset = "offset"
QueryParamLimit = "limit"
HeaderSince = "if-none-match"
)
func (g Groupware) Route(r chi.Router) {
@@ -30,7 +43,7 @@ func (g Groupware) Route(r chi.Router) {
r.Get("/{mailbox}/messages", g.GetAllMessages)
})
r.Route("/messages", func(r chi.Router) {
r.Get("/", g.GetMessageUpdates)
r.Get("/", g.GetMessages)
r.Get("/{id}", g.GetMessagesById)
})
r.Route("/blobs", func(r chi.Router) {