move services/groupware/pkg/jmap to pkg/jmap

This commit is contained in:
Pascal Bleser
2025-05-05 16:19:43 +02:00
parent b8b1b02564
commit d4cf02b17d
11 changed files with 2 additions and 2 deletions

10
pkg/jmap/email.go Normal file
View File

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

View File

@@ -0,0 +1,148 @@
package jmap
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/opencloud/pkg/version"
)
type HttpJmapUsernameProvider interface {
GetUsername(ctx context.Context, logger *log.Logger) (string, error)
}
type HttpJmapApiClient struct {
baseurl string
jmapurl string
client *http.Client
usernameProvider HttpJmapUsernameProvider
masterUser string
masterPassword string
}
/*
func bearer(req *http.Request, token string) {
req.Header.Add("Authorization", "Bearer "+base64.StdEncoding.EncodeToString([]byte(token)))
}
*/
func NewHttpJmapApiClient(baseurl string, jmapurl string, client *http.Client, usernameProvider HttpJmapUsernameProvider, masterUser string, masterPassword string) *HttpJmapApiClient {
return &HttpJmapApiClient{
baseurl: baseurl,
jmapurl: jmapurl,
client: client,
usernameProvider: usernameProvider,
masterUser: masterUser,
masterPassword: masterPassword,
}
}
func (h *HttpJmapApiClient) auth(logger *log.Logger, ctx context.Context, req *http.Request) error {
username, err := h.usernameProvider.GetUsername(ctx, logger)
if err != nil {
logger.Error().Err(err).Msg("failed to find username")
}
masterUsername := username + "%" + h.masterUser
req.SetBasicAuth(masterUsername, h.masterPassword)
return nil
}
func (h *HttpJmapApiClient) authWithUsername(logger *log.Logger, username string, req *http.Request) error {
masterUsername := username + "%" + h.masterUser
req.SetBasicAuth(masterUsername, h.masterPassword)
return nil
}
func (h *HttpJmapApiClient) GetWellKnown(username string, logger *log.Logger) (WellKnownJmap, 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
}
h.authWithUsername(logger, username, req)
res, err := h.client.Do(req)
if err != nil {
logger.Error().Err(err).Msgf("failed to perform GET %v", wellKnownUrl)
return WellKnownJmap{}, 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)
}
if res.Body != nil {
defer func(Body io.ReadCloser) {
err := Body.Close()
if err != nil {
logger.Error().Err(err).Msg("failed to close response body")
}
}(res.Body)
}
body, err := io.ReadAll(res.Body)
if err != nil {
logger.Error().Err(err).Msg("failed to read response body")
return WellKnownJmap{}, err
}
var data WellKnownJmap
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 data, nil
}
func (h *HttpJmapApiClient) Command(ctx context.Context, logger *log.Logger, request map[string]any) ([]byte, error) {
jmapUrl := h.jmapurl
bodyBytes, marshalErr := json.Marshal(request)
if marshalErr != nil {
logger.Error().Err(marshalErr).Msg("failed to marshall JSON payload")
return nil, marshalErr
}
req, reqErr := http.NewRequestWithContext(ctx, http.MethodPost, jmapUrl, bytes.NewBuffer(bodyBytes))
if reqErr != nil {
logger.Error().Err(reqErr).Msgf("failed to create GET request for %v", jmapUrl)
return nil, reqErr
}
req.Header.Add("Content-Type", "application/json")
req.Header.Add("User-Agent", "OpenCloud/"+version.GetString())
h.auth(logger, ctx, req)
res, err := h.client.Do(req)
if err != nil {
logger.Error().Err(err).Msgf("failed to perform GET %v", jmapUrl)
return nil, err
}
if res.StatusCode < 200 || res.StatusCode > 299 {
logger.Error().Str("status", res.Status).Msg("HTTP response status code is not 2xx")
return nil, fmt.Errorf("HTTP response status is %v", res.Status)
}
if res.Body != nil {
defer func(Body io.ReadCloser) {
err := Body.Close()
if err != nil {
logger.Error().Err(err).Msg("failed to close response body")
}
}(res.Body)
}
body, err := io.ReadAll(res.Body)
if err != nil {
logger.Error().Err(err).Msg("failed to read response body")
return nil, err
}
return body, nil
}

190
pkg/jmap/jmap.go Normal file
View File

@@ -0,0 +1,190 @@
package jmap
import (
"context"
"encoding/json"
"fmt"
"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
}
func NewJmapClient(wellKnown JmapWellKnownClient, api JmapApiClient) JmapClient {
return JmapClient{
wellKnown: wellKnown,
api: api,
}
}
type JmapContext struct {
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
)
func (j *JmapClient) validate(jmapContext JmapContext) error {
if jmapContext.AccountId == "" {
return fmt.Errorf("AccountId not set")
}
return nil
}
func (j *JmapClient) GetMailboxes(jc JmapContext, ctx context.Context, logger *log.Logger) (JmapFolders, error) {
if err := j.validate(jc); err != nil {
return JmapFolders{}, err
}
logger.Info().Str("command", "Mailbox/get").Str("accountId", jc.AccountId).Msg("GetMailboxes")
cmd := simpleCommand("Mailbox/get", map[string]any{"accountId": jc.AccountId})
commandCtx := context.WithValue(ctx, ContextOperationId, "GetMailboxes")
return command(j.api, logger, commandCtx, &cmd, func(body *[]byte) (JmapFolders, 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
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
}
cmd := make([][]any, 4)
cmd[0] = []any{
"Email/query",
map[string]any{
"accountId": jc.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": jc.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": jc.AccountId,
"#ids": map[string]any{
"resultOf": "1",
"name": "Email/get",
"path": "/list/*/threadId",
},
},
"2",
}
cmd[3] = []any{
"Email/get",
map[string]any{
"accountId": jc.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, "EmailQuery")
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))
if err != nil {
return Emails{}, err
}
emails = append(emails, email)
}
return Emails{Emails: emails, State: data.SessionState}, nil
})
}

