mirror of
https://github.com/opencloud-eu/opencloud.git
synced 2026-03-11 03:37:30 -04:00
Groupware improvements: refactoring, k6 tests
* refactored the models to be strongly typed with structs and mapstruct to decompose the dynamic parts of the JMAP payloads * externalized large JSON strings for tests into .json files under testdata/ * added a couple of fantasy Graph groupware APIs to explore further options * added k6 scripts to test those graph/me/messages APIs, with a setup program to set up users in LDAP, fill their IMAP inbox, activate them in Stalwart, cleaning things up, etc...
This commit is contained in:
281
pkg/jmap/jmap.go
281
pkg/jmap/jmap.go
@@ -2,10 +2,10 @@ package jmap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
@@ -26,23 +26,14 @@ type Session struct {
|
||||
JmapUrl string
|
||||
}
|
||||
|
||||
type ContextKey int
|
||||
|
||||
const (
|
||||
ContextAccountId ContextKey = iota
|
||||
ContextOperationId
|
||||
ContextUsername
|
||||
)
|
||||
|
||||
func (s Session) DecorateSession(ctx context.Context) context.Context {
|
||||
ctx = context.WithValue(ctx, ContextUsername, s.Username)
|
||||
ctx = context.WithValue(ctx, ContextAccountId, s.AccountId)
|
||||
return ctx
|
||||
}
|
||||
|
||||
const (
|
||||
logUsername = "username"
|
||||
logAccountId = "account-id"
|
||||
logOperation = "operation"
|
||||
logUsername = "username"
|
||||
logAccountId = "account-id"
|
||||
logMailboxId = "mailbox-id"
|
||||
logFetchBodies = "fetch-bodies"
|
||||
logOffset = "offset"
|
||||
logLimit = "limit"
|
||||
)
|
||||
|
||||
func (s Session) DecorateLogger(l log.Logger) log.Logger {
|
||||
@@ -79,196 +70,90 @@ func (j *Client) FetchSession(username string, logger *log.Logger) (Session, err
|
||||
return NewSession(wk)
|
||||
}
|
||||
|
||||
func (j *Client) GetMailboxes(session Session, ctx context.Context, logger *log.Logger) (Folders, error) {
|
||||
logger.Info().Str("command", "Mailbox/get").Str("accountId", session.AccountId).Msg("GetMailboxes")
|
||||
cmd := simpleCommand("Mailbox/get", map[string]any{"accountId": session.AccountId})
|
||||
commandCtx := context.WithValue(ctx, ContextOperationId, "GetMailboxes")
|
||||
return command(j.api, logger, commandCtx, &cmd, func(body *[]byte) (Folders, error) {
|
||||
var data JmapCommandResponse
|
||||
err := json.Unmarshal(*body, &data)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("failed to deserialize body JSON payload")
|
||||
var zero Folders
|
||||
return zero, err
|
||||
}
|
||||
return parseMailboxGetResponse(data)
|
||||
func (j *Client) logger(operation string, session *Session, logger *log.Logger) *log.Logger {
|
||||
return &log.Logger{Logger: logger.With().Str(logOperation, operation).Str(logUsername, session.Username).Str(logAccountId, session.AccountId).Logger()}
|
||||
}
|
||||
|
||||
func (j *Client) loggerParams(operation string, session *Session, logger *log.Logger, params func(zerolog.Context) zerolog.Context) *log.Logger {
|
||||
base := logger.With().Str(logOperation, operation).Str(logUsername, session.Username).Str(logAccountId, session.AccountId)
|
||||
return &log.Logger{Logger: params(base).Logger()}
|
||||
}
|
||||
|
||||
func (j *Client) GetIdentity(session *Session, ctx context.Context, logger *log.Logger) (IdentityGetResponse, error) {
|
||||
logger = j.logger("GetIdentity", session, logger)
|
||||
cmd, err := NewRequest(NewInvocation(IdentityGet, IdentityGetCommand{AccountId: session.AccountId}, "0"))
|
||||
if err != nil {
|
||||
return IdentityGetResponse{}, err
|
||||
}
|
||||
return command(j.api, logger, ctx, session, cmd, func(body *Response) (IdentityGetResponse, error) {
|
||||
var response IdentityGetResponse
|
||||
err = retrieveResponseMatchParameters(body, IdentityGet, "0", &response)
|
||||
return response, err
|
||||
})
|
||||
}
|
||||
|
||||
func (j *Client) GetEmails(session Session, ctx context.Context, logger *log.Logger, mailboxId string, offset int, limit int, fetchBodies bool, maxBodyValueBytes int) (Emails, error) {
|
||||
cmd := make([][]any, 2)
|
||||
cmd[0] = []any{
|
||||
"Email/query",
|
||||
map[string]any{
|
||||
"accountId": session.AccountId,
|
||||
"filter": map[string]any{
|
||||
"inMailbox": mailboxId,
|
||||
},
|
||||
"sort": []map[string]any{
|
||||
{
|
||||
"isAscending": false,
|
||||
"property": "receivedAt",
|
||||
},
|
||||
},
|
||||
"collapseThreads": true,
|
||||
"position": offset,
|
||||
"limit": limit,
|
||||
"calculateTotal": true,
|
||||
},
|
||||
"0",
|
||||
func (j *Client) GetVacation(session *Session, ctx context.Context, logger *log.Logger) (VacationResponseGetResponse, error) {
|
||||
logger = j.logger("GetVacation", session, logger)
|
||||
cmd, err := NewRequest(NewInvocation(VacationResponseGet, VacationResponseGetCommand{AccountId: session.AccountId}, "0"))
|
||||
if err != nil {
|
||||
return VacationResponseGetResponse{}, err
|
||||
}
|
||||
cmd[1] = []any{
|
||||
"Email/get",
|
||||
map[string]any{
|
||||
"accountId": session.AccountId,
|
||||
"fetchAllBodyValues": fetchBodies,
|
||||
"maxBodyValueBytes": maxBodyValueBytes,
|
||||
"#ids": map[string]any{
|
||||
"name": "Email/query",
|
||||
"path": "/ids/*",
|
||||
"resultOf": "0",
|
||||
},
|
||||
},
|
||||
"1",
|
||||
return command(j.api, logger, ctx, session, cmd, func(body *Response) (VacationResponseGetResponse, error) {
|
||||
var response VacationResponseGetResponse
|
||||
err = retrieveResponseMatchParameters(body, VacationResponseGet, "0", &response)
|
||||
return response, err
|
||||
})
|
||||
}
|
||||
|
||||
func (j *Client) GetMailboxes(session *Session, ctx context.Context, logger *log.Logger) (MailboxGetResponse, error) {
|
||||
logger = j.logger("GetMailboxes", session, logger)
|
||||
cmd, err := NewRequest(NewInvocation(MailboxGet, MailboxGetCommand{AccountId: session.AccountId}, "0"))
|
||||
if err != nil {
|
||||
return MailboxGetResponse{}, err
|
||||
}
|
||||
commandCtx := context.WithValue(ctx, ContextOperationId, "GetEmails")
|
||||
return command(j.api, logger, ctx, session, cmd, func(body *Response) (MailboxGetResponse, error) {
|
||||
var response MailboxGetResponse
|
||||
err = retrieveResponseMatchParameters(body, MailboxGet, "0", &response)
|
||||
return response, err
|
||||
})
|
||||
}
|
||||
|
||||
logger = &log.Logger{Logger: logger.With().Str("mailboxId", mailboxId).Bool("fetchBodies", fetchBodies).Int("offset", offset).Int("limit", limit).Logger()}
|
||||
type Emails struct {
|
||||
Emails []Email
|
||||
State string
|
||||
}
|
||||
|
||||
return command(j.api, logger, commandCtx, &cmd, func(body *[]byte) (Emails, error) {
|
||||
var data JmapCommandResponse
|
||||
err := json.Unmarshal(*body, &data)
|
||||
func (j *Client) GetEmails(session *Session, ctx context.Context, logger *log.Logger, mailboxId string, offset int, limit int, fetchBodies bool, maxBodyValueBytes int) (Emails, error) {
|
||||
logger = j.loggerParams("GetEmails", session, logger, func(z zerolog.Context) zerolog.Context {
|
||||
return z.Bool(logFetchBodies, fetchBodies).Int(logOffset, offset).Int(logLimit, limit)
|
||||
})
|
||||
cmd, err := NewRequest(
|
||||
NewInvocation(EmailQuery, EmailQueryCommand{
|
||||
AccountId: session.AccountId,
|
||||
Filter: &Filter{InMailbox: mailboxId},
|
||||
Sort: []Sort{{Property: "receivedAt", IsAscending: false}},
|
||||
CollapseThreads: true,
|
||||
Position: offset,
|
||||
Limit: limit,
|
||||
CalculateTotal: false,
|
||||
}, "0"),
|
||||
NewInvocation(EmailGet, EmailGetCommand{
|
||||
AccountId: session.AccountId,
|
||||
FetchAllBodyValues: fetchBodies,
|
||||
MaxBodyValueBytes: maxBodyValueBytes,
|
||||
IdRef: &Ref{Name: EmailQuery, Path: "/ids/*", ResultOf: "0"},
|
||||
}, "1"),
|
||||
)
|
||||
if err != nil {
|
||||
return Emails{}, err
|
||||
}
|
||||
|
||||
return command(j.api, logger, ctx, session, cmd, func(body *Response) (Emails, error) {
|
||||
var response EmailGetResponse
|
||||
err = retrieveResponseMatchParameters(body, EmailGet, "1", &response)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("failed to unmarshal response payload")
|
||||
return Emails{}, err
|
||||
}
|
||||
first := retrieveResponseMatch(&data, 3, "Email/get", "1")
|
||||
if first == nil {
|
||||
return Emails{Emails: []Email{}, State: data.SessionState}, nil
|
||||
}
|
||||
if len(first) != 3 {
|
||||
return Emails{}, fmt.Errorf("wrong Email/get response payload size, expecting a length of 3 but it is %v", len(first))
|
||||
}
|
||||
|
||||
payload := first[1].(map[string]any)
|
||||
list, listExists := payload["list"].([]any)
|
||||
if !listExists {
|
||||
return Emails{}, fmt.Errorf("wrong Email/get response payload size, expecting a length of 3 but it is %v", len(first))
|
||||
}
|
||||
|
||||
emails := make([]Email, 0, len(list))
|
||||
for _, elem := range list {
|
||||
email, err := mapEmail(elem.(map[string]any), fetchBodies, logger)
|
||||
if err != nil {
|
||||
return Emails{}, err
|
||||
}
|
||||
emails = append(emails, email)
|
||||
}
|
||||
return Emails{Emails: emails, State: data.SessionState}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (j *Client) EmailThreadsQuery(session Session, ctx context.Context, logger *log.Logger, mailboxId string) (Emails, error) {
|
||||
cmd := make([][]any, 4)
|
||||
cmd[0] = []any{
|
||||
"Email/query",
|
||||
map[string]any{
|
||||
"accountId": session.AccountId,
|
||||
"filter": map[string]any{
|
||||
"inMailbox": mailboxId,
|
||||
},
|
||||
"sort": []map[string]any{
|
||||
{
|
||||
"isAscending": false,
|
||||
"property": "receivedAt",
|
||||
},
|
||||
},
|
||||
"collapseThreads": true,
|
||||
"position": 0,
|
||||
"limit": 30,
|
||||
"calculateTotal": true,
|
||||
},
|
||||
"0",
|
||||
}
|
||||
cmd[1] = []any{
|
||||
"Email/get",
|
||||
map[string]any{
|
||||
"accountId": session.AccountId,
|
||||
"#ids": map[string]any{
|
||||
"resultOf": "0",
|
||||
"name": "Email/query",
|
||||
"path": "/ids",
|
||||
},
|
||||
"properties": []string{"threadId"},
|
||||
},
|
||||
"1",
|
||||
}
|
||||
cmd[2] = []any{
|
||||
"Thread/get",
|
||||
map[string]any{
|
||||
"accountId": session.AccountId,
|
||||
"#ids": map[string]any{
|
||||
"resultOf": "1",
|
||||
"name": "Email/get",
|
||||
"path": "/list/*/threadId",
|
||||
},
|
||||
},
|
||||
"2",
|
||||
}
|
||||
cmd[3] = []any{
|
||||
"Email/get",
|
||||
map[string]any{
|
||||
"accountId": session.AccountId,
|
||||
"#ids": map[string]any{
|
||||
"resultOf": "2",
|
||||
"name": "Thread/get",
|
||||
"path": "/list/*/emailIds",
|
||||
},
|
||||
"properties": []string{
|
||||
"threadId",
|
||||
"mailboxIds",
|
||||
"keywords",
|
||||
"hasAttachment",
|
||||
"from",
|
||||
"subject",
|
||||
"receivedAt",
|
||||
"size",
|
||||
"preview",
|
||||
},
|
||||
},
|
||||
"3",
|
||||
}
|
||||
|
||||
commandCtx := context.WithValue(ctx, ContextOperationId, "EmailThreadsQuery")
|
||||
return command(j.api, logger, commandCtx, &cmd, func(body *[]byte) (Emails, error) {
|
||||
var data JmapCommandResponse
|
||||
err := json.Unmarshal(*body, &data)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("failed to unmarshal response payload")
|
||||
return Emails{}, err
|
||||
}
|
||||
first := retrieveResponseMatch(&data, 3, "Email/get", "3")
|
||||
if first == nil {
|
||||
return Emails{Emails: []Email{}, State: data.SessionState}, nil
|
||||
}
|
||||
if len(first) != 3 {
|
||||
return Emails{}, fmt.Errorf("wrong Email/get response payload size, expecting a length of 3 but it is %v", len(first))
|
||||
}
|
||||
|
||||
payload := first[1].(map[string]any)
|
||||
list, listExists := payload["list"].([]any)
|
||||
if !listExists {
|
||||
return Emails{}, fmt.Errorf("wrong Email/get response payload size, expecting a length of 3 but it is %v", len(first))
|
||||
}
|
||||
|
||||
emails := make([]Email, 0, len(list))
|
||||
for _, elem := range list {
|
||||
email, err := mapEmail(elem.(map[string]any), false, logger)
|
||||
if err != nil {
|
||||
return Emails{}, err
|
||||
}
|
||||
emails = append(emails, email)
|
||||
}
|
||||
return Emails{Emails: emails, State: data.SessionState}, nil
|
||||
return Emails{Emails: response.List, State: body.SessionState}, nil
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
package jmap
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
)
|
||||
|
||||
type ApiClient interface {
|
||||
Command(ctx context.Context, logger *log.Logger, session *Session, request Request) ([]byte, error)
|
||||
}
|
||||
|
||||
type WellKnownClient interface {
|
||||
GetWellKnown(username string, logger *log.Logger) (WellKnownResponse, error)
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
package jmap
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
)
|
||||
|
||||
type ApiClient interface {
|
||||
Command(ctx context.Context, logger *log.Logger, request map[string]any) ([]byte, error)
|
||||
}
|
||||
@@ -23,6 +23,7 @@ type HttpJmapApiClient struct {
|
||||
usernameProvider HttpJmapUsernameProvider
|
||||
masterUser string
|
||||
masterPassword string
|
||||
userAgent string
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -39,6 +40,7 @@ func NewHttpJmapApiClient(baseurl string, jmapurl string, client *http.Client, u
|
||||
usernameProvider: usernameProvider,
|
||||
masterUser: masterUser,
|
||||
masterPassword: masterPassword,
|
||||
userAgent: "OpenCloud/" + version.GetString(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +54,7 @@ func (h *HttpJmapApiClient) auth(logger *log.Logger, ctx context.Context, req *h
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *HttpJmapApiClient) authWithUsername(logger *log.Logger, username string, req *http.Request) error {
|
||||
func (h *HttpJmapApiClient) authWithUsername(_ *log.Logger, username string, req *http.Request) error {
|
||||
masterUsername := username + "%" + h.masterUser
|
||||
req.SetBasicAuth(masterUsername, h.masterPassword)
|
||||
return nil
|
||||
@@ -103,8 +105,11 @@ func (h *HttpJmapApiClient) GetWellKnown(username string, logger *log.Logger) (W
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (h *HttpJmapApiClient) Command(ctx context.Context, logger *log.Logger, request map[string]any) ([]byte, error) {
|
||||
func (h *HttpJmapApiClient) Command(ctx context.Context, logger *log.Logger, session *Session, request Request) ([]byte, error) {
|
||||
jmapUrl := h.jmapurl
|
||||
if jmapUrl == "" {
|
||||
jmapUrl = session.JmapUrl
|
||||
}
|
||||
|
||||
bodyBytes, marshalErr := json.Marshal(request)
|
||||
if marshalErr != nil {
|
||||
@@ -118,7 +123,7 @@ func (h *HttpJmapApiClient) Command(ctx context.Context, logger *log.Logger, req
|
||||
return nil, reqErr
|
||||
}
|
||||
req.Header.Add("Content-Type", "application/json")
|
||||
req.Header.Add("User-Agent", "OpenCloud/"+version.GetString())
|
||||
req.Header.Add("User-Agent", h.userAgent)
|
||||
h.auth(logger, ctx, req)
|
||||
|
||||
res, err := h.client.Do(req)
|
||||
@@ -1,67 +1,166 @@
|
||||
package jmap
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
JmapCore = "urn:ietf:params:jmap:core"
|
||||
JmapMail = "urn:ietf:params:jmap:mail"
|
||||
JmapCore = "urn:ietf:params:jmap:core"
|
||||
JmapMail = "urn:ietf:params:jmap:mail"
|
||||
JmapMDN = "urn:ietf:params:jmap:mdn" // https://datatracker.ietf.org/doc/rfc9007/
|
||||
JmapSubmission = "urn:ietf:params:jmap:submission"
|
||||
JmapVacationResponse = "urn:ietf:params:jmap:vacationresponse"
|
||||
JmapCalendars = "urn:ietf:params:jmap:calendars"
|
||||
JmapKeywordPrefix = "$"
|
||||
JmapKeywordSeen = "$seen"
|
||||
JmapKeywordDraft = "$draft"
|
||||
JmapKeywordFlagged = "$flagged"
|
||||
JmapKeywordAnswered = "$answered"
|
||||
JmapKeywordForwarded = "$forwarded"
|
||||
JmapKeywordPhishing = "$phising"
|
||||
JmapKeywordJunk = "$junk"
|
||||
JmapKeywordNotJunk = "$notjunk"
|
||||
JmapKeywordMdnSent = "$mdnsent"
|
||||
)
|
||||
|
||||
type WellKnownResponse struct {
|
||||
Username string `json:"username"`
|
||||
ApiUrl string `json:"apiUrl"`
|
||||
PrimaryAccounts map[string]string `json:"primaryAccounts"`
|
||||
type WellKnownAccount struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
IsPersonal bool `json:"isPersonal"`
|
||||
IsReadOnly bool `json:"isReadOnly"`
|
||||
AccountCapabilities map[string]any `json:"accountCapabilities,omitempty"`
|
||||
}
|
||||
|
||||
type JmapFolder struct {
|
||||
type WellKnownResponse struct {
|
||||
Capabilities map[string]any `json:"capabilities,omitempty"`
|
||||
Accounts map[string]WellKnownAccount `json:"accounts,omitempty"`
|
||||
PrimaryAccounts map[string]string `json:"primaryAccounts,omitempty"`
|
||||
Username string `json:"username,omitempty"`
|
||||
ApiUrl string `json:"apiUrl,omitempty"`
|
||||
DownloadUrl string `json:"downloadUrl,omitempty"`
|
||||
UploadUrl string `json:"uploadUrl,omitempty"`
|
||||
EventSourceUrl string `json:"eventSourceUrl,omitempty"`
|
||||
State string `json:"state,omitempty"`
|
||||
}
|
||||
|
||||
type Mailbox struct {
|
||||
Id string
|
||||
Name string
|
||||
ParentId string
|
||||
Role string
|
||||
SortOrder int
|
||||
IsSubscribed bool
|
||||
TotalEmails int
|
||||
UnreadEmails int
|
||||
TotalThreads int
|
||||
UnreadThreads int
|
||||
}
|
||||
type Folders struct {
|
||||
Folders []JmapFolder
|
||||
state string
|
||||
MyRights map[string]bool
|
||||
}
|
||||
|
||||
type JmapCommandResponse struct {
|
||||
MethodResponses [][]any `json:"methodResponses"`
|
||||
SessionState string `json:"sessionState"`
|
||||
type MailboxGetCommand struct {
|
||||
AccountId string `json:"accountId"`
|
||||
}
|
||||
|
||||
type Filter struct {
|
||||
InMailbox string `json:"inMailbox,omitempty"`
|
||||
InMailboxOtherThan []string `json:"inMailboxOtherThan,omitempty"`
|
||||
Before time.Time `json:"before,omitzero"` // omitzero requires Go 1.24
|
||||
After time.Time `json:"after,omitzero"`
|
||||
MinSize int `json:"minSize,omitempty"`
|
||||
MaxSize int `json:"maxSize,omitempty"`
|
||||
AllInThreadHaveKeyword string `json:"allInThreadHaveKeyword,omitempty"`
|
||||
SomeInThreadHaveKeyword string `json:"someInThreadHaveKeyword,omitempty"`
|
||||
NoneInThreadHaveKeyword string `json:"noneInThreadHaveKeyword,omitempty"`
|
||||
HasKeyword string `json:"hasKeyword,omitempty"`
|
||||
NotKeyword string `json:"notKeyword,omitempty"`
|
||||
HasAttachment bool `json:"hasAttachment,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
type Sort struct {
|
||||
Property string `json:"property,omitempty"`
|
||||
IsAscending bool `json:"isAscending,omitempty"`
|
||||
}
|
||||
|
||||
type EmailQueryCommand struct {
|
||||
AccountId string `json:"accountId"`
|
||||
Filter *Filter `json:"filter,omitempty"`
|
||||
Sort []Sort `json:"sort,omitempty"`
|
||||
CollapseThreads bool `json:"collapseThreads,omitempty"`
|
||||
Position int `json:"position,omitempty"`
|
||||
Limit int `json:"limit,omitempty"`
|
||||
CalculateTotal bool `json:"calculateTotal,omitempty"`
|
||||
}
|
||||
|
||||
type Ref struct {
|
||||
Name Command `json:"name"`
|
||||
Path string `json:"path,omitempty"`
|
||||
ResultOf string `json:"resultOf,omitempty"`
|
||||
}
|
||||
|
||||
type EmailGetCommand struct {
|
||||
AccountId string `json:"accountId"`
|
||||
FetchAllBodyValues bool `json:"fetchAllBodyValues,omitempty"`
|
||||
MaxBodyValueBytes int `json:"maxBodyValueBytes,omitempty"`
|
||||
IdRef *Ref `json:"#ids,omitempty"`
|
||||
}
|
||||
|
||||
type EmailAddress struct {
|
||||
Name string
|
||||
Email string
|
||||
}
|
||||
|
||||
type EmailBodyRef struct {
|
||||
PartId string
|
||||
BlobId string
|
||||
Size int
|
||||
Name string
|
||||
Type string
|
||||
Charset string
|
||||
Disposition string
|
||||
Cid string
|
||||
Language string
|
||||
Location string
|
||||
}
|
||||
|
||||
type EmailBody struct {
|
||||
IsEncodingProblem bool
|
||||
IsTruncated bool
|
||||
Value string
|
||||
}
|
||||
type Email struct {
|
||||
Id string
|
||||
MessageId string
|
||||
MessageId []string
|
||||
BlobId string
|
||||
ThreadId string
|
||||
Size int
|
||||
From string
|
||||
From []EmailAddress
|
||||
To []EmailAddress
|
||||
Cc []EmailAddress
|
||||
Bcc []EmailAddress
|
||||
ReplyTo []EmailAddress
|
||||
Subject string
|
||||
HasAttachments bool
|
||||
Received time.Time
|
||||
ReceivedAt time.Time
|
||||
SentAt time.Time
|
||||
Preview string
|
||||
Bodies map[string]string
|
||||
}
|
||||
|
||||
type Emails struct {
|
||||
Emails []Email
|
||||
State string
|
||||
BodyValues map[string]EmailBody
|
||||
TextBody []EmailBodyRef
|
||||
HtmlBody []EmailBodyRef
|
||||
Keywords map[string]bool
|
||||
MailboxIds map[string]bool
|
||||
}
|
||||
|
||||
type Command string
|
||||
|
||||
const (
|
||||
EmailGet Command = "Email/get"
|
||||
EmailQuery Command = "Email/query"
|
||||
ThreadGet Command = "Thread/get"
|
||||
EmailGet Command = "Email/get"
|
||||
EmailQuery Command = "Email/query"
|
||||
EmailSet Command = "Email/set"
|
||||
ThreadGet Command = "Thread/get"
|
||||
MailboxGet Command = "Mailbox/get"
|
||||
MailboxQuery Command = "Mailbox/query"
|
||||
IdentityGet Command = "Identity/get"
|
||||
VacationResponseGet Command = "VacationResponse/get"
|
||||
)
|
||||
|
||||
type Invocation struct {
|
||||
@@ -70,53 +169,7 @@ type Invocation struct {
|
||||
Tag string
|
||||
}
|
||||
|
||||
func (i *Invocation) MarshalJSON() ([]byte, error) {
|
||||
arr := []any{string(i.Command), i.Parameters, i.Tag}
|
||||
return json.Marshal(arr)
|
||||
}
|
||||
func strarr(value any) ([]string, error) {
|
||||
switch v := value.(type) {
|
||||
case []string:
|
||||
return v, nil
|
||||
case int:
|
||||
return []string{strconv.FormatInt(int64(v), 10)}, nil
|
||||
case float32:
|
||||
return []string{strconv.FormatFloat(float64(v), 'f', -1, 32)}, nil
|
||||
case float64:
|
||||
return []string{strconv.FormatFloat(v, 'f', -1, 64)}, nil
|
||||
case string:
|
||||
return []string{v}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported string array type")
|
||||
}
|
||||
}
|
||||
func (i *Invocation) UnmarshalJSON(bs []byte) error {
|
||||
arr := []any{}
|
||||
json.Unmarshal(bs, &arr)
|
||||
i.Command = Command(arr[0].(string))
|
||||
payload := arr[1].(map[string]any)
|
||||
switch i.Command {
|
||||
case EmailQuery:
|
||||
ids, err := strarr(payload["ids"])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
i.Parameters = EmailQueryResponse{
|
||||
AccountId: payload["accountId"].(string),
|
||||
QueryState: payload["queryState"].(string),
|
||||
CanCalculateChanges: payload["canCalculateChanges"].(bool),
|
||||
Position: payload["position"].(int),
|
||||
Ids: ids,
|
||||
Total: payload["total"].(int),
|
||||
}
|
||||
default:
|
||||
return &json.UnsupportedTypeError{}
|
||||
}
|
||||
i.Tag = arr[2].(string)
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewInvocation(command Command, parameters map[string]any, tag string) Invocation {
|
||||
func NewInvocation(command Command, parameters any, tag string) Invocation {
|
||||
return Invocation{
|
||||
Command: command,
|
||||
Parameters: parameters,
|
||||
@@ -138,8 +191,6 @@ func NewRequest(methodCalls ...Invocation) (Request, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// TODO: NewRequestWithIds
|
||||
|
||||
type Response struct {
|
||||
MethodResponses []Invocation `json:"methodResponses"`
|
||||
CreatedIds map[string]string `json:"createdIds,omitempty"`
|
||||
@@ -154,13 +205,115 @@ type EmailQueryResponse struct {
|
||||
Ids []string `json:"ids"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
type Thread struct {
|
||||
ThreadId string `json:"threadId"`
|
||||
Id string `json:"id"`
|
||||
}
|
||||
type EmailGetResponse struct {
|
||||
AccountId string `json:"accountId"`
|
||||
State string `json:"state"`
|
||||
List []Thread `json:"list"`
|
||||
NotFound []any `json:"notFound"`
|
||||
AccountId string `json:"accountId"`
|
||||
State string `json:"state"`
|
||||
List []Email `json:"list"`
|
||||
NotFound []any `json:"notFound"`
|
||||
}
|
||||
|
||||
type MailboxGetResponse struct {
|
||||
AccountId string `json:"accountId"`
|
||||
State string `json:"state"`
|
||||
List []Mailbox `json:"list"`
|
||||
NotFound []any `json:"notFound"`
|
||||
}
|
||||
|
||||
type MailboxQueryResponse struct {
|
||||
AccountId string `json:"accountId"`
|
||||
QueryState string `json:"queryState"`
|
||||
CanCalculateChanges bool `json:"canCalculateChanges"`
|
||||
Position int `json:"position"`
|
||||
Ids []string `json:"ids"`
|
||||
}
|
||||
|
||||
type EmailBodyStructure struct {
|
||||
Type string //`json:"type"`
|
||||
PartId string //`json:"partId"`
|
||||
Other map[string]any `mapstructure:",remain"`
|
||||
}
|
||||
|
||||
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"`
|
||||
BodyStructure EmailBodyStructure `json:"bodyStructure,omitempty"`
|
||||
//BodyStructure map[string]any `json:"bodyStructure,omitempty"`
|
||||
}
|
||||
|
||||
type EmailSetCommand struct {
|
||||
AccountId string `json:"accountId"`
|
||||
Create map[string]EmailCreate `json:"create,omitempty"`
|
||||
}
|
||||
|
||||
type EmailSetResponse struct {
|
||||
}
|
||||
|
||||
type Thread struct {
|
||||
Id string
|
||||
EmailIds []string
|
||||
}
|
||||
|
||||
type ThreadGetResponse struct {
|
||||
AccountId string
|
||||
State string
|
||||
List []Thread
|
||||
NotFound []any
|
||||
}
|
||||
|
||||
type IdentityGetCommand struct {
|
||||
AccountId string `json:"accountId"`
|
||||
Ids []string `json:"ids,omitempty"`
|
||||
}
|
||||
|
||||
type Identity struct {
|
||||
Id string `json:"id"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
ReplyTo string `json:"replyTo:omitempty"`
|
||||
Bcc []EmailAddress `json:"bcc,omitempty"`
|
||||
TextSignature string `json:"textSignature,omitempty"`
|
||||
HtmlSignature string `json:"htmlSignature,omitempty"`
|
||||
MayDelete bool `json:"mayDelete"`
|
||||
}
|
||||
|
||||
type IdentityGetResponse struct {
|
||||
AccountId string `json:"accountId"`
|
||||
State string `json:"state"`
|
||||
List []Identity `json:"list,omitempty"`
|
||||
NotFound []any `json:"notFound,omitempty"`
|
||||
}
|
||||
|
||||
type VacationResponseGetCommand struct {
|
||||
AccountId string `json:"accountId"`
|
||||
}
|
||||
|
||||
type VacationResponse struct {
|
||||
Id string `json:"id"`
|
||||
IsEnabled bool `json:"isEnabled"`
|
||||
FromDate time.Time `json:"fromDate,omitzero"`
|
||||
ToDate time.Time `json:"toDate,omitzero"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
TextBody string `json:"textBody,omitempty"`
|
||||
HtmlBody string `json:"htmlBody,omitempty"`
|
||||
}
|
||||
|
||||
type VacationResponseGetResponse struct {
|
||||
AccountId string `json:"accountId"`
|
||||
State string `json:"state,omitempty"`
|
||||
List []VacationResponse `json:"list,omitempty"`
|
||||
NotFound []any `json:"notFound,omitempty"`
|
||||
}
|
||||
|
||||
var CommandResponseTypeMap = map[Command]func() any{
|
||||
MailboxQuery: func() any { return MailboxQueryResponse{} },
|
||||
MailboxGet: func() any { return MailboxGetResponse{} },
|
||||
EmailQuery: func() any { return EmailQueryResponse{} },
|
||||
EmailGet: func() any { return EmailGetResponse{} },
|
||||
ThreadGet: func() any { return ThreadGetResponse{} },
|
||||
IdentityGet: func() any { return IdentityGetResponse{} },
|
||||
VacationResponseGet: func() any { return VacationResponseGetResponse{} },
|
||||
}
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
package jmap_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/jmap"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRequestSerialization(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
request, err := jmap.NewRequest(
|
||||
jmap.NewInvocation(jmap.EmailGet, map[string]any{
|
||||
"accountId": "j",
|
||||
"queryState": "aaa",
|
||||
"ids": []string{"a", "b"},
|
||||
"total": 1,
|
||||
}, "0"),
|
||||
)
|
||||
require.NoError(err)
|
||||
|
||||
require.Len(request.MethodCalls, 1)
|
||||
require.Equal("0", request.MethodCalls[0].Tag)
|
||||
|
||||
requestAsJson, err := json.Marshal(request)
|
||||
require.NoError(err)
|
||||
require.Equal(`{"using":["urn:ietf:params:jmap:core","urn:ietf:params:jmap:mail"],"methodCalls":[["Email/get",{"accountId":"j","ids":["a","b"],"queryState":"aaa","total":1},"0"]]}`, string(requestAsJson))
|
||||
}
|
||||
|
||||
const mails2 = `{"methodResponses":[
|
||||
["Email/query",{
|
||||
"accountId":"j",
|
||||
"queryState":"sqcakzewfqdk7oay",
|
||||
"canCalculateChanges":true,
|
||||
"position":0,
|
||||
"ids":["fmaaaabh"],
|
||||
"total":1
|
||||
}, "0"],
|
||||
["Email/get", {
|
||||
"accountId":"j",
|
||||
"state":"sqcakzewfqdk7oay",
|
||||
"list":[
|
||||
{
|
||||
"threadId":"bl",
|
||||
"id":"fmaaaabh"
|
||||
}
|
||||
],
|
||||
"notFound":[]
|
||||
}, "1"],
|
||||
["Thread/get",{
|
||||
"accountId":"j",
|
||||
"state":"sqcakzewfqdk7oay",
|
||||
"list":[
|
||||
{
|
||||
"id":"bl",
|
||||
"emailIds":["fmaaaabh"]
|
||||
}
|
||||
],
|
||||
"notFound":[]
|
||||
}, "2"],
|
||||
["Email/get",{
|
||||
"accountId":"j",
|
||||
"state":"sqcakzewfqdk7oay",
|
||||
"list":[
|
||||
{
|
||||
"threadId":"bl",
|
||||
"mailboxIds":{"a":true},
|
||||
"keywords":{},
|
||||
"hasAttachment":false,
|
||||
"from":[
|
||||
{"name":"current generally", "email":"current.generally@example.com"}
|
||||
],
|
||||
"subject":"eros auctor proin",
|
||||
"receivedAt":"2025-04-30T09:47:44Z",
|
||||
"size":15423,
|
||||
"preview":"Lorem ipsum dolor sit amet consectetur adipiscing elit sed urna tristique himenaeos eu a mattis laoreet aliquet enim. Magnis est facilisis nibh nisl vitae nisi mauris nostra velit donec erat pellentesque sagittis ligula turpis suscipit ultricies. Morbi ...",
|
||||
"id":"fmaaaabh"
|
||||
}
|
||||
],
|
||||
"notFound":[]
|
||||
}, "3"]
|
||||
], "sessionState":"3e25b2a0"
|
||||
}`
|
||||
|
||||
func TestResponseDeserialization(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
var response jmap.Response
|
||||
err := json.Unmarshal([]byte(mails2), &response)
|
||||
require.NoError(err)
|
||||
|
||||
t.Log(response)
|
||||
|
||||
require.Len(response.MethodResponses, 4)
|
||||
require.Nil(response.CreatedIds)
|
||||
require.Equal("3e25b2a0", response.SessionState)
|
||||
require.Equal(jmap.EmailQuery, response.MethodResponses[0].Command)
|
||||
require.Equal(map[string]any{
|
||||
"accountId": "j",
|
||||
"queryState": "sqcakzewfqdk7oay",
|
||||
"canCalculateChanges": true,
|
||||
"position": 0.0,
|
||||
"ids": []any{"fmaaaabh"},
|
||||
"total": 1.0,
|
||||
}, response.MethodResponses[0].Parameters)
|
||||
|
||||
require.Equal("0", response.MethodResponses[0].Tag)
|
||||
require.Equal(jmap.EmailGet, response.MethodResponses[1].Command)
|
||||
require.Equal("1", response.MethodResponses[1].Tag)
|
||||
require.Equal(jmap.ThreadGet, response.MethodResponses[2].Command)
|
||||
require.Equal("2", response.MethodResponses[2].Tag)
|
||||
require.Equal(jmap.EmailGet, response.MethodResponses[3].Command)
|
||||
require.Equal("3", response.MethodResponses[3].Tag)
|
||||
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
|
||||
)
|
||||
|
||||
// implements HttpJmapUsernameProvider
|
||||
type RevaContextHttpJmapUsernameProvider struct {
|
||||
}
|
||||
|
||||
@@ -2,8 +2,11 @@ package jmap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -11,161 +14,6 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const mails1 = `{"methodResponses": [
|
||||
["Email/query",{
|
||||
"accountId":"j",
|
||||
"queryState":"sqcakzewfqdk7oay",
|
||||
"canCalculateChanges":true,
|
||||
"position":0,
|
||||
"ids":["fmaaaabh"],
|
||||
"total":1
|
||||
},"0"],
|
||||
["Email/get",{
|
||||
"accountId":"j",
|
||||
"state":"sqcakzewfqdk7oay",
|
||||
"list":[
|
||||
{"threadId":"bl","id":"fmaaaabh"}
|
||||
],"notFound":[]
|
||||
},"1"],
|
||||
["Thread/get",{
|
||||
"accountId":"j",
|
||||
"state":"sqcakzewfqdk7oay",
|
||||
"list":[
|
||||
{"id":"bl","emailIds":["fmaaaabh"]}
|
||||
],"notFound":[]
|
||||
},"2"],
|
||||
["Email/get",{
|
||||
"accountId":"j",
|
||||
"state":"sqcakzewfqdk7oay",
|
||||
"list":[
|
||||
{"threadId":"bl","mailboxIds":{"a":true},"keywords":{},"hasAttachment":false,"from":[{"name":"current generally","email":"current.generally"}],"subject":"eros auctor proin","receivedAt":"2025-04-30T09:47:44Z","size":15423,"preview":"Lorem ipsum dolor sit amet consectetur adipiscing elit sed urna tristique himenaeos eu a mattis laoreet aliquet enim. Magnis est facilisis nibh nisl vitae nisi mauris nostra velit donec erat pellentesque sagittis ligula turpis suscipit ultricies. Morbi ...","id":"fmaaaabh"}
|
||||
],"notFound":[]
|
||||
},"3"]
|
||||
],"sessionState":"3e25b2a0"
|
||||
}`
|
||||
|
||||
const mailboxes = `{"methodResponses": [
|
||||
["Mailbox/get", {
|
||||
"accountId":"cs",
|
||||
"state":"n",
|
||||
"list": [
|
||||
{
|
||||
"id":"a",
|
||||
"name":"Inbox",
|
||||
"parentId":null,
|
||||
"role":"inbox",
|
||||
"sortOrder":0,
|
||||
"isSubscribed":true,
|
||||
"totalEmails":0,
|
||||
"unreadEmails":0,
|
||||
"totalThreads":0,
|
||||
"unreadThreads":0,
|
||||
"myRights":{
|
||||
"mayReadItems":true,
|
||||
"mayAddItems":true,
|
||||
"mayRemoveItems":true,
|
||||
"maySetSeen":true,
|
||||
"maySetKeywords":true,
|
||||
"mayCreateChild":true,
|
||||
"mayRename":true,
|
||||
"mayDelete":true,
|
||||
"maySubmit":true
|
||||
}
|
||||
},{
|
||||
"id":"b",
|
||||
"name":"Deleted Items",
|
||||
"parentId":null,
|
||||
"role":"trash",
|
||||
"sortOrder":0,
|
||||
"isSubscribed":true,
|
||||
"totalEmails":0,
|
||||
"unreadEmails":0,
|
||||
"totalThreads":0,
|
||||
"unreadThreads":0,
|
||||
"myRights":{
|
||||
"mayReadItems":true,
|
||||
"mayAddItems":true,
|
||||
"mayRemoveItems":true,
|
||||
"maySetSeen":true,
|
||||
"maySetKeywords":true,
|
||||
"mayCreateChild":true,
|
||||
"mayRename":true,
|
||||
"mayDelete":true,
|
||||
"maySubmit":true
|
||||
}
|
||||
},{
|
||||
"id":"c",
|
||||
"name":"Junk Mail",
|
||||
"parentId":null,
|
||||
"role":"junk",
|
||||
"sortOrder":0,
|
||||
"isSubscribed":true,
|
||||
"totalEmails":0,
|
||||
"unreadEmails":0,
|
||||
"totalThreads":0,
|
||||
"unreadThreads":0,
|
||||
"myRights":{
|
||||
"mayReadItems":true,
|
||||
"mayAddItems":true,
|
||||
"mayRemoveItems":true,
|
||||
"maySetSeen":true,
|
||||
"maySetKeywords":true,
|
||||
"mayCreateChild":true,
|
||||
"mayRename":true,
|
||||
"mayDelete":true,
|
||||
"maySubmit":true
|
||||
}
|
||||
},{
|
||||
"id":"d",
|
||||
"name":"Drafts",
|
||||
"parentId":null,
|
||||
"role":"drafts",
|
||||
"sortOrder":0,
|
||||
"isSubscribed":true,
|
||||
"totalEmails":0,
|
||||
"unreadEmails":0,
|
||||
"totalThreads":0,
|
||||
"unreadThreads":0,
|
||||
"myRights":{
|
||||
"mayReadItems":true,
|
||||
"mayAddItems":true,
|
||||
"mayRemoveItems":true,
|
||||
"maySetSeen":true,
|
||||
"maySetKeywords":true,
|
||||
"mayCreateChild":true,
|
||||
"mayRename":true,
|
||||
"mayDelete":true,
|
||||
"maySubmit":true
|
||||
}
|
||||
},{
|
||||
"id":"e",
|
||||
"name":"Sent Items",
|
||||
"parentId":null,
|
||||
"role":"sent",
|
||||
"sortOrder":0,
|
||||
"isSubscribed":true,
|
||||
"totalEmails":0,
|
||||
"unreadEmails":0,
|
||||
"totalThreads":0,
|
||||
"unreadThreads":0,
|
||||
"myRights":{
|
||||
"mayReadItems":true,
|
||||
"mayAddItems":true,
|
||||
"mayRemoveItems":true,
|
||||
"maySetSeen":true,
|
||||
"maySetKeywords":true,
|
||||
"mayCreateChild":true,
|
||||
"mayRename":true,
|
||||
"mayDelete":true,
|
||||
"maySubmit":true
|
||||
}
|
||||
}
|
||||
],
|
||||
"notFound":[]
|
||||
},"0"]
|
||||
], "sessionState":"3e25b2a0"
|
||||
}`
|
||||
|
||||
type TestJmapWellKnownClient struct {
|
||||
t *testing.T
|
||||
}
|
||||
@@ -176,6 +24,7 @@ func NewTestJmapWellKnownClient(t *testing.T) WellKnownClient {
|
||||
|
||||
func (t *TestJmapWellKnownClient) GetWellKnown(username string, logger *log.Logger) (WellKnownResponse, error) {
|
||||
return WellKnownResponse{
|
||||
Username: generateRandomString(8),
|
||||
ApiUrl: "test://",
|
||||
PrimaryAccounts: map[string]string{JmapMail: generateRandomString(2 + seededRand.Intn(10))},
|
||||
}, nil
|
||||
@@ -189,17 +38,32 @@ func NewTestJmapApiClient(t *testing.T) ApiClient {
|
||||
return &TestJmapApiClient{t: t}
|
||||
}
|
||||
|
||||
func (t *TestJmapApiClient) Command(ctx context.Context, logger *log.Logger, request map[string]any) ([]byte, error) {
|
||||
methodCalls := request["methodCalls"].(*[][]any)
|
||||
command := (*methodCalls)[0][0].(string)
|
||||
func serveTestFile(t *testing.T, name string) ([]byte, error) {
|
||||
cwd, _ := os.Getwd()
|
||||
p := filepath.Join(cwd, "testdata", name)
|
||||
bytes, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return bytes, err
|
||||
}
|
||||
// try to parse it first to avoid any deeper issues that are caused by the test tools
|
||||
var target map[string]any
|
||||
err = json.Unmarshal(bytes, &target)
|
||||
if err != nil {
|
||||
t.Errorf("failed to parse JSON test data file '%v': %v", p, err)
|
||||
}
|
||||
return bytes, err
|
||||
}
|
||||
|
||||
func (t *TestJmapApiClient) Command(ctx context.Context, logger *log.Logger, session *Session, request Request) ([]byte, error) {
|
||||
command := request.MethodCalls[0].Command
|
||||
switch command {
|
||||
case "Mailbox/get":
|
||||
return []byte(mailboxes), nil
|
||||
case "Email/query":
|
||||
return []byte(mails1), nil
|
||||
case MailboxGet:
|
||||
return serveTestFile(t.t, "mailboxes1.json")
|
||||
case EmailQuery:
|
||||
return serveTestFile(t.t, "mails1.json")
|
||||
default:
|
||||
require.Fail(t.t, "unsupported jmap command: %v", command)
|
||||
return nil, fmt.Errorf("unsupported jmap command: %v", command)
|
||||
require.Fail(t.t, "TestJmapApiClient: unsupported jmap command: %v", command)
|
||||
return nil, fmt.Errorf("TestJmapApiClient: unsupported jmap command: %v", command)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,15 +89,22 @@ func TestRequests(t *testing.T) {
|
||||
|
||||
session := Session{AccountId: "123", JmapUrl: "test://"}
|
||||
|
||||
folders, err := client.GetMailboxes(session, ctx, &logger)
|
||||
folders, err := client.GetMailboxes(&session, ctx, &logger)
|
||||
require.NoError(err)
|
||||
require.Len(folders.Folders, 5)
|
||||
require.Len(folders.List, 5)
|
||||
|
||||
emails, err := client.EmailThreadsQuery(session, ctx, &logger, "Inbox")
|
||||
emails, err := client.GetEmails(&session, ctx, &logger, "Inbox", 0, 0, true, 0)
|
||||
require.NoError(err)
|
||||
require.Len(emails.Emails, 1)
|
||||
require.Len(emails.Emails, 3)
|
||||
|
||||
email := emails.Emails[0]
|
||||
require.Equal("eros auctor proin", email.Subject)
|
||||
require.Equal(false, email.HasAttachments)
|
||||
{
|
||||
email := emails.Emails[0]
|
||||
require.Equal("Ornare Senectus Ultrices Elit", email.Subject)
|
||||
require.Equal(false, email.HasAttachments)
|
||||
}
|
||||
{
|
||||
email := emails.Emails[1]
|
||||
require.Equal("Lorem Tortor Eros Blandit Adipiscing Scelerisque Fermentum", email.Subject)
|
||||
require.Equal(false, email.HasAttachments)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,186 +2,175 @@ package jmap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"time"
|
||||
|
||||
"github.com/mitchellh/mapstructure"
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
)
|
||||
|
||||
func command[T any](api ApiClient,
|
||||
logger *log.Logger,
|
||||
ctx context.Context,
|
||||
methodCalls *[][]any,
|
||||
mapper func(body *[]byte) (T, error)) (T, error) {
|
||||
body := map[string]any{
|
||||
"using": []string{JmapCore, JmapMail},
|
||||
"methodCalls": methodCalls,
|
||||
}
|
||||
session *Session,
|
||||
request Request,
|
||||
mapper func(body *Response) (T, error)) (T, error) {
|
||||
|
||||
/*
|
||||
{
|
||||
"using":[
|
||||
"urn:ietf:params:jmap:core",
|
||||
"urn:ietf:params:jmap:mail"
|
||||
],
|
||||
"methodCalls":[
|
||||
[
|
||||
"Identity/get", {
|
||||
"accountId": "cp"
|
||||
}, "0"
|
||||
]
|
||||
]
|
||||
}
|
||||
*/
|
||||
|
||||
responseBody, err := api.Command(ctx, logger, body)
|
||||
responseBody, err := api.Command(ctx, logger, session, request)
|
||||
if err != nil {
|
||||
var zero T
|
||||
return zero, err
|
||||
}
|
||||
return mapper(&responseBody)
|
||||
}
|
||||
|
||||
func simpleCommand(cmd string, params map[string]any) [][]any {
|
||||
jmap := make([][]any, 1)
|
||||
jmap[0] = make([]any, 3)
|
||||
jmap[0][0] = cmd
|
||||
jmap[0][1] = params
|
||||
jmap[0][2] = "0"
|
||||
return jmap
|
||||
}
|
||||
|
||||
func mapFolder(item map[string]any) JmapFolder {
|
||||
return JmapFolder{
|
||||
Id: item["id"].(string),
|
||||
Name: item["name"].(string),
|
||||
Role: item["role"].(string),
|
||||
TotalEmails: int(item["totalEmails"].(float64)),
|
||||
UnreadEmails: int(item["unreadEmails"].(float64)),
|
||||
TotalThreads: int(item["totalThreads"].(float64)),
|
||||
UnreadThreads: int(item["unreadThreads"].(float64)),
|
||||
}
|
||||
}
|
||||
|
||||
func parseMailboxGetResponse(data JmapCommandResponse) (Folders, error) {
|
||||
first := data.MethodResponses[0]
|
||||
params := first[1]
|
||||
payload := params.(map[string]any)
|
||||
state := payload["state"].(string)
|
||||
list := payload["list"].([]any)
|
||||
folders := make([]JmapFolder, 0, len(list))
|
||||
for _, a := range list {
|
||||
item := a.(map[string]any)
|
||||
folder := mapFolder(item)
|
||||
folders = append(folders, folder)
|
||||
}
|
||||
return Folders{Folders: folders, state: state}, nil
|
||||
}
|
||||
|
||||
func firstFromStringArray(obj map[string]any, key string) string {
|
||||
ary, ok := obj[key]
|
||||
if ok {
|
||||
if ary := ary.([]any); len(ary) > 0 {
|
||||
return ary[0].(string)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func mapEmail(elem map[string]any, fetchBodies bool, logger *log.Logger) (Email, error) {
|
||||
fromList := elem["from"].([]any)
|
||||
from := fromList[0].(map[string]any)
|
||||
var subject string
|
||||
var value any = elem["subject"]
|
||||
if value != nil {
|
||||
subject = value.(string)
|
||||
} else {
|
||||
subject = ""
|
||||
}
|
||||
var hasAttachments bool
|
||||
hasAttachmentsAny := elem["hasAttachments"]
|
||||
if hasAttachmentsAny != nil {
|
||||
hasAttachments = hasAttachmentsAny.(bool)
|
||||
} else {
|
||||
hasAttachments = false
|
||||
}
|
||||
|
||||
received, err := time.ParseInLocation(time.RFC3339, elem["receivedAt"].(string), time.UTC)
|
||||
var data Response
|
||||
err = json.Unmarshal(responseBody, &data)
|
||||
if err != nil {
|
||||
return Email{}, err
|
||||
logger.Error().Err(err).Msg("failed to deserialize body JSON payload")
|
||||
var zero T
|
||||
return zero, err
|
||||
}
|
||||
|
||||
bodies := map[string]string{}
|
||||
if fetchBodies {
|
||||
bodyValuesAny, ok := elem["bodyValues"]
|
||||
if ok {
|
||||
bodyValues := bodyValuesAny.(map[string]any)
|
||||
textBody, ok := elem["textBody"].([]any)
|
||||
if ok && len(textBody) > 0 {
|
||||
pick := textBody[0].(map[string]any)
|
||||
mime := pick["type"].(string)
|
||||
partId := pick["partId"].(string)
|
||||
content, ok := bodyValues[partId]
|
||||
if ok {
|
||||
m := content.(map[string]any)
|
||||
value, ok = m["value"]
|
||||
if ok {
|
||||
bodies[mime] = value.(string)
|
||||
} else {
|
||||
logger.Warn().Msg("textBody part has no value")
|
||||
}
|
||||
} else {
|
||||
logger.Warn().Msgf("textBody references non-existent partId=%v", partId)
|
||||
}
|
||||
} else {
|
||||
logger.Warn().Msgf("no textBody: %v", elem)
|
||||
}
|
||||
htmlBody, ok := elem["htmlBody"].([]any)
|
||||
if ok && len(htmlBody) > 0 {
|
||||
pick := htmlBody[0].(map[string]any)
|
||||
mime := pick["type"].(string)
|
||||
partId := pick["partId"].(string)
|
||||
content, ok := bodyValues[partId]
|
||||
if ok {
|
||||
m := content.(map[string]any)
|
||||
value, ok = m["value"]
|
||||
if ok {
|
||||
bodies[mime] = value.(string)
|
||||
} else {
|
||||
logger.Warn().Msg("htmlBody part has no value")
|
||||
}
|
||||
} else {
|
||||
logger.Warn().Msgf("htmlBody references non-existent partId=%v", partId)
|
||||
}
|
||||
} else {
|
||||
logger.Warn().Msg("no htmlBody")
|
||||
}
|
||||
} else {
|
||||
logger.Warn().Msg("no bodies found in email")
|
||||
}
|
||||
} else {
|
||||
bodies = nil
|
||||
}
|
||||
|
||||
return Email{
|
||||
Id: elem["id"].(string),
|
||||
MessageId: firstFromStringArray(elem, "messageId"),
|
||||
BlobId: elem["blobId"].(string),
|
||||
ThreadId: elem["threadId"].(string),
|
||||
Size: int(elem["size"].(float64)),
|
||||
From: from["email"].(string),
|
||||
Subject: subject,
|
||||
HasAttachments: hasAttachments,
|
||||
Received: received,
|
||||
Preview: elem["preview"].(string),
|
||||
Bodies: bodies,
|
||||
}, nil
|
||||
return mapper(&data)
|
||||
}
|
||||
|
||||
func retrieveResponseMatch(data *JmapCommandResponse, length int, operation string, tag string) []any {
|
||||
for _, elem := range data.MethodResponses {
|
||||
if len(elem) == length && elem[0] == operation && elem[2] == tag {
|
||||
return elem
|
||||
func mapstructStringToTimeHook() mapstructure.DecodeHookFunc {
|
||||
// mapstruct isn't able to properly map RFC3339 date strings into Time
|
||||
// objects, which is why we require this custom hook,
|
||||
// see https://github.com/mitchellh/mapstructure/issues/41
|
||||
return func(from reflect.Type, to reflect.Type, data any) (any, error) {
|
||||
if to != reflect.TypeOf(time.Time{}) {
|
||||
return data, nil
|
||||
}
|
||||
switch from.Kind() {
|
||||
case reflect.String:
|
||||
return time.Parse(time.RFC3339, data.(string))
|
||||
case reflect.Float64:
|
||||
return time.Unix(0, int64(data.(float64))*int64(time.Millisecond)), nil
|
||||
case reflect.Int64:
|
||||
return time.Unix(0, data.(int64)*int64(time.Millisecond)), nil
|
||||
default:
|
||||
return data, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func decodeMap(input map[string]any, target any) error {
|
||||
// https://github.com/mitchellh/mapstructure/issues/41
|
||||
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
|
||||
Metadata: nil,
|
||||
DecodeHook: mapstructure.ComposeDecodeHookFunc(mapstructStringToTimeHook()),
|
||||
Result: &target,
|
||||
ErrorUnused: false,
|
||||
ErrorUnset: false,
|
||||
IgnoreUntaggedFields: false,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return decoder.Decode(input)
|
||||
}
|
||||
|
||||
func decodeParameters(input any, target any) error {
|
||||
m, ok := input.(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("decodeParameters: parameters is not a map but a %T", input)
|
||||
}
|
||||
return decodeMap(m, target)
|
||||
}
|
||||
|
||||
func retrieveResponseMatch(data *Response, command Command, tag string) (Invocation, bool) {
|
||||
for _, inv := range data.MethodResponses {
|
||||
if command == inv.Command && tag == inv.Tag {
|
||||
return inv, true
|
||||
}
|
||||
}
|
||||
return Invocation{}, false
|
||||
}
|
||||
|
||||
func retrieveResponseMatchParameters[T any](data *Response, command Command, tag string, target *T) error {
|
||||
match, ok := retrieveResponseMatch(data, command, tag)
|
||||
if !ok {
|
||||
return fmt.Errorf("failed to find JMAP response invocation match for command '%v' and tag '%v'", command, tag)
|
||||
}
|
||||
params := match.Parameters
|
||||
typedParams, ok := params.(T)
|
||||
if !ok {
|
||||
actualType := reflect.TypeOf(params)
|
||||
expectedType := reflect.TypeOf(*target)
|
||||
return fmt.Errorf("JMAP response invocation matches command '%v' and tag '%v' but the type %v does not match the expected %v", command, tag, actualType, expectedType)
|
||||
}
|
||||
*target = typedParams
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *EmailBodyStructure) UnmarshalJSON(bs []byte) error {
|
||||
m := map[string]any{}
|
||||
err := json.Unmarshal(bs, &m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return decodeMap(m, e)
|
||||
}
|
||||
|
||||
func (e *EmailBodyStructure) MarshalJSON() ([]byte, error) {
|
||||
m := map[string]any{}
|
||||
m["type"] = e.Type
|
||||
m["partId"] = e.PartId
|
||||
for k, v := range e.Other {
|
||||
m[k] = v
|
||||
}
|
||||
return json.Marshal(m)
|
||||
}
|
||||
|
||||
func (i *Invocation) MarshalJSON() ([]byte, error) {
|
||||
// JMAP requests have a slightly unusual structure since they are not a JSON object
|
||||
// but, instead, a three-element array composed of
|
||||
// 0: the command (e.g. "Email/query")
|
||||
// 1: the actual payload of the request (structure depends on the command)
|
||||
// 2: a tag that can be used to identify the matching response payload
|
||||
// That implementation aspect thus requires us to use a custom marshalling hook.
|
||||
arr := []any{string(i.Command), i.Parameters, i.Tag}
|
||||
return json.Marshal(arr)
|
||||
}
|
||||
|
||||
func (i *Invocation) UnmarshalJSON(bs []byte) error {
|
||||
// JMAP responses have a slightly unusual structure since they are not a JSON object
|
||||
// but, instead, a three-element array composed of
|
||||
// 0: the command (e.g. "Thread/get") this is a response to
|
||||
// 1: the actual payload of the response (structure depends on the command)
|
||||
// 2: the tag (same as in the request invocation)
|
||||
// That implementation aspect thus requires us to use a custom unmarshalling hook.
|
||||
arr := []any{}
|
||||
err := json.Unmarshal(bs, &arr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(arr) != 3 {
|
||||
// JMAP response must really always be an array of three elements
|
||||
return fmt.Errorf("Invocation array length ought to be 3 but is %d", len(arr))
|
||||
}
|
||||
// The first element in the array is the command:
|
||||
i.Command = Command(arr[0].(string))
|
||||
// The third element in the array is the tag:
|
||||
i.Tag = arr[2].(string)
|
||||
|
||||
// Due to the dynamic nature of request and response types in JMAP, we
|
||||
// switch to using mapstruct here to deserialize the payload in the "parameters"
|
||||
// element of JMAP invocation response arrays, as their expected struct type
|
||||
// is directly inferred from the command (e.g. "Mailbox/get")
|
||||
payload := arr[1]
|
||||
|
||||
paramsFactory, ok := CommandResponseTypeMap[i.Command]
|
||||
if !ok {
|
||||
return fmt.Errorf("unsupported JMAP operation cannot be unmarshalled: %v", i.Command)
|
||||
}
|
||||
params := paramsFactory()
|
||||
err = decodeParameters(payload, ¶ms)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
i.Parameters = params
|
||||
return nil
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
121
pkg/jmap/testdata/mailboxes1.json
vendored
Normal file
121
pkg/jmap/testdata/mailboxes1.json
vendored
Normal file
@@ -0,0 +1,121 @@
|
||||
{"methodResponses": [
|
||||
["Mailbox/get", {
|
||||
"accountId":"cs",
|
||||
"state":"n",
|
||||
"list": [
|
||||
{
|
||||
"id":"a",
|
||||
"name":"Inbox",
|
||||
"parentId":null,
|
||||
"role":"inbox",
|
||||
"sortOrder":0,
|
||||
"isSubscribed":true,
|
||||
"totalEmails":10,
|
||||
"unreadEmails":8,
|
||||
"totalThreads":10,
|
||||
"unreadThreads":8,
|
||||
"myRights":{
|
||||
"mayReadItems":true,
|
||||
"mayAddItems":true,
|
||||
"mayRemoveItems":true,
|
||||
"maySetSeen":true,
|
||||
"maySetKeywords":true,
|
||||
"mayCreateChild":true,
|
||||
"mayRename":true,
|
||||
"mayDelete":true,
|
||||
"maySubmit":true
|
||||
}
|
||||
},{
|
||||
"id":"b",
|
||||
"name":"Deleted Items",
|
||||
"parentId":null,
|
||||
"role":"trash",
|
||||
"sortOrder":0,
|
||||
"isSubscribed":true,
|
||||
"totalEmails":20,
|
||||
"unreadEmails":0,
|
||||
"totalThreads":20,
|
||||
"unreadThreads":0,
|
||||
"myRights":{
|
||||
"mayReadItems":true,
|
||||
"mayAddItems":true,
|
||||
"mayRemoveItems":true,
|
||||
"maySetSeen":true,
|
||||
"maySetKeywords":true,
|
||||
"mayCreateChild":true,
|
||||
"mayRename":true,
|
||||
"mayDelete":true,
|
||||
"maySubmit":true
|
||||
}
|
||||
},{
|
||||
"id":"c",
|
||||
"name":"Junk Mail",
|
||||
"parentId":null,
|
||||
"role":"junk",
|
||||
"sortOrder":0,
|
||||
"isSubscribed":true,
|
||||
"totalEmails":0,
|
||||
"unreadEmails":0,
|
||||
"totalThreads":0,
|
||||
"unreadThreads":0,
|
||||
"myRights":{
|
||||
"mayReadItems":true,
|
||||
"mayAddItems":true,
|
||||
"mayRemoveItems":true,
|
||||
"maySetSeen":true,
|
||||
"maySetKeywords":true,
|
||||
"mayCreateChild":true,
|
||||
"mayRename":true,
|
||||
"mayDelete":true,
|
||||
"maySubmit":true
|
||||
}
|
||||
},{
|
||||
"id":"d",
|
||||
"name":"Drafts",
|
||||
"parentId":null,
|
||||
"role":"drafts",
|
||||
"sortOrder":0,
|
||||
"isSubscribed":true,
|
||||
"totalEmails":0,
|
||||
"unreadEmails":0,
|
||||
"totalThreads":0,
|
||||
"unreadThreads":0,
|
||||
"myRights":{
|
||||
"mayReadItems":true,
|
||||
"mayAddItems":true,
|
||||
"mayRemoveItems":true,
|
||||
"maySetSeen":true,
|
||||
"maySetKeywords":true,
|
||||
"mayCreateChild":true,
|
||||
"mayRename":true,
|
||||
"mayDelete":true,
|
||||
"maySubmit":true
|
||||
}
|
||||
},{
|
||||
"id":"e",
|
||||
"name":"Sent Items",
|
||||
"parentId":null,
|
||||
"role":"sent",
|
||||
"sortOrder":0,
|
||||
"isSubscribed":true,
|
||||
"totalEmails":0,
|
||||
"unreadEmails":0,
|
||||
"totalThreads":0,
|
||||
"unreadThreads":0,
|
||||
"myRights":{
|
||||
"mayReadItems":true,
|
||||
"mayAddItems":true,
|
||||
"mayRemoveItems":true,
|
||||
"maySetSeen":true,
|
||||
"maySetKeywords":true,
|
||||
"mayCreateChild":true,
|
||||
"mayRename":true,
|
||||
"mayDelete":true,
|
||||
"maySubmit":true
|
||||
}
|
||||
}
|
||||
],
|
||||
"notFound":[]
|
||||
},"0"]
|
||||
], "sessionState":"3e25b2a0"
|
||||
}
|
||||
277
pkg/jmap/testdata/mails1.json
vendored
Normal file
277
pkg/jmap/testdata/mails1.json
vendored
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user