mirror of
https://github.com/opencloud-eu/opencloud.git
synced 2026-01-16 00:59:37 -06:00
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:
787
pkg/jmap/jmap.go
787
pkg/jmap/jmap.go
@@ -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
|
||||
})
|
||||
|
||||
}
|
||||
@@ -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
136
pkg/jmap/jmap_api_blob.go
Normal 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
626
pkg/jmap/jmap_api_email.go
Normal 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
|
||||
})
|
||||
|
||||
}
|
||||
22
pkg/jmap/jmap_api_identity.go
Normal file
22
pkg/jmap/jmap_api_identity.go
Normal 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)
|
||||
})
|
||||
}
|
||||
71
pkg/jmap/jmap_api_mailbox.go
Normal file
71
pkg/jmap/jmap_api_mailbox.go
Normal 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
|
||||
})
|
||||
}
|
||||
22
pkg/jmap/jmap_api_vacation.go
Normal file
22
pkg/jmap/jmap_api_vacation.go
Normal 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
64
pkg/jmap/jmap_client.go
Normal 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()}
|
||||
}
|
||||
@@ -232,6 +232,42 @@ const (
|
||||
|
||||
// An ifInState argument was supplied, and it does not match the current state.
|
||||
SetErrorTypeStateMismatch = "stateMismatch"
|
||||
|
||||
// The Email to be sent is invalid in some way.
|
||||
//
|
||||
// The SetError SHOULD contain a property called properties of type String[] that lists all the properties
|
||||
// of the Email that were invalid.
|
||||
SetErrorInvalidEmail = "invalidEmail"
|
||||
|
||||
// The envelope (supplied or generated) has more recipients than the server allows.
|
||||
//
|
||||
// A maxRecipients UnsignedInt property MUST also be present on the SetError specifying
|
||||
// the maximum number of allowed recipients.
|
||||
SetErrorTooManyRecipients = "tooManyRecipients"
|
||||
|
||||
// The envelope (supplied or generated) does not have any rcptTo email addresses.
|
||||
SetErrorNoRecipients = "noRecipients"
|
||||
|
||||
// The rcptTo property of the envelope (supplied or generated) contains at least one rcptTo value which
|
||||
// is not a valid email address for sending to.
|
||||
//
|
||||
// An invalidRecipients String[] property MUST also be present on the SetError, which is a list of the invalid addresses.
|
||||
SetErrorInvalidRecipients = "invalidRecipients"
|
||||
|
||||
// The server does not permit the user to send a message with this envelope From address [RFC5321].
|
||||
//
|
||||
// [RFC5321]: https://datatracker.ietf.org/doc/html/rfc5321
|
||||
SetErrorForbiddenMailFrom = "forbiddenMailFrom"
|
||||
|
||||
// The server does not permit the user to send a message with the From header field [RFC5322] of the message to be sent.
|
||||
//
|
||||
// [RFC5322]: https://datatracker.ietf.org/doc/html/rfc5322
|
||||
SetErrorForbiddenFrom = "forbiddenFrom"
|
||||
|
||||
// The user does not have permission to send at all right now for some reason.
|
||||
//
|
||||
// A description String property MAY be present on the SetError object to display to the user why they are not permitted.
|
||||
SetErrorForbiddenToSend = "forbiddenToSend"
|
||||
)
|
||||
|
||||
type SetError struct {
|
||||
@@ -242,6 +278,21 @@ type SetError struct {
|
||||
//
|
||||
// This is a non-localised string and is not intended to be shown directly to end users.
|
||||
Description string `json:"description,omitempty"`
|
||||
|
||||
// Lists all the properties of the Email that were invalid.
|
||||
//
|
||||
// Only set for the invalidEmail error after a failed EmailSubmission/set errors.
|
||||
Properties []string `json:"properties,omitempty"`
|
||||
|
||||
// Specifies the maximum number of allowed recipients.
|
||||
//
|
||||
// Only set for the tooManyRecipients error after a failed EmailSubmission/set errors.
|
||||
MaxRecipients int `json:"maxRecipients,omitzero"`
|
||||
|
||||
// List of invalid addresses.
|
||||
//
|
||||
// Only set for the invalidRecipients error after a failed EmailSubmission/set errors.
|
||||
InvalidRecipients []string `json:"invalidRecipients,omitempty"`
|
||||
}
|
||||
|
||||
type FilterOperatorTerm string
|
||||
@@ -607,6 +658,19 @@ type EmailHeader struct {
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// Email body part.
|
||||
//
|
||||
// The client may specify a partId OR a blobId, but not both.
|
||||
// If a partId is given, this partId MUST be present in the bodyValues property.
|
||||
//
|
||||
// The charset property MUST be omitted if a partId is given (the part’s content is included
|
||||
// in bodyValues, and the server may choose any appropriate encoding).
|
||||
//
|
||||
// The size property MUST be omitted if a partId is given. If a blobId is given, it may be
|
||||
// included but is ignored by the server (the size is actually calculated from the blob content
|
||||
// itself).
|
||||
//
|
||||
// A Content-Transfer-Encoding header field MUST NOT be given.
|
||||
type EmailBodyPart struct {
|
||||
// Identifies this part uniquely within the Email.
|
||||
//
|
||||
@@ -895,6 +959,242 @@ type Email struct {
|
||||
Preview string `json:"preview,omitempty"`
|
||||
}
|
||||
|
||||
type Address struct {
|
||||
// The email address being represented by the object.
|
||||
//
|
||||
// This is a “Mailbox” as used in the Reverse-path or Forward-path of the MAIL FROM or RCPT TO command in [RFC5321].
|
||||
//
|
||||
// [RFC5321]: https://datatracker.ietf.org/doc/html/rfc5321
|
||||
Email string `json:"email,omitempty"`
|
||||
|
||||
// Any parameters to send with the email address (either mail-parameter or rcpt-parameter as appropriate,
|
||||
// as specified in [RFC5321]).
|
||||
//
|
||||
// If supplied, each key in the object is a parameter name, and the value is either the parameter value (type String)
|
||||
// or null if the parameter does not take a value.
|
||||
//
|
||||
// [RFC5321]: https://datatracker.ietf.org/doc/html/rfc5321
|
||||
Parameters map[string]any `json:"parameters,omitempty"` // TODO RFC5321
|
||||
}
|
||||
|
||||
// Information for use when sending via SMTP.
|
||||
type Envelope struct {
|
||||
// The email address to use as the return address in the SMTP submission,
|
||||
// plus any parameters to pass with the MAIL FROM address.
|
||||
MailFrom Address `json:"mailFrom"`
|
||||
|
||||
// The email addresses to send the message to, and any RCPT TO parameters to pass with the recipient.
|
||||
RcptTo []Address `json:"rcptTo"`
|
||||
}
|
||||
|
||||
type EmailSubmissionUndoStatus string
|
||||
|
||||
const (
|
||||
UndoStatusPending EmailSubmissionUndoStatus = "pending"
|
||||
UndoStatusFinal EmailSubmissionUndoStatus = "final"
|
||||
UndoStatusCanceled EmailSubmissionUndoStatus = "canceled"
|
||||
)
|
||||
|
||||
type DeliveryStatusDelivered string
|
||||
|
||||
const (
|
||||
DeliveredQueued DeliveryStatusDelivered = "queued"
|
||||
DeliveredYes DeliveryStatusDelivered = "yes"
|
||||
DeliveredNo DeliveryStatusDelivered = "no"
|
||||
DeliveredUnknown DeliveryStatusDelivered = "unknown"
|
||||
)
|
||||
|
||||
type DeliveryStatusDisplayed string
|
||||
|
||||
const (
|
||||
DisplayedUnknown DeliveryStatusDisplayed = "unknown"
|
||||
DisplayedYes DeliveryStatusDisplayed = "yes"
|
||||
)
|
||||
|
||||
type DeliveryStatus struct {
|
||||
// The SMTP reply string returned for this recipient when the server last tried to
|
||||
// relay the message, or in a later Delivery Status Notification (DSN, as defined in
|
||||
// [RFC3464]) response for the message.
|
||||
//
|
||||
// This SHOULD be the response to the RCPT TO stage, unless this was accepted and the
|
||||
// message as a whole was rejected at the end of the DATA stage, in which case the
|
||||
// DATA stage reply SHOULD be used instead.
|
||||
//
|
||||
// [RFC3464]: https://datatracker.ietf.org/doc/html/rfc3464
|
||||
SmtpReply string `json:"smtpReply"`
|
||||
|
||||
// Represents whether the message has been successfully delivered to the recipient.
|
||||
//
|
||||
// This MUST be one of the following values:
|
||||
// - queued: The message is in a local mail queue and status will change once it exits
|
||||
// the local mail queues. The smtpReply property may still change.
|
||||
// - yes: The message was successfully delivered to the mail store of the recipient.
|
||||
// The smtpReply property is final.
|
||||
// - no: Delivery to the recipient permanently failed. The smtpReply property is final.
|
||||
// - unknown: The final delivery status is unknown, (e.g., it was relayed to an external
|
||||
// machine and no further information is available).
|
||||
// The smtpReply property may still change if a DSN arrives.
|
||||
Delivered DeliveryStatusDelivered `json:"delivered"`
|
||||
|
||||
// Represents whether the message has been displayed to the recipient.
|
||||
//
|
||||
// This MUST be one of the following values:
|
||||
// - unknown: The display status is unknown. This is the initial value.
|
||||
// - yes: The recipient’s system claims the message content has been displayed to the recipient.
|
||||
// Note that there is no guarantee that the recipient has noticed, read, or understood the content.
|
||||
Displayed DeliveryStatusDisplayed `json:"displayed"`
|
||||
}
|
||||
|
||||
type EmailSubmission struct {
|
||||
// (server-set) The id of the EmailSubmission.
|
||||
Id string `json:"id"`
|
||||
|
||||
// The id of the Identity to associate with this submission.
|
||||
IdentityId string `json:"identityId"`
|
||||
|
||||
// The id of the Email to send.
|
||||
//
|
||||
// The Email being sent does not have to be a draft, for example, when “redirecting” an existing Email
|
||||
// to a different address.
|
||||
EmailId string `json:"emailId"`
|
||||
|
||||
// (server-set) The Thread id of the Email to send.
|
||||
//
|
||||
// This is set by the server to the threadId property of the Email referenced by the emailId.
|
||||
ThreadId string `json:"threadId"`
|
||||
|
||||
// Information for use when sending via SMTP.
|
||||
//
|
||||
// If the envelope property is null or omitted on creation, the server MUST generate this from the
|
||||
// referenced Email as follows:
|
||||
//
|
||||
// - mailFrom: The email address in the Sender header field, if present; otherwise,
|
||||
// it’s the email address in the From header field, if present.
|
||||
// In either case, no parameters are added.
|
||||
// - rcptTo: The deduplicated set of email addresses from the To, Cc, and Bcc header fields,
|
||||
// if present, with no parameters for any of them.
|
||||
Envelope *Envelope `json:"envelope,omitempty"`
|
||||
|
||||
// (server-set) The date the submission was/will be released for delivery.
|
||||
SendAt time.Time `json:"sendAt,omitzero"`
|
||||
|
||||
// (server-set) This represents whether the submission may be canceled.
|
||||
//
|
||||
// This is server set on create and MUST be one of the following values:
|
||||
//
|
||||
// - pending: It may be possible to cancel this submission.
|
||||
// - final: The message has been relayed to at least one recipient in a manner that cannot be
|
||||
// recalled. It is no longer possible to cancel this submission.
|
||||
// - canceled: The submission was canceled and will not be delivered to any recipient.
|
||||
UndoStatus EmailSubmissionUndoStatus `json:"undoStatus"`
|
||||
|
||||
// (server-set) This represents the delivery status for each of the submission’s recipients, if known.
|
||||
//
|
||||
// This property MAY not be supported by all servers, in which case it will remain null.
|
||||
//
|
||||
// Servers that support it SHOULD update the EmailSubmission object each time the status of any of
|
||||
// the recipients changes, even if some recipients are still being retried.
|
||||
//
|
||||
// This value is a map from the email address of each recipient to a DeliveryStatus object.
|
||||
DeliveryStatus map[string]DeliveryStatus `json:"deliveryStatus"`
|
||||
|
||||
// (server-set) A list of blob ids for DSNs [RFC3464] received for this submission,
|
||||
// in order of receipt, oldest first.
|
||||
//
|
||||
// The blob is the whole MIME message (with a top-level content-type of multipart/report), as received.
|
||||
//
|
||||
// [RFC3464]: https://datatracker.ietf.org/doc/html/rfc3464
|
||||
DsnBlobIds []string `json:"dsnBlobIds,omitempty"`
|
||||
|
||||
// (server-set) A list of blob ids for MDNs [RFC8098] received for this submission,
|
||||
// in order of receipt, oldest first.
|
||||
//
|
||||
// The blob is the whole MIME message (with a top-level content-type of multipart/report), as received.
|
||||
//
|
||||
// [RFC8098]: https://datatracker.ietf.org/doc/html/rfc8098
|
||||
MdnBlobIds []string `json:"mdnBlobIds,omitempty"`
|
||||
}
|
||||
|
||||
type EmailSubmissionGetRefCommand struct {
|
||||
// The id of the account to use.
|
||||
AccountId string `json:"accountId"`
|
||||
|
||||
IdRef *ResultReference `json:"#ids,omitempty"`
|
||||
|
||||
Properties []string `json:"properties,omitempty"`
|
||||
}
|
||||
|
||||
type EmailSubmissionGetResponse struct {
|
||||
AccountId string `json:"accountId"`
|
||||
State string `json:"state"`
|
||||
List []EmailSubmission `json:"list,omitempty"`
|
||||
NotFound []string `json:"notFound,omitempty"`
|
||||
}
|
||||
|
||||
// Patch Object.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// - moves it from the drafts folder (which has Mailbox id “7cb4e8ee-df87-4757-b9c4-2ea1ca41b38e”)
|
||||
// to the sent folder (which we presume has Mailbox id “73dbcb4b-bffc-48bd-8c2a-a2e91ca672f6”)
|
||||
//
|
||||
// - removes the $draft flag and
|
||||
//
|
||||
// {
|
||||
// "mailboxIds/7cb4e8ee-df87-4757-b9c4-2ea1ca41b38e": null,
|
||||
// "mailboxIds/73dbcb4b-bffc-48bd-8c2a-a2e91ca672f6": true,
|
||||
// "keywords/$draft": null
|
||||
// }
|
||||
type PatchObject map[string]any
|
||||
|
||||
// same as EmailSubmission but without the server-set attributes
|
||||
type EmailSubmissionCreate struct {
|
||||
// The id of the Identity to associate with this submission.
|
||||
IdentityId string `json:"identityId"`
|
||||
// The id of the Email to send.
|
||||
//
|
||||
// The Email being sent does not have to be a draft, for example, when “redirecting” an existing
|
||||
// Email to a different address.
|
||||
EmailId string `json:"emailId"`
|
||||
|
||||
// Information for use when sending via SMTP.
|
||||
Envelope *Envelope `json:"envelope,omitempty"`
|
||||
}
|
||||
|
||||
type EmailSubmissionSetCommand struct {
|
||||
AccountId string `json:"accountId"`
|
||||
Create map[string]EmailSubmissionCreate `json:"create,omitempty"`
|
||||
OldState string `json:"oldState,omitempty"`
|
||||
NewState string `json:"newState,omitempty"`
|
||||
|
||||
// A map of EmailSubmission id to an object containing properties to update on the Email object
|
||||
// referenced by the EmailSubmission if the create/update/destroy succeeds.
|
||||
//
|
||||
// (For references to EmailSubmissions created in the same “/set” invocation, this is equivalent
|
||||
// to a creation-reference, so the id will be the creation id prefixed with a #.)
|
||||
OnSuccessUpdateEmail map[string]PatchObject `json:"onSuccessUpdateEmail,omitempty"`
|
||||
|
||||
// A list of EmailSubmission ids for which the Email with the corresponding emailId should be destroyed
|
||||
// if the create/update/destroy succeeds.
|
||||
//
|
||||
// (For references to EmailSubmission creations, this is equivalent to a creation-reference so the
|
||||
// id will be the creation id prefixed with a #.)
|
||||
OnSuccessDestroyEmail []string `json:"onSuccessDestroyEmail,omitempty"`
|
||||
}
|
||||
|
||||
type CreatedEmailSubmission struct {
|
||||
Id string `json:"id"`
|
||||
}
|
||||
|
||||
type EmailSubmissionSetResponse struct {
|
||||
AccountId string `json:"accountId"`
|
||||
OldState string `json:"oldState"`
|
||||
NewState string `json:"newState"`
|
||||
Created map[string]CreatedEmailSubmission `json:"created,omitempty"`
|
||||
NotCreated map[string]SetError `json:"notCreated,omitempty"`
|
||||
// TODO(pbleser-oc) add updated and destroyed when they are needed
|
||||
}
|
||||
|
||||
type Command string
|
||||
|
||||
type Invocation struct {
|
||||
@@ -1126,21 +1426,78 @@ type EmailBodyStructure struct {
|
||||
}
|
||||
|
||||
type EmailCreate struct {
|
||||
MailboxIds map[string]bool `json:"mailboxIds,omitempty"`
|
||||
Keywords map[string]bool `json:"keywords,omitempty"`
|
||||
From []EmailAddress `json:"from,omitempty"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
ReceivedAt time.Time `json:"receivedAt,omitzero"`
|
||||
SentAt time.Time `json:"sentAt,omitzero"`
|
||||
// The set of Mailbox ids this Email belongs to.
|
||||
//
|
||||
// An Email in the mail store MUST belong to one or more Mailboxes at all times
|
||||
// (until it is destroyed).
|
||||
//
|
||||
// The set is represented as an object, with each key being a Mailbox id.
|
||||
// The value for each key in the object MUST be true.
|
||||
MailboxIds map[string]bool `json:"mailboxIds,omitempty"`
|
||||
|
||||
// A set of keywords that apply to the Email.
|
||||
//
|
||||
// The set is represented as an object, with the keys being the keywords.
|
||||
// The value for each key in the object MUST be true.
|
||||
Keywords map[string]bool `json:"keywords,omitempty"`
|
||||
|
||||
// The ["From:" field] specifies the author(s) of the message, that is, the mailbox(es)
|
||||
// of the person(s) or system(s) responsible for the writing of the message
|
||||
//
|
||||
// ["From:" field]: https://www.rfc-editor.org/rfc/rfc5322.html#section-3.6.2
|
||||
From []EmailAddress `json:"from,omitempty"`
|
||||
|
||||
// The "Subject:" field contains a short string identifying the topic of the message.
|
||||
Subject string `json:"subject,omitempty"`
|
||||
|
||||
// The date the Email was received by the message store.
|
||||
//
|
||||
// (default: time of most recent Received header, or time of import on server if none).
|
||||
ReceivedAt time.Time `json:"receivedAt,omitzero"`
|
||||
|
||||
// The origination date specifies the date and time at which the creator of the message indicated that
|
||||
// the message was complete and ready to enter the mail delivery system.
|
||||
//
|
||||
// For instance, this might be the time that a user pushes the "send" or "submit" button in an
|
||||
// application program.
|
||||
//
|
||||
// In any case, it is specifically not intended to convey the time that the message is actually transported,
|
||||
// but rather the time at which the human or other creator of the message has put the message into its final
|
||||
// form, ready for transport.
|
||||
//
|
||||
// (For example, a portable computer user who is not connected to a network might queue a message for delivery.
|
||||
// The origination date is intended to contain the date and time that the user queued the message, not the time
|
||||
// when the user connected to the network to send the message.)
|
||||
SentAt time.Time `json:"sentAt,omitzero"`
|
||||
|
||||
// This is the full MIME structure of the message body, without recursing into message/rfc822 or message/global parts.
|
||||
//
|
||||
// Note that EmailBodyParts may have subParts if they are of type multipart/*.
|
||||
BodyStructure EmailBodyStructure `json:"bodyStructure"`
|
||||
|
||||
// This is a map of partId to an EmailBodyValue object for none, some, or all text/* parts.
|
||||
BodyValues map[string]EmailBodyValue `json:"bodyValues,omitempty"`
|
||||
}
|
||||
|
||||
type EmailUpdate map[string]any
|
||||
|
||||
type EmailSetCommand struct {
|
||||
AccountId string `json:"accountId"`
|
||||
Create map[string]EmailCreate `json:"create,omitempty"`
|
||||
Update map[string]EmailUpdate `json:"update,omitempty"`
|
||||
Destroy []string `json:"destroy,omitempty"`
|
||||
}
|
||||
|
||||
type EmailSetResponse struct {
|
||||
AccountId string `json:"accountId"`
|
||||
OldState string `json:"oldState,omitempty"`
|
||||
NewState string `json:"newState"`
|
||||
Created map[string]Email `json:"created,omitempty"`
|
||||
Updated map[string]Email `json:"updated,omitempty"`
|
||||
Destroyed []string `json:"destroyed,omitempty"`
|
||||
NotCreated map[string]SetError `json:"notCreated,omitempty"`
|
||||
NotUpdated map[string]SetError `json:"notUpdated,omitempty"`
|
||||
NotDestroyed map[string]SetError `json:"notDestroyed,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -1372,6 +1729,8 @@ const (
|
||||
EmailChanges Command = "Email/changes"
|
||||
EmailSet Command = "Email/set"
|
||||
EmailImport Command = "Email/import"
|
||||
EmailSubmissionGet Command = "EmailSubmission/get"
|
||||
EmailSubmissionSet Command = "EmailSubmission/set"
|
||||
ThreadGet Command = "Thread/get"
|
||||
MailboxGet Command = "Mailbox/get"
|
||||
MailboxQuery Command = "Mailbox/query"
|
||||
@@ -1390,6 +1749,8 @@ var CommandResponseTypeMap = map[Command]func() any{
|
||||
EmailQuery: func() any { return EmailQueryResponse{} },
|
||||
EmailChanges: func() any { return EmailChangesResponse{} },
|
||||
EmailGet: func() any { return EmailGetResponse{} },
|
||||
EmailSubmissionGet: func() any { return EmailSubmissionGetResponse{} },
|
||||
EmailSubmissionSet: func() any { return EmailSubmissionSetResponse{} },
|
||||
ThreadGet: func() any { return ThreadGetResponse{} },
|
||||
IdentityGet: func() any { return IdentityGetResponse{} },
|
||||
VacationResponseGet: func() any { return VacationResponseGetResponse{} },
|
||||
|
||||
120
pkg/jmap/jmap_session.go
Normal file
120
pkg/jmap/jmap_session.go
Normal 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()}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user