groupware: minor email searching response improvements + started implementing vacation response setting API

This commit is contained in:
Pascal Bleser
2025-08-08 14:45:24 +02:00
parent 2bdbc5a42e
commit 084eb005e3
9 changed files with 201 additions and 15 deletions

View File

@@ -23,6 +23,9 @@ const (
type Emails struct {
Emails []Email `json:"emails,omitempty"`
Total int `json:"total,omitzero"`
Limit int `json:"limit,omitzero"`
Offset int `json:"offset,omitzero"`
State string `json:"state,omitempty"`
}
@@ -60,7 +63,7 @@ func (j *Client) GetAllEmails(accountId string, session *Session, ctx context.Co
Filter: &EmailFilterCondition{InMailbox: mailboxId},
Sort: []Sort{{Property: emailSortByReceivedAt, IsAscending: false}},
CollapseThreads: true,
CalculateTotal: false,
CalculateTotal: true,
}
if offset >= 0 {
query.Position = offset
@@ -87,12 +90,24 @@ func (j *Client) GetAllEmails(accountId string, session *Session, ctx context.Co
}
return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (Emails, Error) {
var response EmailGetResponse
err = retrieveResponseMatchParameters(body, EmailGet, "1", &response)
var queryResponse EmailQueryResponse
err = retrieveResponseMatchParameters(body, EmailQuery, "0", &queryResponse)
if err != nil {
return Emails{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
}
return Emails{Emails: response.List, State: body.SessionState}, nil
var getResponse EmailGetResponse
err = retrieveResponseMatchParameters(body, EmailGet, "1", &getResponse)
if err != nil {
return Emails{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
}
return Emails{
Emails: getResponse.List,
State: body.SessionState,
Total: queryResponse.Total,
Limit: queryResponse.Limit,
Offset: queryResponse.Position,
}, nil
})
}

View File

@@ -2,10 +2,15 @@ package jmap
import (
"context"
"time"
"github.com/opencloud-eu/opencloud/pkg/log"
)
const (
vacationResponseId = "singleton"
)
// https://jmap.io/spec-mail.html#vacationresponseget
func (j *Client) GetVacationResponse(accountId string, session *Session, ctx context.Context, logger *log.Logger) (VacationResponseGetResponse, Error) {
aid := session.MailAccountId(accountId)
@@ -20,3 +25,115 @@ func (j *Client) GetVacationResponse(accountId string, session *Session, ctx con
return response, simpleError(err, JmapErrorInvalidJmapResponsePayload)
})
}
type VacationResponseStatusChange struct {
VacationResponse VacationResponse `json:"vacationResponse"`
ResponseState string `json:"state"`
SessionState string `json:"sessionState"`
}
func (j *Client) SetVacationResponseStatus(accountId string, enabled bool, session *Session, ctx context.Context, logger *log.Logger) (VacationResponseStatusChange, Error) {
aid := session.MailAccountId(accountId)
logger = j.logger(aid, "EnableVacationResponse", session, logger)
cmd, err := request(invocation(VacationResponseSet, VacationResponseSetRequest{
AccountId: aid,
Update: map[string]PatchObject{
"u": {
"/isEnabled": enabled,
},
},
}, "0"))
if err != nil {
return VacationResponseStatusChange{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err}
}
return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (VacationResponseStatusChange, Error) {
var response VacationResponseSetResponse
err = retrieveResponseMatchParameters(body, VacationResponseSet, "0", &response)
if err != nil {
return VacationResponseStatusChange{}, simpleError(err, JmapErrorInvalidJmapResponsePayload)
}
updated, ok := response.Updated["u"]
if !ok {
// TODO implement error when not updated
}
return VacationResponseStatusChange{
VacationResponse: updated,
ResponseState: response.NewState,
SessionState: response.State,
}, nil
})
}
type VacationResponseBody struct {
// Should a vacation response be sent if a message arrives between the "fromDate" and "toDate"?
IsEnabled bool `json:"isEnabled"`
// If "isEnabled" is true, messages that arrive on or after this date-time (but before the "toDate" if defined) should receive the
// user's vacation response. If null, the vacation response is effective immediately.
FromDate time.Time `json:"fromDate,omitzero"`
// If "isEnabled" is true, messages that arrive before this date-time but on or after the "fromDate" if defined) should receive the
// user's vacation response. If null, the vacation response is effective indefinitely.
ToDate time.Time `json:"toDate,omitzero"`
// The subject that will be used by the message sent in response to messages when the vacation response is enabled.
// If null, an appropriate subject SHOULD be set by the server.
Subject string `json:"subject,omitempty"`
// The plaintext body to send in response to messages when the vacation response is enabled.
// If this is null, the server SHOULD generate a plaintext body part from the "htmlBody" when sending vacation responses
// but MAY choose to send the response as HTML only. If both "textBody" and "htmlBody" are null, an appropriate default
// body SHOULD be generated for responses by the server.
TextBody string `json:"textBody,omitempty"`
// The HTML body to send in response to messages when the vacation response is enabled.
// If this is null, the server MAY choose to generate an HTML body part from the "textBody" when sending vacation responses
// or MAY choose to send the response as plaintext only.
HtmlBody string `json:"htmlBody,omitempty"`
}
type VacationResponseChange struct {
VacationResponse VacationResponse `json:"vacationResponse"`
ResponseState string `json:"state"`
SessionState string `json:"sessionState"`
}
func (j *Client) SetVacationResponse(accountId string, vacation VacationResponseBody, session *Session, ctx context.Context, logger *log.Logger) (VacationResponseChange, Error) {
aid := session.MailAccountId(accountId)
logger = j.logger(aid, "SetVacationResponse", session, logger)
set := VacationResponseSetRequest{
AccountId: aid,
Create: map[string]VacationResponse{
vacationResponseId: {
IsEnabled: vacation.IsEnabled,
FromDate: vacation.FromDate,
ToDate: vacation.ToDate,
Subject: vacation.Subject,
TextBody: vacation.TextBody,
HtmlBody: vacation.HtmlBody,
},
},
}
cmd, err := request(invocation(VacationResponseSet, set, "0"))
if err != nil {
return VacationResponseChange{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err}
}
return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (VacationResponseChange, Error) {
var response VacationResponseSetResponse
err = retrieveResponseMatchParameters(body, VacationResponseSet, "0", &response)
if err != nil {
return VacationResponseChange{}, simpleError(err, JmapErrorInvalidJmapResponsePayload)
}
created, ok := response.Created[vacationResponseId]
if !ok {
// TODO handle case where created is missing
}
return VacationResponseChange{
VacationResponse: created,
ResponseState: response.NewState,
SessionState: response.State,
}, nil
})
}

