diff --git a/go.mod b/go.mod index 1ac99aeb10..c50449649c 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,9 @@ require ( github.com/CiscoM31/godata v1.0.11 github.com/KimMachineGun/automemlimit v0.7.5 github.com/Masterminds/semver v1.5.0 + github.com/MicahParks/jwkset v0.8.0 github.com/MicahParks/keyfunc/v2 v2.1.0 + github.com/MicahParks/keyfunc/v3 v3.3.11 github.com/Nerzal/gocloak/v13 v13.9.0 github.com/bbalet/stopwords v1.0.0 github.com/beevik/etree v1.6.0 @@ -129,8 +131,6 @@ require ( github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/Masterminds/sprig v2.22.0+incompatible // indirect - github.com/MicahParks/jwkset v0.8.0 // indirect - github.com/MicahParks/keyfunc/v3 v3.3.11 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/ProtonMail/go-crypto v1.1.5 // indirect github.com/RoaringBitmap/roaring/v2 v2.4.5 // indirect diff --git a/pkg/jmap/jmap.go b/pkg/jmap/jmap.go index 167e8880c8..e247feea43 100644 --- a/pkg/jmap/jmap.go +++ b/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 }) } diff --git a/pkg/jmap/jmap_well_known_client.go b/pkg/jmap/jmap_api.go similarity index 56% rename from pkg/jmap/jmap_well_known_client.go rename to pkg/jmap/jmap_api.go index 0cdcbbd5fe..22a59b731c 100644 --- a/pkg/jmap/jmap_well_known_client.go +++ b/pkg/jmap/jmap_api.go @@ -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) } diff --git a/pkg/jmap/jmap_api_client.go b/pkg/jmap/jmap_api_client.go deleted file mode 100644 index 3dea8937c9..0000000000 --- a/pkg/jmap/jmap_api_client.go +++ /dev/null @@ -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) -} diff --git a/pkg/jmap/http_jmap_api_client.go b/pkg/jmap/jmap_http.go similarity index 92% rename from pkg/jmap/http_jmap_api_client.go rename to pkg/jmap/jmap_http.go index c555d504ae..72f550e181 100644 --- a/pkg/jmap/http_jmap_api_client.go +++ b/pkg/jmap/jmap_http.go @@ -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) diff --git a/pkg/jmap/jmap_model.go b/pkg/jmap/jmap_model.go index f551963e2d..4bfffd9201 100644 --- a/pkg/jmap/jmap_model.go +++ b/pkg/jmap/jmap_model.go @@ -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{} }, } diff --git a/pkg/jmap/jmap_model_test.go b/pkg/jmap/jmap_model_test.go deleted file mode 100644 index 4664279523..0000000000 --- a/pkg/jmap/jmap_model_test.go +++ /dev/null @@ -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) - -} diff --git a/pkg/jmap/reva_context_http_jmap_username_provider.go b/pkg/jmap/jmap_reva.go similarity index 94% rename from pkg/jmap/reva_context_http_jmap_username_provider.go rename to pkg/jmap/jmap_reva.go index fcc3b7a3ac..4f9611b28c 100644 --- a/pkg/jmap/reva_context_http_jmap_username_provider.go +++ b/pkg/jmap/jmap_reva.go @@ -8,6 +8,7 @@ import ( revactx "github.com/opencloud-eu/reva/v2/pkg/ctx" ) +// implements HttpJmapUsernameProvider type RevaContextHttpJmapUsernameProvider struct { } diff --git a/pkg/jmap/jmap_test.go b/pkg/jmap/jmap_test.go index fa28ca29c6..cd4f209a60 100644 --- a/pkg/jmap/jmap_test.go +++ b/pkg/jmap/jmap_test.go @@ -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) + } } diff --git a/pkg/jmap/jmap_tools.go b/pkg/jmap/jmap_tools.go index 85525f9d71..4d60a17e7b 100644 --- a/pkg/jmap/jmap_tools.go +++ b/pkg/jmap/jmap_tools.go @@ -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 } diff --git a/pkg/jmap/jmap_tools_test.go b/pkg/jmap/jmap_tools_test.go index b8d0a9b4f0..fccf822382 100644 --- a/pkg/jmap/jmap_tools_test.go +++ b/pkg/jmap/jmap_tools_test.go @@ -4,292 +4,113 @@ import ( "encoding/json" "testing" - "github.com/opencloud-eu/opencloud/pkg/log" "github.com/stretchr/testify/require" ) -const jmap_email_with_text_and_html_bodies = ` - { - "id": "mk2aaadcx", - "blobId": "cby92nwhy2pswwygvnavdnv0zc3kffdafeauqko2lyw1qvtnjhztwaya72ma", - "threadId": "dcx", - "mailboxIds": { - "a": true - }, - "keywords": {}, - "size": 9284, - "receivedAt": "2025-05-30T08:53:32Z", - "messageId": [ - "1748595212.4933355@example.com" - ], - "inReplyTo": null, - "references": null, - "sender": null, - "from": [ - { - "name": "Superb Openly", - "email": "superb.openly@example.com" - } - ], - "to": [ - { - "name": "alan", - "email": "alan@example.com" - } - ], - "cc": null, - "bcc": null, - "replyTo": null, - "subject": "libero ad blandit rutrum lacinia consectetur sem", - "sentAt": "2025-05-30T10:53:32+02:00", - "hasAttachment": false, - "preview": "Diam egestas imperdiet non eu quam semper euismod netus venenatis ante magnis mus finibus maecenas nec cras ac commodo nascetur aliquet habitasse porta velit felis tempus ligula vulputate. Elit dolor fames neque hac nunc ornare nibh facilisis nisl finib...", - "bodyValues": { - "1": { - "isEncodingProblem": false, - "isTruncated": false, - "value": "Diam egestas imperdiet non eu quam semper euismod netus venenatis ante magnis mus finibus maecenas nec cras ac commodo nascetur aliquet habitasse porta velit felis tempus ligula vulputate. Elit dolor fames neque hac nunc ornare nibh facilisis nisl finibus magna senectus montes vulputate justo dis cras interdum. Convallis montes urna iaculis etiam mauris lorem tristique accumsan erat tincidunt posuere quis felis primis dolor a ipsum hendrerit parturient dictum pulvinar phasellus id porttitor. Etiam mi sollicitudin justo eu natoque eros malesuada nostra vulputate maximus habitant arcu fames imperdiet odio at netus eget maecenas elit parturient hendrerit nibh dui augue quisque tellus amet platea sit. Lectus risus potenti bibendum gravida fringilla sollicitudin sit enim consectetur ipsum accumsan parturient lorem varius sagittis rutrum montes vehicula nec mus hendrerit hac malesuada vel ac integer elementum.\nEuismod donec aliquet suspendisse mi blandit faucibus egestas adipiscing purus congue id himenaeos aenean per. Nullam habitasse est volutpat montes laoreet posuere eget suscipit, ultrices interdum mi nulla ac at eleifend praesent dis nostra massa habitant sapien integer porta consequat amet. Ut conubia amet vulputate ridiculus euismod fermentum libero auctor, mus donec eros a netus ad condimentum morbi facilisi neque tellus feugiat class erat metus inceptos.\nAenean himenaeos ridiculus risus dictum taciti dui quisque penatibus interdum magnis sollicitudin commodo tempor ultrices dapibus mi tempus ullamcorper nibh volutpat justo consequat fusce amet hendrerit laoreet dignissim sit venenatis semper libero mus suscipit. Quis aptent non varius porttitor aliquam iaculis justo facilisi nostra sodales imperdiet integer odio tincidunt quisque rhoncus ullamcorper laoreet tristique dolor. Blandit fringilla adipiscing dictumst euismod magnis volutpat tortor mollis varius elementum nostra litora porttitor habitant convallis risus urna consectetur eleifend suspendisse auctor posuere. Senectus mauris purus a dis tincidunt parturient tortor proin facilisis taciti tellus egestas dui ante in turpis adipiscing lacus neque quisque sagittis tristique suscipit est vestibulum nullam. Pellentesque massa ligula lobortis habitasse rutrum finibus fermentum hac egestas augue aliquet efficitur volutpat mattis ac imperdiet malesuada id etiam turpis tempus tellus interdum quisque at pulvinar nullam proin velit dictumst. Habitant rutrum sit dignissim porta luctus aenean volutpat aliquam arcu lacus tincidunt augue mattis porttitor neque congue risus nostra ridiculus dui molestie maximus libero justo faucibus.\nNisl condimentum pulvinar vulputate quam ante urna habitasse suscipit, volutpat lorem venenatis sem dignissim sapien penatibus ipsum felis faucibus eget velit sociosqu dictumst arcu viverra erat. Vitae auctor lobortis etiam ligula maecenas aptent fringilla, tempus pellentesque euismod neque sociosqu posuere curabitur venenatis dis elit inceptos ullamcorper natoque. Suspendisse elementum semper diam luctus odio fringilla sem nascetur blandit nam cubilia integer senectus in sociosqu sollicitudin nisi parturient. Ante maximus donec hac malesuada nisl quam massa nunc justo conubia fringilla tellus natoque scelerisque cubilia litora.\nAliquet morbi ligula quisque dapibus ultrices eros sem malesuada lobortis massa litora vestibulum varius commodo egestas tincidunt aenean ullamcorper at duis velit auctor parturient. Feugiat natoque posuere orci rhoncus ante mus quam, quis non sapien ut purus volutpat himenaeos et senectus fermentum placerat elementum augue. Natoque id mauris vel mus molestie elementum fames hac consectetur sed platea ad eget efficitur maecenas conubia morbi justo vivamus placerat curae pretium nisi ipsum imperdiet. Velit eros volutpat efficitur pharetra natoque primis luctus nunc lacus fusce dolor sagittis porta maecenas odio rutrum dis consectetur nam metus venenatis ut. Iaculis turpis luctus per orci taciti vehicula amet ad integer, quis litora mauris praesent ullamcorper cursus faucibus at eros dictum dolor morbi mus semper senectus laoreet felis torquent. Phasellus senectus nibh ornare dui convallis orci consequat enim justo etiam himenaeos dictum velit dis magna tempor maecenas fermentum luctus morbi molestie praesent condimentum hendrerit penatibus nisl tempus." - }, - "2": { - "isEncodingProblem": false, - "isTruncated": false, - "value": "

Diam egestas imperdiet non eu quam semper euismod netus venenatis ante magnis mus finibus maecenas nec cras ac commodo nascetur aliquet habitasse porta velit felis tempus ligula vulputate. Elit dolor fames neque hac nunc ornare nibh facilisis nisl finibus magna senectus montes vulputate justo dis cras interdum. Convallis montes urna iaculis etiam mauris lorem tristique accumsan erat tincidunt posuere quis felis primis dolor a ipsum hendrerit parturient dictum pulvinar phasellus id porttitor. Etiam mi sollicitudin justo eu natoque eros malesuada nostra vulputate maximus habitant arcu fames imperdiet odio at netus eget maecenas elit parturient hendrerit nibh dui augue quisque tellus amet platea sit. Lectus risus potenti bibendum gravida fringilla sollicitudin sit enim consectetur ipsum accumsan parturient lorem varius sagittis rutrum montes vehicula nec mus hendrerit hac malesuada vel ac integer elementum.

\n

Euismod donec aliquet suspendisse mi blandit faucibus egestas adipiscing purus congue id himenaeos aenean per. Nullam habitasse est volutpat montes laoreet posuere eget suscipit, ultrices interdum mi nulla ac at eleifend praesent dis nostra massa habitant sapien integer porta consequat amet. Ut conubia amet vulputate ridiculus euismod fermentum libero auctor, mus donec eros a netus ad condimentum morbi facilisi neque tellus feugiat class erat metus inceptos.

\n

Aenean himenaeos ridiculus risus dictum taciti dui quisque penatibus interdum magnis sollicitudin commodo tempor ultrices dapibus mi tempus ullamcorper nibh volutpat justo consequat fusce amet hendrerit laoreet dignissim sit venenatis semper libero mus suscipit. Quis aptent non varius porttitor aliquam iaculis justo facilisi nostra sodales imperdiet integer odio tincidunt quisque rhoncus ullamcorper laoreet tristique dolor. Blandit fringilla adipiscing dictumst euismod magnis volutpat tortor mollis varius elementum nostra litora porttitor habitant convallis risus urna consectetur eleifend suspendisse auctor posuere. Senectus mauris purus a dis tincidunt parturient tortor proin facilisis taciti tellus egestas dui ante in turpis adipiscing lacus neque quisque sagittis tristique suscipit est vestibulum nullam. Pellentesque massa ligula lobortis habitasse rutrum finibus fermentum hac egestas augue aliquet efficitur volutpat mattis ac imperdiet malesuada id etiam turpis tempus tellus interdum quisque at pulvinar nullam proin velit dictumst. Habitant rutrum sit dignissim porta luctus aenean volutpat aliquam arcu lacus tincidunt augue mattis porttitor neque congue risus nostra ridiculus dui molestie maximus libero justo faucibus.

\n

Nisl condimentum pulvinar vulputate quam ante urna habitasse suscipit, volutpat lorem venenatis sem dignissim sapien penatibus ipsum felis faucibus eget velit sociosqu dictumst arcu viverra erat. Vitae auctor lobortis etiam ligula maecenas aptent fringilla, tempus pellentesque euismod neque sociosqu posuere curabitur venenatis dis elit inceptos ullamcorper natoque. Suspendisse elementum semper diam luctus odio fringilla sem nascetur blandit nam cubilia integer senectus in sociosqu sollicitudin nisi parturient. Ante maximus donec hac malesuada nisl quam massa nunc justo conubia fringilla tellus natoque scelerisque cubilia litora.

\n

Aliquet morbi ligula quisque dapibus ultrices eros sem malesuada lobortis massa litora vestibulum varius commodo egestas tincidunt aenean ullamcorper at duis velit auctor parturient. Feugiat natoque posuere orci rhoncus ante mus quam, quis non sapien ut purus volutpat himenaeos et senectus fermentum placerat elementum augue. Natoque id mauris vel mus molestie elementum fames hac consectetur sed platea ad eget efficitur maecenas conubia morbi justo vivamus placerat curae pretium nisi ipsum imperdiet. Velit eros volutpat efficitur pharetra natoque primis luctus nunc lacus fusce dolor sagittis porta maecenas odio rutrum dis consectetur nam metus venenatis ut. Iaculis turpis luctus per orci taciti vehicula amet ad integer, quis litora mauris praesent ullamcorper cursus faucibus at eros dictum dolor morbi mus semper senectus laoreet felis torquent. Phasellus senectus nibh ornare dui convallis orci consequat enim justo etiam himenaeos dictum velit dis magna tempor maecenas fermentum luctus morbi molestie praesent condimentum hendrerit penatibus nisl tempus.

" - } - }, - "textBody": [ - { - "partId": "1", - "blobId": "cfy92nwhy2pswwygvnavdnv0zc3kffdafeauqko2lyw1qvtnjhztwaya72mmga3iee", - "size": 4328, - "name": null, - "type": "text/plain", - "charset": "utf-8", - "disposition": null, - "cid": null, - "language": null, - "location": null - } - ], - "htmlBody": [ - { - "partId": "2", - "blobId": "cfy92nwhy2pswwygvnavdnv0zc3kffdafeauqko2lyw1qvtnjhztwaya72mimjulei", - "size": 4363, - "name": null, - "type": "text/html", - "charset": "utf-8", - "disposition": null, - "cid": null, - "language": null, - "location": null - } - ], - "attachments": [] - } -` - -const jmap_email_with_text_body = ` - { - "id": "mliaaadc7", - "blobId": "cc0tuhkv1lncttirzg9p97wd7k7gezaz9fbwjir31wrcvkykbm1zkaya9ima", - "threadId": "dc7", - "mailboxIds": { - "a": true - }, - "keywords": {}, - "size": 4080, - "receivedAt": "2025-05-30T09:59:55Z", - "messageId": [ - "1748599195.5902335@example.com" - ], - "inReplyTo": null, - "references": null, - "sender": null, - "from": [ - { - "name": "Cunning Properly", - "email": "cunning.properly@example.com" - } - ], - "to": [ - { - "name": "alan", - "email": "alan@example.com" - } - ], - "cc": null, - "bcc": null, - "replyTo": null, - "subject": "Parturient Nostra Orci", - "sentAt": "2025-05-30T11:59:55+02:00", - "hasAttachment": false, - "preview": "Et magnis pulvinar congue aliquet tincidunt morbi lobortis mattis mus litora malesuada fringilla varius ullamcorper parturient fames accumsan faucibus erat. Magna id est cras eu a netus orci ridiculus lobortis urna dis ipsum at fermentum mi lacinia quis...", - "bodyValues": { - "0": { - "isEncodingProblem": false, - "isTruncated": false, - "value": "Et magnis pulvinar congue aliquet tincidunt morbi lobortis mattis mus litora malesuada fringilla varius ullamcorper parturient fames accumsan faucibus erat. Magna id est cras eu a netus orci ridiculus lobortis urna dis ipsum at fermentum mi lacinia quis fames. Cursus ipsum gravida libero ultricies pretium montes rutrum suscipit tempor hac dapibus senectus commodo elementum leo nullam auctor litora pulvinar finibus. Ad nulla torquent quis mollis phasellus sodales aliquet lacinia varius, adipiscing enim habitant et netus egestas eu tempor malesuada mattis hac fusce integer diam inceptos venenatis turpis. Sem senectus aptent non dolor hendrerit magna mauris facilisis justo quam fringilla cursus gravida praesent malesuada taciti odio etiam magnis nostra vivamus. Tempus fames faucibus massa rutrum sit habitant morbi curabitur integer erat et condimentum tincidunt tempor libero vulputate maecenas rhoncus turpis congue a luctus aenean tristique lacinia cursus est fusce non mollis justo euismod facilisis egestas.\nAuctor maecenas vestibulum aenean accumsan eros ex potenti sociosqu, fusce sapien quis faucibus aliquam vivamus tristique hendrerit in per fermentum cras sodales curabitur scelerisque. Finibus metus adipiscing taciti eget rutrum vitae arcu torquent, dignissim at nibh venenatis facilisis molestie erat augue massa feugiat aliquam sollicitudin elementum cursus in. Est neque cras aenean felis justo euismod adipiscing magnis sagittis ut massa aliquet malesuada laoreet velit purus suspendisse bibendum pharetra litora ultrices diam ullamcorper volutpat venenatis egestas. Non laoreet eu interdum sodales phasellus morbi risus maecenas parturient auctor senectus urna ornare faucibus sociosqu habitant nisi cubilia viverra diam fames condimentum tempor scelerisque iaculis lacus elit feugiat adipiscing vivamus. Euismod volutpat gravida fames nascetur ridiculus iaculis habitasse vulputate habitant netus varius rhoncus ultrices porttitor himenaeos lorem libero congue turpis parturient quisque nostra aliquet in sem curabitur eleifend accumsan faucibus per pellentesque. Nibh auctor dictum vivamus eros ex gravida hac torquent purus suscipit fames lacus sagittis condimentum morbi dui litora cras duis iaculis massa porta praesent sapien. Ultricies tortor phasellus mus erat metus nisi malesuada augue sollicitudin convallis egestas ultrices arcu luctus tempus molestie facilisis nam scelerisque feugiat. Nibh imperdiet accumsan fermentum auctor et neque blandit elementum id eget justo suscipit interdum etiam mus tempus diam cursus nunc malesuada aliquam vestibulum. Arcu facilisi curae sed mi felis commodo, sapien neque aenean nullam rutrum torquent lectus fringilla rhoncus eros elit molestie.\nAptent fringilla cubilia sed duis non eu vulputate dis efficitur per ad venenatis dictumst egestas commodo blandit. Conubia finibus curae molestie egestas interdum mollis aliquam venenatis penatibus habitant varius natoque aptent nec. Mattis hac commodo integer donec gravida himenaeos vivamus primis rhoncus nam cursus erat nibh nascetur elementum felis duis in volutpat aliquet nulla vehicula placerat est. Placerat dis est aenean laoreet convallis metus sit mi, porttitor ullamcorper risus augue commodo dictumst nisi platea cubilia maximus elit volutpat hac rutrum suspendisse. Lacinia taciti justo non ligula vivamus aliquam luctus tellus dictumst vulputate interdum per aptent a metus eu mauris hac ex montes senectus blandit proin. Proin ullamcorper habitant justo pharetra felis commodo parturient scelerisque rutrum suspendisse ad ante cubilia pulvinar est vivamus quisque imperdiet vestibulum varius aliquam enim. Mollis aliquam metus montes dapibus volutpat maecenas fermentum massa tempor condimentum rhoncus lacinia tincidunt accumsan leo nunc elementum maximus fringilla dui augue." - } - }, - "textBody": [ - { - "partId": "0", - "blobId": "cg0tuhkv1lncttirzg9p97wd7k7gezaz9fbwjir31wrcvkykbm1zkaya9imiuaxgdu", - "size": 3814, - "name": null, - "type": "text/plain", - "charset": "utf-8", - "disposition": null, - "cid": null, - "language": null, - "location": null - } - ], - "htmlBody": [ - { - "partId": "0", - "blobId": "cg0tuhkv1lncttirzg9p97wd7k7gezaz9fbwjir31wrcvkykbm1zkaya9imiuaxgdu", - "size": 3814, - "name": null, - "type": "text/plain", - "charset": "utf-8", - "disposition": null, - "cid": null, - "language": null, - "location": null - } - ], - "attachments": [] - } -` - -const jmap_email_with_html_body = ` - { - "id": "mleaaadcz", - "blobId": "cdrahu0j7gjhl3jscjnzt0ursycvwn29u9uxjlrlcpeinrm2r0yz1aya9ema", - "threadId": "dcz", - "mailboxIds": { - "a": true - }, - "keywords": {}, - "size": 10556, - "receivedAt": "2025-05-30T09:59:55Z", - "messageId": [ - "1748599195.3428368@example.com" - ], - "inReplyTo": null, - "references": null, - "sender": null, - "from": [ - { - "name": "Eminent Extremely", - "email": "eminent.extremely@example.com" - } - ], - "to": [ - { - "name": "alan", - "email": "alan@example.com" - } - ], - "cc": null, - "bcc": null, - "replyTo": null, - "subject": "Et Magnis Pulvinar Congue Aliquet Tincidunt Morbi Lobortis Mattis", - "sentAt": "2025-05-30T11:59:55+02:00", - "hasAttachment": false, - "preview": "Lorem ipsum dolor sit amet consectetur adipiscing elit, montes aenean lectus porttitor mauris ridiculus rutrum et inceptos torquent congue tristique dictumst nullam suspendisse. Lobortis ad per habitasse volutpat proin posuere convallis dapibus tristiqu...", - "bodyValues": { - "0": { - "isEncodingProblem": false, - "isTruncated": false, - "value": "

Lorem ipsum dolor sit amet consectetur adipiscing elit, montes aenean lectus porttitor mauris ridiculus rutrum et inceptos torquent congue tristique dictumst nullam suspendisse. Lobortis ad per habitasse volutpat proin posuere convallis dapibus tristique lacinia placerat scelerisque curabitur sed aenean sodales pharetra est nisl odio sagittis platea in. Venenatis semper inceptos laoreet orci aliquam natoque magna id tempor lacus duis convallis molestie ridiculus vivamus etiam tortor ultrices blandit dictum finibus volutpat. Finibus amet donec justo lectus senectus morbi convallis a hendrerit malesuada neque nisl ad nulla per nunc praesent.

\n

Elit dolor nostra vehicula massa placerat convallis dictum natoque commodo diam, ultricies nam consequat inceptos torquent himenaeos risus eleifend scelerisque dui cursus libero nisl neque fusce montes metus proin cras donec nibh. Tempus sodales fames consectetur in integer aliquet odio maecenas est sapien risus parturient lorem aliquam viverra mattis feugiat eu platea ex tempor mi efficitur a. Ut vestibulum nibh et himenaeos taciti nisl class pretium maximus est ultrices fermentum nunc mus dapibus vel massa venenatis facilisis non nascetur leo. Scelerisque metus nisi suspendisse pharetra fames malesuada pretium dictumst, etiam potenti molestie vestibulum habitasse aenean velit ridiculus condimentum ut montes at tortor arcu curae id luctus.

\n

Nulla sollicitudin vestibulum vulputate urna sem etiam senectus turpis ac tempus, laoreet natoque metus justo dapibus purus libero fringilla aenean orci integer imperdiet duis curabitur feugiat blandit proin consequat quam velit ante. Scelerisque elit tincidunt feugiat primis risus amet ac interdum varius luctus quis dui consectetur platea conubia senectus mus efficitur mauris cubilia libero magnis egestas elementum ultricies. Elit dapibus finibus proin aliquet etiam nibh quam laoreet senectus primis a mattis vel montes massa porta dui commodo velit mi bibendum cubilia sed euismod. Posuere consequat velit mauris in sollicitudin id dolor nisl placerat magna aliquet sed metus curae. Penatibus commodo cubilia ex velit leo ultricies ipsum dignissim molestie curae lectus, integer a risus bibendum varius ornare laoreet fermentum fusce duis luctus ultrices nostra sem id nascetur dictum tempor morbi aliquet mauris. Duis posuere enim odio nisl condimentum nunc eleifend nullam primis maecenas, tellus pretium congue nascetur lacinia in lorem vel quisque lectus proin laoreet consectetur faucibus aliquet montes ad sodales commodo vestibulum. Fames odio luctus donec habitasse neque posuere purus quis penatibus netus mi lobortis suspendisse vehicula eu lorem erat libero in scelerisque leo dapibus tristique amet.

\n

Vulputate erat consectetur cras iaculis nascetur lectus pulvinar fames est ut malesuada natoque hac orci euismod rhoncus ad faucibus nostra aptent sociosqu. Leo primis dictumst libero platea nisl at mauris eu fusce penatibus, nunc maximus sodales montes facilisis pharetra ex ipsum class curae aenean parturient tortor massa morbi cras varius ut augue vivamus elementum. Lorem eget facilisi nec varius elementum mattis aliquam praesent blandit dapibus aliquet ornare montes malesuada taciti netus egestas lacus morbi. A nascetur nibh commodo sodales consequat nullam taciti risus, viverra proin quam quis elementum libero molestie fusce egestas curae augue mattis nisl montes senectus. Elementum tempus in dictum sagittis ac hac feugiat lorem efficitur consequat neque per tellus penatibus. Ut suscipit tempus nec tincidunt potenti libero luctus eleifend auctor pulvinar ultrices purus imperdiet dignissim at mus et montes phasellus maecenas hac egestas nulla porta. Placerat sed netus consectetur dis duis varius elementum convallis nostra natoque. Massa morbi ante egestas sit feugiat fusce conubia imperdiet vestibulum maximus mollis himenaeos porttitor auctor aliquet neque suscipit rhoncus viverra natoque vivamus posuere commodo arcu quis cras.

\n

Habitant sem ullamcorper euismod libero curabitur orci urna felis senectus lacus nunc congue morbi molestie adipiscing per sed pharetra magna ut arcu convallis consectetur non. Lorem vel id elementum lobortis netus scelerisque diam fames volutpat tristique congue justo penatibus bibendum sociosqu adipiscing est auctor habitasse ullamcorper cursus quis. Risus quis fermentum vehicula adipiscing erat orci, aptent proin gravida habitasse porttitor mattis ipsum praesent ligula feugiat ad efficitur integer. Lorem maecenas venenatis per suscipit accumsan aliquet penatibus faucibus facilisi facilisis sodales platea suspendisse euismod. Turpis posuere nisi ut tempor aliquet dapibus cursus ante mauris sed auctor dictum egestas nullam sapien porttitor justo pretium. Cursus lorem quisque sem leo convallis molestie etiam conubia pretium lacinia ultricies vestibulum nec sodales natoque commodo dictumst volutpat fames parturient justo cubilia augue velit purus aliquam. Nisl integer mus ultricies laoreet congue vivamus cras ultrices orci fames quis non tempor vel libero at nulla malesuada fermentum dolor lacus ornare ut sodales adipiscing eros nascetur lectus. Platea turpis libero habitasse a praesent cras sem eros hendrerit finibus integer tempor ipsum sapien in nostra litora sit montes risus iaculis class at torquent non magna suspendisse purus dictumst vulputate mi sodales curabitur scelerisque.

\n

Metus laoreet morbi erat ligula gravida non montes aliquam et bibendum tempus pharetra posuere nulla eleifend ante tortor habitant. Suscipit nisl proin natoque mollis ligula commodo scelerisque leo pellentesque per senectus adipiscing quis varius aenean curae phasellus magnis aptent felis nec nibh nisi erat lobortis auctor vehicula molestie. Fames purus velit bibendum maecenas tortor ultricies maximus convallis rhoncus inceptos per porta ipsum eu non habitant lacinia pellentesque. Ante et platea id at tempus magna orci etiam feugiat habitant conubia nascetur aenean. Purus nostra lectus lobortis etiam est lacus luctus laoreet sed ac lacinia quis at egestas class ridiculus litora eleifend urna porttitor enim.

\n

Faucibus felis integer eleifend in molestie eget platea tincidunt dui nec aliquam ultricies sodales quam porttitor facilisi potenti facilisis nisl vehicula tempus curae arcu. Magnis aliquet mi per mollis egestas porta montes ut pulvinar arcu neque adipiscing duis feugiat vel senectus quis facilisi elit nibh felis sodales ullamcorper diam sollicitudin ad. Venenatis hendrerit eget quisque sagittis facilisi quam non sociosqu curae enim, potenti augue dapibus ullamcorper auctor mi dignissim etiam viverra orci commodo laoreet inceptos pellentesque adipiscing class ac sem luctus faucibus fringilla. Urna ante in class auctor facilisi risus himenaeos, vitae malesuada dui mattis mollis aenean cras porttitor dignissim praesent egestas pretium condimentum aliquam. Arcu fringilla dictumst turpis vitae ex tempus vehicula efficitur maximus tincidunt pulvinar praesent nulla odio lacus ridiculus fames pharetra mauris ornare felis aenean penatibus taciti dignissim fusce diam orci vel. Habitant lectus primis risus nisi dolor erat senectus eros, felis varius sit phasellus quisque congue blandit bibendum ante est ligula nostra aliquet aptent magna purus. Pharetra eu hendrerit pulvinar magnis primis quisque integer in mus pellentesque suspendisse lacinia sem dictumst nisl auctor maximus platea.

\n

Nascetur tortor ac placerat facilisi integer litora sit varius duis efficitur sapien, hendrerit diam accumsan elit montes vehicula magnis consequat nostra justo parturient torquent pretium interdum a tincidunt dictum magna vel etiam ut dolor ullamcorper. Tincidunt aliquam lectus id velit class ad malesuada auctor litora consectetur aptent pharetra etiam dolor et tristique lacus.

\n

Non quis nullam urna ligula aptent curabitur odio lacus suspendisse lacinia molestie mus morbi elementum maximus interdum a purus enim sem sapien scelerisque lobortis et phasellus. Elementum vulputate vehicula posuere iaculis sodales fames urna rhoncus purus, laoreet metus ornare sem consequat mollis nibh lorem parturient adipiscing porttitor pretium placerat habitasse libero eleifend enim morbi. Massa tellus viverra nascetur leo aenean nisl vivamus malesuada at ipsum lobortis rutrum accumsan senectus dignissim elit fermentum integer praesent a purus proin faucibus aptent ad adipiscing imperdiet convallis mauris sodales. Sed iaculis mauris ut nunc fusce justo et venenatis libero litora eget aliquet penatibus gravida interdum phasellus turpis ullamcorper cubilia duis leo ex mattis vel cras donec lacinia sodales malesuada id elementum. Mus ultrices ullamcorper suspendisse nec dapibus senectus fermentum felis netus non magna congue neque bibendum dignissim ipsum aenean integer curae facilisi donec. Dolor purus nibh diam facilisis erat etiam mollis consectetur semper, vestibulum suscipit mauris egestas venenatis neque varius dignissim pulvinar ligula lobortis morbi aliquam eros nullam suspendisse orci tortor. Accumsan rutrum tempus arcu eros convallis vel natoque commodo eget diam mollis himenaeos proin placerat suspendisse duis taciti. Urna gravida a mus lacinia aliquam justo in lectus nec sed habitasse penatibus et ex massa vel commodo facilisis rutrum taciti odio torquent inceptos imperdiet sociosqu montes cursus nostra suscipit quam venenatis. Mattis nulla congue interdum gravida ornare ac sed, sagittis iaculis sem parturient netus proin maecenas dignissim rhoncus efficitur condimentum egestas dis litora. Odio imperdiet facilisi tempus ipsum donec tortor dictumst sem finibus parturient aptent molestie pretium risus sagittis pellentesque nisi litora congue cras viverra enim tempor vehicula platea.

\n

Sodales pretium egestas libero viverra lobortis interdum amet quis fames neque convallis dictumst sollicitudin eros felis nec. Nibh nisi sit nam magna elit fames habitasse sollicitudin libero lacus luctus porttitor enim conubia dolor suscipit aptent platea dictum habitant primis imperdiet taciti. Lobortis dui scelerisque feugiat venenatis vehicula tristique mi iaculis efficitur imperdiet aliquet sociosqu ipsum ornare sed gravida amet platea nisl mollis consectetur ex ac.

" - } - }, - "textBody": [ - { - "partId": "0", - "blobId": "chrahu0j7gjhl3jscjnzt0ursycvwn29u9uxjlrlcpeinrm2r0yz1aya9emlqaueka", - "size": 10244, - "name": null, - "type": "text/html", - "charset": "utf-8", - "disposition": null, - "cid": null, - "language": null, - "location": null - } - ], - "htmlBody": [ - { - "partId": "0", - "blobId": "chrahu0j7gjhl3jscjnzt0ursycvwn29u9uxjlrlcpeinrm2r0yz1aya9emlqaueka", - "size": 10244, - "name": null, - "type": "text/html", - "charset": "utf-8", - "disposition": null, - "cid": null, - "language": null, - "location": null - } - ], - "attachments": [] - } -` - -func TestMapEmailWithTextAndHtmlBodies(t *testing.T) { +func TestDeserializeMailboxGetResponse(t *testing.T) { require := require.New(t) - - var elem map[string]any - err := json.Unmarshal([]byte(jmap_email_with_text_and_html_bodies), &elem) + jsonBytes, err := serveTestFile(t, "mailboxes1.json") require.NoError(err) - - logger := log.NopLogger() - - email, err := mapEmail(elem, true, &logger) + var data Response + err = json.Unmarshal(jsonBytes, &data) require.NoError(err) - require.Equal("libero ad blandit rutrum lacinia consectetur sem", email.Subject) - require.Equal("Diam egestas imperdiet non eu quam semper euismod netus venenatis ante magnis mus finibus maecenas nec cras ac commodo nascetur aliquet habitasse porta velit felis tempus ligula vulputate. Elit dolor fames neque hac nunc ornare nibh facilisis nisl finib...", email.Preview) - require.Len(email.Bodies, 2) - require.Contains(email.Bodies, "text/html") - require.Equal("

Diam egestas imperdiet non eu quam semper euismod netus venenatis ante magnis mus finibus maecenas nec cras ac commodo nascetur aliquet habitasse porta velit felis tempus ligula vulputate. Elit dolor fames neque hac nunc ornare nibh facilisis nisl finibus magna senectus montes vulputate justo dis cras interdum. Convallis montes urna iaculis etiam mauris lorem tristique accumsan erat tincidunt posuere quis felis primis dolor a ipsum hendrerit parturient dictum pulvinar phasellus id porttitor. Etiam mi sollicitudin justo eu natoque eros malesuada nostra vulputate maximus habitant arcu fames imperdiet odio at netus eget maecenas elit parturient hendrerit nibh dui augue quisque tellus amet platea sit. Lectus risus potenti bibendum gravida fringilla sollicitudin sit enim consectetur ipsum accumsan parturient lorem varius sagittis rutrum montes vehicula nec mus hendrerit hac malesuada vel ac integer elementum.

\n

Euismod donec aliquet suspendisse mi blandit faucibus egestas adipiscing purus congue id himenaeos aenean per. Nullam habitasse est volutpat montes laoreet posuere eget suscipit, ultrices interdum mi nulla ac at eleifend praesent dis nostra massa habitant sapien integer porta consequat amet. Ut conubia amet vulputate ridiculus euismod fermentum libero auctor, mus donec eros a netus ad condimentum morbi facilisi neque tellus feugiat class erat metus inceptos.

\n

Aenean himenaeos ridiculus risus dictum taciti dui quisque penatibus interdum magnis sollicitudin commodo tempor ultrices dapibus mi tempus ullamcorper nibh volutpat justo consequat fusce amet hendrerit laoreet dignissim sit venenatis semper libero mus suscipit. Quis aptent non varius porttitor aliquam iaculis justo facilisi nostra sodales imperdiet integer odio tincidunt quisque rhoncus ullamcorper laoreet tristique dolor. Blandit fringilla adipiscing dictumst euismod magnis volutpat tortor mollis varius elementum nostra litora porttitor habitant convallis risus urna consectetur eleifend suspendisse auctor posuere. Senectus mauris purus a dis tincidunt parturient tortor proin facilisis taciti tellus egestas dui ante in turpis adipiscing lacus neque quisque sagittis tristique suscipit est vestibulum nullam. Pellentesque massa ligula lobortis habitasse rutrum finibus fermentum hac egestas augue aliquet efficitur volutpat mattis ac imperdiet malesuada id etiam turpis tempus tellus interdum quisque at pulvinar nullam proin velit dictumst. Habitant rutrum sit dignissim porta luctus aenean volutpat aliquam arcu lacus tincidunt augue mattis porttitor neque congue risus nostra ridiculus dui molestie maximus libero justo faucibus.

\n

Nisl condimentum pulvinar vulputate quam ante urna habitasse suscipit, volutpat lorem venenatis sem dignissim sapien penatibus ipsum felis faucibus eget velit sociosqu dictumst arcu viverra erat. Vitae auctor lobortis etiam ligula maecenas aptent fringilla, tempus pellentesque euismod neque sociosqu posuere curabitur venenatis dis elit inceptos ullamcorper natoque. Suspendisse elementum semper diam luctus odio fringilla sem nascetur blandit nam cubilia integer senectus in sociosqu sollicitudin nisi parturient. Ante maximus donec hac malesuada nisl quam massa nunc justo conubia fringilla tellus natoque scelerisque cubilia litora.

\n

Aliquet morbi ligula quisque dapibus ultrices eros sem malesuada lobortis massa litora vestibulum varius commodo egestas tincidunt aenean ullamcorper at duis velit auctor parturient. Feugiat natoque posuere orci rhoncus ante mus quam, quis non sapien ut purus volutpat himenaeos et senectus fermentum placerat elementum augue. Natoque id mauris vel mus molestie elementum fames hac consectetur sed platea ad eget efficitur maecenas conubia morbi justo vivamus placerat curae pretium nisi ipsum imperdiet. Velit eros volutpat efficitur pharetra natoque primis luctus nunc lacus fusce dolor sagittis porta maecenas odio rutrum dis consectetur nam metus venenatis ut. Iaculis turpis luctus per orci taciti vehicula amet ad integer, quis litora mauris praesent ullamcorper cursus faucibus at eros dictum dolor morbi mus semper senectus laoreet felis torquent. Phasellus senectus nibh ornare dui convallis orci consequat enim justo etiam himenaeos dictum velit dis magna tempor maecenas fermentum luctus morbi molestie praesent condimentum hendrerit penatibus nisl tempus.

", email.Bodies["text/html"]) - require.Contains(email.Bodies, "text/plain") - require.Equal("Diam egestas imperdiet non eu quam semper euismod netus venenatis ante magnis mus finibus maecenas nec cras ac commodo nascetur aliquet habitasse porta velit felis tempus ligula vulputate. Elit dolor fames neque hac nunc ornare nibh facilisis nisl finibus magna senectus montes vulputate justo dis cras interdum. Convallis montes urna iaculis etiam mauris lorem tristique accumsan erat tincidunt posuere quis felis primis dolor a ipsum hendrerit parturient dictum pulvinar phasellus id porttitor. Etiam mi sollicitudin justo eu natoque eros malesuada nostra vulputate maximus habitant arcu fames imperdiet odio at netus eget maecenas elit parturient hendrerit nibh dui augue quisque tellus amet platea sit. Lectus risus potenti bibendum gravida fringilla sollicitudin sit enim consectetur ipsum accumsan parturient lorem varius sagittis rutrum montes vehicula nec mus hendrerit hac malesuada vel ac integer elementum.\nEuismod donec aliquet suspendisse mi blandit faucibus egestas adipiscing purus congue id himenaeos aenean per. Nullam habitasse est volutpat montes laoreet posuere eget suscipit, ultrices interdum mi nulla ac at eleifend praesent dis nostra massa habitant sapien integer porta consequat amet. Ut conubia amet vulputate ridiculus euismod fermentum libero auctor, mus donec eros a netus ad condimentum morbi facilisi neque tellus feugiat class erat metus inceptos.\nAenean himenaeos ridiculus risus dictum taciti dui quisque penatibus interdum magnis sollicitudin commodo tempor ultrices dapibus mi tempus ullamcorper nibh volutpat justo consequat fusce amet hendrerit laoreet dignissim sit venenatis semper libero mus suscipit. Quis aptent non varius porttitor aliquam iaculis justo facilisi nostra sodales imperdiet integer odio tincidunt quisque rhoncus ullamcorper laoreet tristique dolor. Blandit fringilla adipiscing dictumst euismod magnis volutpat tortor mollis varius elementum nostra litora porttitor habitant convallis risus urna consectetur eleifend suspendisse auctor posuere. Senectus mauris purus a dis tincidunt parturient tortor proin facilisis taciti tellus egestas dui ante in turpis adipiscing lacus neque quisque sagittis tristique suscipit est vestibulum nullam. Pellentesque massa ligula lobortis habitasse rutrum finibus fermentum hac egestas augue aliquet efficitur volutpat mattis ac imperdiet malesuada id etiam turpis tempus tellus interdum quisque at pulvinar nullam proin velit dictumst. Habitant rutrum sit dignissim porta luctus aenean volutpat aliquam arcu lacus tincidunt augue mattis porttitor neque congue risus nostra ridiculus dui molestie maximus libero justo faucibus.\nNisl condimentum pulvinar vulputate quam ante urna habitasse suscipit, volutpat lorem venenatis sem dignissim sapien penatibus ipsum felis faucibus eget velit sociosqu dictumst arcu viverra erat. Vitae auctor lobortis etiam ligula maecenas aptent fringilla, tempus pellentesque euismod neque sociosqu posuere curabitur venenatis dis elit inceptos ullamcorper natoque. Suspendisse elementum semper diam luctus odio fringilla sem nascetur blandit nam cubilia integer senectus in sociosqu sollicitudin nisi parturient. Ante maximus donec hac malesuada nisl quam massa nunc justo conubia fringilla tellus natoque scelerisque cubilia litora.\nAliquet morbi ligula quisque dapibus ultrices eros sem malesuada lobortis massa litora vestibulum varius commodo egestas tincidunt aenean ullamcorper at duis velit auctor parturient. Feugiat natoque posuere orci rhoncus ante mus quam, quis non sapien ut purus volutpat himenaeos et senectus fermentum placerat elementum augue. Natoque id mauris vel mus molestie elementum fames hac consectetur sed platea ad eget efficitur maecenas conubia morbi justo vivamus placerat curae pretium nisi ipsum imperdiet. Velit eros volutpat efficitur pharetra natoque primis luctus nunc lacus fusce dolor sagittis porta maecenas odio rutrum dis consectetur nam metus venenatis ut. Iaculis turpis luctus per orci taciti vehicula amet ad integer, quis litora mauris praesent ullamcorper cursus faucibus at eros dictum dolor morbi mus semper senectus laoreet felis torquent. Phasellus senectus nibh ornare dui convallis orci consequat enim justo etiam himenaeos dictum velit dis magna tempor maecenas fermentum luctus morbi molestie praesent condimentum hendrerit penatibus nisl tempus.", email.Bodies["text/plain"]) - require.Equal("1748595212.4933355@example.com", email.MessageId) - require.False(email.HasAttachments) - require.Equal("cby92nwhy2pswwygvnavdnv0zc3kffdafeauqko2lyw1qvtnjhztwaya72ma", email.BlobId) + require.Empty(data.CreatedIds) + require.Equal("3e25b2a0", data.SessionState) + require.Len(data.MethodResponses, 1) + resp := data.MethodResponses[0] + require.Equal(MailboxGet, resp.Command) + require.Equal("0", resp.Tag) + require.IsType(MailboxGetResponse{}, resp.Parameters) + mgr := resp.Parameters.(MailboxGetResponse) + require.Equal("cs", mgr.AccountId) + require.Len(mgr.List, 5) + require.Equal("n", mgr.State) + require.Empty(mgr.NotFound) + var rights = []string{"mayReadItems", "mayAddItems", "mayRemoveItems", "maySetSeen", "maySetKeywords", "mayCreateChild", "mayRename", "mayDelete", "maySubmit"} + var folders = []struct { + id string + name string + role string + total int + unread int + }{ + {"a", "Inbox", "inbox", 10, 8}, + {"b", "Deleted Items", "trash", 20, 0}, + {"c", "Junk Mail", "junk", 0, 0}, + {"d", "Drafts", "drafts", 0, 0}, + {"e", "Sent Items", "sent", 0, 0}, + } + for i, expected := range folders { + folder := mgr.List[i] + require.Equal(expected.id, folder.Id) + require.Equal(expected.name, folder.Name) + require.Equal(expected.role, folder.Role) + require.Equal(expected.total, folder.TotalEmails) + require.Equal(expected.total, folder.TotalThreads) + require.Equal(expected.unread, folder.UnreadEmails) + require.Equal(expected.unread, folder.UnreadThreads) + require.Empty(folder.ParentId) + require.Zero(folder.SortOrder) + require.True(folder.IsSubscribed) + for _, right := range rights { + require.Contains(folder.MyRights, right) + require.True(folder.MyRights[right]) + } + } } -func TestMapEmailWithHtmlBody(t *testing.T) { +func TestDeserializeEmailGetResponse(t *testing.T) { require := require.New(t) - - var elem map[string]any - err := json.Unmarshal([]byte(jmap_email_with_html_body), &elem) + jsonBytes, err := serveTestFile(t, "mails1.json") require.NoError(err) - - logger := log.NopLogger() - - email, err := mapEmail(elem, true, &logger) + var data Response + err = json.Unmarshal(jsonBytes, &data) require.NoError(err) - require.Len(email.Bodies, 1) - require.Contains(email.Bodies, "text/html") - require.Equal("

Lorem ipsum dolor sit amet consectetur adipiscing elit, montes aenean lectus porttitor mauris ridiculus rutrum et inceptos torquent congue tristique dictumst nullam suspendisse. Lobortis ad per habitasse volutpat proin posuere convallis dapibus tristique lacinia placerat scelerisque curabitur sed aenean sodales pharetra est nisl odio sagittis platea in. Venenatis semper inceptos laoreet orci aliquam natoque magna id tempor lacus duis convallis molestie ridiculus vivamus etiam tortor ultrices blandit dictum finibus volutpat. Finibus amet donec justo lectus senectus morbi convallis a hendrerit malesuada neque nisl ad nulla per nunc praesent.

\n

Elit dolor nostra vehicula massa placerat convallis dictum natoque commodo diam, ultricies nam consequat inceptos torquent himenaeos risus eleifend scelerisque dui cursus libero nisl neque fusce montes metus proin cras donec nibh. Tempus sodales fames consectetur in integer aliquet odio maecenas est sapien risus parturient lorem aliquam viverra mattis feugiat eu platea ex tempor mi efficitur a. Ut vestibulum nibh et himenaeos taciti nisl class pretium maximus est ultrices fermentum nunc mus dapibus vel massa venenatis facilisis non nascetur leo. Scelerisque metus nisi suspendisse pharetra fames malesuada pretium dictumst, etiam potenti molestie vestibulum habitasse aenean velit ridiculus condimentum ut montes at tortor arcu curae id luctus.

\n

Nulla sollicitudin vestibulum vulputate urna sem etiam senectus turpis ac tempus, laoreet natoque metus justo dapibus purus libero fringilla aenean orci integer imperdiet duis curabitur feugiat blandit proin consequat quam velit ante. Scelerisque elit tincidunt feugiat primis risus amet ac interdum varius luctus quis dui consectetur platea conubia senectus mus efficitur mauris cubilia libero magnis egestas elementum ultricies. Elit dapibus finibus proin aliquet etiam nibh quam laoreet senectus primis a mattis vel montes massa porta dui commodo velit mi bibendum cubilia sed euismod. Posuere consequat velit mauris in sollicitudin id dolor nisl placerat magna aliquet sed metus curae. Penatibus commodo cubilia ex velit leo ultricies ipsum dignissim molestie curae lectus, integer a risus bibendum varius ornare laoreet fermentum fusce duis luctus ultrices nostra sem id nascetur dictum tempor morbi aliquet mauris. Duis posuere enim odio nisl condimentum nunc eleifend nullam primis maecenas, tellus pretium congue nascetur lacinia in lorem vel quisque lectus proin laoreet consectetur faucibus aliquet montes ad sodales commodo vestibulum. Fames odio luctus donec habitasse neque posuere purus quis penatibus netus mi lobortis suspendisse vehicula eu lorem erat libero in scelerisque leo dapibus tristique amet.

\n

Vulputate erat consectetur cras iaculis nascetur lectus pulvinar fames est ut malesuada natoque hac orci euismod rhoncus ad faucibus nostra aptent sociosqu. Leo primis dictumst libero platea nisl at mauris eu fusce penatibus, nunc maximus sodales montes facilisis pharetra ex ipsum class curae aenean parturient tortor massa morbi cras varius ut augue vivamus elementum. Lorem eget facilisi nec varius elementum mattis aliquam praesent blandit dapibus aliquet ornare montes malesuada taciti netus egestas lacus morbi. A nascetur nibh commodo sodales consequat nullam taciti risus, viverra proin quam quis elementum libero molestie fusce egestas curae augue mattis nisl montes senectus. Elementum tempus in dictum sagittis ac hac feugiat lorem efficitur consequat neque per tellus penatibus. Ut suscipit tempus nec tincidunt potenti libero luctus eleifend auctor pulvinar ultrices purus imperdiet dignissim at mus et montes phasellus maecenas hac egestas nulla porta. Placerat sed netus consectetur dis duis varius elementum convallis nostra natoque. Massa morbi ante egestas sit feugiat fusce conubia imperdiet vestibulum maximus mollis himenaeos porttitor auctor aliquet neque suscipit rhoncus viverra natoque vivamus posuere commodo arcu quis cras.

\n

Habitant sem ullamcorper euismod libero curabitur orci urna felis senectus lacus nunc congue morbi molestie adipiscing per sed pharetra magna ut arcu convallis consectetur non. Lorem vel id elementum lobortis netus scelerisque diam fames volutpat tristique congue justo penatibus bibendum sociosqu adipiscing est auctor habitasse ullamcorper cursus quis. Risus quis fermentum vehicula adipiscing erat orci, aptent proin gravida habitasse porttitor mattis ipsum praesent ligula feugiat ad efficitur integer. Lorem maecenas venenatis per suscipit accumsan aliquet penatibus faucibus facilisi facilisis sodales platea suspendisse euismod. Turpis posuere nisi ut tempor aliquet dapibus cursus ante mauris sed auctor dictum egestas nullam sapien porttitor justo pretium. Cursus lorem quisque sem leo convallis molestie etiam conubia pretium lacinia ultricies vestibulum nec sodales natoque commodo dictumst volutpat fames parturient justo cubilia augue velit purus aliquam. Nisl integer mus ultricies laoreet congue vivamus cras ultrices orci fames quis non tempor vel libero at nulla malesuada fermentum dolor lacus ornare ut sodales adipiscing eros nascetur lectus. Platea turpis libero habitasse a praesent cras sem eros hendrerit finibus integer tempor ipsum sapien in nostra litora sit montes risus iaculis class at torquent non magna suspendisse purus dictumst vulputate mi sodales curabitur scelerisque.

\n

Metus laoreet morbi erat ligula gravida non montes aliquam et bibendum tempus pharetra posuere nulla eleifend ante tortor habitant. Suscipit nisl proin natoque mollis ligula commodo scelerisque leo pellentesque per senectus adipiscing quis varius aenean curae phasellus magnis aptent felis nec nibh nisi erat lobortis auctor vehicula molestie. Fames purus velit bibendum maecenas tortor ultricies maximus convallis rhoncus inceptos per porta ipsum eu non habitant lacinia pellentesque. Ante et platea id at tempus magna orci etiam feugiat habitant conubia nascetur aenean. Purus nostra lectus lobortis etiam est lacus luctus laoreet sed ac lacinia quis at egestas class ridiculus litora eleifend urna porttitor enim.

\n

Faucibus felis integer eleifend in molestie eget platea tincidunt dui nec aliquam ultricies sodales quam porttitor facilisi potenti facilisis nisl vehicula tempus curae arcu. Magnis aliquet mi per mollis egestas porta montes ut pulvinar arcu neque adipiscing duis feugiat vel senectus quis facilisi elit nibh felis sodales ullamcorper diam sollicitudin ad. Venenatis hendrerit eget quisque sagittis facilisi quam non sociosqu curae enim, potenti augue dapibus ullamcorper auctor mi dignissim etiam viverra orci commodo laoreet inceptos pellentesque adipiscing class ac sem luctus faucibus fringilla. Urna ante in class auctor facilisi risus himenaeos, vitae malesuada dui mattis mollis aenean cras porttitor dignissim praesent egestas pretium condimentum aliquam. Arcu fringilla dictumst turpis vitae ex tempus vehicula efficitur maximus tincidunt pulvinar praesent nulla odio lacus ridiculus fames pharetra mauris ornare felis aenean penatibus taciti dignissim fusce diam orci vel. Habitant lectus primis risus nisi dolor erat senectus eros, felis varius sit phasellus quisque congue blandit bibendum ante est ligula nostra aliquet aptent magna purus. Pharetra eu hendrerit pulvinar magnis primis quisque integer in mus pellentesque suspendisse lacinia sem dictumst nisl auctor maximus platea.

\n

Nascetur tortor ac placerat facilisi integer litora sit varius duis efficitur sapien, hendrerit diam accumsan elit montes vehicula magnis consequat nostra justo parturient torquent pretium interdum a tincidunt dictum magna vel etiam ut dolor ullamcorper. Tincidunt aliquam lectus id velit class ad malesuada auctor litora consectetur aptent pharetra etiam dolor et tristique lacus.

\n

Non quis nullam urna ligula aptent curabitur odio lacus suspendisse lacinia molestie mus morbi elementum maximus interdum a purus enim sem sapien scelerisque lobortis et phasellus. Elementum vulputate vehicula posuere iaculis sodales fames urna rhoncus purus, laoreet metus ornare sem consequat mollis nibh lorem parturient adipiscing porttitor pretium placerat habitasse libero eleifend enim morbi. Massa tellus viverra nascetur leo aenean nisl vivamus malesuada at ipsum lobortis rutrum accumsan senectus dignissim elit fermentum integer praesent a purus proin faucibus aptent ad adipiscing imperdiet convallis mauris sodales. Sed iaculis mauris ut nunc fusce justo et venenatis libero litora eget aliquet penatibus gravida interdum phasellus turpis ullamcorper cubilia duis leo ex mattis vel cras donec lacinia sodales malesuada id elementum. Mus ultrices ullamcorper suspendisse nec dapibus senectus fermentum felis netus non magna congue neque bibendum dignissim ipsum aenean integer curae facilisi donec. Dolor purus nibh diam facilisis erat etiam mollis consectetur semper, vestibulum suscipit mauris egestas venenatis neque varius dignissim pulvinar ligula lobortis morbi aliquam eros nullam suspendisse orci tortor. Accumsan rutrum tempus arcu eros convallis vel natoque commodo eget diam mollis himenaeos proin placerat suspendisse duis taciti. Urna gravida a mus lacinia aliquam justo in lectus nec sed habitasse penatibus et ex massa vel commodo facilisis rutrum taciti odio torquent inceptos imperdiet sociosqu montes cursus nostra suscipit quam venenatis. Mattis nulla congue interdum gravida ornare ac sed, sagittis iaculis sem parturient netus proin maecenas dignissim rhoncus efficitur condimentum egestas dis litora. Odio imperdiet facilisi tempus ipsum donec tortor dictumst sem finibus parturient aptent molestie pretium risus sagittis pellentesque nisi litora congue cras viverra enim tempor vehicula platea.

\n

Sodales pretium egestas libero viverra lobortis interdum amet quis fames neque convallis dictumst sollicitudin eros felis nec. Nibh nisi sit nam magna elit fames habitasse sollicitudin libero lacus luctus porttitor enim conubia dolor suscipit aptent platea dictum habitant primis imperdiet taciti. Lobortis dui scelerisque feugiat venenatis vehicula tristique mi iaculis efficitur imperdiet aliquet sociosqu ipsum ornare sed gravida amet platea nisl mollis consectetur ex ac.

", email.Bodies["text/html"]) + require.Empty(data.CreatedIds) + require.Equal("3e25b2a0", data.SessionState) + require.Len(data.MethodResponses, 2) + resp := data.MethodResponses[1] + require.Equal(EmailGet, resp.Command) + require.Equal("1", resp.Tag) + require.IsType(EmailGetResponse{}, resp.Parameters) + egr := resp.Parameters.(EmailGetResponse) + require.Equal("d", egr.AccountId) + require.Len(egr.List, 3) + require.Equal("suqmq", egr.State) + require.Empty(egr.NotFound) + email := egr.List[0] + require.Equal("moyaaaddw", email.Id) + require.Equal("cbejozsk1fgcviw7thwzsvtgmf1ep0a3izjoimj02jmtsunpeuwmsaya1yma", email.BlobId) } -func TestMapEmailWithTextBody(t *testing.T) { +func TestUnknown(t *testing.T) { require := require.New(t) - var elem map[string]any - err := json.Unmarshal([]byte(jmap_email_with_text_body), &elem) - require.NoError(err) + const text = `{ + "subject": "aaa", + "bodyStructure": { + "type": "a", + "partId": "b", + "header:x": "yz", + "header:a": "bc" + } + }` - logger := log.NopLogger() + var target EmailCreate + err := json.Unmarshal([]byte(text), &target) - email, err := mapEmail(elem, true, &logger) require.NoError(err) - require.Len(email.Bodies, 1) - require.Contains(email.Bodies, "text/plain") - require.Equal("Et magnis pulvinar congue aliquet tincidunt morbi lobortis mattis mus litora malesuada fringilla varius ullamcorper parturient fames accumsan faucibus erat. Magna id est cras eu a netus orci ridiculus lobortis urna dis ipsum at fermentum mi lacinia quis fames. Cursus ipsum gravida libero ultricies pretium montes rutrum suscipit tempor hac dapibus senectus commodo elementum leo nullam auctor litora pulvinar finibus. Ad nulla torquent quis mollis phasellus sodales aliquet lacinia varius, adipiscing enim habitant et netus egestas eu tempor malesuada mattis hac fusce integer diam inceptos venenatis turpis. Sem senectus aptent non dolor hendrerit magna mauris facilisis justo quam fringilla cursus gravida praesent malesuada taciti odio etiam magnis nostra vivamus. Tempus fames faucibus massa rutrum sit habitant morbi curabitur integer erat et condimentum tincidunt tempor libero vulputate maecenas rhoncus turpis congue a luctus aenean tristique lacinia cursus est fusce non mollis justo euismod facilisis egestas.\nAuctor maecenas vestibulum aenean accumsan eros ex potenti sociosqu, fusce sapien quis faucibus aliquam vivamus tristique hendrerit in per fermentum cras sodales curabitur scelerisque. Finibus metus adipiscing taciti eget rutrum vitae arcu torquent, dignissim at nibh venenatis facilisis molestie erat augue massa feugiat aliquam sollicitudin elementum cursus in. Est neque cras aenean felis justo euismod adipiscing magnis sagittis ut massa aliquet malesuada laoreet velit purus suspendisse bibendum pharetra litora ultrices diam ullamcorper volutpat venenatis egestas. Non laoreet eu interdum sodales phasellus morbi risus maecenas parturient auctor senectus urna ornare faucibus sociosqu habitant nisi cubilia viverra diam fames condimentum tempor scelerisque iaculis lacus elit feugiat adipiscing vivamus. Euismod volutpat gravida fames nascetur ridiculus iaculis habitasse vulputate habitant netus varius rhoncus ultrices porttitor himenaeos lorem libero congue turpis parturient quisque nostra aliquet in sem curabitur eleifend accumsan faucibus per pellentesque. Nibh auctor dictum vivamus eros ex gravida hac torquent purus suscipit fames lacus sagittis condimentum morbi dui litora cras duis iaculis massa porta praesent sapien. Ultricies tortor phasellus mus erat metus nisi malesuada augue sollicitudin convallis egestas ultrices arcu luctus tempus molestie facilisis nam scelerisque feugiat. Nibh imperdiet accumsan fermentum auctor et neque blandit elementum id eget justo suscipit interdum etiam mus tempus diam cursus nunc malesuada aliquam vestibulum. Arcu facilisi curae sed mi felis commodo, sapien neque aenean nullam rutrum torquent lectus fringilla rhoncus eros elit molestie.\nAptent fringilla cubilia sed duis non eu vulputate dis efficitur per ad venenatis dictumst egestas commodo blandit. Conubia finibus curae molestie egestas interdum mollis aliquam venenatis penatibus habitant varius natoque aptent nec. Mattis hac commodo integer donec gravida himenaeos vivamus primis rhoncus nam cursus erat nibh nascetur elementum felis duis in volutpat aliquet nulla vehicula placerat est. Placerat dis est aenean laoreet convallis metus sit mi, porttitor ullamcorper risus augue commodo dictumst nisi platea cubilia maximus elit volutpat hac rutrum suspendisse. Lacinia taciti justo non ligula vivamus aliquam luctus tellus dictumst vulputate interdum per aptent a metus eu mauris hac ex montes senectus blandit proin. Proin ullamcorper habitant justo pharetra felis commodo parturient scelerisque rutrum suspendisse ad ante cubilia pulvinar est vivamus quisque imperdiet vestibulum varius aliquam enim. Mollis aliquam metus montes dapibus volutpat maecenas fermentum massa tempor condimentum rhoncus lacinia tincidunt accumsan leo nunc elementum maximus fringilla dui augue.", email.Bodies["text/plain"]) + require.Equal("aaa", target.Subject) + bs := target.BodyStructure + require.Equal("a", bs.Type) + require.Equal("b", bs.PartId) + require.Contains(bs.Other, "header:x") + require.Equal(bs.Other["header:x"], "yz") + require.Contains(bs.Other, "header:a") + require.Equal(bs.Other["header:a"], "bc") + + result, err := json.Marshal(target) + require.NoError(err) + require.Equal(`{"subject":"aaa","bodyStructure":{"type":"a","partId":"b","header:a":"bc","header:x":"yz"}}`, string(result)) } diff --git a/pkg/jmap/testdata/mailboxes1.json b/pkg/jmap/testdata/mailboxes1.json new file mode 100644 index 0000000000..4ffb5c317f --- /dev/null +++ b/pkg/jmap/testdata/mailboxes1.json @@ -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" +} diff --git a/pkg/jmap/testdata/mails1.json b/pkg/jmap/testdata/mails1.json new file mode 100644 index 0000000000..21a1d08cba --- /dev/null +++ b/pkg/jmap/testdata/mails1.json @@ -0,0 +1,277 @@ +{ + "methodResponses": [ + [ + "Email/query", + { + "accountId": "d", + "queryState": "suqmq", + "canCalculateChanges": true, + "position": 0, + "ids": [ + "moyaaaddw", + "mouaaaddv", + "moqaaaddu" + ], + "limit": 3 + }, + "0" + ], + [ + "Email/get", + { + "accountId": "d", + "state": "suqmq", + "list": [ + { + "id": "moyaaaddw", + "blobId": "cbejozsk1fgcviw7thwzsvtgmf1ep0a3izjoimj02jmtsunpeuwmsaya1yma", + "threadId": "ddw", + "mailboxIds": { + "a": true + }, + "keywords": {}, + "size": 1842, + "receivedAt": "2025-06-02T09:31:01Z", + "messageId": [ + "1748856661.7568858@example.com" + ], + "inReplyTo": null, + "references": null, + "sender": [ + { + "name": "Beloved Deadly", + "email": "beloved.deadly@example.com" + } + ], + "from": [ + { + "name": "Beloved Deadly", + "email": "beloved.deadly@example.com" + } + ], + "to": [ + { + "name": "alan", + "email": "alan@example.com" + } + ], + "cc": [ + { + "name": "Team Lead", + "email": "lead@example.com" + }, + { + "name": "Coworker", + "email": "coworker@example.com" + } + ], + "bcc": null, + "replyTo": null, + "subject": "Ornare Senectus Ultrices Elit", + "sentAt": "2025-06-02T11:31:01+02:00", + "hasAttachment": false, + "preview": "Lorem tortor eros blandit adipiscing scelerisque fermentum fames himenaeos varius pulvinar nascetur erat turpis risus sagittis felis augue efficitur proin ante id suspendisse eu mattis.\nEst sociosqu arcu elit penatibus vehicula magnis senectus maximus m...", + "bodyValues": { + "0": { + "isEncodingProblem": false, + "isTruncated": false, + "value": "

Lorem tortor eros blandit adipiscing scelerisque fermentum fames himenaeos varius pulvinar nascetur erat turpis risus sagittis felis augue efficitur proin ante id suspendisse eu mattis.

\n

Est sociosqu arcu elit penatibus vehicula magnis senectus maximus massa nisl praesent viverra malesuada sapien. Semper tempus ridiculus habitasse pharetra molestie vehicula class, placerat pulvinar interdum viverra nam feugiat blandit urna inceptos lobortis odio imperdiet ante neque. Dui vulputate finibus lacinia non ultricies conubia velit vestibulum eleifend venenatis nec quam justo magnis urna aliquet commodo senectus montes lectus blandit tortor sollicitudin elit curae natoque tempus imperdiet curabitur. Phasellus imperdiet mollis ultrices tellus pellentesque sodales malesuada sociosqu curae finibus nostra taciti ultricies duis quam ex habitant fusce vitae torquent felis vulputate. Urna non litora adipiscing mollis hac consectetur fusce duis sit imperdiet pretium arcu malesuada magna faucibus ad himenaeos consequat etiam fermentum. Elit ac placerat conubia dis malesuada dui torquent odio convallis pulvinar, netus lectus natoque taciti ultrices nostra vivamus fringilla lacinia feugiat aenean at ultricies mi fusce lobortis amet est nec phasellus. Dapibus mus venenatis cursus maecenas ultrices rutrum convallis velit pretium sodales mi lorem cras hac semper nibh laoreet curabitur sem est ultricies in lacus ornare senectus blandit.

" + } + }, + "textBody": [ + { + "partId": "0", + "blobId": "cfejozsk1fgcviw7thwzsvtgmf1ep0a3izjoimj02jmtsunpeuwmsaya1ymiqa0kbm", + "size": 1450, + "name": null, + "type": "text/html", + "charset": "utf-8", + "disposition": null, + "cid": null, + "language": null, + "location": null + } + ], + "htmlBody": [ + { + "partId": "0", + "blobId": "cfejozsk1fgcviw7thwzsvtgmf1ep0a3izjoimj02jmtsunpeuwmsaya1ymiqa0kbm", + "size": 1450, + "name": null, + "type": "text/html", + "charset": "utf-8", + "disposition": null, + "cid": null, + "language": null, + "location": null + } + ], + "attachments": [] + }, + { + "id": "mouaaaddv", + "blobId": "can2gyjt2yc1s0jo7zvikomhq0bhh7atp177v1l039kqtdqyicpiuaya1uma", + "threadId": "ddv", + "mailboxIds": { + "a": true + }, + "keywords": {}, + "size": 4603, + "receivedAt": "2025-06-02T09:31:01Z", + "messageId": [ + "1748856661.8998663@example.com" + ], + "inReplyTo": null, + "references": null, + "sender": [ + { + "name": "Helped Notably", + "email": "helped.notably@example.com" + } + ], + "from": [ + { + "name": "Helped Notably", + "email": "helped.notably@example.com" + } + ], + "to": [ + { + "name": "alan", + "email": "alan@example.com" + } + ], + "cc": null, + "bcc": null, + "replyTo": null, + "subject": "Lorem Tortor Eros Blandit Adipiscing Scelerisque Fermentum", + "sentAt": "2025-06-02T11:31:01+02:00", + "hasAttachment": false, + "preview": "Consectetur facilisi suscipit ex ultrices nibh torquent fermentum urna et, aptent nostra euismod tempus scelerisque inceptos quis aenean magna nec tellus sociosqu est mauris commodo congue blandit cursus cubilia. Metus congue magna imperdiet tempor dign...", + "bodyValues": { + "1": { + "isEncodingProblem": false, + "isTruncated": false, + "value": "Consectetur facilisi suscipit ex ultrices nibh torquent fermentum urna et, aptent nostra euismod tempus scelerisque inceptos quis aenean magna nec tellus sociosqu est mauris commodo congue blandit cursus cubilia. Metus congue magna imperdiet tempor dignissim phasellus quam per ridiculus curabitur taciti potenti tellus faucibus morbi erat aliquam euismod nascetur mattis. Potenti mauris sollicitudin netus a neque nascetur auctor aptent purus sodales ultricies finibus euismod dolor vivamus vestibulum congue dis id leo inceptos natoque torquent quis libero. Venenatis pharetra pellentesque sit quisque quis posuere efficitur imperdiet, ullamcorper ornare augue a porttitor fermentum ad phasellus quam proin dolor mollis vestibulum eu inceptos nostra. Vestibulum orci consequat arcu eros pharetra nunc blandit nibh ligula risus porta auctor diam lectus maximus commodo interdum dapibus morbi duis. Efficitur vehicula at lacus augue sit dapibus non suspendisse laoreet lacinia metus nam sollicitudin luctus sagittis leo ullamcorper tempus platea mi aptent aliquet primis. Ipsum erat cursus ad fusce sagittis dui convallis magnis mi porttitor aliquam quis efficitur inceptos commodo conubia vivamus sociosqu interdum himenaeos penatibus mollis platea in fames auctor.\nLobortis netus arcu malesuada finibus euismod massa ut fames mattis dignissim leo suspendisse purus parturient iaculis consectetur hac imperdiet commodo dui. Ante efficitur ut amet aenean gravida eleifend justo ipsum suspendisse bibendum dignissim leo pharetra sit tincidunt vel aliquam turpis elit feugiat facilisis quis tellus aliquet class magna consequat. Gravida felis elit ipsum convallis ornare integer orci lectus semper mattis sodales fusce sed in hendrerit pellentesque himenaeos aenean velit. Sed dui facilisi morbi nullam per sollicitudin ligula taciti tellus quisque faucibus sapien penatibus maecenas mattis consequat tempor litora volutpat posuere nisi lacinia luctus interdum nam tortor dictum." + }, + "2": { + "isEncodingProblem": false, + "isTruncated": false, + "value": "

Consectetur facilisi suscipit ex ultrices nibh torquent fermentum urna et, aptent nostra euismod tempus scelerisque inceptos quis aenean magna nec tellus sociosqu est mauris commodo congue blandit cursus cubilia. Metus congue magna imperdiet tempor dignissim phasellus quam per ridiculus curabitur taciti potenti tellus faucibus morbi erat aliquam euismod nascetur mattis. Potenti mauris sollicitudin netus a neque nascetur auctor aptent purus sodales ultricies finibus euismod dolor vivamus vestibulum congue dis id leo inceptos natoque torquent quis libero. Venenatis pharetra pellentesque sit quisque quis posuere efficitur imperdiet, ullamcorper ornare augue a porttitor fermentum ad phasellus quam proin dolor mollis vestibulum eu inceptos nostra. Vestibulum orci consequat arcu eros pharetra nunc blandit nibh ligula risus porta auctor diam lectus maximus commodo interdum dapibus morbi duis. Efficitur vehicula at lacus augue sit dapibus non suspendisse laoreet lacinia metus nam sollicitudin luctus sagittis leo ullamcorper tempus platea mi aptent aliquet primis. Ipsum erat cursus ad fusce sagittis dui convallis magnis mi porttitor aliquam quis efficitur inceptos commodo conubia vivamus sociosqu interdum himenaeos penatibus mollis platea in fames auctor.

\n

Lobortis netus arcu malesuada finibus euismod massa ut fames mattis dignissim leo suspendisse purus parturient iaculis consectetur hac imperdiet commodo dui. Ante efficitur ut amet aenean gravida eleifend justo ipsum suspendisse bibendum dignissim leo pharetra sit tincidunt vel aliquam turpis elit feugiat facilisis quis tellus aliquet class magna consequat. Gravida felis elit ipsum convallis ornare integer orci lectus semper mattis sodales fusce sed in hendrerit pellentesque himenaeos aenean velit. Sed dui facilisi morbi nullam per sollicitudin ligula taciti tellus quisque faucibus sapien penatibus maecenas mattis consequat tempor litora volutpat posuere nisi lacinia luctus interdum nam tortor dictum.

" + } + }, + "textBody": [ + { + "partId": "1", + "blobId": "cen2gyjt2yc1s0jo7zvikomhq0bhh7atp177v1l039kqtdqyicpiuaya1umo7a0zb2", + "size": 1977, + "name": null, + "type": "text/plain", + "charset": "utf-8", + "disposition": null, + "cid": null, + "language": null, + "location": null + } + ], + "htmlBody": [ + { + "partId": "2", + "blobId": "cen2gyjt2yc1s0jo7zvikomhq0bhh7atp177v1l039kqtdqyicpiuaya1umicfghb2", + "size": 1991, + "name": null, + "type": "text/html", + "charset": "utf-8", + "disposition": null, + "cid": null, + "language": null, + "location": null + } + ], + "attachments": [] + }, + { + "id": "moqaaaddu", + "blobId": "cbnhjfwus1qzaro9g77ccattplywro3h209ajriudofqma00u2eo1aya1qma", + "threadId": "ddu", + "mailboxIds": { + "a": true + }, + "keywords": {}, + "size": 10654, + "receivedAt": "2025-06-02T09:31:01Z", + "messageId": [ + "1748856661.8411591@example.com" + ], + "inReplyTo": null, + "references": null, + "sender": [ + { + "name": "Endless Virtually", + "email": "endless.virtually@example.com" + } + ], + "from": [ + { + "name": "Endless Virtually", + "email": "endless.virtually@example.com" + } + ], + "to": [ + { + "name": "alan", + "email": "alan@example.com" + } + ], + "cc": null, + "bcc": null, + "replyTo": null, + "subject": "Consectetur Facilisi Suscipit Ex Ultrices Nibh Torquent Fermentum Urna", + "sentAt": "2025-06-02T11:31:01+02:00", + "hasAttachment": false, + "preview": "Congue a rutrum vestibulum finibus dictum pharetra vehicula tortor ultrices rhoncus, litora tempus phasellus sapien class cursus gravida justo inceptos eleifend nisl ad posuere et magnis ullamcorper vitae porta suspendisse amet. Fringilla turpis ultrici...", + "bodyValues": { + "0": { + "isEncodingProblem": false, + "isTruncated": false, + "value": "Congue a rutrum vestibulum finibus dictum pharetra vehicula tortor ultrices rhoncus, litora tempus phasellus sapien class cursus gravida justo inceptos eleifend nisl ad posuere et magnis ullamcorper vitae porta suspendisse amet. Fringilla turpis ultricies non senectus mi lectus lacus, consequat finibus risus ligula semper laoreet malesuada sociosqu natoque fames lobortis libero ex curae interdum. Placerat etiam viverra odio posuere sodales ullamcorper penatibus nisl pretium sociosqu tortor montes netus tristique porttitor mattis varius facilisi dui neque mollis vivamus metus platea. Consectetur nascetur laoreet commodo aliquet amet bibendum lacus mattis mollis suspendisse himenaeos inceptos adipiscing montes sodales viverra a elementum dignissim. Luctus netus laoreet dis magnis cursus ligula phasellus interdum conubia senectus rutrum efficitur dolor maximus torquent odio velit fames potenti adipiscing sit metus lacus tortor. Ac imperdiet torquent nam natoque placerat faucibus tempor finibus ante integer at pellentesque nascetur sodales morbi leo eleifend ultricies euismod luctus eros tempus varius habitasse tortor erat laoreet sed et interdum. Augue mollis sodales dictumst sem eros sollicitudin imperdiet fusce vitae diam libero ullamcorper tortor accumsan pulvinar platea elit velit praesent potenti.\nPotenti nam diam quam senectus mus condimentum torquent, posuere hendrerit netus habitant vestibulum sagittis dignissim montes risus neque etiam proin ante elementum purus. Torquent porta nibh phasellus arcu maecenas curae elit sit habitant ultrices mus nisi metus ridiculus venenatis montes nulla senectus enim mauris semper. Laoreet suscipit lectus conubia aptent a felis ultricies cras platea amet sapien proin potenti luctus quam nisl sollicitudin blandit dignissim mollis. Quam maximus dolor accumsan ad quisque dictum ornare tellus fusce congue aptent tristique eros rhoncus nibh aenean parturient ipsum nostra ultrices hac.\nTurpis porttitor rhoncus pellentesque tempus praesent auctor orci tristique suspendisse bibendum class mollis sollicitudin pulvinar mi augue maximus aliquam conubia odio imperdiet. Dignissim potenti penatibus imperdiet quisque morbi cubilia lorem quis aenean adipiscing consequat sociosqu est aliquam ut phasellus ullamcorper nisi nisl mollis tortor proin gravida commodo ligula laoreet ex eget. Sem cursus sapien iaculis nulla orci vehicula varius, hac efficitur integer vestibulum fringilla quisque facilisis habitant id nam accumsan primis enim nibh odio. Iaculis dapibus est luctus dis euismod purus sociosqu magna felis vitae scelerisque curae tempor nisl primis dictumst dignissim non sit taciti suscipit pretium duis elit quisque suspendisse penatibus donec egestas.\nSenectus etiam tempus sollicitudin vivamus id quam eu, proin semper sodales volutpat velit pharetra ut gravida nec leo nascetur sapien commodo accumsan lacus. Dolor felis sagittis elementum sodales pulvinar mauris consequat eleifend, torquent taciti diam lobortis malesuada tristique ad convallis ornare aenean facilisi posuere hendrerit non montes. Hac suspendisse amet netus lorem erat ex vulputate quisque placerat ultrices tincidunt morbi dictum eleifend ut curabitur fames tortor sem. Est ante ut ex nunc tincidunt taciti eget ipsum imperdiet aliquam placerat class sociosqu cras at a eleifend per himenaeos sagittis quisque interdum dictum convallis suspendisse. Nibh sed vivamus ad magnis fermentum lorem fames ultricies quis curae, amet pulvinar nisi commodo maximus tellus sapien ante mattis purus tincidunt mi hendrerit morbi tristique adipiscing ipsum himenaeos ligula et. Neque tempus amet condimentum commodo euismod leo dis lacus nisl dignissim, hendrerit mi quisque facilisi massa habitant nec enim felis integer dolor sapien vitae parturient est nostra curae ornare mus etiam dui. Habitant vel integer fringilla curae auctor quisque porta lectus sodales lorem lobortis taciti, dignissim maecenas aptent sit curabitur velit ridiculus fames dolor sollicitudin condimentum purus arcu cursus vehicula adipiscing consequat amet facilisis turpis ultrices porttitor vivamus. Bibendum aliquet sem felis magnis nascetur gravida dolor porttitor augue lobortis risus blandit habitasse auctor nam litora convallis morbi ullamcorper nibh odio pharetra ornare per netus tristique volutpat enim.\nEuismod nibh elementum arcu etiam vel orci egestas cursus ultricies, integer turpis vitae porta dolor dignissim libero tristique vestibulum a est proin nec vivamus suspendisse rutrum semper inceptos. Mi consequat odio litora lorem pulvinar blandit ac lectus pharetra dolor, dignissim sociosqu ad rhoncus duis bibendum interdum primis montes morbi ex vestibulum est adipiscing potenti conubia eros ullamcorper dui. Velit libero sem arcu eros dapibus nostra ante cursus aliquam aliquet convallis congue sociosqu magnis malesuada rhoncus et amet lobortis bibendum pulvinar molestie. Ut ipsum ligula sapien viverra sociosqu habitant habitasse nam himenaeos dis facilisis id semper aliquet mauris magnis consectetur diam volutpat. Ante amet vel felis diam sagittis ullamcorper ultrices cursus enim conubia primis fermentum cras quam fringilla vehicula aliquam duis consectetur augue velit praesent quis eleifend. Vestibulum condimentum eleifend sociosqu efficitur habitant ultricies porta donec aliquet enim, penatibus nullam dis nulla pharetra rutrum laoreet tellus nisl lacus primis nam tempus ultrices pretium suscipit vivamus rhoncus arcu. Senectus non neque ac pellentesque amet felis id dolor porta luctus elementum nisl eleifend risus laoreet bibendum facilisi litora. Rhoncus quam odio tristique mi taciti phasellus egestas lacus senectus sodales nullam himenaeos tellus diam facilisi nunc consequat sollicitudin dolor semper enim nulla justo.\nQuisque sem lectus himenaeos ac aliquam nibh ultrices bibendum nullam ipsum viverra euismod etiam senectus commodo pretium tincidunt volutpat non nostra torquent cras. Nibh congue rhoncus lobortis taciti diam proin lacus nisl dis sodales euismod finibus felis suscipit viverra commodo laoreet molestie rutrum nisi ex maecenas. Ridiculus venenatis justo mattis sit fringilla nisi arcu aptent primis quis ipsum habitant tincidunt vel adipiscing. Bibendum rhoncus convallis proin sollicitudin praesent dui maximus at hendrerit turpis ultrices fames enim vel orci molestie conubia himenaeos vestibulum dolor mauris ut lobortis hac rutrum. Potenti varius aliquam volutpat neque bibendum pretium mus justo natoque ac turpis montes sollicitudin orci cubilia posuere parturient. Aenean nisl montes dignissim at vitae nec vel nam nunc lectus iaculis augue nibh dictumst.\nHimenaeos quis primis elementum scelerisque fringilla litora proin eu porttitor duis magnis urna mi maecenas vestibulum curae phasellus amet facilisi augue. Vel enim facilisis vehicula elit eleifend ut natoque lobortis, dolor pretium egestas non nascetur habitant condimentum odio dapibus porttitor rutrum sem nec fermentum luctus consequat. Netus natoque fames ligula iaculis duis quam facilisis cras, nam felis per eleifend himenaeos senectus maximus suscipit quis amet est tempor sem nascetur orci dictum. Nunc inceptos habitasse nisl est id semper elit lacus, adipiscing interdum viverra primis dis porta pulvinar montes class ad duis netus potenti per rutrum nostra. Primis sit gravida consequat fermentum platea curabitur orci dis senectus id iaculis pellentesque ad elit duis feugiat lorem vitae sodales nisi mus euismod.\nLigula amet interdum fringilla mauris hendrerit semper justo nisl aenean facilisis sed tellus venenatis lorem sapien. Condimentum et pulvinar tempus aenean scelerisque aliquet donec inceptos lacinia nulla, penatibus ultrices sapien nec volutpat quisque ridiculus natoque habitant ultricies nibh feugiat accumsan a arcu lorem pharetra faucibus taciti posuere. Malesuada amet suscipit dictum nam id varius taciti eu sollicitudin erat convallis nostra scelerisque neque justo finibus. Eros hac ultrices mus felis pellentesque maecenas platea tellus est interdum accumsan tempus adipiscing nostra nec neque vitae suspendisse quam. Velit imperdiet diam senectus dictum metus ipsum nisl quam habitasse nibh commodo convallis integer netus magna lectus ante lacus per. Inceptos commodo sed ullamcorper volutpat libero at varius lacus ad ac facilisis posuere accumsan diam fames pretium penatibus mauris ornare mollis aenean gravida finibus quis curae.\nFinibus libero conubia lobortis netus at nisl sed leo amet consectetur laoreet phasellus fusce nascetur dis molestie dolor quis duis nibh primis cubilia curabitur hac potenti. Himenaeos erat etiam libero commodo auctor sit duis tortor mi rhoncus eu ridiculus sem nulla amet ornare potenti enim feugiat mus placerat posuere pretium curabitur sociosqu. Phasellus fames massa non est ultrices aenean velit, tempor ipsum felis interdum fringilla pretium magna nisi dignissim ante ridiculus volutpat vestibulum. Per vulputate quam maximus fermentum conubia ullamcorper lorem integer eu bibendum auctor tortor sodales congue morbi semper nullam himenaeos pulvinar facilisi senectus dignissim.\nLobortis ullamcorper est ultrices dis tempus accumsan ex felis, fusce velit efficitur mattis ac facilisi bibendum suspendisse augue tellus fermentum eleifend aenean ante mollis aliquam. Ornare himenaeos nostra gravida etiam fermentum vehicula morbi odio vestibulum tempor, est porttitor congue habitant neque facilisis fames eleifend dapibus varius ex justo blandit purus leo aenean litora viverra scelerisque. Et consequat aliquam class mi ultricies nunc sagittis turpis lorem odio nullam est suspendisse ad mattis dis id posuere aptent proin maecenas pharetra fringilla. Per nulla volutpat luctus vestibulum cubilia ullamcorper quisque venenatis aptent enim curae aenean nullam dis conubia ligula maecenas phasellus curabitur. Platea aptent vehicula urna est nisl porttitor ornare at fusce, vestibulum fames torquent potenti lectus cras suspendisse tincidunt ipsum natoque commodo nibh quisque praesent ultrices facilisi egestas dis turpis. Eleifend nulla fusce neque vel egestas congue libero ridiculus urna curabitur placerat sapien sociosqu nunc convallis nascetur dapibus ultricies tincidunt pellentesque aliquet duis malesuada laoreet condimentum venenatis erat potenti turpis." + } + }, + "textBody": [ + { + "partId": "0", + "blobId": "cfnhjfwus1qzaro9g77ccattplywro3h209ajriudofqma00u2eo1aya1qmpsavfka", + "size": 10277, + "name": null, + "type": "text/plain", + "charset": "utf-8", + "disposition": null, + "cid": null, + "language": null, + "location": null + } + ], + "htmlBody": [ + { + "partId": "0", + "blobId": "cfnhjfwus1qzaro9g77ccattplywro3h209ajriudofqma00u2eo1aya1qmpsavfka", + "size": 10277, + "name": null, + "type": "text/plain", + "charset": "utf-8", + "disposition": null, + "cid": null, + "language": null, + "location": null + } + ], + "attachments": [] + } + ], + "notFound": [] + }, + "1" + ] + ], + "sessionState": "3e25b2a0" +} \ No newline at end of file diff --git a/services/graph/pkg/service/v0/groupware.go b/services/graph/pkg/service/v0/groupware.go deleted file mode 100644 index 977b8c69a7..0000000000 --- a/services/graph/pkg/service/v0/groupware.go +++ /dev/null @@ -1,234 +0,0 @@ -package svc - -import ( - "context" - "crypto/tls" - "net/http" - "strconv" - "time" - - "github.com/opencloud-eu/opencloud/pkg/jmap" - "github.com/opencloud-eu/opencloud/pkg/log" - "github.com/opencloud-eu/opencloud/services/graph/pkg/config" - "github.com/opencloud-eu/opencloud/services/graph/pkg/errorcode" - - "github.com/go-chi/render" - - "github.com/jellydator/ttlcache/v3" -) - -const ( - logFolderId = "folder-id" - logQuery = "query" -) - -type Groupware struct { - logger *log.Logger - jmapClient jmap.Client - sessionCache *ttlcache.Cache[string, jmap.Session] - usernameProvider jmap.HttpJmapUsernameProvider // we also need it for ourselves for now - defaultEmailLimit int - maxBodyValueBytes int -} - -type ItemBody struct { - Content string `json:"content"` - ContentType string `json:"contentType"` // text|html -} - -type Message struct { - Id string `json:"id"` - CreatedDateTime time.Time `json:"createdDateTime"` - ReceivedDateTime time.Time `json:"receivedDateTime"` - HasAttachments bool `json:"hasAttachments"` - InternetMessageId string `json:"InternetMessageId"` - Subject string `json:"subject"` - BodyPreview string `json:"bodyPreview"` - Body ItemBody `json:"body"` -} - -func NewGroupware(logger *log.Logger, config *config.Config) *Groupware { - baseUrl := config.Mail.BaseUrl - jmapUrl := config.Mail.JmapUrl - masterUsername := config.Mail.Master.Username - masterPassword := config.Mail.Master.Password - defaultEmailLimit := config.Mail.DefaultEmailLimit - maxBodyValueBytes := config.Mail.MaxBodyValueBytes - - tr := http.DefaultTransport.(*http.Transport).Clone() - tr.ResponseHeaderTimeout = time.Duration(config.Mail.Timeout) - tlsConfig := &tls.Config{InsecureSkipVerify: true} - tr.TLSClientConfig = tlsConfig - c := *http.DefaultClient - c.Transport = tr - - jmapUsernameProvider := jmap.NewRevaContextHttpJmapUsernameProvider() - - api := jmap.NewHttpJmapApiClient( - baseUrl, - jmapUrl, - &c, - jmapUsernameProvider, - masterUsername, - masterPassword, - ) - - jmapClient := jmap.NewClient(api, api) - - loader := ttlcache.LoaderFunc[string, jmap.Session]( - func(c *ttlcache.Cache[string, jmap.Session], key string) *ttlcache.Item[string, jmap.Session] { - jmapContext, err := jmapClient.FetchSession(key, logger) - if err != nil { - logger.Error().Err(err).Str("username", key).Msg("failed to retrieve well-known") - return nil - } - item := c.Set(key, jmapContext, config.Mail.SessionCacheTTL) - return item - }, - ) - - sessionCache := ttlcache.New( - ttlcache.WithTTL[string, jmap.Session]( - config.Mail.SessionCacheTTL, - ), - ttlcache.WithDisableTouchOnHit[string, jmap.Session](), - ttlcache.WithLoader(loader), - ) - go sessionCache.Start() - - return &Groupware{ - logger: logger, - jmapClient: jmapClient, - sessionCache: sessionCache, - usernameProvider: jmapUsernameProvider, - defaultEmailLimit: defaultEmailLimit, - maxBodyValueBytes: maxBodyValueBytes, - } -} - -func pickInbox(folders jmap.Folders) string { - for _, folder := range folders.Folders { - if folder.Role == "inbox" { - return folder.Id - } - } - return "" -} - -func (g Groupware) session(ctx context.Context, logger *log.Logger) (jmap.Session, error) { - username, err := g.usernameProvider.GetUsername(ctx, logger) - if err != nil { - logger.Error().Err(err).Msg("failed to retrieve username") - return jmap.Session{}, err - } - - item := g.sessionCache.Get(username) - return item.Value(), nil -} - -func (g Groupware) GetMessages(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - logger := g.logger.SubloggerWithRequestID(ctx) - - session, err := g.session(ctx, &logger) - if err != nil { - logger.Error().Err(err).Interface(logQuery, r.URL.Query()).Msg("failed to determine JMAP session") - errorcode.ServiceNotAvailable.Render(w, r, http.StatusInternalServerError, err.Error()) - return - } - - ctx = session.DecorateSession(ctx) - logger = session.DecorateLogger(logger) - - offset, ok, _ := parseNumericParam(r, "$skip", 0) - if ok { - logger = log.Logger{Logger: logger.With().Int("$skip", offset).Logger()} - } - limit, ok, _ := parseNumericParam(r, "$top", g.defaultEmailLimit) - if ok { - logger = log.Logger{Logger: logger.With().Int("$top", limit).Logger()} - } - - logger.Debug().Msg("fetching folders") - folders, err := g.jmapClient.GetMailboxes(session, ctx, &logger) - if err != nil { - logger.Error().Err(err).Interface(logQuery, r.URL.Query()).Msg("could not retrieve mailboxes") - errorcode.ServiceNotAvailable.Render(w, r, http.StatusInternalServerError, err.Error()) - return - } - - inboxId := pickInbox(folders) - // TODO handle not found - logger = log.Logger{Logger: logger.With().Str(logFolderId, inboxId).Logger()} - - logger.Debug().Msg("fetching emails from inbox") - emails, err := g.jmapClient.GetEmails(session, ctx, &logger, inboxId, offset, limit, true, g.maxBodyValueBytes) - if err != nil { - logger.Error().Err(err).Interface(logQuery, r.URL.Query()).Msg("could not retrieve emails from inbox") - errorcode.ServiceNotAvailable.Render(w, r, http.StatusInternalServerError, err.Error()) - return - } - - messages := make([]Message, 0, len(emails.Emails)) - for _, email := range emails.Emails { - message := message(email, logger) - messages = append(messages, message) - } - - render.Status(r, http.StatusOK) - render.JSON(w, r, messages) -} - -// https://learn.microsoft.com/en-us/graph/api/resources/message?view=graph-rest-1.0:w -func message(email jmap.Email, logger log.Logger) Message { - var body ItemBody - switch len(email.Bodies) { - case 0: - break - case 1: - for mime, content := range email.Bodies { - body = ItemBody{Content: content, ContentType: mime} - logger.Debug().Msgf("one body: %v", mime) - } - default: - content, ok := email.Bodies["text/html"] - if ok { - body = ItemBody{Content: content, ContentType: "text/html"} - } else { - content, ok = email.Bodies["text/plain"] - if ok { - body = ItemBody{Content: content, ContentType: "text/plain"} - } else { - for mime, content := range email.Bodies { - body = ItemBody{Content: content, ContentType: mime} - break - } - } - } - } - - return Message{ - Id: email.Id, - Subject: email.Subject, - CreatedDateTime: email.Received, - ReceivedDateTime: email.Received, - HasAttachments: email.HasAttachments, - InternetMessageId: email.MessageId, - BodyPreview: email.Preview, - Body: body, - } // TODO more email fields -} - -func parseNumericParam(r *http.Request, param string, defaultValue int) (int, bool, error) { - str := r.URL.Query().Get(param) - if str == "" { - return defaultValue, false, nil - } - - value, err := strconv.ParseInt(str, 10, 0) - if err != nil { - return defaultValue, false, nil - } - return int(value), true, nil -} diff --git a/services/graph/pkg/service/v0/groupware/groupware.go b/services/graph/pkg/service/v0/groupware/groupware.go new file mode 100644 index 0000000000..863ebbbb2f --- /dev/null +++ b/services/graph/pkg/service/v0/groupware/groupware.go @@ -0,0 +1,393 @@ +package groupware + +import ( + "context" + "crypto/tls" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/opencloud-eu/opencloud/pkg/jmap" + "github.com/opencloud-eu/opencloud/pkg/log" + "github.com/opencloud-eu/opencloud/services/graph/pkg/config" + "github.com/opencloud-eu/opencloud/services/graph/pkg/errorcode" + + "github.com/go-chi/render" + + "github.com/jellydator/ttlcache/v3" +) + +const ( + logFolderId = "folder-id" + logQuery = "query" +) + +type Groupware struct { + logger *log.Logger + jmapClient jmap.Client + sessionCache *ttlcache.Cache[string, jmap.Session] + usernameProvider jmap.HttpJmapUsernameProvider // we also need it for ourselves for now + defaultEmailLimit int + maxBodyValueBytes int +} + +type ItemBody struct { + Content string `json:"content"` + ContentType string `json:"contentType"` // text|html +} + +type EmailAddress struct { + Address string `json:"address"` + Name string `json:"name"` +} + +type Messages struct { + Context string `json:"@odata.context,omitempty"` + Value []Message `json:"value"` +} + +type Message struct { + Etag string `json:"@odata.etag,omitempty"` + Id string `json:"id,omitempty"` + CreatedDateTime time.Time `json:"createdDateTime,omitzero"` + ReceivedDateTime time.Time `json:"receivedDateTime,omitzero"` + SentDateTime time.Time `json:"sentDateTime,omitzero"` + HasAttachments bool `json:"hasAttachments,omitempty"` + InternetMessageId string `json:"internetMessageId,omitempty"` + Subject string `json:"subject,omitempty"` + BodyPreview string `json:"bodyPreview,omitempty"` + Body *ItemBody `json:"body,omitempty"` + From *EmailAddress `json:"from,omitempty"` + ToRecipients []EmailAddress `json:"toRecipients,omitempty"` + CcRecipients []EmailAddress `json:"ccRecipients,omitempty"` + BccRecipients []EmailAddress `json:"bccRecipients,omitempty"` + ReplyTo []EmailAddress `json:"replyTo,omitempty"` + IsRead bool `json:"isRead,omitempty"` + IsDraft bool `json:"isDraft,omitempty"` + Importance string `json:"importance,omitempty"` + ParentFolderId string `json:"parentFolderId,omitempty"` + Categories []string `json:"categories,omitempty"` + ConversationId string `json:"conversationId,omitempty"` + WebLink string `json:"webLink,omitempty"` + // ConversationIndex string `json:"conversationIndex"` +} + +func NewGroupware(logger *log.Logger, config *config.Config) *Groupware { + baseUrl := config.Mail.BaseUrl + jmapUrl := config.Mail.JmapUrl + masterUsername := config.Mail.Master.Username + masterPassword := config.Mail.Master.Password + defaultEmailLimit := config.Mail.DefaultEmailLimit + maxBodyValueBytes := config.Mail.MaxBodyValueBytes + + tr := http.DefaultTransport.(*http.Transport).Clone() + tr.ResponseHeaderTimeout = time.Duration(config.Mail.Timeout) + tlsConfig := &tls.Config{InsecureSkipVerify: true} + tr.TLSClientConfig = tlsConfig + c := *http.DefaultClient + c.Transport = tr + + jmapUsernameProvider := jmap.NewRevaContextHttpJmapUsernameProvider() + + api := jmap.NewHttpJmapApiClient( + baseUrl, + jmapUrl, + &c, + jmapUsernameProvider, + masterUsername, + masterPassword, + ) + + jmapClient := jmap.NewClient(api, api) + + loader := ttlcache.LoaderFunc[string, jmap.Session]( + func(c *ttlcache.Cache[string, jmap.Session], key string) *ttlcache.Item[string, jmap.Session] { + jmapContext, err := jmapClient.FetchSession(key, logger) + if err != nil { + logger.Error().Err(err).Str("username", key).Msg("failed to retrieve well-known") + return nil + } + item := c.Set(key, jmapContext, config.Mail.SessionCacheTTL) + return item + }, + ) + + sessionCache := ttlcache.New( + ttlcache.WithTTL[string, jmap.Session]( + config.Mail.SessionCacheTTL, + ), + ttlcache.WithDisableTouchOnHit[string, jmap.Session](), + ttlcache.WithLoader(loader), + ) + go sessionCache.Start() + + return &Groupware{ + logger: logger, + jmapClient: jmapClient, + sessionCache: sessionCache, + usernameProvider: jmapUsernameProvider, + defaultEmailLimit: defaultEmailLimit, + maxBodyValueBytes: maxBodyValueBytes, + } +} + +func pickInbox(folders []jmap.Mailbox) string { + for _, folder := range folders { + if folder.Role == "inbox" { + return folder.Id + } + } + return "" +} + +func (g Groupware) session(ctx context.Context, logger *log.Logger) (jmap.Session, bool, error) { + username, err := g.usernameProvider.GetUsername(ctx, logger) + if err != nil { + logger.Error().Err(err).Msg("failed to retrieve username") + return jmap.Session{}, false, err + } + + item := g.sessionCache.Get(username) + if item != nil { + return item.Value(), true, nil + } else { + return jmap.Session{}, false, nil + } +} + +func (g Groupware) withSession(w http.ResponseWriter, r *http.Request, handler func(r *http.Request, ctx context.Context, logger log.Logger, session *jmap.Session) (any, error)) { + ctx := r.Context() + logger := g.logger.SubloggerWithRequestID(ctx) + session, ok, err := g.session(ctx, &logger) + if err != nil { + logger.Error().Err(err).Interface(logQuery, r.URL.Query()).Msg("failed to determine JMAP session") + errorcode.ServiceNotAvailable.Render(w, r, http.StatusInternalServerError, err.Error()) + return + } + if !ok { + // no session = authentication failed + logger.Warn().Err(err).Interface(logQuery, r.URL.Query()).Msg("could not authenticate") + errorcode.AccessDenied.Render(w, r, http.StatusForbidden, "failed to authenticate") + return + } + logger = session.DecorateLogger(logger) + + response, err := handler(r, ctx, logger, &session) + if err != nil { + logger.Error().Err(err).Interface(logQuery, r.URL.Query()).Msg(err.Error()) + errorcode.ServiceNotAvailable.Render(w, r, http.StatusInternalServerError, err.Error()) + return + } + + render.Status(r, http.StatusOK) + render.JSON(w, r, response) +} + +func (g Groupware) GetIdentity(w http.ResponseWriter, r *http.Request) { + g.withSession(w, r, func(r *http.Request, ctx context.Context, logger log.Logger, session *jmap.Session) (any, error) { + return g.jmapClient.GetIdentity(session, ctx, &logger) + }) +} + +func (g Groupware) GetVacation(w http.ResponseWriter, r *http.Request) { + g.withSession(w, r, func(r *http.Request, ctx context.Context, logger log.Logger, session *jmap.Session) (any, error) { + return g.jmapClient.GetVacation(session, ctx, &logger) + }) +} + +func (g Groupware) GetMessages(w http.ResponseWriter, r *http.Request) { + g.withSession(w, r, func(r *http.Request, ctx context.Context, logger log.Logger, session *jmap.Session) (any, error) { + offset, ok, _ := parseNumericParam(r, "$skip", 0) + if ok { + logger = log.Logger{Logger: logger.With().Int("$skip", offset).Logger()} + } + limit, ok, _ := parseNumericParam(r, "$top", g.defaultEmailLimit) + if ok { + logger = log.Logger{Logger: logger.With().Int("$top", limit).Logger()} + } + + mailboxGetResponse, err := g.jmapClient.GetMailboxes(session, ctx, &logger) + if err != nil { + return nil, err + } + + inboxId := pickInbox(mailboxGetResponse.List) + // TODO handle not found + logger = log.Logger{Logger: logger.With().Str(logFolderId, inboxId).Logger()} + + emails, err := g.jmapClient.GetEmails(session, ctx, &logger, inboxId, offset, limit, true, g.maxBodyValueBytes) + if err != nil { + return nil, err + } + + messages := make([]Message, 0, len(emails.Emails)) + for _, email := range emails.Emails { + message := message(email, emails.State) + messages = append(messages, message) + } + + odataContext := *r.URL + odataContext.Path = fmt.Sprintf("/graph/v1.0/$metadata#users('%s')/mailFolders('%s')/messages()", session.Username, inboxId) + return Messages{Context: odataContext.String(), Value: messages}, nil + }) +} + +func mapContentType(jmap string) string { + switch jmap { + case "text/html": + return "html" + case "text/plain": + return "text" + default: + return jmap + } +} + +func foldBody(email jmap.Email) *ItemBody { + if email.BodyValues != nil { + if len(email.HtmlBody) > 0 { + pick := email.HtmlBody[0] + content, ok := email.BodyValues[pick.PartId] + if ok { + return &ItemBody{Content: content.Value, ContentType: mapContentType(pick.Type)} + } + } + if len(email.TextBody) > 0 { + pick := email.TextBody[0] + content, ok := email.BodyValues[pick.PartId] + if ok { + return &ItemBody{Content: content.Value, ContentType: mapContentType(pick.Type)} + } + } + } + return nil +} + +func firstOf[T any](ary []T) T { + if len(ary) > 0 { + return ary[0] + } + var nothing T + return nothing +} + +func emailAddress(j jmap.EmailAddress) EmailAddress { + return EmailAddress{Address: j.Email, Name: j.Name} +} + +func emailAddresses(j []jmap.EmailAddress) []EmailAddress { + result := make([]EmailAddress, len(j)) + for i := 0; i < len(j); i++ { + result[i] = emailAddress(j[i]) + } + return result +} + +func hasKeyword(j jmap.Email, kw string) bool { + value, ok := j.Keywords[kw] + if ok { + return value + } + return false +} + +func categories(j jmap.Email) []string { + categories := []string{} + for k, v := range j.Keywords { + if v && !strings.HasPrefix(k, jmap.JmapKeywordPrefix) { + categories = append(categories, k) + } + } + return categories +} + +/* +func toEdmBinary(value int) string { + return fmt.Sprintf("%X", value) +} +*/ + +// https://learn.microsoft.com/en-us/graph/api/resources/message?view=graph-rest-1.0 +func message(email jmap.Email, state string) Message { + body := foldBody(email) + importance := "" // omit "normal" as it is expected to be the default + if hasKeyword(email, jmap.JmapKeywordFlagged) { + importance = "high" + } + + mailboxId := "" + for k, v := range email.MailboxIds { + if v { + // TODO how to map JMAP short identifiers (e.g. 'a') to something uniquely addressable for the clients? + // e.g. do we need to include tenant/sharding/cluster information? + mailboxId = k + break + } + } + + // TODO how to map JMAP short identifiers (e.g. 'a') to something uniquely addressable for the clients? + // e.g. do we need to include tenant/sharding/cluster information? + id := email.Id + // for this one too: + messageId := firstOf(email.MessageId) + // as well as this one: + threadId := email.ThreadId + + categories := categories(email) + + var from *EmailAddress = nil + if len(email.From) > 0 { + e := emailAddress(email.From[0]) + from = &e + } + + // TODO how to map JMAP state to an OData Etag? + etag := state + + weblink, err := url.JoinPath("/groupware/mail", id) + if err != nil { + weblink = "" + } + + return Message{ + Etag: etag, + Id: id, + Subject: email.Subject, + CreatedDateTime: email.ReceivedAt, + ReceivedDateTime: email.ReceivedAt, + SentDateTime: email.SentAt, + HasAttachments: email.HasAttachments, + InternetMessageId: messageId, + BodyPreview: email.Preview, + Body: body, + From: from, + ToRecipients: emailAddresses(email.To), + CcRecipients: emailAddresses(email.Cc), + BccRecipients: emailAddresses(email.Bcc), + ReplyTo: emailAddresses(email.ReplyTo), + IsRead: hasKeyword(email, jmap.JmapKeywordSeen), + IsDraft: hasKeyword(email, jmap.JmapKeywordDraft), + Importance: importance, + ParentFolderId: mailboxId, + Categories: categories, + ConversationId: threadId, + WebLink: weblink, + // ConversationIndex: toEdmBinary(email.ThreadIndex), + } // TODO more email fields +} + +func parseNumericParam(r *http.Request, param string, defaultValue int) (int, bool, error) { + str := r.URL.Query().Get(param) + if str == "" { + return defaultValue, false, nil + } + + value, err := strconv.ParseInt(str, 10, 0) + if err != nil { + return defaultValue, false, nil + } + return int(value), true, nil +} diff --git a/services/graph/pkg/service/v0/groupware/groupware_test.go b/services/graph/pkg/service/v0/groupware/groupware_test.go new file mode 100644 index 0000000000..e6ddcc0b51 --- /dev/null +++ b/services/graph/pkg/service/v0/groupware/groupware_test.go @@ -0,0 +1,51 @@ +package groupware + +import ( + "time" + + g "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/opencloud-eu/opencloud/pkg/jmap" +) + +var _ = g.Describe("Groupware", func() { + + g.Describe("message", func() { + g.It("copies a JMAP Email to a Graph Message correctly", func() { + now := time.Now() + email := jmap.Email{ + Id: "id", + MessageId: []string{"123.456@example.com"}, + BlobId: "918e929e-a296-4078-915a-2c2abc580a8d", + ThreadId: "t", + Size: 12345, + From: []jmap.EmailAddress{ + {Name: "Bobbie Draper", Email: "bobbie@mcrn.mars"}, + }, + To: []jmap.EmailAddress{ + {Name: "Camina Drummer", Email: "camina@opa.org"}, + }, + Subject: "test subject", + HasAttachments: true, + ReceivedAt: now, + Preview: "the preview", + TextBody: []jmap.EmailBodyRef{ + {PartId: "0", Type: "text/plain"}, + }, + BodyValues: map[string]jmap.EmailBody{ + "0": { + IsEncodingProblem: false, + IsTruncated: false, + Value: "the body", + }, + }, + } + + msg := message(email, "aaa") + Expect(msg.Body.ContentType).To(Equal("text/plain")) + Expect(msg.Body.Content).To(Equal("the body")) + Expect(msg.Subject).To(Equal("test subject")) + }) + }) +}) diff --git a/services/graph/pkg/service/v0/messages.go b/services/graph/pkg/service/v0/messages.go index 8a5e6afa81..91ba0d1afc 100644 --- a/services/graph/pkg/service/v0/messages.go +++ b/services/graph/pkg/service/v0/messages.go @@ -10,7 +10,7 @@ import ( "github.com/opencloud-eu/opencloud/services/graph/pkg/errorcode" "github.com/opencloud-eu/opencloud/services/graph/pkg/identity" revactx "github.com/opencloud-eu/reva/v2/pkg/ctx" - libregraph "github.com/owncloud/libre-graph-api-go" + libregraph "github.com/opencloud-eu/libre-graph-api-go" ) // GetMessages implements the Service interface. diff --git a/services/graph/pkg/service/v0/service.go b/services/graph/pkg/service/v0/service.go index 47eb961488..6390b1bf66 100644 --- a/services/graph/pkg/service/v0/service.go +++ b/services/graph/pkg/service/v0/service.go @@ -34,6 +34,7 @@ import ( settingssvc "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/settings/v0" "github.com/opencloud-eu/opencloud/services/graph/pkg/identity" graphm "github.com/opencloud-eu/opencloud/services/graph/pkg/middleware" + "github.com/opencloud-eu/opencloud/services/graph/pkg/service/v0/groupware" "github.com/opencloud-eu/opencloud/services/graph/pkg/unifiedrole" ) @@ -202,7 +203,7 @@ func NewService(opts ...Option) (Graph, error) { //nolint:maintidx natskv: options.NatsKeyValue, } - gw := NewGroupware(&options.Logger, options.Config) + gw := groupware.NewGroupware(&options.Logger, options.Config) if err := setIdentityBackends(options, &svc); err != nil { return svc, err @@ -321,6 +322,8 @@ func NewService(opts ...Option) (Graph, error) { //nolint:maintidx r.Delete("/", usersUserProfilePhotoApi.DeleteProfilePhoto(GetUserIDFromCTX)) }) r.Get("/messages", gw.GetMessages) + r.Get("/identity", gw.GetIdentity) + r.Get("/vacation", gw.GetVacation) }) r.Route("/users", func(r chi.Router) { r.Get("/", svc.GetUsers) diff --git a/tests/groupware/.gitignore b/tests/groupware/.gitignore new file mode 100644 index 0000000000..3340cfac37 --- /dev/null +++ b/tests/groupware/.gitignore @@ -0,0 +1 @@ +/users.csv diff --git a/tests/groupware/go.mod b/tests/groupware/go.mod new file mode 100644 index 0000000000..43a51642e1 --- /dev/null +++ b/tests/groupware/go.mod @@ -0,0 +1,36 @@ +module opencloud.eu/groupware/tests + +go 1.24.2 + +require github.com/go-ldap/ldap/v3 v3.4.11 + +require ( + github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a // indirect + github.com/emersion/go-message v0.18.1 // indirect + github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 // indirect + github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect + github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/olekukonko/tablewriter v0.0.5 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/rivo/uniseg v0.4.4 // indirect + github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect + golang.org/x/net v0.38.0 // indirect + golang.org/x/sys v0.31.0 // indirect + golang.org/x/text v0.24.0 // indirect +) + +require ( + github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect + github.com/dustinkirkland/golang-petname v0.0.0-20240428194347-eebcea082ee0 + github.com/emersion/go-imap/v2 v2.0.0-beta.5 + github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect + github.com/go-faker/faker/v4 v4.6.1 + github.com/google/uuid v1.6.0 // indirect + github.com/jhillyerd/enmime v1.3.0 + github.com/rs/zerolog v1.34.0 + golang.org/x/crypto v0.36.0 // indirect + gopkg.in/loremipsum.v1 v1.1.2 +) diff --git a/tests/groupware/go.sum b/tests/groupware/go.sum new file mode 100644 index 0000000000..d2d837f8be --- /dev/null +++ b/tests/groupware/go.sum @@ -0,0 +1,116 @@ +github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8= +github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= +github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI= +github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= +github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a h1:MISbI8sU/PSK/ztvmWKFcI7UGb5/HQT7B+i3a2myKgI= +github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a/go.mod h1:2GxOXOlEPAMFPfp014mK1SWq8G8BN8o7/dfYqJrVGn8= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustinkirkland/golang-petname v0.0.0-20240428194347-eebcea082ee0 h1:aYo8nnk3ojoQkP5iErif5Xxv0Mo0Ga/FR5+ffl/7+Nk= +github.com/dustinkirkland/golang-petname v0.0.0-20240428194347-eebcea082ee0/go.mod h1:8AuBTZBRSFqEYBPYULd+NN474/zZBLP+6WeT5S9xlAc= +github.com/emersion/go-imap/v2 v2.0.0-beta.5 h1:H3858DNmBuXyMK1++YrQIRdpKE1MwBc+ywBtg3n+0wA= +github.com/emersion/go-imap/v2 v2.0.0-beta.5/go.mod h1:BZTFHsS1hmgBkFlHqbxGLXk2hnRqTItUgwjSSCsYNAk= +github.com/emersion/go-message v0.18.1 h1:tfTxIoXFSFRwWaZsgnqS1DSZuGpYGzSmCZD8SK3QA2E= +github.com/emersion/go-message v0.18.1/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA= +github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 h1:hH4PQfOndHDlpzYfLAAfl63E8Le6F2+EL/cdhlkyRJY= +github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= +github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo= +github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= +github.com/go-faker/faker/v4 v4.6.1 h1:xUyVpAjEtB04l6XFY0V/29oR332rOSPWV4lU8RwDt4k= +github.com/go-faker/faker/v4 v4.6.1/go.mod h1:arSdxNCSt7mOhdk8tEolvHeIJ7eX4OX80wXjKKvkKBY= +github.com/go-ldap/ldap/v3 v3.4.11 h1:4k0Yxweg+a3OyBLjdYn5OKglv18JNvfDykSoI8bW0gU= +github.com/go-ldap/ldap/v3 v3.4.11/go.mod h1:bY7t0FLK8OAVpp/vV6sSlpz3EQDGcQwc8pF0ujLgKvM= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs= +github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 h1:iCHtR9CQyktQ5+f3dMVZfwD2KWJUgm7M0gdL9NGr8KA= +github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk= +github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= +github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= +github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo= +github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= +github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg= +github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo= +github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o= +github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg= +github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8= +github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= +github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= +github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= +github.com/jhillyerd/enmime v1.3.0 h1:LV5kzfLidiOr8qRGIpYYmUZCnhrPbcFAnAFUnWn99rw= +github.com/jhillyerd/enmime v1.3.0/go.mod h1:6c6jg5HdRRV2FtvVL69LjiX1M8oE0xDX9VEhV3oy4gs= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= +github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo= +github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= +golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/loremipsum.v1 v1.1.2 h1:12APklfJKuGszqZsrArW5QoQh03/W+qyCCjvnDuS6Tw= +gopkg.in/loremipsum.v1 v1.1.2/go.mod h1:TuRvzFuzuejXj+odBU6Tubp/EPUyGb9wmSvHenyP2Ts= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/tests/groupware/groupware.ts b/tests/groupware/groupware.ts new file mode 100644 index 0000000000..c697c55574 --- /dev/null +++ b/tests/groupware/groupware.ts @@ -0,0 +1,241 @@ +import { SharedArray } from 'k6/data' +import http from 'k6/http' +import encoding from 'k6/encoding' +import exec from 'k6/execution' + +import { randomItem } from 'https://jslib.k6.io/k6-utils/1.2.0/index.js' +import papaparse from 'https://jslib.k6.io/papaparse/5.1.1/index.js' +import { URL } from 'https://jslib.k6.io/url/1.0.0/index.js' +import { check, fail, group } from 'k6' +import { Counter } from 'k6/metrics' + +export const options = { + noConnectionReuse: true, + noVUConnectionReuse: true, + insecureSkipTLSVerify: true, + scenarios: { + rampup: { + executor: 'ramping-vus', + startVUs: 0, + stages: [ + { target: 50, duration: '30s' }, + { target: 75, duration: '30s' }, + { target: 100, duration: '60s' }, + { target: 50, duration: '20s' }, + ], + gracefulRampDown: '10s', + }, + }, +} + +const TEST_USER_NAMES: string|undefined = __ENV.TEST_USER_NAMES +const TEST_USER_PASSWORD: string = __ENV.TEST_USER_PASSWORD ?? 'demo' +const TEST_USER_DOMAIN: string = __ENV.TEST_USER_DOMAIN ?? 'example.org' +const CLOUD_URL: string = __ENV.CLOUD_URL ?? 'https://cloud.opencloud.test' +const KEYCLOAK_URL: string = __ENV.KEYCLOAK_URL ?? 'https://keycloak.opencloud.test/realms/openCloud' +const KEYCLOAK_CLIENT_ID: string = __ENV.KEYCLOAK_CLIENT_ID ?? 'groupware' +const USERS_FILE: string = __ENV.USERS_FILE ?? 'users.csv' +const JWT_EXPIRATION_THRESHOLD_SECONDS: number = parseInt(__ENV.JWT_EXPIRATION_THRESHOLD_SECONDS ?? '2') + +type JwtHeader = { + alg: string + typ: string + kid: string +} + +type JwtPayload = { + exp: number + iat: number +} + +type Jwt = { + header: JwtHeader + payload: JwtPayload + signature: string +} + +function decodeJwt(token: string): Jwt { + const parts = token.split('.') + const header = JSON.parse(encoding.b64decode(parts[0], 'rawurl', 's')) as JwtHeader + const payload = JSON.parse(encoding.b64decode(parts[1], 'rawurl', 's')) as JwtPayload + const signature = encoding.b64decode(parts[2], 'rawurl', 's') + return {header: header, payload: payload, signature: signature} as Jwt +} + +type User = { + name: string + password: string + mail: string +} + +type Identity = { + id: string + name: string + email: string + replyTo: string | undefined + bcc: string | undefined + textSignature: string | undefined + htmlSignature: string | undefined + mayDelete: boolean +} + +type IdentityGetResponse = { + accountId: string + state: string + list: Identity[] + notFound: string[] | undefined +} + +type VacationResponseGetResponse = { + accountId: string + state: string + notFound: string[] +} + +type EmailAddress = { + name: string | undefined + address: string +} + +type Message = { + '@odata.etag': string + id: string + createdDateTime: string + receivedDateTime: string + sentDateTime: string + internetMessageId: string + subject: string + bodyPreview: string + from: EmailAddress | undefined + toRecipients: EmailAddress[] + ccRecipients: EmailAddress[] + parentFolderId: string + conversationId: string + webLink: string +} + +type Messages = { + '@odata.context': string + value: Message[] +} + +function token(user: User): string { + const res = http.post(`${KEYCLOAK_URL}/protocol/openid-connect/token`, { + client_id: KEYCLOAK_CLIENT_ID, + scope: 'openid', + grant_type: 'password', + username: user.name, + password: user.password, + }) + if (res.status !== 200) { + fail(`failed to retrieve token for ${user.name}: ${res.status} ${res.status_text}`) + } + const accessToken = res.json('access_token')?.toString() + if (accessToken === undefined) { + fail(`access token is empty for ${user.name}`) + } else { + return accessToken + } +} + +function authenticate(user: User): Auth { + const raw = token(user) + const jwt = decodeJwt(raw) + return {raw: raw, jwt: jwt} as Auth +} + +const users: User[] = new SharedArray('users', function () { + if (TEST_USER_NAMES) { + return TEST_USER_NAMES.split(',').map((name) => { return {name: name, password: TEST_USER_PASSWORD, mail: `${name}@${TEST_USER_DOMAIN}`} as User }) + } else { + return papaparse.parse(open(USERS_FILE), { header: true, skipEmptyLines: true, }).data.map((row:object) => row as User) + } +}) + +type Auth = { + raw: string + jwt: Jwt +} + +type TestData = { + auth: object +} + +export function setup(): TestData { + const auth = {} + for (const user of users) { + const a = authenticate(user) + auth[user.name] = a + } + return { + auth: auth, + } as TestData +} + +const stalwartIdRegex = /^[0-9a-z]+$/ + +export default function testSuite(data: TestData) { + const user = randomItem(users) as User + let auth = data.auth[user.name] + + if (auth === undefined) { + fail(`missing authentication for user ${user.name}`) + } + const now = Math.floor(Date.now() / 1000) + if (auth.jwt.payload.exp - now < JWT_EXPIRATION_THRESHOLD_SECONDS) { + exec.test.abort(`token is expired for ${user.name}, need to renew`) + } + + group('retrieve user identity using /me/identity', () => { + const res = http.get(`${CLOUD_URL}/graph/v1.0/me/identity`, {headers: {Authorization: `Bearer ${auth.raw}`}}) + check(res, { + 'is status 200': (r) => r.status === 200, + }); + + const response = res.json() as IdentityGetResponse + check(response, { + 'identity response has an accountId': r => r.accountId !== undefined && stalwartIdRegex.test(r.accountId), + 'identity response has a state': r => r.state !== undefined && stalwartIdRegex.test(r.state), + 'identity response has an empty notFound': r => r.notFound === undefined, + 'identity response has one identity item in its list': r => r.list && r.list.length === 1, + 'identity response has one identity item with an id': r => r.list && r.list.length === 1 && stalwartIdRegex.test(r.list[0].id), + 'identity response has one identity item with a name': r => r.list && r.list.length === 1 && r.list[0].name !== undefined, + 'identity response has one identity item with the expected email': r => r.list && r.list.length === 1 && r.list[0].email === user.mail, + 'identity response has one identity item with mayDelete=true': r => r.list && r.list.length === 1 && r.list[0].mayDelete === true, + 'identity response has one identity item with an empty replyTo': r => r.list && r.list.length === 1 && r.list[0].replyTo === undefined, + 'identity response has one identity item with an empty bcc': r => r.list && r.list.length === 1 && r.list[0].bcc === undefined, + 'identity response has one identity item with an empty textSignature': r => r.list && r.list.length === 1 && r.list[0].textSignature === undefined, + 'identity response has one identity item with an empty htmlSignature': r => r.list && r.list.length === 1 && r.list[0].htmlSignature === undefined, + }) + }) + + group('retrieve user vacationresponse using /me/vacation', () => { + const res = http.get(`${CLOUD_URL}/graph/v1.0/me/vacation`, {headers: {Authorization: `Bearer ${auth.raw}`}}) + check(res, { + 'is status 200': (r) => r.status === 200, + }); + + const response = res.json() as VacationResponseGetResponse + check(response, { + 'vacation response has an accountId': r => r.accountId !== undefined && stalwartIdRegex.test(r.accountId), + 'vacation response has a state': r => r.state !== undefined && stalwartIdRegex.test(r.state), + 'vacation response has a notFound that only contains "singleton"': r => r.notFound && r.notFound.length == 1 && r.notFound[0] == 'singleton', + }) + }) + + group('retrieve user top message using /me/messages', () => { + const url = new URL(`${CLOUD_URL}/graph/v1.0/me/messages`) + url.searchParams.append('$top', '1') + const res = http.get(url.toString(), {headers: {Authorization: `Bearer ${auth.raw}`}}) + check(res, { + 'is status 200': (r) => r.status === 200, + }); + + const response = res.json() as Messages + check(response, { + 'messages has a context': r => r['@odata.context'] !== undefined, + 'messages has a value with a length of 0 or 1': r => r.value !== undefined && (r.value.length === 0 || r.value.length === 1), + 'if there is a message, it has a subject': r => r.value !== undefined && (r.value.length === 0 || r.value[0].subject !== ''), + }) + }) +} diff --git a/tests/groupware/package-lock.json b/tests/groupware/package-lock.json new file mode 100644 index 0000000000..2ad53896e1 --- /dev/null +++ b/tests/groupware/package-lock.json @@ -0,0 +1,23 @@ +{ + "name": "groupware", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "groupware", + "version": "1.0.0", + "license": "ASL", + "devDependencies": { + "@types/k6": "^1.0.2" + } + }, + "node_modules/@types/k6": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@types/k6/-/k6-1.0.2.tgz", + "integrity": "sha512-bpja1c7OXIJk06aPGN+Aw5f3QhP0PjvgX2Fwfa3rJUaUI+O1ZE3491g9hMjhH21ZlTaAhz7h6veyxaz/RqPUTg==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/tests/groupware/package.json b/tests/groupware/package.json new file mode 100644 index 0000000000..a350fe477d --- /dev/null +++ b/tests/groupware/package.json @@ -0,0 +1,16 @@ +{ + "name": "groupware", + "version": "1.0.0", + "main": "groupware.ts", + "type": "module", + "scripts": { + "test": "k6 run groupware.ts" + }, + "keywords": [], + "author": "Pascal Bleser ", + "license": "ASL", + "description": "", + "devDependencies": { + "@types/k6": "^1.0.2" + } +} diff --git a/tests/groupware/setup.go b/tests/groupware/setup.go new file mode 100644 index 0000000000..94cba4aaa4 --- /dev/null +++ b/tests/groupware/setup.go @@ -0,0 +1,568 @@ +package main + +import ( + "bytes" + crand "crypto/rand" + "crypto/sha1" + "crypto/tls" + "encoding/base64" + "encoding/csv" + "encoding/json" + "fmt" + "io" + "math/rand" + "os" + "path" + "regexp" + "slices" + "strconv" + "strings" + "time" + + "net/http" + "net/mail" + "net/url" + + petname "github.com/dustinkirkland/golang-petname" + "github.com/emersion/go-imap/v2" + "github.com/emersion/go-imap/v2/imapclient" + "github.com/go-faker/faker/v4" + "github.com/go-ldap/ldap/v3" + "github.com/jhillyerd/enmime" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "golang.org/x/text/cases" + "golang.org/x/text/language" + "gopkg.in/loremipsum.v1" +) + +var usersToKeep = []string{"lynn", "alan", "mary", "margaret"} + +const displayNameMark = "$generated" + +func enabled(value string) bool { + value = strings.ToLower(value) + return value == "true" || value == "on" || value == "1" +} + +func config(key string, defaultValue string) string { + value, ok := os.LookupEnv(key) + if ok { + return value + } else { + return defaultValue + } +} + +func iconfig(log *zerolog.Logger, key string, defaultValue int) int { + value, ok := os.LookupEnv(key) + if ok { + result, err := strconv.Atoi(value) + if err != nil { + log.Fatal().Msgf("invalid value for %v is not numeric: '%v'", key, value) + panic(err) + } else { + return result + } + } else { + return defaultValue + } +} + +func hashPassword(clear string, saltSize int) string { + salt := make([]byte, saltSize) + crand.Read(salt) + sha := sha1.New() + sha.Write([]byte(clear)) + sha.Write([]byte(salt)) + digest := sha.Sum(nil) + combined := append(digest, salt...) + return "{SSHA}" + base64.StdEncoding.EncodeToString(combined) +} + +const passwordCharset = "abcdefghijklmnopqrstuvwxyz" + "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + "0123456789" + +var seededRand *rand.Rand = rand.New(rand.NewSource(time.Now().UnixNano())) + +func randomPassword() string { + length := 8 + rand.Intn(32) + b := make([]byte, length) + for i := range b { + b[i] = passwordCharset[seededRand.Intn(len(passwordCharset))] + } + return string(b) +} + +func htmlJoin(parts []string) []string { + var result []string + for i := range parts { + result = append(result, fmt.Sprintf("

%v

", parts[i])) + } + return result +} + +var paraSplitter = regexp.MustCompile("[\r\n]+") + +func htmlFormat(body string, msg enmime.MailBuilder) enmime.MailBuilder { + return msg.HTML([]byte(strings.Join(htmlJoin(paraSplitter.Split(body, -1)), "\n"))) +} + +func textFormat(body string, msg enmime.MailBuilder) enmime.MailBuilder { + return msg.Text([]byte(body)) +} + +func bothFormat(body string, msg enmime.MailBuilder) enmime.MailBuilder { + msg = htmlFormat(body, msg) + msg = textFormat(body, msg) + return msg +} + +var formats = []func(string, enmime.MailBuilder) enmime.MailBuilder{ + htmlFormat, + textFormat, + bothFormat, +} + +func fill(i *imapclient.Client, folder string, count int, uid string, clearPassword string, displayName string, domain string, ccEvery int, bccEvery int) { + err := i.Login(uid, clearPassword).Wait() + if err != nil { + panic(err) + } + + selectOptions := &imap.SelectOptions{ReadOnly: false} + _, err = i.Select(folder, selectOptions).Wait() + if err != nil { + panic(err) + } + + toName := displayName + toAddress := fmt.Sprintf("%s@%s", uid, domain) + ccName1 := "Team Lead" + ccAddress1 := fmt.Sprintf("lead@%s", domain) + ccName2 := "Coworker" + ccAddress2 := fmt.Sprintf("coworker@%s", domain) + bccName := "HR" + bccAddress := fmt.Sprintf("corporate@%s", domain) + titler := cases.Title(language.English, cases.NoLower) + + loremIpsumGenerator := loremipsum.New() + for n := range count { + first := petname.Adjective() + last := petname.Adverb() + messageId := fmt.Sprintf("%d.%d@%s", time.Now().Unix(), 1000000+rand.Intn(8999999), domain) + + format := formats[n%len(formats)] + + text := loremIpsumGenerator.Paragraphs(2 + rand.Intn(9)) + from := fmt.Sprintf("%s.%s@%s", strings.ToLower(first), strings.ToLower(last), domain) + sender := fmt.Sprintf("%s %s <%s.%s@%s>", titler.String(first), titler.String(last), strings.ToLower(first), strings.ToLower(last), domain) + + msg := enmime.Builder(). + From(titler.String(first)+" "+titler.String(last), from). + Subject(titler.String(loremIpsumGenerator.Words(3+rand.Intn(7)))). + Header("Message-ID", messageId). + Header("Sender", sender). + To(toName, toAddress) + + if n%ccEvery == 0 { + msg = msg.CCAddrs([]mail.Address{{Name: ccName1, Address: ccAddress1}, {Name: ccName2, Address: ccAddress2}}) + } + if n%bccEvery == 0 { + msg = msg.BCC(bccName, bccAddress) + } + + msg = format(text, msg) + + buf := new(bytes.Buffer) + part, _ := msg.Build() + part.Encode(buf) + mail := buf.String() + + size := int64(len(mail)) + appendCmd := i.Append(folder, size, nil) + if _, err := appendCmd.Write([]byte(mail)); err != nil { + log.Error().Err(err).Str("uid", uid).Msg("imap: failed to append message") + } + if err := appendCmd.Close(); err != nil { + log.Error().Err(err).Str("uid", uid).Msg("imap: failed to close append command") + } + if _, err := appendCmd.Wait(); err != nil { + log.Error().Err(err).Str("uid", uid).Msg("imap: append command failed") + } + } + + if err = i.Logout().Wait(); err != nil { + panic(err) + } +} + +type User struct { + uid string + password string +} + +type PrincipalRoles []string + +func (r PrincipalRoles) MarshalZerologArray(a *zerolog.Array) { + for _, role := range r { + a.Str(role) + } +} + +type Principal struct { + Id int `json:"id,omitempty"` + Type string `json:"type,omitempty"` + Emails []string `json:"emails,omitempty"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Roles PrincipalRoles `json:"roles,omitempty"` + Secrets []string `json:"secrets,omitempty"` +} + +type Principals struct { + Data struct { + Items []Principal `json:"items,omitempty"` + } `json:"data,omitzero"` + Total int `json:"total,omitempty"` +} + +type StalwartOAuthRequest struct { + Type string `json:"type"` + ClientId string `json:"client_id"` + RedirectUri string `json:"redirect_uri"` + Nonce string `json:"nonce"` +} + +func activateUsersInStalwart(_ *zerolog.Logger, baseurl string, users []User) []User { + var h *http.Client + { + tr := &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} + h = &http.Client{Transport: tr} + } + u, err := url.Parse(baseurl) + if err != nil { + panic(err) + } + u.Path = path.Join(u.Path, "api", "oauth") + + activated := []User{} + for _, user := range users { + oauth := StalwartOAuthRequest{Type: "code", ClientId: "groupware", RedirectUri: "stalwart://auth", Nonce: "aaa"} + body, err := json.Marshal(oauth) + if err != nil { + panic(err) + } + req, err := http.NewRequest("POST", u.String(), bytes.NewReader(body)) + if err != nil { + panic(err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.SetBasicAuth(user.uid, user.password) + resp, err := h.Do(req) + if err != nil { + panic(err) + } + defer func(r *http.Response) { + r.Body.Close() + }(resp) + if resp.StatusCode == 200 { + activated = append(activated, user) + } else { + panic(fmt.Errorf("the Stalwart API response is not 200 but %v %v", resp.StatusCode, resp.Status)) + } + _, err = io.ReadAll(resp.Body) + if err != nil { + panic(err) + } + } + return activated +} + +func cleanStalwart(log *zerolog.Logger, baseurl string, adminUsername string, adminPassword string) []Principal { + var h *http.Client + { + tr := &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} + h = &http.Client{Transport: tr} + } + + var principals Principals + { + u, err := url.Parse(baseurl) + if err != nil { + panic(err) + } + u.Path = path.Join(u.Path, "api", "principal") + req, err := http.NewRequest("GET", u.String(), nil) + if err != nil { + panic(err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.SetBasicAuth(adminUsername, adminPassword) + resp, err := h.Do(req) + if err != nil { + panic(err) + } + defer func(r *http.Response) { + r.Body.Close() + }(resp) + if resp.StatusCode != 200 { + panic(fmt.Errorf("the Stalwart API response is not 200 but %v %v", resp.StatusCode, resp.Status)) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + panic(err) + } + err = json.Unmarshal(body, &principals) + if err != nil { + panic(err) + } + } + + deleted := []Principal{} + for _, principal := range principals.Data.Items { + if principal.Type != "individual" { + log.Debug().Str("name", principal.Name).Str("type", principal.Type).Msgf("stalwart: preserving principal: type is not '%v'", "individual") + continue + } + if !slices.Contains(principal.Roles, "user") { + log.Debug().Str("name", principal.Name).Array("roles", principal.Roles).Msgf("stalwart: preserving principal: does not have the role '%v'", "user") + continue + } + if slices.Contains(usersToKeep, principal.Name) { + log.Debug().Str("name", principal.Name).Msg("stalwart: preserving principal: is a user to keep") + continue + } + if !strings.HasSuffix(principal.Description, displayNameMark) { + log.Debug().Str("name", principal.Name).Str("description", principal.Description).Msgf("stalwart: preserving principal: does not have the description suffix '%v'", displayNameMark) + continue + } + log.Debug().Str("name", principal.Name).Msg("stalwart: will delete principal") + + u, err := url.Parse(baseurl) + if err != nil { + panic(err) + } + // the documentation states "principal_id" but it only works with the principal's name attribute + u.Path = path.Join(u.Path, "api", "principal", principal.Name) // strconv.Itoa(principal.Id)) + + req, err := http.NewRequest("DELETE", u.String(), nil) + if err != nil { + panic(err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.SetBasicAuth(adminUsername, adminPassword) + resp, err := h.Do(req) + if err != nil { + panic(err) + } + defer func(r *http.Response) { + r.Body.Close() + }(resp) + if resp.StatusCode != 200 { + panic(fmt.Errorf("the Stalwart API response is not 200 but %v %v", resp.StatusCode, resp.Status)) + } + _, err = io.ReadAll(resp.Body) + if err != nil { + panic(err) + } + deleted = append(deleted, principal) + } + return deleted +} + +func main() { + log := zerolog.New(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.TimeOnly}).With().Timestamp().Logger() + + fillImapInbox := enabled(config("FILL_IMAP", "true")) + imapHost := config("FILL_IMAP_HOST", "localhost:636") + ccEvery := iconfig(&log, "FILL_IMAP_CC_EVERY", 3) + bccEvery := iconfig(&log, "FILL_IMAP_BCC_EVERY", 2) + folder := config("FILL_IMAP_FOLDER", "Inbox") + imapCount := iconfig(&log, "FILL_IMAP_COUNT", 10) + domain := config("DOMAIN", "example.org") + baseDN := config("BASE_DN", "ou=users,dc=opencloud,dc=eu") + ldapUrl := config("LDAP_URL", "ldaps://localhost:636") + bindDN := config("BIND_DN", "cn=admin,dc=opencloud,dc=eu") + bindPassword := config("BIND_PASSWORD", "admin") + userPassword := config("USER_PASSWORD", "") + usersFile := config("USERS_FILE", "") + count := iconfig(&log, "COUNT", 10) + cleanup := enabled(config("CLEANUP", "true")) + cleanupLdap := enabled(config("CLEANUP_LDAP", strconv.FormatBool(cleanup))) + cleanupStalwart := enabled(config("CLEANUP_STALWART", strconv.FormatBool(cleanup))) + stalwartBaseUrl := config("STALWART_URL", "https://stalwart.opencloud.test") + stalwartAdminUser := config("STALWART_ADMIN_USER", "mailadmin") + stalwartAdminPassword := config("STALWART_ADMIN_PASSWORD", "admin") + activateStalwart := enabled(config("ACTIVATE_STALWART", "true")) + saltSize := iconfig(&log, "SALT_SIZE", 16) + + l, err := ldap.DialURL(ldapUrl, ldap.DialWithTLSConfig(&tls.Config{InsecureSkipVerify: true})) + if err != nil { + log.Fatal().Err(err).Str("url", ldapUrl).Msg("failed to connect to LDAP server") + panic(err) + } + err = l.Bind(bindDN, bindPassword) + if err != nil { + log.Fatal().Err(err).Str("url", ldapUrl).Str("bindDN", bindDN).Msg("failed to authenticate to LDAP server") + panic(err) + } + + var i *imapclient.Client + if fillImapInbox { + i, err := imapclient.DialTLS(imapHost, &imapclient.Options{TLSConfig: &tls.Config{InsecureSkipVerify: true}}) + if err != nil { + log.Fatal().Err(err).Str("host", imapHost).Msg("failed to connect to IMAP server") + panic(err) + } + defer func(imap *imapclient.Client) { + err := imap.Close() + if err != nil { + log.Warn().Err(err).Msg("failed to close IMAP connection") + } + }(i) + } else { + i = nil + } + + if cleanupStalwart { + deleted := cleanStalwart(&log, stalwartBaseUrl, stalwartAdminUser, stalwartAdminPassword) + log.Info().Msgf("deleted %v principals from Stalwart", len(deleted)) + } + + if cleanupLdap { + deleted := []string{} + { + llog := log.With().Str("url", ldapUrl).Logger() + llog.Debug().Msg("ldap: cleaning up LDAP") + filter := fmt.Sprintf("(&(objectClass=inetOrgPerson)(description=%v))", ldap.EscapeFilter(displayNameMark)) + existing, err := l.Search(ldap.NewSearchRequest( + baseDN, + ldap.ScopeSingleLevel, + ldap.NeverDerefAliases, + 0, 0, false, + filter, + []string{"uid"}, + []ldap.Control{}, + )) + if err != nil { + llog.Fatal().Err(err).Str("filter", filter).Msg("ldap: failed to perform search query") + panic(err) + } + + for _, entry := range existing.Entries { + uid := entry.GetAttributeValue("uid") + if slices.Contains(usersToKeep, uid) { + llog.Debug().Str("uid", uid).Msg("ldap: preserving user: in list of users to keep") + continue + } + err = l.Del(ldap.NewDelRequest(entry.DN, []ldap.Control{})) + if err != nil { + llog.Fatal().Err(err).Msg("ldap: failed to delete entry") + panic(err) + } + deleted = append(deleted, uid) + llog.Debug().Str("dn", entry.DN).Msg("ldap: deleted user entry") + } + } + log.Info().Msgf("ldap: deleted %v user entries", len(deleted)) + } + + created := []User{} + { + var flog zerolog.Logger + if usersFile != "" { + flog = log.With().Str("filename", usersFile).Logger() + } else { + flog = log + } + llog := log.With().Str("url", ldapUrl).Logger() + + var d io.Writer + { + if usersFile != "" { + f, err := os.OpenFile(usersFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) + if err != nil { + flog.Fatal().Err(err).Msg("failed to open/create users output CSV file") + panic(err) + } + defer f.Close() + d = f + } else { + d = os.Stdout + } + } + w := csv.NewWriter(d) + w.Comma = ';' + w.UseCRLF = false + err = w.Write([]string{"name", "password", "mail"}) + if err != nil { + flog.Fatal().Err(err).Msg("failed to open/create users output CSV file") + panic(err) + } + for range count { + cn := strings.ToLower(faker.Username()) + uid := cn + gn := faker.FirstName() + sn := faker.LastName() + mailAddress := fmt.Sprintf("%s@%s", uid, domain) + dn := fmt.Sprintf("uid=%s,%s", uid, baseDN) + displayName := fmt.Sprintf("%s %s %s", gn, sn, displayNameMark) + description := displayNameMark + var clearPassword string + if userPassword != "" { + clearPassword = userPassword + } else { + clearPassword = randomPassword() + } + hashedPassword := hashPassword(clearPassword, saltSize) + err = l.Add(&ldap.AddRequest{ + DN: dn, + Attributes: []ldap.Attribute{ + {Type: "objectClass", Vals: []string{"inetOrgPerson", "organizationalPerson", "person", "top"}}, + {Type: "cn", Vals: []string{cn}}, + {Type: "sn", Vals: []string{sn}}, + {Type: "givenName", Vals: []string{gn}}, + {Type: "mail", Vals: []string{mailAddress}}, + {Type: "displayName", Vals: []string{displayName}}, + {Type: "description", Vals: []string{description}}, + {Type: "userPassword", Vals: []string{hashedPassword}}, + }, + }) + if err != nil { + llog.Fatal().Err(err).Str("uid", uid).Msg("failed to add entry") + panic(err) + } + err = w.Write([]string{uid, clearPassword, mailAddress}) + if err != nil { + flog.Fatal().Err(err).Str("uid", uid).Msg("failed to write entry to CSV") + panic(err) + } + + if i != nil && imapCount > 0 { + fill(i, folder, imapCount, uid, clearPassword, displayName, domain, ccEvery, bccEvery) + } + created = append(created, User{uid: uid, password: clearPassword}) + } + w.Flush() + if err := w.Error(); err != nil { + flog.Fatal().Err(err).Msg("failed to flush CSV") + panic(err) + } + + { + zev := log.Info() + if usersFile != "" { + zev = zev.Str("filename", usersFile) + } + zev.Msgf("ldap: added %v users", len(created)) + } + } + + if activateStalwart && len(created) > 0 { + activated := activateUsersInStalwart(&log, stalwartBaseUrl, created) + log.Info().Msgf("stalwart: activated %v users", len(activated)) + } +}