Groupware and jmap: cleanup and API documentation

This commit is contained in:
Pascal Bleser
2025-07-07 10:37:43 +02:00
parent 8df4ef67a2
commit d544efdec7
9 changed files with 355 additions and 226 deletions

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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
}

View 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
}

View File

@@ -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