groupware: more JMAP operations implementation

This commit is contained in:
Pascal Bleser
2025-08-01 17:22:14 +02:00
parent b4c8b5a3f4
commit cc41e511c3
7 changed files with 1379 additions and 152 deletions

View File

@@ -2,6 +2,7 @@ package jmap
import (
"context"
"encoding/base64"
"fmt"
"io"
"net/url"
@@ -55,14 +56,51 @@ func NewClient(wellKnown SessionClient, api ApiClient) Client {
type Session struct {
// The name of the user to use to authenticate against Stalwart
Username string
// The base URL to use for JMAP operations towards Stalwart
JmapUrl url.URL
// The upload URL template
UploadUrlTemplate string
// TODO
DefaultMailAccountId string
SessionResponse
}
// Create a new Session from a SessionResponse.
func newSession(sessionResponse SessionResponse) (Session, Error) {
username := sessionResponse.Username
if username == "" {
return Session{}, SimpleError{code: JmapErrorInvalidSessionResponse, err: fmt.Errorf("JMAP session response does not provide a username")}
}
mailAccountId := sessionResponse.PrimaryAccounts.Mail
if mailAccountId == "" {
return Session{}, SimpleError{code: JmapErrorInvalidSessionResponse, err: fmt.Errorf("JMAP session response does not provide a primary mail account")}
}
apiStr := sessionResponse.ApiUrl
if apiStr == "" {
return Session{}, SimpleError{code: JmapErrorInvalidSessionResponse, err: fmt.Errorf("JMAP session response does not provide an API URL")}
}
apiUrl, err := url.Parse(apiStr)
if err != nil {
return Session{}, SimpleError{code: JmapErrorInvalidSessionResponse, err: fmt.Errorf("JMAP session response provides an invalid API URL")}
}
uploadUrl := sessionResponse.UploadUrl
if uploadUrl == "" {
return Session{}, SimpleError{code: JmapErrorInvalidSessionResponse, err: fmt.Errorf("JMAP session response does not provide an upload URL")}
}
return Session{
Username: username,
DefaultMailAccountId: mailAccountId,
JmapUrl: *apiUrl,
UploadUrlTemplate: uploadUrl,
SessionResponse: sessionResponse,
}, nil
}
func (s *Session) MailAccountId(accountId string) string {
if accountId != "" && accountId != defaultAccountId {
return accountId
@@ -71,6 +109,14 @@ func (s *Session) MailAccountId(accountId string) string {
return s.DefaultMailAccountId
}
func (s *Session) BlobAccountId(accountId string) string {
if accountId != "" && accountId != defaultAccountId {
return accountId
}
// TODO(pbleser-oc) handle case where there is no default blob account
return s.PrimaryAccounts.Blob
}
const (
logOperation = "operation"
logUsername = "username"
@@ -81,6 +127,7 @@ const (
logLimit = "limit"
logApiUrl = "apiurl"
logSessionState = "session-state"
logSince = "since"
defaultAccountId = "*"
@@ -114,32 +161,6 @@ func (j *Client) onSessionOutdated(session *Session) {
})
}
// Create a new Session from a WellKnownResponse.
func newSession(sessionResponse SessionResponse) (Session, Error) {
username := sessionResponse.Username
if username == "" {
return Session{}, SimpleError{code: JmapErrorInvalidSessionResponse, err: fmt.Errorf("JMAP session response does not provide a username")}
}
mailAccountId := sessionResponse.PrimaryAccounts.Mail
if mailAccountId == "" {
return Session{}, SimpleError{code: JmapErrorInvalidSessionResponse, err: fmt.Errorf("JMAP session response does not provide a primary mail account")}
}
apiStr := sessionResponse.ApiUrl
if apiStr == "" {
return Session{}, SimpleError{code: JmapErrorInvalidSessionResponse, err: fmt.Errorf("JMAP session response does not provide an API URL")}
}
apiUrl, err := url.Parse(apiStr)
if err != nil {
return Session{}, SimpleError{code: JmapErrorInvalidSessionResponse, err: fmt.Errorf("JMAP session response provides an invalid API URL")}
}
return Session{
Username: username,
DefaultMailAccountId: mailAccountId,
JmapUrl: *apiUrl,
SessionResponse: sessionResponse,
}, 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.GetSession(username, logger)
@@ -242,7 +263,7 @@ func (j *Client) SearchMailboxes(accountId string, session *Session, ctx context
invocation(MailboxQuery, SimpleMailboxQueryCommand{AccountId: aid, Filter: filter}, "0"),
invocation(MailboxGet, MailboxGetRefCommand{
AccountId: aid,
IdRef: &Ref{Name: MailboxQuery, Path: "/ids/*", ResultOf: "0"},
IdRef: &ResultReference{Name: MailboxQuery, Path: "/ids/*", ResultOf: "0"},
}, "1"),
)
if err != nil {
@@ -310,7 +331,7 @@ func (j *Client) GetAllEmails(accountId string, session *Session, ctx context.Co
get := EmailGetRefCommand{
AccountId: aid,
FetchAllBodyValues: fetchBodies,
IdRef: &Ref{Name: EmailQuery, Path: "/ids/*", ResultOf: "0"},
IdRef: &ResultReference{Name: EmailQuery, Path: "/ids/*", ResultOf: "0"},
}
if maxBodyValueBytes >= 0 {
get.MaxBodyValueBytes = maxBodyValueBytes
@@ -333,3 +354,330 @@ func (j *Client) GetAllEmails(accountId string, session *Session, ctx context.Co
return Emails{Emails: response.List, State: body.SessionState}, nil
})
}
type EmailsSince struct {
Destroyed []string `json:"destroyed,omitzero"`
HasMoreChanges bool `json:"hasMoreChanges,omitzero"`
NewState string `json:"newState"`
Created []Email `json:"created,omitempty"`
Updated []Email `json:"updated,omitempty"`
State string `json:"state,omitempty"`
}
func (j *Client) GetEmailsInMailboxSince(accountId string, session *Session, ctx context.Context, logger *log.Logger, mailboxId string, since string, fetchBodies bool, maxBodyValueBytes int, maxChanges int) (EmailsSince, Error) {
aid := session.MailAccountId(accountId)
logger = j.loggerParams(aid, "GetEmailsInMailboxSince", session, logger, func(z zerolog.Context) zerolog.Context {
return z.Bool(logFetchBodies, fetchBodies).Str(logSince, since)
})
changes := MailboxChangesCommand{
AccountId: aid,
SinceState: since,
}
if maxChanges >= 0 {
changes.MaxChanges = maxChanges
}
getCreated := EmailGetRefCommand{
AccountId: aid,
FetchAllBodyValues: fetchBodies,
IdRef: &ResultReference{Name: MailboxChanges, Path: "/created", ResultOf: "0"},
}
if maxBodyValueBytes >= 0 {
getCreated.MaxBodyValueBytes = maxBodyValueBytes
}
getUpdated := EmailGetRefCommand{
AccountId: aid,
FetchAllBodyValues: fetchBodies,
IdRef: &ResultReference{Name: MailboxChanges, Path: "/updated", ResultOf: "0"},
}
if maxBodyValueBytes >= 0 {
getUpdated.MaxBodyValueBytes = maxBodyValueBytes
}
cmd, err := request(
invocation(MailboxChanges, changes, "0"),
invocation(EmailGet, getCreated, "1"),
invocation(EmailGet, getUpdated, "2"),
)
if err != nil {
return EmailsSince{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err}
}
return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (EmailsSince, Error) {
var mailboxResponse MailboxChangesResponse
err = retrieveResponseMatchParameters(body, MailboxChanges, "0", &mailboxResponse)
if err != nil {
return EmailsSince{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
}
var createdResponse EmailGetResponse
err = retrieveResponseMatchParameters(body, EmailGet, "1", &createdResponse)
if err != nil {
return EmailsSince{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
}
var updatedResponse EmailGetResponse
err = retrieveResponseMatchParameters(body, EmailGet, "2", &updatedResponse)
if err != nil {
return EmailsSince{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
}
return EmailsSince{
Destroyed: mailboxResponse.Destroyed,
HasMoreChanges: mailboxResponse.HasMoreChanges,
NewState: mailboxResponse.NewState,
Created: createdResponse.List,
Updated: createdResponse.List,
State: body.SessionState,
}, nil
})
}
func (j *Client) GetEmailsSince(accountId string, session *Session, ctx context.Context, logger *log.Logger, since string, fetchBodies bool, maxBodyValueBytes int, maxChanges int) (EmailsSince, Error) {
aid := session.MailAccountId(accountId)
logger = j.loggerParams(aid, "GetEmailsSince", session, logger, func(z zerolog.Context) zerolog.Context {
return z.Bool(logFetchBodies, fetchBodies).Str(logSince, since)
})
changes := EmailChangesCommand{
AccountId: aid,
SinceState: since,
}
if maxChanges >= 0 {
changes.MaxChanges = maxChanges
}
getCreated := EmailGetRefCommand{
AccountId: aid,
FetchAllBodyValues: fetchBodies,
IdRef: &ResultReference{Name: EmailChanges, Path: "/created", ResultOf: "0"},
}
if maxBodyValueBytes >= 0 {
getCreated.MaxBodyValueBytes = maxBodyValueBytes
}
getUpdated := EmailGetRefCommand{
AccountId: aid,
FetchAllBodyValues: fetchBodies,
IdRef: &ResultReference{Name: EmailChanges, Path: "/updated", ResultOf: "0"},
}
if maxBodyValueBytes >= 0 {
getUpdated.MaxBodyValueBytes = maxBodyValueBytes
}
cmd, err := request(
invocation(EmailChanges, changes, "0"),
invocation(EmailGet, getCreated, "1"),
invocation(EmailGet, getUpdated, "2"),
)
if err != nil {
return EmailsSince{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err}
}
return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (EmailsSince, Error) {
var changesResponse EmailChangesResponse
err = retrieveResponseMatchParameters(body, EmailChanges, "0", &changesResponse)
if err != nil {
return EmailsSince{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
}
var createdResponse EmailGetResponse
err = retrieveResponseMatchParameters(body, EmailGet, "1", &createdResponse)
if err != nil {
return EmailsSince{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
}
var updatedResponse EmailGetResponse
err = retrieveResponseMatchParameters(body, EmailGet, "2", &updatedResponse)
if err != nil {
return EmailsSince{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
}
return EmailsSince{
Destroyed: changesResponse.Destroyed,
HasMoreChanges: changesResponse.HasMoreChanges,
NewState: changesResponse.NewState,
Created: createdResponse.List,
Updated: createdResponse.List,
State: body.SessionState,
}, nil
})
}
func (j *Client) GetBlob(accountId string, session *Session, ctx context.Context, logger *log.Logger, id string) (*Blob, Error) {
aid := session.BlobAccountId(accountId)
cmd, err := request(
invocation(BlobUpload, BlobGetCommand{
AccountId: aid,
Ids: []string{id},
Properties: []string{BlobPropertyData, BlobPropertyDigestSha512, BlobPropertySize},
}, "0"),
)
if err != nil {
return nil, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err}
}
return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (*Blob, Error) {
var response BlobGetResponse
err = retrieveResponseMatchParameters(body, BlobGet, "0", &response)
if err != nil {
return nil, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
}
if len(response.List) != 1 {
return nil, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
}
get := response.List[0]
return &get, nil
})
}
type UploadedBlob struct {
Id string `json:"id"`
Size int `json:"size"`
Type string `json:"type"`
Sha512 string `json:"sha:512"`
}
func (j *Client) UploadBlob(accountId string, session *Session, ctx context.Context, logger *log.Logger, data []byte, contentType string) (UploadedBlob, Error) {
aid := session.MailAccountId(accountId)
encoded := base64.StdEncoding.EncodeToString(data)
upload := BlobUploadCommand{
AccountId: aid,
Create: map[string]UploadObject{
"0": {
Data: []DataSourceObject{{
DataAsBase64: encoded,
}},
Type: contentType,
},
},
}
getHash := BlobGetRefCommand{
AccountId: aid,
IdRef: &ResultReference{
ResultOf: "0",
Name: BlobUpload,
Path: "/ids",
},
Properties: []string{BlobPropertyDigestSha512},
}
cmd, err := request(
invocation(BlobUpload, upload, "0"),
invocation(BlobGet, getHash, "1"),
)
if err != nil {
return UploadedBlob{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err}
}
return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (UploadedBlob, Error) {
var uploadResponse BlobUploadResponse
err = retrieveResponseMatchParameters(body, BlobUpload, "0", &uploadResponse)
if err != nil {
return UploadedBlob{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
}
var getResponse BlobGetResponse
err = retrieveResponseMatchParameters(body, BlobGet, "1", &getResponse)
if err != nil {
return UploadedBlob{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
}
if len(uploadResponse.Created) != 1 {
return UploadedBlob{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
}
upload, ok := uploadResponse.Created["0"]
if !ok {
return UploadedBlob{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
}
if len(getResponse.List) != 1 {
return UploadedBlob{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
}
get := getResponse.List[0]
return UploadedBlob{
Id: upload.Id,
Size: upload.Size,
Type: upload.Type,
Sha512: get.DigestSha512,
}, nil
})
}
func (j *Client) ImportEmail(accountId string, session *Session, ctx context.Context, logger *log.Logger, data []byte) (UploadedBlob, Error) {
aid := session.MailAccountId(accountId)
encoded := base64.StdEncoding.EncodeToString(data)
upload := BlobUploadCommand{
AccountId: aid,
Create: map[string]UploadObject{
"0": {
Data: []DataSourceObject{{
DataAsBase64: encoded,
}},
Type: EmailMimeType,
},
},
}
getHash := BlobGetRefCommand{
AccountId: aid,
IdRef: &ResultReference{
ResultOf: "0",
Name: BlobUpload,
Path: "/ids",
},
Properties: []string{BlobPropertyDigestSha512},
}
cmd, err := request(
invocation(BlobUpload, upload, "0"),
invocation(BlobGet, getHash, "1"),
)
if err != nil {
return UploadedBlob{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err}
}
return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (UploadedBlob, Error) {
var uploadResponse BlobUploadResponse
err = retrieveResponseMatchParameters(body, BlobUpload, "0", &uploadResponse)
if err != nil {
return UploadedBlob{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
}
var getResponse BlobGetResponse
err = retrieveResponseMatchParameters(body, BlobGet, "1", &getResponse)
if err != nil {
return UploadedBlob{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
}
if len(uploadResponse.Created) != 1 {
return UploadedBlob{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
}
upload, ok := uploadResponse.Created["0"]
if !ok {
return UploadedBlob{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
}
if len(getResponse.List) != 1 {
return UploadedBlob{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
}
get := getResponse.List[0]
return UploadedBlob{
Id: upload.Id,
Size: upload.Size,
Type: upload.Type,
Sha512: get.DigestSha512,
}, nil
})
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -122,11 +122,11 @@ func TestRequests(t *testing.T) {
{
email := emails.Emails[0]
require.Equal("Ornare Senectus Ultrices Elit", email.Subject)
require.Equal(false, email.HasAttachments)
require.Equal(false, email.HasAttachment)
}
{
email := emails.Emails[1]
require.Equal("Lorem Tortor Eros Blandit Adipiscing Scelerisque Fermentum", email.Subject)
require.Equal(false, email.HasAttachments)
require.Equal(false, email.HasAttachment)
}
}

View File

@@ -0,0 +1,25 @@
package groupware
import (
"fmt"
"net/http"
"github.com/go-chi/chi/v5"
)
func (g Groupware) GetBlob(w http.ResponseWriter, r *http.Request) {
g.respond(w, r, func(req Request) (any, string, *Error) {
blobId := chi.URLParam(req.r, UriParamBlobId)
if blobId == "" {
errorId := req.errorId()
msg := fmt.Sprintf("Invalid value for path parameter '%v': empty", UriParamBlobId)
return nil, "", apiError(errorId, ErrorInvalidRequestParameter,
withDetail(msg),
withSource(&ErrorSource{Parameter: UriParamBlobId}),
)
}
res, err := g.jmap.GetBlob(req.GetAccountId(), req.session, req.ctx, req.logger, blobId)
return res, res.Digest(), req.apiErrorFromJmap(err)
})
}

View File

@@ -106,6 +106,7 @@ func (g Groupware) GetMailboxes(w http.ResponseWriter, r *http.Request) {
if subscribed != "" {
b, err := strconv.ParseBool(subscribed)
if err != nil {
// TODO proper response object
w.WriteHeader(http.StatusBadRequest)
return
}

View File

@@ -12,45 +12,70 @@ import (
func (g Groupware) GetAllMessages(w http.ResponseWriter, r *http.Request) {
mailboxId := chi.URLParam(r, UriParamMailboxId)
g.respond(w, r, func(req Request) (any, string, *Error) {
if mailboxId == "" {
errorId := req.errorId()
msg := fmt.Sprintf("Missing required mailbox ID path parameter '%v'", UriParamMailboxId)
return nil, "", apiError(errorId, ErrorInvalidRequestParameter,
withDetail(msg),
withSource(&ErrorSource{Parameter: UriParamMailboxId}),
)
}
page, ok, err := req.parseNumericParam(QueryParamPage, -1)
if err != nil {
return nil, "", err
}
logger := req.logger
if ok {
logger = &log.Logger{Logger: logger.With().Int(QueryParamPage, page).Logger()}
}
since := r.Header.Get(HeaderSince)
size, ok, err := req.parseNumericParam(QueryParamSize, -1)
if err != nil {
return nil, "", err
}
if ok {
logger = &log.Logger{Logger: logger.With().Int(QueryParamSize, size).Logger()}
}
if since != "" {
// ... then it's a completely different operation
maxChanges := -1
g.respond(w, r, func(req Request) (any, string, *Error) {
if mailboxId == "" {
errorId := req.errorId()
msg := fmt.Sprintf("Missing required mailbox ID path parameter '%v'", UriParamMailboxId)
return nil, "", apiError(errorId, ErrorInvalidRequestParameter,
withDetail(msg),
withSource(&ErrorSource{Parameter: UriParamMailboxId}),
)
}
logger := &log.Logger{Logger: req.logger.With().Str(HeaderSince, since).Logger()}
offset := page * size
limit := size
if limit < 0 {
limit = g.defaultEmailLimit
}
emails, jerr := g.jmap.GetEmailsInMailboxSince(req.GetAccountId(), req.session, req.ctx, logger, mailboxId, since, true, g.maxBodyValueBytes, maxChanges)
if jerr != nil {
return nil, "", req.apiErrorFromJmap(jerr)
}
emails, jerr := g.jmap.GetAllEmails(req.GetAccountId(), req.session, req.ctx, logger, mailboxId, offset, limit, true, g.maxBodyValueBytes)
if jerr != nil {
return nil, "", req.apiErrorFromJmap(jerr)
}
return emails, emails.State, nil
})
} else {
g.respond(w, r, func(req Request) (any, string, *Error) {
if mailboxId == "" {
errorId := req.errorId()
msg := fmt.Sprintf("Missing required mailbox ID path parameter '%v'", UriParamMailboxId)
return nil, "", apiError(errorId, ErrorInvalidRequestParameter,
withDetail(msg),
withSource(&ErrorSource{Parameter: UriParamMailboxId}),
)
}
page, ok, err := req.parseNumericParam(QueryParamPage, -1)
if err != nil {
return nil, "", err
}
logger := req.logger
if ok {
logger = &log.Logger{Logger: logger.With().Int(QueryParamPage, page).Logger()}
}
return emails, emails.State, nil
})
size, ok, err := req.parseNumericParam(QueryParamSize, -1)
if err != nil {
return nil, "", err
}
if ok {
logger = &log.Logger{Logger: logger.With().Int(QueryParamSize, size).Logger()}
}
offset := page * size
limit := size
if limit < 0 {
limit = g.defaultEmailLimit
}
emails, jerr := g.jmap.GetAllEmails(req.GetAccountId(), req.session, req.ctx, logger, mailboxId, offset, limit, true, g.maxBodyValueBytes)
if jerr != nil {
return nil, "", req.apiErrorFromJmap(jerr)
}
return emails, emails.State, nil
})
}
}
func (g Groupware) GetMessagesById(w http.ResponseWriter, r *http.Request) {
@@ -75,3 +100,22 @@ func (g Groupware) GetMessagesById(w http.ResponseWriter, r *http.Request) {
return emails, emails.State, nil
})
}
func (g Groupware) GetMessageUpdates(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
since := q.Get(QueryParamSince)
if since == "" {
since = r.Header.Get("If-None-Match")
}
maxChanges := -1
g.respond(w, r, func(req Request) (any, string, *Error) {
logger := &log.Logger{Logger: req.logger.With().Str(HeaderSince, since).Logger()}
emails, jerr := g.jmap.GetEmailsSince(req.GetAccountId(), req.session, req.ctx, logger, since, true, g.maxBodyValueBytes, maxChanges)
if jerr != nil {
return nil, "", req.apiErrorFromJmap(jerr)
}
return emails, emails.State, nil
})
}

View File

@@ -10,6 +10,9 @@ const (
QueryParamPage = "page"
QueryParamSize = "size"
UriParamMessagesId = "id"
UriParamBlobId = "blobid"
QueryParamSince = "since"
HeaderSince = "if-none-match"
)
func (g Groupware) Route(r chi.Router) {
@@ -21,8 +24,10 @@ func (g Groupware) Route(r chi.Router) {
r.Get("/mailboxes/{mailbox}", g.GetMailbox)
r.Get("/mailboxes/{mailbox}/messages", g.GetAllMessages)
r.Get("/messages/{id}", g.GetMessagesById)
r.Get("/messages", g.GetMessageUpdates)
r.Get("/identity", g.GetIdentity)
r.Get("/vacation", g.GetVacation)
r.Get("/blobs/{blobid}", g.GetBlob)
})
r.NotFound(g.NotFound)
}