mirror of
https://github.com/opencloud-eu/opencloud.git
synced 2026-01-12 15:20:41 -06:00
Groupware and jmap: cleanup and API documentation
This commit is contained in:
@@ -3,6 +3,7 @@ package jmap
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
"github.com/rs/zerolog"
|
||||
@@ -11,6 +12,11 @@ import (
|
||||
type Client struct {
|
||||
wellKnown WellKnownClient
|
||||
api ApiClient
|
||||
io.Closer
|
||||
}
|
||||
|
||||
func (j *Client) Close() error {
|
||||
return j.api.Close()
|
||||
}
|
||||
|
||||
func NewClient(wellKnown WellKnownClient, api ApiClient) Client {
|
||||
@@ -20,10 +26,32 @@ func NewClient(wellKnown WellKnownClient, api ApiClient) Client {
|
||||
}
|
||||
}
|
||||
|
||||
// Cached user related information
|
||||
//
|
||||
// This information is typically retrieved once (or at least for a certain period of time) from the
|
||||
// JMAP well-known endpoint of Stalwart and then kept in cache to avoid the performance cost of
|
||||
// retrieving it over and over again.
|
||||
//
|
||||
// This is really only needed due to the Graph API limitations, since ideally, the account ID should
|
||||
// be passed as a request parameter by the UI, in order to support a user having multiple accounts.
|
||||
//
|
||||
// Keeping track of the JMAP URL might be useful though, in case of Stalwart sharding strategies making
|
||||
// use of that, by providing different URLs for JMAP on a per-user basis, and that is not something
|
||||
// we would want to query before every single JMAP request. On the other hand, that then also creates
|
||||
// a risk of going out-of-sync, e.g. if a node is down and the user is reassigned to a different node.
|
||||
// There might be webhooks to subscribe to in Stalwart to be notified of such situations, in which case
|
||||
// the Session needs to be removed from the cache.
|
||||
//
|
||||
// The Username is only here for convenience, it could just as well be passed as a separate parameter
|
||||
// instead of being part of the Session, since the username is always part of the request (typically in
|
||||
// the authentication token payload.)
|
||||
type Session struct {
|
||||
Username string
|
||||
// The name of the user to use to authenticate against Stalwart
|
||||
Username string
|
||||
// The identifier of the account to use when performing JMAP operations with Stalwart
|
||||
AccountId string
|
||||
JmapUrl string
|
||||
// The base URL to use for JMAP operations towards Stalwart
|
||||
JmapUrl string
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -34,26 +62,44 @@ const (
|
||||
logFetchBodies = "fetch-bodies"
|
||||
logOffset = "offset"
|
||||
logLimit = "limit"
|
||||
|
||||
emailSortByReceivedAt = "receivedAt"
|
||||
emailSortBySize = "size"
|
||||
emailSortByFrom = "from"
|
||||
emailSortByTo = "to"
|
||||
emailSortBySubject = "subject"
|
||||
emailSortBySentAt = "sentAt"
|
||||
emailSortByHasKeyword = "hasKeyword"
|
||||
emailSortByAllInThreadHaveKeyword = "allInThreadHaveKeyword"
|
||||
emailSortBySomeInThreadHaveKeyword = "someInThreadHaveKeyword"
|
||||
)
|
||||
|
||||
// Create a new log.Logger that is decorated with fields containing information about the Session.
|
||||
func (s Session) DecorateLogger(l log.Logger) log.Logger {
|
||||
return log.Logger{
|
||||
Logger: l.With().Str(logUsername, s.Username).Str(logAccountId, s.AccountId).Logger(),
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
errWellKnownResponseHasNoUsername = fmt.Errorf("well-known response has no username")
|
||||
errWellKnownResponseHasJmapMailPrimaryAccount = fmt.Errorf("PrimaryAccounts in well-known response has no entry for %v", JmapMail)
|
||||
errWellKnownResponseHasNoApiUrl = fmt.Errorf("well-known response has no API URL")
|
||||
)
|
||||
|
||||
// Create a new Session from a WellKnownResponse.
|
||||
func NewSession(wellKnownResponse WellKnownResponse) (Session, error) {
|
||||
username := wellKnownResponse.Username
|
||||
if username == "" {
|
||||
return Session{}, fmt.Errorf("well-known response has no username")
|
||||
return Session{}, errWellKnownResponseHasNoUsername
|
||||
}
|
||||
accountId := wellKnownResponse.PrimaryAccounts[JmapMail]
|
||||
if accountId == "" {
|
||||
return Session{}, fmt.Errorf("PrimaryAccounts in well-known response has no entry for %v", JmapMail)
|
||||
return Session{}, errWellKnownResponseHasJmapMailPrimaryAccount
|
||||
}
|
||||
apiUrl := wellKnownResponse.ApiUrl
|
||||
if apiUrl == "" {
|
||||
return Session{}, fmt.Errorf("well-known response has no API URL")
|
||||
return Session{}, errWellKnownResponseHasNoApiUrl
|
||||
}
|
||||
return Session{
|
||||
Username: username,
|
||||
@@ -62,6 +108,7 @@ func NewSession(wellKnownResponse WellKnownResponse) (Session, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Retrieve JMAP well-known data from the Stalwart server and create a Session from that.
|
||||
func (j *Client) FetchSession(username string, logger *log.Logger) (Session, error) {
|
||||
wk, err := j.wellKnown.GetWellKnown(username, logger)
|
||||
if err != nil {
|
||||
@@ -79,9 +126,10 @@ func (j *Client) loggerParams(operation string, session *Session, logger *log.Lo
|
||||
return &log.Logger{Logger: params(base).Logger()}
|
||||
}
|
||||
|
||||
// https://jmap.io/spec-mail.html#identityget
|
||||
func (j *Client) GetIdentity(session *Session, ctx context.Context, logger *log.Logger) (IdentityGetResponse, error) {
|
||||
logger = j.logger("GetIdentity", session, logger)
|
||||
cmd, err := NewRequest(NewInvocation(IdentityGet, IdentityGetCommand{AccountId: session.AccountId}, "0"))
|
||||
cmd, err := request(invocation(IdentityGet, IdentityGetCommand{AccountId: session.AccountId}, "0"))
|
||||
if err != nil {
|
||||
return IdentityGetResponse{}, err
|
||||
}
|
||||
@@ -92,9 +140,10 @@ func (j *Client) GetIdentity(session *Session, ctx context.Context, logger *log.
|
||||
})
|
||||
}
|
||||
|
||||
func (j *Client) GetVacation(session *Session, ctx context.Context, logger *log.Logger) (VacationResponseGetResponse, error) {
|
||||
logger = j.logger("GetVacation", session, logger)
|
||||
cmd, err := NewRequest(NewInvocation(VacationResponseGet, VacationResponseGetCommand{AccountId: session.AccountId}, "0"))
|
||||
// https://jmap.io/spec-mail.html#vacationresponseget
|
||||
func (j *Client) GetVacationResponse(session *Session, ctx context.Context, logger *log.Logger) (VacationResponseGetResponse, error) {
|
||||
logger = j.logger("GetVacationResponse", session, logger)
|
||||
cmd, err := request(invocation(VacationResponseGet, VacationResponseGetCommand{AccountId: session.AccountId}, "0"))
|
||||
if err != nil {
|
||||
return VacationResponseGetResponse{}, err
|
||||
}
|
||||
@@ -105,9 +154,10 @@ func (j *Client) GetVacation(session *Session, ctx context.Context, logger *log.
|
||||
})
|
||||
}
|
||||
|
||||
func (j *Client) GetMailboxes(session *Session, ctx context.Context, logger *log.Logger) (MailboxGetResponse, error) {
|
||||
logger = j.logger("GetMailboxes", session, logger)
|
||||
cmd, err := NewRequest(NewInvocation(MailboxGet, MailboxGetCommand{AccountId: session.AccountId}, "0"))
|
||||
// https://jmap.io/spec-mail.html#mailboxget
|
||||
func (j *Client) GetMailbox(session *Session, ctx context.Context, logger *log.Logger, ids []string) (MailboxGetResponse, error) {
|
||||
logger = j.logger("GetMailbox", session, logger)
|
||||
cmd, err := request(invocation(MailboxGet, MailboxGetCommand{AccountId: session.AccountId, Ids: ids}, "0"))
|
||||
if err != nil {
|
||||
return MailboxGetResponse{}, err
|
||||
}
|
||||
@@ -118,6 +168,10 @@ func (j *Client) GetMailboxes(session *Session, ctx context.Context, logger *log
|
||||
})
|
||||
}
|
||||
|
||||
func (j *Client) GetAllMailboxes(session *Session, ctx context.Context, logger *log.Logger) (MailboxGetResponse, error) {
|
||||
return j.GetMailbox(session, ctx, logger, nil)
|
||||
}
|
||||
|
||||
type Emails struct {
|
||||
Emails []Email
|
||||
State string
|
||||
@@ -127,17 +181,17 @@ func (j *Client) GetEmails(session *Session, ctx context.Context, logger *log.Lo
|
||||
logger = j.loggerParams("GetEmails", session, logger, func(z zerolog.Context) zerolog.Context {
|
||||
return z.Bool(logFetchBodies, fetchBodies).Int(logOffset, offset).Int(logLimit, limit)
|
||||
})
|
||||
cmd, err := NewRequest(
|
||||
NewInvocation(EmailQuery, EmailQueryCommand{
|
||||
cmd, err := request(
|
||||
invocation(EmailQuery, EmailQueryCommand{
|
||||
AccountId: session.AccountId,
|
||||
Filter: &Filter{InMailbox: mailboxId},
|
||||
Sort: []Sort{{Property: "receivedAt", IsAscending: false}},
|
||||
Sort: []Sort{{Property: emailSortByReceivedAt, IsAscending: false}},
|
||||
CollapseThreads: true,
|
||||
Position: offset,
|
||||
Limit: limit,
|
||||
CalculateTotal: false,
|
||||
}, "0"),
|
||||
NewInvocation(EmailGet, EmailGetCommand{
|
||||
invocation(EmailGet, EmailGetCommand{
|
||||
AccountId: session.AccountId,
|
||||
FetchAllBodyValues: fetchBodies,
|
||||
MaxBodyValueBytes: maxBodyValueBytes,
|
||||
|
||||
@@ -2,12 +2,14 @@ package jmap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
)
|
||||
|
||||
type ApiClient interface {
|
||||
Command(ctx context.Context, logger *log.Logger, session *Session, request Request) ([]byte, error)
|
||||
io.Closer
|
||||
}
|
||||
|
||||
type WellKnownClient interface {
|
||||
|
||||
@@ -7,13 +7,15 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path"
|
||||
|
||||
"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)
|
||||
// Provide the username for JMAP operations.
|
||||
GetUsername(req *http.Request, ctx context.Context, logger *log.Logger) (string, error)
|
||||
}
|
||||
|
||||
type HttpJmapApiClient struct {
|
||||
@@ -26,6 +28,11 @@ type HttpJmapApiClient struct {
|
||||
userAgent string
|
||||
}
|
||||
|
||||
var (
|
||||
_ ApiClient = &HttpJmapApiClient{}
|
||||
_ WellKnownClient = &HttpJmapApiClient{}
|
||||
)
|
||||
|
||||
/*
|
||||
func bearer(req *http.Request, token string) {
|
||||
req.Header.Add("Authorization", "Bearer "+base64.StdEncoding.EncodeToString([]byte(token)))
|
||||
@@ -44,10 +51,27 @@ func NewHttpJmapApiClient(baseurl string, jmapurl string, client *http.Client, u
|
||||
}
|
||||
}
|
||||
|
||||
func (h *HttpJmapApiClient) Close() error {
|
||||
h.client.CloseIdleConnections()
|
||||
return nil
|
||||
}
|
||||
|
||||
type AuthenticationError struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e AuthenticationError) Error() string {
|
||||
return fmt.Sprintf("failed to find user for authentication: %v", e.Err.Error())
|
||||
}
|
||||
func (e AuthenticationError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
func (h *HttpJmapApiClient) auth(logger *log.Logger, ctx context.Context, req *http.Request) error {
|
||||
username, err := h.usernameProvider.GetUsername(ctx, logger)
|
||||
username, err := h.usernameProvider.GetUsername(req, ctx, logger)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("failed to find username")
|
||||
return AuthenticationError{Err: err}
|
||||
}
|
||||
masterUsername := username + "%" + h.masterUser
|
||||
req.SetBasicAuth(masterUsername, h.masterPassword)
|
||||
@@ -60,13 +84,28 @@ func (h *HttpJmapApiClient) authWithUsername(_ *log.Logger, username string, req
|
||||
return nil
|
||||
}
|
||||
|
||||
type HttpError struct {
|
||||
Method string
|
||||
Url string
|
||||
Username string
|
||||
Op string
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e HttpError) Error() string {
|
||||
return fmt.Sprintf("HTTP error for method=%v url='%v' username='%v' while %v: %v", e.Method, e.Url, e.Username, e.Op, e.Err.Error())
|
||||
}
|
||||
func (e HttpError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
func (h *HttpJmapApiClient) GetWellKnown(username string, logger *log.Logger) (WellKnownResponse, error) {
|
||||
wellKnownUrl := h.baseurl + "/.well-known/jmap"
|
||||
wellKnownUrl := path.Join(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 WellKnownResponse{}, err
|
||||
return WellKnownResponse{}, HttpError{Op: "creating request", Method: http.MethodGet, Url: wellKnownUrl, Username: username, Err: err}
|
||||
}
|
||||
h.authWithUsername(logger, username, req)
|
||||
req.Header.Add("Cache-Control", "no-cache, no-store, must-revalidate") // spec recommendation
|
||||
@@ -74,11 +113,11 @@ func (h *HttpJmapApiClient) GetWellKnown(username string, logger *log.Logger) (W
|
||||
res, err := h.client.Do(req)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msgf("failed to perform GET %v", wellKnownUrl)
|
||||
return WellKnownResponse{}, err
|
||||
return WellKnownResponse{}, HttpError{Op: "performing request", Method: http.MethodGet, Url: wellKnownUrl, Username: username, Err: err}
|
||||
}
|
||||
if res.StatusCode != 200 {
|
||||
logger.Error().Str("status", res.Status).Msg("HTTP response status code is not 200")
|
||||
return WellKnownResponse{}, fmt.Errorf("HTTP response status is %v", res.Status)
|
||||
return WellKnownResponse{}, HttpError{Op: "processing response", Method: http.MethodGet, Url: wellKnownUrl, Username: username, Err: fmt.Errorf("status is %v", res.Status)}
|
||||
}
|
||||
if res.Body != nil {
|
||||
defer func(Body io.ReadCloser) {
|
||||
@@ -92,14 +131,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 WellKnownResponse{}, err
|
||||
return WellKnownResponse{}, HttpError{Op: "reading response body", Method: http.MethodGet, Url: wellKnownUrl, Username: username, Err: err}
|
||||
}
|
||||
|
||||
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 WellKnownResponse{}, err
|
||||
return WellKnownResponse{}, HttpError{Op: "reading decoding response JSON payload", Method: http.MethodGet, Url: wellKnownUrl, Username: username, Err: err}
|
||||
}
|
||||
|
||||
return data, nil
|
||||
@@ -129,11 +168,11 @@ func (h *HttpJmapApiClient) Command(ctx context.Context, logger *log.Logger, ses
|
||||
res, err := h.client.Do(req)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msgf("failed to perform GET %v", jmapUrl)
|
||||
return nil, err
|
||||
return nil, HttpError{Op: "performing request", Method: http.MethodPost, Url: jmapUrl, Username: session.Username, Err: 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)
|
||||
return nil, HttpError{Op: "processing response", Method: http.MethodPost, Url: jmapUrl, Username: session.Username, Err: fmt.Errorf("status is %v", res.Status)}
|
||||
}
|
||||
if res.Body != nil {
|
||||
defer func(Body io.ReadCloser) {
|
||||
@@ -147,7 +186,7 @@ func (h *HttpJmapApiClient) Command(ctx context.Context, logger *log.Logger, ses
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("failed to read response body")
|
||||
return nil, err
|
||||
return nil, HttpError{Op: "reading response body", Method: http.MethodPost, Url: jmapUrl, Username: session.Username, Err: err}
|
||||
}
|
||||
|
||||
return body, nil
|
||||
|
||||
@@ -57,7 +57,8 @@ type Mailbox struct {
|
||||
}
|
||||
|
||||
type MailboxGetCommand struct {
|
||||
AccountId string `json:"accountId"`
|
||||
AccountId string `json:"accountId"`
|
||||
Ids []string `json:"ids,omitempty"`
|
||||
}
|
||||
|
||||
type Filter struct {
|
||||
@@ -79,6 +80,8 @@ type Filter struct {
|
||||
type Sort struct {
|
||||
Property string `json:"property,omitempty"`
|
||||
IsAscending bool `json:"isAscending,omitempty"`
|
||||
Keyword string `json:"keyword,omitempty"`
|
||||
Collation string `json:"collation,omitempty"`
|
||||
}
|
||||
|
||||
type EmailQueryCommand struct {
|
||||
@@ -169,7 +172,7 @@ type Invocation struct {
|
||||
Tag string
|
||||
}
|
||||
|
||||
func NewInvocation(command Command, parameters any, tag string) Invocation {
|
||||
func invocation(command Command, parameters any, tag string) Invocation {
|
||||
return Invocation{
|
||||
Command: command,
|
||||
Parameters: parameters,
|
||||
@@ -183,7 +186,7 @@ type Request struct {
|
||||
CreatedIds map[string]string `json:"createdIds,omitempty"`
|
||||
}
|
||||
|
||||
func NewRequest(methodCalls ...Invocation) (Request, error) {
|
||||
func request(methodCalls ...Invocation) (Request, error) {
|
||||
return Request{
|
||||
Using: []string{JmapCore, JmapMail},
|
||||
MethodCalls: methodCalls,
|
||||
@@ -241,7 +244,6 @@ type EmailCreate struct {
|
||||
ReceivedAt time.Time `json:"receivedAt,omitzero"`
|
||||
SentAt time.Time `json:"sentAt,omitzero"`
|
||||
BodyStructure EmailBodyStructure `json:"bodyStructure,omitempty"`
|
||||
//BodyStructure map[string]any `json:"bodyStructure,omitempty"`
|
||||
}
|
||||
|
||||
type EmailSetCommand struct {
|
||||
|
||||
@@ -3,24 +3,30 @@ package jmap
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
|
||||
)
|
||||
|
||||
// implements HttpJmapUsernameProvider
|
||||
type RevaContextHttpJmapUsernameProvider struct {
|
||||
// HttpJmapUsernameProvider implementation that uses Reva's enrichment of the Context
|
||||
// to retrieve the current username.
|
||||
type revaContextHttpJmapUsernameProvider struct {
|
||||
}
|
||||
|
||||
func NewRevaContextHttpJmapUsernameProvider() RevaContextHttpJmapUsernameProvider {
|
||||
return RevaContextHttpJmapUsernameProvider{}
|
||||
var _ HttpJmapUsernameProvider = revaContextHttpJmapUsernameProvider{}
|
||||
|
||||
func NewRevaContextHttpJmapUsernameProvider() HttpJmapUsernameProvider {
|
||||
return revaContextHttpJmapUsernameProvider{}
|
||||
}
|
||||
|
||||
func (r RevaContextHttpJmapUsernameProvider) GetUsername(ctx context.Context, logger *log.Logger) (string, error) {
|
||||
var errUserNotInContext = fmt.Errorf("user not in context")
|
||||
|
||||
func (r revaContextHttpJmapUsernameProvider) GetUsername(req *http.Request, 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")
|
||||
logger.Error().Msg("could not get user: user not in reva context")
|
||||
return "", errUserNotInContext
|
||||
}
|
||||
return u.GetUsername(), nil
|
||||
}
|
||||
|
||||
@@ -22,6 +22,10 @@ func NewTestJmapWellKnownClient(t *testing.T) WellKnownClient {
|
||||
return &TestJmapWellKnownClient{t: t}
|
||||
}
|
||||
|
||||
func (t *TestJmapWellKnownClient) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *TestJmapWellKnownClient) GetWellKnown(username string, logger *log.Logger) (WellKnownResponse, error) {
|
||||
return WellKnownResponse{
|
||||
Username: generateRandomString(8),
|
||||
@@ -38,6 +42,10 @@ func NewTestJmapApiClient(t *testing.T) ApiClient {
|
||||
return &TestJmapApiClient{t: t}
|
||||
}
|
||||
|
||||
func (t TestJmapApiClient) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func serveTestFile(t *testing.T, name string) ([]byte, error) {
|
||||
cwd, _ := os.Getwd()
|
||||
p := filepath.Join(cwd, "testdata", name)
|
||||
@@ -89,7 +97,7 @@ func TestRequests(t *testing.T) {
|
||||
|
||||
session := Session{AccountId: "123", JmapUrl: "test://"}
|
||||
|
||||
folders, err := client.GetMailboxes(&session, ctx, &logger)
|
||||
folders, err := client.GetAllMailboxes(&session, ctx, &logger)
|
||||
require.NoError(err)
|
||||
require.Len(folders.List, 5)
|
||||
|
||||
|
||||
@@ -4,10 +4,9 @@ import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/jmap"
|
||||
@@ -32,8 +31,11 @@ type Groupware struct {
|
||||
usernameProvider jmap.HttpJmapUsernameProvider // we also need it for ourselves for now
|
||||
defaultEmailLimit int
|
||||
maxBodyValueBytes int
|
||||
io.Closer
|
||||
}
|
||||
|
||||
var _ io.Closer = Groupware{}
|
||||
|
||||
type ItemBody struct {
|
||||
Content string `json:"content"`
|
||||
ContentType string `json:"contentType"` // text|html
|
||||
@@ -103,24 +105,11 @@ func NewGroupware(logger *log.Logger, config *config.Config) *Groupware {
|
||||
|
||||
jmapClient := jmap.NewClient(api, api)
|
||||
|
||||
loader := ttlcache.LoaderFunc[string, jmap.Session](
|
||||
func(c *ttlcache.Cache[string, jmap.Session], key string) *ttlcache.Item[string, jmap.Session] {
|
||||
jmapContext, err := jmapClient.FetchSession(key, logger)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Str("username", key).Msg("failed to retrieve well-known")
|
||||
return nil
|
||||
}
|
||||
item := c.Set(key, jmapContext, config.Mail.SessionCacheTTL)
|
||||
return item
|
||||
},
|
||||
)
|
||||
|
||||
sessionCache := ttlcache.New(
|
||||
ttlcache.WithTTL[string, jmap.Session](
|
||||
config.Mail.SessionCacheTTL,
|
||||
),
|
||||
ttlcache.WithDisableTouchOnHit[string, jmap.Session](),
|
||||
ttlcache.WithLoader(loader),
|
||||
)
|
||||
go sessionCache.Start()
|
||||
|
||||
@@ -134,23 +123,33 @@ func NewGroupware(logger *log.Logger, config *config.Config) *Groupware {
|
||||
}
|
||||
}
|
||||
|
||||
func pickInbox(folders []jmap.Mailbox) string {
|
||||
for _, folder := range folders {
|
||||
if folder.Role == "inbox" {
|
||||
return folder.Id
|
||||
}
|
||||
}
|
||||
return ""
|
||||
func (g Groupware) Close() error {
|
||||
g.sessionCache.Stop()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g Groupware) session(ctx context.Context, logger *log.Logger) (jmap.Session, bool, error) {
|
||||
username, err := g.usernameProvider.GetUsername(ctx, logger)
|
||||
func (g Groupware) session(req *http.Request, ctx context.Context, logger *log.Logger) (jmap.Session, bool, error) {
|
||||
username, err := g.usernameProvider.GetUsername(req, ctx, logger)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("failed to retrieve username")
|
||||
return jmap.Session{}, false, err
|
||||
}
|
||||
|
||||
item := g.sessionCache.Get(username)
|
||||
fetchErrRef := atomic.Value{}
|
||||
item, _ := g.sessionCache.GetOrSetFunc(username, func() jmap.Session {
|
||||
jmapContext, err := g.jmapClient.FetchSession(username, logger)
|
||||
if err != nil {
|
||||
fetchErrRef.Store(err)
|
||||
logger.Error().Err(err).Str("username", username).Msg("failed to retrieve well-known")
|
||||
return jmap.Session{}
|
||||
}
|
||||
return jmapContext
|
||||
})
|
||||
p := fetchErrRef.Load()
|
||||
if p != nil {
|
||||
err = p.(error)
|
||||
return jmap.Session{}, false, err
|
||||
}
|
||||
if item != nil {
|
||||
return item.Value(), true, nil
|
||||
} else {
|
||||
@@ -161,7 +160,7 @@ func (g Groupware) session(ctx context.Context, logger *log.Logger) (jmap.Sessio
|
||||
func (g Groupware) withSession(w http.ResponseWriter, r *http.Request, handler func(r *http.Request, ctx context.Context, logger log.Logger, session *jmap.Session) (any, error)) {
|
||||
ctx := r.Context()
|
||||
logger := g.logger.SubloggerWithRequestID(ctx)
|
||||
session, ok, err := g.session(ctx, &logger)
|
||||
session, ok, err := g.session(r, ctx, &logger)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Interface(logQuery, r.URL.Query()).Msg("failed to determine JMAP session")
|
||||
errorcode.ServiceNotAvailable.Render(w, r, http.StatusInternalServerError, err.Error())
|
||||
@@ -194,7 +193,7 @@ func (g Groupware) GetIdentity(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (g Groupware) GetVacation(w http.ResponseWriter, r *http.Request) {
|
||||
g.withSession(w, r, func(r *http.Request, ctx context.Context, logger log.Logger, session *jmap.Session) (any, error) {
|
||||
return g.jmapClient.GetVacation(session, ctx, &logger)
|
||||
return g.jmapClient.GetVacationResponse(session, ctx, &logger)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -209,7 +208,7 @@ func (g Groupware) GetMessages(w http.ResponseWriter, r *http.Request) {
|
||||
logger = log.Logger{Logger: logger.With().Int("$top", limit).Logger()}
|
||||
}
|
||||
|
||||
mailboxGetResponse, err := g.jmapClient.GetMailboxes(session, ctx, &logger)
|
||||
mailboxGetResponse, err := g.jmapClient.GetAllMailboxes(session, ctx, &logger)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -236,160 +235,3 @@ func (g Groupware) GetMessages(w http.ResponseWriter, r *http.Request) {
|
||||
return Messages{Context: odataContext.String(), Value: messages}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func mapContentType(jmap string) string {
|
||||
switch jmap {
|
||||
case "text/html":
|
||||
return "html"
|
||||
case "text/plain":
|
||||
return "text"
|
||||
default:
|
||||
return jmap
|
||||
}
|
||||
}
|
||||
|
||||
func foldBody(email jmap.Email) *ItemBody {
|
||||
if email.BodyValues != nil {
|
||||
if len(email.HtmlBody) > 0 {
|
||||
pick := email.HtmlBody[0]
|
||||
content, ok := email.BodyValues[pick.PartId]
|
||||
if ok {
|
||||
return &ItemBody{Content: content.Value, ContentType: mapContentType(pick.Type)}
|
||||
}
|
||||
}
|
||||
if len(email.TextBody) > 0 {
|
||||
pick := email.TextBody[0]
|
||||
content, ok := email.BodyValues[pick.PartId]
|
||||
if ok {
|
||||
return &ItemBody{Content: content.Value, ContentType: mapContentType(pick.Type)}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func firstOf[T any](ary []T) T {
|
||||
if len(ary) > 0 {
|
||||
return ary[0]
|
||||
}
|
||||
var nothing T
|
||||
return nothing
|
||||
}
|
||||
|
||||
func emailAddress(j jmap.EmailAddress) EmailAddress {
|
||||
return EmailAddress{Address: j.Email, Name: j.Name}
|
||||
}
|
||||
|
||||
func emailAddresses(j []jmap.EmailAddress) []EmailAddress {
|
||||
result := make([]EmailAddress, len(j))
|
||||
for i := 0; i < len(j); i++ {
|
||||
result[i] = emailAddress(j[i])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func hasKeyword(j jmap.Email, kw string) bool {
|
||||
value, ok := j.Keywords[kw]
|
||||
if ok {
|
||||
return value
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func categories(j jmap.Email) []string {
|
||||
categories := []string{}
|
||||
for k, v := range j.Keywords {
|
||||
if v && !strings.HasPrefix(k, jmap.JmapKeywordPrefix) {
|
||||
categories = append(categories, k)
|
||||
}
|
||||
}
|
||||
return categories
|
||||
}
|
||||
|
||||
/*
|
||||
func toEdmBinary(value int) string {
|
||||
return fmt.Sprintf("%X", value)
|
||||
}
|
||||
*/
|
||||
|
||||
// https://learn.microsoft.com/en-us/graph/api/resources/message?view=graph-rest-1.0
|
||||
func message(email jmap.Email, state string) Message {
|
||||
body := foldBody(email)
|
||||
importance := "" // omit "normal" as it is expected to be the default
|
||||
if hasKeyword(email, jmap.JmapKeywordFlagged) {
|
||||
importance = "high"
|
||||
}
|
||||
|
||||
mailboxId := ""
|
||||
for k, v := range email.MailboxIds {
|
||||
if v {
|
||||
// TODO how to map JMAP short identifiers (e.g. 'a') to something uniquely addressable for the clients?
|
||||
// e.g. do we need to include tenant/sharding/cluster information?
|
||||
mailboxId = k
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// TODO how to map JMAP short identifiers (e.g. 'a') to something uniquely addressable for the clients?
|
||||
// e.g. do we need to include tenant/sharding/cluster information?
|
||||
id := email.Id
|
||||
// for this one too:
|
||||
messageId := firstOf(email.MessageId)
|
||||
// as well as this one:
|
||||
threadId := email.ThreadId
|
||||
|
||||
categories := categories(email)
|
||||
|
||||
var from *EmailAddress = nil
|
||||
if len(email.From) > 0 {
|
||||
e := emailAddress(email.From[0])
|
||||
from = &e
|
||||
}
|
||||
|
||||
// TODO how to map JMAP state to an OData Etag?
|
||||
etag := state
|
||||
|
||||
weblink, err := url.JoinPath("/groupware/mail", id)
|
||||
if err != nil {
|
||||
weblink = ""
|
||||
}
|
||||
|
||||
return Message{
|
||||
Etag: etag,
|
||||
Id: id,
|
||||
Subject: email.Subject,
|
||||
CreatedDateTime: email.ReceivedAt,
|
||||
ReceivedDateTime: email.ReceivedAt,
|
||||
SentDateTime: email.SentAt,
|
||||
HasAttachments: email.HasAttachments,
|
||||
InternetMessageId: messageId,
|
||||
BodyPreview: email.Preview,
|
||||
Body: body,
|
||||
From: from,
|
||||
ToRecipients: emailAddresses(email.To),
|
||||
CcRecipients: emailAddresses(email.Cc),
|
||||
BccRecipients: emailAddresses(email.Bcc),
|
||||
ReplyTo: emailAddresses(email.ReplyTo),
|
||||
IsRead: hasKeyword(email, jmap.JmapKeywordSeen),
|
||||
IsDraft: hasKeyword(email, jmap.JmapKeywordDraft),
|
||||
Importance: importance,
|
||||
ParentFolderId: mailboxId,
|
||||
Categories: categories,
|
||||
ConversationId: threadId,
|
||||
WebLink: weblink,
|
||||
// ConversationIndex: toEdmBinary(email.ThreadIndex),
|
||||
} // TODO more email fields
|
||||
}
|
||||
|
||||
func parseNumericParam(r *http.Request, param string, defaultValue int) (int, bool, error) {
|
||||
str := r.URL.Query().Get(param)
|
||||
if str == "" {
|
||||
return defaultValue, false, nil
|
||||
}
|
||||
|
||||
value, err := strconv.ParseInt(str, 10, 0)
|
||||
if err != nil {
|
||||
return defaultValue, false, nil
|
||||
}
|
||||
return int(value), true, nil
|
||||
}
|
||||
|
||||
176
services/graph/pkg/service/v0/groupware/groupware_tools.go
Normal file
176
services/graph/pkg/service/v0/groupware/groupware_tools.go
Normal file
@@ -0,0 +1,176 @@
|
||||
package groupware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/jmap"
|
||||
)
|
||||
|
||||
func pickInbox(folders []jmap.Mailbox) string {
|
||||
for _, folder := range folders {
|
||||
if folder.Role == "inbox" {
|
||||
return folder.Id
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func mapContentType(jmap string) string {
|
||||
switch jmap {
|
||||
case "text/html":
|
||||
return "html"
|
||||
case "text/plain":
|
||||
return "text"
|
||||
default:
|
||||
return jmap
|
||||
}
|
||||
}
|
||||
|
||||
func foldBody(email jmap.Email) *ItemBody {
|
||||
if email.BodyValues != nil {
|
||||
if len(email.HtmlBody) > 0 {
|
||||
pick := email.HtmlBody[0]
|
||||
content, ok := email.BodyValues[pick.PartId]
|
||||
if ok {
|
||||
return &ItemBody{Content: content.Value, ContentType: mapContentType(pick.Type)}
|
||||
}
|
||||
}
|
||||
if len(email.TextBody) > 0 {
|
||||
pick := email.TextBody[0]
|
||||
content, ok := email.BodyValues[pick.PartId]
|
||||
if ok {
|
||||
return &ItemBody{Content: content.Value, ContentType: mapContentType(pick.Type)}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func firstOf[T any](ary []T) T {
|
||||
if len(ary) > 0 {
|
||||
return ary[0]
|
||||
}
|
||||
var nothing T
|
||||
return nothing
|
||||
}
|
||||
|
||||
func emailAddress(j jmap.EmailAddress) EmailAddress {
|
||||
return EmailAddress{Address: j.Email, Name: j.Name}
|
||||
}
|
||||
|
||||
func emailAddresses(j []jmap.EmailAddress) []EmailAddress {
|
||||
result := make([]EmailAddress, len(j))
|
||||
for i := 0; i < len(j); i++ {
|
||||
result[i] = emailAddress(j[i])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func hasKeyword(j jmap.Email, kw string) bool {
|
||||
value, ok := j.Keywords[kw]
|
||||
if ok {
|
||||
return value
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func categories(j jmap.Email) []string {
|
||||
categories := []string{}
|
||||
for k, v := range j.Keywords {
|
||||
if v && !strings.HasPrefix(k, jmap.JmapKeywordPrefix) {
|
||||
categories = append(categories, k)
|
||||
}
|
||||
}
|
||||
return categories
|
||||
}
|
||||
|
||||
/*
|
||||
func toEdmBinary(value int) string {
|
||||
return fmt.Sprintf("%X", value)
|
||||
}
|
||||
*/
|
||||
|
||||
// https://learn.microsoft.com/en-us/graph/api/resources/message?view=graph-rest-1.0
|
||||
func message(email jmap.Email, state string) Message {
|
||||
body := foldBody(email)
|
||||
importance := "" // omit "normal" as it is expected to be the default
|
||||
if hasKeyword(email, jmap.JmapKeywordFlagged) {
|
||||
importance = "high"
|
||||
}
|
||||
|
||||
mailboxId := ""
|
||||
for k, v := range email.MailboxIds {
|
||||
if v {
|
||||
// TODO how to map JMAP short identifiers (e.g. 'a') to something uniquely addressable for the clients?
|
||||
// e.g. do we need to include tenant/sharding/cluster information?
|
||||
mailboxId = k
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// TODO how to map JMAP short identifiers (e.g. 'a') to something uniquely addressable for the clients?
|
||||
// e.g. do we need to include tenant/sharding/cluster information?
|
||||
id := email.Id
|
||||
// for this one too:
|
||||
messageId := firstOf(email.MessageId)
|
||||
// as well as this one:
|
||||
threadId := email.ThreadId
|
||||
|
||||
categories := categories(email)
|
||||
|
||||
var from *EmailAddress = nil
|
||||
if len(email.From) > 0 {
|
||||
e := emailAddress(email.From[0])
|
||||
from = &e
|
||||
}
|
||||
|
||||
// TODO how to map JMAP state to an OData Etag?
|
||||
etag := state
|
||||
|
||||
weblink, err := url.JoinPath("/groupware/mail", id)
|
||||
if err != nil {
|
||||
weblink = ""
|
||||
}
|
||||
|
||||
return Message{
|
||||
Etag: etag,
|
||||
Id: id,
|
||||
Subject: email.Subject,
|
||||
CreatedDateTime: email.ReceivedAt,
|
||||
ReceivedDateTime: email.ReceivedAt,
|
||||
SentDateTime: email.SentAt,
|
||||
HasAttachments: email.HasAttachments,
|
||||
InternetMessageId: messageId,
|
||||
BodyPreview: email.Preview,
|
||||
Body: body,
|
||||
From: from,
|
||||
ToRecipients: emailAddresses(email.To),
|
||||
CcRecipients: emailAddresses(email.Cc),
|
||||
BccRecipients: emailAddresses(email.Bcc),
|
||||
ReplyTo: emailAddresses(email.ReplyTo),
|
||||
IsRead: hasKeyword(email, jmap.JmapKeywordSeen),
|
||||
IsDraft: hasKeyword(email, jmap.JmapKeywordDraft),
|
||||
Importance: importance,
|
||||
ParentFolderId: mailboxId,
|
||||
Categories: categories,
|
||||
ConversationId: threadId,
|
||||
WebLink: weblink,
|
||||
// ConversationIndex: toEdmBinary(email.ThreadIndex),
|
||||
} // TODO more email fields
|
||||
}
|
||||
|
||||
func parseNumericParam(r *http.Request, param string, defaultValue int) (int, bool, error) {
|
||||
str := r.URL.Query().Get(param)
|
||||
if str == "" {
|
||||
return defaultValue, false, nil
|
||||
}
|
||||
|
||||
value, err := strconv.ParseInt(str, 10, 0)
|
||||
if err != nil {
|
||||
return defaultValue, false, nil
|
||||
}
|
||||
return int(value), true, nil
|
||||
}
|
||||
@@ -109,7 +109,7 @@ func httpApiClient(config *config.Config, usernameProvider jmap.HttpJmapUsername
|
||||
}
|
||||
func (g Groupware) WellDefined(w http.ResponseWriter, r *http.Request) {
|
||||
logger := g.logger.SubloggerWithRequestID(r.Context())
|
||||
username, err := g.usernameProvider.GetUsername(r.Context(), &logger)
|
||||
username, err := g.usernameProvider.GetUsername(r, r.Context(), &logger)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user