mirror of
https://github.com/opencloud-eu/opencloud.git
synced 2026-01-03 19:00:05 -06:00
WIP: restructure the Jmap client, and implement the /me/messages Graph API endpoint with it
This commit is contained in:
@@ -36,6 +36,8 @@ type Config struct {
|
||||
Keycloak Keycloak `yaml:"keycloak"`
|
||||
ServiceAccount ServiceAccount `yaml:"service_account"`
|
||||
|
||||
Mail Mail `yaml:"mail"`
|
||||
|
||||
Context context.Context `yaml:"-"`
|
||||
|
||||
Metadata Metadata `yaml:"metadata_config"`
|
||||
@@ -177,3 +179,16 @@ type Store struct {
|
||||
AuthUsername string `yaml:"username" env:"OC_PERSISTENT_STORE_AUTH_USERNAME;GRAPH_STORE_AUTH_USERNAME" desc:"The username to authenticate with the store. Only applies when store type 'nats-js-kv' is configured." introductionVersion:"1.0.0"`
|
||||
AuthPassword string `yaml:"password" env:"OC_PERSISTENT_STORE_AUTH_PASSWORD;GRAPH_STORE_AUTH_PASSWORD" desc:"The password to authenticate with the store. Only applies when store type 'nats-js-kv' is configured." introductionVersion:"1.0.0"`
|
||||
}
|
||||
|
||||
type MasterAuth struct {
|
||||
Username string `yaml:"username" env:"OC_JMAP_MASTER_USERNAME;GROUPWARE_JMAP_MASTER_USERNAME"`
|
||||
Password string `yaml:"password" env:"OC_JMAP_MASTER_PASSWORD;GROUPWARE_JMAP_MASTER_PASSWORD"`
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -135,6 +135,16 @@ func DefaultConfig() *config.Config {
|
||||
Nodes: []string{"127.0.0.1:9233"},
|
||||
Database: "graph",
|
||||
},
|
||||
Mail: config.Mail{
|
||||
Master: config.MasterAuth{
|
||||
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),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
149
services/graph/pkg/service/v0/groupware.go
Normal file
149
services/graph/pkg/service/v0/groupware.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"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/opencloud-eu/opencloud/services/groupware/pkg/jmap"
|
||||
|
||||
"github.com/go-chi/render"
|
||||
|
||||
"github.com/jellydator/ttlcache/v3"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
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.NewJmapClient(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)
|
||||
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)
|
||||
return item
|
||||
},
|
||||
)
|
||||
|
||||
contextCache := ttlcache.New(
|
||||
ttlcache.WithTTL[string, jmap.JmapContext](
|
||||
config.Mail.ContextCacheTTL,
|
||||
),
|
||||
ttlcache.WithDisableTouchOnHit[string, jmap.JmapContext](),
|
||||
ttlcache.WithLoader(loader),
|
||||
)
|
||||
go contextCache.Start()
|
||||
|
||||
return &Groupware{
|
||||
logger: logger,
|
||||
jmapClient: jmapClient,
|
||||
contextCache: contextCache,
|
||||
usernameProvider: jmapUsernameProvider,
|
||||
}
|
||||
}
|
||||
|
||||
func pickInbox(folders jmap.JmapFolders) string {
|
||||
for _, folder := range folders.Folders {
|
||||
if folder.Role == "inbox" {
|
||||
return folder.Id
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (g Groupware) context(ctx context.Context, logger *log.Logger) (jmap.JmapContext, error) {
|
||||
username, err := g.usernameProvider.GetUsername(ctx, logger)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("failed to retrieve username")
|
||||
return jmap.JmapContext{}, err
|
||||
}
|
||||
|
||||
item := g.contextCache.Get(username)
|
||||
return item.Value(), nil
|
||||
}
|
||||
|
||||
func (g Groupware) GetMessages(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
logger := g.logger.SubloggerWithRequestID(ctx)
|
||||
|
||||
jmapContext, err := g.context(ctx, &logger)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Interface("query", r.URL.Query()).Msg("failed to determine Jmap context")
|
||||
errorcode.ServiceNotAvailable.Render(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
ctx = context.WithValue(ctx, jmap.ContextAccountId, jmapContext.AccountId)
|
||||
|
||||
logger.Debug().Msg("fetching folders")
|
||||
folders, err := g.jmapClient.GetMailboxes(jmapContext, ctx, &logger)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Interface("query", 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)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Interface("query", 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
|
||||
messages = append(messages, message)
|
||||
}
|
||||
|
||||
render.Status(r, http.StatusOK)
|
||||
render.JSON(w, r, messages)
|
||||
}
|
||||
83
services/graph/pkg/service/v0/messages.go
Normal file
83
services/graph/pkg/service/v0/messages.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/CiscoM31/godata"
|
||||
"github.com/go-chi/render"
|
||||
"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"
|
||||
)
|
||||
|
||||
// GetMessages implements the Service interface.
|
||||
func (g Graph) GetMessages(w http.ResponseWriter, r *http.Request) {
|
||||
logger := g.logger.SubloggerWithRequestID(r.Context())
|
||||
logger.Debug().Msg("calling get messages in /me")
|
||||
sanitizedPath := strings.TrimPrefix(r.URL.Path, "/graph/v1.0/")
|
||||
|
||||
odataReq, err := godata.ParseRequest(r.Context(), sanitizedPath, r.URL.Query())
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Interface("query", r.URL.Query()).Msg("could not get messages: query error")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
u, ok := revactx.ContextGetUser(r.Context())
|
||||
if !ok {
|
||||
logger.Debug().Msg("could not get messages: user not in context")
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, "user not in context")
|
||||
return
|
||||
}
|
||||
|
||||
exp, err := identity.GetExpandValues(odataReq.Query)
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Interface("query", r.URL.Query()).Msg("could not get messages: $expand error")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var me *libregraph.User
|
||||
// We can just return the user from context unless we need to expand the group memberships
|
||||
if !slices.Contains(exp, "memberOf") {
|
||||
me = identity.CreateUserModelFromCS3(u)
|
||||
} else {
|
||||
var err error
|
||||
logger.Debug().Msg("calling get user on backend")
|
||||
me, err = g.identityBackend.GetUser(r.Context(), u.GetId().GetOpaqueId(), odataReq)
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Interface("user", u).Msg("could not get user from backend")
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
if me.MemberOf == nil {
|
||||
me.MemberOf = []libregraph.Group{}
|
||||
}
|
||||
}
|
||||
|
||||
// expand appRoleAssignments if requested
|
||||
if slices.Contains(exp, appRoleAssignments) {
|
||||
var err error
|
||||
me.AppRoleAssignments, err = g.fetchAppRoleAssignments(r.Context(), me.GetId())
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Str("userid", me.GetId()).Msg("could not get appRoleAssignments for self")
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
preferedLanguage, _, err := getUserLanguage(r.Context(), g.valueService, me.GetId())
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("could not get user language")
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, "could not get user language")
|
||||
return
|
||||
}
|
||||
|
||||
me.PreferredLanguage = &preferedLanguage
|
||||
|
||||
render.Status(r, http.StatusOK)
|
||||
render.JSON(w, r, me)
|
||||
}
|
||||
@@ -202,6 +202,8 @@ func NewService(opts ...Option) (Graph, error) { //nolint:maintidx
|
||||
natskv: options.NatsKeyValue,
|
||||
}
|
||||
|
||||
gw := NewGroupware(&options.Logger, options.Config)
|
||||
|
||||
if err := setIdentityBackends(options, &svc); err != nil {
|
||||
return svc, err
|
||||
}
|
||||
@@ -318,6 +320,7 @@ func NewService(opts ...Option) (Graph, error) { //nolint:maintidx
|
||||
r.Patch("/", usersUserProfilePhotoApi.UpsertProfilePhoto(GetUserIDFromCTX))
|
||||
r.Delete("/", usersUserProfilePhotoApi.DeleteProfilePhoto(GetUserIDFromCTX))
|
||||
})
|
||||
r.Get("/messages", gw.GetMessages)
|
||||
})
|
||||
r.Route("/users", func(r chi.Router) {
|
||||
r.Get("/", svc.GetUsers)
|
||||
|
||||
@@ -2,6 +2,7 @@ package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/shared"
|
||||
)
|
||||
@@ -29,8 +30,8 @@ type MasterAuth struct {
|
||||
}
|
||||
|
||||
type Mail struct {
|
||||
Master MasterAuth `yaml:"master"`
|
||||
JmapUrl string `yaml:"jmap_url" env:"OC_JMAP_URL;GROUPWARE_JMAP_URL"`
|
||||
CS3AllowInsecure bool `yaml:"cs3_allow_insecure" env:"OC_INSECURE;GROUPWARE_CS3SOURCE_INSECURE" desc:"Ignore untrusted SSL certificates when connecting to the CS3 source." introductionVersion:"1.0.0"`
|
||||
RevaGateway string `yaml:"reva_gateway" env:"OC_REVA_GATEWAY" desc:"CS3 gateway used to look up user metadata" introductionVersion:"1.0.0"`
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package defaults
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/shared"
|
||||
"github.com/opencloud-eu/opencloud/services/groupware/pkg/config"
|
||||
)
|
||||
|
||||
@@ -26,12 +25,11 @@ func DefaultConfig() *config.Config {
|
||||
},
|
||||
Mail: config.Mail{
|
||||
Master: config.MasterAuth{
|
||||
Username: "master",
|
||||
Password: "secret",
|
||||
Username: "masteradmin",
|
||||
Password: "admin",
|
||||
},
|
||||
JmapUrl: "https://stalwart.opencloud.test/jmap",
|
||||
RevaGateway: shared.DefaultRevaConfig().Address,
|
||||
CS3AllowInsecure: false,
|
||||
BaseUrl: "https://stalwart.opencloud.test",
|
||||
JmapUrl: "https://stalwart.opencloud.test/jmap",
|
||||
},
|
||||
HTTP: config.HTTP{
|
||||
Addr: "127.0.0.1:9276",
|
||||
@@ -79,6 +77,8 @@ func EnsureDefaults(cfg *config.Config) {
|
||||
cfg.HTTP.TLS = cfg.Commons.HTTPServiceTLS
|
||||
}
|
||||
|
||||
// TODO p.bleser add Mail here
|
||||
|
||||
}
|
||||
|
||||
// Sanitize sanitized the configuration
|
||||
|
||||
@@ -8,34 +8,3 @@ type Email struct {
|
||||
HasAttachments bool
|
||||
Received time.Time
|
||||
}
|
||||
|
||||
func NewEmail(elem map[string]any) Email {
|
||||
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, receivedErr := time.ParseInLocation(time.RFC3339, elem["receivedAt"].(string), time.UTC)
|
||||
if receivedErr != nil {
|
||||
panic(receivedErr)
|
||||
}
|
||||
|
||||
return Email{
|
||||
From: from["email"].(string),
|
||||
Subject: subject,
|
||||
HasAttachments: hasAttachments,
|
||||
Received: received,
|
||||
}
|
||||
}
|
||||
|
||||
148
services/groupware/pkg/jmap/http_jmap_api_client.go
Normal file
148
services/groupware/pkg/jmap/http_jmap_api_client.go
Normal 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
|
||||
}
|
||||
@@ -1,243 +1,95 @@
|
||||
package jmap
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
)
|
||||
|
||||
type WellKnownJmap struct {
|
||||
ApiUrl string `json:"apiUrl"`
|
||||
PrimaryAccounts map[string]string `json:"primaryAccounts"`
|
||||
}
|
||||
|
||||
/*
|
||||
func bearer(req *http.Request, token string) {
|
||||
req.Header.Add("Authorization", "Bearer "+base64.StdEncoding.EncodeToString([]byte(token)))
|
||||
}
|
||||
*/
|
||||
|
||||
func fetch[T any](client *http.Client, url string, username string, password string, mapper func(body *[]byte) T) T {
|
||||
req, reqErr := http.NewRequest(http.MethodGet, url, nil)
|
||||
if reqErr != nil {
|
||||
panic(reqErr)
|
||||
}
|
||||
req.SetBasicAuth(username, password)
|
||||
|
||||
res, getErr := client.Do(req)
|
||||
if getErr != nil {
|
||||
panic(getErr)
|
||||
}
|
||||
if res.StatusCode != 200 {
|
||||
panic(fmt.Sprintf("HTTP status code not 200: %d", res.StatusCode))
|
||||
}
|
||||
if res.Body != nil {
|
||||
defer func(Body io.ReadCloser) {
|
||||
err := Body.Close()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}(res.Body)
|
||||
}
|
||||
|
||||
body, readErr := io.ReadAll(res.Body)
|
||||
if readErr != nil {
|
||||
log.Fatal(readErr)
|
||||
}
|
||||
|
||||
return mapper(&body)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
const (
|
||||
JmapCore = "urn:ietf:params:jmap:core"
|
||||
JmapMail = "urn:ietf:params:jmap:mail"
|
||||
)
|
||||
|
||||
func command[T any](client *http.Client, ctx context.Context, url string, username string, password string, methodCalls *[][]any, mapper func(body *[]byte) T) T {
|
||||
jmapWrapper := 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"
|
||||
]
|
||||
]
|
||||
}
|
||||
*/
|
||||
|
||||
bodyBytes, marshalErr := json.Marshal(jmapWrapper)
|
||||
if marshalErr != nil {
|
||||
panic(marshalErr)
|
||||
}
|
||||
|
||||
req, reqErr := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(bodyBytes))
|
||||
if reqErr != nil {
|
||||
panic(reqErr)
|
||||
}
|
||||
req.SetBasicAuth(username, password)
|
||||
req.Header.Add("Content-Type", "application/json")
|
||||
|
||||
slog.Info("jmap", "url", url, "username", username)
|
||||
res, postErr := client.Do(req)
|
||||
if postErr != nil {
|
||||
panic(postErr)
|
||||
}
|
||||
if res.StatusCode != 200 {
|
||||
panic(fmt.Sprintf("HTTP status code not 200: %d", res.StatusCode))
|
||||
}
|
||||
if res.Body != nil {
|
||||
defer func(Body io.ReadCloser) {
|
||||
err := Body.Close()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}(res.Body)
|
||||
}
|
||||
|
||||
body, readErr := io.ReadAll(res.Body)
|
||||
if readErr != nil {
|
||||
log.Fatal(readErr)
|
||||
}
|
||||
|
||||
if slog.Default().Enabled(ctx, slog.LevelDebug) {
|
||||
slog.Debug(ctx.Value("operation").(string) + " response: " + string(body))
|
||||
}
|
||||
|
||||
return mapper(&body)
|
||||
}
|
||||
|
||||
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 JmapClient struct {
|
||||
client *http.Client
|
||||
username string
|
||||
password string
|
||||
url string
|
||||
accountId string
|
||||
ctx context.Context
|
||||
wellKnown JmapWellKnownClient
|
||||
api JmapApiClient
|
||||
}
|
||||
|
||||
func New(client *http.Client, ctx context.Context, username string, password string, url string, accountId string) JmapClient {
|
||||
func NewJmapClient(wellKnown JmapWellKnownClient, api JmapApiClient) JmapClient {
|
||||
return JmapClient{
|
||||
client: client,
|
||||
ctx: ctx,
|
||||
username: username,
|
||||
password: password,
|
||||
url: url,
|
||||
accountId: accountId,
|
||||
wellKnown: wellKnown,
|
||||
api: api,
|
||||
}
|
||||
}
|
||||
|
||||
func (jmap *JmapClient) FetchWellKnown() WellKnownJmap {
|
||||
return fetch(jmap.client, jmap.url+"/.well-known/jmap", jmap.username, jmap.password, func(body *[]byte) WellKnownJmap {
|
||||
var data WellKnownJmap
|
||||
jsonErr := json.Unmarshal(*body, &data)
|
||||
if jsonErr != nil {
|
||||
panic(jsonErr)
|
||||
}
|
||||
|
||||
/*
|
||||
u, urlErr := url.Parse(data.ApiUrl)
|
||||
if urlErr != nil {
|
||||
panic(urlErr)
|
||||
}
|
||||
jmap.url = jmap.url + u.Path
|
||||
*/
|
||||
jmap.accountId = data.PrimaryAccounts[JmapMail]
|
||||
return data
|
||||
})
|
||||
type JmapContext struct {
|
||||
AccountId string
|
||||
JmapUrl string
|
||||
}
|
||||
|
||||
func (jmap *JmapClient) GetMailboxes() JmapFolders {
|
||||
/*
|
||||
{"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"}
|
||||
func NewJmapContext(wellKnown WellKnownJmap) (JmapContext, error) {
|
||||
// TODO validate
|
||||
return JmapContext{
|
||||
AccountId: wellKnown.PrimaryAccounts[JmapMail],
|
||||
JmapUrl: wellKnown.ApiUrl,
|
||||
}, nil
|
||||
}
|
||||
|
||||
*/
|
||||
cmd := simpleCommand("Mailbox/get", map[string]any{"accountId": jmap.accountId})
|
||||
commandCtx := context.WithValue(jmap.ctx, "operation", "GetMailboxes")
|
||||
return command(jmap.client, commandCtx, jmap.url, jmap.username, jmap.password, &cmd, func(body *[]byte) JmapFolders {
|
||||
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
|
||||
jsonErr := json.Unmarshal(*body, &data)
|
||||
if jsonErr != nil {
|
||||
panic(jsonErr)
|
||||
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
|
||||
}
|
||||
first := data.MethodResponses[0]
|
||||
params := first[1]
|
||||
payload := params.(map[string]any)
|
||||
state := payload["state"].(string)
|
||||
list := payload["list"].([]any)
|
||||
folders := make([]JmapFolder, len(list))
|
||||
for i, a := range list {
|
||||
item := a.(map[string]any)
|
||||
folders[i] = 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)),
|
||||
}
|
||||
}
|
||||
return JmapFolders{Folders: folders, state: state}
|
||||
return parseMailboxGetResponse(data)
|
||||
})
|
||||
}
|
||||
|
||||
type Emails struct {
|
||||
Emails []Email
|
||||
State string
|
||||
}
|
||||
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 (jmap *JmapClient) EmailQuery(mailboxId string) Emails {
|
||||
cmd := make([][]any, 4)
|
||||
cmd[0] = []any{
|
||||
"Email/query",
|
||||
map[string]any{
|
||||
"accountId": jmap.accountId,
|
||||
"accountId": jc.AccountId,
|
||||
"filter": map[string]any{
|
||||
"inMailbox": mailboxId,
|
||||
},
|
||||
@@ -257,7 +109,7 @@ func (jmap *JmapClient) EmailQuery(mailboxId string) Emails {
|
||||
cmd[1] = []any{
|
||||
"Email/get",
|
||||
map[string]any{
|
||||
"accountId": jmap.accountId,
|
||||
"accountId": jc.AccountId,
|
||||
"#ids": map[string]any{
|
||||
"resultOf": "0",
|
||||
"name": "Email/query",
|
||||
@@ -270,7 +122,7 @@ func (jmap *JmapClient) EmailQuery(mailboxId string) Emails {
|
||||
cmd[2] = []any{
|
||||
"Thread/get",
|
||||
map[string]any{
|
||||
"accountId": jmap.accountId,
|
||||
"accountId": jc.AccountId,
|
||||
"#ids": map[string]any{
|
||||
"resultOf": "1",
|
||||
"name": "Email/get",
|
||||
@@ -282,7 +134,7 @@ func (jmap *JmapClient) EmailQuery(mailboxId string) Emails {
|
||||
cmd[3] = []any{
|
||||
"Email/get",
|
||||
map[string]any{
|
||||
"accountId": jmap.accountId,
|
||||
"accountId": jc.AccountId,
|
||||
"#ids": map[string]any{
|
||||
"resultOf": "2",
|
||||
"name": "Thread/get",
|
||||
@@ -303,58 +155,36 @@ func (jmap *JmapClient) EmailQuery(mailboxId string) Emails {
|
||||
"3",
|
||||
}
|
||||
|
||||
commandCtx := context.WithValue(jmap.ctx, "operation", "GetMailboxes")
|
||||
return command(jmap.client, commandCtx, jmap.url, jmap.username, jmap.password, &cmd, func(body *[]byte) Emails {
|
||||
commandCtx := context.WithValue(ctx, ContextOperationId, "EmailQuery")
|
||||
return command(j.api, logger, commandCtx, &cmd, func(body *[]byte) (Emails, error) {
|
||||
var data JmapCommandResponse
|
||||
jsonErr := json.Unmarshal(*body, &data)
|
||||
if jsonErr != nil {
|
||||
panic(jsonErr)
|
||||
err := json.Unmarshal(*body, &data)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("failed to unmarshal response payload")
|
||||
return Emails{}, err
|
||||
}
|
||||
matches := make([][]any, 1)
|
||||
for _, elem := range data.MethodResponses {
|
||||
if elem[0] == "Email/get" && elem[2] == "3" {
|
||||
matches = append(matches, elem)
|
||||
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)
|
||||
}
|
||||
/*
|
||||
matches := lo.Filter(data.MethodResponses, func(elem []any, index int) bool {
|
||||
return elem[0] == "Email/get" && elem[2] == "3"
|
||||
})
|
||||
*/
|
||||
payload := matches[0][1].(map[string]any)
|
||||
list := payload["list"].([]any)
|
||||
|
||||
/*
|
||||
{
|
||||
"threadId": "cc",
|
||||
"mailboxIds": {
|
||||
"a": true
|
||||
},
|
||||
"keywords": {},
|
||||
"hasAttachment": false,
|
||||
"from": [
|
||||
{
|
||||
"name": null,
|
||||
"email": "root@nsa.gov"
|
||||
}
|
||||
],
|
||||
"subject": "Hello 5",
|
||||
"receivedAt": "2025-04-10T13:07:27Z",
|
||||
"size": 47,
|
||||
"preview": "Hi <3",
|
||||
"id": "iiaaaaaa"
|
||||
},
|
||||
*/
|
||||
|
||||
emails := make([]Email, len(list))
|
||||
for i, elem := range list {
|
||||
emails[i] = NewEmail(elem.(map[string]any))
|
||||
}
|
||||
/*
|
||||
emails := lo.Map(list, func(elem any, _ int) Email {
|
||||
return NewEmail(elem.(map[string]any))
|
||||
})
|
||||
*/
|
||||
return Emails{Emails: emails, State: data.SessionState}
|
||||
return Emails{Emails: emails, State: data.SessionState}, nil
|
||||
})
|
||||
}
|
||||
|
||||
11
services/groupware/pkg/jmap/jmap_api_client.go
Normal file
11
services/groupware/pkg/jmap/jmap_api_client.go
Normal 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
services/groupware/pkg/jmap/jmap_model.go
Normal file
30
services/groupware/pkg/jmap/jmap_model.go
Normal 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
services/groupware/pkg/jmap/jmap_test.go
Normal file
293
services/groupware/pkg/jmap/jmap_test.go
Normal 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
services/groupware/pkg/jmap/jmap_tools.go
Normal file
118
services/groupware/pkg/jmap/jmap_tools.go
Normal 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
|
||||
}
|
||||
9
services/groupware/pkg/jmap/jmap_well_known_client.go
Normal file
9
services/groupware/pkg/jmap/jmap_well_known_client.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/render"
|
||||
@@ -13,14 +15,6 @@ import (
|
||||
"github.com/opencloud-eu/opencloud/services/groupware/pkg/jmap"
|
||||
)
|
||||
|
||||
/*
|
||||
type contextKey string
|
||||
|
||||
const (
|
||||
keyContextKey contextKey = "key"
|
||||
)
|
||||
*/
|
||||
|
||||
// Service defines the service handlers.
|
||||
type Service interface {
|
||||
ServeHTTP(w http.ResponseWriter, r *http.Request)
|
||||
@@ -42,11 +36,7 @@ func NewService(opts ...Option) Service {
|
||||
),
|
||||
)
|
||||
|
||||
svc := Groupware{
|
||||
config: options.Config,
|
||||
mux: m,
|
||||
logger: options.Logger,
|
||||
}
|
||||
svc := NewGroupware(options.Config, &options.Logger, m)
|
||||
|
||||
m.Route(options.Config.HTTP.Root, func(r chi.Router) {
|
||||
r.Get("/", svc.WellDefined)
|
||||
@@ -61,17 +51,29 @@ func NewService(opts ...Option) Service {
|
||||
return svc
|
||||
}
|
||||
|
||||
// Thumbnails implements the business logic for Service.
|
||||
type Groupware struct {
|
||||
config *config.Config
|
||||
logger log.Logger
|
||||
mux *chi.Mux
|
||||
httpClient *http.Client
|
||||
jmapClient jmap.JmapClient
|
||||
usernameProvider jmap.HttpJmapUsernameProvider
|
||||
config *config.Config
|
||||
logger *log.Logger
|
||||
mux *chi.Mux
|
||||
}
|
||||
|
||||
// ServeHTTP implements the Service interface.
|
||||
func (s Groupware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
s.mux.ServeHTTP(w, r)
|
||||
func NewGroupware(config *config.Config, logger *log.Logger, mux *chi.Mux) *Groupware {
|
||||
usernameProvider := jmap.NewRevaContextHttpJmapUsernameProvider()
|
||||
httpApiClient := httpApiClient(config, usernameProvider)
|
||||
jmapClient := jmap.NewJmapClient(httpApiClient, httpApiClient)
|
||||
return &Groupware{
|
||||
jmapClient: jmapClient,
|
||||
usernameProvider: usernameProvider,
|
||||
config: config,
|
||||
mux: mux,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (g Groupware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
g.mux.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
type IndexResponse struct {
|
||||
@@ -87,10 +89,36 @@ func (g Groupware) Ping(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (g Groupware) WellDefined(w http.ResponseWriter, r *http.Request) {
|
||||
//logger := g.logger.SubloggerWithRequestID(r.Context())
|
||||
func httpApiClient(config *config.Config, usernameProvider jmap.HttpJmapUsernameProvider) *jmap.HttpJmapApiClient {
|
||||
tr := http.DefaultTransport.(*http.Transport).Clone()
|
||||
tr.ResponseHeaderTimeout = time.Duration(10) * time.Second
|
||||
tlsConfig := &tls.Config{InsecureSkipVerify: true}
|
||||
tr.TLSClientConfig = tlsConfig
|
||||
c := *http.DefaultClient
|
||||
c.Transport = tr
|
||||
|
||||
client := jmap.New(g.httpClient, r.Context(), "alan", "demo", "https://stalwart.opencloud.test/jmap", "cs")
|
||||
wellKnown := client.FetchWellKnown()
|
||||
_ = render.Render(w, r, IndexResponse{AccountId: wellKnown.PrimaryAccounts[jmap.JmapMail]})
|
||||
api := jmap.NewHttpJmapApiClient(
|
||||
config.Mail.BaseUrl,
|
||||
config.Mail.JmapUrl,
|
||||
&c,
|
||||
usernameProvider,
|
||||
config.Mail.Master.Username,
|
||||
config.Mail.Master.Password,
|
||||
)
|
||||
return api
|
||||
}
|
||||
func (g Groupware) WellDefined(w http.ResponseWriter, r *http.Request) {
|
||||
logger := g.logger.SubloggerWithRequestID(r.Context())
|
||||
username, err := g.usernameProvider.GetUsername(r.Context(), &logger)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
jmapContext, err := g.jmapClient.FetchJmapContext(username, &logger)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
_ = render.Render(w, r, IndexResponse{AccountId: jmapContext.AccountId})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user