From 084eb005e301c4860cacffbb08433f176fbe8ace Mon Sep 17 00:00:00 2001
From: Pascal Bleser
Date: Fri, 8 Aug 2025 14:45:24 +0200
Subject: [PATCH] groupware: minor email searching response improvements +
started implementing vacation response setting API
---
pkg/jmap/jmap_api_email.go | 23 +++-
pkg/jmap/jmap_api_vacation.go | 117 ++++++++++++++++++
pkg/jmap/jmap_model.go | 23 ++++
.../groupware/pkg/groupware/groupware_api.go | 2 +-
.../pkg/groupware/groupware_api_identity.go | 2 +-
.../pkg/groupware/groupware_api_messages.go | 10 +-
.../pkg/groupware/groupware_api_vacation.go | 16 +++
.../pkg/groupware/groupware_error.go | 15 ++-
.../pkg/groupware/groupware_route.go | 8 +-
9 files changed, 201 insertions(+), 15 deletions(-)
diff --git a/pkg/jmap/jmap_api_email.go b/pkg/jmap/jmap_api_email.go
index 07faa9f04f..bd99d37a7b 100644
--- a/pkg/jmap/jmap_api_email.go
+++ b/pkg/jmap/jmap_api_email.go
@@ -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
})
}
diff --git a/pkg/jmap/jmap_api_vacation.go b/pkg/jmap/jmap_api_vacation.go
index a9e3936058..c26523470b 100644
--- a/pkg/jmap/jmap_api_vacation.go
+++ b/pkg/jmap/jmap_api_vacation.go
@@ -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
+ })
+}
diff --git a/pkg/jmap/jmap_model.go b/pkg/jmap/jmap_model.go
index 9cb7c51d41..2c35380d4e 100644
--- a/pkg/jmap/jmap_model.go
+++ b/pkg/jmap/jmap_model.go
@@ -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{} },
}
diff --git a/services/groupware/pkg/groupware/groupware_api.go b/services/groupware/pkg/groupware/groupware_api.go
index e3c3e0eea8..c01dfb0e25 100644
--- a/services/groupware/pkg/groupware/groupware_api.go
+++ b/services/groupware/pkg/groupware/groupware_api.go
@@ -1,7 +1,7 @@
package groupware
const (
- Version = "1.0.0"
+ Version = "0.0.1"
)
const (
diff --git a/services/groupware/pkg/groupware/groupware_api_identity.go b/services/groupware/pkg/groupware/groupware_api_identity.go
index 9a7f812184..a4e7b03459 100644
--- a/services/groupware/pkg/groupware/groupware_api_identity.go
+++ b/services/groupware/pkg/groupware/groupware_api_identity.go
@@ -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 {
diff --git a/services/groupware/pkg/groupware/groupware_api_messages.go b/services/groupware/pkg/groupware/groupware_api_messages.go
index 9a79521edb..8e910f3980 100644
--- a/services/groupware/pkg/groupware/groupware_api_messages.go
+++ b/services/groupware/pkg/groupware/groupware_api_messages.go
@@ -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)
diff --git a/services/groupware/pkg/groupware/groupware_api_vacation.go b/services/groupware/pkg/groupware/groupware_api_vacation.go
index b6b3dc8c16..e845fbfbc3 100644
--- a/services/groupware/pkg/groupware/groupware_api_vacation.go
+++ b/services/groupware/pkg/groupware/groupware_api_vacation.go
@@ -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)
+ })
+}
diff --git a/services/groupware/pkg/groupware/groupware_error.go b/services/groupware/pkg/groupware/groupware_error.go
index 15bc54ddf8..eeb01ba16e 100644
--- a/services/groupware/pkg/groupware/groupware_error.go
+++ b/services/groupware/pkg/groupware/groupware_error.go
@@ -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,
diff --git a/services/groupware/pkg/groupware/groupware_route.go b/services/groupware/pkg/groupware/groupware_route.go
index 687b6eccee..3c860eac04 100644
--- a/services/groupware/pkg/groupware/groupware_route.go
+++ b/services/groupware/pkg/groupware/groupware_route.go
@@ -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) {