From 5df0ec0e25f9bef080e3bdc5f6819c54e2bb014f Mon Sep 17 00:00:00 2001 From: Pascal Bleser Date: Wed, 1 Oct 2025 12:13:08 +0200 Subject: [PATCH] groupware: more mock data, added missing JMAP types --- pkg/jmap/jmap_model.go | 564 +++++++++++++++++- pkg/jscalendar/jscalendar_model.go | 34 +- pkg/jscalendar/jscalendar_model_test.go | 1 - services/groupware/api-examples.yaml | 1 - .../pkg/groupware/groupware_api_account.go | 4 +- .../pkg/groupware/groupware_mock_calendars.go | 123 +++- .../pkg/groupware/groupware_mock_contacts.go | 14 +- .../pkg/groupware/groupware_request.go | 6 +- 8 files changed, 668 insertions(+), 79 deletions(-) diff --git a/pkg/jmap/jmap_model.go b/pkg/jmap/jmap_model.go index 52448a547..529442eed 100644 --- a/pkg/jmap/jmap_model.go +++ b/pkg/jmap/jmap_model.go @@ -7,11 +7,64 @@ import ( "github.com/opencloud-eu/opencloud/pkg/jscalendar" ) +// https://www.iana.org/assignments/jmap/jmap.xml#jmap-data-types +type ObjectType string + // TODO -type UTCDate struct { +type UTCDateTime struct { time.Time } +// TODO +type LocalDate struct { + time.Time +} + +// Should the calendar’s 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 user’s own account, and “none” for calendars shared with the user. +type IncludeInAvailability string + +type TypeOfCalendarAlert string + +// `CalendarEventNotification` type. +// +// This MUST be one of +// !- `created` +// !- `updated` +// !- `destroyed` +type CalendarEventNotificationTypeOption string + +// `Principal` type. +// +// This MUST be one of the following values: +// !- `individual`: This represents a single person. +// !- `group`: This represents a group of people. +// !- `resource`: This represents some resource, e.g. a projector. +// !- `location`: This represents a location. +// !- `other`: This represents some other undefined principal. +type PrincipalTypeOption string + +// Algorithms in this list MUST be present in the ["HTTP Digest Algorithm Values" registry] +// defined by [RFC3230]; however, in JMAP, they must be lowercased, e.g., "md5" rather than +// "MD5". +// +// Clients SHOULD prefer algorithms listed earlier in this list. +// +// ["HTTP Digest Algorithm Values" registry]: https://www.iana.org/assignments/http-dig-alg/http-dig-alg.xhtml +type HttpDigestAlgorithm string + +// The ResourceType data type is used to act as a unit of measure for the quota usage. +type ResourceType string + +// The Scope data type is used to represent the entities the quota applies to. +type Scope string + const ( JmapCore = "urn:ietf:params:jmap:core" JmapMail = "urn:ietf:params:jmap:mail" @@ -24,6 +77,30 @@ const ( JmapBlob = "urn:ietf:params:jmap:blob" JmapQuota = "urn:ietf:params:jmap:quota" JmapWebsocket = "urn:ietf:params:jmap:websocket" + JmapPrincipals = "urn:ietf:params:jmap:principals" + JmapPrincipalsOwner = "urn:ietf:params:jmap:principals:owner" + + CoreType = ObjectType("Core") + PushSubscriptionType = ObjectType("PushSubscription") + MailboxType = ObjectType("Mailbox") + ThreadType = ObjectType("Thread") + EmailType = ObjectType("Email") + EmailDeliveryType = ObjectType("EmailDelivery") + SearchSnippetType = ObjectType("SearchSnippet") + IdentityType = ObjectType("Identity") + EmailSubmissionType = ObjectType("EmailSubmission") + VacationResponseType = ObjectType("VacationResponse") + MDNType = ObjectType("MDN") + QuotaType = ObjectType("Quota") + SieveScriptType = ObjectType("SieveScript") + PrincipalType = ObjectType("PrincipalType") + ShareNotificationType = ObjectType("ShareNotification") + AddressBookType = ObjectType("AddressBook") + ContactCardType = ObjectType("ContactCard") + CalendarType = ObjectType("Calendar") + CalendarEventType = ObjectType("CalendarEvent") + CalendarEventNotificationType = ObjectType("CalendarEventNotification") + ParticipantIdentityType = ObjectType("ParticipantIdentity") JmapKeywordPrefix = "$" JmapKeywordSeen = "$seen" @@ -47,9 +124,71 @@ const ( JmapMailboxRoleSent = "sent" //JmapMailboxRoleSubscribed = "subscribed" JmapMailboxRoleTrash = "trash" + + CalendarAlertType = TypeOfCalendarAlert("CalendarAlert") + + CalendarEventNotificationTypeOptionCreated = CalendarEventNotificationTypeOption("created") + CalendarEventNotificationTypeOptionUpdated = CalendarEventNotificationTypeOption("updated") + CalendarEventNotificationTypeOptionDestroyed = CalendarEventNotificationTypeOption("destroyed") + + PrincipalTypeOptionIndividual = PrincipalTypeOption("individual") + PrincipalTypeOptionGroup = PrincipalTypeOption("group") + PrincipalTypeOptionResource = PrincipalTypeOption("resource") + PrincipalTypeOptionLocation = PrincipalTypeOption("location") + PrincipalTypeOptionOther = PrincipalTypeOption("other") + + HttpDigestAlgorithmAdler32 = HttpDigestAlgorithm("adler32") + HttpDigestAlgorithmCrc32c = HttpDigestAlgorithm("crc32c") + HttpDigestAlgorithmMd5 = HttpDigestAlgorithm("md5") + HttpDigestAlgorithmSha = HttpDigestAlgorithm("sha") + HttpDigestAlgorithmSha256 = HttpDigestAlgorithm("sha-256") + HttpDigestAlgorithmSha512 = HttpDigestAlgorithm("sha-512") + HttpDigestAlgorithmUnixSum = HttpDigestAlgorithm("unixsum") + HttpDigestAlgorithmUnixcksum = HttpDigestAlgorithm("unixcksum") + + // The quota is measured in a number of data type objects. + // + // For example, a quota can have a limit of 50 `Mail` objects. + ResourceTypeCount = ResourceType("count") + + // The quota is measured in size (in octets). + // + // For example, a quota can have a limit of 25000 octets. + ResourceTypeOctets = ResourceType("octets") + + // The quota information applies to just the client's account. + ScopeAccount = Scope("account") + // The quota information applies to all accounts sharing this domain. + ScopeDomain = Scope("domain") + // The quota information applies to all accounts belonging to the server. + ScopeGlobal = Scope("global") ) var ( + ObjectTypes = []ObjectType{ + CoreType, + PushSubscriptionType, + MailboxType, + ThreadType, + EmailType, + EmailDeliveryType, + SearchSnippetType, + IdentityType, + EmailSubmissionType, + VacationResponseType, + MDNType, + QuotaType, + SieveScriptType, + PrincipalType, + ShareNotificationType, + AddressBookType, + ContactCardType, + CalendarType, + CalendarEventType, + CalendarEventNotificationType, + ParticipantIdentityType, + } + JmapMailboxRoles = []string{ JmapMailboxRoleInbox, JmapMailboxRoleSent, @@ -57,17 +196,43 @@ var ( JmapMailboxRoleJunk, JmapMailboxRoleTrash, } -) -// Should the calendar’s 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 user’s own account, and “none” for calendars shared with the user. -type IncludeInAvailability string + CalendarEventNotificationOptionTypes = []CalendarEventNotificationTypeOption{ + CalendarEventNotificationTypeOptionCreated, + CalendarEventNotificationTypeOptionUpdated, + CalendarEventNotificationTypeOptionDestroyed, + } + + PrincipalTypeOptions = []PrincipalTypeOption{ + PrincipalTypeOptionIndividual, + PrincipalTypeOptionGroup, + PrincipalTypeOptionResource, + PrincipalTypeOptionLocation, + PrincipalTypeOptionOther, + } + + HttpDigestAlgorithms = []HttpDigestAlgorithm{ + HttpDigestAlgorithmAdler32, + HttpDigestAlgorithmCrc32c, + HttpDigestAlgorithmMd5, + HttpDigestAlgorithmSha, + HttpDigestAlgorithmSha256, + HttpDigestAlgorithmSha512, + HttpDigestAlgorithmUnixSum, + HttpDigestAlgorithmUnixcksum, + } + + ResourceTypes = []ResourceType{ + ResourceTypeCount, + ResourceTypeOctets, + } + + Scopes = []Scope{ + ScopeAccount, + ScopeDomain, + ScopeGlobal, + } +) const ( IncludeInAvailabilityAll = IncludeInAvailability("all") @@ -263,7 +428,7 @@ type SessionBlobAccountCapabilities struct { // Clients SHOULD prefer algorithms listed earlier in this list. // // ["HTTP Digest Algorithm Values" registry]: https://www.iana.org/assignments/http-dig-alg/http-dig-alg.xhtml - SupportedDigestAlgorithms []string `json:"supportedDigestAlgorithms"` + SupportedDigestAlgorithms []HttpDigestAlgorithm `json:"supportedDigestAlgorithms"` } type SessionQuotaAccountCapabilities struct { @@ -280,6 +445,20 @@ type SessionContactsAccountCapabilities struct { MayCreateAddressBook bool `json:"mayCreateAddressBook"` } +type SessionPrincipalsAccountCapabilities struct { + // The id of the principal in this account that corresponds to the user fetching this object, if any. + CurrentUserPrincipalId string `json:"currentUserPrincipalId,omitempty"` +} + +type SessionPrincipalsOwnerAccountCapabilities struct { + // The id of an account with the `urn:ietf:params:jmap:principals` capability that contains the + // corresponding `Principal` object. + AccountIdForPrincipal string `json:"accountIdForPrincipal,omitempty"` + + // The id of the `Principal` that owns this account. + PrincipalId string `json:"principalId,omitempty"` +} + type SessionAccountCapabilities struct { Mail SessionMailAccountCapabilities `json:"urn:ietf:params:jmap:mail"` Submission SessionSubmissionAccountCapabilities `json:"urn:ietf:params:jmap:submission"` @@ -288,9 +467,11 @@ type SessionAccountCapabilities struct { Blob SessionBlobAccountCapabilities `json:"urn:ietf:params:jmap:blob"` Quota SessionQuotaAccountCapabilities `json:"urn:ietf:params:jmap:quota"` Contacts SessionContactsAccountCapabilities `json:"urn:ietf:params:jmap:contacts"` + Principals *SessionPrincipalsAccountCapabilities `json:"urn:ietf:params:jmap:principals,omitempty"` + PrincipalsOwner *SessionPrincipalsOwnerAccountCapabilities `json:"urn:ietf:params:jmap:principals:owner,omitempty"` } -type SessionAccount struct { +type Account struct { // A user-friendly string to show when presenting content from this account, e.g., the email address representing the owner of the account. Name string `json:"name,omitempty"` // This is true if the account belongs to the authenticated user rather than a group account or a personal account of another user that has been shared with them. @@ -357,6 +538,9 @@ type SessionWebsocketCapabilities struct { type SessionContactsCapabilities struct { } +type SessionPrincipalCapabilities struct { +} + type SessionCapabilities struct { Core SessionCoreCapabilities `json:"urn:ietf:params:jmap:core"` Mail SessionMailCapabilities `json:"urn:ietf:params:jmap:mail"` @@ -366,7 +550,8 @@ type SessionCapabilities struct { Blob SessionBlobCapabilities `json:"urn:ietf:params:jmap:blob"` Quota SessionQuotaCapabilities `json:"urn:ietf:params:jmap:quota"` Websocket SessionWebsocketCapabilities `json:"urn:ietf:params:jmap:websocket"` - Contacts SessionContactsCapabilities `json:"urn:ietf:params:jmap:contacts"` + Contacts *SessionContactsCapabilities `json:"urn:ietf:params:jmap:contacts"` + Principals *SessionPrincipalCapabilities `json:"urn:ietf:params:jmap:principals"` } type SessionPrimaryAccounts struct { @@ -387,7 +572,7 @@ type State string type SessionResponse struct { Capabilities SessionCapabilities `json:"capabilities"` - Accounts map[string]SessionAccount `json:"accounts,omitempty"` + Accounts map[string]Account `json:"accounts,omitempty"` // A map of capability URIs (as found in accountCapabilities) to the account id that is considered to be the user’s main or default // account for data pertaining to that capability. @@ -2010,15 +2195,6 @@ type EmailSubmissionSetResponse struct { // TODO(pbleser-oc) add updated and destroyed when they are needed } -type ObjectType string - -const ( - VacationResponseType ObjectType = "VacationResponse" - EmailType ObjectType = "Email" - EmailDeliveryType ObjectType = "EmailDelivery" - MailboxType ObjectType = "Mailbox" -) - type Command string type Invocation struct { @@ -3160,23 +3336,347 @@ type CalendarEvent struct { // `recurrenceId` within a particular account. Id string `json:"id"` - baseEventId string + // This is only defined if the `id` property is a synthetic id, generated by the + // server to represent a particular instance of a recurring event (immutable; server-set). + // + // This property gives the id of the "real" `CalendarEvent` this was generated from. + BaseEventId string `json:"baseEventId,omitempty"` - calendarIds map[string]bool + // The set of Calendar ids this event belongs to. + // + // An event MUST belong to one or more Calendars at all times (until it is destroyed). + // + // The set is represented as an object, with each key being a Calendar id. + // + // The value for each key in the object MUST be `true`. + CalendarIds map[string]bool `json:"calendarIds,omitempty"` - isDraft bool + // If true, this event is to be considered a draft. + // + // The server will not send any scheduling messages to participants or send push notifications + // for alerts. + // + // This may only be set to `true` upon creation. + // + // Once set to `false`, the value cannot be updated to `true`. + // + // This property MUST NOT appear in `recurrenceOverrides`. + IsDraft bool `json:"isDraft,omitzero"` - isOrigin bool + // Is this the authoritative source for this event (i.e., does it control scheduling for + // this event; the event has not been added as a result of an invitation from another calendar system)? + // + // This is true if, and only if: + // !- the event’s `replyTo` property is null; or + // !- the account will receive messages sent to at least one of the methods specified in the `replyTo` property of the event. + IsOrigin bool `json:"isOrigin,omitzero"` - utcStart UTCDate + // For simple clients that do not implement time zone support. + // + // Clients should only use this if also asking the server to expand recurrences, as you cannot accurately + // expand a recurrence without the original time zone. + // + // This property is calculated at fetch time by the server. + // + // Time zones are political and they can and do change at any time. + // + // Fetching exactly the same property again may return a different results if the time zone data has been updated on the server. + // + // Time zone data changes are not considered `updates` to the event. + // + // If set, the server will convert the UTC date to the event's current time zone and store the local time. + // + // This property is not included in `CalendarEvent/get` responses by default and must be requested explicitly. + // + // Floating events (events without a time zone) will be interpreted as per the time zone given as a `CalendarEvent/get` argument. + // + // Note that it is not possible to accurately calculate the expansion of recurrence rules or recurrence overrides with the + // `utcStart` property rather than the local start time. Even simple recurrences such as "repeat weekly" may cross a + // daylight-savings boundary and end up at a different UTC time. Clients that wish to use "utcStart" are RECOMMENDED to + // request the server expand recurrences. + UtcStart UTCDateTime `json:"utcStart,omitzero"` - utcEnd UTCDate - - // TODO https://jmap.io/spec-calendars.html#calendar-events + // The server calculates the end time in UTC from the start/timeZone/duration properties of the event. + // + // This property is not included by default and must be requested explicitly. + // + // Like `utcStart`, it is calculated at fetch time if requested and may change due to time zone data changes. + // + // Floating events will be interpreted as per the time zone given as a `CalendarEvent/get` argument. + UtcEnd UTCDateTime `json:"utcEnd,omitzero"` jscalendar.Event } +// A ParticipantIdentity stores information about a URI that represents the user within that account in an event’s participants. +type ParticipantIdentity struct { + // The id of the ParticipantIdentity (immutable; server-set). + Id string `json:"id"` + + // The display name of the participant to use when adding this participant to an event, e.g. "Joe Bloggs". + // + // default: + Name string `json:"name,omitempty"` + + // The URI that represents this participant for scheduling. + // + // This URI MAY also be the URI for one of the sendTo methods. + ScheduleId string `json:"scheduleId"` + + // Represents methods by which the participant may receive invitations and updates to an event. + // + // The keys in the property value are the available methods and MUST only contain ASCII alphanumeric + // characters (`A-Za-z0-9`). + // + // The value is a URI for the method specified in the key. + SendTo map[string]string `json:"sendTo,omitempty"` + + // This SHOULD be true for exactly one participant identity in any account, and MUST NOT be true for more + // than one participant identity within an account (server-set). + // + // The default identity should be used by clients whenever they need to choose an identity for the user + // within this account, and they do not have any other information on which to make a choice. + // + // For example, if creating a scheduled event in this account, the default identity may be automatically + // added as an owner. (But the client may ignore this if, for example, it has its own feature to allow + // users to choose which identity to use based on the invitees.) + IsDefault bool `json:"isDefault,omitzero"` +} + +type CalendarAlert struct { + // This MUST be the string `CalendarAlert`. + Type TypeOfCalendarAlert `json:"@type,omitempty"` + + // The account id for the calendar in which the alert triggered. + AccountId string `json:"accountId"` + + // The CalendarEvent id for the alert that triggered. + // + // Note, for a recurring event this is the id of the base event, never a synthetic id for a particular instance. + CalendarEventId string `json:"calendarEventId"` + + // The uid property of the CalendarEvent for the alert that triggered. + Uid string `json:"uid"` + + // The `recurrenceId` for the instance of the event for which this alert is being + // triggered, or null if the event is not recurring. + RecurrenceId LocalDate `json:"recurrenceId,omitzero"` + + // The id for the alert that triggered. + AlertId string `json:"alertId"` +} + +type Person struct { + // The name of the person who made the change. + Name string `json:"name"` + + // The email of the person who made the change, or null if no email is available. + Email string `json:"email,omitempty"` + + // The id of the `Principal` corresponding to the person who made the change, if any. + // + // This will be null if the change was due to receving an iTIP message. + PrincipalId string `json:"principalId,omitempty"` + + // The `scheduleId` URI of the person who made the change, if any. + // + // This will normally be set if the change was made due to receving an iTIP message. + ScheduleId string `json:"scheduleId,omitempty"` +} + +type CalendarEventNotification struct { + // The id of the `CalendarEventNotification`. + Id string `json:"id"` + + // The time this notification was created. + Created UTCDateTime `json:"created,omitzero"` + + // Who made the change. + ChangedBy *Person `json:"person,omitempty"` + + // Comment sent along with the change by the user that made it. + // + // (e.g. `COMMENT` property in an iTIP message), if any. + Comment string `json:"comment,omitempty"` + + // `CalendarEventNotification` type. + // + // This MUST be one of + // !- `created` + // !- `updated` + // !- `destroyed` + Type CalendarEventNotificationTypeOption `json:"type"` + + // The id of the CalendarEvent that this notification is about. + // + // If the change only affects a single instance of a recurring event, the server MAY set the + // `event` and `event`atch properties for just that instance; the `calendarEventId` MUST + // still be for the base event. + CalendarEventId string `json:"calendarEventId"` + + // Is this event a draft? (created/updated only) + IsDraft bool `json:"isDraft,omitzero"` + + // The data before the change (if updated or destroyed), + // or the data after creation (if created). + Event *jscalendar.Event `json:"event,omitempty"` + + // A patch encoding the change between the data in the event property, + // and the data after the update (updated only). + EventPatch PatchObject `json:"eventPatch,omitempty"` +} + +// A Principal represents an individual, group, location (e.g. a room), resource (e.g. a projector) or other entity +// in a collaborative environment. +// +// Sharing in JMAP is generally configured by assigning rights to certain data within an account to other principals, +// for example a user may assign permission to read their calendar to a principal representing another user, or their team. +// +// In a shared environment such as a workplace, a user may have access to a large number of principals. +// +// In most systems the user will have access to a single `Account` containing `Principal` objects, but they may +// have access to multiple if, for example, aggregating data from different places. +type Principal struct { + // The id of the principal. + Id string `json:"id"` + + // `Principal` type. + // + // This MUST be one of the following values: + // !- `individual`: This represents a single person. + // !- `group`: This represents a group of people. + // !- `resource`: This represents some resource, e.g. a projector. + // !- `location`: This represents a location. + // !- `other`: This represents some other undefined principal. + Type PrincipalTypeOption `json:"type"` + + // The name of the principal, e.g. `"Jane Doe"`, or `"Room 4B"`. + Name string `json:"name"` + + // A longer description of the principal, for example details about the + // facilities of a resource, or null if no description available. + Description string `json:"description,omitempty"` + + // An email address for the principal, or null if no email is available. + Email string `json:"email,omitempty"` + + // The time zone for this principal, if known. + // + // If not null, the value MUST be a time zone id from the IANA Time Zone Database TZDB. + TimeZone string `json:"timeZone,omitempty"` + + // A map of JMAP capability URIs to domain specific information about the principal in relation + // to that capability, as defined in the document that registered the capability. + Capabilities map[string]any `json:"capabilities,omitempty"` + + // A map of account id to `Account` object for each JMAP Account containing data for + // this principal that the user has access to, or null if none. + Accounts map[string]Account `json:"accounts,omitempty"` +} + +// TODO https://jmap.io/spec-sharing.html#object-properties +type ShareNotification struct { +} + +type Shareable struct { + // Has the user indicated they wish to see this data? + // + // The initial value for this when data is shared by another user is implementation dependent, + // although data types may give advice on appropriate defaults. + IsSubscribed bool `json:"isSubscribed,omitzero"` + + // The set of permissions the user currently has. + // + // Appropriate permissions are domain specific and must be defined per data type. + MyRights map[string]bool `json:"myRights,omitempty"` + + // A map of principal id to rights to give that principal, or null if not shared with anyone. + // + // The account id for the principal id can be found in the capabilities of the `Account` this object is in. + // + // Users with appropriate permission may set this property to modify who the data is shared with. + // + // The principal that owns the account this data is in MUST NOT be in the set of sharees; their rights are implicit. + ShareWith map[string]map[string]bool `json:"shareWith,omitempty"` +} + +// The Quota is an object that displays the limit set to an account usage. +// +// It then shows as well the current usage in regard to that limit. +type Quota struct { + // The unique identifier for this object. + Id string `json:"id"` + + // The resource type of the quota. + ResourceType ResourceType `json:"resourceType"` + + // The current usage of the defined quota, using the `resourceType` defined as unit of measure. + // + // Computation of this value is handled by the server. + Used uint `json:"used"` + + // The hard limit set by this quota, using the `resourceType` defined as unit of measure. + // + // Objects in scope may not be created or updated if this limit is reached. + HardLimit uint `json:"hardLimit"` + + // The Scope data type is used to represent the entities the quota applies to. + // + // It is defined as a "String" with values from the following set: + // !- `account`: The quota information applies to just the client's account. + // !- `domain`: The quota information applies to all accounts sharing this domain. + // !- `global`: The quota information applies to all accounts belonging to the server. + Scope Scope `json:"scope"` + + // The name of the quota. + // + // Useful for managing quotas and using queries for searching. + Name string `json:"name"` + + // A list of all the type names as defined in the "JMAP Types Names" registry + // (e.g., `Email`, `Calendar`, etc.) to which this quota applies. + // + // This allows the quotas to be assigned to distinct or shared data types. + // + // The server MUST filter out any types for which the client did not request the associated capability + // in the `using` section of the request. + // + // Further, the server MUST NOT return Quota objects for which there are no types recognized by the client. + Types []ObjectType `json:"types,omitempty"` + + // The warn limit set by this quota, using the `resourceType` defined as unit of measure. + // + // It can be used to send a warning to an entity about to reach the hard limit soon, but with no + // action taken yet. + // + // If set, it SHOULD be lower than the `softLimit` (if present and different from null) and the `hardLimit`. + WarnLimit uint `json:"warnLimit,omitzero"` + + // The soft limit set by this quota, using the `resourceType` defined as unit of measure. + // + // It can be used to still allow some operations but refuse some others. + // + // What is allowed or not is up to the server. + // + // For example, it could be used for blocking outgoing events of an entity (sending emails, creating + // calendar events, etc.) while still receiving incoming events (receiving emails, receiving calendars + // events, etc.). + // + // If set, it SHOULD be higher than the `warnLimit` (if present and different from null) but lower + // than the `hardLimit`. + SoftLimit uint `json:"softLimit,omitzero"` + + // Arbitrary, free, human-readable description of this quota. + // + // It might be used to explain where the different limits come from and explain the entities and data + // types this quota applies to. + // + // The description MUST be encoded in UTF-8 [RFC3629] as described in [RFC8620], Section 1.5, and + // selected based on an `Accept-Language` header in the request (as defined in [RFC9110], Section 12.5.4) + // or out-of-band information about the user's language or locale. + Description string `json:"description,omitempty"` +} + type ErrorResponse struct { Type string `json:"type"` Description string `json:"description,omitempty"` diff --git a/pkg/jscalendar/jscalendar_model.go b/pkg/jscalendar/jscalendar_model.go index 2546f490f..b5c8cda6b 100644 --- a/pkg/jscalendar/jscalendar_model.go +++ b/pkg/jscalendar/jscalendar_model.go @@ -37,7 +37,6 @@ type SignedDuration string // TODO type Relationship string type Display string type Rel string -type Method string type LocationTypeOption string type LocationRelation string type VirtualLocationFeature string @@ -225,17 +224,6 @@ const ( RelWorkingCopy = Rel("working-copy") RelWorkingCopyOf = Rel("working-copy-of") - MethodPublish = Method("publish") - MethodRequest = Method("request") - MethodReply = Method("reply") - MethodAdd = Method("add") - MethodCancel = Method("cancel") - MethodRefresh = Method("refresh") - MethodCounter = Method("counter") - MethodDeclineCounter = Method("declinecounter") - - // mlr --csv --headerless-csv-output cut -f Token ./location-type-registry-1.csv |sort|perl -ne 'chomp; print "LocationTypeOption".ucfirst($_)." = LocationTypeOption(\"".$_."\")\n"' - LocationTypeOptionAircraft = LocationTypeOption("aircraft") LocationTypeOptionAirport = LocationTypeOption("airport") LocationTypeOptionArena = LocationTypeOption("arena") @@ -537,17 +525,6 @@ var ( RelWorkingCopyOf, } - Methods = []Method{ - MethodPublish, - MethodRequest, - MethodReply, - MethodAdd, - MethodCancel, - MethodRefresh, - MethodCounter, - MethodDeclineCounter, - } - LocationTypeOptions = []LocationTypeOption{ LocationTypeOptionAircraft, LocationTypeOptionAirport, @@ -1781,10 +1758,11 @@ type Object struct { // to know which version of the object a scheduling message relates to. Sequence uint `json:"sequence,omitzero"` - // This is the iTIP [RFC5546] method, in lowercase. - // - // This MUST only be present if the JSCalendar object represents an iTIP scheduling message. - Method Method `json:"method,omitempty"` + /* + // CalendarEvent objects MUST NOT have a “method” property as this is only used when representing iTIP + // [@!RFC5546] scheduling messages, not events in a data store. + Method Method `json:"method,omitempty"` + */ // This indicates that the time is not important to display to the user when rendering this calendar object. // @@ -2232,3 +2210,5 @@ func (g *Group) UnmarshalJSON(b []byte) error { type tmp Group return json.Unmarshal(b, (*tmp)(g)) } + +// mlr --csv --headerless-csv-output cut -f Token ./location-type-registry-1.csv |sort|perl -ne 'chomp; print "LocationTypeOption".ucfirst($_)." = LocationTypeOption(\"".$_."\")\n"' diff --git a/pkg/jscalendar/jscalendar_model_test.go b/pkg/jscalendar/jscalendar_model_test.go index c7b1c0fa5..5061927a5 100644 --- a/pkg/jscalendar/jscalendar_model_test.go +++ b/pkg/jscalendar/jscalendar_model_test.go @@ -727,7 +727,6 @@ func TestEvent(t *testing.T) { }, }, Sequence: 3, - Method: MethodRefresh, ShowWithoutTime: true, Locations: map[string]Location{ "loc1": { diff --git a/services/groupware/api-examples.yaml b/services/groupware/api-examples.yaml index e8d97e073..ddc474c6c 100644 --- a/services/groupware/api-examples.yaml +++ b/services/groupware/api-examples.yaml @@ -9,7 +9,6 @@ examples: emailReceivedAt: '2025-09-23T10:58:03Z' emailSentAt: '2025-09-23T12:58:03+02:00' blobId: 'cfz7vkmhcfwl1gfln02hga2fb3xwsqirirousda0rs1soeosla2p1aiaahcqjwaf' - attachmentName: 'Alloy_Yellow_Scale.pdf' attachmentType: 'application/pdf' attachmentSize: 192128 attachmentDisposition: 'attachment' diff --git a/services/groupware/pkg/groupware/groupware_api_account.go b/services/groupware/pkg/groupware/groupware_api_account.go index 94c6cb4d5..10500fb34 100644 --- a/services/groupware/pkg/groupware/groupware_api_account.go +++ b/services/groupware/pkg/groupware/groupware_api_account.go @@ -13,7 +13,7 @@ import ( type SwaggerGetAccountResponse struct { // in: body Body struct { - *jmap.SessionAccount + *jmap.Account } } @@ -40,7 +40,7 @@ func (g *Groupware) GetAccount(w http.ResponseWriter, r *http.Request) { // swagger:response GetAccountsResponse200 type SwaggerGetAccountsResponse struct { // in: body - Body map[string]jmap.SessionAccount + Body map[string]jmap.Account } // swagger:route GET /groupware/accounts account accounts diff --git a/services/groupware/pkg/groupware/groupware_mock_calendars.go b/services/groupware/pkg/groupware/groupware_mock_calendars.go index 363fda8bc..fb0043fe1 100644 --- a/services/groupware/pkg/groupware/groupware_mock_calendars.go +++ b/services/groupware/pkg/groupware/groupware_mock_calendars.go @@ -20,7 +20,7 @@ var C1 = jmap.Calendar{ Type: jscalendar.AlertType, Trigger: jscalendar.AbsoluteTrigger{ Type: jscalendar.AbsoluteTriggerType, - When: MustParse("2025-09-30T20:34:12Z"), + When: mustParseTime("2025-09-30T20:34:12Z"), }, }, }, @@ -63,17 +63,25 @@ var AllCalendars = []jmap.Calendar{C1} var E1 = jmap.CalendarEvent{ Id: "ovei9oqu", + CalendarIds: map[string]bool{ + C1.Id: true, + }, + BaseEventId: "ahtah9qu", + IsDraft: true, + IsOrigin: true, + UtcStart: jmap.UTCDateTime{Time: mustParseTime("2025-10-01T00:00:00Z")}, + UtcEnd: jmap.UTCDateTime{Time: mustParseTime("2025-10-07T00:00:00Z")}, Event: jscalendar.Event{ Type: jscalendar.EventType, - Start: jscalendar.LocalDateTime{Time: MustParse("2025-09-30T12:00:00Z")}, + Start: jscalendar.LocalDateTime{Time: mustParseTime("2025-09-30T12:00:00Z")}, Duration: "PT30M", Status: jscalendar.StatusConfirmed, Object: jscalendar.Object{ CommonObject: jscalendar.CommonObject{ Uid: "9a7ab91a-edca-4988-886f-25e00743430d", ProdId: "Mock 0.0", - Created: MustParse("2025-09-29T16:17:18Z"), - Updated: MustParse("2025-09-29T16:17:18Z"), + Created: mustParseTime("2025-09-29T16:17:18Z"), + Updated: mustParseTime("2025-09-29T16:17:18Z"), Title: "Meeting of the Minds", Description: "Internal meeting about the grand strategy for the future", DescriptionContentType: "text/plain", @@ -104,7 +112,6 @@ var E1 = jmap.CalendarEvent{ }, RelatedTo: map[string]jscalendar.Relation{}, Sequence: 0, - Method: jscalendar.MethodAdd, ShowWithoutTime: false, Locations: map[string]jscalendar.Location{ "ux1uokie": { @@ -139,7 +146,111 @@ var E1 = jmap.CalendarEvent{ }, }, }, - // TODO more properties, a lot more properties + RecurrenceRules: []jscalendar.RecurrenceRule{ + { + Type: jscalendar.RecurrenceRuleType, + Frequency: jscalendar.FrequencyWeekly, + Interval: 1, + Rscale: jscalendar.RscaleIso8601, + Skip: jscalendar.SkipOmit, + FirstDayOfWeek: jscalendar.DayOfWeekMonday, + Count: 4, + }, + }, + FreeBusyStatus: jscalendar.FreeBusyStatusBusy, + Privacy: jscalendar.PrivacyPublic, + ReplyTo: map[jscalendar.ReplyMethod]string{ + jscalendar.ReplyMethodImip: "mailto:organizer@example.com", + }, + SentBy: "organizer@example.com", + Participants: map[string]jscalendar.Participant{ + "eegh7uph": { + Type: jscalendar.ParticipantType, + Name: "Anderson Dawes", + Email: "adawes@opa.org", + Description: "Called the meeting", + SendTo: map[jscalendar.SendToMethod]string{ + jscalendar.SendToMethodImip: "mailto:adawes@opa.org", + }, + Kind: jscalendar.ParticipantKindIndividual, + Roles: map[jscalendar.Role]bool{ + jscalendar.RoleAttendee: true, + jscalendar.RoleChair: true, + jscalendar.RoleOwner: true, + }, + LocationId: "ux1uokie", + Language: "en-GB", + ParticipationStatus: jscalendar.ParticipationStatusAccepted, + ParticipationComment: "I'll be there for sure", + ExpectReply: true, + ScheduleAgent: jscalendar.ScheduleAgentServer, + ScheduleSequence: 1, + ScheduleStatus: []string{"1.0"}, + ScheduleUpdated: mustParseTime("2025-10-01T11:59:12Z"), + SentBy: "adawes@opa.org", + InvitedBy: "eegh7uph", + Links: map[string]jscalendar.Link{ + "ieni5eiw": { + Type: jscalendar.LinkType, + Href: "https://static.wikia.nocookie.net/expanse/images/1/1e/OPA_leader.png/revision/latest?cb=20250121103410", + ContentType: "image/png", + Rel: jscalendar.RelIcon, + Size: 192812, + Display: jscalendar.DisplayBadge, + Title: "Anderson Dawes' photo", + }, + }, + ScheduleId: "mailto:adawes@opa.org", + }, + "xeikie9p": { + Type: jscalendar.ParticipantType, + Name: "Klaes Ashford", + Email: "ashford@opa.org", + Description: "As the first officer on the Behemoth", + SendTo: map[jscalendar.SendToMethod]string{ + jscalendar.SendToMethodImip: "mailto:ashford@opa.org", + jscalendar.SendToMethodOther: "https://behemoth.example.com/ping/@ashford", + }, + Kind: jscalendar.ParticipantKindIndividual, + Roles: map[jscalendar.Role]bool{ + jscalendar.RoleAttendee: true, + }, + LocationId: "em4eal0o", + Language: "en-GB", + ParticipationStatus: jscalendar.ParticipationStatusNeedsAction, + ExpectReply: true, + ScheduleAgent: jscalendar.ScheduleAgentServer, + ScheduleSequence: 0, + SentBy: "adawes@opa.org", + InvitedBy: "eegh7uph", + Links: map[string]jscalendar.Link{ + "oifooj6g": { + Type: jscalendar.LinkType, + Href: "https://static.wikia.nocookie.net/expanse/images/0/02/Klaes_Ashford_-_Expanse_season_4_promotional_2.png/revision/latest?cb=20191206012007", + ContentType: "image/png", + Rel: jscalendar.RelIcon, + Size: 201291, + Display: jscalendar.DisplayBadge, + Title: "Ashford on Medina Station", + }, + }, + ScheduleId: "mailto:ashford@opa.org", + }, + }, + Alerts: map[string]jscalendar.Alert{ + "ahqu4xi0": { + Type: jscalendar.AlertType, + Trigger: jscalendar.OffsetTrigger{ + Type: jscalendar.OffsetTriggerType, + Offset: "PT-5M", + RelativeTo: jscalendar.RelativeToStart, + }, + }, + }, + TimeZone: "UTC", + MayInviteSelf: true, + MayInviteOthers: true, + HideAttendees: false, }, }, } diff --git a/services/groupware/pkg/groupware/groupware_mock_contacts.go b/services/groupware/pkg/groupware/groupware_mock_contacts.go index 009f0a437..dfdc436b6 100644 --- a/services/groupware/pkg/groupware/groupware_mock_contacts.go +++ b/services/groupware/pkg/groupware/groupware_mock_contacts.go @@ -7,7 +7,7 @@ import ( "github.com/opencloud-eu/opencloud/pkg/jscontact" ) -func MustParse(text string) time.Time { +func mustParseTime(text string) time.Time { t, err := time.Parse(time.RFC3339, text) if err != nil { panic(err) @@ -53,8 +53,8 @@ var CaminaDrummerContact = jscontact.ContactCard{ A2.Id: true, }, Version: jscontact.JSContactVersion_1_0, - Created: MustParse("2025-09-30T11:00:12Z").UTC(), - Updated: MustParse("2025-09-30T11:00:12Z").UTC(), + Created: mustParseTime("2025-09-30T11:00:12Z").UTC(), + Updated: mustParseTime("2025-09-30T11:00:12Z").UTC(), Kind: jscontact.ContactCardKindIndividual, Language: "en-GB", ProdId: "Mock 0.0", @@ -330,7 +330,7 @@ var CaminaDrummerContact = jscontact.ContactCard{ Notes: map[string]jscontact.Note{ "n1": { Type: jscontact.NoteType, - Created: MustParse("2025-09-30T11:00:12Z").UTC(), + Created: mustParseTime("2025-09-30T11:00:12Z").UTC(), Author: &jscontact.Author{ Type: jscontact.AuthorType, Name: "expanse.fandom.com", @@ -348,8 +348,8 @@ var AndersonDawesContact = jscontact.ContactCard{ A1.Id: true, }, Version: jscontact.JSContactVersion_1_0, - Created: MustParse("2025-09-30T11:00:12Z").UTC(), - Updated: MustParse("2025-09-30T11:00:12Z").UTC(), + Created: mustParseTime("2025-09-30T11:00:12Z").UTC(), + Updated: mustParseTime("2025-09-30T11:00:12Z").UTC(), Kind: jscontact.ContactCardKindIndividual, Language: "en-GB", ProdId: "Mock 0.0", @@ -544,7 +544,7 @@ var AndersonDawesContact = jscontact.ContactCard{ Kind: jscontact.AnniversaryKindBirth, Date: jscontact.Timestamp{ Type: jscontact.TimestampType, - Utc: MustParse("1961-08-24T00:00:00Z"), + Utc: mustParseTime("1961-08-24T00:00:00Z"), }, }, }, diff --git a/services/groupware/pkg/groupware/groupware_request.go b/services/groupware/pkg/groupware/groupware_request.go index 625538cbd..ca83c987d 100644 --- a/services/groupware/pkg/groupware/groupware_request.go +++ b/services/groupware/pkg/groupware/groupware_request.go @@ -105,17 +105,17 @@ func (r Request) GetAccountIdForSubmission() (string, *Error) { return r.getAccountId(r.session.PrimaryAccounts.Blob, errNoPrimaryAccountForSubmission) } -func (r Request) GetAccountForMail() (jmap.SessionAccount, *Error) { +func (r Request) GetAccountForMail() (jmap.Account, *Error) { accountId, err := r.GetAccountIdForMail() if err != nil { - return jmap.SessionAccount{}, err + return jmap.Account{}, err } account, ok := r.session.Accounts[accountId] if !ok { r.logger.Debug().Msgf("failed to find account '%v'", accountId) // TODO metric for inexistent accounts - return jmap.SessionAccount{}, apiError(r.errorId(), ErrorNonExistingAccount, + return jmap.Account{}, apiError(r.errorId(), ErrorNonExistingAccount, withDetail(fmt.Sprintf("The account '%v' does not exist", log.SafeString(accountId))), withSource(&ErrorSource{Parameter: UriParamAccountId}), )