refactored the Session object, refactored the services/groupware directory, and started Swagger documentation implementation

This commit is contained in:
Pascal Bleser
2025-07-30 18:50:36 +02:00
parent 2c41319a27
commit 7b4aafad34
16 changed files with 606 additions and 111 deletions

View File

@@ -47,9 +47,14 @@ func NewClient(wellKnown SessionClient, api ApiClient) Client {
// 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
AccountId string // The identifier of the account to use when performing JMAP operations with Stalwart
JmapUrl url.URL // The base URL to use for JMAP operations towards Stalwart
// 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
// TODO
MailAccountId string
SessionResponse
}
const (
@@ -75,7 +80,7 @@ const (
// 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(),
Logger: l.With().Str(logUsername, s.Username).Str(logAccountId, s.MailAccountId).Logger(),
}
}
@@ -85,8 +90,8 @@ func NewSession(sessionResponse SessionResponse) (Session, Error) {
if username == "" {
return Session{}, SimpleError{code: JmapErrorInvalidSessionResponse, err: fmt.Errorf("JMAP session response does not provide a username")}
}
accountId := sessionResponse.PrimaryAccounts[JmapMail]
if accountId == "" {
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
@@ -98,9 +103,10 @@ func NewSession(sessionResponse SessionResponse) (Session, Error) {
return Session{}, SimpleError{code: JmapErrorInvalidSessionResponse, err: fmt.Errorf("JMAP session response provides an invalid API URL")}
}
return Session{
Username: username,
AccountId: accountId,
JmapUrl: *apiUrl,
Username: username,
MailAccountId: mailAccountId,
JmapUrl: *apiUrl,
SessionResponse: sessionResponse,
}, nil
}
@@ -114,18 +120,18 @@ func (j *Client) FetchSession(username string, logger *log.Logger) (Session, Err
}
func (j *Client) logger(operation string, session *Session, logger *log.Logger) *log.Logger {
return &log.Logger{Logger: logger.With().Str(logOperation, operation).Str(logUsername, session.Username).Str(logAccountId, session.AccountId).Logger()}
return &log.Logger{Logger: logger.With().Str(logOperation, operation).Str(logUsername, session.Username).Str(logAccountId, session.MailAccountId).Logger()}
}
func (j *Client) loggerParams(operation string, session *Session, logger *log.Logger, params func(zerolog.Context) zerolog.Context) *log.Logger {
base := logger.With().Str(logOperation, operation).Str(logUsername, session.Username).Str(logAccountId, session.AccountId)
base := logger.With().Str(logOperation, operation).Str(logUsername, session.Username).Str(logAccountId, session.MailAccountId)
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 := request(invocation(IdentityGet, IdentityGetCommand{AccountId: session.AccountId}, "0"))
cmd, err := request(invocation(IdentityGet, IdentityGetCommand{AccountId: session.MailAccountId}, "0"))
if err != nil {
return IdentityGetResponse{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err}
}
@@ -139,7 +145,7 @@ func (j *Client) GetIdentity(session *Session, ctx context.Context, logger *log.
// 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"))
cmd, err := request(invocation(VacationResponseGet, VacationResponseGetCommand{AccountId: session.MailAccountId}, "0"))
if err != nil {
return VacationResponseGetResponse{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err}
}
@@ -153,7 +159,7 @@ func (j *Client) GetVacationResponse(session *Session, ctx context.Context, logg
// 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"))
cmd, err := request(invocation(MailboxGet, MailboxGetCommand{AccountId: session.MailAccountId, Ids: ids}, "0"))
if err != nil {
return MailboxGetResponse{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err}
}
@@ -171,7 +177,7 @@ func (j *Client) GetAllMailboxes(session *Session, ctx context.Context, logger *
// https://jmap.io/spec-mail.html#mailboxquery
func (j *Client) QueryMailbox(session *Session, ctx context.Context, logger *log.Logger, filter MailboxFilterCondition) (MailboxQueryResponse, Error) {
logger = j.logger("QueryMailbox", session, logger)
cmd, err := request(invocation(MailboxQuery, SimpleMailboxQueryCommand{AccountId: session.AccountId, Filter: filter}, "0"))
cmd, err := request(invocation(MailboxQuery, SimpleMailboxQueryCommand{AccountId: session.MailAccountId, Filter: filter}, "0"))
if err != nil {
return MailboxQueryResponse{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err}
}
@@ -191,9 +197,9 @@ func (j *Client) SearchMailboxes(session *Session, ctx context.Context, logger *
logger = j.logger("SearchMailboxes", session, logger)
cmd, err := request(
invocation(MailboxQuery, SimpleMailboxQueryCommand{AccountId: session.AccountId, Filter: filter}, "0"),
invocation(MailboxQuery, SimpleMailboxQueryCommand{AccountId: session.MailAccountId, Filter: filter}, "0"),
invocation(MailboxGet, MailboxGetRefCommand{
AccountId: session.AccountId,
AccountId: session.MailAccountId,
IdRef: &Ref{Name: MailboxQuery, Path: "/ids/*", ResultOf: "0"},
}, "1"),
)
@@ -222,7 +228,7 @@ func (j *Client) GetEmails(session *Session, ctx context.Context, logger *log.Lo
})
query := EmailQueryCommand{
AccountId: session.AccountId,
AccountId: session.MailAccountId,
Filter: &MessageFilter{InMailbox: mailboxId},
Sort: []Sort{{Property: emailSortByReceivedAt, IsAscending: false}},
CollapseThreads: true,
@@ -236,7 +242,7 @@ func (j *Client) GetEmails(session *Session, ctx context.Context, logger *log.Lo
}
get := EmailGetRefCommand{
AccountId: session.AccountId,
AccountId: session.MailAccountId,
FetchAllBodyValues: fetchBodies,
IdRef: &Ref{Name: EmailQuery, Path: "/ids/*", ResultOf: "0"},
}

View File

@@ -11,6 +11,11 @@ const (
JmapSubmission = "urn:ietf:params:jmap:submission"
JmapVacationResponse = "urn:ietf:params:jmap:vacationresponse"
JmapCalendars = "urn:ietf:params:jmap:calendars"
JmapSieve = "urn:ietf:params:jmap:sieve"
JmapBlob = "urn:ietf:params:jmap:blob"
JmapQuota = "urn:ietf:params:jmap:quota"
JmapWebsocket = "urn:ietf:params:jmap:websocket"
JmapKeywordPrefix = "$"
JmapKeywordSeen = "$seen"
JmapKeywordDraft = "$draft"
@@ -23,17 +28,119 @@ const (
JmapKeywordMdnSent = "$mdnsent"
)
type SessionMailAccountCapabilities struct {
MaxMailboxesPerEmail int `json:"maxMailboxesPerEmail"`
MaxMailboxDepth int `json:"maxMailboxDepth"`
MaxSizeMailboxName int `json:"maxSizeMailboxName"`
MaxSizeAttachmentsPerEmail int `json:"maxSizeAttachmentsPerEmail"`
EmailQuerySortOptions []string `json:"emailQuerySortOptions"`
MayCreateTopLevelMailbox bool `json:"mayCreateTopLevelMailbox"`
}
type SessionSubmissionAccountCapabilities struct {
MaxDelayedSend int `json:"maxDelayedSend"`
SubmissionExtensions map[string][]string `json:"submissionExtensions"`
}
type SessionVacationResponseAccountCapabilities struct {
}
type SessionSieveAccountCapabilities struct {
MaxSizeScriptName int `json:"maxSizeScriptName"`
MaxSizeScript int `json:"maxSizeScript"`
MaxNumberScripts int `json:"maxNumberScripts"`
MaxNumberRedirects int `json:"maxNumberRedirects"`
SieveExtensions []string `json:"sieveExtensions"`
NotificationMethods []string `json:"notificationMethods"`
ExternalLists any `json:"externalLists"` // ?
}
type SessionBlobAccountCapabilities struct {
MaxSizeBlobSet int `json:"maxSizeBlobSet"`
MaxDataSources int `json:"maxDataSources"`
SupportedTypeNames []string `json:"supportedTypeNames"`
SupportedDigestAlgorithms []string `json:"supportedDigestAlgorithms"`
}
type SessionQuotaAccountCapabilities struct {
}
type SessionAccountCapabilities struct {
Mail SessionMailAccountCapabilities `json:"urn:ietf:params:jmap:mail"`
Submission SessionSubmissionAccountCapabilities `json:"urn:ietf:params:jmap:submission"`
VacationResponse SessionVacationResponseAccountCapabilities `json:"urn:ietf:params:jmap:vacationresponse"`
Sieve SessionSieveAccountCapabilities `json:"urn:ietf:params:jmap:sieve"`
Blob SessionBlobAccountCapabilities `json:"urn:ietf:params:jmap:blob"`
Quota SessionQuotaAccountCapabilities `json:"urn:ietf:params:jmap:quota"`
}
type SessionAccount struct {
Name string `json:"name,omitempty"`
IsPersonal bool `json:"isPersonal"`
IsReadOnly bool `json:"isReadOnly"`
AccountCapabilities map[string]any `json:"accountCapabilities,omitempty"`
Name string `json:"name,omitempty"`
IsPersonal bool `json:"isPersonal"`
IsReadOnly bool `json:"isReadOnly"`
AccountCapabilities SessionAccountCapabilities `json:"accountCapabilities,omitempty"`
}
type SessionCoreCapabilities struct {
MaxSizeUpload int `json:"maxSizeUpload"`
MaxConcurrentUpload int `json:"maxConcurrentUpload"`
MaxSizeRequest int `json:"maxSizeRequest"`
MaxConcurrentRequests int `json:"maxConcurrentRequests"`
MaxCallsInRequest int `json:"maxCallsInRequest"`
MaxObjectsInGet int `json:"maxObjectsInGet"`
MaxObjectsInSet int `json:"maxObjectsInSet"`
CollationAlgorithms []string `json:"collationAlgorithms"`
}
type SessionMailCapabilities struct {
}
type SessionSubmissionCapabilities struct {
}
type SessionVacationResponseCapabilities struct {
}
type SessionSieveCapabilities struct {
}
type SessionBlobCapabilities struct {
}
type SessionQuotaCapabilities struct {
}
type SessionWebsocketCapabilities struct {
Url string `json:"url"`
SupportsPush bool `json:"supportsPush"`
}
type SessionCapabilities struct {
Core SessionCoreCapabilities `json:"urn:ietf:params:jmap:core"`
Mail SessionMailCapabilities `json:"urn:ietf:params:jmap:mail"`
Submission SessionSubmissionCapabilities `json:"urn:ietf:params:jmap:submission"`
VacationResponse SessionVacationResponseCapabilities `json:"urn:ietf:params:jmap:vacationresponse"`
Sieve SessionSieveCapabilities `json:"urn:ietf:params:jmap:sieve"`
Blob SessionBlobCapabilities `json:"urn:ietf:params:jmap:blob"`
Quota SessionQuotaCapabilities `json:"urn:ietf:params:jmap:quota"`
Websocket SessionWebsocketCapabilities `json:"urn:ietf:params:jmap:websocket"`
}
type SessionPrimaryAccounts struct {
Core string `json:"urn:ietf:params:jmap:core"`
Mail string `json:"urn:ietf:params:jmap:mail"`
Submission string `json:"urn:ietf:params:jmap:submission"`
VacationResponse string `json:"urn:ietf:params:jmap:vacationresponse"`
Sieve string `json:"urn:ietf:params:jmap:sieve"`
Blob string `json:"urn:ietf:params:jmap:blob"`
Quota string `json:"urn:ietf:params:jmap:quota"`
Websocket string `json:"urn:ietf:params:jmap:websocket"`
}
type SessionResponse struct {
Capabilities map[string]any `json:"capabilities,omitempty"`
Capabilities SessionCapabilities `json:"capabilities,omitempty"`
Accounts map[string]SessionAccount `json:"accounts,omitempty"`
PrimaryAccounts map[string]string `json:"primaryAccounts,omitempty"`
PrimaryAccounts SessionPrimaryAccounts `json:"primaryAccounts,omitempty"`
Username string `json:"username,omitempty"`
ApiUrl string `json:"apiUrl,omitempty"`
DownloadUrl string `json:"downloadUrl,omitempty"`
@@ -326,21 +433,45 @@ type VacationResponseGetCommand struct {
AccountId string `json:"accountId"`
}
// https://datatracker.ietf.org/doc/html/rfc8621#section-8
type VacationResponse struct {
Id string `json:"id"`
IsEnabled bool `json:"isEnabled"`
FromDate time.Time `json:"fromDate,omitzero"`
ToDate time.Time `json:"toDate,omitzero"`
Subject string `json:"subject,omitempty"`
TextBody string `json:"textBody,omitempty"`
HtmlBody string `json:"htmlBody,omitempty"`
// The id of the object.
// There is only ever one VacationResponse object, and its id is "singleton"
Id string `json:"id"`
// Should a vacation response be sent if a message arrives between the "fromDate" and "toDate"?
IsEnabled bool `json:"isEnabled"`
// If "isEnabled" is true, messages that arrive on or after this date-time (but before the "toDate" if defined) should receive the
// user's vacation response. If null, the vacation response is effective immediately.
FromDate time.Time `json:"fromDate,omitzero"`
// If "isEnabled" is true, messages that arrive before this date-time but on or after the "fromDate" if defined) should receive the
// user's vacation response. If null, the vacation response is effective indefinitely.
ToDate time.Time `json:"toDate,omitzero"`
// The subject that will be used by the message sent in response to messages when the vacation response is enabled.
// If null, an appropriate subject SHOULD be set by the server.
Subject string `json:"subject,omitempty"`
// The plaintext body to send in response to messages when the vacation response is enabled.
// If this is null, the server SHOULD generate a plaintext body part from the "htmlBody" when sending vacation responses
// but MAY choose to send the response as HTML only. If both "textBody" and "htmlBody" are null, an appropriate default
// body SHOULD be generated for responses by the server.
TextBody string `json:"textBody,omitempty"`
// The HTML body to send in response to messages when the vacation response is enabled.
// If this is null, the server MAY choose to generate an HTML body part from the "textBody" when sending vacation responses
// or MAY choose to send the response as plaintext only.
HtmlBody string `json:"htmlBody,omitempty"`
}
type VacationResponseGetResponse struct {
AccountId string `json:"accountId"`
State string `json:"state,omitempty"`
List []VacationResponse `json:"list,omitempty"`
NotFound []any `json:"notFound,omitempty"`
// The identifier of the account this response pertains to.
AccountId string `json:"accountId"`
// A string representing the state on the server for all the data of this type in the account
// (not just the objects returned in this call).
// If the data changes, this string MUST change. If the data is unchanged, servers SHOULD return the same state string
// on subsequent requests for this data type.
State string `json:"state,omitempty"`
// An array of VacationResponse objects.
List []VacationResponse `json:"list,omitempty"`
// Contains identifiers of requested objects that were not found.
NotFound []any `json:"notFound,omitempty"`
}
var CommandResponseTypeMap = map[Command]func() any{

View File

@@ -28,10 +28,20 @@ func (t *TestJmapWellKnownClient) Close() error {
}
func (t *TestJmapWellKnownClient) GetSession(username string, logger *log.Logger) (SessionResponse, Error) {
pa := generateRandomString(2 + seededRand.Intn(10))
return SessionResponse{
Username: generateRandomString(8),
ApiUrl: "test://",
PrimaryAccounts: map[string]string{JmapMail: generateRandomString(2 + seededRand.Intn(10))},
Username: generateRandomString(8),
ApiUrl: "test://",
PrimaryAccounts: SessionPrimaryAccounts{
Core: pa,
Mail: pa,
Submission: pa,
VacationResponse: pa,
Sieve: pa,
Blob: pa,
Quota: pa,
Websocket: pa,
},
}, nil
}
@@ -99,7 +109,7 @@ func TestRequests(t *testing.T) {
jmapUrl, err := url.Parse("http://localhost/jmap")
require.NoError(err)
session := Session{AccountId: "123", Username: "user123", JmapUrl: *jmapUrl}
session := Session{MailAccountId: "123", Username: "user123", JmapUrl: *jmapUrl}
folders, err := client.GetAllMailboxes(&session, ctx, &logger)
require.NoError(err)

View File

@@ -32,6 +32,10 @@ func command[T any](api ApiClient,
return zero, SimpleError{code: JmapErrorDecodingResponseBody, err: err}
}
if data.SessionState != session.State {
// TODO(pbleser-oc) handle session renewal
}
return mapper(&data)
}