groupware: add addressbook and calendar creation APIs

* add Groupware APIs for creating and deleting addressbooks

 * add Groupware APIs for creating and deleting calendars

 * add JMAP APIs for creating and deleting addressbooks, calendars

 * add JMAP APIs to retrieve Principals

 * fix API tagging

 * move addressbook JMAP APIs into its own file

 * move addressbook Groupware APIs into its own file
This commit is contained in:
Pascal Bleser
2026-04-03 15:15:20 +02:00
parent b120b250b3
commit f144a7cc8b
30 changed files with 1539 additions and 346 deletions

118
pkg/jmap/api_addressbook.go Normal file
View File

@@ -0,0 +1,118 @@
package jmap
import (
"context"
"github.com/opencloud-eu/opencloud/pkg/log"
)
var NS_ADDRESSBOOKS = ns(JmapContacts)
type AddressBooksResponse struct {
AddressBooks []AddressBook `json:"addressbooks"`
NotFound []string `json:"notFound,omitempty"`
}
func (j *Client) GetAddressbooks(accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, ids []string) (AddressBooksResponse, SessionState, State, Language, Error) {
logger = j.logger("GetAddressbooks", session, logger)
cmd, err := j.request(session, logger, NS_ADDRESSBOOKS,
invocation(CommandAddressBookGet, AddressBookGetCommand{AccountId: accountId, Ids: ids}, "0"),
)
if err != nil {
return AddressBooksResponse{}, "", "", "", err
}
return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (AddressBooksResponse, State, Error) {
var response AddressBookGetResponse
err = retrieveResponseMatchParameters(logger, body, CommandAddressBookGet, "0", &response)
if err != nil {
return AddressBooksResponse{}, response.State, err
}
return AddressBooksResponse{
AddressBooks: response.List,
NotFound: response.NotFound,
}, response.State, nil
})
}
type AddressBookChanges struct {
HasMoreChanges bool `json:"hasMoreChanges"`
OldState State `json:"oldState,omitempty"`
NewState State `json:"newState"`
Created []AddressBook `json:"created,omitempty"`
Updated []AddressBook `json:"updated,omitempty"`
Destroyed []string `json:"destroyed,omitempty"`
}
// Retrieve Address Book changes since a given state.
// @apidoc addressbook,changes
func (j *Client) GetAddressbookChanges(accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, sinceState State, maxChanges uint) (AddressBookChanges, SessionState, State, Language, Error) {
return changesTemplate(j, "GetAddressbookChanges", NS_ADDRESSBOOKS,
CommandAddressBookChanges, CommandAddressBookGet,
func() AddressBookChangesCommand {
return AddressBookChangesCommand{AccountId: accountId, SinceState: sinceState, MaxChanges: posUIntPtr(maxChanges)}
},
func(path string, rof string) AddressBookGetRefCommand {
return AddressBookGetRefCommand{
AccountId: accountId,
IdsRef: &ResultReference{
Name: CommandAddressBookChanges,
Path: path,
ResultOf: rof,
},
}
},
func(resp AddressBookChangesResponse) (State, State, bool, []string) {
return resp.OldState, resp.NewState, resp.HasMoreChanges, resp.Destroyed
},
func(resp AddressBookGetResponse) []AddressBook { return resp.List },
func(oldState, newState State, hasMoreChanges bool, created, updated []AddressBook, destroyed []string) AddressBookChanges {
return AddressBookChanges{
OldState: oldState,
NewState: newState,
HasMoreChanges: hasMoreChanges,
Created: created,
Updated: updated,
Destroyed: destroyed,
}
},
func(resp AddressBookGetResponse) State { return resp.State },
session, ctx, logger, acceptLanguage,
)
}
func (j *Client) CreateAddressBook(accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, create AddressBookChange) (*AddressBook, SessionState, State, Language, Error) {
return createTemplate(j, "CreateAddressBook", NS_ADDRESSBOOKS, AddressBookType, CommandAddressBookSet, CommandAddressBookGet,
func(accountId string, create map[string]AddressBookChange) AddressBookSetCommand {
return AddressBookSetCommand{AccountId: accountId, Create: create}
},
func(accountId string, ref string) AddressBookGetCommand {
return AddressBookGetCommand{AccountId: accountId, Ids: []string{ref}}
},
func(resp AddressBookSetResponse) map[string]*AddressBook {
return resp.Created
},
func(resp AddressBookSetResponse) map[string]SetError {
return resp.NotCreated
},
func(resp AddressBookGetResponse) []AddressBook {
return resp.List
},
func(resp AddressBookSetResponse) State {
return resp.NewState
},
accountId, session, ctx, logger, acceptLanguage, create,
)
}
func (j *Client) DeleteAddressBook(accountId string, destroy []string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string) (map[string]SetError, SessionState, State, Language, Error) {
return deleteTemplate(j, "DeleteAddressBook", NS_ADDRESSBOOKS, CommandAddressBookSet,
func(accountId string, destroy []string) AddressBookSetCommand {
return AddressBookSetCommand{AccountId: accountId, Destroy: destroy}
},
func(resp AddressBookSetResponse) map[string]SetError { return resp.NotDestroyed },
func(resp AddressBookSetResponse) State { return resp.NewState },
accountId, destroy, session, ctx, logger, acceptLanguage,
)
}

View File