View File

@@ -1616,6 +1616,27 @@ type VacationResponseGetResponse struct {
NotFound []any `json:"notFound,omitempty"`
}
type VacationResponseSetRequest struct {
AccountId string `json:"accountId"`
IfInState string `json:"ifInState,omitempty"`
Create map[string]VacationResponse `json:"create,omitempty"`
Update map[string]PatchObject `json:"update,omitempty"`
Destroy []string `json:"destroy,omitempty"`
}
type VacationResponseSetResponse struct {
AccountId string `json:"accountId"`
OldState string `json:"oldState,omitempty"`
NewState string `json:"newState,omitempty"`
Created map[string]VacationResponse `json:"created,omitempty"`
Updated map[string]VacationResponse `json:"updated,omitempty"`
Destroyed []string `json:"destroyed,omitempty"`
NotCreated map[string]SetError `json:"notCreated,omitempty"`
NotUpdated map[string]SetError `json:"notUpdated,omitempty"`
NotDestroyed map[string]SetError `json:"notDestroyed,omitempty"`
State string `json:"state,omitempty"`
}
// One of these attributes must be set, but not both.
type DataSourceObject struct {
DataAsText string `json:"data:asText,omitempty"`
@@ -1741,6 +1762,7 @@ const (
MailboxChanges Command = "Mailbox/changes"
IdentityGet Command = "Identity/get"
VacationResponseGet Command = "VacationResponse/get"
VacationResponseSet Command = "VacationResponse/set"
SearchSnippetGet Command = "SearchSnippet/get"
)
@@ -1758,5 +1780,6 @@ var CommandResponseTypeMap = map[Command]func() any{
ThreadGet: func() any { return ThreadGetResponse{} },
IdentityGet: func() any { return IdentityGetResponse{} },
VacationResponseGet: func() any { return VacationResponseGetResponse{} },
VacationResponseSet: func() any { return VacationResponseSetResponse{} },
SearchSnippetGet: func() any { return SearchSnippetGetResponse{} },
}

View File

@@ -1,7 +1,7 @@
package groupware
const (
Version = "1.0.0"
Version = "0.0.1"
)
const (

View File

@@ -4,7 +4,7 @@ import (
"net/http"
)
func (g Groupware) GetIdentity(w http.ResponseWriter, r *http.Request) {
func (g Groupware) GetIdentities(w http.ResponseWriter, r *http.Request) {
g.respond(w, r, func(req Request) Response {
res, err := g.jmap.GetIdentity(req.GetAccountId(), req.session, req.ctx, req.logger)
if err != nil {

View File

@@ -12,7 +12,7 @@ import (
"github.com/opencloud-eu/opencloud/pkg/log"
)
func (g Groupware) GetAllMessages(w http.ResponseWriter, r *http.Request) {
func (g Groupware) GetAllMessagesInMailbox(w http.ResponseWriter, r *http.Request) {
mailboxId := chi.URLParam(r, UriParamMailboxId)
since := r.Header.Get(HeaderSince)
@@ -280,6 +280,14 @@ func (g Groupware) searchMessages(w http.ResponseWriter, r *http.Request) {
return errResp
}
var empty jmap.EmailFilterElement
if filter == empty {
errorId := req.errorId()
msg := "Invalid search request has no criteria"
return errorResponse(apiError(errorId, ErrorInvalidUserRequest, withDetail(msg)))
}
fetchEmails, ok, err := req.parseBoolParam(QueryParamSearchFetchEmails, false)
if err != nil {
return errorResponse(err)

View File

@@ -37,3 +37,19 @@ func (g Groupware) GetVacation(w http.ResponseWriter, r *http.Request) {
return response(res, res.State)
})
}
func (g Groupware) SetVacation(w http.ResponseWriter, r *http.Request) {
g.respond(w, r, func(req Request) Response {
var body jmap.VacationResponseBody
err := req.body(&body)
if err != nil {
return errorResponse(err)
}
res, jerr := g.jmap.SetVacationResponse(req.GetAccountId(), body, req.session, req.ctx, req.logger)
if jerr != nil {
return req.errorResponseFromJmap(jerr)
}
return response(res, res.SessionState)
})
}

View File

@@ -119,7 +119,7 @@ func groupwareErrorFromJmap(j jmap.Error) *GroupwareError {
case jmap.JmapErrorAuthenticationFailed:
return &ErrorForbidden
case jmap.JmapErrorInvalidHttpRequest:
return &ErrorInvalidRequest
return &ErrorInvalidBackendRequest
case jmap.JmapErrorServerResponse:
return &ErrorServerResponse
case jmap.JmapErrorReadingResponseBody:
@@ -148,7 +148,7 @@ const (
ErrorCodeInvalidAuthentication = "AUTINV"
ErrorCodeMissingAuthentication = "AUTMIS"
ErrorCodeForbiddenGeneric = "AUTFOR"
ErrorCodeInvalidRequest = "INVREQ"
ErrorCodeInvalidBackendRequest = "INVREQ"
ErrorCodeServerResponse = "SRVRSP"
ErrorCodeStreamingResponse = "SRVRST"
ErrorCodeServerReadingResponse = "SRVRRE"
@@ -162,6 +162,7 @@ const (
ErrorCodeInvalidRequestParameter = "INVPAR"
ErrorCodeNonExistingAccount = "INVACC"
ErrorCodeApiInconsistency = "APIINC"
ErrorCodeInvalidUserRequest = "INVURQ"
)
var (
@@ -189,9 +190,9 @@ var (
Title: "Invalid Authentication",
Detail: "Authentication credentials were provided but are either invalid or not authorized to perform the request operation.",
}
ErrorInvalidRequest = GroupwareError{
ErrorInvalidBackendRequest = GroupwareError{
Status: http.StatusInternalServerError,
Code: ErrorCodeInvalidRequest,
Code: ErrorCodeInvalidBackendRequest,
Title: "Invalid Request",
Detail: "The request that was meant to be sent to the mail server is invalid, which might be caused by configuration issues.",
}
@@ -261,6 +262,12 @@ var (
Title: "Invalid Request Parameter",
Detail: "At least one of the parameters in the request is invalid.",
}
ErrorInvalidUserRequest = GroupwareError{
Status: http.StatusBadRequest,
Code: ErrorCodeInvalidUserRequest,
Title: "Invalid Request",
Detail: "The request is invalid.",
}
ErrorNonExistingAccount = GroupwareError{
Status: http.StatusBadRequest,
Code: ErrorCodeNonExistingAccount,

View File

@@ -39,19 +39,19 @@ func (g Groupware) Route(r chi.Router) {
r.Get("/accounts", g.GetAccounts)
r.Route("/accounts/{accountid}", func(r chi.Router) {
r.Get("/", g.GetAccount)
r.Get("/identity", g.GetIdentity)
r.Get("/identities", g.GetIdentities)
r.Get("/vacation", g.GetVacation)
r.Post("/vacation", g.SetVacation)
r.Route("/mailboxes", func(r chi.Router) {
r.Get("/", g.GetMailboxes) // ?name=&role=&subcribed=
r.Get("/{mailbox}", g.GetMailbox)
r.Get("/{mailbox}/messages", g.GetAllMessages)
r.Get("/{mailbox}/messages", g.GetAllMessagesInMailbox)
})
r.Route("/messages", func(r chi.Router) {
r.Get("/", g.GetMessages) // ?fetchemails=true&fetchbodies=true&text=&subject=&body=&keyword=&keyword=&...
r.Post("/", g.CreateMessage)
r.Get("/{messageid}", g.GetMessagesById)
r.Patch("/{messageid}", g.UpdateMessage) // or PUT?
r.Put("/{messageid}", g.UpdateMessage) // or PATCH?
r.Put("/{messageid}", g.UpdateMessage) // or PATCH?
r.Delete("/{messageId}", g.DeleteMessage)
})
r.Route("/blobs", func(r chi.Router) {