mirror of
https://github.com/opencloud-eu/opencloud.git
synced 2026-02-11 14:39:09 -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()}
|
||||
}
|
||||
Reference in New Issue
Block a user