@@ -161,6 +161,8 @@ type CalendarEventChanges struct {
Destroyed []string `json:"destroyed,omitempty"`
}
// Retrieve the changes in Calendar Events since a given State.
// @api:tags event,changes
func (j *Client) GetCalendarEventChanges(accountId string, session *Session, ctx context.Context, logger *log.Logger,
acceptLanguage string, sinceState State, maxChanges uint) (CalendarEventChanges, SessionState, State, Language, Error) {
return changesTemplate(j, "GetCalendarEventChanges", NS_CALENDARS,
@@ -229,3 +231,38 @@ func (j *Client) DeleteCalendarEvent(accountId string, destroy []string, session
func(resp CalendarEventSetResponse) State { return resp.NewState },
accountId, destroy, session, ctx, logger, acceptLanguage)
}
func (j *Client) CreateCalendar(accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, create CalendarChange) (*Calendar, SessionState, State, Language, Error) {
return createTemplate(j, "CreateCalendar", NS_CALENDARS, CalendarType, CommandAddressBookSet, CommandAddressBookGet,
func(accountId string, create map[string]CalendarChange) CalendarSetCommand {
return CalendarSetCommand{AccountId: accountId, Create: create}
},
func(accountId string, ref string) CalendarGetCommand {
return CalendarGetCommand{AccountId: accountId, Ids: []string{ref}}
},
func(resp CalendarSetResponse) map[string]*Calendar {
return resp.Created
},
func(resp CalendarSetResponse) map[string]SetError {
return resp.NotCreated
},
func(resp CalendarGetResponse) []Calendar {
return resp.List
},
func(resp CalendarSetResponse) State {
return resp.NewState
},
accountId, session, ctx, logger, acceptLanguage, create,
)
}
func (j *Client) DeleteCalendar(accountId string, destroy []string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string) (map[string]SetError, SessionState, State, Language, Error) {
return deleteTemplate(j, "DeleteCalendar", NS_ADDRESSBOOKS, CommandAddressBookSet,
func(accountId string, destroy []string) CalendarSetCommand {
return CalendarSetCommand{AccountId: accountId, Destroy: destroy}
},
func(resp CalendarSetResponse) map[string]SetError { return resp.NotDestroyed },
func(resp CalendarSetResponse) State { return resp.NewState },
accountId, destroy, session, ctx, logger, acceptLanguage,
)
}

View File

@@ -74,6 +74,8 @@ func (s StateMap) MarshalZerologObject(e *zerolog.Event) {
// if s.Quotas != nil { e.Str("quotas", string(*s.Quotas)) }
}
// Retrieve the changes in any type of objects at once since a given State.
// @api:tags changes
func (j *Client) GetChanges(accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, stateMap StateMap, maxChanges uint) (Changes, SessionState, State, Language, Error) { //NOSONAR
logger = log.From(j.logger("GetChanges", session, logger).With().Object("state", stateMap).Uint("maxChanges", maxChanges))

View File

@@ -11,80 +11,6 @@ import (
var NS_CONTACTS = ns(JmapContacts)
type AddressBooksResponse struct {
AddressBooks []AddressBook `json:"addressbooks"`
NotFound []string `json:"notFound,omitempty"`
}
func (j *Client) GetAddressbooks(accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, ids []string) (AddressBooksResponse, SessionState, State, Language, Error) {
logger = j.logger("GetAddressbooks", session, logger)
cmd, err := j.request(session, logger, NS_CONTACTS,
invocation(CommandAddressBookGet, AddressBookGetCommand{AccountId: accountId, Ids: ids}, "0"),
)
if err != nil {
return AddressBooksResponse{}, "", "", "", err
}
return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (AddressBooksResponse, State, Error) {
var response AddressBookGetResponse
err = retrieveResponseMatchParameters(logger, body, CommandAddressBookGet, "0", &response)
if err != nil {
return AddressBooksResponse{}, response.State, err
}
return AddressBooksResponse{
AddressBooks: response.List,
NotFound: response.NotFound,
}, response.State, nil
})
}
type AddressBookChanges struct {
HasMoreChanges bool `json:"hasMoreChanges"`
OldState State `json:"oldState,omitempty"`
NewState State `json:"newState"`
Created []AddressBook `json:"created,omitempty"`
Updated []AddressBook `json:"updated,omitempty"`
Destroyed []string `json:"destroyed,omitempty"`
}
// Retrieve Address Book changes since a given state.
// @apidoc addressbook,changes
func (j *Client) GetAddressbookChanges(accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, sinceState State, maxChanges uint) (AddressBookChanges, SessionState, State, Language, Error) {
return changesTemplate(j, "GetAddressbookChanges", NS_CONTACTS,
CommandAddressBookChanges, CommandAddressBookGet,
func() AddressBookChangesCommand {
return AddressBookChangesCommand{AccountId: accountId, SinceState: sinceState, MaxChanges: posUIntPtr(maxChanges)}
},
func(path string, rof string) AddressBookGetRefCommand {
return AddressBookGetRefCommand{
AccountId: accountId,
IdsRef: &ResultReference{
Name: CommandAddressBookChanges,
Path: path,
ResultOf: rof,
},
}
},
func(resp AddressBookChangesResponse) (State, State, bool, []string) {
return resp.OldState, resp.NewState, resp.HasMoreChanges, resp.Destroyed
},
func(resp AddressBookGetResponse) []AddressBook { return resp.List },
func(oldState, newState State, hasMoreChanges bool, created, updated []AddressBook, destroyed []string) AddressBookChanges {
return AddressBookChanges{
OldState: oldState,
NewState: newState,
HasMoreChanges: hasMoreChanges,
Created: created,
Updated: updated,
Destroyed: destroyed,
}
},
func(resp AddressBookGetResponse) State { return resp.State },
session, ctx, logger, acceptLanguage,
)
}
func (j *Client) GetContactCardsById(accountId string, session *Session, ctx context.Context, logger *log.Logger,
acceptLanguage string, contactIds []string) (map[string]jscontact.ContactCard, SessionState, State, Language, Error) {
logger = j.logger("GetContactCardsById", session, logger)
@@ -132,6 +58,8 @@ type ContactCardChanges struct {
Destroyed []string `json:"destroyed,omitempty"`
}
// Retrieve the changes in Contact Cards since a given State.
// @api:tags contact,changes
func (j *Client) GetContactCardChanges(accountId string, session *Session, ctx context.Context, logger *log.Logger,
acceptLanguage string, sinceState State, maxChanges uint) (ContactCardChanges, SessionState, State, Language, Error) {
return changesTemplate(j, "GetContactCardChanges", NS_CONTACTS,

View File

@@ -204,7 +204,8 @@ type EmailChanges struct {
Destroyed []string `json:"destroyed,omitempty"`
}
// Get all the Emails that have been created, updated or deleted since a given state.
// Retrieve the changes in Emails since a given State.
// @api:tags email,changes
func (j *Client) GetEmailChanges(accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, sinceState State, fetchBodies bool, maxBodyValueBytes uint, maxChanges uint) (EmailChanges, SessionState, State, Language, Error) { //NOSONAR
logger = j.loggerParams("GetEmailChanges", session, logger, func(z zerolog.Context) zerolog.Context {
return z.Bool(logFetchBodies, fetchBodies).Str(logSinceState, string(sinceState))
@@ -1080,6 +1081,8 @@ type EmailSubmissionChanges struct {
Destroyed []string `json:"destroyed,omitempty"`
}
// Retrieve the changes in Email Submissions since a given State.
// @api:tags email,changes
func (j *Client) GetEmailSubmissionChanges(accountId string, session *Session, ctx context.Context, logger *log.Logger,
acceptLanguage string, sinceState State, maxChanges uint) (EmailSubmissionChanges, SessionState, State, Language, Error) {
return changesTemplate(j, "GetEmailSubmissionChanges", NS_MAIL_SUBMISSION,

View File

@@ -180,6 +180,8 @@ type IdentityChanges struct {
Destroyed []string `json:"destroyed,omitempty"`
}
// Retrieve the changes in Email Identities since a given State.
// @api:tags email,changes
func (j *Client) GetIdentityChanges(accountId string, session *Session, ctx context.Context, logger *log.Logger,
acceptLanguage string, sinceState State, maxChanges uint) (IdentityChanges, SessionState, State, Language, Error) {
return changesTemplate(j, "GetIdentityChanges", NS_IDENTITY,

View File

@@ -13,7 +13,7 @@ var NS_MAILBOX = ns(JmapMail)
type MailboxesResponse struct {
Mailboxes []Mailbox `json:"mailboxes"`
NotFound []any `json:"notFound"`
NotFound []string `json:"notFound"`
}
func (j *Client) GetMailbox(accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, ids []string) (MailboxesResponse, SessionState, State, Language, Error) {
@@ -172,6 +172,7 @@ func (j *Client) GetMailboxChanges(accountId string, session *Session, ctx conte
}
// Retrieve Mailbox changes of multiple Accounts.
// @api:tags email,changes
func (j *Client) GetMailboxChangesForMultipleAccounts(accountIds []string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, sinceStateMap map[string]State, maxChanges uint) (map[string]MailboxChanges, SessionState, State, Language, Error) { //NOSONAR
return changesTemplateN(j, "GetMailboxChangesForMultipleAccounts", NS_MAILBOX,
accountIds, sinceStateMap, CommandMailboxChanges, CommandMailboxGet,

View File

@@ -20,6 +20,8 @@ type Objects struct {
EmailSubmissions *EmailSubmissionGetResponse `json:"submissions,omitempty"`
}
// Retrieve objects of all types by their identifiers in a single batch.
// @api:tags changes
func (j *Client) GetObjects(accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, //NOSONAR
mailboxIds []string, emailIds []string,
addressbookIds []string, contactIds []string,

37
pkg/jmap/api_principal.go Normal file
View File

@@ -0,0 +1,37 @@
package jmap
import (
"context"
"github.com/opencloud-eu/opencloud/pkg/log"
)
var NS_PRINCIPALS = ns(JmapPrincipals)
type PrincipalsResponse struct {
Principals []Principal `json:"principals"`
NotFound []string `json:"notFound,omitempty"`
}
func (j *Client) GetPrincipals(accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, ids []string) (PrincipalsResponse, SessionState, State, Language, Error) {
logger = j.logger("GetPrincipals", session, logger)
cmd, err := j.request(session, logger, NS_PRINCIPALS,
invocation(CommandPrincipalGet, PrincipalGetCommand{AccountId: accountId, Ids: ids}, "0"),
)
if err != nil {
return PrincipalsResponse{}, "", "", "", err
}
return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (PrincipalsResponse, State, Error) {
var response PrincipalGetResponse
err = retrieveResponseMatchParameters(logger, body, CommandPrincipalGet, "0", &response)
if err != nil {
return PrincipalsResponse{}, response.State, err
}
return PrincipalsResponse{
Principals: response.List,
NotFound: response.NotFound,
}, response.State, nil
})
}

View File

@@ -29,6 +29,8 @@ type QuotaChanges struct {
Destroyed []string `json:"destroyed,omitempty"`
}
// Retrieve the changes in Quotas since a given State.
// @api:tags quota,changes
func (j *Client) GetQuotaChanges(accountId string, session *Session, ctx context.Context, logger *log.Logger,
acceptLanguage string, sinceState State, maxChanges uint) (QuotaChanges, SessionState, State, Language, Error) {
return changesTemplate(j, "GetQuotaChanges", NS_QUOTA,

View File

@@ -26,6 +26,55 @@ const (
EnableMediaWithBlobId = false
)
type AddressBooksBoxes struct {
sharedReadOnly bool
sharedReadWrite bool
sharedDelete bool
sortOrdered bool
}
func TestAddressBooks(t *testing.T) {
if skip(t) {
return
}
require := require.New(t)
s, err := newStalwartTest(t, withDirectoryQueries(true))
require.NoError(err)
defer s.Close()
user := pickUser()
session := s.Session(user.name)
principalIds := []string{}
{
principals, _, _, _, err := s.client.GetPrincipals(session.PrimaryAccounts.Mail, session, s.ctx, s.logger, "", []string{})
require.NoError(err)
require.NotEmpty(principals.Principals)
principalIds = structs.Map(principals.Principals, func(p Principal) string { return p.Id })
}
num := uint(5 + rand.Intn(30))
{
accountId := ""
a, boxes, abooks, err := s.fillAddressBook(t, num, session, user, principalIds)
require.NoError(err)
require.NotEmpty(a)
require.Len(abooks, int(num))
accountId = a
ids := structs.Map(abooks, func(a AddressBook) string { return a.Id })
{
errMap, _, _, _, err := s.client.DeleteAddressBook(accountId, ids, session, s.ctx, s.logger, "")
require.NoError(err)
require.Empty(errMap)
}
allBoxesAreTicked(t, boxes)
}
}
func TestContacts(t *testing.T) {
if skip(t) {
return
@@ -97,6 +146,73 @@ type ContactsBoxes struct {
var streetNumberRegex = regexp.MustCompile(`^(\d+)\s+(.+)$`)
func (s *StalwartTest) fillAddressBook(
t *testing.T,
count uint,
session *Session,
_ User,
principalIds []string,
) (string, AddressBooksBoxes, []AddressBook, error) {
require := require.New(t)
accountId := session.PrimaryAccounts.Contacts
require.NotEmpty(accountId, "no primary account for contacts in session")
boxes := AddressBooksBoxes{}
created := []AddressBook{}
printer := func(s string) { log.Println(s) }
for i := range count {
name := gofakeit.Company()
description := gofakeit.SentenceSimple()
subscribed := gofakeit.Bool()
abook := AddressBookChange{
Name: &name,
Description: &description,
IsSubscribed: &subscribed,
}
if i%2 == 0 {
abook.SortOrder = posUIntPtr(gofakeit.Uint())
boxes.sortOrdered = true
}
var sharing *AddressBookRights = nil
switch i % 4 {
default:
// no sharing
case 1:
sharing = &AddressBookRights{MayRead: true, MayWrite: true, MayAdmin: false, MayDelete: false}
boxes.sharedReadWrite = true
case 2:
sharing = &AddressBookRights{MayRead: true, MayWrite: false, MayAdmin: false, MayDelete: false}
boxes.sharedReadOnly = true
case 3:
sharing = &AddressBookRights{MayRead: true, MayWrite: true, MayAdmin: false, MayDelete: true}
boxes.sharedDelete = true
}
if sharing != nil {
numPrincipals := 1 + rand.Intn(len(principalIds)-1)
m := make(map[string]AddressBookRights, numPrincipals)
for _, p := range pickRandomN(numPrincipals, principalIds...) {
m[p] = *sharing
}
abook.ShareWith = m
}
a, sessionState, state, _, err := s.client.CreateAddressBook(accountId, session, s.ctx, s.logger, "", abook)
if err != nil {
return accountId, boxes, created, err
}
require.NotEmpty(sessionState)
require.NotEmpty(state)
require.NotNil(a)
created = append(created, *a)
printer(fmt.Sprintf("📔 created %*s/%v id=%v", int(math.Log10(float64(count))+1), strconv.Itoa(int(i+1)), count, a.Id))
}
return accountId, boxes, created, nil
}
func (s *StalwartTest) fillContacts( //NOSONAR
t *testing.T,
count uint,

View File

@@ -17,6 +17,7 @@ import (
"reflect"
"regexp"
"slices"
"strconv"
"strings"
"testing"
"text/template"
@@ -120,7 +121,7 @@ tracer.log.level = "trace"
tracer.log.lossy = false
tracer.log.multiline = false
tracer.log.type = "stdout"
sharing.allow-directory-query = false
sharing.allow-directory-query = {{.dirquery}}
auth.dkim.sign = false
auth.dkim.verify = "disable"
auth.spf.verify.ehlo = "disable"
@@ -134,11 +135,11 @@ auth.iprev.verify = "disable"
func skip(t *testing.T) bool {
if os.Getenv("CI") == "woodpecker" {
t.Skip("Skipping tests because CI==wookpecker")
t.Skip("Skipping tests because CI==woodpecker")
return true
}
if os.Getenv("CI_SYSTEM_NAME") == "woodpecker" {
t.Skip("Skipping tests because CI_SYSTEM_NAME==wookpecker")
t.Skip("Skipping tests because CI_SYSTEM_NAME==woodpecker")
return true
}
if os.Getenv("USE_TESTCONTAINERS") == "false" {
@@ -206,7 +207,13 @@ func (lc *stalwartTestLogConsumer) Accept(l testcontainers.Log) {
fmt.Print("STALWART: " + string(l.Content))
}
func newStalwartTest(t *testing.T) (*StalwartTest, error) { //NOSONAR
func withDirectoryQueries(allowDirectoryQueries bool) func(map[string]any) {
return func(m map[string]any) {
m["dirquery"] = strconv.FormatBool(allowDirectoryQueries)
}
}
func newStalwartTest(t *testing.T, options ...func(map[string]any)) (*StalwartTest, error) { //NOSONAR
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
var _ context.CancelFunc = cancel // ignore context leak warning: it is passed in the struct and called in Close()
@@ -235,14 +242,20 @@ func newStalwartTest(t *testing.T) (*StalwartTest, error) { //NOSONAR
hostname := "localhost"
configBuf := bytes.NewBufferString("")
template.Must(template.New("").Parse(configTemplate)).Execute(configBuf, map[string]any{
settings := map[string]any{
"hostname": hostname,
"masterusername": masterUsername,
"masterpassword": masterPasswordHash,
"httpPort": httpPort,
"imapsPort": imapsPort,
})
"dirquery": "false",
}
for _, option := range options {
option(settings)
}
configBuf := bytes.NewBufferString("")
template.Must(template.New("").Parse(configTemplate)).Execute(configBuf, settings)
config := configBuf.String()
configReader := strings.NewReader(config)
@@ -1128,7 +1141,10 @@ func pickUser() User {
}
func pickRandoms[T any](s ...T) []T {
n := rand.Intn(len(s))
return pickRandomN[T](rand.Intn(len(s)), s...)
}
func pickRandomN[T any](n int, s ...T) []T {
if n == 0 {
return []T{}
}
@@ -1165,17 +1181,18 @@ func pickLocale() string {
func allBoxesAreTicked[S any](t *testing.T, s S, exceptions ...string) {
v := reflect.ValueOf(s)
typ := v.Type()
tname := typ.Name()
for i := range v.NumField() {
name := typ.Field(i).Name
if slices.Contains(exceptions, name) {
log.Printf("(/) %s\n", name)
log.Printf("%s[🍒] %s\n", tname, name)
continue
}
value := v.Field(i).Bool()
if value {
log.Printf("(X) %s\n", name)
log.Printf("%s[✅] %s\n", tname, name)
} else {
log.Printf("( ) %s\n", name)
log.Printf("%s[❌] %s\n", tname, name)
}
require.True(t, value, "should be true: %v", name)
}

View File

@@ -2861,7 +2861,7 @@ type MailboxGetResponse struct {
// This array contains the ids passed to the method for records that do not exist.
// The array is empty if all requested ids were found or if the ids argument passed in was either null or an empty array.
NotFound []any `json:"notFound"`
NotFound []string `json:"notFound"`
}
type MailboxChangesCommand struct {
@@ -4037,6 +4037,141 @@ type Calendar struct {
MyRights *CalendarRights `json:"myRights,omitempty"`
}
type CalendarChange struct {
// The user-visible name of the calendar.
//
// This may be any UTF-8 string of at least 1 character in length and maximum 255 octets in size.
Name string `json:"name"`
// An optional longer-form description of the calendar, to provide context in shared environments
// where users need more than just the name.
Description string `json:"description,omitempty"`
// A color to be used when displaying events associated with the calendar.
//
// If not null, the value MUST be a case-insensitive color name taken from the set of names
// defined in Section 4.3 of CSS Color Module Level 3 COLORS, or an RGB value in hexadecimal
// notation, as defined in Section 4.2.1 of CSS Color Module Level 3.
//
// The color SHOULD have sufficient contrast to be used as text on a white background.
Color string `json:"color,omitempty"`
// Defines the sort order of calendars when presented in the clients UI, so it is consistent
// between devices.
//
// The number MUST be an integer in the range 0 <= sortOrder < 2^31.
//
// A calendar with a lower order should be displayed before a calendar with a higher order in any
// list of calendars in the clients UI.
//
// Calendars with equal order SHOULD be sorted in alphabetical order by name.
//
// The sorting should take into account locale-specific character order convention.
SortOrder uint `json:"sortOrder,omitzero"`
// True if the user has indicated they wish to see this Calendar in their client.
//
// This SHOULD default to `false` for Calendars in shared accounts the user has access to and `true`
// for any new Calendars created by the user themself.
//
// If false, the calendar SHOULD only be displayed when the user explicitly requests it or to offer
// it for the user to subscribe to.
//
// For example, a company may have a large number of shared calendars which all employees have
// permission to access, but you would only subscribe to the ones you care about and want to be able
// to have normally accessible.
IsSubscribed bool `json:"isSubscribed"`
// Should the calendars events be displayed to the user at the moment?
//
// Clients MUST ignore this property if `isSubscribed` is false.
//
// If an event is in multiple calendars, it should be displayed if `isVisible` is `true`
// for any of those calendars.
IsVisible bool `json:"isVisible" default:"true" doc:"opt"`
// This SHOULD be true for exactly one calendar in any account, and MUST NOT be true for more
// than one calendar within an account (server-set).
//
// The default calendar should be used by clients whenever they need to choose a calendar
// for the user within this account, and they do not have any other information on which to make
// a choice.
//
// For example, if the user creates a new event, the client may automatically set the event as
// belonging to the default calendar from the users primary account.
IsDefault bool `json:"isDefault,omitzero"`
// Should the calendars events be used as part of availability calculation?
//
// This MUST be one of:
// * `all`: all events are considered.
// * `attending`: events the user is a confirmed or tentative participant of are considered.
// * `none`: all events are ignored (but may be considered if also in another calendar).
//
// This should default to “all” for the calendars in the users own account, and “none” for calendars shared with the user.
IncludeInAvailability IncludeInAvailability `json:"includeInAvailability,omitempty"`
// A map of alert ids to Alert objects (see [@!RFC8984], Section 4.5.2) to apply for events
// where `showWithoutTime` is `false` and `useDefaultAlerts` is `true`.
//
// Ids MUST be unique across all default alerts in the account, including those in other
// calendars; a UUID is recommended.
//
// The "trigger" MUST NOT be an `AbsoluteTrigger`, as this would fire for every event at the same
// time and so does not make sense for a default alert.
//
// If omitted on creation, the default is server dependent.
//
// For example, servers may choose to always default to null, or may copy the alerts from the default calendar.
DefaultAlertsWithTime map[string]jscalendar.Alert `json:"defaultAlertsWithTime,omitempty"`
// A map of alert ids to Alert objects (see [@!RFC8984], Section 4.5.2) to apply for events where
// `showWithoutTime` is `true` and `useDefaultAlerts` is `true`.
//
// Ids MUST be unique across all default alerts in the account, including those in other
// calendars; a UUID is recommended.
//
// The "trigger" MUST NOT be an `AbsoluteTrigger`, as this would fire for every event at the
// same time and so does not make sense for a default alert.
//
// If omitted on creation, the default is server dependent.
//
// For example, servers may choose to always default to null, or may copy the alerts from the default calendar.
DefaultAlertsWithoutTime map[string]jscalendar.Alert `json:"defaultAlertsWithoutTime,omitempty"`
// The time zone to use for events without a time zone when the server needs to resolve them into
// absolute time, e.g., for alerts or availability calculation.
//
// The value MUST be a time zone id from the IANA Time Zone Database TZDB.
//
// If null, the `timeZone` of the accounts associated `Principal` will be used.
//
// Clients SHOULD use this as the default for new events in this calendar if set.
TimeZone string `json:"timeZone,omitempty"`
// A map of `Principal` id to rights for principals this calendar is shared with.
//
// The principal to which this calendar belongs MUST NOT be in this set.
//
// This is null if the calendar is not shared with anyone.
//
// May be modified only if the user has the `mayAdmin` right.
//
// The account id for the principals may be found in the `urn:ietf:params:jmap:principals:owner`
// capability of the `Account` to which the calendar belongs.
ShareWith map[string]CalendarRights `json:"shareWith,omitempty"`
// The set of access rights the user has in relation to this Calendar.
//
// If any event is in multiple calendars, the user has the following rights:
// * The user may fetch the event if they have the mayReadItems right on any calendar the event is in.
// * The user may remove an event from a calendar (by modifying the events “calendarIds” property) if the user
// has the appropriate permission for that calendar.
// * The user may make other changes to the event if they have the right to do so in all calendars to which the
// event belongs.
MyRights *CalendarRights `json:"myRights,omitempty"`
}
// A CalendarEvent object contains information about an event, or recurring series of events,
// that takes place at a particular time.
//
@@ -5150,6 +5285,122 @@ type AddressBookGetResponse struct {
NotFound []string `json:"notFound,omitempty"`
}
type AddressBookChange struct {
// The user-visible name of the AddressBook.
//
// This may be any UTF-8 string of at least 1 character in length and maximum 255 octets in size.
Name *string `json:"name"`
// An optional longer-form description of the AddressBook, to provide context in shared environments
// where users need more than just the name.
Description *string `json:"description,omitempty"`
// Defines the sort order of AddressBooks when presented in the clients UI, so it is consistent between devices.
//
// The number MUST be an integer in the range 0 <= sortOrder < 2^31.
//
// An AddressBook with a lower order should be displayed before a AddressBook with a higher order in any list
// of AddressBooks in the clients UI.
//
// AddressBooks with equal order SHOULD be sorted in alphabetical order by name.
//
// The sorting should take into account locale-specific character order convention.
SortOrder *uint `json:"sortOrder,omitzero" default:"0" doc:"opt"`
// True if the user has indicated they wish to see this AddressBook in their client.
//
// This SHOULD default to false for AddressBooks in shared accounts the user has access to and true for any
// new AddressBooks created by the user themself.
//
// If false, the AddressBook and its contents SHOULD only be displayed when the user explicitly requests it
// or to offer it for the user to subscribe to.
IsSubscribed *bool `json:"isSubscribed"`
// A map of Principal id to rights for principals this AddressBook is shared with.
//
// The principal to which this AddressBook belongs MUST NOT be in this set.
//
// This is null if the AddressBook is not shared with anyone.
//
// May be modified only if the user has the mayAdmin right.
//
// The account id for the principals may be found in the urn:ietf:params:jmap:principals:owner capability
// of the Account to which the AddressBook belongs.
ShareWith map[string]AddressBookRights `json:"shareWith,omitempty"`
}
func (a AddressBookChange) AsPatch() PatchObject {
p := PatchObject{}
if a.Name != nil {
p["name"] = *a.Name
}
if a.Description != nil {
p["description"] = *a.Description
}
if a.IsSubscribed != nil {
p["isSubscribed"] = *a.IsSubscribed
}
if a.ShareWith != nil {
p["shareWith"] = a.ShareWith
}
return p
}
type AddressBookSetCommand struct {
AccountId string `json:"accountId"`
IfInState string `json:"ifInState,omitempty"`
Create map[string]AddressBookChange `json:"create,omitempty"`
Update map[string]PatchObject `json:"update,omitempty"`
Destroy []string `json:"destroy,omitempty"`
}
type AddressBookSetResponse struct {
// The id of the account used for the call.
AccountId string `json:"accountId"`
// The state string that would have been returned by AddressBook/get before making the
// requested changes, or null if the server doesnt know what the previous state
// string was.
OldState State `json:"oldState,omitempty"`
// The state string that will now be returned by Email/get.
NewState State `json:"newState"`
// A map of the creation id to an object containing any properties of the created Email object
// that were not sent by the client.
//
// This includes all server-set properties (such as the id in most object types) and any properties
// that were omitted by the client and thus set to a default by the server.
//
// This argument is null if no ContactCard objects were successfully created.
Created map[string]*AddressBook `json:"created,omitempty"`
// The keys in this map are the ids of all AddressBooks that were successfully updated.
//
// The value for each id is an AddressBook object containing any property that changed in a way not
// explicitly requested by the PatchObject sent to the server, or null if none.
//
// This lets the client know of any changes to server-set or computed properties.
//
// This argument is null if no ContactCard objects were successfully updated.
Updated map[string]*AddressBook `json:"updated,omitempty"`
// A list of ContactCard ids for records that were successfully destroyed, or null if none.
Destroyed []string `json:"destroyed,omitempty"`
// A map of the creation id to a SetError object for each record that failed to be created,
// or null if all successful.
NotCreated map[string]SetError `json:"notCreated,omitempty"`
// A map of the ContactCard id to a SetError object for each record that failed to be updated,
// or null if all successful.
NotUpdated map[string]SetError `json:"notUpdated,omitempty"`
// A map of the ContactCard id to a SetError object for each record that failed to be destroyed,
// or null if all successful.
NotDestroyed map[string]SetError `json:"notDestroyed,omitempty"`
}
type AddressBookChangesCommand struct {
// The id of the account to use.
AccountId string `json:"accountId"`
@@ -5547,7 +5798,7 @@ type ContactCardGetResponse struct {
// This array contains the ids passed to the method for records that do not exist.
//
// The array is empty if all requested ids were found or if the ids argument passed in was either null or an empty array.
NotFound []any `json:"notFound"`
NotFound []string `json:"notFound"`
}
type ContactCardChangesCommand struct {
@@ -5739,6 +5990,61 @@ type CalendarGetResponse struct {
NotFound []string `json:"notFound,omitempty"`
}
type CalendarSetCommand struct {
AccountId string `json:"accountId"`
IfInState string `json:"ifInState,omitempty"`
Create map[string]CalendarChange `json:"create,omitempty"`
Update map[string]PatchObject `json:"update,omitempty"`
Destroy []string `json:"destroy,omitempty"`
}
type CalendarSetResponse struct {
// The id of the account used for the call.
AccountId string `json:"accountId"`
// The state string that would have been returned by Calendar/get before making the
// requested changes, or null if the server doesnt know what the previous state
// string was.
OldState State `json:"oldState,omitempty"`
// The state string that will now be returned by Email/get.
NewState State `json:"newState"`
// A map of the creation id to an object containing any properties of the created Email object
// that were not sent by the client.
//
// This includes all server-set properties (such as the id in most object types) and any properties
// that were omitted by the client and thus set to a default by the server.
//
// This argument is null if no Calendar objects were successfully created.
Created map[string]*Calendar `json:"created,omitempty"`
// The keys in this map are the ids of all Calendars that were successfully updated.
//
// The value for each id is an Calendar object containing any property that changed in a way not
// explicitly requested by the PatchObject sent to the server, or null if none.
//
// This lets the client know of any changes to server-set or computed properties.
//
// This argument is null if no Calendar objects were successfully updated.
Updated map[string]*Calendar `json:"updated,omitempty"`
// A list of Calendar ids for records that were successfully destroyed, or null if none.
Destroyed []string `json:"destroyed,omitempty"`
// A map of the creation id to a SetError object for each record that failed to be created,
// or null if all successful.
NotCreated map[string]SetError `json:"notCreated,omitempty"`
// A map of the Calendar id to a SetError object for each record that failed to be updated,
// or null if all successful.
NotUpdated map[string]SetError `json:"notUpdated,omitempty"`
// A map of the Calendar id to a SetError object for each record that failed to be destroyed,
// or null if all successful.
NotDestroyed map[string]SetError `json:"notDestroyed,omitempty"`
}
type CalendarChangesCommand struct {
// The id of the account to use.
AccountId string `json:"accountId"`
@@ -6087,7 +6393,7 @@ type CalendarEventGetResponse struct {
// This array contains the ids passed to the method for records that do not exist.
//
// The array is empty if all requested ids were found or if the ids argument passed in was either null or an empty array.
NotFound []any `json:"notFound"`
NotFound []string `json:"notFound"`
}
type CalendarEventChangesCommand struct {
@@ -6239,6 +6545,139 @@ type CalendarEventSetResponse struct {
NotDestroyed map[string]SetError `json:"notDestroyed,omitempty"`
}
type PrincipalGetCommand struct {
AccountId string `json:"accountId"`
Ids []string `json:"ids,omitempty"`
}
type PrincipalGetRefCommand struct {
AccountId string `json:"accountId"`
IdsRef *ResultReference `json:"#ids,omitempty"`
}
type PrincipalGetResponse struct {
// The id of the account used for the call.
AccountId string `json:"accountId"`
// A (preferably short) 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 Principal data is unchanged, servers SHOULD return the same state string on subsequent requests for this data type.
// When a client receives a response with a different state string to a previous call, it MUST either throw away all currently
// cached objects for the type or call Principal/changes to get the exact changes.
State State `json:"state"`
// An array of the Principal objects requested.
// This is the empty array if no objects were found or if the ids argument passed in was also an empty array.
// The results MAY be in a different order to the ids in the request arguments.
// If an identical id is included more than once in the request, the server MUST only include it once in either
// the list or the notFound argument of the response.
List []Principal `json:"list"`
// This array contains the ids passed to the method for records that do not exist.
// The array is empty if all requested ids were found or if the ids argument passed in was either null or an empty array.
NotFound []string `json:"notFound"`
}
type PrincipalFilterElement interface {
_isAPrincipalFilterElement() // marker method
}
type PrincipalFilterCondition struct {
// A list of Account ids.
// The Principal matches if any of the ids in this list are keys in the Principal's "accounts" property (i.e., if any of the Account ids belong to the Principal).
AccountIds []string `json:"accountIds,omitempty"`
// The email property of the Principal contains the given string.
Email string `json:"email,omitempty"`
// The name property of the Principal contains the given string.
Name string `json:"name,omitempty"`
// The name, email, or description property of the Principal contains the given string.
Text string `json:"text,omitempty"`
// The type must be exactly as given to match the condition.
Type PrincipalTypeOption `json:"type,omitempty"`
// The timeZone must be exactly as given to match the condition.
TimeZone string `json:"timeZone,omitempty"`
}
func (c PrincipalFilterCondition) _isAPrincipalFilterElement() { //NOSONAR
// marker interface method, does not need to do anything
}
var _ PrincipalFilterElement = &PrincipalFilterCondition{}
type PrincipalFilterOperator struct {
Operator FilterOperatorTerm `json:"operator"`
Conditions []PrincipalFilterElement `json:"conditions,omitempty"`
}
func (c PrincipalFilterOperator) _isAPrincipalFilterElement() { //NOSONAR
// marker interface method, does not need to do anything
}
var _ PrincipalFilterElement = &PrincipalFilterOperator{}
type PrincipalComparator struct {
Property string `json:"property"`
IsAscending bool `json:"isAscending,omitempty"`
Limit int `json:"limit,omitzero"`
CalculateTotal bool `json:"calculateTotal,omitempty"`
}
type PrincipalQueryCommand struct {
AccountId string `json:"accountId"`
Filter PrincipalFilterElement `json:"filter,omitempty"`
Sort []PrincipalComparator `json:"sort,omitempty"`
SortAsTree bool `json:"sortAsTree,omitempty"`
FilterAsTree bool `json:"filterAsTree,omitempty"`
}
type PrincipalQueryResponse struct {
// The id of the account used for the call.
AccountId string `json:"accountId"`
// A string encoding the current state of the query on the server.
//
// This string MUST change if the results of the query (i.e., the matching ids and their sort order) have changed.
// The queryState string MAY change if something has changed on the server, which means the results may have
// changed but the server doesnt know for sure.
//
// The queryState string only represents the ordered list of ids that match the particular query (including its
// sort/filter). There is no requirement for it to change if a property on an object matching the query changes
// but the query results are unaffected (indeed, it is more efficient if the queryState string does not change
// in this case). The queryState string only has meaning when compared to future responses to a query with the
// same type/sort/filter or when used with /queryChanges to fetch changes.
//
// Should a client receive back a response with a different queryState string to a previous call, it MUST either
// throw away the currently cached query and fetch it again (note, this does not require fetching the records
// again, just the list of ids) or call Mailbox/queryChanges to get the difference.
QueryState State `json:"queryState"`
// This is true if the server supports calling Mailbox/queryChanges with these filter/sort parameters.
//
// Note, this does not guarantee that the Mailbox/queryChanges call will succeed, as it may only be possible for
// a limited time afterwards due to server internal implementation details.
CanCalculateChanges bool `json:"canCalculateChanges"`
// The zero-based index of the first result in the ids array within the complete list of query results.
Position int `json:"position"`
// The list of ids for each Mailbox in the query results, starting at the index given by the position argument
// of this response and continuing until it hits the end of the results or reaches the limit number of ids.
//
// If position is >= total, this MUST be the empty list.
Ids []string `json:"ids"`
// The total number of Mailbox in the results (given the filter) (only if requested).
//
// This argument MUST be omitted if the calculateTotal request argument is not true.
Total int `json:"total,omitzero"`
// The limit enforced by the server on the maximum number of results to return (if set by the server).
//
// This is only returned if the server set a limit or used a different limit than that given in the request.
Limit int `json:"limit,omitzero"`
}
type ErrorResponse struct {
Type string `json:"type"`
Description string `json:"description,omitempty"`
@@ -6270,6 +6709,7 @@ const (
CommandQuotaGet Command = "Quota/get"
CommandQuotaChanges Command = "Quota/changes"
CommandAddressBookGet Command = "AddressBook/get"
CommandAddressBookSet Command = "AddressBook/set"
CommandAddressBookChanges Command = "AddressBook/changes"
CommandContactCardQuery Command = "ContactCard/query"
CommandContactCardGet Command = "ContactCard/get"
@@ -6277,11 +6717,14 @@ const (
CommandContactCardSet Command = "ContactCard/set"
CommandCalendarEventParse Command = "CalendarEvent/parse"
CommandCalendarGet Command = "Calendar/get"
CommandCalendarSet Command = "Calendar/set"
CommandCalendarChanges Command = "Calendar/changes"
CommandCalendarEventQuery Command = "CalendarEvent/query"
CommandCalendarEventGet Command = "CalendarEvent/get"
CommandCalendarEventSet Command = "CalendarEvent/set"
CommandCalendarEventChanges Command = "CalendarEvent/changes"
CommandPrincipalGet Command = "Principal/get"
CommandPrincipalQuery Command = "Principal/query"
)
var CommandResponseTypeMap = map[Command]func() any{
@@ -6309,6 +6752,7 @@ var CommandResponseTypeMap = map[Command]func() any{
CommandQuotaGet: func() any { return QuotaGetResponse{} },
CommandQuotaChanges: func() any { return QuotaChangesResponse{} },
CommandAddressBookGet: func() any { return AddressBookGetResponse{} },
CommandAddressBookSet: func() any { return AddressBookSetResponse{} },
CommandAddressBookChanges: func() any { return AddressBookChangesResponse{} },
CommandContactCardQuery: func() any { return ContactCardQueryResponse{} },
CommandContactCardGet: func() any { return ContactCardGetResponse{} },
@@ -6316,9 +6760,12 @@ var CommandResponseTypeMap = map[Command]func() any{
CommandContactCardSet: func() any { return ContactCardSetResponse{} },
CommandCalendarEventParse: func() any { return CalendarEventParseResponse{} },
CommandCalendarGet: func() any { return CalendarGetResponse{} },
CommandCalendarSet: func() any { return CalendarSetResponse{} },
CommandCalendarChanges: func() any { return CalendarChangesResponse{} },
CommandCalendarEventQuery: func() any { return CalendarEventQueryResponse{} },
CommandCalendarEventGet: func() any { return CalendarEventGetResponse{} },
CommandCalendarEventSet: func() any { return CalendarEventSetResponse{} },
CommandCalendarEventChanges: func() any { return CalendarEventChangesResponse{} },
CommandPrincipalGet: func() any { return PrincipalGetResponse{} },
CommandPrincipalQuery: func() any { return PrincipalQueryResponse{} },
}

View File

@@ -10,6 +10,7 @@ import (
"strings"
"time"
"github.com/opencloud-eu/opencloud/pkg/jscalendar"
"github.com/opencloud-eu/opencloud/pkg/jscontact"
c "github.com/opencloud-eu/opencloud/pkg/jscontact"
)
@@ -491,6 +492,17 @@ func (e Exemplar) Quotas() []Quota {
}
}
func (e Exemplar) QuotaGetResponse() QuotaGetResponse {
return QuotaGetResponse{
AccountId: e.AccountId,
State: "oroomoh1",
NotFound: []string{"aab2n", "aab8f"},
List: []Quota{
e.Quota(),
},
}
}
func (e Exemplar) Identity() Identity {
return Identity{
Id: e.IdentityId,
@@ -530,6 +542,15 @@ func (e Exemplar) Identity_req() Identity { //NOSONAR
}
}
func (e Exemplar) IdentityGetResponse() IdentityGetResponse {
return IdentityGetResponse{
AccountId: e.AccountId,
State: "geechae0",
NotFound: []string{"eea2"},
List: e.Identities(),
}
}
func (e Exemplar) Thread() Thread {
return Thread{
Id: e.ThreadId,
@@ -697,6 +718,15 @@ func (e Exemplar) Mailboxes() []Mailbox {
return []Mailbox{a, b, c, d, f, g}
}
func (e Exemplar) MailboxGetResponse() MailboxGetResponse {
return MailboxGetResponse{
AccountId: e.AccountId,
State: "aesh2ahj",
List: e.Mailboxes(),
NotFound: []string{"ah"},
}
}
func (e Exemplar) MailboxChanges() MailboxChanges {
a, _, _ := e.MailboxInbox()
return MailboxChanges{
@@ -757,10 +787,10 @@ func (e Exemplar) Email() Email {
},
},
TextBody: []EmailBodyPart{
{PartId: "1", BlobId: "ckyxndo0fxob1jnm3z2lroex131oj7eo2ezo1djhlfgtsu7jgucfeaiasibnebdw", Size: 115, Type: "text/plain", Charset: "utf-8"},
{PartId: "1", BlobId: "ckyxndo0fxob1jnm3z2lroex131oj7eo2ezo1djhlfgtsu7jgucfeaiasibnebdw", Size: 115, Type: "text/plain", Charset: "utf-8"}, //NOSONAR
},
HtmlBody: []EmailBodyPart{
{PartId: "2", BlobId: "ckyxndo0fxob1jnm3z2lroex131oj7eo2ezo1djhlfgtsu7jgucfeaiasibnsbvjae", Size: 163, Type: "text/html", Charset: "utf-8"},
{PartId: "2", BlobId: "ckyxndo0fxob1jnm3z2lroex131oj7eo2ezo1djhlfgtsu7jgucfeaiasibnsbvjae", Size: 163, Type: "text/html", Charset: "utf-8"}, //NOSONAR
},
Preview: "The Canterbury was destroyed while investigating a false distress call from the Scopuli.",
}
@@ -786,6 +816,49 @@ func (e Exemplar) Emails() Emails {
}
}
func (e Exemplar) EmailGetResponse() EmailGetResponse {
return EmailGetResponse{
AccountId: e.AccountId,
State: "aesh2ahj",
NotFound: []string{"ahx"},
List: e.Emails().Emails,
}
}
func (e Exemplar) EmailSubmission() EmailSubmission {
sendAt, err := time.Parse(time.RFC3339, "2026-04-08T14:00:00.000Z")
if err != nil {
panic(err)
}
return EmailSubmission{
Id: "cea1ae",
IdentityId: e.IdentityId,
EmailId: e.EmailId,
ThreadId: e.ThreadId,
Envelope: &Envelope{
MailFrom: Address{
Email: "camina@opa.org.example.com",
},
RcptTo: []Address{
{Email: "crissy@earth.gov.example.com"},
},
},
SendAt: sendAt,
UndoStatus: UndoStatusPending,
}
}
func (e Exemplar) EmailSubmissionGetResponse() EmailSubmissionGetResponse {
return EmailSubmissionGetResponse{
AccountId: e.AccountId,
State: "eiph2pha",
NotFound: []string{"zfa92bn"},
List: []EmailSubmission{
e.EmailSubmission(),
},
}
}
func (e Exemplar) VacationResponse() VacationResponse {
from, _ := time.Parse(time.RFC3339, "20260101T00:00:00.000Z")
to, _ := time.Parse(time.RFC3339, "20260114T23:59:59.999Z")
@@ -1011,6 +1084,16 @@ func (e Exemplar) CalendarsResponse() CalendarsResponse {
}
}
func (e Exemplar) CalendarGetResponse() CalendarGetResponse {
a := e.Calendar()
return CalendarGetResponse{
AccountId: e.AccountId,
State: "aesh2ahj",
List: []Calendar{a},
NotFound: []string{"eehn", "eehz"},
}
}
func (e Exemplar) Link() c.Link {
return c.Link{
Type: c.LinkType,
@@ -1786,6 +1869,178 @@ func (e Exemplar) ContactCard() c.ContactCard {
}
}
func (e Exemplar) ContactCardGetResponse() ContactCardGetResponse {
a := e.ContactCard()
b, _, _ := e.DesignContactCard()
return ContactCardGetResponse{
AccountId: e.AccountId,
State: "ewohl8ie",
NotFound: []string{"eeaa2"},
List: []c.ContactCard{a, b},
}
}
func (e Exemplar) CalendarEvent() CalendarEvent {
cal := e.Calendar()
return CalendarEvent{
Id: "aeZaik2faash",
CalendarIds: map[string]bool{cal.Id: true},
IsDraft: false,
IsOrigin: true,
Event: jscalendar.Event{
Type: jscalendar.EventType,
Object: jscalendar.Object{
CommonObject: jscalendar.CommonObject{
Uid: "dda22c7e-7674-4811-ae2e-2cc1ac605f5c",
ProdId: "Groupware//1.0",
Created: "2026-04-01T15:29:12.912Z",
Updated: "2026-04-01T15:35:44.091Z",
Title: "James Holden's Intronisation Ceremony",
Description: "James Holden will be confirmed as the President of the Transport Union, in room 2201 on station TSL-5.",
DescriptionContentType: "text/plain",
Links: map[string]jscalendar.Link{
"aig1oh": jscalendar.Link{
Type: jscalendar.LinkType,
Href: "https://expanse.fandom.com/wiki/TSL-5",
ContentType: "text/html",
Display: "TSL-5",
Title: "TSL-5",
},
},
Locale: "en-US",
Keywords: map[string]bool{"union": true},
Categories: map[string]bool{
"meeting": true,
},
Color: "#ff0000",
},
ShowWithoutTime: false,
Locations: map[string]jscalendar.Location{
"eigha6": jscalendar.Location{
Type: jscalendar.LocationType,
Name: "Room 2201",
LocationTypes: map[jscalendar.LocationTypeOption]bool{
jscalendar.LocationTypeOptionOffice: true,
},
Coordinates: "geo:40.7495,-73.9681",
Links: map[string]jscalendar.Link{
"ohb6qu": jscalendar.Link{
Type: jscalendar.LinkType,
Href: "https://nss.org/what-is-l5/",
ContentType: "text/html",
Display: "Lagrange Point 5",
Title: "Lagrange Point 5",
},
},
},
},
Sequence: 0,
MainLocationId: "eigha6",
VirtualLocations: map[string]jscalendar.VirtualLocation{
"eec4ei": jscalendar.VirtualLocation{
Type: jscalendar.VirtualLocationType,
Name: "OpenTalk",
Uri: "https://earth.gov.example.com/opentalk/l5/2022",
Features: map[jscalendar.VirtualLocationFeature]bool{
jscalendar.VirtualLocationFeatureVideo: true,
jscalendar.VirtualLocationFeatureScreen: true,
jscalendar.VirtualLocationFeatureAudio: true,
},
},
},
Priority: 1,
FreeBusyStatus: jscalendar.FreeBusyStatusBusy,
Privacy: jscalendar.PrivacyPublic,
SentBy: "avasarala@earth.gov.example.com",
Participants: map[string]jscalendar.Participant{
"xaku3f": jscalendar.Participant{
Type: jscalendar.ParticipantType,
Name: "Christjen Avasarala",
Email: "crissy@earth.gov.example.com",
Kind: jscalendar.ParticipantKindIndividual,
Roles: map[jscalendar.Role]bool{
jscalendar.RoleRequired: true,
jscalendar.RoleChair: true,
jscalendar.RoleOwner: true,
},
ParticipationStatus: jscalendar.ParticipationStatusAccepted,
},
"chao1a": jscalendar.Participant{
Type: jscalendar.ParticipantType,
Name: "Camina Drummer",
Email: "camina@opa.org.example.com",
Kind: jscalendar.ParticipantKindIndividual,
Roles: map[jscalendar.Role]bool{
jscalendar.RoleRequired: true,
},
ParticipationStatus: jscalendar.ParticipationStatusAccepted,
ParticipationComment: "I'll definitely be there",
ExpectReply: true,
InvitedBy: "xaku3f",
},
"ees0oo": jscalendar.Participant{
Type: jscalendar.ParticipantType,
Name: "James Holden",
Email: "james.holden@rocinante.space",
Kind: jscalendar.ParticipantKindIndividual,
Roles: map[jscalendar.Role]bool{
jscalendar.RoleRequired: true,
},
ParticipationStatus: jscalendar.ParticipationStatusAccepted,
ExpectReply: true,
InvitedBy: "xaku3f",
},
},
Alerts: map[string]jscalendar.Alert{
"kus9fa": jscalendar.Alert{
Type: jscalendar.AlertType,
Action: jscalendar.AlertActionDisplay,
Trigger: jscalendar.OffsetTrigger{
Type: jscalendar.OffsetTriggerType,
Offset: "-PT1H",
RelativeTo: jscalendar.RelativeToStart,
},
},
"lohve9": jscalendar.Alert{
Type: jscalendar.AlertType,
Action: jscalendar.AlertActionDisplay,
Trigger: jscalendar.OffsetTrigger{
Type: jscalendar.OffsetTriggerType,
Offset: "-PT10M",
RelativeTo: jscalendar.RelativeToStart,
},
},
},
MayInviteOthers: true,
HideAttendees: false,
},
},
}
}
func (e Exemplar) CalendarEventGetResponse() CalendarEventGetResponse {
ev := e.CalendarEvent()
return CalendarEventGetResponse{
AccountId: e.AccountId,
State: "zah1ooj0",
NotFound: []string{"eea9"},
List: []CalendarEvent{
ev,
},
}
}
func (e Exemplar) AddressBookChanges() AddressBookChanges {
a := e.AddressBook()
return AddressBookChanges{
OldState: "eebees6o",
NewState: "gae1iey0",
HasMoreChanges: true,
Created: []AddressBook{a},
Destroyed: []string{"l9fn"},
}
}
func (e Exemplar) ContactCardChanges() (ContactCardChanges, string, string) {
c := e.ContactCard()
return ContactCardChanges{
@@ -1818,7 +2073,7 @@ func (e Exemplar) EmailChanges() EmailChanges {
}
}
func (e Exemplar) Changes() Changes {
func (e Exemplar) Changes() (Changes, string, string) {
return Changes{
MaxChanges: 3,
Mailboxes: &MailboxChangesResponse{
@@ -1862,5 +2117,28 @@ func (e Exemplar) Changes() Changes {
HasMoreChanges: true,
Created: []string{"fq", "fr", "fs"},
},
}, "A set of changes to objects", "changes"
}
func (e Exemplar) Objects() Objects {
mailboxes := e.MailboxGetResponse()
emails := e.EmailGetResponse()
calendars := e.CalendarGetResponse()
events := e.CalendarEventGetResponse()
addressbooks := e.AddressBookGetResponse()
contacts := e.ContactCardGetResponse()
quotas := e.QuotaGetResponse()
identities := e.IdentityGetResponse()
emailSubmissions := e.EmailSubmissionGetResponse()
return Objects{
Mailboxes: &mailboxes,
Emails: &emails,
Calendars: &calendars,
Events: &events,
Addressbooks: &addressbooks,
Contacts: &contacts,
Quotas: &quotas,
Identities: &identities,
EmailSubmissions: &emailSubmissions,
}
}

View File

@@ -76,19 +76,19 @@ func getTemplateN[GETREQ any, GETRESP any, ITEM any, RESP any]( //NOSONAR
})
}
func createTemplate[T any, SETREQ any, GETREQ any, SETRESP any, GETRESP any]( //NOSONAR
func createTemplate[T any, C any, SETREQ any, GETREQ any, SETRESP any, GETRESP any]( //NOSONAR
client *Client, name string, using []JmapNamespace, t ObjectType,
setCommand Command, getCommand Command,
setCommandFactory func(string, map[string]T) SETREQ,
setCommandFactory func(string, map[string]C) SETREQ,
getCommandFactory func(string, string) GETREQ,
createdMapper func(SETRESP) map[string]*T,
notCreatedMapper func(SETRESP) map[string]SetError,
listMapper func(GETRESP) []T,
stateMapper func(SETRESP) State,
accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, create T) (*T, SessionState, State, Language, Error) {
accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, create C) (*T, SessionState, State, Language, Error) {
logger = client.logger(name, session, logger)
createMap := map[string]T{"c": create}
createMap := map[string]C{"c": create}
cmd, err := client.request(session, logger, using,
invocation(setCommand, setCommandFactory(accountId, createMap), "0"),
invocation(getCommand, getCommandFactory(accountId, "#c"), "1"),

View File

@@ -58,6 +58,9 @@ tags:
- name: vacation
x-displayName: Vacation Responses
description: APIs about vacation responses
- name: blob
x-displayName: BLOBs
description: APIs about binary large objects
- name: changes
x-displayName: Changes
description: APIs for retrieving changes to objects
@@ -89,6 +92,9 @@ x-tagGroups:
- name: Quotas
tags:
- quota
- name: Blobs
tags:
- blob
- name: changes
tags:
- changes

View File

@@ -1,6 +1,6 @@
{
"dependencies": {
"@redocly/cli": "^2.25.2",
"@redocly/cli": "^2.25.3",
"@types/js-yaml": "^4.0.9",
"cheerio": "^1.2.0",
"js-yaml": "^4.1.1",

View File

@@ -0,0 +1,155 @@
package groupware
import (
"net/http"
"github.com/opencloud-eu/opencloud/pkg/jmap"
"github.com/opencloud-eu/opencloud/pkg/log"
)
// Get all addressbooks of an account.
func (g *Groupware) GetAddressbooks(w http.ResponseWriter, r *http.Request) {
g.respond(w, r, func(req Request) Response {
ok, accountId, resp := req.needContactWithAccount()
if !ok {
return resp
}
addressbooks, sessionState, state, lang, jerr := g.jmap.GetAddressbooks(accountId, req.session, req.ctx, req.logger, req.language(), nil)
if jerr != nil {
return req.jmapError(accountId, jerr, sessionState, lang)
}
var body jmap.AddressBooksResponse = addressbooks
return req.respond(accountId, body, sessionState, AddressBookResponseObjectType, state)
})
}
// Get an addressbook of an account by its identifier.
func (g *Groupware) GetAddressbook(w http.ResponseWriter, r *http.Request) {
g.respond(w, r, func(req Request) Response {
ok, accountId, resp := req.needContactWithAccount()
if !ok {
return resp
}
l := req.logger.With()
addressBookId, err := req.PathParam(UriParamAddressBookId)
if err != nil {
return req.error(accountId, err)
}
l = l.Str(UriParamAddressBookId, log.SafeString(addressBookId))
logger := log.From(l)
addressbooks, sessionState, state, lang, jerr := g.jmap.GetAddressbooks(accountId, req.session, req.ctx, logger, req.language(), []string{addressBookId})
if jerr != nil {
return req.jmapError(accountId, jerr, sessionState, lang)
}
if len(addressbooks.NotFound) > 0 {
return req.notFound(accountId, sessionState, AddressBookResponseObjectType, state)
} else {
return req.respond(accountId, addressbooks.AddressBooks[0], sessionState, AddressBookResponseObjectType, state)
}
})
}
// Get the changes to Address Books since a certain State.
// @api:tags addressbook,changes
func (g *Groupware) GetAddressBookChanges(w http.ResponseWriter, r *http.Request) {
g.respond(w, r, func(req Request) Response {
ok, accountId, resp := req.needContactWithAccount()
if !ok {
return resp
}
l := req.logger.With()
maxChanges, ok, err := req.parseUIntParam(QueryParamMaxChanges, 0)
if err != nil {
return req.error(accountId, err)
}
if ok {
l = l.Uint(QueryParamMaxChanges, maxChanges)
}
sinceState := jmap.State(req.OptHeaderParamDoc(HeaderParamSince, "Optionally specifies the state identifier from which on to list addressbook changes"))
if sinceState != "" {
l = l.Str(HeaderParamSince, log.SafeString(string(sinceState)))
}
logger := log.From(l)
changes, sessionState, state, lang, jerr := g.jmap.GetAddressbookChanges(accountId, req.session, req.ctx, logger, req.language(), sinceState, maxChanges)
if jerr != nil {
return req.jmapError(accountId, jerr, sessionState, lang)
}
return req.respond(accountId, changes, sessionState, AddressBookResponseObjectType, state)
})
}
func (g *Groupware) CreateAddressBook(w http.ResponseWriter, r *http.Request) {
g.respond(w, r, func(req Request) Response {
ok, accountId, resp := req.needContactWithAccount()
if !ok {
return resp
}
l := req.logger.With()
var create jmap.AddressBookChange
err := req.bodydoc(&create, "The address book to create")
if err != nil {
return req.error(accountId, err)
}
logger := log.From(l)
created, sessionState, state, lang, jerr := g.jmap.CreateAddressBook(accountId, req.session, req.ctx, logger, req.language(), create)
if jerr != nil {
return req.jmapError(accountId, jerr, sessionState, lang)
}
return req.respond(accountId, created, sessionState, ContactResponseObjectType, state)
})
}
func (g *Groupware) DeleteAddressBook(w http.ResponseWriter, r *http.Request) {
g.respond(w, r, func(req Request) Response {
ok, accountId, resp := req.needContactWithAccount()
if !ok {
return resp
}
l := req.logger.With().Str(accountId, log.SafeString(accountId))
addressBookId, err := req.PathParam(UriParamAddressBookId)
if err != nil {
return req.error(accountId, err)
}
l.Str(UriParamAddressBookId, log.SafeString(addressBookId))
logger := log.From(l)
deleted, sessionState, state, lang, jerr := g.jmap.DeleteAddressBook(accountId, []string{addressBookId}, req.session, req.ctx, logger, req.language())
if jerr != nil {
return req.jmapError(accountId, jerr, sessionState, lang)
}
for _, e := range deleted {
desc := e.Description
if desc != "" {
return req.error(accountId, apiError(
req.errorId(),
ErrorFailedToDeleteAddressBook,
withDetail(e.Description),
))
} else {
return req.error(accountId, apiError(
req.errorId(),
ErrorFailedToDeleteAddressBook,
))
}
}
return req.noContent(accountId, sessionState, AddressBookResponseObjectType, state)
})
}

View File

@@ -67,17 +67,18 @@ func (g *Groupware) UploadBlob(w http.ResponseWriter, r *http.Request) {
})
}
// Download a BLOB by its identifier.
func (g *Groupware) DownloadBlob(w http.ResponseWriter, r *http.Request) {
g.stream(w, r, func(req Request, w http.ResponseWriter) *Error {
blobId, err := req.PathParam(UriParamBlobId)
blobId, err := req.PathParam(UriParamBlobId) // the unique identifier of the blob to download
if err != nil {
return err
}
name, err := req.PathParam(UriParamBlobName)
name, err := req.PathParam(UriParamBlobName) // the filename of the blob to download, which is then used in the response and may be arbitrary if unknown
if err != nil {
return err
}
typ, _ := req.getStringParam(QueryParamBlobType, "")
typ, _ := req.getStringParam(QueryParamBlobType, "") // optionally, the Content-Type of the blob, which is then used in the response
accountId, gwerr := req.GetAccountIdForBlob()
if gwerr != nil {

View File

@@ -2,7 +2,6 @@ package groupware
import (
"net/http"
"strings"
"github.com/opencloud-eu/opencloud/pkg/jmap"
"github.com/opencloud-eu/opencloud/pkg/log"
@@ -55,8 +54,8 @@ func (g *Groupware) GetCalendarById(w http.ResponseWriter, r *http.Request) {
})
}
// Get the changes that occured in a given mailbox since a certain state.
// @api:tags calendars,changes
// Get the changes to Calendars since a certain State.
// @api:tags calendar,changes
func (g *Groupware) GetCalendarChanges(w http.ResponseWriter, r *http.Request) {
g.respond(w, r, func(req Request) Response {
ok, accountId, resp := req.needCalendarWithAccount()
@@ -90,131 +89,47 @@ func (g *Groupware) GetCalendarChanges(w http.ResponseWriter, r *http.Request) {
})
}
// Get all the events in a calendar of an account by its identifier.
func (g *Groupware) GetEventsInCalendar(w http.ResponseWriter, r *http.Request) { //NOSONAR
func (g *Groupware) CreateCalendar(w http.ResponseWriter, r *http.Request) {
g.respond(w, r, func(req Request) Response {
ok, accountId, resp := req.needCalendarWithAccount()
ok, accountId, resp := req.needContactWithAccount()
if !ok {
return resp
}
l := req.logger.With()
calendarId, err := req.PathParam(UriParamCalendarId)
if err != nil {
return req.error(accountId, err)
}
l = l.Str(UriParamCalendarId, log.SafeString(calendarId))
offset, ok, err := req.parseUIntParam(QueryParamOffset, 0)
if err != nil {
return req.error(accountId, err)
}
if ok {
l = l.Uint(QueryParamOffset, offset)
}
limit, ok, err := req.parseUIntParam(QueryParamLimit, g.defaults.contactLimit)
if err != nil {
return req.error(accountId, err)
}
if ok {
l = l.Uint(QueryParamLimit, limit)
}
filter := jmap.CalendarEventFilterCondition{
InCalendar: calendarId,
}
sortBy := []jmap.CalendarEventComparator{{Property: jmap.CalendarEventPropertyUpdated, IsAscending: false}}
logger := log.From(l)
eventsByAccountId, sessionState, state, lang, jerr := g.jmap.QueryCalendarEvents(single(accountId), req.session, req.ctx, logger, req.language(), filter, sortBy, offset, limit)
if jerr != nil {
return req.jmapError(accountId, jerr, sessionState, lang)
}
if events, ok := eventsByAccountId[accountId]; ok {
return req.respond(accountId, events, sessionState, EventResponseObjectType, state)
} else {
return req.notFound(accountId, sessionState, EventResponseObjectType, state)
}
})
}
// Get changes to Contacts since a given State
// @api:tags event,changes
func (g *Groupware) GetEventChanges(w http.ResponseWriter, r *http.Request) {
g.respond(w, r, func(req Request) Response {
ok, accountId, resp := req.needCalendarWithAccount()
if !ok {
return resp
}
l := req.logger.With()
var maxChanges uint = 0
if v, ok, err := req.parseUIntParam(QueryParamMaxChanges, 0); err != nil {
return req.error(accountId, err)
} else if ok {
maxChanges = v
l = l.Uint(QueryParamMaxChanges, v)
}
sinceState := jmap.State(req.OptHeaderParamDoc(HeaderParamSince, "Specifies the state identifier from which on to list event changes"))
l = l.Str(HeaderParamSince, log.SafeString(string(sinceState)))
logger := log.From(l)
changes, sessionState, state, lang, jerr := g.jmap.GetCalendarEventChanges(accountId, req.session, req.ctx, logger, req.language(), sinceState, maxChanges)
if jerr != nil {
return req.jmapError(accountId, jerr, sessionState, lang)
}
var body jmap.CalendarEventChanges = changes
return req.respond(accountId, body, sessionState, ContactResponseObjectType, state)
})
}
func (g *Groupware) CreateCalendarEvent(w http.ResponseWriter, r *http.Request) {
g.respond(w, r, func(req Request) Response {
ok, accountId, resp := req.needCalendarWithAccount()
if !ok {
return resp
}
l := req.logger.With()
var create jmap.CalendarEvent
err := req.body(&create)
var create jmap.CalendarChange
err := req.bodydoc(&create, "The calendar to create")
if err != nil {
return req.error(accountId, err)
}
logger := log.From(l)
created, sessionState, state, lang, jerr := g.jmap.CreateCalendarEvent(accountId, req.session, req.ctx, logger, req.language(), create)
created, sessionState, state, lang, jerr := g.jmap.CreateCalendar(accountId, req.session, req.ctx, logger, req.language(), create)
if jerr != nil {
return req.jmapError(accountId, jerr, sessionState, lang)
}
return req.respond(accountId, created, sessionState, EventResponseObjectType, state)
return req.respond(accountId, created, sessionState, ContactResponseObjectType, state)
})
}
func (g *Groupware) DeleteCalendarEvent(w http.ResponseWriter, r *http.Request) {
func (g *Groupware) DeleteCalendar(w http.ResponseWriter, r *http.Request) {
g.respond(w, r, func(req Request) Response {
ok, accountId, resp := req.needCalendarWithAccount()
ok, accountId, resp := req.needContactWithAccount()
if !ok {
return resp
}
l := req.logger.With().Str(accountId, log.SafeString(accountId))
eventId, err := req.PathParam(UriParamEventId)
calendarId, err := req.PathParam(UriParamCalendarId)
if err != nil {
return req.error(accountId, err)
}
l.Str(UriParamEventId, log.SafeString(eventId))
l.Str(UriParamCalendarId, log.SafeString(calendarId))
logger := log.From(l)
deleted, sessionState, state, lang, jerr := g.jmap.DeleteCalendarEvent(accountId, []string{eventId}, req.session, req.ctx, logger, req.language())
deleted, sessionState, state, lang, jerr := g.jmap.DeleteCalendar(accountId, []string{calendarId}, req.session, req.ctx, logger, req.language())
if jerr != nil {
return req.jmapError(accountId, jerr, sessionState, lang)
}
@@ -222,42 +137,18 @@ func (g *Groupware) DeleteCalendarEvent(w http.ResponseWriter, r *http.Request)
for _, e := range deleted {
desc := e.Description
if desc != "" {
return req.errorS(accountId, apiError(
return req.error(accountId, apiError(
req.errorId(),
ErrorFailedToDeleteContact,
ErrorFailedToDeleteCalendar,
withDetail(e.Description),
), sessionState)
))
} else {
return req.errorS(accountId, apiError(
return req.error(accountId, apiError(
req.errorId(),
ErrorFailedToDeleteContact,
), sessionState)
ErrorFailedToDeleteCalendar,
))
}
}
return req.noContent(accountId, sessionState, EventResponseObjectType, state)
})
}
func (g *Groupware) ParseIcalBlob(w http.ResponseWriter, r *http.Request) {
g.respond(w, r, func(req Request) Response {
accountId, err := req.GetAccountIdForBlob()
if err != nil {
return req.error(accountId, err)
}
blobId, err := req.PathParam(UriParamBlobId)
if err != nil {
return req.error(accountId, err)
}
blobIds := strings.Split(blobId, ",")
l := req.logger.With().Array(UriParamBlobId, log.SafeStringArray(blobIds))
logger := log.From(l)
resp, sessionState, state, lang, jerr := g.jmap.ParseICalendarBlob(accountId, req.session, req.ctx, logger, req.language(), blobIds)
if jerr != nil {
return req.jmapError(accountId, jerr, sessionState, lang)
}
return req.respond(accountId, resp, sessionState, EventResponseObjectType, state)
return req.noContent(accountId, sessionState, CalendarResponseObjectType, state)
})
}

View File

@@ -13,6 +13,7 @@ import (
// object type separately.
//
// This is done through individual query parameter, as follows:
//
// `?emails=rafrag&contacts=rbsxqeay&events=n`
//
// Additionally, the `maxchanges` query parameter may be used to limit the number of changes
@@ -24,6 +25,8 @@ import (
// The response then includes the new state after that maximum number if changes,
// as well as a `hasMoreChanges` boolean flag which can be used to paginate the retrieval of
// changes and the objects associated with the identifiers.
//
// @api:tags changes
func (g *Groupware) GetChanges(w http.ResponseWriter, r *http.Request) { //NOSONAR
g.respond(w, r, func(req Request) Response {
l := req.logger.With()
@@ -34,7 +37,7 @@ func (g *Groupware) GetChanges(w http.ResponseWriter, r *http.Request) { //NOSON
l = l.Str(logAccountId, accountId)
var maxChanges uint = 0
if v, ok, err := req.parseUIntParam(QueryParamMaxChanges, 0); err != nil {
if v, ok, err := req.parseUIntParam(QueryParamMaxChanges, 0); err != nil { // The maximum amount of changes to emit for each type of object.
return req.error(accountId, err)
} else if ok {
maxChanges = v
@@ -43,33 +46,33 @@ func (g *Groupware) GetChanges(w http.ResponseWriter, r *http.Request) { //NOSON
sinceState := jmap.StateMap{}
{
if state, ok := req.getStringParam(QueryParamMailboxes, ""); ok {
if state, ok := req.getStringParam(QueryParamMailboxes, ""); ok { // The state of Mailboxes from which to determine changes.
sinceState.Mailboxes = ptr(toState(state))
}
if state, ok := req.getStringParam(QueryParamEmails, ""); ok {
if state, ok := req.getStringParam(QueryParamEmails, ""); ok { // The state of Emails from which to determine changes.
sinceState.Emails = ptr(toState(state))
}
if state, ok := req.getStringParam(QueryParamAddressbooks, ""); ok {
if state, ok := req.getStringParam(QueryParamAddressbooks, ""); ok { // The state of Address Books from which to determine changes.
sinceState.Addressbooks = ptr(toState(state))
}
if state, ok := req.getStringParam(QueryParamContacts, ""); ok {
if state, ok := req.getStringParam(QueryParamContacts, ""); ok { // The state of Contact Cards from which to determine changes.
sinceState.Contacts = ptr(toState(state))
}
if state, ok := req.getStringParam(QueryParamCalendars, ""); ok {
if state, ok := req.getStringParam(QueryParamCalendars, ""); ok { // The state of Calendars from which to determine changes.
sinceState.Calendars = ptr(toState(state))
}
if state, ok := req.getStringParam(QueryParamEvents, ""); ok {
if state, ok := req.getStringParam(QueryParamEvents, ""); ok { // The state of Calendar Events from which to determine changes.
sinceState.Events = ptr(toState(state))
}
if state, ok := req.getStringParam(QueryParamIdentities, ""); ok {
if state, ok := req.getStringParam(QueryParamIdentities, ""); ok { // The state of Identities from which to determine changes.
sinceState.Identities = ptr(toState(state))
}
if state, ok := req.getStringParam(QueryParamEmailSubmissions, ""); ok {
if state, ok := req.getStringParam(QueryParamEmailSubmissions, ""); ok { // The state of Email Submissions from which to determine changes.
sinceState.EmailSubmissions = ptr(toState(state))
}
//if state, ok := req.getStringParam(QueryParamQuotas, ""); ok { sinceState.Quotas = ptr(toState(state)) }
if sinceState.IsZero() {
return req.noop(accountId)
return req.noop(accountId) // No content response if no object IDs were requested.
}
}

View File

@@ -41,89 +41,6 @@ var (
}
)
// Get all addressbooks of an account.
func (g *Groupware) GetAddressbooks(w http.ResponseWriter, r *http.Request) {
g.respond(w, r, func(req Request) Response {
ok, accountId, resp := req.needContactWithAccount()
if !ok {
return resp
}
addressbooks, sessionState, state, lang, jerr := g.jmap.GetAddressbooks(accountId, req.session, req.ctx, req.logger, req.language(), nil)
if jerr != nil {
return req.jmapError(accountId, jerr, sessionState, lang)
}
var body jmap.AddressBooksResponse = addressbooks
return req.respond(accountId, body, sessionState, AddressBookResponseObjectType, state)
})
}
// Get an addressbook of an account by its identifier.
func (g *Groupware) GetAddressbook(w http.ResponseWriter, r *http.Request) {
g.respond(w, r, func(req Request) Response {
ok, accountId, resp := req.needContactWithAccount()
if !ok {
return resp
}
l := req.logger.With()
addressBookId, err := req.PathParam(UriParamAddressBookId)
if err != nil {
return req.error(accountId, err)
}
l = l.Str(UriParamAddressBookId, log.SafeString(addressBookId))
logger := log.From(l)
addressbooks, sessionState, state, lang, jerr := g.jmap.GetAddressbooks(accountId, req.session, req.ctx, logger, req.language(), []string{addressBookId})
if jerr != nil {
return req.jmapError(accountId, jerr, sessionState, lang)
}
if len(addressbooks.NotFound) > 0 {
return req.notFound(accountId, sessionState, AddressBookResponseObjectType, state)
} else {
return req.respond(accountId, addressbooks.AddressBooks[0], sessionState, AddressBookResponseObjectType, state)
}
})
}
// Get the changes that occured in a given mailbox since a certain state.
// @api:tags mailbox,changes
func (g *Groupware) GetAddressBookChanges(w http.ResponseWriter, r *http.Request) {
g.respond(w, r, func(req Request) Response {
ok, accountId, resp := req.needContactWithAccount()
if !ok {
return resp
}
l := req.logger.With()
maxChanges, ok, err := req.parseUIntParam(QueryParamMaxChanges, 0)
if err != nil {
return req.error(accountId, err)
}
if ok {
l = l.Uint(QueryParamMaxChanges, maxChanges)
}
sinceState := jmap.State(req.OptHeaderParamDoc(HeaderParamSince, "Optionally specifies the state identifier from which on to list addressbook changes"))
if sinceState != "" {
l = l.Str(HeaderParamSince, log.SafeString(string(sinceState)))
}
logger := log.From(l)
changes, sessionState, state, lang, jerr := g.jmap.GetAddressbookChanges(accountId, req.session, req.ctx, logger, req.language(), sinceState, maxChanges)
if jerr != nil {
return req.jmapError(accountId, jerr, sessionState, lang)
}
return req.respond(accountId, changes, sessionState, AddressBookResponseObjectType, state)
})
}
// Get all the contacts in an addressbook of an account by its identifier.
func (g *Groupware) GetContactsInAddressbook(w http.ResponseWriter, r *http.Request) { //NOSONAR
g.respond(w, r, func(req Request) Response {

View File

@@ -21,8 +21,8 @@ import (
"github.com/opencloud-eu/opencloud/services/groupware/pkg/metrics"
)
// Get the changes that occured in a given mailbox since a certain state.
// @api:tags mailbox,changes
// Get the changes tp Emails since a certain State.
// @api:tags email,changes
func (g *Groupware) GetEmailChanges(w http.ResponseWriter, r *http.Request) {
g.respond(w, r, func(req Request) Response {
l := req.logger.With()
@@ -230,6 +230,9 @@ func (g *Groupware) GetEmailsById(w http.ResponseWriter, r *http.Request) { //NO
}
}
// Get the attachments of an email by its identifier.
//
// @api:tags email
func (g *Groupware) GetEmailAttachments(w http.ResponseWriter, r *http.Request) { //NOSONAR
contextAppender := func(l zerolog.Context) zerolog.Context { return l }
q := r.URL.Query()
@@ -941,6 +944,9 @@ func (e emailKeywordUpdates) IsEmpty() bool {
return len(e.Add) == 0 && len(e.Remove) == 0
}
// Update the keywords of an email by its identifier.
//
// @api:tags email
func (g *Groupware) UpdateEmailKeywords(w http.ResponseWriter, r *http.Request) { //NOSONAR
g.respond(w, r, func(req Request) Response {
l := req.logger.With()
@@ -1000,6 +1006,8 @@ func (g *Groupware) UpdateEmailKeywords(w http.ResponseWriter, r *http.Request)
}
// Add keywords to an email by its unique identifier.
//
// @api:tags email
func (g *Groupware) AddEmailKeywords(w http.ResponseWriter, r *http.Request) { //NOSONAR
g.respond(w, r, func(req Request) Response {
l := req.logger.With()
@@ -1060,6 +1068,8 @@ func (g *Groupware) AddEmailKeywords(w http.ResponseWriter, r *http.Request) { /
}
// Remove keywords of an email by its unique identifier.
//
// @api:tags email
func (g *Groupware) RemoveEmailKeywords(w http.ResponseWriter, r *http.Request) { //NOSONAR
g.respond(w, r, func(req Request) Response {
l := req.logger.With()

View File

@@ -0,0 +1,184 @@
package groupware
import (
"net/http"
"strings"
"github.com/opencloud-eu/opencloud/pkg/jmap"
"github.com/opencloud-eu/opencloud/pkg/log"
)
// Get all the events in a calendar of an account by its identifier.
func (g *Groupware) GetEventsInCalendar(w http.ResponseWriter, r *http.Request) { //NOSONAR
g.respond(w, r, func(req Request) Response {
ok, accountId, resp := req.needCalendarWithAccount()
if !ok {
return resp
}
l := req.logger.With()
calendarId, err := req.PathParam(UriParamCalendarId)
if err != nil {
return req.error(accountId, err)
}
l = l.Str(UriParamCalendarId, log.SafeString(calendarId))
offset, ok, err := req.parseUIntParam(QueryParamOffset, 0)
if err != nil {
return req.error(accountId, err)
}
if ok {
l = l.Uint(QueryParamOffset, offset)
}
limit, ok, err := req.parseUIntParam(QueryParamLimit, g.defaults.contactLimit)
if err != nil {
return req.error(accountId, err)
}
if ok {
l = l.Uint(QueryParamLimit, limit)
}
filter := jmap.CalendarEventFilterCondition{
InCalendar: calendarId,
}
sortBy := []jmap.CalendarEventComparator{{Property: jmap.CalendarEventPropertyUpdated, IsAscending: false}}
logger := log.From(l)
eventsByAccountId, sessionState, state, lang, jerr := g.jmap.QueryCalendarEvents(single(accountId), req.session, req.ctx, logger, req.language(), filter, sortBy, offset, limit)
if jerr != nil {
return req.jmapError(accountId, jerr, sessionState, lang)
}
if events, ok := eventsByAccountId[accountId]; ok {
return req.respond(accountId, events, sessionState, EventResponseObjectType, state)
} else {
return req.notFound(accountId, sessionState, EventResponseObjectType, state)
}
})
}
// Get changes to Contacts since a given State
// @api:tags event,changes
func (g *Groupware) GetEventChanges(w http.ResponseWriter, r *http.Request) {
g.respond(w, r, func(req Request) Response {
ok, accountId, resp := req.needCalendarWithAccount()
if !ok {
return resp
}
l := req.logger.With()
var maxChanges uint = 0
if v, ok, err := req.parseUIntParam(QueryParamMaxChanges, 0); err != nil {
return req.error(accountId, err)
} else if ok {
maxChanges = v
l = l.Uint(QueryParamMaxChanges, v)
}
sinceState := jmap.State(req.OptHeaderParamDoc(HeaderParamSince, "Specifies the state identifier from which on to list event changes"))
l = l.Str(HeaderParamSince, log.SafeString(string(sinceState)))
logger := log.From(l)
changes, sessionState, state, lang, jerr := g.jmap.GetCalendarEventChanges(accountId, req.session, req.ctx, logger, req.language(), sinceState, maxChanges)
if jerr != nil {
return req.jmapError(accountId, jerr, sessionState, lang)
}
var body jmap.CalendarEventChanges = changes
return req.respond(accountId, body, sessionState, ContactResponseObjectType, state)
})
}
func (g *Groupware) CreateCalendarEvent(w http.ResponseWriter, r *http.Request) {
g.respond(w, r, func(req Request) Response {
ok, accountId, resp := req.needCalendarWithAccount()
if !ok {
return resp
}
l := req.logger.With()
var create jmap.CalendarEvent
err := req.body(&create)
if err != nil {
return req.error(accountId, err)
}
logger := log.From(l)
created, sessionState, state, lang, jerr := g.jmap.CreateCalendarEvent(accountId, req.session, req.ctx, logger, req.language(), create)
if jerr != nil {
return req.jmapError(accountId, jerr, sessionState, lang)
}
return req.respond(accountId, created, sessionState, EventResponseObjectType, state)
})
}
func (g *Groupware) DeleteCalendarEvent(w http.ResponseWriter, r *http.Request) {
g.respond(w, r, func(req Request) Response {
ok, accountId, resp := req.needCalendarWithAccount()
if !ok {
return resp
}
l := req.logger.With().Str(accountId, log.SafeString(accountId))
eventId, err := req.PathParam(UriParamEventId)
if err != nil {
return req.error(accountId, err)
}
l.Str(UriParamEventId, log.SafeString(eventId))
logger := log.From(l)
deleted, sessionState, state, lang, jerr := g.jmap.DeleteCalendarEvent(accountId, []string{eventId}, req.session, req.ctx, logger, req.language())
if jerr != nil {
return req.jmapError(accountId, jerr, sessionState, lang)
}
for _, e := range deleted {
desc := e.Description
if desc != "" {
return req.errorS(accountId, apiError(
req.errorId(),
ErrorFailedToDeleteContact,
withDetail(e.Description),
), sessionState)
} else {
return req.errorS(accountId, apiError(
req.errorId(),
ErrorFailedToDeleteContact,
), sessionState)
}
}
return req.noContent(accountId, sessionState, EventResponseObjectType, state)
})
}
// Parse a blob that contains an iCal file and return it as JSCalendar.
//
// @api:tags calendar,blob
func (g *Groupware) ParseIcalBlob(w http.ResponseWriter, r *http.Request) {
g.respond(w, r, func(req Request) Response {
accountId, err := req.GetAccountIdForBlob()
if err != nil {
return req.error(accountId, err)
}
blobId, err := req.PathParam(UriParamBlobId)
if err != nil {
return req.error(accountId, err)
}
blobIds := strings.Split(blobId, ",")
l := req.logger.With().Array(UriParamBlobId, log.SafeStringArray(blobIds))
logger := log.From(l)
resp, sessionState, state, lang, jerr := g.jmap.ParseICalendarBlob(accountId, req.session, req.ctx, logger, req.language(), blobIds)
if jerr != nil {
return req.jmapError(accountId, jerr, sessionState, lang)
}
return req.respond(accountId, resp, sessionState, EventResponseObjectType, state)
})
}

View File

@@ -176,7 +176,7 @@ func (g *Groupware) GetMailboxByRoleForAllAccounts(w http.ResponseWriter, r *htt
})
}
// Get the changes that occured in a given mailbox since a certain state.
// Get the changes tp Mailboxes since a certain State.
// @api:tags mailbox,changes
func (g *Groupware) GetMailboxChanges(w http.ResponseWriter, r *http.Request) {
g.respond(w, r, func(req Request) Response {

View File

@@ -36,6 +36,8 @@ type ObjectsRequest struct {
// The response then includes the new state after that maximum number if changes,
// as well as a `hasMoreChanges` boolean flag which can be used to paginate the retrieval of
// changes and the objects associated with the identifiers.
//
// @api:tags mailbox,email,addressbook,contact,calendar,event,quota,identity
func (g *Groupware) GetObjects(w http.ResponseWriter, r *http.Request) { //NOSONAR
g.respond(w, r, func(req Request) Response {
l := req.logger.With()

View File

@@ -200,6 +200,8 @@ const (
ErrorCodeFailedToDeleteEmail = "DELEML"
ErrorCodeFailedToDeleteSomeIdentities = "DELSID"
ErrorCodeFailedToSanitizeEmail = "FSANEM"
ErrorCodeFailedToDeleteAddressBook = "DELABK"
ErrorCodeFailedToDeleteCalendar = "DELCAL"
ErrorCodeFailedToDeleteContact = "DELCNT"
ErrorCodeNoMailboxWithDraftRole = "NMBXDR"
ErrorCodeNoMailboxWithSentRole = "NMBXSE"
@@ -443,12 +445,24 @@ var (
Title: "Failed to sanitize an email",
Detail: "Email content sanitization failed.",
}
ErrorFailedToDeleteAddressBook = GroupwareError{
Status: http.StatusInternalServerError,
Code: ErrorCodeFailedToDeleteAddressBook,
Title: "Failed to delete address books",
Detail: "One or more address books could not be deleted.",
}
ErrorFailedToDeleteContact = GroupwareError{
Status: http.StatusInternalServerError,
Code: ErrorCodeFailedToDeleteContact,
Title: "Failed to delete contacts",
Detail: "One or more contacts could not be deleted.",
}
ErrorFailedToDeleteCalendar = GroupwareError{
Status: http.StatusInternalServerError,
Code: ErrorCodeFailedToDeleteCalendar,
Title: "Failed to delete calendar",
Detail: "One or more calendars could not be deleted.",
}
ErrorNoMailboxWithDraftRole = GroupwareError{
Status: http.StatusExpectationFailed,
Code: ErrorCodeNoMailboxWithDraftRole,

View File

@@ -187,3 +187,17 @@ func (e Exemplar) DeletedMailboxes() ([]string, string, string, string) {
j := jmap.ExemplarInstance
return []string{j.MailboxProjectId, j.MailboxJunkId}, "Identifiers of the Mailboxes that have successfully been deleted", "", "deletedmailboxes"
}
func (e Exemplar) ObjectsRequest() ObjectsRequest {
return ObjectsRequest{
Mailboxes: []string{"ahh9ye", "ahbei8"},
Emails: []string{"koo6ka", "fa1ees", "zaish0", "iek2fo"},
Addressbooks: []string{"ungu0a"},
Contacts: []string{"oo8ahv", "lexue6", "mohth3"},
Calendars: []string{"aa8aqu", "detho5"},
Events: []string{"oo8thu", "mu9sha", "aim1sh", "sair6a"},
Quotas: []string{"vei4ai"},
Identities: []string{"iuj4ae", "mahv9y"},
EmailSubmissions: []string{"eidoo6", "aakie7", "uh7ous"},
}
}

View File

@@ -148,22 +148,28 @@ func (g *Groupware) Route(r chi.Router) {
})
r.Route("/addressbooks", func(r chi.Router) {
r.Get("/", g.GetAddressbooks)
r.Post("/", g.CreateAddressBook)
r.Route("/{addressbookid}", func(r chi.Router) {
r.Get("/", g.GetAddressbook)
r.Get("/contacts", g.GetContactsInAddressbook) //NOSONAR
r.Delete("/", g.DeleteAddressBook)
})
})
r.Route("/contacts", func(r chi.Router) {
r.Get("/", g.GetAllContacts)
r.Post("/", g.CreateContact)
r.Delete("/{contactid}", g.DeleteContact)
r.Get("/{contactid}", g.GetContactById)
r.Route("/{contactid}", func(r chi.Router) {
r.Get("/", g.GetContactById)
r.Delete("/", g.DeleteContact)
})
})
r.Route("/calendars", func(r chi.Router) {
r.Get("/", g.GetCalendars)
r.Post("/", g.CreateCalendar)
r.Route("/{calendarid}", func(r chi.Router) {
r.Get("/", g.GetCalendarById)
r.Get("/events", g.GetEventsInCalendar) //NOSONAR
r.Delete("/", g.DeleteCalendar)
})
})
r.Route("/events", func(r chi.Router) {

View File

@@ -9,8 +9,8 @@ importers:
.:
dependencies:
'@redocly/cli':
specifier: ^2.25.2
version: 2.25.2(@opentelemetry/api@1.9.1)(core-js@3.45.1)
specifier: ^2.25.3
version: 2.25.3(@opentelemetry/api@1.9.1)(core-js@3.45.1)
'@types/js-yaml':
specifier: ^4.0.9
version: 4.0.9
@@ -194,8 +194,8 @@ packages:
'@redocly/cli-otel@0.1.2':
resolution: {integrity: sha512-Bg7BoO5t1x3lVK+KhA5aGPmeXpQmdf6WtTYHhelKJCsQ+tRMiJoFAQoKHoBHAoNxXrhlS3K9lKFLHGmtxsFQfA==}
'@redocly/cli@2.25.2':
resolution: {integrity: sha512-kn1SiHDss3t+Ami37T6ZH5ov1fiEXF1y488bUOUgrh0pEK8VOq8+HlPbdte/cH0K+dWPhuLyKNACd+KhMQPjCw==}
'@redocly/cli@2.25.3':
resolution: {integrity: sha512-02wjApwJwGD+kGWRoiFVY0Hq960ydMAMHrK3AJH2LMiYNYcrzAr1FSbA3OSylvg2gx3w/r1r710B+iMz3KJKbw==}
engines: {node: '>=22.12.0 || >=20.19.0 <21.0.0', npm: '>=10'}
hasBin: true
@@ -209,12 +209,12 @@ packages:
resolution: {integrity: sha512-V09ayfnb5GyysmvARbt+voFZAjGcf7hSYxOYxSkCc4fbH/DTfq5YWoec8cflvmHHqyIFbqvmGKmYFzqhr9zxDg==}
engines: {node: '>=18.17.0', npm: '>=9.5.0'}
'@redocly/openapi-core@2.25.2':
resolution: {integrity: sha512-HIvxgwxQct/IdRJjjqu4g8BLpCik6I3zxp8JFJpRtmY1TSIZAOZjJwlkoh4uQcy/nCP+psSMgQvzjVGml3k6+w==}
'@redocly/openapi-core@2.25.3':
resolution: {integrity: sha512-GIu3Mdym5IDIPCvXTzMZ6TQw/+7sKd52PdysxNVe7zBk22ExSGnVE9UAk9BaLOzXT77PJWDUwaimBdJoPpxHMA==}
engines: {node: '>=22.12.0 || >=20.19.0 <21.0.0', npm: '>=10'}
'@redocly/respect-core@2.25.2':
resolution: {integrity: sha512-GpvmjY2x8u4pAGNts7slexuKDzDWHNUB4gey9/rSqvC8IaqY49vkvMuRodIBwCsqXhn2rpkJbar1UK3rAOuy7g==}
'@redocly/respect-core@2.25.3':
resolution: {integrity: sha512-07m80JYdp7J7kH4D1Vqdpa2ZBFCv3QAwCoh2w9H3OjuT/rXQkBSkJQm1n70fzO/HuUf4azzULdp2XnsIpxP2qw==}
engines: {node: '>=22.12.0 || >=20.19.0 <21.0.0', npm: '>=10'}
'@tsconfig/node10@1.0.12':
@@ -556,8 +556,8 @@ packages:
engines: {node: '>= 12'}
hasBin: true
minimatch@10.2.4:
resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==}
minimatch@10.2.5:
resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
engines: {node: 18 || 20 || >=22}
minimatch@5.1.9:
@@ -1135,15 +1135,15 @@ snapshots:
dependencies:
ulid: 2.4.0
'@redocly/cli@2.25.2(@opentelemetry/api@1.9.1)(core-js@3.45.1)':
'@redocly/cli@2.25.3(@opentelemetry/api@1.9.1)(core-js@3.45.1)':
dependencies:
'@opentelemetry/exporter-trace-otlp-http': 0.202.0(@opentelemetry/api@1.9.1)
'@opentelemetry/resources': 2.0.1(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-trace-node': 2.0.1(@opentelemetry/api@1.9.1)
'@opentelemetry/semantic-conventions': 1.34.0
'@redocly/cli-otel': 0.1.2
'@redocly/openapi-core': 2.25.2
'@redocly/respect-core': 2.25.2
'@redocly/openapi-core': 2.25.3
'@redocly/respect-core': 2.25.3
abort-controller: 3.0.0
ajv: '@redocly/ajv@8.18.0'
ajv-formats: 3.0.1(@redocly/ajv@8.18.0)
@@ -1195,7 +1195,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@redocly/openapi-core@2.25.2':
'@redocly/openapi-core@2.25.3':
dependencies:
'@redocly/ajv': 8.18.0
'@redocly/config': 0.45.0
@@ -1208,12 +1208,12 @@ snapshots:
pluralize: 8.0.0
yaml-ast-parser: 0.0.43
'@redocly/respect-core@2.25.2':
'@redocly/respect-core@2.25.3':
dependencies:
'@faker-js/faker': 7.6.0
'@noble/hashes': 1.8.0
'@redocly/ajv': 8.18.0
'@redocly/openapi-core': 2.25.2
'@redocly/openapi-core': 2.25.3
ajv: '@redocly/ajv@8.18.0'
better-ajv-errors: 1.2.0(@redocly/ajv@8.18.0)
colorette: 2.0.20
@@ -1446,7 +1446,7 @@ snapshots:
glob@13.0.6:
dependencies:
minimatch: 10.2.4
minimatch: 10.2.5
minipass: 7.1.3
path-scurry: 2.0.2
@@ -1527,7 +1527,7 @@ snapshots:
marked@4.3.0: {}
minimatch@10.2.4:
minimatch@10.2.5:
dependencies:
brace-expansion: 5.0.5