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}
This commit is contained in:
Pascal Bleser
2025-08-06 17:31:05 +02:00
parent a64223fe7d
commit e6441e58d4
20 changed files with 1751 additions and 877 deletions

View File

@@ -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
})
}

View File

@@ -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 = "*"
)

136
pkg/jmap/jmap_api_blob.go Normal file
View File

@@ -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
})
}

626
pkg/jmap/jmap_api_email.go Normal file
View File

@@ -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
})
}

View File

@@ -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)
})
}

View File

@@ -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
})
}

View File

@@ -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)
})
}

64
pkg/jmap/jmap_client.go Normal file
View File

@@ -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()}
}

View File

@@ -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 parts 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 recipients 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,
// its 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 submissions 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{} },

120
pkg/jmap/jmap_session.go Normal file
View File

@@ -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()}
}

View File

@@ -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)
})
}

View File

@@ -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)
})
}

View File

@@ -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)
})
}

View File

@@ -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)
})
}

View File

@@ -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)
}
})
}

View File

@@ -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)
})
}

View File

@@ -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)
})
}

View File

@@ -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))
}

View File

@@ -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)
}

View File

@@ -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)