From e6441e58d42f1d3e33c8d4652a8a0dcebdd0cc62 Mon Sep 17 00:00:00 2001
From: Pascal Bleser
Date: Wed, 6 Aug 2025 17:31:05 +0200
Subject: [PATCH] Groupware: refactor jmap package, implement Email/set,
EmailSubmission
* refactor the jmap package to split it into several files as the
jmap.api.go file was becoming too unwieldy
* refactor the Groupware handler function response to be a Response
object, to be more future-proof and avoid adding more and more
return parameters while handling "no content" response as well
* more godoc for the JMAP model
* add Email creation, updating, deleting (Email/set,
EmailSubmission/set)
* add endpoints
- POST /accounts/{accountid}/messages
- PATCH|PUT /accounts/{accountid}/messages/{messageid}
- DELETE /accounts/{accountid}/messages/{messageid}
---
pkg/jmap/jmap.go | 787 ------------------
pkg/jmap/jmap_api.go | 18 +
pkg/jmap/jmap_api_blob.go | 136 +++
pkg/jmap/jmap_api_email.go | 626 ++++++++++++++
pkg/jmap/jmap_api_identity.go | 22 +
pkg/jmap/jmap_api_mailbox.go | 71 ++
pkg/jmap/jmap_api_vacation.go | 22 +
pkg/jmap/jmap_client.go | 64 ++
pkg/jmap/jmap_model.go | 373 ++++++++-
pkg/jmap/jmap_session.go | 120 +++
.../pkg/groupware/groupware_api_account.go | 10 +-
.../pkg/groupware/groupware_api_blob.go | 17 +-
.../pkg/groupware/groupware_api_identity.go | 7 +-
.../pkg/groupware/groupware_api_index.go | 6 +-
.../pkg/groupware/groupware_api_mailbox.go | 18 +-
.../pkg/groupware/groupware_api_messages.go | 175 +++-
.../pkg/groupware/groupware_api_vacation.go | 7 +-
.../pkg/groupware/groupware_error.go | 25 +-
.../pkg/groupware/groupware_framework.go | 112 ++-
.../pkg/groupware/groupware_route.go | 12 +-
20 files changed, 1751 insertions(+), 877 deletions(-)
delete mode 100644 pkg/jmap/jmap.go
create mode 100644 pkg/jmap/jmap_api_blob.go
create mode 100644 pkg/jmap/jmap_api_email.go
create mode 100644 pkg/jmap/jmap_api_identity.go
create mode 100644 pkg/jmap/jmap_api_mailbox.go
create mode 100644 pkg/jmap/jmap_api_vacation.go
create mode 100644 pkg/jmap/jmap_client.go
create mode 100644 pkg/jmap/jmap_session.go
diff --git a/pkg/jmap/jmap.go b/pkg/jmap/jmap.go
deleted file mode 100644
index 933ec12bc5..0000000000
--- a/pkg/jmap/jmap.go
+++ /dev/null
@@ -1,787 +0,0 @@
-package jmap
-
-import (
- "context"
- "encoding/base64"
- "fmt"
- "io"
- "net/url"
- "strings"
-
- "github.com/opencloud-eu/opencloud/pkg/log"
- "github.com/rs/zerolog"
-)
-
-type SessionEventListener interface {
- OnSessionOutdated(session *Session)
-}
-
-type Client struct {
- wellKnown SessionClient
- api ApiClient
- blob BlobClient
- sessionEventListeners *eventListeners[SessionEventListener]
- io.Closer
-}
-
-func (j *Client) Close() error {
- return j.api.Close()
-}
-
-func NewClient(wellKnown SessionClient, api ApiClient, blob BlobClient) Client {
- return Client{
- wellKnown: wellKnown,
- api: api,
- blob: blob,
- sessionEventListeners: newEventListeners[SessionEventListener](),
- }
-}
-
-// Cached user related information
-//
-// This information is typically retrieved once (or at least for a certain period of time) from the
-// JMAP well-known endpoint of Stalwart and then kept in cache to avoid the performance cost of
-// retrieving it over and over again.
-//
-// This is really only needed due to the Graph API limitations, since ideally, the account ID should
-// be passed as a request parameter by the UI, in order to support a user having multiple accounts.
-//
-// Keeping track of the JMAP URL might be useful though, in case of Stalwart sharding strategies making
-// use of that, by providing different URLs for JMAP on a per-user basis, and that is not something
-// we would want to query before every single JMAP request. On the other hand, that then also creates
-// a risk of going out-of-sync, e.g. if a node is down and the user is reassigned to a different node.
-// There might be webhooks to subscribe to in Stalwart to be notified of such situations, in which case
-// the Session needs to be removed from the cache.
-//
-// The Username is only here for convenience, it could just as well be passed as a separate parameter
-// instead of being part of the Session, since the username is always part of the request (typically in
-// the authentication token payload.)
-type Session struct {
- // The name of the user to use to authenticate against Stalwart
- Username string
-
- // The base URL to use for JMAP operations towards Stalwart
- JmapUrl url.URL
-
- // The upload URL template
- UploadUrlTemplate string
-
- // The upload URL template
- DownloadUrlTemplate string
-
- // TODO
- DefaultMailAccountId string
-
- SessionResponse
-}
-
-// Create a new Session from a SessionResponse.
-func newSession(sessionResponse SessionResponse) (Session, Error) {
- username := sessionResponse.Username
- if username == "" {
- return Session{}, SimpleError{code: JmapErrorInvalidSessionResponse, err: fmt.Errorf("JMAP session response does not provide a username")}
- }
- mailAccountId := sessionResponse.PrimaryAccounts.Mail
- if mailAccountId == "" {
- return Session{}, SimpleError{code: JmapErrorInvalidSessionResponse, err: fmt.Errorf("JMAP session response does not provide a primary mail account")}
- }
- apiStr := sessionResponse.ApiUrl
- if apiStr == "" {
- return Session{}, SimpleError{code: JmapErrorInvalidSessionResponse, err: fmt.Errorf("JMAP session response does not provide an API URL")}
- }
- apiUrl, err := url.Parse(apiStr)
- if err != nil {
- return Session{}, SimpleError{code: JmapErrorInvalidSessionResponse, err: fmt.Errorf("JMAP session response provides an invalid API URL")}
- }
- uploadUrl := sessionResponse.UploadUrl
- if uploadUrl == "" {
- return Session{}, SimpleError{code: JmapErrorInvalidSessionResponse, err: fmt.Errorf("JMAP session response does not provide an upload URL")}
- }
- downloadUrl := sessionResponse.DownloadUrl
- if downloadUrl == "" {
- return Session{}, SimpleError{code: JmapErrorInvalidSessionResponse, err: fmt.Errorf("JMAP session response does not provide an download URL")}
- }
-
- return Session{
- Username: username,
- DefaultMailAccountId: mailAccountId,
- JmapUrl: *apiUrl,
- UploadUrlTemplate: uploadUrl,
- DownloadUrlTemplate: downloadUrl,
- SessionResponse: sessionResponse,
- }, nil
-}
-
-func (s *Session) MailAccountId(accountId string) string {
- if accountId != "" && accountId != defaultAccountId {
- return accountId
- }
- // TODO(pbleser-oc) handle case where there is no default mail account
- return s.DefaultMailAccountId
-}
-
-func (s *Session) BlobAccountId(accountId string) string {
- if accountId != "" && accountId != defaultAccountId {
- return accountId
- }
- // TODO(pbleser-oc) handle case where there is no default blob account
- return s.PrimaryAccounts.Blob
-}
-
-const (
- logOperation = "operation"
- logUsername = "username"
- logAccountId = "account-id"
- logMailboxId = "mailbox-id"
- logFetchBodies = "fetch-bodies"
- logOffset = "offset"
- logLimit = "limit"
- logApiUrl = "apiurl"
- logDownloadUrl = "downloadurl"
- logBlobId = "blobId"
- logUploadUrl = "downloadurl"
- logSessionState = "session-state"
- logSince = "since"
-
- defaultAccountId = "*"
-
- emailSortByReceivedAt = "receivedAt"
- emailSortBySize = "size"
- emailSortByFrom = "from"
- emailSortByTo = "to"
- emailSortBySubject = "subject"
- emailSortBySentAt = "sentAt"
- emailSortByHasKeyword = "hasKeyword"
- emailSortByAllInThreadHaveKeyword = "allInThreadHaveKeyword"
- emailSortBySomeInThreadHaveKeyword = "someInThreadHaveKeyword"
-)
-
-// Create a new log.Logger that is decorated with fields containing information about the Session.
-func (s Session) DecorateLogger(l log.Logger) log.Logger {
- return log.Logger{Logger: l.With().
- Str(logUsername, s.Username).
- Str(logApiUrl, s.ApiUrl).
- Str(logSessionState, s.State).
- Logger()}
-}
-
-func (j *Client) AddSessionEventListener(listener SessionEventListener) {
- j.sessionEventListeners.add(listener)
-}
-
-func (j *Client) onSessionOutdated(session *Session) {
- j.sessionEventListeners.signal(func(listener SessionEventListener) {
- listener.OnSessionOutdated(session)
- })
-}
-
-// Retrieve JMAP well-known data from the Stalwart server and create a Session from that.
-func (j *Client) FetchSession(username string, logger *log.Logger) (Session, Error) {
- wk, err := j.wellKnown.GetSession(username, logger)
- if err != nil {
- return Session{}, err
- }
- return newSession(wk)
-}
-
-func (j *Client) logger(accountId string, operation string, session *Session, logger *log.Logger) *log.Logger {
- zc := logger.With().Str(logOperation, operation).Str(logUsername, session.Username)
- if accountId != "" {
- zc = zc.Str(logAccountId, accountId)
- }
- return &log.Logger{Logger: zc.Logger()}
-}
-
-func (j *Client) loggerParams(accountId string, operation string, session *Session, logger *log.Logger, params func(zerolog.Context) zerolog.Context) *log.Logger {
- zc := logger.With().Str(logOperation, operation).Str(logUsername, session.Username)
- if accountId != "" {
- zc = zc.Str(logAccountId, accountId)
- }
- return &log.Logger{Logger: params(zc).Logger()}
-}
-
-// https://jmap.io/spec-mail.html#identityget
-func (j *Client) GetIdentity(accountId string, session *Session, ctx context.Context, logger *log.Logger) (IdentityGetResponse, Error) {
- aid := session.MailAccountId(accountId)
- logger = j.logger(aid, "GetIdentity", session, logger)
- cmd, err := request(invocation(IdentityGet, IdentityGetCommand{AccountId: aid}, "0"))
- if err != nil {
- return IdentityGetResponse{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err}
- }
- return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (IdentityGetResponse, Error) {
- var response IdentityGetResponse
- err = retrieveResponseMatchParameters(body, IdentityGet, "0", &response)
- return response, simpleError(err, JmapErrorInvalidJmapResponsePayload)
- })
-}
-
-// 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)
- logger = j.logger(aid, "GetVacationResponse", session, logger)
- cmd, err := request(invocation(VacationResponseGet, VacationResponseGetCommand{AccountId: aid}, "0"))
- if err != nil {
- return VacationResponseGetResponse{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err}
- }
- return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (VacationResponseGetResponse, Error) {
- var response VacationResponseGetResponse
- err = retrieveResponseMatchParameters(body, VacationResponseGet, "0", &response)
- return response, simpleError(err, JmapErrorInvalidJmapResponsePayload)
- })
-}
-
-// https://jmap.io/spec-mail.html#mailboxget
-func (j *Client) GetMailbox(accountId string, session *Session, ctx context.Context, logger *log.Logger, ids []string) (MailboxGetResponse, Error) {
- aid := session.MailAccountId(accountId)
- logger = j.logger(aid, "GetMailbox", session, logger)
- cmd, err := request(invocation(MailboxGet, MailboxGetCommand{AccountId: aid, Ids: ids}, "0"))
- if err != nil {
- return MailboxGetResponse{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err}
- }
- return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (MailboxGetResponse, Error) {
- var response MailboxGetResponse
- err = retrieveResponseMatchParameters(body, MailboxGet, "0", &response)
- return response, simpleError(err, JmapErrorInvalidJmapResponsePayload)
- })
-}
-
-func (j *Client) GetAllMailboxes(accountId string, session *Session, ctx context.Context, logger *log.Logger) (MailboxGetResponse, Error) {
- return j.GetMailbox(accountId, session, ctx, logger, nil)
-}
-
-// https://jmap.io/spec-mail.html#mailboxquery
-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, MailboxQueryCommand{AccountId: aid, Filter: filter}, "0"))
- if err != nil {
- return MailboxQueryResponse{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err}
- }
- return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (MailboxQueryResponse, Error) {
- var response MailboxQueryResponse
- err = retrieveResponseMatchParameters(body, MailboxQuery, "0", &response)
- return response, simpleError(err, JmapErrorInvalidJmapResponsePayload)
- })
-}
-
-type Mailboxes struct {
- Mailboxes []Mailbox `json:"mailboxes,omitempty"`
- State string `json:"state,omitempty"`
-}
-
-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, MailboxQueryCommand{AccountId: aid, Filter: filter}, "0"),
- invocation(MailboxGet, MailboxGetRefCommand{
- AccountId: aid,
- IdRef: &ResultReference{Name: MailboxQuery, Path: "/ids/*", ResultOf: "0"},
- }, "1"),
- )
- if err != nil {
- return Mailboxes{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err}
- }
-
- return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (Mailboxes, Error) {
- var response MailboxGetResponse
- err = retrieveResponseMatchParameters(body, MailboxGet, "1", &response)
- if err != nil {
- return Mailboxes{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
- }
- return Mailboxes{Mailboxes: response.List, State: body.SessionState}, nil
- })
-}
-
-type Emails struct {
- Emails []Email `json:"emails,omitempty"`
- State string `json:"state,omitempty"`
-}
-
-func (j *Client) GetEmails(accountId string, session *Session, ctx context.Context, logger *log.Logger, ids []string, fetchBodies bool, maxBodyValueBytes int) (Emails, Error) {
- aid := session.MailAccountId(accountId)
- logger = j.logger(aid, "GetEmails", session, logger)
-
- get := EmailGetCommand{AccountId: aid, Ids: ids, FetchAllBodyValues: fetchBodies}
- if maxBodyValueBytes >= 0 {
- get.MaxBodyValueBytes = maxBodyValueBytes
- }
-
- cmd, err := request(invocation(EmailGet, get, "0"))
- if err != nil {
- return Emails{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err}
- }
- return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (Emails, Error) {
- var response EmailGetResponse
- err = retrieveResponseMatchParameters(body, EmailGet, "0", &response)
- if err != nil {
- return Emails{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
- }
- return Emails{Emails: response.List, State: body.SessionState}, nil
- })
-}
-
-func (j *Client) GetAllEmails(accountId string, session *Session, ctx context.Context, logger *log.Logger, mailboxId string, offset int, limit int, fetchBodies bool, maxBodyValueBytes int) (Emails, Error) {
- aid := session.MailAccountId(accountId)
- logger = j.loggerParams(aid, "GetAllEmails", session, logger, func(z zerolog.Context) zerolog.Context {
- return z.Bool(logFetchBodies, fetchBodies).Int(logOffset, offset).Int(logLimit, limit)
- })
-
- query := EmailQueryCommand{
- AccountId: aid,
- Filter: &EmailFilterCondition{InMailbox: mailboxId},
- Sort: []Sort{{Property: emailSortByReceivedAt, IsAscending: false}},
- CollapseThreads: true,
- CalculateTotal: false,
- }
- if offset >= 0 {
- query.Position = offset
- }
- if limit >= 0 {
- query.Limit = limit
- }
-
- get := EmailGetRefCommand{
- AccountId: aid,
- FetchAllBodyValues: fetchBodies,
- IdRef: &ResultReference{Name: EmailQuery, Path: "/ids/*", ResultOf: "0"},
- }
- if maxBodyValueBytes >= 0 {
- get.MaxBodyValueBytes = maxBodyValueBytes
- }
-
- cmd, err := request(
- invocation(EmailQuery, query, "0"),
- invocation(EmailGet, get, "1"),
- )
- if err != nil {
- return Emails{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err}
- }
-
- return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (Emails, Error) {
- var response EmailGetResponse
- err = retrieveResponseMatchParameters(body, EmailGet, "1", &response)
- if err != nil {
- return Emails{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
- }
- return Emails{Emails: response.List, State: body.SessionState}, nil
- })
-}
-
-type EmailsSince struct {
- Destroyed []string `json:"destroyed,omitzero"`
- HasMoreChanges bool `json:"hasMoreChanges,omitzero"`
- NewState string `json:"newState"`
- Created []Email `json:"created,omitempty"`
- Updated []Email `json:"updated,omitempty"`
- State string `json:"state,omitempty"`
-}
-
-func (j *Client) GetEmailsInMailboxSince(accountId string, session *Session, ctx context.Context, logger *log.Logger, mailboxId string, since string, fetchBodies bool, maxBodyValueBytes int, maxChanges int) (EmailsSince, Error) {
- aid := session.MailAccountId(accountId)
- logger = j.loggerParams(aid, "GetEmailsInMailboxSince", session, logger, func(z zerolog.Context) zerolog.Context {
- return z.Bool(logFetchBodies, fetchBodies).Str(logSince, since)
- })
-
- changes := MailboxChangesCommand{
- AccountId: aid,
- SinceState: since,
- }
- if maxChanges >= 0 {
- changes.MaxChanges = maxChanges
- }
-
- getCreated := EmailGetRefCommand{
- AccountId: aid,
- FetchAllBodyValues: fetchBodies,
- IdRef: &ResultReference{Name: MailboxChanges, Path: "/created", ResultOf: "0"},
- }
- if maxBodyValueBytes >= 0 {
- getCreated.MaxBodyValueBytes = maxBodyValueBytes
- }
- getUpdated := EmailGetRefCommand{
- AccountId: aid,
- FetchAllBodyValues: fetchBodies,
- IdRef: &ResultReference{Name: MailboxChanges, Path: "/updated", ResultOf: "0"},
- }
- if maxBodyValueBytes >= 0 {
- getUpdated.MaxBodyValueBytes = maxBodyValueBytes
- }
-
- cmd, err := request(
- invocation(MailboxChanges, changes, "0"),
- invocation(EmailGet, getCreated, "1"),
- invocation(EmailGet, getUpdated, "2"),
- )
- if err != nil {
- return EmailsSince{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err}
- }
-
- return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (EmailsSince, Error) {
- var mailboxResponse MailboxChangesResponse
- err = retrieveResponseMatchParameters(body, MailboxChanges, "0", &mailboxResponse)
- if err != nil {
- return EmailsSince{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
- }
-
- var createdResponse EmailGetResponse
- err = retrieveResponseMatchParameters(body, EmailGet, "1", &createdResponse)
- if err != nil {
- return EmailsSince{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
- }
-
- var updatedResponse EmailGetResponse
- err = retrieveResponseMatchParameters(body, EmailGet, "2", &updatedResponse)
- if err != nil {
- return EmailsSince{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
- }
-
- return EmailsSince{
- Destroyed: mailboxResponse.Destroyed,
- HasMoreChanges: mailboxResponse.HasMoreChanges,
- NewState: mailboxResponse.NewState,
- Created: createdResponse.List,
- Updated: createdResponse.List,
- State: body.SessionState,
- }, nil
- })
-}
-
-func (j *Client) GetEmailsSince(accountId string, session *Session, ctx context.Context, logger *log.Logger, since string, fetchBodies bool, maxBodyValueBytes int, maxChanges int) (EmailsSince, Error) {
- aid := session.MailAccountId(accountId)
- logger = j.loggerParams(aid, "GetEmailsSince", session, logger, func(z zerolog.Context) zerolog.Context {
- return z.Bool(logFetchBodies, fetchBodies).Str(logSince, since)
- })
-
- changes := EmailChangesCommand{
- AccountId: aid,
- SinceState: since,
- }
- if maxChanges >= 0 {
- changes.MaxChanges = maxChanges
- }
-
- getCreated := EmailGetRefCommand{
- AccountId: aid,
- FetchAllBodyValues: fetchBodies,
- IdRef: &ResultReference{Name: EmailChanges, Path: "/created", ResultOf: "0"},
- }
- if maxBodyValueBytes >= 0 {
- getCreated.MaxBodyValueBytes = maxBodyValueBytes
- }
- getUpdated := EmailGetRefCommand{
- AccountId: aid,
- FetchAllBodyValues: fetchBodies,
- IdRef: &ResultReference{Name: EmailChanges, Path: "/updated", ResultOf: "0"},
- }
- if maxBodyValueBytes >= 0 {
- getUpdated.MaxBodyValueBytes = maxBodyValueBytes
- }
-
- cmd, err := request(
- invocation(EmailChanges, changes, "0"),
- invocation(EmailGet, getCreated, "1"),
- invocation(EmailGet, getUpdated, "2"),
- )
- if err != nil {
- return EmailsSince{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err}
- }
-
- return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (EmailsSince, Error) {
- var changesResponse EmailChangesResponse
- err = retrieveResponseMatchParameters(body, EmailChanges, "0", &changesResponse)
- if err != nil {
- return EmailsSince{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
- }
-
- var createdResponse EmailGetResponse
- err = retrieveResponseMatchParameters(body, EmailGet, "1", &createdResponse)
- if err != nil {
- return EmailsSince{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
- }
-
- var updatedResponse EmailGetResponse
- err = retrieveResponseMatchParameters(body, EmailGet, "2", &updatedResponse)
- if err != nil {
- return EmailsSince{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
- }
-
- return EmailsSince{
- Destroyed: changesResponse.Destroyed,
- HasMoreChanges: changesResponse.HasMoreChanges,
- NewState: changesResponse.NewState,
- Created: createdResponse.List,
- Updated: createdResponse.List,
- State: body.SessionState,
- }, nil
- })
-}
-
-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)
-
- cmd, err := request(
- invocation(BlobUpload, BlobGetCommand{
- AccountId: aid,
- Ids: []string{id},
- Properties: []string{BlobPropertyData, BlobPropertyDigestSha512, BlobPropertySize},
- }, "0"),
- )
- if err != nil {
- return nil, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err}
- }
-
- return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (*Blob, Error) {
- var response BlobGetResponse
- err = retrieveResponseMatchParameters(body, BlobGet, "0", &response)
- if err != nil {
- return nil, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
- }
-
- if len(response.List) != 1 {
- return nil, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
- }
- get := response.List[0]
- return &get, nil
- })
-}
-
-type UploadedBlob struct {
- Id string `json:"id"`
- Size int `json:"size"`
- Type string `json:"type"`
- Sha512 string `json:"sha:512"`
-}
-
-func (j *Client) UploadBlobStream(accountId string, session *Session, ctx context.Context, logger *log.Logger, contentType string, body io.Reader) (UploadedBlob, Error) {
- aid := session.BlobAccountId(accountId)
- // TODO(pbleser-oc) use a library for proper URL template parsing
- uploadUrl := strings.ReplaceAll(session.UploadUrlTemplate, "{accountId}", aid)
- return j.blob.UploadBinary(ctx, logger, session, uploadUrl, contentType, body)
-}
-
-func (j *Client) DownloadBlobStream(accountId string, blobId string, name string, typ string, session *Session, ctx context.Context, logger *log.Logger) (*BlobDownload, Error) {
- aid := session.BlobAccountId(accountId)
- // TODO(pbleser-oc) use a library for proper URL template parsing
- downloadUrl := session.DownloadUrlTemplate
- downloadUrl = strings.ReplaceAll(downloadUrl, "{accountId}", aid)
- downloadUrl = strings.ReplaceAll(downloadUrl, "{blobId}", blobId)
- downloadUrl = strings.ReplaceAll(downloadUrl, "{name}", name)
- downloadUrl = strings.ReplaceAll(downloadUrl, "{type}", typ)
- logger = &log.Logger{Logger: logger.With().Str(logDownloadUrl, downloadUrl).Str(logBlobId, blobId).Str(logAccountId, aid).Logger()}
- return j.blob.DownloadBinary(ctx, logger, session, downloadUrl)
-}
-
-func (j *Client) UploadBlob(accountId string, session *Session, ctx context.Context, logger *log.Logger, data []byte, contentType string) (UploadedBlob, Error) {
- aid := session.MailAccountId(accountId)
-
- encoded := base64.StdEncoding.EncodeToString(data)
-
- upload := BlobUploadCommand{
- AccountId: aid,
- Create: map[string]UploadObject{
- "0": {
- Data: []DataSourceObject{{
- DataAsBase64: encoded,
- }},
- Type: contentType,
- },
- },
- }
-
- getHash := BlobGetRefCommand{
- AccountId: aid,
- IdRef: &ResultReference{
- ResultOf: "0",
- Name: BlobUpload,
- Path: "/ids",
- },
- Properties: []string{BlobPropertyDigestSha512},
- }
-
- cmd, err := request(
- invocation(BlobUpload, upload, "0"),
- invocation(BlobGet, getHash, "1"),
- )
- if err != nil {
- return UploadedBlob{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err}
- }
-
- return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (UploadedBlob, Error) {
- var uploadResponse BlobUploadResponse
- err = retrieveResponseMatchParameters(body, BlobUpload, "0", &uploadResponse)
- if err != nil {
- return UploadedBlob{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
- }
-
- var getResponse BlobGetResponse
- err = retrieveResponseMatchParameters(body, BlobGet, "1", &getResponse)
- if err != nil {
- return UploadedBlob{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
- }
-
- if len(uploadResponse.Created) != 1 {
- return UploadedBlob{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
- }
- upload, ok := uploadResponse.Created["0"]
- if !ok {
- return UploadedBlob{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
- }
-
- if len(getResponse.List) != 1 {
- return UploadedBlob{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
- }
- get := getResponse.List[0]
-
- return UploadedBlob{
- Id: upload.Id,
- Size: upload.Size,
- Type: upload.Type,
- Sha512: get.DigestSha512,
- }, nil
- })
-
-}
-
-func (j *Client) ImportEmail(accountId string, session *Session, ctx context.Context, logger *log.Logger, data []byte) (UploadedBlob, Error) {
- aid := session.MailAccountId(accountId)
-
- encoded := base64.StdEncoding.EncodeToString(data)
-
- upload := BlobUploadCommand{
- AccountId: aid,
- Create: map[string]UploadObject{
- "0": {
- Data: []DataSourceObject{{
- DataAsBase64: encoded,
- }},
- Type: EmailMimeType,
- },
- },
- }
-
- getHash := BlobGetRefCommand{
- AccountId: aid,
- IdRef: &ResultReference{
- ResultOf: "0",
- Name: BlobUpload,
- Path: "/ids",
- },
- Properties: []string{BlobPropertyDigestSha512},
- }
-
- cmd, err := request(
- invocation(BlobUpload, upload, "0"),
- invocation(BlobGet, getHash, "1"),
- )
- if err != nil {
- return UploadedBlob{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err}
- }
-
- return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (UploadedBlob, Error) {
- var uploadResponse BlobUploadResponse
- err = retrieveResponseMatchParameters(body, BlobUpload, "0", &uploadResponse)
- if err != nil {
- return UploadedBlob{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
- }
-
- var getResponse BlobGetResponse
- err = retrieveResponseMatchParameters(body, BlobGet, "1", &getResponse)
- if err != nil {
- return UploadedBlob{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
- }
-
- if len(uploadResponse.Created) != 1 {
- return UploadedBlob{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
- }
- upload, ok := uploadResponse.Created["0"]
- if !ok {
- return UploadedBlob{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
- }
-
- if len(getResponse.List) != 1 {
- return UploadedBlob{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
- }
- get := getResponse.List[0]
-
- return UploadedBlob{
- Id: upload.Id,
- Size: upload.Size,
- Type: upload.Type,
- Sha512: get.DigestSha512,
- }, nil
- })
-
-}
diff --git a/pkg/jmap/jmap_api.go b/pkg/jmap/jmap_api.go
index e9d93618df..bf996e1dc1 100644
--- a/pkg/jmap/jmap_api.go
+++ b/pkg/jmap/jmap_api.go
@@ -20,3 +20,21 @@ type BlobClient interface {
UploadBinary(ctx context.Context, logger *log.Logger, session *Session, uploadUrl string, contentType string, content io.Reader) (UploadedBlob, Error)
DownloadBinary(ctx context.Context, logger *log.Logger, session *Session, downloadUrl string) (*BlobDownload, Error)
}
+
+const (
+ logOperation = "operation"
+ logUsername = "username"
+ logAccountId = "account-id"
+ logMailboxId = "mailbox-id"
+ logFetchBodies = "fetch-bodies"
+ logOffset = "offset"
+ logLimit = "limit"
+ logApiUrl = "apiurl"
+ logDownloadUrl = "downloadurl"
+ logBlobId = "blobId"
+ logUploadUrl = "downloadurl"
+ logSessionState = "session-state"
+ logSince = "since"
+
+ defaultAccountId = "*"
+)
diff --git a/pkg/jmap/jmap_api_blob.go b/pkg/jmap/jmap_api_blob.go
new file mode 100644
index 0000000000..e7406d5206
--- /dev/null
+++ b/pkg/jmap/jmap_api_blob.go
@@ -0,0 +1,136 @@
+package jmap
+
+import (
+ "context"
+ "encoding/base64"
+ "io"
+ "strings"
+
+ "github.com/opencloud-eu/opencloud/pkg/log"
+)
+
+func (j *Client) GetBlob(accountId string, session *Session, ctx context.Context, logger *log.Logger, id string) (*Blob, Error) {
+ aid := session.BlobAccountId(accountId)
+
+ cmd, err := request(
+ invocation(BlobUpload, BlobGetCommand{
+ AccountId: aid,
+ Ids: []string{id},
+ Properties: []string{BlobPropertyData, BlobPropertyDigestSha512, BlobPropertySize},
+ }, "0"),
+ )
+ if err != nil {
+ return nil, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err}
+ }
+
+ return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (*Blob, Error) {
+ var response BlobGetResponse
+ err = retrieveResponseMatchParameters(body, BlobGet, "0", &response)
+ if err != nil {
+ return nil, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
+ }
+
+ if len(response.List) != 1 {
+ return nil, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
+ }
+ get := response.List[0]
+ return &get, nil
+ })
+}
+
+type UploadedBlob struct {
+ Id string `json:"id"`
+ Size int `json:"size"`
+ Type string `json:"type"`
+ Sha512 string `json:"sha:512"`
+}
+
+func (j *Client) UploadBlobStream(accountId string, session *Session, ctx context.Context, logger *log.Logger, contentType string, body io.Reader) (UploadedBlob, Error) {
+ aid := session.BlobAccountId(accountId)
+ // TODO(pbleser-oc) use a library for proper URL template parsing
+ uploadUrl := strings.ReplaceAll(session.UploadUrlTemplate, "{accountId}", aid)
+ return j.blob.UploadBinary(ctx, logger, session, uploadUrl, contentType, body)
+}
+
+func (j *Client) DownloadBlobStream(accountId string, blobId string, name string, typ string, session *Session, ctx context.Context, logger *log.Logger) (*BlobDownload, Error) {
+ aid := session.BlobAccountId(accountId)
+ // TODO(pbleser-oc) use a library for proper URL template parsing
+ downloadUrl := session.DownloadUrlTemplate
+ downloadUrl = strings.ReplaceAll(downloadUrl, "{accountId}", aid)
+ downloadUrl = strings.ReplaceAll(downloadUrl, "{blobId}", blobId)
+ downloadUrl = strings.ReplaceAll(downloadUrl, "{name}", name)
+ downloadUrl = strings.ReplaceAll(downloadUrl, "{type}", typ)
+ logger = &log.Logger{Logger: logger.With().Str(logDownloadUrl, downloadUrl).Str(logBlobId, blobId).Str(logAccountId, aid).Logger()}
+ return j.blob.DownloadBinary(ctx, logger, session, downloadUrl)
+}
+
+func (j *Client) UploadBlob(accountId string, session *Session, ctx context.Context, logger *log.Logger, data []byte, contentType string) (UploadedBlob, Error) {
+ aid := session.MailAccountId(accountId)
+
+ encoded := base64.StdEncoding.EncodeToString(data)
+
+ upload := BlobUploadCommand{
+ AccountId: aid,
+ Create: map[string]UploadObject{
+ "0": {
+ Data: []DataSourceObject{{
+ DataAsBase64: encoded,
+ }},
+ Type: contentType,
+ },
+ },
+ }
+
+ getHash := BlobGetRefCommand{
+ AccountId: aid,
+ IdRef: &ResultReference{
+ ResultOf: "0",
+ Name: BlobUpload,
+ Path: "/ids",
+ },
+ Properties: []string{BlobPropertyDigestSha512},
+ }
+
+ cmd, err := request(
+ invocation(BlobUpload, upload, "0"),
+ invocation(BlobGet, getHash, "1"),
+ )
+ if err != nil {
+ return UploadedBlob{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err}
+ }
+
+ return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (UploadedBlob, Error) {
+ var uploadResponse BlobUploadResponse
+ err = retrieveResponseMatchParameters(body, BlobUpload, "0", &uploadResponse)
+ if err != nil {
+ return UploadedBlob{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
+ }
+
+ var getResponse BlobGetResponse
+ err = retrieveResponseMatchParameters(body, BlobGet, "1", &getResponse)
+ if err != nil {
+ return UploadedBlob{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
+ }
+
+ if len(uploadResponse.Created) != 1 {
+ return UploadedBlob{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
+ }
+ upload, ok := uploadResponse.Created["0"]
+ if !ok {
+ return UploadedBlob{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
+ }
+
+ if len(getResponse.List) != 1 {
+ return UploadedBlob{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
+ }
+ get := getResponse.List[0]
+
+ return UploadedBlob{
+ Id: upload.Id,
+ Size: upload.Size,
+ Type: upload.Type,
+ Sha512: get.DigestSha512,
+ }, nil
+ })
+
+}
diff --git a/pkg/jmap/jmap_api_email.go b/pkg/jmap/jmap_api_email.go
new file mode 100644
index 0000000000..b3a4d98eb4
--- /dev/null
+++ b/pkg/jmap/jmap_api_email.go
@@ -0,0 +1,626 @@
+package jmap
+
+import (
+ "context"
+ "encoding/base64"
+ "time"
+
+ "github.com/opencloud-eu/opencloud/pkg/log"
+ "github.com/rs/zerolog"
+)
+
+const (
+ emailSortByReceivedAt = "receivedAt"
+ emailSortBySize = "size"
+ emailSortByFrom = "from"
+ emailSortByTo = "to"
+ emailSortBySubject = "subject"
+ emailSortBySentAt = "sentAt"
+ emailSortByHasKeyword = "hasKeyword"
+ emailSortByAllInThreadHaveKeyword = "allInThreadHaveKeyword"
+ emailSortBySomeInThreadHaveKeyword = "someInThreadHaveKeyword"
+)
+
+type Emails struct {
+ Emails []Email `json:"emails,omitempty"`
+ State string `json:"state,omitempty"`
+}
+
+func (j *Client) GetEmails(accountId string, session *Session, ctx context.Context, logger *log.Logger, ids []string, fetchBodies bool, maxBodyValueBytes int) (Emails, Error) {
+ aid := session.MailAccountId(accountId)
+ logger = j.logger(aid, "GetEmails", session, logger)
+
+ get := EmailGetCommand{AccountId: aid, Ids: ids, FetchAllBodyValues: fetchBodies}
+ if maxBodyValueBytes >= 0 {
+ get.MaxBodyValueBytes = maxBodyValueBytes
+ }
+
+ cmd, err := request(invocation(EmailGet, get, "0"))
+ if err != nil {
+ return Emails{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err}
+ }
+ return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (Emails, Error) {
+ var response EmailGetResponse
+ err = retrieveResponseMatchParameters(body, EmailGet, "0", &response)
+ if err != nil {
+ return Emails{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
+ }
+ return Emails{Emails: response.List, State: body.SessionState}, nil
+ })
+}
+
+func (j *Client) GetAllEmails(accountId string, session *Session, ctx context.Context, logger *log.Logger, mailboxId string, offset int, limit int, fetchBodies bool, maxBodyValueBytes int) (Emails, Error) {
+ aid := session.MailAccountId(accountId)
+ logger = j.loggerParams(aid, "GetAllEmails", session, logger, func(z zerolog.Context) zerolog.Context {
+ return z.Bool(logFetchBodies, fetchBodies).Int(logOffset, offset).Int(logLimit, limit)
+ })
+
+ query := EmailQueryCommand{
+ AccountId: aid,
+ Filter: &EmailFilterCondition{InMailbox: mailboxId},
+ Sort: []Sort{{Property: emailSortByReceivedAt, IsAscending: false}},
+ CollapseThreads: true,
+ CalculateTotal: false,
+ }
+ if offset >= 0 {
+ query.Position = offset
+ }
+ if limit >= 0 {
+ query.Limit = limit
+ }
+
+ get := EmailGetRefCommand{
+ AccountId: aid,
+ FetchAllBodyValues: fetchBodies,
+ IdRef: &ResultReference{Name: EmailQuery, Path: "/ids/*", ResultOf: "0"},
+ }
+ if maxBodyValueBytes >= 0 {
+ get.MaxBodyValueBytes = maxBodyValueBytes
+ }
+
+ cmd, err := request(
+ invocation(EmailQuery, query, "0"),
+ invocation(EmailGet, get, "1"),
+ )
+ if err != nil {
+ return Emails{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err}
+ }
+
+ return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (Emails, Error) {
+ var response EmailGetResponse
+ err = retrieveResponseMatchParameters(body, EmailGet, "1", &response)
+ if err != nil {
+ return Emails{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
+ }
+ return Emails{Emails: response.List, State: body.SessionState}, nil
+ })
+}
+
+type EmailsSince struct {
+ Destroyed []string `json:"destroyed,omitzero"`
+ HasMoreChanges bool `json:"hasMoreChanges,omitzero"`
+ NewState string `json:"newState"`
+ Created []Email `json:"created,omitempty"`
+ Updated []Email `json:"updated,omitempty"`
+ State string `json:"state,omitempty"`
+}
+
+func (j *Client) GetEmailsInMailboxSince(accountId string, session *Session, ctx context.Context, logger *log.Logger, mailboxId string, since string, fetchBodies bool, maxBodyValueBytes int, maxChanges int) (EmailsSince, Error) {
+ aid := session.MailAccountId(accountId)
+ logger = j.loggerParams(aid, "GetEmailsInMailboxSince", session, logger, func(z zerolog.Context) zerolog.Context {
+ return z.Bool(logFetchBodies, fetchBodies).Str(logSince, since)
+ })
+
+ changes := MailboxChangesCommand{
+ AccountId: aid,
+ SinceState: since,
+ }
+ if maxChanges >= 0 {
+ changes.MaxChanges = maxChanges
+ }
+
+ getCreated := EmailGetRefCommand{
+ AccountId: aid,
+ FetchAllBodyValues: fetchBodies,
+ IdRef: &ResultReference{Name: MailboxChanges, Path: "/created", ResultOf: "0"},
+ }
+ if maxBodyValueBytes >= 0 {
+ getCreated.MaxBodyValueBytes = maxBodyValueBytes
+ }
+ getUpdated := EmailGetRefCommand{
+ AccountId: aid,
+ FetchAllBodyValues: fetchBodies,
+ IdRef: &ResultReference{Name: MailboxChanges, Path: "/updated", ResultOf: "0"},
+ }
+ if maxBodyValueBytes >= 0 {
+ getUpdated.MaxBodyValueBytes = maxBodyValueBytes
+ }
+
+ cmd, err := request(
+ invocation(MailboxChanges, changes, "0"),
+ invocation(EmailGet, getCreated, "1"),
+ invocation(EmailGet, getUpdated, "2"),
+ )
+ if err != nil {
+ return EmailsSince{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err}
+ }
+
+ return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (EmailsSince, Error) {
+ var mailboxResponse MailboxChangesResponse
+ err = retrieveResponseMatchParameters(body, MailboxChanges, "0", &mailboxResponse)
+ if err != nil {
+ return EmailsSince{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
+ }
+
+ var createdResponse EmailGetResponse
+ err = retrieveResponseMatchParameters(body, EmailGet, "1", &createdResponse)
+ if err != nil {
+ return EmailsSince{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
+ }
+
+ var updatedResponse EmailGetResponse
+ err = retrieveResponseMatchParameters(body, EmailGet, "2", &updatedResponse)
+ if err != nil {
+ return EmailsSince{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
+ }
+
+ return EmailsSince{
+ Destroyed: mailboxResponse.Destroyed,
+ HasMoreChanges: mailboxResponse.HasMoreChanges,
+ NewState: mailboxResponse.NewState,
+ Created: createdResponse.List,
+ Updated: createdResponse.List,
+ State: body.SessionState,
+ }, nil
+ })
+}
+
+func (j *Client) GetEmailsSince(accountId string, session *Session, ctx context.Context, logger *log.Logger, since string, fetchBodies bool, maxBodyValueBytes int, maxChanges int) (EmailsSince, Error) {
+ aid := session.MailAccountId(accountId)
+ logger = j.loggerParams(aid, "GetEmailsSince", session, logger, func(z zerolog.Context) zerolog.Context {
+ return z.Bool(logFetchBodies, fetchBodies).Str(logSince, since)
+ })
+
+ changes := EmailChangesCommand{
+ AccountId: aid,
+ SinceState: since,
+ }
+ if maxChanges >= 0 {
+ changes.MaxChanges = maxChanges
+ }
+
+ getCreated := EmailGetRefCommand{
+ AccountId: aid,
+ FetchAllBodyValues: fetchBodies,
+ IdRef: &ResultReference{Name: EmailChanges, Path: "/created", ResultOf: "0"},
+ }
+ if maxBodyValueBytes >= 0 {
+ getCreated.MaxBodyValueBytes = maxBodyValueBytes
+ }
+ getUpdated := EmailGetRefCommand{
+ AccountId: aid,
+ FetchAllBodyValues: fetchBodies,
+ IdRef: &ResultReference{Name: EmailChanges, Path: "/updated", ResultOf: "0"},
+ }
+ if maxBodyValueBytes >= 0 {
+ getUpdated.MaxBodyValueBytes = maxBodyValueBytes
+ }
+
+ cmd, err := request(
+ invocation(EmailChanges, changes, "0"),
+ invocation(EmailGet, getCreated, "1"),
+ invocation(EmailGet, getUpdated, "2"),
+ )
+ if err != nil {
+ return EmailsSince{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err}
+ }
+
+ return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (EmailsSince, Error) {
+ var changesResponse EmailChangesResponse
+ err = retrieveResponseMatchParameters(body, EmailChanges, "0", &changesResponse)
+ if err != nil {
+ return EmailsSince{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
+ }
+
+ var createdResponse EmailGetResponse
+ err = retrieveResponseMatchParameters(body, EmailGet, "1", &createdResponse)
+ if err != nil {
+ return EmailsSince{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
+ }
+
+ var updatedResponse EmailGetResponse
+ err = retrieveResponseMatchParameters(body, EmailGet, "2", &updatedResponse)
+ if err != nil {
+ return EmailsSince{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
+ }
+
+ return EmailsSince{
+ Destroyed: changesResponse.Destroyed,
+ HasMoreChanges: changesResponse.HasMoreChanges,
+ NewState: changesResponse.NewState,
+ Created: createdResponse.List,
+ Updated: createdResponse.List,
+ State: body.SessionState,
+ }, nil
+ })
+}
+
+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"`
+ SessionState string `json:"sessionState,omitempty"`
+}
+
+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,
+ SessionState: body.SessionState,
+ }, nil
+ })
+
+}
+
+type UploadedEmail struct {
+ Id string `json:"id"`
+ Size int `json:"size"`
+ Type string `json:"type"`
+ Sha512 string `json:"sha:512"`
+}
+
+func (j *Client) ImportEmail(accountId string, session *Session, ctx context.Context, logger *log.Logger, data []byte) (UploadedEmail, Error) {
+ aid := session.MailAccountId(accountId)
+
+ encoded := base64.StdEncoding.EncodeToString(data)
+
+ upload := BlobUploadCommand{
+ AccountId: aid,
+ Create: map[string]UploadObject{
+ "0": {
+ Data: []DataSourceObject{{
+ DataAsBase64: encoded,
+ }},
+ Type: EmailMimeType,
+ },
+ },
+ }
+
+ getHash := BlobGetRefCommand{
+ AccountId: aid,
+ IdRef: &ResultReference{
+ ResultOf: "0",
+ Name: BlobUpload,
+ Path: "/ids",
+ },
+ Properties: []string{BlobPropertyDigestSha512},
+ }
+
+ cmd, err := request(
+ invocation(BlobUpload, upload, "0"),
+ invocation(BlobGet, getHash, "1"),
+ )
+ if err != nil {
+ return UploadedEmail{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err}
+ }
+
+ return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (UploadedEmail, Error) {
+ var uploadResponse BlobUploadResponse
+ err = retrieveResponseMatchParameters(body, BlobUpload, "0", &uploadResponse)
+ if err != nil {
+ return UploadedEmail{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
+ }
+
+ var getResponse BlobGetResponse
+ err = retrieveResponseMatchParameters(body, BlobGet, "1", &getResponse)
+ if err != nil {
+ return UploadedEmail{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
+ }
+
+ if len(uploadResponse.Created) != 1 {
+ return UploadedEmail{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
+ }
+ upload, ok := uploadResponse.Created["0"]
+ if !ok {
+ return UploadedEmail{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
+ }
+
+ if len(getResponse.List) != 1 {
+ return UploadedEmail{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
+ }
+ get := getResponse.List[0]
+
+ return UploadedEmail{
+ Id: upload.Id,
+ Size: upload.Size,
+ Type: upload.Type,
+ Sha512: get.DigestSha512,
+ }, nil
+ })
+
+}
+
+type CreatedEmail struct {
+ Email Email `json:"email"`
+ State string `json:"state"`
+}
+
+func (j *Client) CreateEmail(accountId string, email EmailCreate, session *Session, ctx context.Context, logger *log.Logger) (CreatedEmail, Error) {
+ aid := session.MailAccountId(accountId)
+
+ cmd, err := request(
+ invocation(EmailSubmissionSet, EmailSetCommand{
+ AccountId: aid,
+ Create: map[string]EmailCreate{
+ "c": email,
+ },
+ }, "0"),
+ )
+ if err != nil {
+ return CreatedEmail{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err}
+ }
+
+ return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (CreatedEmail, Error) {
+ var setResponse EmailSetResponse
+ err = retrieveResponseMatchParameters(body, EmailSet, "0", &setResponse)
+ if err != nil {
+ return CreatedEmail{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
+ }
+
+ if len(setResponse.NotCreated) > 0 {
+ // error occured
+ // TODO(pbleser-oc) handle submission errors
+ }
+
+ created, ok := setResponse.Created["c"]
+ if !ok {
+ // failed to create?
+ // TODO(pbleser-oc) handle email creation failure
+ }
+
+ return CreatedEmail{
+ Email: created,
+ State: setResponse.NewState,
+ }, nil
+ })
+}
+
+type UpdatedEmails struct {
+ Updated map[string]Email `json:"email"`
+ State string `json:"state"`
+}
+
+// The Email/set method encompasses:
+// - Changing the keywords of an Email (e.g., unread/flagged status)
+// - Adding/removing an Email to/from Mailboxes (moving a message)
+// - Deleting Emails
+//
+// To create drafts, use the CreateEmail function instead.
+//
+// To delete mails, use the DeleteEmails function instead.
+func (j *Client) UpdateEmails(accountId string, updates map[string]EmailUpdate, session *Session, ctx context.Context, logger *log.Logger) (UpdatedEmails, Error) {
+ aid := session.MailAccountId(accountId)
+
+ cmd, err := request(
+ invocation(EmailSet, EmailSetCommand{
+ AccountId: aid,
+ Update: updates,
+ }, "0"),
+ )
+ if err != nil {
+ return UpdatedEmails{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err}
+ }
+
+ return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (UpdatedEmails, Error) {
+ var setResponse EmailSetResponse
+ err = retrieveResponseMatchParameters(body, EmailSet, "0", &setResponse)
+ if err != nil {
+ return UpdatedEmails{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
+ }
+ if len(setResponse.NotUpdated) != len(updates) {
+ // error occured
+ // TODO(pbleser-oc) handle submission errors
+ }
+ return UpdatedEmails{
+ Updated: setResponse.Updated,
+ State: setResponse.NewState,
+ }, nil
+ })
+}
+
+type DeletedEmails struct {
+ State string `json:"state"`
+}
+
+func (j *Client) DeleteEmails(accountId string, destroy []string, session *Session, ctx context.Context, logger *log.Logger) (DeletedEmails, Error) {
+ aid := session.MailAccountId(accountId)
+
+ cmd, err := request(
+ invocation(EmailSet, EmailSetCommand{
+ AccountId: aid,
+ Destroy: destroy,
+ }, "0"),
+ )
+ if err != nil {
+ return DeletedEmails{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err}
+ }
+
+ return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (DeletedEmails, Error) {
+ var setResponse EmailSetResponse
+ err = retrieveResponseMatchParameters(body, EmailSet, "0", &setResponse)
+ if err != nil {
+ return DeletedEmails{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
+ }
+ if len(setResponse.NotDestroyed) != len(destroy) {
+ // error occured
+ // TODO(pbleser-oc) handle submission errors
+ }
+ return DeletedEmails{State: setResponse.NewState}, nil
+ })
+}
+
+type SubmittedEmail struct {
+ Id string `json:"id"`
+ State string `json:"state"`
+ SendAt time.Time `json:"sendAt,omitzero"`
+ ThreadId string `json:"threadId,omitempty"`
+ UndoStatus EmailSubmissionUndoStatus `json:"undoStatus,omitempty"`
+ Envelope Envelope `json:"envelope,omitempty"`
+
+ // A list of blob ids for DSNs [RFC3464] received for this submission,
+ // in order of receipt, oldest first.
+ //
+ // The blob is the whole MIME message (with a top-level content-type of multipart/report), as received.
+ //
+ // [RFC3464]: https://datatracker.ietf.org/doc/html/rfc3464
+ DsnBlobIds []string `json:"dsnBlobIds,omitempty"`
+
+ // A list of blob ids for MDNs [RFC8098] received for this submission,
+ // in order of receipt, oldest first.
+ //
+ // The blob is the whole MIME message (with a top-level content-type of multipart/report), as received.
+ //
+ // [RFC8098]: https://datatracker.ietf.org/doc/html/rfc8098
+ MdnBlobIds []string `json:"mdnBlobIds,omitempty"`
+}
+
+func (j *Client) SubmitEmail(accountId string, identityId string, emailId string, session *Session, ctx context.Context, logger *log.Logger, data []byte) (SubmittedEmail, Error) {
+ aid := session.SubmissionAccountId(accountId)
+
+ set := EmailSubmissionSetCommand{
+ AccountId: aid,
+ Create: map[string]EmailSubmissionCreate{
+ "s0": {
+ IdentityId: identityId,
+ EmailId: emailId,
+ },
+ },
+ OnSuccessUpdateEmail: map[string]PatchObject{
+ "#s0": {
+ "keywords/" + JmapKeywordDraft: nil,
+ },
+ },
+ }
+
+ get := EmailSubmissionGetRefCommand{
+ AccountId: aid,
+ IdRef: &ResultReference{
+ ResultOf: "0",
+ Name: EmailSubmissionSet,
+ Path: "/created/s0/id",
+ },
+ }
+
+ cmd, err := request(
+ invocation(EmailSubmissionSet, set, "0"),
+ invocation(EmailSubmissionGet, get, "1"),
+ )
+ if err != nil {
+ return SubmittedEmail{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err}
+ }
+
+ return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (SubmittedEmail, Error) {
+ var submissionResponse EmailSubmissionSetResponse
+ err = retrieveResponseMatchParameters(body, EmailSubmissionSet, "0", &submissionResponse)
+ if err != nil {
+ return SubmittedEmail{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
+ }
+
+ if len(submissionResponse.NotCreated) > 0 {
+ // error occured
+ // TODO(pbleser-oc) handle submission errors
+ }
+
+ // there is an implicit Email/set response:
+ // "After all create/update/destroy items in the EmailSubmission/set invocation have been processed,
+ // a single implicit Email/set call MUST be made to perform any changes requested in these two arguments.
+ // The response to this MUST be returned after the EmailSubmission/set response."
+ // from an example in the spec, it has the same tag as the EmailSubmission/set command ("0" in this case)
+ var setResponse EmailSetResponse
+ err = retrieveResponseMatchParameters(body, EmailSet, "0", &setResponse)
+ if err != nil {
+ return SubmittedEmail{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
+ }
+
+ var getResponse EmailSubmissionGetResponse
+ err = retrieveResponseMatchParameters(body, EmailSubmissionGet, "1", &getResponse)
+ if err != nil {
+ return SubmittedEmail{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
+ }
+
+ if len(getResponse.List) != 1 {
+ // for some reason (error?)...
+ // TODO(pbleser-oc) handle absence of emailsubmission
+ }
+
+ submission := getResponse.List[0]
+
+ return SubmittedEmail{
+ Id: submission.Id,
+ State: setResponse.NewState,
+ SendAt: submission.SendAt,
+ ThreadId: submission.ThreadId,
+ UndoStatus: submission.UndoStatus,
+ Envelope: *submission.Envelope,
+ DsnBlobIds: submission.DsnBlobIds,
+ MdnBlobIds: submission.MdnBlobIds,
+ }, nil
+ })
+
+}
diff --git a/pkg/jmap/jmap_api_identity.go b/pkg/jmap/jmap_api_identity.go
new file mode 100644
index 0000000000..84c0b342f1
--- /dev/null
+++ b/pkg/jmap/jmap_api_identity.go
@@ -0,0 +1,22 @@
+package jmap
+
+import (
+ "context"
+
+ "github.com/opencloud-eu/opencloud/pkg/log"
+)
+
+// https://jmap.io/spec-mail.html#identityget
+func (j *Client) GetIdentity(accountId string, session *Session, ctx context.Context, logger *log.Logger) (IdentityGetResponse, Error) {
+ aid := session.MailAccountId(accountId)
+ logger = j.logger(aid, "GetIdentity", session, logger)
+ cmd, err := request(invocation(IdentityGet, IdentityGetCommand{AccountId: aid}, "0"))
+ if err != nil {
+ return IdentityGetResponse{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err}
+ }
+ return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (IdentityGetResponse, Error) {
+ var response IdentityGetResponse
+ err = retrieveResponseMatchParameters(body, IdentityGet, "0", &response)
+ return response, simpleError(err, JmapErrorInvalidJmapResponsePayload)
+ })
+}
diff --git a/pkg/jmap/jmap_api_mailbox.go b/pkg/jmap/jmap_api_mailbox.go
new file mode 100644
index 0000000000..34efb0a231
--- /dev/null
+++ b/pkg/jmap/jmap_api_mailbox.go
@@ -0,0 +1,71 @@
+package jmap
+
+import (
+ "context"
+
+ "github.com/opencloud-eu/opencloud/pkg/log"
+)
+
+// https://jmap.io/spec-mail.html#mailboxget
+func (j *Client) GetMailbox(accountId string, session *Session, ctx context.Context, logger *log.Logger, ids []string) (MailboxGetResponse, Error) {
+ aid := session.MailAccountId(accountId)
+ logger = j.logger(aid, "GetMailbox", session, logger)
+ cmd, err := request(invocation(MailboxGet, MailboxGetCommand{AccountId: aid, Ids: ids}, "0"))
+ if err != nil {
+ return MailboxGetResponse{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err}
+ }
+ return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (MailboxGetResponse, Error) {
+ var response MailboxGetResponse
+ err = retrieveResponseMatchParameters(body, MailboxGet, "0", &response)
+ return response, simpleError(err, JmapErrorInvalidJmapResponsePayload)
+ })
+}
+
+func (j *Client) GetAllMailboxes(accountId string, session *Session, ctx context.Context, logger *log.Logger) (MailboxGetResponse, Error) {
+ return j.GetMailbox(accountId, session, ctx, logger, nil)
+}
+
+// https://jmap.io/spec-mail.html#mailboxquery
+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, MailboxQueryCommand{AccountId: aid, Filter: filter}, "0"))
+ if err != nil {
+ return MailboxQueryResponse{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err}
+ }
+ return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (MailboxQueryResponse, Error) {
+ var response MailboxQueryResponse
+ err = retrieveResponseMatchParameters(body, MailboxQuery, "0", &response)
+ return response, simpleError(err, JmapErrorInvalidJmapResponsePayload)
+ })
+}
+
+type Mailboxes struct {
+ Mailboxes []Mailbox `json:"mailboxes,omitempty"`
+ State string `json:"state,omitempty"`
+}
+
+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, MailboxQueryCommand{AccountId: aid, Filter: filter}, "0"),
+ invocation(MailboxGet, MailboxGetRefCommand{
+ AccountId: aid,
+ IdRef: &ResultReference{Name: MailboxQuery, Path: "/ids/*", ResultOf: "0"},
+ }, "1"),
+ )
+ if err != nil {
+ return Mailboxes{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err}
+ }
+
+ return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (Mailboxes, Error) {
+ var response MailboxGetResponse
+ err = retrieveResponseMatchParameters(body, MailboxGet, "1", &response)
+ if err != nil {
+ return Mailboxes{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
+ }
+ return Mailboxes{Mailboxes: response.List, State: body.SessionState}, nil
+ })
+}
diff --git a/pkg/jmap/jmap_api_vacation.go b/pkg/jmap/jmap_api_vacation.go
new file mode 100644
index 0000000000..a9e3936058
--- /dev/null
+++ b/pkg/jmap/jmap_api_vacation.go
@@ -0,0 +1,22 @@
+package jmap
+
+import (
+ "context"
+
+ "github.com/opencloud-eu/opencloud/pkg/log"
+)
+
+// 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)
+ logger = j.logger(aid, "GetVacationResponse", session, logger)
+ cmd, err := request(invocation(VacationResponseGet, VacationResponseGetCommand{AccountId: aid}, "0"))
+ if err != nil {
+ return VacationResponseGetResponse{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err}
+ }
+ return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (VacationResponseGetResponse, Error) {
+ var response VacationResponseGetResponse
+ err = retrieveResponseMatchParameters(body, VacationResponseGet, "0", &response)
+ return response, simpleError(err, JmapErrorInvalidJmapResponsePayload)
+ })
+}
diff --git a/pkg/jmap/jmap_client.go b/pkg/jmap/jmap_client.go
new file mode 100644
index 0000000000..d47d69ea94
--- /dev/null
+++ b/pkg/jmap/jmap_client.go
@@ -0,0 +1,64 @@
+package jmap
+
+import (
+ "io"
+
+ "github.com/opencloud-eu/opencloud/pkg/log"
+ "github.com/rs/zerolog"
+)
+
+type Client struct {
+ wellKnown SessionClient
+ api ApiClient
+ blob BlobClient
+ sessionEventListeners *eventListeners[SessionEventListener]
+ io.Closer
+}
+
+func (j *Client) Close() error {
+ return j.api.Close()
+}
+
+func NewClient(wellKnown SessionClient, api ApiClient, blob BlobClient) Client {
+ return Client{
+ wellKnown: wellKnown,
+ api: api,
+ blob: blob,
+ sessionEventListeners: newEventListeners[SessionEventListener](),
+ }
+}
+
+func (j *Client) AddSessionEventListener(listener SessionEventListener) {
+ j.sessionEventListeners.add(listener)
+}
+
+func (j *Client) onSessionOutdated(session *Session) {
+ j.sessionEventListeners.signal(func(listener SessionEventListener) {
+ listener.OnSessionOutdated(session)
+ })
+}
+
+// Retrieve JMAP well-known data from the Stalwart server and create a Session from that.
+func (j *Client) FetchSession(username string, logger *log.Logger) (Session, Error) {
+ wk, err := j.wellKnown.GetSession(username, logger)
+ if err != nil {
+ return Session{}, err
+ }
+ return newSession(wk)
+}
+
+func (j *Client) logger(accountId string, operation string, session *Session, logger *log.Logger) *log.Logger {
+ zc := logger.With().Str(logOperation, operation).Str(logUsername, session.Username)
+ if accountId != "" {
+ zc = zc.Str(logAccountId, accountId)
+ }
+ return &log.Logger{Logger: zc.Logger()}
+}
+
+func (j *Client) loggerParams(accountId string, operation string, session *Session, logger *log.Logger, params func(zerolog.Context) zerolog.Context) *log.Logger {
+ zc := logger.With().Str(logOperation, operation).Str(logUsername, session.Username)
+ if accountId != "" {
+ zc = zc.Str(logAccountId, accountId)
+ }
+ return &log.Logger{Logger: params(zc).Logger()}
+}
diff --git a/pkg/jmap/jmap_model.go b/pkg/jmap/jmap_model.go
index 9299af6093..44da27eb31 100644
--- a/pkg/jmap/jmap_model.go
+++ b/pkg/jmap/jmap_model.go
@@ -232,6 +232,42 @@ const (
// An ifInState argument was supplied, and it does not match the current state.
SetErrorTypeStateMismatch = "stateMismatch"
+
+ // The Email to be sent is invalid in some way.
+ //
+ // The SetError SHOULD contain a property called properties of type String[] that lists all the properties
+ // of the Email that were invalid.
+ SetErrorInvalidEmail = "invalidEmail"
+
+ // The envelope (supplied or generated) has more recipients than the server allows.
+ //
+ // A maxRecipients UnsignedInt property MUST also be present on the SetError specifying
+ // the maximum number of allowed recipients.
+ SetErrorTooManyRecipients = "tooManyRecipients"
+
+ // The envelope (supplied or generated) does not have any rcptTo email addresses.
+ SetErrorNoRecipients = "noRecipients"
+
+ // The rcptTo property of the envelope (supplied or generated) contains at least one rcptTo value which
+ // is not a valid email address for sending to.
+ //
+ // An invalidRecipients String[] property MUST also be present on the SetError, which is a list of the invalid addresses.
+ SetErrorInvalidRecipients = "invalidRecipients"
+
+ // The server does not permit the user to send a message with this envelope From address [RFC5321].
+ //
+ // [RFC5321]: https://datatracker.ietf.org/doc/html/rfc5321
+ SetErrorForbiddenMailFrom = "forbiddenMailFrom"
+
+ // The server does not permit the user to send a message with the From header field [RFC5322] of the message to be sent.
+ //
+ // [RFC5322]: https://datatracker.ietf.org/doc/html/rfc5322
+ SetErrorForbiddenFrom = "forbiddenFrom"
+
+ // The user does not have permission to send at all right now for some reason.
+ //
+ // A description String property MAY be present on the SetError object to display to the user why they are not permitted.
+ SetErrorForbiddenToSend = "forbiddenToSend"
)
type SetError struct {
@@ -242,6 +278,21 @@ type SetError struct {
//
// This is a non-localised string and is not intended to be shown directly to end users.
Description string `json:"description,omitempty"`
+
+ // Lists all the properties of the Email that were invalid.
+ //
+ // Only set for the invalidEmail error after a failed EmailSubmission/set errors.
+ Properties []string `json:"properties,omitempty"`
+
+ // Specifies the maximum number of allowed recipients.
+ //
+ // Only set for the tooManyRecipients error after a failed EmailSubmission/set errors.
+ MaxRecipients int `json:"maxRecipients,omitzero"`
+
+ // List of invalid addresses.
+ //
+ // Only set for the invalidRecipients error after a failed EmailSubmission/set errors.
+ InvalidRecipients []string `json:"invalidRecipients,omitempty"`
}
type FilterOperatorTerm string
@@ -607,6 +658,19 @@ type EmailHeader struct {
Value string `json:"value"`
}
+// Email body part.
+//
+// The client may specify a partId OR a blobId, but not both.
+// If a partId is given, this partId MUST be present in the bodyValues property.
+//
+// The charset property MUST be omitted if a partId is given (the part’s content is included
+// in bodyValues, and the server may choose any appropriate encoding).
+//
+// The size property MUST be omitted if a partId is given. If a blobId is given, it may be
+// included but is ignored by the server (the size is actually calculated from the blob content
+// itself).
+//
+// A Content-Transfer-Encoding header field MUST NOT be given.
type EmailBodyPart struct {
// Identifies this part uniquely within the Email.
//
@@ -895,6 +959,242 @@ type Email struct {
Preview string `json:"preview,omitempty"`
}
+type Address struct {
+ // The email address being represented by the object.
+ //
+ // This is a “Mailbox” as used in the Reverse-path or Forward-path of the MAIL FROM or RCPT TO command in [RFC5321].
+ //
+ // [RFC5321]: https://datatracker.ietf.org/doc/html/rfc5321
+ Email string `json:"email,omitempty"`
+
+ // Any parameters to send with the email address (either mail-parameter or rcpt-parameter as appropriate,
+ // as specified in [RFC5321]).
+ //
+ // If supplied, each key in the object is a parameter name, and the value is either the parameter value (type String)
+ // or null if the parameter does not take a value.
+ //
+ // [RFC5321]: https://datatracker.ietf.org/doc/html/rfc5321
+ Parameters map[string]any `json:"parameters,omitempty"` // TODO RFC5321
+}
+
+// Information for use when sending via SMTP.
+type Envelope struct {
+ // The email address to use as the return address in the SMTP submission,
+ // plus any parameters to pass with the MAIL FROM address.
+ MailFrom Address `json:"mailFrom"`
+
+ // The email addresses to send the message to, and any RCPT TO parameters to pass with the recipient.
+ RcptTo []Address `json:"rcptTo"`
+}
+
+type EmailSubmissionUndoStatus string
+
+const (
+ UndoStatusPending EmailSubmissionUndoStatus = "pending"
+ UndoStatusFinal EmailSubmissionUndoStatus = "final"
+ UndoStatusCanceled EmailSubmissionUndoStatus = "canceled"
+)
+
+type DeliveryStatusDelivered string
+
+const (
+ DeliveredQueued DeliveryStatusDelivered = "queued"
+ DeliveredYes DeliveryStatusDelivered = "yes"
+ DeliveredNo DeliveryStatusDelivered = "no"
+ DeliveredUnknown DeliveryStatusDelivered = "unknown"
+)
+
+type DeliveryStatusDisplayed string
+
+const (
+ DisplayedUnknown DeliveryStatusDisplayed = "unknown"
+ DisplayedYes DeliveryStatusDisplayed = "yes"
+)
+
+type DeliveryStatus struct {
+ // The SMTP reply string returned for this recipient when the server last tried to
+ // relay the message, or in a later Delivery Status Notification (DSN, as defined in
+ // [RFC3464]) response for the message.
+ //
+ // This SHOULD be the response to the RCPT TO stage, unless this was accepted and the
+ // message as a whole was rejected at the end of the DATA stage, in which case the
+ // DATA stage reply SHOULD be used instead.
+ //
+ // [RFC3464]: https://datatracker.ietf.org/doc/html/rfc3464
+ SmtpReply string `json:"smtpReply"`
+
+ // Represents whether the message has been successfully delivered to the recipient.
+ //
+ // This MUST be one of the following values:
+ // - queued: The message is in a local mail queue and status will change once it exits
+ // the local mail queues. The smtpReply property may still change.
+ // - yes: The message was successfully delivered to the mail store of the recipient.
+ // The smtpReply property is final.
+ // - no: Delivery to the recipient permanently failed. The smtpReply property is final.
+ // - unknown: The final delivery status is unknown, (e.g., it was relayed to an external
+ // machine and no further information is available).
+ // The smtpReply property may still change if a DSN arrives.
+ Delivered DeliveryStatusDelivered `json:"delivered"`
+
+ // Represents whether the message has been displayed to the recipient.
+ //
+ // This MUST be one of the following values:
+ // - unknown: The display status is unknown. This is the initial value.
+ // - yes: The recipient’s system claims the message content has been displayed to the recipient.
+ // Note that there is no guarantee that the recipient has noticed, read, or understood the content.
+ Displayed DeliveryStatusDisplayed `json:"displayed"`
+}
+
+type EmailSubmission struct {
+ // (server-set) The id of the EmailSubmission.
+ Id string `json:"id"`
+
+ // The id of the Identity to associate with this submission.
+ IdentityId string `json:"identityId"`
+
+ // The id of the Email to send.
+ //
+ // The Email being sent does not have to be a draft, for example, when “redirecting” an existing Email
+ // to a different address.
+ EmailId string `json:"emailId"`
+
+ // (server-set) The Thread id of the Email to send.
+ //
+ // This is set by the server to the threadId property of the Email referenced by the emailId.
+ ThreadId string `json:"threadId"`
+
+ // Information for use when sending via SMTP.
+ //
+ // If the envelope property is null or omitted on creation, the server MUST generate this from the
+ // referenced Email as follows:
+ //
+ // - mailFrom: The email address in the Sender header field, if present; otherwise,
+ // it’s the email address in the From header field, if present.
+ // In either case, no parameters are added.
+ // - rcptTo: The deduplicated set of email addresses from the To, Cc, and Bcc header fields,
+ // if present, with no parameters for any of them.
+ Envelope *Envelope `json:"envelope,omitempty"`
+
+ // (server-set) The date the submission was/will be released for delivery.
+ SendAt time.Time `json:"sendAt,omitzero"`
+
+ // (server-set) This represents whether the submission may be canceled.
+ //
+ // This is server set on create and MUST be one of the following values:
+ //
+ // - pending: It may be possible to cancel this submission.
+ // - final: The message has been relayed to at least one recipient in a manner that cannot be
+ // recalled. It is no longer possible to cancel this submission.
+ // - canceled: The submission was canceled and will not be delivered to any recipient.
+ UndoStatus EmailSubmissionUndoStatus `json:"undoStatus"`
+
+ // (server-set) This represents the delivery status for each of the submission’s recipients, if known.
+ //
+ // This property MAY not be supported by all servers, in which case it will remain null.
+ //
+ // Servers that support it SHOULD update the EmailSubmission object each time the status of any of
+ // the recipients changes, even if some recipients are still being retried.
+ //
+ // This value is a map from the email address of each recipient to a DeliveryStatus object.
+ DeliveryStatus map[string]DeliveryStatus `json:"deliveryStatus"`
+
+ // (server-set) A list of blob ids for DSNs [RFC3464] received for this submission,
+ // in order of receipt, oldest first.
+ //
+ // The blob is the whole MIME message (with a top-level content-type of multipart/report), as received.
+ //
+ // [RFC3464]: https://datatracker.ietf.org/doc/html/rfc3464
+ DsnBlobIds []string `json:"dsnBlobIds,omitempty"`
+
+ // (server-set) A list of blob ids for MDNs [RFC8098] received for this submission,
+ // in order of receipt, oldest first.
+ //
+ // The blob is the whole MIME message (with a top-level content-type of multipart/report), as received.
+ //
+ // [RFC8098]: https://datatracker.ietf.org/doc/html/rfc8098
+ MdnBlobIds []string `json:"mdnBlobIds,omitempty"`
+}
+
+type EmailSubmissionGetRefCommand struct {
+ // The id of the account to use.
+ AccountId string `json:"accountId"`
+
+ IdRef *ResultReference `json:"#ids,omitempty"`
+
+ Properties []string `json:"properties,omitempty"`
+}
+
+type EmailSubmissionGetResponse struct {
+ AccountId string `json:"accountId"`
+ State string `json:"state"`
+ List []EmailSubmission `json:"list,omitempty"`
+ NotFound []string `json:"notFound,omitempty"`
+}
+
+// Patch Object.
+//
+// Example:
+//
+// - moves it from the drafts folder (which has Mailbox id “7cb4e8ee-df87-4757-b9c4-2ea1ca41b38e”)
+// to the sent folder (which we presume has Mailbox id “73dbcb4b-bffc-48bd-8c2a-a2e91ca672f6”)
+//
+// - removes the $draft flag and
+//
+// {
+// "mailboxIds/7cb4e8ee-df87-4757-b9c4-2ea1ca41b38e": null,
+// "mailboxIds/73dbcb4b-bffc-48bd-8c2a-a2e91ca672f6": true,
+// "keywords/$draft": null
+// }
+type PatchObject map[string]any
+
+// same as EmailSubmission but without the server-set attributes
+type EmailSubmissionCreate struct {
+ // The id of the Identity to associate with this submission.
+ IdentityId string `json:"identityId"`
+ // The id of the Email to send.
+ //
+ // The Email being sent does not have to be a draft, for example, when “redirecting” an existing
+ // Email to a different address.
+ EmailId string `json:"emailId"`
+
+ // Information for use when sending via SMTP.
+ Envelope *Envelope `json:"envelope,omitempty"`
+}
+
+type EmailSubmissionSetCommand struct {
+ AccountId string `json:"accountId"`
+ Create map[string]EmailSubmissionCreate `json:"create,omitempty"`
+ OldState string `json:"oldState,omitempty"`
+ NewState string `json:"newState,omitempty"`
+
+ // A map of EmailSubmission id to an object containing properties to update on the Email object
+ // referenced by the EmailSubmission if the create/update/destroy succeeds.
+ //
+ // (For references to EmailSubmissions created in the same “/set” invocation, this is equivalent
+ // to a creation-reference, so the id will be the creation id prefixed with a #.)
+ OnSuccessUpdateEmail map[string]PatchObject `json:"onSuccessUpdateEmail,omitempty"`
+
+ // A list of EmailSubmission ids for which the Email with the corresponding emailId should be destroyed
+ // if the create/update/destroy succeeds.
+ //
+ // (For references to EmailSubmission creations, this is equivalent to a creation-reference so the
+ // id will be the creation id prefixed with a #.)
+ OnSuccessDestroyEmail []string `json:"onSuccessDestroyEmail,omitempty"`
+}
+
+type CreatedEmailSubmission struct {
+ Id string `json:"id"`
+}
+
+type EmailSubmissionSetResponse struct {
+ AccountId string `json:"accountId"`
+ OldState string `json:"oldState"`
+ NewState string `json:"newState"`
+ Created map[string]CreatedEmailSubmission `json:"created,omitempty"`
+ NotCreated map[string]SetError `json:"notCreated,omitempty"`
+ // TODO(pbleser-oc) add updated and destroyed when they are needed
+}
+
type Command string
type Invocation struct {
@@ -1126,21 +1426,78 @@ type EmailBodyStructure struct {
}
type EmailCreate struct {
- MailboxIds map[string]bool `json:"mailboxIds,omitempty"`
- Keywords map[string]bool `json:"keywords,omitempty"`
- From []EmailAddress `json:"from,omitempty"`
- Subject string `json:"subject,omitempty"`
- ReceivedAt time.Time `json:"receivedAt,omitzero"`
- SentAt time.Time `json:"sentAt,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.
+ 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 map[string]bool `json:"keywords,omitempty"`
+
+ // The ["From:" field] specifies the author(s) of the message, that is, the mailbox(es)
+ // of the person(s) or system(s) responsible for the writing of the message
+ //
+ // ["From:" field]: https://www.rfc-editor.org/rfc/rfc5322.html#section-3.6.2
+ From []EmailAddress `json:"from,omitempty"`
+
+ // The "Subject:" field contains a short string identifying the topic of the message.
+ Subject string `json:"subject,omitempty"`
+
+ // The date the Email was received by the message store.
+ //
+ // (default: time of most recent Received header, or time of import on server if none).
+ ReceivedAt time.Time `json:"receivedAt,omitzero"`
+
+ // The origination date specifies the date and time at which the creator of the message indicated that
+ // the message was complete and ready to enter the mail delivery system.
+ //
+ // For instance, this might be the time that a user pushes the "send" or "submit" button in an
+ // application program.
+ //
+ // In any case, it is specifically not intended to convey the time that the message is actually transported,
+ // but rather the time at which the human or other creator of the message has put the message into its final
+ // form, ready for transport.
+ //
+ // (For example, a portable computer user who is not connected to a network might queue a message for delivery.
+ // The origination date is intended to contain the date and time that the user queued the message, not the time
+ // when the user connected to the network to send the message.)
+ SentAt time.Time `json:"sentAt,omitzero"`
+
+ // This is the full MIME structure of the message body, without recursing into message/rfc822 or message/global parts.
+ //
+ // Note that EmailBodyParts may have subParts if they are of type multipart/*.
BodyStructure EmailBodyStructure `json:"bodyStructure"`
+
+ // This is a map of partId to an EmailBodyValue object for none, some, or all text/* parts.
+ BodyValues map[string]EmailBodyValue `json:"bodyValues,omitempty"`
}
+type EmailUpdate map[string]any
+
type EmailSetCommand struct {
AccountId string `json:"accountId"`
Create map[string]EmailCreate `json:"create,omitempty"`
+ Update map[string]EmailUpdate `json:"update,omitempty"`
+ Destroy []string `json:"destroy,omitempty"`
}
type EmailSetResponse struct {
+ AccountId string `json:"accountId"`
+ OldState string `json:"oldState,omitempty"`
+ NewState string `json:"newState"`
+ Created map[string]Email `json:"created,omitempty"`
+ Updated map[string]Email `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"`
}
const (
@@ -1372,6 +1729,8 @@ const (
EmailChanges Command = "Email/changes"
EmailSet Command = "Email/set"
EmailImport Command = "Email/import"
+ EmailSubmissionGet Command = "EmailSubmission/get"
+ EmailSubmissionSet Command = "EmailSubmission/set"
ThreadGet Command = "Thread/get"
MailboxGet Command = "Mailbox/get"
MailboxQuery Command = "Mailbox/query"
@@ -1390,6 +1749,8 @@ var CommandResponseTypeMap = map[Command]func() any{
EmailQuery: func() any { return EmailQueryResponse{} },
EmailChanges: func() any { return EmailChangesResponse{} },
EmailGet: func() any { return EmailGetResponse{} },
+ EmailSubmissionGet: func() any { return EmailSubmissionGetResponse{} },
+ EmailSubmissionSet: func() any { return EmailSubmissionSetResponse{} },
ThreadGet: func() any { return ThreadGetResponse{} },
IdentityGet: func() any { return IdentityGetResponse{} },
VacationResponseGet: func() any { return VacationResponseGetResponse{} },
diff --git a/pkg/jmap/jmap_session.go b/pkg/jmap/jmap_session.go
new file mode 100644
index 0000000000..4600f0bc54
--- /dev/null
+++ b/pkg/jmap/jmap_session.go
@@ -0,0 +1,120 @@
+package jmap
+
+import (
+ "fmt"
+ "net/url"
+
+ "github.com/opencloud-eu/opencloud/pkg/log"
+)
+
+type SessionEventListener interface {
+ OnSessionOutdated(session *Session)
+}
+
+// Cached user related information
+//
+// This information is typically retrieved once (or at least for a certain period of time) from the
+// JMAP well-known endpoint of Stalwart and then kept in cache to avoid the performance cost of
+// retrieving it over and over again.
+//
+// This is really only needed due to the Graph API limitations, since ideally, the account ID should
+// be passed as a request parameter by the UI, in order to support a user having multiple accounts.
+//
+// Keeping track of the JMAP URL might be useful though, in case of Stalwart sharding strategies making
+// use of that, by providing different URLs for JMAP on a per-user basis, and that is not something
+// we would want to query before every single JMAP request. On the other hand, that then also creates
+// a risk of going out-of-sync, e.g. if a node is down and the user is reassigned to a different node.
+// There might be webhooks to subscribe to in Stalwart to be notified of such situations, in which case
+// the Session needs to be removed from the cache.
+//
+// The Username is only here for convenience, it could just as well be passed as a separate parameter
+// instead of being part of the Session, since the username is always part of the request (typically in
+// the authentication token payload.)
+type Session struct {
+ // The name of the user to use to authenticate against Stalwart
+ Username string
+
+ // The base URL to use for JMAP operations towards Stalwart
+ JmapUrl url.URL
+
+ // The upload URL template
+ UploadUrlTemplate string
+
+ // The upload URL template
+ DownloadUrlTemplate string
+
+ // TODO
+ DefaultMailAccountId string
+
+ SessionResponse
+}
+
+// Create a new Session from a SessionResponse.
+func newSession(sessionResponse SessionResponse) (Session, Error) {
+ username := sessionResponse.Username
+ if username == "" {
+ return Session{}, SimpleError{code: JmapErrorInvalidSessionResponse, err: fmt.Errorf("JMAP session response does not provide a username")}
+ }
+ mailAccountId := sessionResponse.PrimaryAccounts.Mail
+ if mailAccountId == "" {
+ return Session{}, SimpleError{code: JmapErrorInvalidSessionResponse, err: fmt.Errorf("JMAP session response does not provide a primary mail account")}
+ }
+ apiStr := sessionResponse.ApiUrl
+ if apiStr == "" {
+ return Session{}, SimpleError{code: JmapErrorInvalidSessionResponse, err: fmt.Errorf("JMAP session response does not provide an API URL")}
+ }
+ apiUrl, err := url.Parse(apiStr)
+ if err != nil {
+ return Session{}, SimpleError{code: JmapErrorInvalidSessionResponse, err: fmt.Errorf("JMAP session response provides an invalid API URL")}
+ }
+ uploadUrl := sessionResponse.UploadUrl
+ if uploadUrl == "" {
+ return Session{}, SimpleError{code: JmapErrorInvalidSessionResponse, err: fmt.Errorf("JMAP session response does not provide an upload URL")}
+ }
+ downloadUrl := sessionResponse.DownloadUrl
+ if downloadUrl == "" {
+ return Session{}, SimpleError{code: JmapErrorInvalidSessionResponse, err: fmt.Errorf("JMAP session response does not provide an download URL")}
+ }
+
+ return Session{
+ Username: username,
+ DefaultMailAccountId: mailAccountId,
+ JmapUrl: *apiUrl,
+ UploadUrlTemplate: uploadUrl,
+ DownloadUrlTemplate: downloadUrl,
+ SessionResponse: sessionResponse,
+ }, nil
+}
+
+func (s *Session) MailAccountId(accountId string) string {
+ if accountId != "" && accountId != defaultAccountId {
+ return accountId
+ }
+ // TODO(pbleser-oc) handle case where there is no default mail account
+ return s.DefaultMailAccountId
+}
+
+func (s *Session) BlobAccountId(accountId string) string {
+ if accountId != "" && accountId != defaultAccountId {
+ return accountId
+ }
+ // TODO(pbleser-oc) handle case where there is no default blob account
+ return s.PrimaryAccounts.Blob
+}
+
+func (s *Session) SubmissionAccountId(accountId string) string {
+ if accountId != "" && accountId != defaultAccountId {
+ return accountId
+ }
+ // TODO(pbleser-oc) handle case where there is no default submission account
+ return s.PrimaryAccounts.Submission
+}
+
+// Create a new log.Logger that is decorated with fields containing information about the Session.
+func (s Session) DecorateLogger(l log.Logger) log.Logger {
+ return log.Logger{Logger: l.With().
+ Str(logUsername, s.Username).
+ Str(logApiUrl, s.ApiUrl).
+ Str(logSessionState, s.State).
+ Logger()}
+}
diff --git a/services/groupware/pkg/groupware/groupware_api_account.go b/services/groupware/pkg/groupware/groupware_api_account.go
index 82673e0b12..93be5e3468 100644
--- a/services/groupware/pkg/groupware/groupware_api_account.go
+++ b/services/groupware/pkg/groupware/groupware_api_account.go
@@ -5,17 +5,17 @@ import (
)
func (g Groupware) GetAccount(w http.ResponseWriter, r *http.Request) {
- g.respond(w, r, func(req Request) (any, string, *Error) {
+ g.respond(w, r, func(req Request) Response {
account, err := req.GetAccount()
if err != nil {
- return nil, "", err
+ return errorResponse(err)
}
- return account, req.session.State, nil
+ return response(account, req.session.State)
})
}
func (g Groupware) GetAccounts(w http.ResponseWriter, r *http.Request) {
- g.respond(w, r, func(req Request) (any, string, *Error) {
- return req.session.Accounts, req.session.State, nil
+ g.respond(w, r, func(req Request) Response {
+ return response(req.session.Accounts, req.session.State)
})
}
diff --git a/services/groupware/pkg/groupware/groupware_api_blob.go b/services/groupware/pkg/groupware/groupware_api_blob.go
index b205b82e89..f5cd8572ba 100644
--- a/services/groupware/pkg/groupware/groupware_api_blob.go
+++ b/services/groupware/pkg/groupware/groupware_api_blob.go
@@ -14,24 +14,27 @@ const (
)
func (g Groupware) GetBlob(w http.ResponseWriter, r *http.Request) {
- g.respond(w, r, func(req Request) (any, string, *Error) {
+ g.respond(w, r, func(req Request) Response {
blobId := chi.URLParam(req.r, UriParamBlobId)
if blobId == "" {
errorId := req.errorId()
msg := fmt.Sprintf("Invalid value for path parameter '%v': empty", UriParamBlobId)
- return nil, "", apiError(errorId, ErrorInvalidRequestParameter,
+ return errorResponse(apiError(errorId, ErrorInvalidRequestParameter,
withDetail(msg),
withSource(&ErrorSource{Parameter: UriParamBlobId}),
- )
+ ))
}
res, err := g.jmap.GetBlob(req.GetAccountId(), req.session, req.ctx, req.logger, blobId)
- return res, res.Digest(), req.apiErrorFromJmap(err)
+ if err != nil {
+ return req.errorResponseFromJmap(err)
+ }
+ return etagOnlyResponse(res, res.Digest())
})
}
func (g Groupware) UploadBlob(w http.ResponseWriter, r *http.Request) {
- g.respond(w, r, func(req Request) (any, string, *Error) {
+ g.respond(w, r, func(req Request) Response {
contentType := r.Header.Get("Content-Type")
body := r.Body
if body != nil {
@@ -45,10 +48,10 @@ func (g Groupware) UploadBlob(w http.ResponseWriter, r *http.Request) {
resp, err := g.jmap.UploadBlobStream(req.GetAccountId(), req.session, req.ctx, req.logger, contentType, body)
if err != nil {
- return resp, "", req.apiErrorFromJmap(err)
+ return req.errorResponseFromJmap(err)
}
- return resp, resp.Sha512, nil
+ return etagOnlyResponse(resp, resp.Sha512)
})
}
diff --git a/services/groupware/pkg/groupware/groupware_api_identity.go b/services/groupware/pkg/groupware/groupware_api_identity.go
index 2645e2acb7..9a7f812184 100644
--- a/services/groupware/pkg/groupware/groupware_api_identity.go
+++ b/services/groupware/pkg/groupware/groupware_api_identity.go
@@ -5,8 +5,11 @@ import (
)
func (g Groupware) GetIdentity(w http.ResponseWriter, r *http.Request) {
- g.respond(w, r, func(req Request) (any, string, *Error) {
+ g.respond(w, r, func(req Request) Response {
res, err := g.jmap.GetIdentity(req.GetAccountId(), req.session, req.ctx, req.logger)
- return res, res.State, req.apiErrorFromJmap(err)
+ if err != nil {
+ return req.errorResponseFromJmap(err)
+ }
+ return response(res, res.State)
})
}
diff --git a/services/groupware/pkg/groupware/groupware_api_index.go b/services/groupware/pkg/groupware/groupware_api_index.go
index c37c1758ea..b89c8f1f27 100644
--- a/services/groupware/pkg/groupware/groupware_api_index.go
+++ b/services/groupware/pkg/groupware/groupware_api_index.go
@@ -68,7 +68,7 @@ type SwaggerIndexResponse struct {
//
// 200: IndexResponse
func (g Groupware) Index(w http.ResponseWriter, r *http.Request) {
- g.respond(w, r, func(req Request) (any, string, *Error) {
+ g.respond(w, r, func(req Request) Response {
accounts := make(map[string]IndexAccount, len(req.session.Accounts))
for i, a := range req.session.Accounts {
accounts[i] = IndexAccount{
@@ -93,7 +93,7 @@ func (g Groupware) Index(w http.ResponseWriter, r *http.Request) {
}
}
- return IndexResponse{
+ return response(IndexResponse{
Version: Version,
Capabilities: Capabilities,
Limits: IndexLimits{
@@ -107,6 +107,6 @@ func (g Groupware) Index(w http.ResponseWriter, r *http.Request) {
Mail: req.session.PrimaryAccounts.Mail,
Submission: req.session.PrimaryAccounts.Submission,
},
- }, req.session.State, nil
+ }, req.session.State)
})
}
diff --git a/services/groupware/pkg/groupware/groupware_api_mailbox.go b/services/groupware/pkg/groupware/groupware_api_mailbox.go
index 941ba1b43c..3fab4d99e1 100644
--- a/services/groupware/pkg/groupware/groupware_api_mailbox.go
+++ b/services/groupware/pkg/groupware/groupware_api_mailbox.go
@@ -38,16 +38,16 @@ func (g Groupware) GetMailbox(w http.ResponseWriter, r *http.Request) {
return
}
- g.respond(w, r, func(req Request) (any, string, *Error) {
+ g.respond(w, r, func(req Request) Response {
res, err := g.jmap.GetMailbox(req.GetAccountId(), req.session, req.ctx, req.logger, []string{mailboxId})
if err != nil {
- return res, "", req.apiErrorFromJmap(err)
+ return req.errorResponseFromJmap(err)
}
if len(res.List) == 1 {
- return res.List[0], res.State, req.apiErrorFromJmap(err)
+ return response(res.List[0], res.State)
} else {
- return nil, res.State, req.apiErrorFromJmap(err)
+ return notFoundResponse(res.State)
}
})
}
@@ -114,19 +114,19 @@ func (g Groupware) GetMailboxes(w http.ResponseWriter, r *http.Request) {
hasCriteria = true
}
- g.respond(w, r, func(req Request) (any, string, *Error) {
+ g.respond(w, r, func(req Request) Response {
if hasCriteria {
mailboxes, err := g.jmap.SearchMailboxes(req.GetAccountId(), req.session, req.ctx, req.logger, filter)
if err != nil {
- return nil, "", req.apiErrorFromJmap(err)
+ return req.errorResponseFromJmap(err)
}
- return mailboxes.Mailboxes, mailboxes.State, nil
+ return response(mailboxes.Mailboxes, mailboxes.State)
} else {
mailboxes, err := g.jmap.GetAllMailboxes(req.GetAccountId(), req.session, req.ctx, req.logger)
if err != nil {
- return nil, "", req.apiErrorFromJmap(err)
+ return req.errorResponseFromJmap(err)
}
- return mailboxes.List, mailboxes.State, nil
+ return response(mailboxes.List, mailboxes.State)
}
})
}
diff --git a/services/groupware/pkg/groupware/groupware_api_messages.go b/services/groupware/pkg/groupware/groupware_api_messages.go
index ab1cf0853a..bbd08496e1 100644
--- a/services/groupware/pkg/groupware/groupware_api_messages.go
+++ b/services/groupware/pkg/groupware/groupware_api_messages.go
@@ -4,6 +4,7 @@ import (
"fmt"
"net/http"
"strings"
+ "time"
"github.com/go-chi/chi/v5"
@@ -18,38 +19,38 @@ func (g Groupware) GetAllMessages(w http.ResponseWriter, r *http.Request) {
if since != "" {
// ... then it's a completely different operation
maxChanges := -1
- g.respond(w, r, func(req Request) (any, string, *Error) {
+ g.respond(w, r, func(req Request) Response {
if mailboxId == "" {
errorId := req.errorId()
msg := fmt.Sprintf("Missing required mailbox ID path parameter '%v'", UriParamMailboxId)
- return nil, "", apiError(errorId, ErrorInvalidRequestParameter,
+ return errorResponse(apiError(errorId, ErrorInvalidRequestParameter,
withDetail(msg),
withSource(&ErrorSource{Parameter: UriParamMailboxId}),
- )
+ ))
}
logger := &log.Logger{Logger: req.logger.With().Str(HeaderSince, since).Logger()}
emails, jerr := g.jmap.GetEmailsInMailboxSince(req.GetAccountId(), req.session, req.ctx, logger, mailboxId, since, true, g.maxBodyValueBytes, maxChanges)
if jerr != nil {
- return nil, "", req.apiErrorFromJmap(jerr)
+ return req.errorResponseFromJmap(jerr)
}
- return emails, emails.State, nil
+ return response(emails, emails.State)
})
} else {
- g.respond(w, r, func(req Request) (any, string, *Error) {
+ g.respond(w, r, func(req Request) Response {
l := req.logger.With()
if mailboxId == "" {
errorId := req.errorId()
msg := fmt.Sprintf("Missing required mailbox ID path parameter '%v'", UriParamMailboxId)
- return nil, "", apiError(errorId, ErrorInvalidRequestParameter,
+ return errorResponse(apiError(errorId, ErrorInvalidRequestParameter,
withDetail(msg),
withSource(&ErrorSource{Parameter: UriParamMailboxId}),
- )
+ ))
}
offset, ok, err := req.parseNumericParam(QueryParamOffset, 0)
if err != nil {
- return nil, "", err
+ return errorResponse(err)
}
if ok {
l = l.Int(QueryParamOffset, offset)
@@ -57,7 +58,7 @@ func (g Groupware) GetAllMessages(w http.ResponseWriter, r *http.Request) {
limit, ok, err := req.parseNumericParam(QueryParamLimit, g.defaultEmailLimit)
if err != nil {
- return nil, "", err
+ return errorResponse(err)
}
if ok {
l = l.Int(QueryParamLimit, limit)
@@ -67,34 +68,34 @@ func (g Groupware) GetAllMessages(w http.ResponseWriter, r *http.Request) {
emails, jerr := g.jmap.GetAllEmails(req.GetAccountId(), req.session, req.ctx, logger, mailboxId, offset, limit, true, g.maxBodyValueBytes)
if jerr != nil {
- return nil, "", req.apiErrorFromJmap(jerr)
+ return req.errorResponseFromJmap(jerr)
}
- return emails, emails.State, nil
+ return response(emails, emails.State)
})
}
}
func (g Groupware) GetMessagesById(w http.ResponseWriter, r *http.Request) {
- id := chi.URLParam(r, UriParamMessagesId)
- g.respond(w, r, func(req Request) (any, string, *Error) {
+ id := chi.URLParam(r, UriParamMessageId)
+ g.respond(w, r, func(req Request) Response {
ids := strings.Split(id, ",")
if len(ids) < 1 {
errorId := req.errorId()
- msg := fmt.Sprintf("Invalid value for path parameter '%v': '%s': %s", UriParamMessagesId, logstr(id), "empty list of mail ids")
- return nil, "", apiError(errorId, ErrorInvalidRequestParameter,
+ msg := fmt.Sprintf("Invalid value for path parameter '%v': '%s': %s", UriParamMessageId, logstr(id), "empty list of mail ids")
+ return errorResponse(apiError(errorId, ErrorInvalidRequestParameter,
withDetail(msg),
- withSource(&ErrorSource{Parameter: UriParamMessagesId}),
- )
+ withSource(&ErrorSource{Parameter: UriParamMessageId}),
+ ))
}
logger := &log.Logger{Logger: req.logger.With().Str("id", logstr(id)).Logger()}
emails, jerr := g.jmap.GetEmails(req.GetAccountId(), req.session, req.ctx, logger, ids, true, g.maxBodyValueBytes)
if jerr != nil {
- return nil, "", req.apiErrorFromJmap(jerr)
+ return req.errorResponseFromJmap(jerr)
}
- return emails, emails.State, nil
+ return response(emails, emails.State)
})
}
@@ -107,19 +108,19 @@ func (g Groupware) GetMessages(w http.ResponseWriter, r *http.Request) {
if since != "" {
// get messages changes since a given state
maxChanges := -1
- g.respond(w, r, func(req Request) (any, string, *Error) {
+ g.respond(w, r, func(req Request) Response {
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)
+ return req.errorResponseFromJmap(jerr)
}
- return emails, emails.State, nil
+ return response(emails, emails.State)
})
} else {
// do a search
- g.respond(w, r, func(req Request) (any, string, *Error) {
+ g.respond(w, r, func(req Request) Response {
mailboxId := q.Get(QueryParamMailboxId)
notInMailboxIds := q[QueryParamNotInMailboxId]
text := q.Get(QueryParamSearchText)
@@ -134,7 +135,7 @@ func (g Groupware) GetMessages(w http.ResponseWriter, r *http.Request) {
offset, ok, err := req.parseNumericParam(QueryParamOffset, 0)
if err != nil {
- return nil, "", err
+ return errorResponse(err)
}
if ok {
l = l.Int(QueryParamOffset, offset)
@@ -142,7 +143,7 @@ func (g Groupware) GetMessages(w http.ResponseWriter, r *http.Request) {
limit, ok, err := req.parseNumericParam(QueryParamLimit, g.defaultEmailLimit)
if err != nil {
- return nil, "", err
+ return errorResponse(err)
}
if ok {
l = l.Int(QueryParamLimit, limit)
@@ -150,7 +151,7 @@ func (g Groupware) GetMessages(w http.ResponseWriter, r *http.Request) {
before, ok, err := req.parseDateParam(QueryParamSearchBefore)
if err != nil {
- return nil, "", err
+ return errorResponse(err)
}
if ok {
l = l.Time(QueryParamSearchBefore, before)
@@ -158,7 +159,7 @@ func (g Groupware) GetMessages(w http.ResponseWriter, r *http.Request) {
after, ok, err := req.parseDateParam(QueryParamSearchAfter)
if err != nil {
- return nil, "", err
+ return errorResponse(err)
}
if ok {
l = l.Time(QueryParamSearchAfter, after)
@@ -194,7 +195,7 @@ func (g Groupware) GetMessages(w http.ResponseWriter, r *http.Request) {
minSize, ok, err := req.parseNumericParam(QueryParamSearchMinSize, 0)
if err != nil {
- return nil, "", err
+ return errorResponse(err)
}
if ok {
l = l.Int(QueryParamSearchMinSize, minSize)
@@ -202,7 +203,7 @@ func (g Groupware) GetMessages(w http.ResponseWriter, r *http.Request) {
maxSize, ok, err := req.parseNumericParam(QueryParamSearchMaxSize, 0)
if err != nil {
- return nil, "", err
+ return errorResponse(err)
}
if ok {
l = l.Int(QueryParamSearchMaxSize, maxSize)
@@ -229,10 +230,120 @@ func (g Groupware) GetMessages(w http.ResponseWriter, r *http.Request) {
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 req.errorResponseFromJmap(jerr)
}
- return emails, emails.QueryState, nil
+ return etagResponse(emails, emails.SessionState, emails.QueryState)
})
}
}
+
+type MessageCreation struct {
+ MailboxIds []string `json:"mailboxIds,omitempty"`
+ Keywords []string `json:"keywords,omitempty"`
+ From []jmap.EmailAddress `json:"from,omitempty"`
+ Subject string `json:"subject,omitempty"`
+ ReceivedAt time.Time `json:"receivedAt,omitzero"`
+ SentAt time.Time `json:"sentAt,omitzero"` // huh?
+ BodyStructure jmap.EmailBodyStructure `json:"bodyStructure"`
+ BodyValues map[string]jmap.EmailBodyValue `json:"bodyValues,omitempty"`
+}
+
+func (g Groupware) CreateMessage(w http.ResponseWriter, r *http.Request) {
+ g.respond(w, r, func(req Request) Response {
+ messageId := chi.URLParam(r, UriParamMessageId)
+
+ l := req.logger.With()
+ l.Str(UriParamMessageId, messageId)
+ logger := &log.Logger{Logger: l.Logger()}
+
+ var body MessageCreation
+ err := req.body(&body)
+ if err != nil {
+ return errorResponse(err)
+ }
+
+ mailboxIdsMap := map[string]bool{}
+ for _, mailboxId := range body.MailboxIds {
+ mailboxIdsMap[mailboxId] = true
+ }
+
+ keywordsMap := map[string]bool{}
+ for _, keyword := range body.Keywords {
+ keywordsMap[keyword] = true
+ }
+
+ create := jmap.EmailCreate{
+ MailboxIds: mailboxIdsMap,
+ Keywords: keywordsMap,
+ From: body.From,
+ Subject: body.Subject,
+ ReceivedAt: body.ReceivedAt,
+ SentAt: body.SentAt,
+ BodyStructure: body.BodyStructure,
+ BodyValues: body.BodyValues,
+ }
+
+ created, jerr := g.jmap.CreateEmail(req.GetAccountId(), create, req.session, req.ctx, logger)
+ if jerr != nil {
+ return req.errorResponseFromJmap(jerr)
+ }
+
+ return response(created.Email, created.State)
+ })
+}
+
+func (g Groupware) UpdateMessage(w http.ResponseWriter, r *http.Request) {
+ g.respond(w, r, func(req Request) Response {
+ messageId := chi.URLParam(r, UriParamMessageId)
+
+ l := req.logger.With()
+ l.Str(UriParamMessageId, messageId)
+
+ logger := &log.Logger{Logger: l.Logger()}
+
+ var body map[string]any
+ err := req.body(&body)
+ if err != nil {
+ return errorResponse(err)
+ }
+
+ updates := map[string]jmap.EmailUpdate{
+ messageId: body,
+ }
+
+ result, jerr := g.jmap.UpdateEmails(req.GetAccountId(), updates, req.session, req.ctx, logger)
+ if jerr != nil {
+ return req.errorResponseFromJmap(jerr)
+ }
+
+ if result.Updated == nil {
+ // TODO(pbleser-oc) handle missing update response
+ }
+ updatedEmail, ok := result.Updated[messageId]
+ if !ok {
+ // TODO(pbleser-oc) handle missing update response
+ }
+
+ return response(updatedEmail, result.State)
+ })
+
+}
+
+func (g Groupware) DeleteMessage(w http.ResponseWriter, r *http.Request) {
+ g.respond(w, r, func(req Request) Response {
+ messageId := chi.URLParam(r, UriParamMessageId)
+
+ l := req.logger.With()
+ l.Str(UriParamMessageId, messageId)
+
+ logger := &log.Logger{Logger: l.Logger()}
+
+ deleted, jerr := g.jmap.DeleteEmails(req.GetAccountId(), []string{messageId}, req.session, req.ctx, logger)
+ if jerr != nil {
+ return req.errorResponseFromJmap(jerr)
+ }
+
+ return noContentResponse(deleted.State)
+ })
+}
diff --git a/services/groupware/pkg/groupware/groupware_api_vacation.go b/services/groupware/pkg/groupware/groupware_api_vacation.go
index a1cbb72b32..b6b3dc8c16 100644
--- a/services/groupware/pkg/groupware/groupware_api_vacation.go
+++ b/services/groupware/pkg/groupware/groupware_api_vacation.go
@@ -29,8 +29,11 @@ type SwaggerVacationResponse200 struct {
// 400: ErrorResponse400
// 500: ErrorResponse500
func (g Groupware) GetVacation(w http.ResponseWriter, r *http.Request) {
- g.respond(w, r, func(req Request) (any, string, *Error) {
+ g.respond(w, r, func(req Request) Response {
res, err := g.jmap.GetVacationResponse(req.GetAccountId(), req.session, req.ctx, req.logger)
- return res, res.State, req.apiErrorFromJmap(err)
+ if err != nil {
+ return req.errorResponseFromJmap(err)
+ }
+ return response(res, res.State)
})
}
diff --git a/services/groupware/pkg/groupware/groupware_error.go b/services/groupware/pkg/groupware/groupware_error.go
index c3ca52f553..9b33410573 100644
--- a/services/groupware/pkg/groupware/groupware_error.go
+++ b/services/groupware/pkg/groupware/groupware_error.go
@@ -386,6 +386,9 @@ func errorResponse(id string, error GroupwareError, options ...ErrorOpt) ErrorRe
func errorId(r *http.Request, ctx context.Context) string {
requestId := chimiddleware.GetReqID(ctx)
+ if requestId == "" {
+ requestId = r.Header.Get("x-request-id")
+ }
localId := uuid.NewString()
if requestId != "" {
return requestId + "." + localId
@@ -398,14 +401,14 @@ func (r Request) errorId() string {
return errorId(r.r, r.ctx)
}
-func apiError(id string, error GroupwareError, options ...ErrorOpt) *Error {
+func apiError(id string, gwerr GroupwareError, options ...ErrorOpt) *Error {
err := &Error{
Id: id,
- NumStatus: error.Status,
- Status: strconv.Itoa(error.Status),
- Code: error.Code,
- Title: error.Title,
- Detail: error.Detail,
+ NumStatus: gwerr.Status,
+ Status: strconv.Itoa(gwerr.Status),
+ Code: gwerr.Code,
+ Title: gwerr.Title,
+ Detail: gwerr.Detail,
}
for _, o := range options {
@@ -415,11 +418,11 @@ func apiError(id string, error GroupwareError, options ...ErrorOpt) *Error {
return err
}
-func (r Request) apiErrorFromJmap(error jmap.Error) *Error {
- if error == nil {
+func (r Request) apiErrorFromJmap(err jmap.Error) *Error {
+ if err == nil {
return nil
}
- gwe := groupwareErrorFromJmap(error)
+ gwe := groupwareErrorFromJmap(err)
if gwe == nil {
return nil
}
@@ -431,3 +434,7 @@ func (r Request) apiErrorFromJmap(error jmap.Error) *Error {
func errorResponses(errors ...Error) ErrorResponse {
return ErrorResponse{Errors: errors}
}
+
+func (r Request) errorResponseFromJmap(err jmap.Error) Response {
+ return errorResponse(r.apiErrorFromJmap(err))
+}
diff --git a/services/groupware/pkg/groupware/groupware_framework.go b/services/groupware/pkg/groupware/groupware_framework.go
index 4930546748..32648f1e34 100644
--- a/services/groupware/pkg/groupware/groupware_framework.go
+++ b/services/groupware/pkg/groupware/groupware_framework.go
@@ -3,7 +3,9 @@ package groupware
import (
"context"
"crypto/tls"
+ "encoding/json"
"fmt"
+ "io"
"net/http"
"net/url"
"strconv"
@@ -180,6 +182,67 @@ type Request struct {
session *jmap.Session
}
+type Response struct {
+ body any
+ err *Error
+ etag string
+ sessionState string
+}
+
+func errorResponse(err *Error) Response {
+ return Response{
+ body: nil,
+ err: err,
+ etag: "",
+ sessionState: "",
+ }
+}
+
+func response(body any, sessionStatus string) Response {
+ return Response{
+ body: body,
+ err: nil,
+ etag: sessionStatus,
+ sessionState: sessionStatus,
+ }
+}
+
+func etagResponse(body any, sessionState string, etag string) Response {
+ return Response{
+ body: body,
+ err: nil,
+ etag: etag,
+ sessionState: sessionState,
+ }
+}
+
+func etagOnlyResponse(body any, etag string) Response {
+ return Response{
+ body: body,
+ err: nil,
+ etag: etag,
+ sessionState: "",
+ }
+}
+
+func noContentResponse(sessionStatus string) Response {
+ return Response{
+ body: "",
+ err: nil,
+ etag: sessionStatus,
+ sessionState: sessionStatus,
+ }
+}
+
+func notFoundResponse(sessionStatus string) Response {
+ return Response{
+ body: nil,
+ err: nil,
+ etag: sessionStatus,
+ sessionState: sessionStatus,
+ }
+}
+
func (r Request) GetAccountId() string {
accountId := chi.URLParam(r.r, UriParamAccount)
return r.session.MailAccountId(accountId)
@@ -246,6 +309,22 @@ func (r Request) parseDateParam(param string) (time.Time, bool, *Error) {
return t, true, nil
}
+func (r Request) body(target any) *Error {
+ body := r.r.Body
+ defer func(b io.ReadCloser) {
+ err := b.Close()
+ if err != nil {
+ r.logger.Error().Err(err).Msg("failed to close request body")
+ }
+ }(body)
+
+ err := json.NewDecoder(body).Decode(target)
+ if err != nil {
+ // TODO(pbleser-oc) error handling when failing to decode body
+ }
+ return 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 {
@@ -318,7 +397,7 @@ func (g Groupware) serveError(w http.ResponseWriter, r *http.Request, error *Err
render.Render(w, r, errorResponses(*error))
}
-func (g Groupware) respond(w http.ResponseWriter, r *http.Request, handler func(r Request) (any, string, *Error)) {
+func (g Groupware) respond(w http.ResponseWriter, r *http.Request, handler func(r Request) Response) {
ctx := r.Context()
logger := g.logger.SubloggerWithRequestID(ctx)
@@ -355,23 +434,34 @@ func (g Groupware) respond(w http.ResponseWriter, r *http.Request, handler func(
session: &session,
}
- response, state, apierr := handler(req)
- if apierr != nil {
- g.log(apierr)
+ response := handler(req)
+ if response.err != nil {
+ g.log(response.err)
w.Header().Add("Content-Type", ContentTypeJsonApi)
- render.Status(r, apierr.NumStatus)
- w.WriteHeader(apierr.NumStatus)
- render.Render(w, r, errorResponses(*apierr))
+ render.Status(r, response.err.NumStatus)
+ w.WriteHeader(response.err.NumStatus)
+ render.Render(w, r, errorResponses(*response.err))
return
}
- if state != "" {
- w.Header().Add("ETag", state)
+ if response.etag != "" {
+ w.Header().Add("ETag", response.etag)
}
- if response == nil {
+ if response.sessionState != "" {
+ if response.etag == "" {
+ w.Header().Add("ETag", response.sessionState)
+ }
+ w.Header().Add("Session-State", response.sessionState)
+ }
+
+ switch response.body {
+ case nil:
render.Status(r, http.StatusNotFound)
w.WriteHeader(http.StatusNotFound)
- } else {
+ case "":
+ render.Status(r, http.StatusNoContent)
+ w.WriteHeader(http.StatusNoContent)
+ default:
render.Status(r, http.StatusOK)
render.JSON(w, r, response)
}
diff --git a/services/groupware/pkg/groupware/groupware_route.go b/services/groupware/pkg/groupware/groupware_route.go
index 7f661a3ffa..4fc27267c7 100644
--- a/services/groupware/pkg/groupware/groupware_route.go
+++ b/services/groupware/pkg/groupware/groupware_route.go
@@ -5,9 +5,9 @@ import (
)
const (
- UriParamAccount = "account"
+ UriParamAccount = "accountid"
UriParamMailboxId = "mailbox"
- UriParamMessagesId = "id"
+ UriParamMessageId = "messageid"
UriParamBlobId = "blobid"
UriParamBlobName = "blobname"
QueryParamBlobType = "type"
@@ -33,7 +33,7 @@ const (
func (g Groupware) Route(r chi.Router) {
r.Get("/", g.Index)
r.Get("/accounts", g.GetAccounts)
- r.Route("/accounts/{account}", func(r chi.Router) {
+ r.Route("/accounts/{accountid}", func(r chi.Router) {
r.Get("/", g.GetAccount)
r.Get("/identity", g.GetIdentity)
r.Get("/vacation", g.GetVacation)
@@ -44,7 +44,11 @@ func (g Groupware) Route(r chi.Router) {
})
r.Route("/messages", func(r chi.Router) {
r.Get("/", g.GetMessages)
- r.Get("/{id}", g.GetMessagesById)
+ r.Post("/", g.CreateMessage)
+ r.Get("/{messageid}", g.GetMessagesById)
+ r.Patch("/{messageid}", g.UpdateMessage) // or PUT?
+ r.Put("/{messageid}", g.UpdateMessage) // or PATCH?
+ r.Delete("/{messageId}", g.DeleteMessage)
})
r.Route("/blobs", func(r chi.Router) {
r.Get("/{blobid}", g.GetBlob)