groupware and jmap improvements and refactoring

This commit is contained in:
Pascal Bleser
2025-05-30 15:28:32 +02:00
parent 1669601d99
commit 1ca1485286
14 changed files with 912 additions and 180 deletions

View File

@@ -1,10 +0,0 @@
package jmap
import "time"
type Email struct {
From string
Subject string
HasAttachments bool
Received time.Time
}

View File

@@ -58,24 +58,25 @@ func (h *HttpJmapApiClient) authWithUsername(logger *log.Logger, username string
return nil
}
func (h *HttpJmapApiClient) GetWellKnown(username string, logger *log.Logger) (WellKnownJmap, error) {
func (h *HttpJmapApiClient) GetWellKnown(username string, logger *log.Logger) (WellKnownResponse, error) {
wellKnownUrl := h.baseurl + "/.well-known/jmap"
req, err := http.NewRequest(http.MethodGet, wellKnownUrl, nil)
if err != nil {
logger.Error().Err(err).Msgf("failed to create GET request for %v", wellKnownUrl)
return WellKnownJmap{}, err
return WellKnownResponse{}, err
}
h.authWithUsername(logger, username, req)
req.Header.Add("Cache-Control", "no-cache, no-store, must-revalidate") // spec recommendation
res, err := h.client.Do(req)
if err != nil {
logger.Error().Err(err).Msgf("failed to perform GET %v", wellKnownUrl)
return WellKnownJmap{}, err
return WellKnownResponse{}, err
}
if res.StatusCode != 200 {
logger.Error().Str("status", res.Status).Msg("HTTP response status code is not 200")
return WellKnownJmap{}, fmt.Errorf("HTTP response status is %v", res.Status)
return WellKnownResponse{}, fmt.Errorf("HTTP response status is %v", res.Status)
}
if res.Body != nil {
defer func(Body io.ReadCloser) {
@@ -89,14 +90,14 @@ func (h *HttpJmapApiClient) GetWellKnown(username string, logger *log.Logger) (W
body, err := io.ReadAll(res.Body)
if err != nil {
logger.Error().Err(err).Msg("failed to read response body")
return WellKnownJmap{}, err
return WellKnownResponse{}, err
}
var data WellKnownJmap
var data WellKnownResponse
err = json.Unmarshal(body, &data)
if err != nil {
logger.Error().Str("url", wellKnownUrl).Err(err).Msg("failed to decode JSON payload from .well-known/jmap response")
return WellKnownJmap{}, err
return WellKnownResponse{}, err
}
return data, nil

View File

@@ -8,88 +8,172 @@ import (
"github.com/opencloud-eu/opencloud/pkg/log"
)
const (
JmapCore = "urn:ietf:params:jmap:core"
JmapMail = "urn:ietf:params:jmap:mail"
)
type JmapClient struct {
wellKnown JmapWellKnownClient
api JmapApiClient
type Client struct {
wellKnown WellKnownClient
api ApiClient
}
func NewJmapClient(wellKnown JmapWellKnownClient, api JmapApiClient) JmapClient {
return JmapClient{
func NewClient(wellKnown WellKnownClient, api ApiClient) Client {
return Client{
wellKnown: wellKnown,
api: api,
}
}
type JmapContext struct {
type Session struct {
Username string
AccountId string
JmapUrl string
}
func NewJmapContext(wellKnown WellKnownJmap) (JmapContext, error) {
// TODO validate
return JmapContext{
AccountId: wellKnown.PrimaryAccounts[JmapMail],
JmapUrl: wellKnown.ApiUrl,
}, nil
}
func (j *JmapClient) FetchJmapContext(username string, logger *log.Logger) (JmapContext, error) {
wk, err := j.wellKnown.GetWellKnown(username, logger)
if err != nil {
return JmapContext{}, err
}
return NewJmapContext(wk)
}
type ContextKey int
const (
ContextAccountId ContextKey = iota
ContextOperationId
ContextUsername
)
func (j *JmapClient) validate(jmapContext JmapContext) error {
if jmapContext.AccountId == "" {
return fmt.Errorf("AccountId not set")
}
return nil
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
}
func (j *JmapClient) GetMailboxes(jc JmapContext, ctx context.Context, logger *log.Logger) (JmapFolders, error) {
if err := j.validate(jc); err != nil {
return JmapFolders{}, err
}
const (
logUsername = "username"
logAccountId = "account-id"
)
logger.Info().Str("command", "Mailbox/get").Str("accountId", jc.AccountId).Msg("GetMailboxes")
cmd := simpleCommand("Mailbox/get", map[string]any{"accountId": jc.AccountId})
func (s Session) DecorateLogger(l log.Logger) log.Logger {
return log.Logger{
Logger: l.With().Str(logUsername, s.Username).Str(logAccountId, s.AccountId).Logger(),
}
}
func NewSession(wellKnownResponse WellKnownResponse) (Session, error) {
username := wellKnownResponse.Username
if username == "" {
return Session{}, fmt.Errorf("well-known response has no username")
}
accountId := wellKnownResponse.PrimaryAccounts[JmapMail]
if accountId == "" {
return Session{}, fmt.Errorf("PrimaryAccounts in well-known response has no entry for %v", JmapMail)
}
apiUrl := wellKnownResponse.ApiUrl
if apiUrl == "" {
return Session{}, fmt.Errorf("well-known response has no API URL")
}
return Session{
Username: username,
AccountId: accountId,
JmapUrl: apiUrl,
}, nil
}
func (j *Client) FetchSession(username string, logger *log.Logger) (Session, error) {
wk, err := j.wellKnown.GetWellKnown(username, logger)
if err != nil {
return 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) (JmapFolders, error) {
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 JmapFolders
var zero Folders
return zero, err
}
return parseMailboxGetResponse(data)
})
}
func (j *JmapClient) EmailQuery(jc JmapContext, ctx context.Context, logger *log.Logger, mailboxId string) (Emails, error) {
if err := j.validate(jc); err != nil {
return Emails{}, 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",
}
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",
}
commandCtx := context.WithValue(ctx, ContextOperationId, "GetEmails")
logger = &log.Logger{Logger: logger.With().Str("mailboxId", mailboxId).Bool("fetchBodies", fetchBodies).Int("offset", offset).Int("limit", limit).Logger()}
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", "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": jc.AccountId,
"accountId": session.AccountId,
"filter": map[string]any{
"inMailbox": mailboxId,
},
@@ -109,7 +193,7 @@ func (j *JmapClient) EmailQuery(jc JmapContext, ctx context.Context, logger *log
cmd[1] = []any{
"Email/get",
map[string]any{
"accountId": jc.AccountId,
"accountId": session.AccountId,
"#ids": map[string]any{
"resultOf": "0",
"name": "Email/query",
@@ -122,7 +206,7 @@ func (j *JmapClient) EmailQuery(jc JmapContext, ctx context.Context, logger *log
cmd[2] = []any{
"Thread/get",
map[string]any{
"accountId": jc.AccountId,
"accountId": session.AccountId,
"#ids": map[string]any{
"resultOf": "1",
"name": "Email/get",
@@ -134,7 +218,7 @@ func (j *JmapClient) EmailQuery(jc JmapContext, ctx context.Context, logger *log
cmd[3] = []any{
"Email/get",
map[string]any{
"accountId": jc.AccountId,
"accountId": session.AccountId,
"#ids": map[string]any{
"resultOf": "2",
"name": "Thread/get",
@@ -155,7 +239,7 @@ func (j *JmapClient) EmailQuery(jc JmapContext, ctx context.Context, logger *log
"3",
}
commandCtx := context.WithValue(ctx, ContextOperationId, "EmailQuery")
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)
@@ -179,7 +263,7 @@ func (j *JmapClient) EmailQuery(jc JmapContext, ctx context.Context, logger *log
emails := make([]Email, 0, len(list))
for _, elem := range list {
email, err := mapEmail(elem.(map[string]any))
email, err := mapEmail(elem.(map[string]any), false, logger)
if err != nil {
return Emails{}, err
}

View File

@@ -6,6 +6,6 @@ import (
"github.com/opencloud-eu/opencloud/pkg/log"
)
type JmapApiClient interface {
type ApiClient interface {
Command(ctx context.Context, logger *log.Logger, request map[string]any) ([]byte, error)
}

View File

@@ -1,6 +1,19 @@
package jmap
type WellKnownJmap struct {
import (
"encoding/json"
"fmt"
"strconv"
"time"
)
const (
JmapCore = "urn:ietf:params:jmap:core"
JmapMail = "urn:ietf:params:jmap:mail"
)
type WellKnownResponse struct {
Username string `json:"username"`
ApiUrl string `json:"apiUrl"`
PrimaryAccounts map[string]string `json:"primaryAccounts"`
}
@@ -14,7 +27,7 @@ type JmapFolder struct {
TotalThreads int
UnreadThreads int
}
type JmapFolders struct {
type Folders struct {
Folders []JmapFolder
state string
}
@@ -24,7 +37,130 @@ type JmapCommandResponse struct {
SessionState string `json:"sessionState"`
}
type Email struct {
Id string
MessageId string
BlobId string
ThreadId string
Size int
From string
Subject string
HasAttachments bool
Received time.Time
Preview string
Bodies map[string]string
}
type Emails struct {
Emails []Email
State string
}
type Command string
const (
EmailGet Command = "Email/get"
EmailQuery Command = "Email/query"
ThreadGet Command = "Thread/get"
)
type Invocation struct {
Command Command
Parameters any
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 {
return Invocation{
Command: command,
Parameters: parameters,
Tag: tag,
}
}
type Request struct {
Using []string `json:"using"`
MethodCalls []Invocation `json:"methodCalls"`
CreatedIds map[string]string `json:"createdIds,omitempty"`
}
func NewRequest(methodCalls ...Invocation) (Request, error) {
return Request{
Using: []string{JmapCore, JmapMail},
MethodCalls: methodCalls,
CreatedIds: nil,
}, nil
}
// TODO: NewRequestWithIds
type Response struct {
MethodResponses []Invocation `json:"methodResponses"`
CreatedIds map[string]string `json:"createdIds,omitempty"`
SessionState string `json:"sessionState"`
}
type EmailQueryResponse struct {
AccountId string `json:"accountId"`
QueryState string `json:"queryState"`
CanCalculateChanges bool `json:"canCalculateChanges"`
Position int `json:"position"`
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"`
}

117
pkg/jmap/jmap_model_test.go Normal file
View File

@@ -0,0 +1,117 @@
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)
}

View File

@@ -165,71 +165,17 @@ const mailboxes = `{"methodResponses": [
},"0"]
], "sessionState":"3e25b2a0"
}`
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"
}`
type TestJmapWellKnownClient struct {
t *testing.T
}
func NewTestJmapWellKnownClient(t *testing.T) JmapWellKnownClient {
func NewTestJmapWellKnownClient(t *testing.T) WellKnownClient {
return &TestJmapWellKnownClient{t: t}
}
func (t *TestJmapWellKnownClient) GetWellKnown(username string, logger *log.Logger) (WellKnownJmap, error) {
return WellKnownJmap{
func (t *TestJmapWellKnownClient) GetWellKnown(username string, logger *log.Logger) (WellKnownResponse, error) {
return WellKnownResponse{
ApiUrl: "test://",
PrimaryAccounts: map[string]string{JmapMail: generateRandomString(2 + seededRand.Intn(10))},
}, nil
@@ -239,7 +185,7 @@ type TestJmapApiClient struct {
t *testing.T
}
func NewTestJmapApiClient(t *testing.T) JmapApiClient {
func NewTestJmapApiClient(t *testing.T) ApiClient {
return &TestJmapApiClient{t: t}
}
@@ -275,15 +221,15 @@ func TestRequests(t *testing.T) {
wkClient := NewTestJmapWellKnownClient(t)
logger := log.NopLogger()
ctx := context.Background()
client := NewJmapClient(wkClient, apiClient)
client := NewClient(wkClient, apiClient)
jc := JmapContext{AccountId: "123", JmapUrl: "test://"}
session := Session{AccountId: "123", JmapUrl: "test://"}
folders, err := client.GetMailboxes(jc, ctx, &logger)
folders, err := client.GetMailboxes(session, ctx, &logger)
require.NoError(err)
require.Len(folders.Folders, 5)
emails, err := client.EmailQuery(jc, ctx, &logger, "Inbox")
emails, err := client.EmailThreadsQuery(session, ctx, &logger, "Inbox")
require.NoError(err)
require.Len(emails.Emails, 1)

View File

@@ -7,7 +7,7 @@ import (
"github.com/opencloud-eu/opencloud/pkg/log"
)
func command[T any](api JmapApiClient,
func command[T any](api ApiClient,
logger *log.Logger,
ctx context.Context,
methodCalls *[][]any,
@@ -62,7 +62,7 @@ func mapFolder(item map[string]any) JmapFolder {
}
}
func parseMailboxGetResponse(data JmapCommandResponse) (JmapFolders, error) {
func parseMailboxGetResponse(data JmapCommandResponse) (Folders, error) {
first := data.MethodResponses[0]
params := first[1]
payload := params.(map[string]any)
@@ -74,10 +74,20 @@ func parseMailboxGetResponse(data JmapCommandResponse) (JmapFolders, error) {
folder := mapFolder(item)
folders = append(folders, folder)
}
return JmapFolders{Folders: folders, state: state}, nil
return Folders{Folders: folders, state: state}, nil
}
func mapEmail(elem map[string]any) (Email, error) {
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
@@ -100,11 +110,70 @@ func mapEmail(elem map[string]any) (Email, error) {
return Email{}, 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
}

295
pkg/jmap/jmap_tools_test.go Normal file
View File

File diff suppressed because one or more lines are too long

View File

@@ -4,6 +4,6 @@ import (
"github.com/opencloud-eu/opencloud/pkg/log"
)
type JmapWellKnownClient interface {
GetWellKnown(username string, logger *log.Logger) (WellKnownJmap, error)
type WellKnownClient interface {
GetWellKnown(username string, logger *log.Logger) (WellKnownResponse, error)
}

View File

@@ -186,9 +186,11 @@ type MasterAuth struct {
}
type Mail struct {
Master MasterAuth `yaml:"master"`
BaseUrl string `yaml:"base_url" env:"OC_JMAP_BASE_URL;GROUPWARE_BASE_URL"`
JmapUrl string `yaml:"jmap_url" env:"OC_JMAP_JMAP_URL;GROUPWARE_JMAP_URL"`
Timeout time.Duration `yaml:"timeout" env:"OC_JMAP_TIMEOUT"`
ContextCacheTTL time.Duration `yaml:"context_cache_ttl" env:"OC_JMAP_CONTEXT_CACHE_TTL"`
Master MasterAuth `yaml:"master"`
BaseUrl string `yaml:"base_url" env:"GROUPWARE_BASE_URL"`
JmapUrl string `yaml:"jmap_url" env:"GROUPWARE_JMAP_URL"`
Timeout time.Duration `yaml:"timeout" env:"GROUPWARE_JMAP_TIMEOUT"`
SessionCacheTTL time.Duration `yaml:"session_cache_ttl" env:"GROUPWARE_SESSION_CACHE_TTL"`
DefaultEmailLimit int `yaml:"default_email_limit" env:"GROUPWARE_JMAP_DEFAULT_EMAIL_LIMIT"`
MaxBodyValueBytes int `yaml:"max_body_value_bytes" env:"GROUPWARE_JMAP_MAx_BODY_VALUE_BYTES"`
}

View File

@@ -140,10 +140,12 @@ func DefaultConfig() *config.Config {
Username: "master",
Password: "admin",
},
BaseUrl: "https://stalwart.opencloud.test",
JmapUrl: "https://stalwart.opencloud.test/jmap",
Timeout: time.Duration(3 * time.Second),
ContextCacheTTL: time.Duration(1 * time.Hour),
BaseUrl: "https://stalwart.opencloud.test",
JmapUrl: "https://stalwart.opencloud.test/jmap",
Timeout: time.Duration(3 * time.Second),
SessionCacheTTL: time.Duration(1 * time.Hour),
DefaultEmailLimit: 100,
MaxBodyValueBytes: 0,
},
}
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"crypto/tls"
"net/http"
"strconv"
"time"
"github.com/opencloud-eu/opencloud/pkg/jmap"
@@ -16,11 +17,23 @@ import (
"github.com/jellydator/ttlcache/v3"
)
const (
logFolderId = "folder-id"
logQuery = "query"
)
type Groupware struct {
logger *log.Logger
jmapClient jmap.JmapClient
contextCache *ttlcache.Cache[string, jmap.JmapContext]
usernameProvider jmap.HttpJmapUsernameProvider // we also need it for ourselves for now
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 {
@@ -30,6 +43,8 @@ type Message struct {
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 {
@@ -37,6 +52,8 @@ func NewGroupware(logger *log.Logger, config *config.Config) *Groupware {
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)
@@ -56,38 +73,40 @@ func NewGroupware(logger *log.Logger, config *config.Config) *Groupware {
masterPassword,
)
jmapClient := jmap.NewJmapClient(api, api)
jmapClient := jmap.NewClient(api, api)
loader := ttlcache.LoaderFunc[string, jmap.JmapContext](
func(c *ttlcache.Cache[string, jmap.JmapContext], key string) *ttlcache.Item[string, jmap.JmapContext] {
jmapContext, err := jmapClient.FetchJmapContext(key, logger)
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.ContextCacheTTL)
item := c.Set(key, jmapContext, config.Mail.SessionCacheTTL)
return item
},
)
contextCache := ttlcache.New(
ttlcache.WithTTL[string, jmap.JmapContext](
config.Mail.ContextCacheTTL,
sessionCache := ttlcache.New(
ttlcache.WithTTL[string, jmap.Session](
config.Mail.SessionCacheTTL,
),
ttlcache.WithDisableTouchOnHit[string, jmap.JmapContext](),
ttlcache.WithDisableTouchOnHit[string, jmap.Session](),
ttlcache.WithLoader(loader),
)
go contextCache.Start()
go sessionCache.Start()
return &Groupware{
logger: logger,
jmapClient: jmapClient,
contextCache: contextCache,
usernameProvider: jmapUsernameProvider,
logger: logger,
jmapClient: jmapClient,
sessionCache: sessionCache,
usernameProvider: jmapUsernameProvider,
defaultEmailLimit: defaultEmailLimit,
maxBodyValueBytes: maxBodyValueBytes,
}
}
func pickInbox(folders jmap.JmapFolders) string {
func pickInbox(folders jmap.Folders) string {
for _, folder := range folders.Folders {
if folder.Role == "inbox" {
return folder.Id
@@ -96,14 +115,14 @@ func pickInbox(folders jmap.JmapFolders) string {
return ""
}
func (g Groupware) context(ctx context.Context, logger *log.Logger) (jmap.JmapContext, error) {
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.JmapContext{}, err
return jmap.Session{}, err
}
item := g.contextCache.Get(username)
item := g.sessionCache.Get(username)
return item.Value(), nil
}
@@ -112,38 +131,109 @@ func (g Groupware) GetMessages(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(ctx)
jmapContext, err := g.context(ctx, &logger)
session, err := g.session(ctx, &logger)
if err != nil {
logger.Error().Err(err).Interface("query", r.URL.Query()).Msg("failed to determine Jmap context")
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 = context.WithValue(ctx, jmap.ContextAccountId, jmapContext.AccountId)
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(jmapContext, ctx, &logger)
folders, err := g.jmapClient.GetMailboxes(session, ctx, &logger)
if err != nil {
logger.Error().Err(err).Interface("query", r.URL.Query()).Msg("could not retrieve mailboxes")
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)
logger.Debug().Str("mailboxId", inboxId).Msg("fetching emails from inbox")
emails, err := g.jmapClient.EmailQuery(jmapContext, ctx, &logger, inboxId)
// 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("query", r.URL.Query()).Msg("could not retrieve emails from inbox")
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{Id: "todo", Subject: email.Subject} // TODO more email fields
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:
logger.Info().Msgf("zero bodies: %v", email)
case 1:
logger.Info().Msg("1 body")
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"}
logger.Info().Msgf("%v bodies: picked text/html", len(email.Bodies))
} else {
content, ok = email.Bodies["text/plain"]
if ok {
body = ItemBody{Content: content, ContentType: "text/plain"}
logger.Info().Msgf("%v bodies: picked text/plain", len(email.Bodies))
} else {
logger.Info().Msgf("%v bodies: neither text/html nor text/plain", len(email.Bodies))
for mime, content := range email.Bodies {
body = ItemBody{Content: content, ContentType: mime}
logger.Info().Msgf("%v bodies: picked first: %v", len(email.Bodies), 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
}

View File

@@ -52,7 +52,7 @@ func NewService(opts ...Option) Service {
}
type Groupware struct {
jmapClient jmap.JmapClient
jmapClient jmap.Client
usernameProvider jmap.HttpJmapUsernameProvider
config *config.Config
logger *log.Logger
@@ -62,7 +62,7 @@ type Groupware struct {
func NewGroupware(config *config.Config, logger *log.Logger, mux *chi.Mux) *Groupware {
usernameProvider := jmap.NewRevaContextHttpJmapUsernameProvider()
httpApiClient := httpApiClient(config, usernameProvider)
jmapClient := jmap.NewJmapClient(httpApiClient, httpApiClient)
jmapClient := jmap.NewClient(httpApiClient, httpApiClient)
return &Groupware{
jmapClient: jmapClient,
usernameProvider: usernameProvider,
@@ -115,7 +115,7 @@ func (g Groupware) WellDefined(w http.ResponseWriter, r *http.Request) {
return
}
jmapContext, err := g.jmapClient.FetchJmapContext(username, &logger)
jmapContext, err := g.jmapClient.FetchSession(username, &logger)
if err != nil {
return
}