groupware: implement message search with snippets

This commit is contained in:
Pascal Bleser
2025-08-05 16:28:31 +02:00
parent 5c561dfdf1
commit 67803b435a
6 changed files with 363 additions and 62 deletions
+76 -5
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)
+76 -16
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{} },
}
+5 -5
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 {