View File

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

30
pkg/jmap/jmap_model.go Normal file
View File

@@ -0,0 +1,30 @@
package jmap
type WellKnownJmap struct {
ApiUrl string `json:"apiUrl"`
PrimaryAccounts map[string]string `json:"primaryAccounts"`
}
type JmapFolder struct {
Id string
Name string
Role string
TotalEmails int
UnreadEmails int
TotalThreads int
UnreadThreads int
}
type JmapFolders struct {
Folders []JmapFolder
state string
}
type JmapCommandResponse struct {
MethodResponses [][]any `json:"methodResponses"`
SessionState string `json:"sessionState"`
}
type Emails struct {
Emails []Email
State string
}

293
pkg/jmap/jmap_test.go Normal file
View File

@@ -0,0 +1,293 @@
package jmap
import (
"context"
"fmt"
"math/rand"
"testing"
"time"
"github.com/opencloud-eu/opencloud/pkg/log"
"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"
}`
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 {
return &TestJmapWellKnownClient{t: t}
}
func (t *TestJmapWellKnownClient) GetWellKnown(username string, logger *log.Logger) (WellKnownJmap, error) {
return WellKnownJmap{
ApiUrl: "test://",
PrimaryAccounts: map[string]string{JmapMail: generateRandomString(2 + seededRand.Intn(10))},
}, nil
}
type TestJmapApiClient struct {
t *testing.T
}
func NewTestJmapApiClient(t *testing.T) JmapApiClient {
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)
switch command {
case "Mailbox/get":
return []byte(mailboxes), nil
case "Email/query":
return []byte(mails1), nil
default:
require.Fail(t.t, "unsupported jmap command: %v", command)
return nil, fmt.Errorf("unsupported jmap command: %v", command)
}
}
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
var seededRand *rand.Rand = rand.New(rand.NewSource(time.Now().UnixNano()))
func generateRandomString(length int) string {
b := make([]byte, length)
for i := range b {
b[i] = charset[seededRand.Intn(len(charset))]
}
return string(b)
}
func TestRequests(t *testing.T) {
require := require.New(t)
apiClient := NewTestJmapApiClient(t)
wkClient := NewTestJmapWellKnownClient(t)
logger := log.NopLogger()
ctx := context.Background()
client := NewJmapClient(wkClient, apiClient)
jc := JmapContext{AccountId: "123", JmapUrl: "test://"}
folders, err := client.GetMailboxes(jc, ctx, &logger)
require.NoError(err)
require.Len(folders.Folders, 5)
emails, err := client.EmailQuery(jc, ctx, &logger, "Inbox")
require.NoError(err)
require.Len(emails.Emails, 1)
email := emails.Emails[0]
require.Equal("eros auctor proin", email.Subject)
require.Equal(false, email.HasAttachments)
}

118
pkg/jmap/jmap_tools.go Normal file
View File

@@ -0,0 +1,118 @@
package jmap
import (
"context"
"time"
"github.com/opencloud-eu/opencloud/pkg/log"
)
func command[T any](api JmapApiClient,
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,
}
/*
{
"using":[
"urn:ietf:params:jmap:core",
"urn:ietf:params:jmap:mail"
],
"methodCalls":[
[
"Identity/get", {
"accountId": "cp"
}, "0"
]
]
}
*/
responseBody, err := api.Command(ctx, logger, body)
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) (JmapFolders, 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 JmapFolders{Folders: folders, state: state}, nil
}
func mapEmail(elem map[string]any) (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)
if err != nil {
return Email{}, err
}
return Email{
From: from["email"].(string),
Subject: subject,
HasAttachments: hasAttachments,
Received: received,
}, nil
}
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
}
}
return nil
}

View File

@@ -0,0 +1,9 @@
package jmap
import (
"github.com/opencloud-eu/opencloud/pkg/log"
)
type JmapWellKnownClient interface {
GetWellKnown(username string, logger *log.Logger) (WellKnownJmap, error)
}

View File

@@ -0,0 +1,25 @@
package jmap
import (
"context"
"fmt"
"github.com/opencloud-eu/opencloud/pkg/log"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
)
type RevaContextHttpJmapUsernameProvider struct {
}
func NewRevaContextHttpJmapUsernameProvider() RevaContextHttpJmapUsernameProvider {
return RevaContextHttpJmapUsernameProvider{}
}
func (r RevaContextHttpJmapUsernameProvider) GetUsername(ctx context.Context, logger *log.Logger) (string, error) {
u, ok := revactx.ContextGetUser(ctx)
if !ok {
logger.Error().Msg("could not get user: user not in context")
return "", fmt.Errorf("user not in context")
}
return u.GetUsername(), nil
}