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