Compare commits

..

175 Commits

Author SHA1 Message Date
Pascal Bleser
3d8cad17d4 groupware: update to Stalwart 0.15.0 2025-12-17 09:34:22 +01:00
Pascal Bleser
790a241d9c groupware: shift some attributes of the Groupware object around, in defaults and config sub-structures 2025-12-09 14:33:29 +01:00
Pascal Bleser
dc7df401aa groupware: fix failing pkg/jscontact unit tests 2025-12-09 10:07:02 +01:00
Pascal Bleser
3da68555f7 groupware: fix failing pkg/jscalendar unit tests 2025-12-09 10:05:49 +01:00
Pascal Bleser
6ff5b1a31d groupware: add description and version annotations for env configuration properties 2025-12-09 09:55:27 +01:00
Pascal Bleser
5dc1f28e87 groupware: improve email submission and testing
* jmap/EmailCreate: add more attributes that were omitted: Headers,
   InReplyTo, References, Sender

 * add jmap GetEmailSubmissionStatus

 * improve email integration tests by adding a thorough test for email
   submission

 * jmap integration tests: provision principals and domains using the
   Stalwart Management API, switching from an in-memory to an internal
   directory
2025-12-09 09:15:39 +01:00
Pascal Bleser
42cce92e8f groupware: add retrieving and adding mailboxIds for drafts and sent if they are missing 2025-12-09 09:15:39 +01:00
Pascal Bleser
25068ada8d groupware: refactor response objects to take a list of accountIds 2025-12-09 09:15:39 +01:00
Pascal Bleser
e7d557ca61 groupware: minor: remove network declaration in stalwart.yml 2025-12-09 09:15:39 +01:00
Pascal Bleser
8ae4694253 groupware: fix missing casting to jmap.State after changes in pkg/jmap 2025-12-09 09:15:39 +01:00
Pascal Bleser
d0effed4b5 groupware, auth-api: remove tracing and tracing configuration 2025-12-09 09:15:39 +01:00
Pascal Bleser
dfda4b3a9a groupware: upgrade the Stalwart image in devtools from 0.14.0 to 0.14.1 2025-12-09 09:15:39 +01:00
Pascal Bleser
aa95437f59 groupware: WS push improvements, add getting email changes to WS integration test 2025-12-09 09:15:39 +01:00
Pascal Bleser
14bd930b92 groupware: JMAP WS push notifications support 2025-12-09 09:15:39 +01:00
Pascal Bleser
6a5c90e6d6 groupware: fix email summaries and allow negative offsets
* fix a bug in how email summaries are flattened across multiple
   accounts, which was previous resulting in empty email objects

 * allow negative offset in email pagination

 * make all /emails endpoints return emails without bodies
2025-12-09 09:15:39 +01:00
Pascal Bleser
282bee59fe groupware: response payload /groupware/accounts/{id}/emails should be without email bodies 2025-12-09 09:15:39 +01:00
Pascal Bleser
225f6f6b64 groupware: add Object-Type and Account-Id response headers
* implement Request.AllAccountIds() to generalize the fetching (and
   uniqifying) of all account IDs, which will allow us to implement
   things such as "subscribed" accounts, or limiting the number of
   accounts in one request

 * add Account-Id response header

 * add Object-Type response header
2025-12-09 09:15:39 +01:00
Pascal Bleser
ae9c8dc653 groupware: feature test improvements and upgrade to Stalwart 0.14.1
* upgrade Stalwart image for devtools/full to 0.14.1

 * re-assert which features are implemented or not in 0.14.1

 * refactor the integration tests yet again to make it clearer and
   easier to see those "features-or-not"

 * get rid of old tests that are now better covered by integration tests

 * rewrite how we compare expected and actual objects in integration
   tests, finally having found a way to ignore the @type attribute
   properly instead of having to mutate all objects to remove it
2025-12-09 09:15:39 +01:00
Pascal Bleser
1ea251c4ea groupware: finalize JMAP events integration test, with multiple changes to the model to conform with draft-ietf-calext-jscalendarbis-10 and fields that are currently not implemented in Stalwart 2025-12-09 09:15:39 +01:00
Pascal Bleser
9f65d90579 groupware: refactor the JMAP integration tests 2025-12-09 09:15:39 +01:00
Pascal Bleser
5dc9f71040 groupware: improve JMAP ContactCard integration tests 2025-12-09 09:15:39 +01:00
Pascal Bleser
e27df2cdc9 groupware: improve JMAP integration tests for ContactCards 2025-12-09 09:15:39 +01:00
Pascal Bleser
b8f60f365b groupware: stalwart: add the magic sharing.allow-directory-query config setting, but keep it to false (default) 2025-12-09 09:15:39 +01:00
Pascal Bleser
a6aeb78cfb groupware: fix recently introduced error with UploadedBlob with and without a sha512 2025-12-09 09:15:39 +01:00
Pascal Bleser
b30585503f groupware: fix blob uploading metadata and add 'POST /blobs' route 2025-12-09 09:15:39 +01:00
Pascal Bleser
457e0d15d3 groupware: add getting a contact by ID + add integration tests for contacts 2025-12-09 09:15:39 +01:00
Pascal Bleser
29d9071a09 groupware: improved integration test for email, fixed two bugs 2025-12-09 09:15:39 +01:00
Pascal Bleser
346500801d groupware: fix deserialization of Event Alert Trigger types using mapstructure 2025-12-09 09:15:39 +01:00
Pascal Bleser
abd7a37a7b groupware: remove mock calendars and contacts 2025-12-09 09:15:39 +01:00
Pascal Bleser
000b7b209b groupware: some fixes accordingly to the latest JMAP and jscalendarbis RFCs 2025-12-09 09:15:39 +01:00
Pascal Bleser
db3efe6975 groupware: return identities with accounts in the /accounts endpoint 2025-12-09 09:15:39 +01:00
Pascal Bleser
dce5b16936 groupware: move POST+DELETE of contacts and events as a top-level route underneath accounts 2025-12-09 09:15:39 +01:00
Pascal Bleser
4c1b887f65 groupware: add real calendars and events 2025-12-09 09:15:39 +01:00
Pascal Bleser
d53f7be95a groupware: implement/fix email submission 2025-12-09 09:15:39 +01:00
Pascal Bleser
0b393de47f groupware: implement Mailbox modification endpoints + refactor ETag/state in the framework
* add endpoints for Mailboxes:
   - PATCH mailboxes/{id}
   - DELETE mailboxes/{id}
   - POST mailboxes

 * refactor the pkg/jmap and groupware framework to systematically
   return a jmap.State out-of-band of the per-method payloads, since
   they are almost always present in JMAP responses, which lead to the
   artificial creation of a lot of composed struct types just to also
   return the State; on the downside, it adds yet another return
   parameter
2025-12-09 09:15:39 +01:00
Pascal Bleser
e36dff994c groupware: add ical blob parsing endpoint 2025-12-09 09:15:39 +01:00
Pascal Bleser
18027f14e0 groupware: add Mailbox sorting 2025-12-09 09:15:39 +01:00
Pascal Bleser
9275ff1527 groupware: also change accounts to an array instead of a map in the response to /groupware/ 2025-12-09 09:15:39 +01:00
Pascal Bleser
e9fb96e55f groupware: jmap: fix id -> blobId attribute when uploading a blob 2025-12-09 09:15:39 +01:00
Pascal Bleser
26317a1855 groupware: minor: reorganize the route nesting 2025-12-09 09:15:39 +01:00
Pascal Bleser
3cddb65e24 groupware: change /accounts endpoint to return an array with the accountId instead of a map 2025-12-09 09:15:39 +01:00
Pascal Bleser
f2e515638c groupware: fix creating contacts 2025-12-09 09:15:39 +01:00
Pascal Bleser
43c11075b7 groupware: actually add total and limit to the email summary endpoint 2025-12-09 09:15:39 +01:00
Pascal Bleser
f15681c50a groupware: fix compilation in tests after recent changes 2025-12-09 09:15:38 +01:00
Pascal Bleser
85305136f8 groupware: add missing total,limit,offset attributes in the QueryEmailsSummaries response 2025-12-09 09:15:38 +01:00
Pascal Bleser
ed730b023c groupware: add threadCount to /groupware/accounts/{accountId}/mailboxes/{mailboxId}/emails 2025-12-09 09:15:38 +01:00
Pascal Bleser
0fdc5a01df groupware: add ContactCard operations 2025-12-09 09:15:38 +01:00
Pascal Bleser
276209c616 groupware: add recipe for using ldapsearch in an Alpine container to DEVELOPER.md 2025-12-09 09:15:38 +01:00
Pascal Bleser
1f59143652 upgrade to Stalwart 0.14.0
* upgrade image version in devtools to 0.14.0

 * fix idmldap configuration to use the cn attribute in order for that
   to also work for groups (groups don't have a uid attribute in the IDM
   built-in LDAP)

 * group resources are now checked against LDAP, changed
   demo-principals.yaml accordingly to refer to a group that exists in
   LDAP as part of the demo data
2025-12-09 09:15:38 +01:00
Pascal Bleser
84ce6b2320 groupware: add threadSize in email-by-id response 2025-12-09 09:15:38 +01:00
Pascal Bleser
5e61c03696 groupware: introduce constants for Email property names, see EmailSummaryProperties 2025-12-09 09:15:38 +01:00
Pascal Bleser
2358e61733 groupware: fix keyword patching syntax for adding and removing email keywords endpoints 2025-12-09 09:15:38 +01:00
Pascal Bleser
ed605f92b4 groupware: fix keyword patching syntax for markAsSeen=true 2025-12-09 09:15:38 +01:00
Pascal Bleser
d80db93332 groupware: add threadSize property in the email summary endpoint 2025-12-09 09:15:38 +01:00
Pascal Bleser
46f8d27e42 groupware: improve email sanitization by using the mime package to parse the part type, in order to recognize HTML ones that need sanitization 2025-12-09 09:15:38 +01:00
Pascal Bleser
8a97320494 groupware: add headers Unmatched-Path and Unsupported-Method to make
development of the web UI easier
2025-12-09 09:15:38 +01:00
Pascal Bleser
0507779211 groupware: add markAsSeen=true to mark an email as $seen before it is
retrieved
2025-12-09 09:15:38 +01:00
Pascal Bleser
4dfed5a43e groupware: add the Retry-After header in responses when the session cannot be retrieved 2025-12-09 09:15:38 +01:00
Pascal Bleser
f024c2c9a9 groupware: add searching emails by their Message-Id + retrieving an email by its ID as message/rfc822 2025-12-09 09:15:38 +01:00
Pascal Bleser
0d23867d54 groupware: add email HTML sanitization
* sanitize email text/html body parts using bluemonday

 * deps(groupware):
   - new dependency: github.com/microcosm-cc/bluemonday
   - transitive dependencies:
     - github.com/aymerick/douceur
     - github.com/gorilla/css
2025-12-09 09:15:38 +01:00
Pascal Bleser
1845fa86f4 groupware: add identity deletion 2025-12-09 09:15:38 +01:00
Pascal Bleser
df8b42451a groupware:
* made several email related operations multi-account:
   QueryEmailSnippets, QueryEmails, QueryEmailsWithSnippets

 * add GetIdentitiesForAllAccounts

 * add GetEmailsForAllAccounts

 * jmap: add CreateIdentity, UpdateIdentity; groupware: add
   GetIdentityById, AddIdentity, ModifyIdentity

 * add temporary workaround until Calendars, Tasks, Contacts are
   implemented in Stalwart when determining the default account for
   those: use the mail one in the mean time
2025-12-09 09:15:38 +01:00
Pascal Bleser
633679c8de groupware: add instructions for using stalwart-admin 2025-12-09 09:15:38 +01:00
Pascal Bleser
cb2c6dc661 groupware: fix NPE when one of the accounts is a group account 2025-12-09 09:15:38 +01:00
Pascal Bleser
b580392a4c groupware: fix devtools LDAP passwords, as it was breaking regular opencloud drive authentication 2025-12-09 09:15:38 +01:00
Pascal Bleser
4cb8a8ae18 groupware: DEVELOPER.md: add note explaining LDAPTLS_REQCERT 2025-12-09 09:15:38 +01:00
Pascal Bleser
ecc9e6b34f groupware: accept both '_' and '*' as the 'default account' placeholder 2025-12-09 09:15:38 +01:00
Pascal Bleser
f1972e0e23 groupware: DEVELOPER.md: explain how to set a quota on a user using the Stalwart management API 2025-12-09 09:15:38 +01:00
Pascal Bleser
2efc4fdfce groupware: jmap: don't collpase threads when searching for emails, and add dumping of JMAP request payloads when trace logging is enabled 2025-12-09 09:15:38 +01:00
Pascal Bleser
d3cb741e44 groupware: try an alternative way to configure Stalwart dynamically in the devtools Docker Compose setup, by using separate files and ${STALWART_AUTH_DIRECTORY} to name to file to mount 2025-12-09 09:15:38 +01:00
Pascal Bleser
73fd7e0f78 jmap: add GetInboxNameForMultipleAccounts 2025-12-09 09:15:38 +01:00
Pascal Bleser
96fcf961b8 groupware: add Mermaid diagrams to describe the two setup options 2025-12-09 09:15:38 +01:00
Pascal Bleser
f5ac62859a groupware: implement email updating and email keyword updating endpoints 2025-12-09 09:15:38 +01:00
Pascal Bleser
051b483def docs(groupware): upgrade @redocly/cli 2.3.1 -> 2.4.0 2025-12-09 09:15:38 +01:00
Pascal Bleser
d470b5176b jmap: fix Email/set 2025-12-09 09:15:38 +01:00
Pascal Bleser
925d9b894b groupware: further updates to make everything work with the builtin LDAP and OIDC 2025-12-09 09:15:38 +01:00
Pascal Bleser
3da0debdec groupware: for /accounts/all/emails/latest/summary, rename the ?unread query parameter into ?seen as that is more intuitive 2025-12-09 09:15:38 +01:00
Pascal Bleser
197c8543f2 groupware: make everything also work with the built-in LDAP and IDP 2025-12-09 09:15:38 +01:00
Pascal Bleser
11a69969f6 groupware: devtools: Stalwart: add internal LDAP configuration 2025-12-09 09:15:38 +01:00
Pascal Bleser
d7b675251d groupware: update @redocly/cli from 2.3.0 to 2.3.1 2025-12-09 09:15:38 +01:00
Pascal Bleser
996bc858c7 docs(groupware): fix basepath in OpenAPI, /groupware instead of /groupware/groupware 2025-12-09 09:15:38 +01:00
Viktor Scharf
b369f8b415 fixed connection reset issue. adapted make file to generate swagger docs on mac 2025-12-09 09:15:38 +01:00
Pascal Bleser
7587c54e4e groupware: improve jmap integration tests
* use gofakeit instead of loremipsum, as it can also fake images for
   attachments

 * random emails for testing: generate threads, add attachments
2025-12-09 09:15:38 +01:00
Pascal Bleser
e6abc2d8ff groupware: rewrite JMAP integration test to be more reusable, and upgrade Stalwart container to 0.13.4 2025-12-09 09:15:38 +01:00
Pascal Bleser
0052d6fc4f groupware: upgrade Stalwart in devtools from 0.13.2 to 0.13.4
* changes from 0.13.4:
   - JMAP: Protocol layer rewrite for zero-copy deserialization and
     architectural improvements.
   - IMAP: Unbounded memory allocation in request parser
     (CVE-2025-61600)
   - IMAP: Wrong permission checked for GETACL.
   - JMAP: References to previous method fail when there are no results
     (stalwartlabs#1507).
   - JMAP: Enforce quota checks on Blob/copy.
   - JMAP: Mailbox/get fails without accountId argument (stalwartlabs#1936).
   - JMAP: Do not return invalidProperties when email update doesn't
     contain changes (stalwartlabs#1139)
   - iTIP: Include date properties in REPLY (stalwartlabs#2102).
   - OIDC: Do not set username field if it is the same as the email field.
   - Telemetry: Fix calculateMetrics housekeeper task (stalwartlabs#2155).
   - Directory: Always use rsplit to extract the domain part from email
     addresses.

  * changes from 0.13.3:
   - CLI: Health checks
   - WebDAV: Assisted discovery v2
   - iTIP: Do not send a REPLY when deleting an event that was not
     accepted.
   - iTIP: Include event details in REPLY messages (stalwart#2102).
   - iTIP: Add organizer to iMIP replies if missing to deal with MS
     Exchange 2010 bug.
   - OIDC: Do not overwrite locally defined aliases (stalwart#2065).
   - HTTP: Scan ban should only be triggered by HTTP parse errors.
   - HTTP: Skip scanner fail2ban checks when the proxy client IP can't
     be parsed (stalwart#2121).
   - JMAP: Do not allow roles to be removed from system mailboxes
     (stalwart#1977).
   - JMAP WS: Fix panic when using invalid server url.
   - SMTP: Do no send EHLO twice when STARTTLS is unavailable
     (stalwart#2050).
   - IMAP: Allow ENABLE UTF8 in IMAPrev1.
   - IMAP: Include administer permission in ACL responses.
   - IMAP: Add owner rights to ACL get responses.
   - IMAP: Do not auto-train Bayes when moving messages from Junk to
     Trash.
   - IMAP/ManageSieve: Increase maximum quoted argument size
     (stalwart#2039).
   - CalDAV: Limit recurrence expansions in calendar reports
     (CVE-2025-59045).
   - WebDAV: Do not fix percent encoding on WebDAV FS (stalwart#2036).
2025-12-09 09:15:38 +01:00
Pascal Bleser
2343e7fa83 groupware: add bootstrapping on / with quotas for all accounts 2025-12-09 09:15:38 +01:00
Pascal Bleser
d95b9a8e8f groupware: add /quota for all accounts 2025-12-09 09:15:38 +01:00
Pascal Bleser
a5701ceb83 groupware: improve instructions in DEVELOPER.md 2025-12-09 09:15:38 +01:00
Pascal Bleser
d79f0b3829 groupware: update @redocly/cli: 2.2.2 -> 2.3.0 2025-12-09 09:15:38 +01:00
Pascal Bleser
abb57193ff groupware: add quota API + add support for Accept-Language and Content-Language 2025-12-09 09:15:37 +01:00
Pascal Bleser
01b4a1f751 groupware: minor improvements to the DEVELOPER.md 2025-12-09 09:15:37 +01:00
Pascal Bleser
17b281cadf groupware: add flag to currently ignore session capability checks for calendars, contacts and tasks, as those are not implemented in Stalwart yet; will need to remove it in the future 2025-12-09 09:15:37 +01:00
Pascal Bleser
f4f24664ad groupware: add JMAP capability checking (in part: for contacts, calendars, tasks) 2025-12-09 09:15:37 +01:00
Pascal Bleser
101f38dd0b /auth: add SkipXAccessToken:true 2025-12-09 09:15:37 +01:00
Pascal Bleser
ebd51dba3b groupware: add mock endpoints for tasklists and tasks 2025-12-09 09:15:37 +01:00
Pascal Bleser
ed488b5a01 groupware: implement JMAP Task specification 2025-12-09 09:15:37 +01:00
Pascal Bleser
2c6ff6cd9e groupware: more mock data, added missing JMAP types 2025-12-09 09:15:37 +01:00
Pascal Bleser
eeccb56d19 groupware: add mock endpoints for addressbooks and contacts 2025-12-09 09:15:37 +01:00
Pascal Bleser
04b038a129 opencloud_full: also keep the 'Trace-Id' HTTP header 2025-12-09 09:15:37 +01:00
Pascal Bleser
09f69c5a62 implement JSCalendar (RFC 8984) 2025-12-09 09:15:37 +01:00
Pascal Bleser
85fed11797 services/groupware/DEVELOPER.md: adapt to new path for the opencloud_full deployment 2025-12-09 09:15:37 +01:00
Pascal Bleser
0e3e9607c3 JSContact: refactored after full test coverage, stronger typing for enumerations 2025-12-09 09:15:37 +01:00
Pascal Bleser
e2c9350ea1 Implement JSContact (RFC9553) Model
* add pkg/jscontact with the implementation of the RFC9553 data model

 * add JMAP Calendar session capabilities support in pkg/jmap
2025-12-09 09:15:37 +01:00
Pascal Bleser
5cc98f0792 Docker Compose Groupware improvements
* made a few changes in order to further simplify the setup for
   developers of the Groupware backend

 * add STALWART_DOMAIN to deployments/examples/opencloud_full/.env

 * adapt the Stalwart configuration file to not set server.hostname and,
   instead, pick it up from /etc/hostname, which is set by Docker
   Compose as we can use default values for STALWART_DOMAIN there, in an
   analogous fashion to the other containers in that project

 * add config/keycloak/clients/groupware.json to avoid requiring manual
   configuration of Keycloak via the admin web UI

 * Stalwart container:
   - listen for SMTPS on :1465
   - remove the stalwart-logs volume, not needed (logs are going to
     stdout)

 * updated services/groupware/DEVELOPER.md:
   - refer to a variable OCDIR to make instructions more copy-pasteable
   - remove manual Keycloak configuration section as it is now obsolete,
     replaced by provisioning a configuration file instead
2025-12-09 09:15:37 +01:00
Pascal Bleser
4fee45379b start websocket implementation, add endpoint for email summaries
* feat(groupware): start implementing JMAP websocket support for push
   notifications (unfinished)

 * groupware: add GetLatestEmailsSummaryForAllAccounts

 * add new vendored dependency: github.com/gorilla/websocket

 * jmap: add QueryEmailSummaries

 * openapi: start adding examples

 * openapi: add new tooling for api-examples.yaml injection

 * apidoc-process.ts: make it more typescript-y

 * bump @redocly/cli from 2.0.8 to latest 2.2.0
2025-12-09 09:15:37 +01:00
Pascal Bleser
2ea8afeb74 feat(groupware): add WebsocketEndpoint to the JMAP Session 2025-12-09 09:15:37 +01:00
Pascal Bleser
c4a16e3e9a refactor(groupware): just use a function for the attachment picker
Minor: be more Go idiomatic: just use a function to pick the attachment
from an Email's attachment list instead of using an interface with
multiple iplementation structs.
2025-12-09 09:15:37 +01:00
Pascal Bleser
a65a59b2d0 groupware: improved attachment APIs
* feat(groupware): add /accounts/{}/emails/{}/attachments

 * feat(groupware): add
   /accounts/{}/emails/{}/attachments?partId=&name=&blobId=
2025-12-09 09:15:37 +01:00
Pascal Bleser
21ea094d99 jmap: modify GetBlob -> GetBlobMetadata
* fix(jmap): fix bug where CommandBlobUpload was used instead of
   CommandBlobGet in GetBlob (now GetBlobMetadata)

 * we currently don't need a variant of BlobGetCommand that also
   retrieves the content of the blob, instead we only use it for
   retrieving metadata about it
2025-12-09 09:15:37 +01:00
Pascal Bleser
431a5ab3de fix(groupware): update DEVELOPER.md imap-filler usage since it was updated to use flags instead of environment variables 2025-12-09 09:15:37 +01:00
Pascal Bleser
1d94e3811e docs(groupware): more developer instructions 2025-12-09 09:15:37 +01:00
Pascal Bleser
3cb78ed31b more updates to the Groupware DEVELOPER.md 2025-12-09 09:15:37 +01:00
Pascal Bleser
d54e27dcdf docs(groupware): add configuration instructions to DEVELOPER.md 2025-12-09 09:15:37 +01:00
Pascal Bleser
c31d7c57bb fix(groupware): fix JMAP error handling
* the JMAP error handling was not working properly, fixed it and added
   error definitions accordingly

 * add operations to retrieve mailbox roles and mailboxes by role for
   all accounts
2025-12-09 09:15:37 +01:00
Pascal Bleser
3026ddb255 refactor(groupware): rename "Messages" to "Email" everywhere
There was really no reason to go with "Messages" as far as the
vocabulary of the Groupware API goes, since the objects those APIs serve
are "Emails", to stick with the wording of the JMAP specification.
2025-12-09 09:15:37 +01:00
Pascal Bleser
c4fb13b263 refactor(groupware): use a function for multi-account method call IDs
* introduce a function 'mcid' to assemble method call IDs per account
   instead of doing that inline in each function, in case the rules for
   doing so change in the future
2025-12-09 09:15:37 +01:00
Pascal Bleser
df21fdf2e2 docs(groupware): add services/groupware/DEVELOPER.md 2025-12-09 09:15:37 +01:00
Pascal Bleser
6224ded8b5 refactor(groupware): add max requests check
* move jmap.request() to jmap.Client.request() and pass the Session
   and a Logger to introduce checking the number of methodCalls within a
   request not exceeding the limit of the Session, as well as error
   handling and logging there instead of in each caller

 * a few bugfixes:
   - add a few missing Send() calls in logs
   - correct the response tag matching for
     GetMailboxChangesForMultipleAccounts
   - fix typo in Identity.ReplyTo json serialization rune
   - fix response tag in pkg/jmap/testdata/mailboxes1.json after
     changing them to be prefixed by the accountId
2025-12-09 09:15:37 +01:00
Pascal Bleser
0da72cf346 groupware: minor typo fixes 2025-12-09 09:15:37 +01:00
Pascal Bleser
aab08dd3de chore(groupware): add launcher for OC + containers for services
* add a launcher for running OpenCloud from within VSCode, but using
   third-party services that are running within the docker compose
   'full' example setup
2025-12-09 09:15:37 +01:00
Pascal Bleser
f470462ead feat(groupware): add fetching all mailboxes for all accounts
* add URL to retrieve all the mailboxes for all the accounts of a user,
   as a first use-case for an all-accounts operation, as
   /accounts/all/mailboxes

 * add URL to retrieve mailbox changes for all the mailboxes of all the
   accounts of a user, as a first use-case for an all-accounts
   operation, as /accounts/all/mailboxes/changes

 * change the defaultAccountId from '*' to '_', as '*' rather indicates
   "all" than "default", and we might want to use that for "all
   accounts" operations in the future

 * refactor(groupware): remove the accountId parameter from the logger()
   function, as it is not used anyways, but also confusing for
   operations that support multiple account ids
2025-12-09 09:15:37 +01:00
Pascal Bleser
62cace14fe docs(groupware): OpenAPI improvements
* refactor some pkg/jmap and groupware methods to make more sense from
   an API point-of-view

 * add path parameter documentation, but automate it by injecting their
   definition into the OpenAPI YAML tree that is extracted from the
   source code using go-swagger as it is too cumbersome, repetitive and
   error-prine to document them in the source code; wrote a TypeScript
   file apidoc-process.ts to do so

 * add generating an offline HTML file for the OpenAPI documentation
   using redocly, and injecting a favicon into the resulting HTML; wrote
   a TypeScript file apidoc-postprocess-html.ts to do so
2025-12-09 09:15:37 +01:00
Pascal Bleser
a8c2beac3a test(groupware): add testcontainers based jmap test
* adds pkg/jmap/jmap_integration_test.go

 * uses ghcr.io/stalwartlabs/stalwart:v0.13.2-alpine

 * can be disabled by setting one of the following environment
   variables, in the same fashion as ca0493b28
   - CI=woodpecker
   - CI_SYSTEM_NAME=woodpecker
   - USE_TESTCONTAINERS=false

 * dependencies:
   - bump github.com/go-test/deep from 1.1.0 to 1.1.1
   - add github.com/cention-sany/utf7
   - add github.com/dustinkirkland/golang-petname
   - add github.com/emersion/go-imap/v2
   - add github.com/emersion/go-message
   - add github.com/emersion/go-sasl
   - add github.com/go-crypt/crypt
   - add github.com/go-crypt/x
   - add github.com/gogs/chardet
   - add github.com/inbucket/html2text
   - add github.com/jhilleryerd/enmime/v2
   - add github.com/ssor/bom
   - add gopkg.in/loremipsum.v1
2025-12-09 09:15:37 +01:00
Pascal Bleser
1b732b8bff refactor(groupware): session cache and DNS autodiscovery
* move the logging of the username and session state away from pkg/jmap
   and into services/groupware

 * introduce more decoupling for the session cache, as well as moving
   the implementation into groupware_session.go
2025-12-09 09:15:37 +01:00
Pascal Bleser
33cc3365ee groupware: add DNS auto-discovery (currently disabled, needs testing) 2025-12-09 09:15:37 +01:00
Pascal Bleser
3e48284295 add a .gitignore entry for debug binaries built by VSCode when running OpenCloud 2025-12-09 09:15:37 +01:00
Pascal Bleser
c9a4bb94cd groupware: session handling improvements
* remove the baseurl from the JMAP client configuration, and pass it to
   the session retrieval functions instead, as that is really the only
   place where it is relevant, and we gain flexibility to discover that
   session URL differently in the future without having to touch the
   JMAP client

 * move the default account identifier handling from the JMAP package to
   the Groupware one, as it really has nothing to do with JMAP itself,
   and is an opinionated feature of the Groupware REST API instead

 * add an event listener interface for JMAP events to be more flexible
   and universal, typically for metrics that are defined on the API
   level that uses the JMAP client

 * add errors for when default accounts cannot be determined

 * split groupware_framework.go into groupware_framework.go,
   groupware_request.go and groupware_response.go

 * move the accountId logging into the Groupware level instead of JMAP
   since it can also be relevant to other operations that might be
   worthy of logging before the JMAP client is even invoked
2025-12-09 09:15:37 +01:00
Pascal Bleser
3ac4bcfeeb groupware: fix debug server, was missing a lot of configuration options and was binding to :80 2025-12-09 09:15:37 +01:00
Pascal Bleser
58583a66bb docs(groupware): add Groupware related ADRs 2025-12-09 09:15:37 +01:00
Pascal Bleser
1d6433f1c9 refactor(groupware): logging and metrics improvements
* some minor code refactorings to improve logging and metrics

 * more code documentation
2025-12-09 09:15:37 +01:00
Pascal Bleser
fc938bc4bc jmap: minor logging improvements 2025-12-09 09:15:37 +01:00
Pascal Bleser
724c44567b groupware: improve metrics
* implement more metrics, in a more streamlined fashion

 * use concurrent-map to store SSE streams instead of a regular map with
   one big lock that will not scale when it grows, causing too much
   contention on that one lock

 * while testing error metrics, noticed a few bugs with error handling
   when Stalwart is down: fixed
2025-12-09 09:15:37 +01:00
Pascal Bleser
1fc75a9091 groupware: jmap: add metrics 2025-12-09 09:15:37 +01:00
Pascal Bleser
5b51804744 groupware: implement metrics
* implement a framework for metrics, with a few exemplary ones
2025-12-09 09:15:37 +01:00
Pascal Bleser
0f3dac0280 groupware: Etag handling
* implement correct Etag and If-None-Match handling, responding with
   304 Not Modified if they match

 * introduce SessionState and State string type aliases to ensure we are
   using the correct fields for those, respectively

 * extract the SessionState from the JMAP response bodies in the
   groupware framework instead of having to do that in every single
   groupware API

 * use uint instead of int in some places to clarify that the values are
   >= 0

 * trace-log how long a Session was held in cache before being evicted

 * add Trace-Id header handling: add to response when specified in
   request, and implement a custom request logger to include it as a
   field

 * implement a more compact trace-logging of all the methods and URIs
   that are served, to put them into a single log entry instead of
   creating one log entry for every URI
2025-12-09 09:15:36 +01:00
Pascal Bleser
d214cfa2b7 groupware: initial related emails implementation with SSE 2025-12-09 09:15:36 +01:00
Pascal Bleser
f97bc0e875 groupware: add /bootstrap
* add a GET /accounts/{a}/boostrap URI that delivers the same as GET /
   but also mailboxes for a given account, in case the UI remembers the
   last used account identifier, to avoid an additional roundtrip

 * streamline the use of simpleError()

 * add logging of errors at the calling site

 * add logging of evictions of Sessions from the cache

 * change default Session cache TTL to 5min instead of 30sec
2025-12-09 09:15:36 +01:00
Pascal Bleser
72ee47fdca groupware: swagger API documentation improvements
* add more documentation for properties

 * fixes after a bit of trial-and-error with go-swagger

 * fix email filter marshalling when there are no search criteria

 * introduce an apidoc.yml that contains Swagger data and is merged when
   generating the swagger.yml from sources
2025-12-09 09:15:36 +01:00
Pascal Bleser
8d9c3b0c4e Groupware improvements
* ensure that all the jmap responses contain the SessionState

 * implement missing errors that were marked as TODO

 * moved common functions from pkg/jmap and pkg/services/groupware to
   pkg/log and pkg/structs to commonalize them across both source trees

 * implement error handling for SetError occurences

 * Email: replace anonymous map[string]bool for mailbox rights with a
   MailboxRights struct, as the keys are well-defined, which allows for
   properly documenting them

 * introduce ObjectType as an "enum"

 * fix JSON marshalling and unmarshalling of EmailBodyStructure

 * move the swagger documentation structs from groupware_api.go to
   groupware_docs.go

 * fix: change verb for /groupware/accounts/*/vacation from POST to PUT
2025-12-09 09:15:36 +01:00
Pascal Bleser
084eb005e3 groupware: minor email searching response improvements + started implementing vacation response setting API 2025-12-09 09:15:36 +01:00
Pascal Bleser
2bdbc5a42e groupware: add identities of all accounts to the index resource 2025-12-09 09:15:36 +01:00
Pascal Bleser
0a5d13b916 groupware: fix email search, add variant that includes the full emails 2025-12-09 09:15:36 +01:00
Pascal Bleser
446a98dd62 groupware: fix email search, add variant that includes the full emails 2025-12-09 09:15:36 +01:00
Pascal Bleser
e6441e58d4 Groupware: refactor jmap package, implement Email/set, EmailSubmission
* refactor the jmap package to split it into several files as the
   jmap.api.go file was becoming too unwieldy

 * refactor the Groupware handler function response to be a Response
   object, to be more future-proof and avoid adding more and more
   return parameters while handling "no content" response as well

 * more godoc for the JMAP model

 * add Email creation, updating, deleting (Email/set,
   EmailSubmission/set)

 * add endpoints
   - POST /accounts/{accountid}/messages
   - PATCH|PUT /accounts/{accountid}/messages/{messageid}
   - DELETE /accounts/{accountid}/messages/{messageid}
2025-12-09 09:15:36 +01:00
Pascal Bleser
a64223fe7d groupware: implement message search with snippets 2025-12-09 09:15:36 +01:00
Pascal Bleser
6e4918b50b groupware: blob streaming (upload and download) 2025-12-09 09:15:36 +01:00
Pascal Bleser
ac8d2587c9 groupware: more JMAP operations implementation 2025-12-09 09:15:36 +01:00
Pascal Bleser
0d2a5e992c groupware: further implementation and improvements 2025-12-09 09:15:36 +01:00
Pascal Bleser
1b9249ecba upgrade Stalwart to 0.13.2 2025-12-09 09:15:36 +01:00
Pascal Bleser
94c932d6a7 refactored the Session object, refactored the services/groupware directory, and started Swagger documentation implementation 2025-12-09 09:15:36 +01:00
Pascal Bleser
8be4d11a5e groupware: refactoring the API mechanisms 2025-12-09 09:15:36 +01:00
Pascal Bleser
2191b1d011 groupware: implement JSON:API's error response format, with a revamped error handling in jmap and services/groupware 2025-12-09 09:15:36 +01:00
Pascal Bleser
07522ce79a Refactor groupware service after ADR decision on the Groupware API
* after having decided that the Groupware API should be a standalone
   independent custom REST API that is using JMAP data models as much as
   possible,
 * removed Groupware APIs from the Graph service
 * moved Groupware implementation to the Groupware service, and
   refactored a few things accordingly
2025-12-09 09:15:36 +01:00
Pascal Bleser
d544efdec7 Groupware and jmap: cleanup and API documentation 2025-12-09 09:15:36 +01:00
Pascal Bleser
8df4ef67a2 groupware: remove unneeded messages.go that was a remainder from an earlier implementation attempt, which also fixes compilation issues due to changes in main 2025-12-09 09:15:36 +01:00
Pascal Bleser
2412e64cc5 opencloud_full: upgrade Stalwart to 0.12.5, and use the ghcr.io container repository to avoid Hub limits 2025-12-09 09:15:36 +01:00
Pascal Bleser
3f8076aa46 Groupware improvements: refactoring, k6 tests
* refactored the models to be strongly typed with structs and mapstruct
   to decompose the dynamic parts of the JMAP payloads

 * externalized large JSON strings for tests into .json files under
   testdata/

 * added a couple of fantasy Graph groupware APIs to explore further
   options

 * added k6 scripts to test those graph/me/messages APIs, with a setup
   program to set up users in LDAP, fill their IMAP inbox, activate them
   in Stalwart, cleaning things up, etc...
2025-12-09 09:15:36 +01:00
Pascal Bleser
5920291ec7 fix Stalwart LDAP configuration 2025-12-09 09:15:36 +01:00
Pascal Bleser
772a902f6d Use password policy overlay in LDAP and configure Stalwart to use it 2025-12-09 09:15:36 +01:00
Pascal Bleser
e0ea733489 upgrade Stalwart to 0.12.4 2025-12-09 09:15:36 +01:00
Pascal Bleser
22e51bd4a1 groupware: removed debugging logs 2025-12-09 09:15:36 +01:00
Pascal Bleser
0f47e3aca8 jwkset: remove debugging printlns 2025-12-09 09:15:36 +01:00
Pascal Bleser
b3766abba5 auth-api: fix: was missing newly introduced metrics 2025-12-09 09:15:36 +01:00
Pascal Bleser
71cddaaf3c groupware and jmap improvements and refactoring 2025-12-09 09:15:36 +01:00
Pascal Bleser
a6cdb4e863 upgrade Stalwart to 0.12 2025-12-09 09:15:36 +01:00
Pascal Bleser
02f33bd1d8 minor corrections to the Stalwart configuration 2025-12-09 09:15:36 +01:00
Pascal Bleser
ebd58fcfdb Introduce a the auth-api service
* primitive implementation to demonstrate how it could work, still to
   be considered WIP at best

 * add new dependency: MicahParks/jwkset and MicahParks/keyfunc to
   retrieve the JWK set from KeyCloak to verify the signature of the
   JWTs sent as part of Bearer authentication in the /auth API

 * (minor) opencloud/.../service.go: clean up a logging statement that
   was introduced earlier to hunt down why the auth-api service was not
   being started
2025-12-09 09:15:36 +01:00
Pascal Bleser
4e6053cdbd add an auth-api service to make an exemplary implementation of an external authentication API for third party services such as Stalwart 2025-12-09 09:15:36 +01:00
Pascal Bleser
4cf4d44321 move services/groupware/pkg/jmap to pkg/jmap 2025-12-09 09:15:36 +01:00
Pascal Bleser
da9ed5f44b WIP: restructure the Jmap client, and implement the /me/messages Graph API endpoint with it 2025-12-09 09:15:36 +01:00
Pascal Bleser
6da208e754 add an OIDC Directory to Stalwart, requires exposing Keycloak port 8080 directly to access the userinfo endpoint using HTTP since the certificates in traefik are self-signed and end up being rejected by Stalwart with no option to bypass the certificate check 2025-12-09 09:15:36 +01:00
Pascal Bleser
6620313b43 rename Stalwart fallback admin username from 'admin' to 'mailadmin' since 'admin' exists as a regular user in LDAP and thus won't have access to the administration 2025-12-09 09:15:36 +01:00
Pascal Bleser
72af257cbd add missing routing for /groupware (currently unprotected for testing) 2025-12-09 09:15:36 +01:00
Pascal Bleser
d638fba8c2 WIP: initial implementation of the groupware service 2025-12-09 09:15:36 +01:00
Pascal Bleser
0435d5679d Add Stalwart container to the opencloud_full deployment, using the OpenLDAP container as a directory for user authentication 2025-12-09 09:15:36 +01:00
561 changed files with 97169 additions and 883 deletions

View File

@@ -36,18 +36,8 @@ ifndef DATE
DATE := $(shell date -u '+%Y%m%d')
endif
LDFLAGS += -X google.golang.org/protobuf/reflect/protoregistry.conflictPolicy=warn -s -w \
-X "$(OC_REPO)/pkg/version.Edition=$(EDITION)" \
-X "$(OC_REPO)/pkg/version.String=$(STRING)" \
-X "$(OC_REPO)/pkg/version.Tag=$(VERSION)" \
-X "$(OC_REPO)/pkg/version.Date=$(DATE)"
DEBUG_LDFLAGS += -X google.golang.org/protobuf/reflect/protoregistry.conflictPolicy=warn \
-X "$(OC_REPO)/pkg/version.Edition=$(EDITION)" \
-X "$(OC_REPO)/pkg/version.String=$(STRING)" \
-X "$(OC_REPO)/pkg/version.Tag=$(VERSION)" \
-X "$(OC_REPO)/pkg/version.Date=$(DATE)"
LDFLAGS += -X google.golang.org/protobuf/reflect/protoregistry.conflictPolicy=warn -s -w -X "$(OC_REPO)/pkg/version.String=$(STRING)" -X "$(OC_REPO)/pkg/version.Tag=$(VERSION)" -X "$(OC_REPO)/pkg/version.Date=$(DATE)"
DEBUG_LDFLAGS += -X google.golang.org/protobuf/reflect/protoregistry.conflictPolicy=warn -X "$(OC_REPO)/pkg/version.String=$(STRING)" -X "$(OC_REPO)/pkg/version.Tag=$(VERSION)" -X "$(OC_REPO)/pkg/version.Date=$(DATE)"
DOCKER_LDFLAGS += -X "$(OC_REPO)/pkg/config/defaults.BaseDataPathType=path" -X "$(OC_REPO)/pkg/config/defaults.BaseDataPathValue=/var/lib/opencloud"
DOCKER_LDFLAGS += -X "$(OC_REPO)/pkg/config/defaults.BaseConfigPathType=path" -X "$(OC_REPO)/pkg/config/defaults.BaseConfigPathValue=/etc/opencloud"

132
.vscode/launch.json vendored
View File

@@ -76,6 +76,138 @@
"OC_SERVICE_ACCOUNT_SECRET": "service-account-secret"
}
},
{
"name": "OpenCloud server with Groupware",
"type": "go",
"request": "launch",
"mode": "debug",
"buildFlags": [
// "-tags", "enable_vips"
],
"program": "${workspaceFolder}/opencloud/cmd/opencloud",
"args": ["server"],
"env": {
// log settings for human developers
"OC_LOG_LEVEL": "info",
"OC_LOG_PRETTY": "true",
"OC_LOG_COLOR": "true",
// set insecure options because we don't have valid certificates in dev environments
"OC_INSECURE": "true",
// enable basic auth for dev setup so that we can use curl for testing
"PROXY_ENABLE_BASIC_AUTH": "true",
// demo users
"IDM_CREATE_DEMO_USERS": "true",
// OC_RUN_SERVICES allows to start a subset of services even in the supervised mode
//"OC_RUN_SERVICES": "settings,storage-system,graph,idp,idm,ocs,store,thumbnails,web,webdav,frontend,gateway,users,groups,auth-basic,storage-authmachine,storage-users,storage-shares,storage-publiclink,storage-system,app-provider,sharing,proxy,ocdav",
/*
* Keep secrets and passwords in one block to allow easy uncommenting
*/
// user id of "admin", for user creation and admin role assignement
"OC_ADMIN_USER_ID": "some-admin-user-id-0000-000000000000", // FIXME currently must have the length of a UUID, see reva/pkg/storage/utils/decomposedfs/spaces.go:228
// admin user default password
"IDM_ADMIN_PASSWORD": "admin",
// system user
"OC_SYSTEM_USER_ID": "some-system-user-id-000-000000000000", // FIXME currently must have the length of a UUID, see reva/pkg/storage/utils/decomposedfs/spaces.go:228
"OC_SYSTEM_USER_API_KEY": "some-system-user-machine-auth-api-key",
// set some hardcoded secrets
"OC_JWT_SECRET": "some-opencloud-jwt-secret",
"OC_MACHINE_AUTH_API_KEY": "some-opencloud-machine-auth-api-key",
"OC_TRANSFER_SECRET": "some-opencloud-transfer-secret",
// collaboration
"COLLABORATION_WOPIAPP_SECRET": "some-wopi-secret",
// idm ldap
"IDM_SVC_PASSWORD": "some-ldap-idm-password",
"GRAPH_LDAP_BIND_PASSWORD": "some-ldap-idm-password",
// reva ldap
"IDM_REVASVC_PASSWORD": "some-ldap-reva-password",
"GROUPS_LDAP_BIND_PASSWORD": "some-ldap-reva-password",
"USERS_LDAP_BIND_PASSWORD": "some-ldap-reva-password",
"AUTH_BASIC_LDAP_BIND_PASSWORD": "some-ldap-reva-password",
// idp ldap
"IDM_IDPSVC_PASSWORD": "some-ldap-idp-password",
"IDP_LDAP_BIND_PASSWORD": "some-ldap-idp-password",
// storage users mount ID
"GATEWAY_STORAGE_USERS_MOUNT_ID": "storage-users-1",
"STORAGE_USERS_MOUNT_ID": "storage-users-1",
// graph application ID
"GRAPH_APPLICATION_ID": "application-1",
// service accounts
"OC_SERVICE_ACCOUNT_ID": "service-account-id",
"OC_SERVICE_ACCOUNT_SECRET": "service-account-secret",
"OC_ADD_RUN_SERVICES": "groupware",
"GROUPWARE_LOG_LEVEL": "trace"
}
},
{
"name": "OpenCloud server with external services",
"type": "go",
"request": "launch",
"mode": "debug",
"buildFlags": [
// "-tags", "enable_vips"
],
"program": "${workspaceFolder}/opencloud/cmd/opencloud",
"args": ["server"],
"env": {
"OC_URL": "https://localhost:9200/",
"PROXY_DEBUG_ADDR": "0.0.0.0:9205",
"OC_BASE_DATA_PATH": "${env:HOME}/.opencloud-with-external",
"OC_CONFIG_DIR": "${env:HOME}/.opencloud-with-external/config",
"GROUPWARE_LOG_LEVEL": "trace",
"OC_LOG_LEVEL": "info",
"OC_LOG_PRETTY": "true",
"OC_LOG_COLOR": "true",
"OC_INSECURE": "true",
"PROXY_ENABLE_BASIC_AUTH": "false",
"IDM_CREATE_DEMO_USERS": "false",
"OC_LDAP_URI": "ldaps://localhost:636",
"OC_LDAP_INSECURE": "true",
"OC_LDAP_BIND_DN": "cn=admin,dc=opencloud,dc=eu",
"OC_LDAP_BIND_PASSWORD": "admin",
"OC_LDAP_GROUP_BASE_DN": "ou=groups,dc=opencloud,dc=eu",
"OC_LDAP_GROUP_SCHEMA_ID": "entryUUID",
"OC_LDAP_USER_BASE_DN": "ou=users,dc=opencloud,dc=eu",
"OC_LDAP_USER_FILTER": "(objectclass=inetOrgPerson)",
"OC_LDAP_USER_SCHEMA_ID": "entryUUID",
"OC_LDAP_DISABLE_USER_MECHANISM": "none",
"OC_LDAP_SERVER_WRITE_ENABLED": "false",
"OC_EXCLUDE_RUN_SERVICES": "idm",
"OC_ADD_RUN_SERVICES": "notifications,groupware",
"NATS_NATS_HOST": "0.0.0.0",
"NATS_NATS_PORT": "9233",
"FRONTEND_ARCHIVER_MAX_SIZE": "10000000000",
"MICRO_REGISTRY_ADDRESS": "127.0.0.1:9233",
"NOTIFICATIONS_SMTP_HOST": "localhost",
"NOTIFICATIONS_SMTP_PORT": "2500",
"NOTIFICATIONS_SMTP_SENDER": "OpenCloud notifications <notifications@cloud.opencloud.test>",
"NOTIFICATIONS_SMTP_USERNAME": "notifications@cloud.opencloud.test",
"NOTIFICATIONS_SMTP_INSECURE": "true",
"NOTIFICATIONS_SMTP_PASSWORD": "",
"NOTIFICATIONS_SMTP_AUTHENTICATION": "",
"NOTIFICATIONS_SMTP_ENCRYPTION": "none",
"PROXY_AUTOPROVISION_ACCOUNTS": "false",
"PROXY_ROLE_ASSIGNMENT_DRIVER": "oidc",
"OC_OIDC_ISSUER": "https://keycloak.opencloud.test/realms/openCloud",
"PROXY_OIDC_REWRITE_WELLKNOWN": "true",
"WEB_OIDC_CLIENT_ID": "web",
"PROXY_USER_OIDC_CLAIM": "uuid",
"PROXY_USER_CS3_CLAIM": "userid",
"WEB_OPTION_ACCOUNT_EDIT_LINK_HREF": "https://keycloak.opencloud.test/realms/openCloud/account",
"OC_ADMIN_USER_ID": "",
"SETTINGS_SETUP_DEFAULT_ASSIGNMENTS": "false",
"GRAPH_ASSIGN_DEFAULT_USER_ROLE": "false",
"GRAPH_USERNAME_MATCH": "none",
"KEYCLOAK_DOMAIN": "keycloak.opencloud.test",
"IDM_ADMIN_PASSWORD": "admin",
"GRAPH_LDAP_SERVER_UUID": "true",
"GRAPH_LDAP_GROUP_CREATE_BASE_DN": "ou=custom,ou=groups,dc=opencloud,dc=eu",
"GRAPH_LDAP_REFINT_ENABLED": "true",
"GATEWAY_GRPC_ADDR": "0.0.0.0:9142",
}
},
{
"name": "Fed OpenCloud server",
"type": "go",

View File

@@ -1,4 +1,4 @@
# The test runner source for UI tests
WEB_COMMITID=3120ea384c7a9d1f1ea0c328965951fc06d66900
WEB_BRANCH=main
WEB_COMMITID=50e3fff6a518361d59cba864a927470f313b6f91
WEB_BRANCH=stable-4.2

View File

@@ -388,8 +388,6 @@ config = {
"production": {
# NOTE: need to be updated if new production releases are determined
"tags": ["2.0", "4.0"],
# NOTE: need to be set to true if patch releases are made from stable-X-branches
"skip_rolling": "false",
"repo": docker_repo_slug,
"build_type": "production",
},
@@ -481,10 +479,6 @@ def main(ctx):
if ctx.build.event == "cron" and ctx.build.sender == "translation-sync":
return translation_sync(ctx)
is_release_pr = (ctx.build.event == "pull_request" and ctx.build.sender == "openclouders" and "🎉 release" in ctx.build.title.lower())
if is_release_pr:
return [licenseCheck(ctx)]
build_release_helpers = \
readyReleaseGo()
@@ -1614,40 +1608,31 @@ def uploadTracingResult(ctx):
def dockerReleases(ctx):
pipelines = []
docker_releases = []
docker_repos = []
build_type = ""
# only make realeases on tag events
if ctx.build.event == "tag":
tag = ctx.build.ref.replace("refs/tags/v", "").lower()
# iterate over production tags to see if this is a production release
is_production = False
skip_rolling = False
for prod_tag in config["dockerReleases"]["production"]["tags"]:
if tag.startswith(prod_tag):
is_production = True
skip_rolling = config["dockerReleases"]["production"]["skip_rolling"]
break
if is_production:
docker_releases.append("production")
# a new production realease is also a rolling release
# unless skip_rolling is set in the config, i.e. for patch-releases on stable-branch
if not skip_rolling:
docker_releases.append("rolling")
docker_repos.append(config["dockerReleases"]["production"]["repo"])
build_type = config["dockerReleases"]["production"]["build_type"]
else:
docker_releases.append("rolling")
docker_repos.append(config["dockerReleases"]["rolling"]["repo"])
build_type = config["dockerReleases"]["rolling"]["build_type"]
# on non tag events, do daily build
else:
docker_releases.append("daily")
docker_repos.append(config["dockerReleases"]["daily"]["repo"])
build_type = config["dockerReleases"]["daily"]["build_type"]
for releaseConfigName in docker_releases:
repo = config["dockerReleases"][releaseConfigName]["repo"]
build_type = config["dockerReleases"][releaseConfigName]["build_type"]
for repo in docker_repos:
repo_pipelines = []
repo_pipelines.append(dockerRelease(ctx, repo, build_type))
@@ -1667,7 +1652,6 @@ def dockerRelease(ctx, repo, build_type):
build_args = {
"REVISION": "%s" % ctx.build.commit,
"VERSION": "%s" % (ctx.build.ref.replace("refs/tags/", "") if ctx.build.event == "tag" else "daily"),
"EDITION": "stable" if build_type == "production" else "rolling",
}
# if no additional tag is given, the build-plugin adds latest
@@ -1831,7 +1815,6 @@ def binaryRelease(ctx, arch, depends_on = []):
"image": OC_CI_GOLANG,
"environment": {
"VERSION": (ctx.build.ref.replace("refs/tags/", "") if ctx.build.event == "tag" else "daily"),
"EDITION": "rolling",
"HTTP_PROXY": {
"from_secret": "ci_http_proxy",
},
@@ -2357,12 +2340,11 @@ def translation_sync(ctx):
"image": OC_CI_GOLANG,
"commands": [
"make l10n-read",
"mkdir tx && cd tx",
"curl -o- https://raw.githubusercontent.com/transifex/cli/master/install.sh | bash",
"export PATH=$PATH:$(pwd) && cd ..",
". ~/.profile",
"make l10n-push",
"make l10n-pull",
"rm -rf tx",
"rm tx",
"make l10n-clean",
],
"environment": {

View File

@@ -1,41 +1,5 @@
# Changelog
## [4.1.0](https://github.com/opencloud-eu/opencloud/releases/tag/v4.1.0) - 2025-12-15
### ❤️ Thanks to all contributors! ❤️
@JammingBen, @ScharfViktor, @Svanvith, @butonic, @flimmy, @fschade, @individual-it, @kulmann, @micbar, @prashant-gurung899, @saw-jan
### 📚 Documentation
- fix typo [[#2024](https://github.com/opencloud-eu/opencloud/pull/2024)]
- [docs] update policies link [[#1996](https://github.com/opencloud-eu/opencloud/pull/1996)]
- fix the link in quickstart script for itself [[#1956](https://github.com/opencloud-eu/opencloud/pull/1956)]
### ✅ Tests
- [full-ci][tests-only] test: fix some test flakiness [[#2003](https://github.com/opencloud-eu/opencloud/pull/2003)]
- [tests-only] Skip test related pipelines for ready-release-go PRs [[#2011](https://github.com/opencloud-eu/opencloud/pull/2011)]
- [full-ci][tests-only] test: add test to check mismatch offset during TUS upload [[#1993](https://github.com/opencloud-eu/opencloud/pull/1993)]
- [full-ci][tests-only] test: proper resource existence check [[#1990](https://github.com/opencloud-eu/opencloud/pull/1990)]
- check propfing after renaming data in file system [[#1809](https://github.com/opencloud-eu/opencloud/pull/1809)]
- fix-get-attribute-test [[#1974](https://github.com/opencloud-eu/opencloud/pull/1974)]
### 📈 Enhancement
- Show edition in opencloud version command [[#2019](https://github.com/opencloud-eu/opencloud/pull/2019)]
### 🐛 Bug Fixes
- fix: enforce trailing slash for server url [[#1995](https://github.com/opencloud-eu/opencloud/pull/1995)]
- fix: enhance resource creation with detailed process information [[#1978](https://github.com/opencloud-eu/opencloud/pull/1978)]
### 📦️ Dependencies
- chore: bump web to v4.3.0 [[#2030](https://github.com/opencloud-eu/opencloud/pull/2030)]
- reva-bump-2.41.0 [[#2032](https://github.com/opencloud-eu/opencloud/pull/2032)]
- build(deps): bump github.com/testcontainers/testcontainers-go from 0.39.0 to 0.40.0 [[#1931](https://github.com/opencloud-eu/opencloud/pull/1931)]
## [4.0.0](https://github.com/opencloud-eu/opencloud/releases/tag/v4.0.0) - 2025-12-01
### ❤️ Thanks to all contributors! ❤️

View File

@@ -27,6 +27,7 @@ OC_MODULES = \
services/app-provider \
services/app-registry \
services/audit \
services/auth-api \
services/auth-app \
services/auth-basic \
services/auth-bearer \
@@ -39,6 +40,7 @@ OC_MODULES = \
services/gateway \
services/graph \
services/groups \
services/groupware \
services/idm \
services/idp \
services/invitations \

View File

@@ -305,8 +305,15 @@ KEYCLOAK_ADMIN_PASSWORD=
# Leaving it default stores data in docker internal volumes.
#RADICALE_DATA_DIR=/your/local/radicale/data
### Stalwart Settings ###
# Note: the leading colon is required to enable the service.
#STALWART=:stalwart.yml
# Domain of Stalwart
# Defaults to "stalwart.opencloud.test"
STALWART_DOMAIN=
## IMPORTANT ##
# This MUST be the last line as it assembles the supplemental compose files to be used.
# ALL supplemental configs must be added here, whether commented or not.
# Each var must either be empty or contain :path/file.yml
COMPOSE_FILE=docker-compose.yml${OPENCLOUD:-}${TIKA:-}${DECOMPOSEDS3:-}${DECOMPOSEDS3_MINIO:-}${DECOMPOSED:-}${COLLABORA:-}${MONITORING:-}${IMPORTER:-}${CLAMAV:-}${INBUCKET:-}${EXTENSIONS:-}${UNZIP:-}${DRAWIO:-}${JSONVIEWER:-}${PROGRESSBARS:-}${EXTERNALSITES:-}${KEYCLOAK:-}${LDAP:-}${KEYCLOAK_AUTOPROVISIONING:-}${LDAP_MANAGER:-}${RADICALE:-}
COMPOSE_FILE=docker-compose.yml${OPENCLOUD:-}${TIKA:-}${DECOMPOSEDS3:-}${DECOMPOSEDS3_MINIO:-}${DECOMPOSED:-}${COLLABORA:-}${MONITORING:-}${IMPORTER:-}${CLAMAV:-}${INBUCKET:-}${EXTENSIONS:-}${UNZIP:-}${DRAWIO:-}${JSONVIEWER:-}${PROGRESSBARS:-}${EXTERNALSITES:-}${KEYCLOAK:-}${LDAP:-}${KEYCLOAK_AUTOPROVISIONING:-}${LDAP_MANAGER:-}${RADICALE:-}${STALWART:-}

View File

@@ -0,0 +1,58 @@
{
"clientId": "groupware",
"name": "OpenCloud Groupware",
"description": "Used for authenticating automated HTTP clients of the OpenCloud Groupware API",
"rootUrl": "",
"adminUrl": "",
"baseUrl": "",
"surrogateAuthRequired": false,
"enabled": true,
"alwaysDisplayInConsole": false,
"clientAuthenticatorType": "client-secret",
"redirectUris": [
"/*"
],
"webOrigins": [
"/*"
],
"notBefore": 0,
"bearerOnly": false,
"consentRequired": false,
"standardFlowEnabled": true,
"implicitFlowEnabled": false,
"directAccessGrantsEnabled": true,
"serviceAccountsEnabled": false,
"publicClient": true,
"frontchannelLogout": true,
"protocol": "openid-connect",
"attributes": {
"oidc.ciba.grant.enabled": "false",
"backchannel.logout.session.required": "true",
"oauth2.device.authorization.grant.enabled": "false",
"backchannel.logout.revoke.offline.tokens": "false"
},
"authenticationFlowBindingOverrides": {},
"fullScopeAllowed": true,
"nodeReRegistrationTimeout": -1,
"defaultClientScopes": [
"web-origins",
"acr",
"profile",
"roles",
"groups",
"OpenCloudUnique_ID",
"basic",
"email"
],
"optionalClientScopes": [
"address",
"phone",
"offline_access",
"microprofile-jwt"
],
"access": {
"view": true,
"configure": true,
"manage": true
}
}

View File

@@ -0,0 +1,26 @@
dn: ou=policies,dc=opencloud,dc=eu
objectClass: organizationalUnit
objectClass: top
ou: policies
dn: cn=default,ou=policies,dc=opencloud,dc=eu
cn: default
objectClass: pwdPolicy
objectClass: person
objectClass: top
pwdAllowUserChange: TRUE
pwdAttribute: userPassword
pwdCheckQuality: 0
pwdExpireWarning: 600
pwdFailureCountInterval: 30
pwdGraceAuthNLimit: 5
pwdInHistory: 5
pwdLockout: FALSE
pwdLockoutDuration: 0
pwdMaxAge: 0
pwdMaxFailure: 5
pwdMinAge: 0
pwdMinLength: 1
pwdMustChange: FALSE
pwdSafeModify: FALSE
sn: default

View File

@@ -0,0 +1,21 @@
# Stalwart Configuration
The mechanics are currently to mount a different configuration file depending on the environment, as we support two scenarios that are described in [`services/groupware/DEVELOPER.md`](../../../../../services/groupware/DEVELOPER.md):
* &laquo;production&raquo; setup, with OpenLDAP and Keycloak containers
* &laquo;homelab&raquo; setup, with the built-in IDM (LDAP) and IDP that run as part of the `opencloud` container
The Docker Compose setup (in [`stalwart.yml`](../../stalwart.yml)) mounts either [`idmldap.toml`](./idmldap.toml) or [`ldap.toml`](./ldap.toml) depending on how the variable `STALWART_AUTH_DIRECTORY` is set, which is either `idmldap` for the homelab setup, or `ldap` for the production setup.
This is thus all done automatically, but whenever changes are performed to Stalwart configuration files, they must be reflected across those two files, to keep them in sync, as the only entry that should differ is this one:
```ruby
storage.directory = "ldap"
```
or this:
```ruby
storage.directory = "idmldap"
```

View File

@@ -0,0 +1,110 @@
authentication.fallback-admin.secret = "$6$4qPYDVhaUHkKcY7s$bB6qhcukb9oFNYRIvaDZgbwxrMa2RvF5dumCjkBFdX19lSNqrgKltf3aPrFMuQQKkZpK2YNuQ83hB1B3NiWzj."
authentication.fallback-admin.user = "mailadmin"
authentication.master.secret = "$6$4qPYDVhaUHkKcY7s$bB6qhcukb9oFNYRIvaDZgbwxrMa2RvF5dumCjkBFdX19lSNqrgKltf3aPrFMuQQKkZpK2YNuQ83hB1B3NiWzj."
authentication.master.user = "master"
directory.idmldap.attributes.class = "objectClass"
directory.idmldap.attributes.description = "displayName"
directory.idmldap.attributes.email = "mail"
directory.idmldap.attributes.groups = "memberOf"
directory.idmldap.attributes.name = "uid"
directory.idmldap.attributes.secret = "userPassword"
directory.idmldap.base-dn = "o=libregraph-idm"
directory.idmldap.bind.auth.method = "default"
directory.idmldap.bind.dn = "uid=reva,ou=sysusers,o=libregraph-idm"
directory.idmldap.bind.secret = "admin"
directory.idmldap.cache.size = 1048576
directory.idmldap.cache.ttl.negative = "10m"
directory.idmldap.cache.ttl.positive = "1h"
directory.idmldap.filter.email = "(&(|(objectClass=person)(objectClass=groupOfNames))(mail=?))"
directory.idmldap.filter.name = "(&(|(objectClass=person)(objectClass=groupOfNames))(uid=?))"
directory.idmldap.timeout = "15s"
directory.idmldap.tls.allow-invalid-certs = true
directory.idmldap.tls.enable = true
directory.idmldap.type = "ldap"
directory.idmldap.url = "ldaps://opencloud:9235"
directory.keycloak.auth.method = "user-token"
directory.keycloak.cache.size = 1048576
directory.keycloak.cache.ttl.negative = "10m"
directory.keycloak.cache.ttl.positive = "1h"
directory.keycloak.endpoint.method = "introspect"
directory.keycloak.endpoint.url = "http://keycloak:8080/realms/openCloud/protocol/openid-connect/userinfo"
directory.keycloak.fields.email = "email"
directory.keycloak.fields.full-name = "name"
directory.keycloak.fields.username = "preferred_username"
directory.keycloak.timeout = "15s"
directory.keycloak.type = "oidc"
directory.ldap.attributes.class = "objectClass"
directory.ldap.attributes.description = "displayName"
directory.ldap.attributes.email = "mail"
directory.ldap.attributes.email-alias = "mailAlias"
directory.ldap.attributes.groups = "memberOf"
directory.ldap.attributes.name = "uid"
directory.ldap.attributes.secret = "userPassword"
directory.ldap.attributes.secret-changed = "pwdChangedTime"
directory.ldap.base-dn = "dc=opencloud,dc=eu"
directory.ldap.bind.auth.dn = "cn=?,ou=users,dc=opencloud,dc=eu"
directory.ldap.bind.auth.enable = true
directory.ldap.bind.auth.search = true
directory.ldap.bind.dn = "cn=admin,dc=opencloud,dc=eu"
directory.ldap.bind.secret = "admin"
directory.ldap.cache.ttl.negative = "10m"
directory.ldap.cache.ttl.positive = "1h"
directory.ldap.filter.email = "(&(|(objectClass=person)(objectClass=groupOfNames))(|(uid=?)(mail=?)(mailAlias=?)(cn=?)))"
directory.ldap.filter.name = "(&(|(objectClass=person)(objectClass=groupOfNames))(|(uid=?)(cn=?)))"
directory.ldap.timeout = "5s"
directory.ldap.tls.allow-invalid-certs = true
directory.ldap.tls.enable = true
directory.ldap.type = "ldap"
directory.ldap.url = "ldap://ldap-server:1389"
http.allowed-endpoint = 200
http.hsts = true
http.permissive-cors = false
http.url = "'https://' + config_get('server.hostname')"
http.use-x-forwarded = true
metrics.prometheus.auth.secret = "secret"
metrics.prometheus.auth.username = "metrics"
metrics.prometheus.enable = true
server.listener.http.bind = "0.0.0.0:8080"
server.listener.http.protocol = "http"
server.listener.https.bind = "0.0.0.0:443"
server.listener.https.protocol = "http"
server.listener.https.tls.implicit = true
server.listener.imap.bind = "0.0.0.0:143"
server.listener.imap.protocol = "imap"
server.listener.imaptls.bind = "0.0.0.0:993"
server.listener.imaptls.protocol = "imap"
server.listener.imaptls.tls.implicit = true
server.listener.pop3.bind = "0.0.0.0:110"
server.listener.pop3.protocol = "pop3"
server.listener.pop3s.bind = "0.0.0.0:995"
server.listener.pop3s.protocol = "pop3"
server.listener.pop3s.tls.implicit = true
server.listener.sieve.bind = "0.0.0.0:4190"
server.listener.sieve.protocol = "managesieve"
server.listener.smtp.bind = "0.0.0.0:25"
server.listener.smtp.protocol = "smtp"
server.listener.submission.bind = "0.0.0.0:587"
server.listener.submission.protocol = "smtp"
server.listener.submissions.bind = "0.0.0.0:465"
server.listener.submissions.protocol = "smtp"
server.listener.submissions.tls.implicit = true
server.max-connections = 8192
server.socket.backlog = 1024
server.socket.nodelay = true
server.socket.reuse-addr = true
server.socket.reuse-port = true
storage.blob = "rocksdb"
storage.data = "rocksdb"
storage.directory = "%{env:STALWART_AUTH_DIRECTORY}%"
storage.fts = "rocksdb"
storage.lookup = "rocksdb"
store.rocksdb.compression = "lz4"
store.rocksdb.path = "/opt/stalwart/data"
store.rocksdb.type = "rocksdb"
tracer.console.ansi = true
tracer.console.buffered = true
tracer.console.enable = true
tracer.console.level = "trace"
tracer.console.lossy = false
tracer.console.multiline = false
tracer.console.type = "stdout"

View File

@@ -0,0 +1,111 @@
authentication.fallback-admin.secret = "$6$4qPYDVhaUHkKcY7s$bB6qhcukb9oFNYRIvaDZgbwxrMa2RvF5dumCjkBFdX19lSNqrgKltf3aPrFMuQQKkZpK2YNuQ83hB1B3NiWzj."
authentication.fallback-admin.user = "mailadmin"
authentication.master.secret = "$6$4qPYDVhaUHkKcY7s$bB6qhcukb9oFNYRIvaDZgbwxrMa2RvF5dumCjkBFdX19lSNqrgKltf3aPrFMuQQKkZpK2YNuQ83hB1B3NiWzj."
authentication.master.user = "master"
directory.idmldap.attributes.class = "objectClass"
directory.idmldap.attributes.description = "displayName"
directory.idmldap.attributes.email = "mail"
directory.idmldap.attributes.groups = "memberOf"
directory.idmldap.attributes.name = "cn"
directory.idmldap.attributes.secret = "userPassword"
directory.idmldap.base-dn = "o=libregraph-idm"
directory.idmldap.bind.auth.method = "default"
directory.idmldap.bind.dn = "uid=reva,ou=sysusers,o=libregraph-idm"
directory.idmldap.bind.secret = "admin"
directory.idmldap.cache.size = 1048576
directory.idmldap.cache.ttl.negative = "10m"
directory.idmldap.cache.ttl.positive = "1h"
directory.idmldap.filter.email = "(&(|(objectClass=person)(objectClass=groupOfNames))(mail=?))"
directory.idmldap.filter.name = "(&(|(objectClass=person)(objectClass=groupOfNames))(cn=?))"
directory.idmldap.timeout = "15s"
directory.idmldap.tls.allow-invalid-certs = true
directory.idmldap.tls.enable = true
directory.idmldap.type = "ldap"
directory.idmldap.url = "ldaps://opencloud:9235"
directory.keycloak.auth.method = "user-token"
directory.keycloak.cache.size = 1048576
directory.keycloak.cache.ttl.negative = "10m"
directory.keycloak.cache.ttl.positive = "1h"
directory.keycloak.endpoint.method = "introspect"
directory.keycloak.endpoint.url = "http://keycloak:8080/realms/openCloud/protocol/openid-connect/userinfo"
directory.keycloak.fields.email = "email"
directory.keycloak.fields.full-name = "name"
directory.keycloak.fields.username = "preferred_username"
directory.keycloak.timeout = "15s"
directory.keycloak.type = "oidc"
directory.ldap.attributes.class = "objectClass"
directory.ldap.attributes.description = "displayName"
directory.ldap.attributes.email = "mail"
directory.ldap.attributes.email-alias = "mailAlias"
directory.ldap.attributes.groups = "memberOf"
directory.ldap.attributes.name = "uid"
directory.ldap.attributes.secret = "userPassword"
directory.ldap.attributes.secret-changed = "pwdChangedTime"
directory.ldap.base-dn = "dc=opencloud,dc=eu"
directory.ldap.bind.auth.dn = "cn=?,ou=users,dc=opencloud,dc=eu"
directory.ldap.bind.auth.enable = true
directory.ldap.bind.auth.search = true
directory.ldap.bind.dn = "cn=admin,dc=opencloud,dc=eu"
directory.ldap.bind.secret = "admin"
directory.ldap.cache.ttl.negative = "10m"
directory.ldap.cache.ttl.positive = "1h"
directory.ldap.filter.email = "(&(|(objectClass=person)(objectClass=groupOfNames))(|(uid=?)(mail=?)(mailAlias=?)(cn=?)))"
directory.ldap.filter.name = "(&(|(objectClass=person)(objectClass=groupOfNames))(|(uid=?)(cn=?)))"
directory.ldap.timeout = "5s"
directory.ldap.tls.allow-invalid-certs = true
directory.ldap.tls.enable = true
directory.ldap.type = "ldap"
directory.ldap.url = "ldap://ldap-server:1389"
http.allowed-endpoint = 200
http.hsts = true
http.permissive-cors = false
http.url = "'https://' + config_get('server.hostname')"
http.use-x-forwarded = true
metrics.prometheus.auth.secret = "secret"
metrics.prometheus.auth.username = "metrics"
metrics.prometheus.enable = true
server.listener.http.bind = "0.0.0.0:8080"
server.listener.http.protocol = "http"
server.listener.https.bind = "0.0.0.0:443"
server.listener.https.protocol = "http"
server.listener.https.tls.implicit = true
server.listener.imap.bind = "0.0.0.0:143"
server.listener.imap.protocol = "imap"
server.listener.imaptls.bind = "0.0.0.0:993"
server.listener.imaptls.protocol = "imap"
server.listener.imaptls.tls.implicit = true
server.listener.pop3.bind = "0.0.0.0:110"
server.listener.pop3.protocol = "pop3"
server.listener.pop3s.bind = "0.0.0.0:995"
server.listener.pop3s.protocol = "pop3"
server.listener.pop3s.tls.implicit = true
server.listener.sieve.bind = "0.0.0.0:4190"
server.listener.sieve.protocol = "managesieve"
server.listener.smtp.bind = "0.0.0.0:25"
server.listener.smtp.protocol = "smtp"
server.listener.submission.bind = "0.0.0.0:587"
server.listener.submission.protocol = "smtp"
server.listener.submissions.bind = "0.0.0.0:465"
server.listener.submissions.protocol = "smtp"
server.listener.submissions.tls.implicit = true
server.max-connections = 8192
server.socket.backlog = 1024
server.socket.nodelay = true
server.socket.reuse-addr = true
server.socket.reuse-port = true
storage.blob = "rocksdb"
storage.data = "rocksdb"
storage.directory = "idmldap"
storage.fts = "rocksdb"
storage.lookup = "rocksdb"
store.rocksdb.compression = "lz4"
store.rocksdb.path = "/opt/stalwart/data"
store.rocksdb.type = "rocksdb"
tracer.console.ansi = true
tracer.console.buffered = true
tracer.console.enable = true
tracer.console.level = "trace"
tracer.console.lossy = false
tracer.console.multiline = false
tracer.console.type = "stdout"
sharing.allow-directory-query = false

View File

@@ -0,0 +1,110 @@
authentication.fallback-admin.secret = "$6$4qPYDVhaUHkKcY7s$bB6qhcukb9oFNYRIvaDZgbwxrMa2RvF5dumCjkBFdX19lSNqrgKltf3aPrFMuQQKkZpK2YNuQ83hB1B3NiWzj."
authentication.fallback-admin.user = "mailadmin"
authentication.master.secret = "$6$4qPYDVhaUHkKcY7s$bB6qhcukb9oFNYRIvaDZgbwxrMa2RvF5dumCjkBFdX19lSNqrgKltf3aPrFMuQQKkZpK2YNuQ83hB1B3NiWzj."
authentication.master.user = "master"
directory.idmldap.attributes.class = "objectClass"
directory.idmldap.attributes.description = "displayName"
directory.idmldap.attributes.email = "mail"
directory.idmldap.attributes.groups = "memberOf"
directory.idmldap.attributes.name = "uid"
directory.idmldap.attributes.secret = "userPassword"
directory.idmldap.base-dn = "o=libregraph-idm"
directory.idmldap.bind.auth.method = "default"
directory.idmldap.bind.dn = "uid=reva,ou=sysusers,o=libregraph-idm"
directory.idmldap.bind.secret = "admin"
directory.idmldap.cache.size = 1048576
directory.idmldap.cache.ttl.negative = "10m"
directory.idmldap.cache.ttl.positive = "1h"
directory.idmldap.filter.email = "(&(|(objectClass=person)(objectClass=groupOfNames))(mail=?))"
directory.idmldap.filter.name = "(&(|(objectClass=person)(objectClass=groupOfNames))(uid=?))"
directory.idmldap.timeout = "15s"
directory.idmldap.tls.allow-invalid-certs = true
directory.idmldap.tls.enable = true
directory.idmldap.type = "ldap"
directory.idmldap.url = "ldaps://opencloud:9235"
directory.keycloak.auth.method = "user-token"
directory.keycloak.cache.size = 1048576
directory.keycloak.cache.ttl.negative = "10m"
directory.keycloak.cache.ttl.positive = "1h"
directory.keycloak.endpoint.method = "introspect"
directory.keycloak.endpoint.url = "http://keycloak:8080/realms/openCloud/protocol/openid-connect/userinfo"
directory.keycloak.fields.email = "email"
directory.keycloak.fields.full-name = "name"
directory.keycloak.fields.username = "preferred_username"
directory.keycloak.timeout = "15s"
directory.keycloak.type = "oidc"
directory.ldap.attributes.class = "objectClass"
directory.ldap.attributes.description = "displayName"
directory.ldap.attributes.email = "mail"
directory.ldap.attributes.email-alias = "mailAlias"
directory.ldap.attributes.groups = "memberOf"
directory.ldap.attributes.name = "uid"
directory.ldap.attributes.secret = "userPassword"
directory.ldap.attributes.secret-changed = "pwdChangedTime"
directory.ldap.base-dn = "dc=opencloud,dc=eu"
directory.ldap.bind.auth.dn = "cn=?,ou=users,dc=opencloud,dc=eu"
directory.ldap.bind.auth.enable = true
directory.ldap.bind.auth.search = true
directory.ldap.bind.dn = "cn=admin,dc=opencloud,dc=eu"
directory.ldap.bind.secret = "admin"
directory.ldap.cache.ttl.negative = "10m"
directory.ldap.cache.ttl.positive = "1h"
directory.ldap.filter.email = "(&(|(objectClass=person)(objectClass=groupOfNames))(|(uid=?)(mail=?)(mailAlias=?)(cn=?)))"
directory.ldap.filter.name = "(&(|(objectClass=person)(objectClass=groupOfNames))(|(uid=?)(cn=?)))"
directory.ldap.timeout = "5s"
directory.ldap.tls.allow-invalid-certs = true
directory.ldap.tls.enable = true
directory.ldap.type = "ldap"
directory.ldap.url = "ldap://ldap-server:1389"
http.allowed-endpoint = 200
http.hsts = true
http.permissive-cors = false
http.url = "'https://' + config_get('server.hostname')"
http.use-x-forwarded = true
metrics.prometheus.auth.secret = "secret"
metrics.prometheus.auth.username = "metrics"
metrics.prometheus.enable = true
server.listener.http.bind = "0.0.0.0:8080"
server.listener.http.protocol = "http"
server.listener.https.bind = "0.0.0.0:443"
server.listener.https.protocol = "http"
server.listener.https.tls.implicit = true
server.listener.imap.bind = "0.0.0.0:143"
server.listener.imap.protocol = "imap"
server.listener.imaptls.bind = "0.0.0.0:993"
server.listener.imaptls.protocol = "imap"
server.listener.imaptls.tls.implicit = true
server.listener.pop3.bind = "0.0.0.0:110"
server.listener.pop3.protocol = "pop3"
server.listener.pop3s.bind = "0.0.0.0:995"
server.listener.pop3s.protocol = "pop3"
server.listener.pop3s.tls.implicit = true
server.listener.sieve.bind = "0.0.0.0:4190"
server.listener.sieve.protocol = "managesieve"
server.listener.smtp.bind = "0.0.0.0:25"
server.listener.smtp.protocol = "smtp"
server.listener.submission.bind = "0.0.0.0:587"
server.listener.submission.protocol = "smtp"
server.listener.submissions.bind = "0.0.0.0:465"
server.listener.submissions.protocol = "smtp"
server.listener.submissions.tls.implicit = true
server.max-connections = 8192
server.socket.backlog = 1024
server.socket.nodelay = true
server.socket.reuse-addr = true
server.socket.reuse-port = true
storage.blob = "rocksdb"
storage.data = "rocksdb"
storage.directory = "ldap"
storage.fts = "rocksdb"
storage.lookup = "rocksdb"
store.rocksdb.compression = "lz4"
store.rocksdb.path = "/opt/stalwart/data"
store.rocksdb.type = "rocksdb"
tracer.console.ansi = true
tracer.console.buffered = true
tracer.console.enable = true
tracer.console.level = "trace"
tracer.console.lossy = false
tracer.console.multiline = false
tracer.console.type = "stdout"

View File

@@ -0,0 +1,7 @@
---
services:
opencloud:
command: [ "-c", "opencloud init || true; dlv --listen=:40000 --headless=true --check-go-version=false --api-version=2 --accept-multiclient exec /usr/bin/opencloud server" ]
ports:
- 40000:40000

View File

@@ -31,6 +31,7 @@ services:
- "--accessLog=true"
- "--accessLog.format=json"
- "--accessLog.fields.headers.names.X-Request-Id=keep"
- "--accessLog.fields.headers.names.Trace-Id=keep"
ports:
- "80:80"
- "443:443"

View File

@@ -57,6 +57,8 @@ services:
KC_FEATURES: impersonation
KEYCLOAK_ADMIN: ${KEYCLOAK_ADMIN_USER:-admin}
KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:-admin}
ports:
- "8080:8080"
labels:
- "traefik.enable=true"
- "traefik.http.routers.keycloak.entrypoints=https"

View File

@@ -24,6 +24,7 @@ services:
OC_LDAP_SERVER_WRITE_ENABLED: "false" # assuming the external ldap is not writable
# OC_RUN_SERVICES specifies to start all services except glauth, idm and accounts. These are replaced by external services
OC_EXCLUDE_RUN_SERVICES: idm
STALWART_AUTH_DIRECTORY: "ldap"
ldap-server:
image: bitnamilegacy/openldap:2.6
@@ -39,6 +40,9 @@ services:
LDAP_TLS_KEY_FILE: /opt/bitnami/openldap/share/openldap.key
LDAP_ROOT: "dc=opencloud,dc=eu"
LDAP_ADMIN_PASSWORD: ${LDAP_ADMIN_PASSWORD:-admin}
LDAP_CONFIGURE_PPOLICY: "yes"
LDAP_PPOLICY_USE_LOCKOUT: "no"
LDAP_PPOLICY_HASH_CLEARTEXT: "no"
ports:
- "127.0.0.1:389:1389"
- "127.0.0.1:636:1636"

View File

@@ -58,6 +58,11 @@ services:
COMPANION_DOMAIN: ${COMPANION_DOMAIN:-companion.opencloud.test}
# enable to allow using the banned passwords list
OC_PASSWORD_POLICY_BANNED_PASSWORDS_LIST: banned-password-list.txt
IDM_REVASVC_PASSWORD: "admin"
AUTH_BASIC_LDAP_BIND_PASSWORD: "admin"
USERS_LDAP_BIND_PASSWORD: "admin"
GROUPS_LDAP_BIND_PASSWORD: "admin"
IDM_LDAPS_ADDR: 0.0.0.0:9235
volumes:
- ./config/opencloud/app-registry.yaml:/etc/opencloud/app-registry.yaml
- ./config/opencloud/csp.yaml:/etc/opencloud/csp.yaml

View File

@@ -0,0 +1,36 @@
---
services:
traefik:
networks:
opencloud-net:
aliases:
- ${STALWART_DOMAIN:-stalwart.opencloud.test}
stalwart:
image: ghcr.io/stalwartlabs/stalwart:v0.15.0-alpine
hostname: ${STALWART_DOMAIN:-stalwart.opencloud.test}
networks:
- opencloud-net
ports:
- "127.0.0.1:143:143"
- "127.0.0.1:993:993"
- "127.0.0.1:1465:465"
volumes:
- /etc/localtime:/etc/localtime:ro
- "./config/stalwart/${STALWART_AUTH_DIRECTORY:-idmldap}.toml:/opt/stalwart/etc/config.toml"
- stalwart-data:/opt/stalwart/data
environment:
STALWART_AUTH_DIRECTORY: "${STALWART_AUTH_DIRECTORY:-idmldap}"
labels:
- "traefik.enable=true"
- "traefik.http.routers.stalwart.entrypoints=https"
- "traefik.http.routers.stalwart.rule=Host(`${STALWART_DOMAIN:-stalwart.opencloud.test}`)"
- "traefik.http.routers.stalwart.tls.certresolver=http"
- "traefik.http.routers.stalwart.service=stalwart"
- "traefik.http.services.stalwart.loadbalancer.server.port=8080"
logging:
driver: ${LOG_DRIVER:-local}
restart: always
volumes:
stalwart-data:

View File

@@ -0,0 +1,343 @@
---
title: "Authentication with Stalwart"
---
* Status: draft
## Context
In a groupware environment, not every user will always use the OpenCloud UI to read their emails, some will resort to other [MUAs (Mail User Agents)](https://en.wikipedia.org/wiki/Email_client) that support a subset of features, use older protocols (IMAP, POP, SMTP, CalDAV, CardDAV) and lesser authentication methods (basic authentication). Those email clients will talk to Stalwart directly, as opposed to the OpenCloud UI which will make use of APIs of the OpenCloud Groupware service, since those protocols are provided by Stalwart and implementing them in OpenCloud would offer very little benefits, but definitely a lot of (almost completely) unnecessary effort.
Those protocols and operations that bypass the OpenCloud UI also need to be authenticated, this in and by Stalwart, and we need to find the best fitting approach that fulfills most or all of the following constraints:
### Single Provisioning
We want to avoid multiple provisioning of users, groups, passwords and other resources as much as possible.
While it is possible to have e.g. OpenCloud's user management also perform [Management API](https://stalw.art/docs/category/management-api/) calls, one still inevitably ends up in situations where users, user passwords, or other resources are not in sync, which becomes complex to debug and fix, and should thus be avoided if possible.
To do so, we should strive to have a single source of truth regarding users, their passwords, and similar resources and attributes such as groups, roles, application passwords, etc...
### Attack Detection
Coordinated attacks such as [denial of service](https://en.wikipedia.org/wiki/Denial-of-service_attack) attempts don't necessarily focus on a single protocol but are commonly multi-pronged, e.g. by brute forcing the [OIDC API](https://www.keycloak.org/docs/latest/authorization_services/index.html#token-endpoint), the OpenCloud Groupware API, IMAP and SMTP, \*DAV protocols, etc...
In order to detect those as well as to quickly react by blacklisting clients that are identified to attempt such attacks, it is useful to have a single authentication service for all the components of the system, all protocols, all clients (e.g. [PowerDNS Weakforced](https://github.com/PowerDNS/weakforced), [Nauthilus](https://nauthilus.org/), ...)
Furthermore, such services typically make use of [DNSBL/RBL services](https://en.wikipedia.org/wiki/Domain_Name_System_blocklist) that allow IP addresses of botnets to be blocked across many services of many providers as a shared defense mechanism.
As a bonus, a centralized authentication component can also provide metrics and observability capabilities across all those protocols.
### Custom Authentication Implementations
Some customers might want custom authentication implementations to integrate with their environment, in which case we would want those to be done once and in the technology stack we're all most familiar with (thus as a service in Go in the OpenCloud framework, and not e.g. a Lua script in Nauthilus, or a Rust plugin in Stalwart, etc...)
## Decision Drivers
TODO
*
## Considered Options
First off, here is a brief explanation of each of the scenarios that we potentially or absolutely need to support, which we will explore for each implementation option:
* MUAs with basic authentication
* these are external mail clients (Thunderbird, Apple Mail, ...) with which users authenticate using legacy protocols (IMAP, POP3, SMTP) and their primary username and password in clear text (encrypted through the mandatory use of TLS)
* MUAs with application password authentication
* these are external mail clients (Thunderbird, Apple Mail, ...) with which users authenticate using legacy protocols (IMAP, POP3, SMTP) and one of the application passwords that they created in the OpenCloud UI, which is a useful security mechanism as it reduces the attack surface when one such password is leaked or discovered
* MUAs with SASL bearer token authentication
* these are more modern external mail clients (Thunderbird) with which users authenticate using legacy protocols (IMAP, POP3, SMTP) but more secure OIDC token based authentication (SASL OAUTHBEARER or SASL XOAUTH2), which closely resembles the OIDC authentication used by the OpenCloud UI towards the OpenCloud backends
* JMAP clients with basic authentication
* modern mail clients (Thunderbird) that speak the JMAP protocol over HTTP and authenticate using their primary username and password in clear text (encrypted through the use of HTTPS)
* JMAP clients with bearer token authentication
* modern mail clients (Thunderbird) that speak the JMAP protocol over HTTP and authenticate using an OIDC token (JWT) obtained from an IDP (typically KeyCloak)
* OpenCloud Groupware with master authentication
* the OpenCloud UI client uses APIs from the OpenCloud Groupware backend (and authenticates using OIDC)
* the OpenCloud Groupware backend, in turn, performs JMAP operations with Stalwart, and authenticates using Stalwart's shared secret master authentication protocol
* OpenCloud Groupware with generated token authentication
* the OpenCloud UI client uses APIs from the OpenCloud Groupware backend (and authenticates using OIDC)
* the OpenCloud Groupware backend, in turn, performs JMAP operations with Stalwart, and authenticates against Stalwart using bearer authentication with JWTs that it generates itself
* in the future, that JWT might also be the JWT that the OpenCloud UI used to authenticate against the OpenCloud Groupware in the first place
### Stalwart with the LDAP Directory
```mermaid
flowchart LR
c(client)
s(Stalwart)
l(LDAP)
c -- IMAP/SMTP --> s
c -- JMAP --> s
s -- LDAP --> l
```
Clients authenticate directly against Stalwart, that is configured to use an LDAP authentication Directory.
An LDAP server (e.g. OpenLDAP) is needed as part of the infrastructure.
OpenCloud also has to make use of the same LDAP server.
* ✅ MUAs with basic authentication
* MUAs authenticate directly against Stalwart
* Stalwart's LDAP Directory plugin supports plain text authentication by looking up the userPassword attribute in the LDAP server
* ❌ MUAs with application password authentication
* MUAs authenticate directly against Stalwart
* Stalwart's LDAP Directory plugin does not support application password as it is hardwired to look up the password in the userPassword attribute in the LDAP server
* even if it did support looking up alternative passwords in LDAP, this would hardly be practical as the application passwords are currently created and stored in OpenCloud, which would need to be modified to store them in LDAP in the first place
* ❌ MUAs with SASL bearer token authentication
* MUAs authenticate directly against Stalwart
* Stalwart's LDAP Directory plugin does not support verifying OIDC tokens
* ✅ JMAP clients with basic authentication
* JMAP clients authenticate directly against Stalwart
* Stalwart's LDAP Directory plugin supports plain text authentication by looking up the userPassword attribute in the LDAP server
* ❌ JMAP clients with bearer token authentication
* JMAP clients authenticate directly against Stalwart
* Stalwart's LDAP Directory plugin does not support verifying OIDC tokens
* ✅ OpenCloud Groupware with master authentication
* the OpenCloud Groupware backend authenticates directly against Stalwart
* Stalwart detects and supports clear text password master authentication regardless of the Directory that is being used, and verifies it against the shared secret password that is configured in the server
* ❌ OpenCloud Groupware with generated token authentication
* the OpenCloud Groupware backend authenticates directly against Stalwart
* Stalwart's LDAP Directory plugin does not support verifying OIDC tokens
### Stalwart with the OIDC Directory
```mermaid
flowchart LR
c(client)
s(Stalwart)
o(IDP)
c -- IMAP/SMTP --> s
c -- JMAP --> s
s -- OIDC HTTP --> o
```
Clients authenticate directly against Stalwart, that is configured to use an OIDC authentication Directory.
An OIDC IDP (server) is needed as part of the infrastructure, e.g. KeyCloak.
Optionally, an LDAP server (e.g. OpenLDAP) might be used as well, and KeyCloak would look up users and their credentials in LDAP.
OpenCloud also has to make use of the same LDAP server, or would need to be modified to be capable of only making use of an OIDC IDP (which would include limitations that are yet to be resolved, e.g. the option of using KeyCloak Admin APIs to retreieve groups, group members, ...)
* ❌ MUAs with basic authentication
* MUAs authenticate directly against Stalwart
* Stalwart's OIDC Directory plugin does not support plain text authentication
* ❌ MUAs with application password authentication
* MUAs authenticate directly against Stalwart
* Stalwart's OIDC Directory plugin does not support application passwords
* ❓ MUAs with SASL bearer token authentication
* MUAs authenticate directly against Stalwart
* Stalwart's OIDC Directory plugin does not currently support external IDPs, but is expected to in future versions
* as of Stalwart 0.12, this would only work if Stalwart itself is used as the IDP when acquiring a token
* ❌ JMAP clients with basic authentication
* JMAP clients authenticate directly against Stalwart
* Stalwart's OIDC Directory plugin does not support plain text authentication
* ❓ JMAP clients with bearer token authentication
* JMAP clients authenticate directly against Stalwart
* Stalwart's OIDC Directory plugin does not currently support external IDPs, but is expected to in future versions
* as of Stalwart 0.12, this would only work if Stalwart itself is used as the IDP when acquiring a token
* ✅ OpenCloud Groupware with master authentication
* the OpenCloud Groupware backend authenticates directly against Stalwart
* Stalwart detects and supports clear text password master authentication regardless of the Directory that is being used, and verifies it against the shared secret password that is configured in the server
* ❓ OpenCloud Groupware with generated token authentication
* the OpenCloud Groupware backend authenticates directly against Stalwart
* Stalwart's OIDC Directory plugin does not currently support external IDPs, but is expected to in future versions
* as of Stalwart 0.12, this would only work if Stalwart itself is used as the IDP when acquiring a token, which is not the case with this approach as the tokens are generated by the Groupware backend itself
### Stalwart with the Internal Directory
```mermaid
flowchart LR
c(client)
s(Stalwart)
c -- IMAP/SMTP --> s
c -- JMAP --> s
```
Clients authenticate directly against Stalwart, that is configured to use an Internal authentication Directory.
Neither an OIDC IDP nor an LDAP server are needed as part of the infrastructure, as principal resources (users, groups) and their credentials exist in Stalwart's storage.
OpenCloud would not be capable of accessing those resources, which means that provisioning of groups, users, user passwords must be duplicated and kept in sync between Stalwart and OpenCloud.
* ✅ MUAs with basic authentication
* MUAs authenticate directly against Stalwart
* Stalwart's Internal Directory plugin supports plain text authentication
* ✅ MUAs with application password authentication
* MUAs authenticate directly against Stalwart
* Stalwart's Internal Directory plugin supports application passwords
* users are able to create those themselves using the self-service web UI of Stalwart
* they are not shared with the OpenCloud application passwords though and would need to be provisioned into Stalwart when created in OpenCloud to provide a single UI
* ❌ MUAs with SASL bearer token authentication
* MUAs authenticate directly against Stalwart
* Stalwart's Internal Directory plugin does not support OIDC token authentication
* ✅ JMAP clients with basic authentication
* JMAP clients authenticate directly against Stalwart
* Stalwart's Internal Directory plugin supports plain text authentication
* ❌ JMAP clients with bearer token authentication
* JMAP clients authenticate directly against Stalwart
* Stalwart's Internal Directory plugin does not support OIDC token authentication
* ✅ OpenCloud Groupware with master authentication
* the OpenCloud Groupware backend authenticates directly against Stalwart
* Stalwart detects and supports clear text password master authentication regardless of the Directory that is being used, and verifies it against the shared secret password that is configured in the server
* ❌ OpenCloud Groupware with generated token authentication
* the OpenCloud Groupware backend authenticates directly against Stalwart
* Stalwart's Internal Directory plugin does not support OIDC token authentication
### Stalwart with the OpenCloud Authentication API
```mermaid
flowchart LR
c(client)
s(Stalwart)
o(OpenCloud)
l(LDAP)
c -- IMAP/SMTP --> s
c -- JMAP --> s
s -- REST --> o
o -- LDAP --> l
```
Clients authenticate directly against Stalwart, that is configured to use an "External" authentication Directory, that is yet to be developed. (warning)
Its protocol is currently not defined, but not particularly relevant at this time, as long as it supports accepting basic and bearer authentication in order to authenticate both username and password credentials as well as OIDC tokens.
That External Directory implementation forwards the basic or bearer credentials to an endpoint in the OpenCloud backend, that the responds with whether the authentication is successful or not, as well as with additional information that is needed for Stalwart (email address, display name, groups, roles, ...)
* ✅ MUAs with basic authentication
* MUAs authenticate directly against Stalwart
* Stalwart's External Directory supports plain text authentication by relaying the authentication operation to the OpenCloud backend, which can then authenticate users by username and password using an LDAP server
* note that this option requires having an LDAP server in the environment, including having it accessible by OpenCloud
* if that is not the case, then a viable option is also to support OIDC tokens and application passwords
* to clarify: this scenario is only about supporting authentication using the "primary" username and password
* ✅ MUAs with application password authentication
* MUAs authenticate directly against Stalwart
* Stalwart's External Directory supports application password authentication by relaying the authentication operation to the OpenCloud backend, which can then authenticate against its list of application passwords
* this is the ideal scenario for application passwords, since they are already supported by OpenCloud, and can be created and managed using the OpenCloud UI
* relaying the authentication operation to OpenCloud also prevents the need for duplicate provisioning of application passwords
* ✅ MUAs with SASL bearer token authentication
* MUAs authenticate directly against Stalwart
* Stalwart's External Directory supports OIDC token authentication by relaying the authentication operation to the OpenCloud backend, which can then either perform local token inspection and authentication by verifying the token's signature, or use the OIDC IDP's token introspection endpoint
* ✅ JMAP clients with basic authentication
* JMAP clients authenticate directly against Stalwart
* Stalwart's External Directory supports plain text authentication by relaying the authentication operation to the OpenCloud backend, which can then authenticate users by username and password using an LDAP server
* the same limitations/requirements as for the "MUAs with basic authentication" scenario apply here as well
* ✅ JMAP clients with bearer token authentication
* MUAs authenticate directly against Stalwart
* Stalwart's External Directory supports OIDC token authentication by relaying the authentication operation to the OpenCloud backend, which can then either perform local token inspection and authentication by verifying the token's signature, or use the OIDC IDP's token introspection endpoint
* ✅ OpenCloud Groupware with master authentication
* the OpenCloud Groupware backend authenticates directly against Stalwart
* Stalwart detects and supports clear text password master authentication regardless of the Directory that is being used, and verifies it against the shared secret password that is configured in the server
* ✅ OpenCloud Groupware with generated token authentication
* the OpenCloud Groupware backend authenticates directly against Stalwart
* in the worst case, the External Directory plugin in Stalwart would also perform a forwarding of the authentication operation to OpenCloud, which would obviously be able to verify a token it has created
* an optimization might be possible here, if the External Directory implementation permits for the configuration of specific issuers which should then be verifying against a JWK set directly, whereas the fallback behaviour would be to query the OpenCloud Authentication API
### Stalwart with Nauthilus and LDAP
```mermaid
flowchart LR
c(client)
s(Stalwart)
n(Nauthilus)
l(LDAP)
c -- IMAP/SMTP --> s
c -- JMAP --> s
s -- REST --> n
n -- LDAP --> l
```
In this scenario, we introduce the [Nauthilus authentication service](https://nauthilus.org/), which has its own API but also a KeyCloak integration plugin.
It supports various backends and can also be scripted for more complex combinations.
⚠️ It would require the implementation of a Stalwart Nauthilus Directory, **that is yet to be developed**.
We do not make use of any OpenCloud Authentication API but, instead, attempt to have everything go through Nauthilus instead, backed by an LDAP server that then contains the users, groups, and user passwords.
The upside of using Nauthilus is that it does brute force attack detection and can provide metrics across multiple protocols and clients in a centralized fashion.
* ✅ MUAs with basic authentication
* MUAs authenticate directly against Stalwart
* Stalwart's Nauthilus Directory supports plain text authentication by relaying the authentication operation to Nauthilus, e.g. using its JSON API
* Nauthilus provides a response that contains user attributes from LDAP (display name, email addresses, ...)
* ❓ MUAs with application password authentication
* Nauthilus has no support for application passwords in itself
* a Lua plugin could potentially be used in Nauthilus to detect whether the clear text password matches a regular expression for application passwords and, if that is the case, first attempt to verify it through an API call (that does not exist yet) to the OpenCloud backend, but that would definitely be more complex and less elegant than having a single API
* ❓ MUAs with SASL bearer token authentication
* it is currently unclear whether Nauthilus supports OIDC token authentication
* ✅ JMAP clients with basic authentication
* Stalwart's Nauthilus Directory supports plain text authentication by relaying the authentication operation to Nauthilus, e.g. using its JSON API
* Nauthilus provides a response that contains user attributes from LDAP (display name, email addresses, ...)
* ❓ JMAP clients with bearer token authentication
* it is currently unclear whether Nauthilus supports OIDC token authentication
* ✅ OpenCloud Groupware with master authentication
* the OpenCloud Groupware backend authenticates directly against Stalwart
* Stalwart detects and supports clear text password master authentication regardless of the Directory that is being used, and verifies it against the shared secret password that is configured in the server
* ❓ OpenCloud Groupware with generated token authentication
* the OpenCloud Groupware backend authenticates directly against Stalwart
* it is currently unclear whether Nauthilus supports OIDC token authentication
* an optimization might be possible here, if the Nauthilus Directory implementation permits for the configuration of specific issuers which should then be verifying against a JWK set directly, whereas the fallback behaviour would be to query the Nauthilus API, but that does sound like a stretch to fit into the concept
### Stalwart with Nauthilus and an OpenCloud Authentication API
```mermaid
flowchart LR
c(client)
j(client)
s(Stalwart)
n(Nauthilus)
o(OpenCloud)
l(LDAP)
k(Keycloak)
c -- IMAP/SMTP --> s
j -- JMAP --> s
s -- REST --> n
subgraph internal auth
n -- REST --> o
o -- LDAP --> l
o -- OIDC --> k
end
```
This option also makes use of the [Nauthilus authentication service](https://nauthilus.org/), but instead of it using LDAP to resolve users, we would either make use of its Lua scripting abilities to implement a backend that performs HTTP calls to an OpenCloud Authentication API, or implement an additional Nauthilus backend that uses the Nauthilus API to delegate to another instance, which would then be the OpenCloud Authentication API with support for the Nauthilus API.
⚠️ As with the previous option, it would require the implementation of a Stalwart Nauthilus Directory, **that is yet to be developed**.
Interestingly, if the OpenCloud Authentication API follows the Nauthilus API, this scenario can easily be degraded by dropping Nauthilus and, instead, having all services talk to the OpenCloud Authentication API directly.
* ✅ MUAs with basic authentication
* MUAs authenticate directly against Stalwart
* Stalwart's Nauthilus Directory supports plain text authentication by relaying the authentication operation to Nauthilus, e.g. using its JSON API
* Nauthilus provides a response that contains user attributes from LDAP (display name, email addresses, ...)
* ✅ MUAs with application password authentication
* Nauthilus would forward the authentication request to the OpenCloud Authentication API, which would support application passwords
* ❓ MUAs with SASL bearer token authentication
* it is currently unclear whether Nauthilus supports OIDC token authentication and whether it would be able to forward such requests to the OpenCloud Authentication API
* ✅ JMAP clients with basic authentication
* Stalwart's Nauthilus Directory supports plain text authentication by relaying the authentication operation to Nauthilus, e.g. using its JSON API
* Nauthilus then forwards that request to the OpenCloud Authentication API
* the OpenCloud Authentication API, and then Nauthilus, provides a response that contains user attributes from LDAP (display name, email addresses, ...) or claims from the JWT
* ❓ JMAP clients with bearer token authentication
* it is currently unclear whether Nauthilus supports OIDC token authentication and whether it would be able to forward such requests to the OpenCloud Authentication API
* ✅ OpenCloud Groupware with master authentication
* the OpenCloud Groupware backend authenticates directly against Stalwart
* Stalwart detects and supports clear text password master authentication regardless of the Directory that is being used, and verifies it against the shared secret password that is configured in the server
* ❓ OpenCloud Groupware with generated token authentication
* the OpenCloud Groupware backend authenticates directly against Stalwart
* it is currently unclear whether Nauthilus supports OIDC token authentication and whether it would be able to forward such requests to the OpenCloud Authentication API
> [!IMPORTANT]
> We need to clarify whether the Nauthilus API allows for a JWT to be submitted for the authentication request, and not only username and password not to secure the request in itself, but to forward an OIDC token based authentication attempt as part of the payload.
### Comparing Options
| | MUA basic | MUA app password | MUA sasl | JMAP clients with basic auth | JMAP clients with JWT auth | Groupware Middleware with master auth | Groupware Middleware with JWT auth |
| --- | --- | --- | --- | --- | --- | --- | --- |
| Stalwart 0.12 with LDAP Directory | ✅ MUA → Stalwart | ❌ not supported with LDAP | ❌ not supported with LDAP | ✅ | ❌ | ✅ | ❌ |
| Stalwart 0.12 with OIDC Directory | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ | ❌ |
| Stalwart 0.12 with Internal Directory | ✅ MUA → Stalwart, must be provisioned in Stalwart | ✅ MUA → Stalwart, must be provisioned in Stalwart | ❌ | ❌ | ❌ unless using Stalwart as IDP | ✅ | ❌ |
| Stalwart + OpenCloud Authentication API | ✅ MUA → Stalwart → OpenCloud | ✅ MUA → Stalwart → OpenCloud | ✅ MUA → Stalwart → OpenCloud | ✅ MUA → Stalwart → OpenCloud | ✅ MUA → Stalwart → OpenCloud | ✅ | ✅ |
| Stalwart + Nauthilus + LDAP | ✅ MUA → IMAP proxy → Nauthilus → LDAP | ❌ | ❌ | ✅ | ❌ | ✅ | ❌ |
| Stalwart + Nauthilus + OpenCloud Authentication API | ✅ MUA → IMAP proxy → Nauthilus → OpenCloud | ✅ MUA → IMAP proxy → Nauthilus → OpenCloud | ✅ MUA → IMAP proxy → Nauthilus → OpenCloud | ✅ MUA → IMAP proxy → Nauthilus → OpenCloud | ✅ MUA → IMAP proxy → Nauthilus → OpenCloud | ✅ | ✅ |
| Stalwart + Nauthilus-like OpenCloud Authentication API | ✅ MUA → Stalwart → OpenCloud | ✅ MUA → Stalwart → OpenCloud | ✅ MUA → Stalwart → OpenCloud | ✅ MUA → Stalwart → OpenCloud | ✅ MUA → Stalwart → OpenCloud | ✅ | ✅ |

View File

@@ -0,0 +1,64 @@
---
status: proposed
date: 2025-06-24
author: Pascal Bleser <p.bleser@opencloud.eu>
decision-makers:
consulted:
informed:
title: "Implementing Groupware as a separate Microservice vs integrated in the OpenCloud Stack"
template: https://raw.githubusercontent.com/adr/madr/refs/tags/4.0.0/template/adr-template.md
---
* Status: draft
## Context
Should the Groupware backend be an independent microservice or be part of the OpenCloud single binary framework?
The OpenCloud backend is built on a framework that
* implements token based authentication between services
* allows for a "single binary" deployment mode that runs all services within that one binary
* integrates services such as a NATS event bus
This decision is about whether the Groupware backend service should be implemented within that framework or, instead, be implemented as a standalone backend service.
## Decision Drivers
* single binary deployment strategy is potentially important (TODO how important is it really? stakeholders:?)
## Considered Options
* have the Groupware Middleware as an independent microservice
* have the Groupware Middleware implemented within the existing OpenCloud framework
## Decision Outcome
TODO
### Consequences
TODO
### Confirmation
TODO
## Pros and Cons of the Options
### Independent Microservice
* (potentially) good: be free from technical decisions made for the existing OpenCloud stack, to avoid carrying potential technical baggage
* (potentially) good: make use of a framework that is more fitting for the tasks the Groupware backend needs to accomplish
* bad: re-implement framework components that already exist, with the need to maintain those in two separate codebases, or the added complexity of a shared library repository
* bad: not have the ability to include the Groupware backend in the single binary deployment
* neutral: a separate code repository and delivery for the Groupware backend, which might or might not be of advantage
* neutral: may be implemented on a completely different technology stack, including the programming language
### Part of the framework
* good: fit into the opinionated choices that were made for the OpenCloud framework so far
* good: many aspects are already implemented in the current framework and can be made use of, potentially enhanced for the needs of the Groupware backend
* good: the ability to include the Groupware backend in the single binary deployment
* neutral: be in the same code repository and part of the same delivery as other services in OpenCloud
* neutral: must be implemented in Go on top of the same technology stack

View File

@@ -0,0 +1,294 @@
---
status: proposed
date: 2025-06-24
author: Pascal Bleser <p.bleser@opencloud.eu>
decision-makers:
consulted:
informed:
title: "Resource Linking"
template: https://raw.githubusercontent.com/adr/madr/refs/tags/4.0.0/template/adr-template.md
---
* Status: draft
## Context
Which semantic and technical approach to take in order to provide strong integration of the various products and capabilities of OpenCloud, OpenTalk, and potentially other products as well?
## Decision Drivers
* a strong integration that allows users to access resources and relationships without having to switch views, which translates into a "mental switch" as well
* an innovative approach that differs from the traditional way groupware applications have been designed in the past
* TODO more decision drivers from PM
* a model that is open and generic enough to integrate many different types of resources and relationships
* a model that allows for independent and incremental upgrades to the resources and relationships that can be contributed by each service
## Considered Options
* resource linking
* application launchers
* TODO? can we come up with more ideas?
## Decision Outcome
TODO
### Consequences
TODO
### Confirmation
TODO
## Pros and Cons of the Options
### Resource Linking
This concept primarily resides on the idea of having resources, which have attributes, and relations between them, pretty much as [RDF (Resource Description Framework)](https://www.w3.org/RDF/) does, where the Groupware backend provides services to explore relations of a given resource.
* good: decoupling of UI, backends as well as other participants, as backends can gradually evolve the relationships and resources they understand and can contribute to over time, as well as for the UI that may just silently ignore resources it does not support yet or does not want to present to the user
* good: potential for an asynchronous architecture that would enable the UI to present some resources early without having to wait for those that require more processing time or are provided by services that happen to be under heavier load
* good: it should provide ammunition for a modern and original UI that is centered around resources and relationships rather than the usual visual paradigms
* bad: it might be a challenge to implement this approach in a performant way with rapid response times, as it could cause additional complexity and storage services (e.g. to denormalize reverse indexes, cache expensive resource graphs, etc...)
#### URNs
Each resource has a unique identifier, for which [URNs (Uniform Resource Names)](https://www.rfc-editor.org/rfc/rfc1737) seem the best representation.
URNs are composed of
* a namespace identifier
* a namespace-specific string
As a convention, we will use the following:
<table>
<thead>
<tr>
<th><code>urn:</code></th>
<th>ns</th>
<th colspan="2">namespace specific string</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>urn:</code></td>
<td><code>oc:</code></td>
<td><code>&lt;type&gt;:</code></td>
<td><code>&lt;unique identifier&gt;:</code></td>
</tr>
</tbody>
</table>
##### Examples
<table>
<thead>
<tr>
<th><code>urn:</code></th>
<th>namespace</th>
<th>type</th>
<th>unique id</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>urn:</code></td>
<td><code>oc:</code></td>
<td><code>user:</code></td>
<td><code>camina.drummer</code></td>
</tr>
<tr>
<td><code>urn:</code></td>
<td><code>oc:</code></td>
<td><code>contact:</code></td>
<td><code>klaes.ashford</code></td>
</tr>
<tr>
<td><code>urn:</code></td>
<td><code>oc:</code></td>
<td><code>event:</code></td>
<td><code>dd4ea520-e414-41e1-b545-b1c7d4ce57e7</code></td>
</tr>
<tr>
<td><code>urn:</code></td>
<td><code>oc:</code></td>
<td><code>mail:</code></td>
<td><code>&lt;1e8074e8-cd56-4358-9f9e-f17cb701b950@opa.org&gt;</code></td>
</tr>
</tbody>
</table>
#### Exploration API
Whenever the user puts a resource into focus in the OpenCloud Groupware UI (i.e. by selecting/clicking that resource, e.g. the sender of an email), it may send a request to the Groupware service API to inquire about related resources.
What those related resources are still stands to be determined, but examples could be along the lines of
* unread emails from the same sender
* emails exchanged with that sender in the last 7 days
* files recently shared with that user
* spaces or groups in common with that user
* OpenTalk meetings planned within the next 3 days
In order to decouple the Groupware service from which resources and relations are supported,
* whenever such an exploration request is received, the Groupware service forwards it to all known services, in a "fan-out" model
* each service can understand the focused resource, or not, but if it does it may return related resources that it is capable of providing using its data model (e.g. OpenTalk providing related meeting resources, OpenCloud Groupware providing related calendar events, contacts, mails, etc...)
* ideally, that happens in an asynchronous fashion, using e.g. [SSE (Server Side Events)](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events) to push results to the OpenCloud UI to avoid having to wait for the slowest contributor, although that pushes the "reduce" part of this ["map-reduce" operation](https://en.wikipedia.org/wiki/MapReduce) to the client
```mermaid
graph LR
c(client)
subgraph backend
a(opencloud api)
g(groupware)
ot(opentalk)
u(users)
s(stalwart)
k(keycloak)
end
subgraph storage
ots@{ shape: cyl, label: "opentalk\nstorage"}
ss@{ shape: cyl, label: "stalwart\nstorage"}
l@{ shape: cyl, label: "ldap"}
end
c-->|/related/urn:oc:user:camina.drummer|a
a-->|/related/urn:oc:user:camina.drummer|g
g-->s
s-->ss
a-->|/related/urn:oc:user:camina.drummer|ot
ot-->ots
a-->|/related/urn:oc:user:camina.drummer|u
u-->k
k-->l
ot-.->|urn:oc:meeting:232403bc-b98f-4643-a917-80bdcfc7aaba|a
g-.->|urn:oc:event:e5193ad3-8f1c-4162-8593-69fe659bcc08|a
```
This allows a decoupling of all the participants, enabling each service to add, remove or alter relationships that it is able to contribute for a given resource type.
Obviously, the UI needs to be able to understand resource types to know how to represent them, but if it silently ignores resource types that it does not know of, backends can evolve independently from the UI.
#### JSON-LD
[JSON-LD (JSON for Linking Data)](https://json-ld.org/) seems like a potent representation format for those relationships in a REST environment.
It could look something like this:
```json
{
"@context": {
"@user": "https://schema.opencloud.eu/user.jsonld",
"link": "https://schema.opencloud.eu/linked.jsonld"
},
"@type": "urn:oc:type:user",
"@id": "urn:oc:user:cdrummer",
"name": "Camina Drummer",
"email": "camina@opa.org",
"roles": ["admin", "pirate"],
"link:rooms": [
{
"@context": {
"@room": "https://meta.opencloud.eu/room.jsonld",
"link": "https://schema.opencloud.eu/linked.jsonld"
},
"@id": "urn:oc:room:a3f19df6-6c7d-45fa-b16c-6e168e2a2a43",
"name": "OPA Leadership Standup 2355-02-27",
"start": "2355-02-27T10:58:15.918Z",
"end": "2355-02-27T13:52:59.010Z",
"started_by": {
"@context": "https://meta.opencloud.eu/user.jsonld",
"@type": "urn:oc:type:user",
"@id": "urn:oc:user:adawes",
"name": "Anderson Dawes",
"email": "anderson@opa.org"
},
"link:events": [
{
"@context": "https://meta.opencloud.eu/event.jsonld",
"@type": "urn:oc:type:event",
"@id": "urn:oc:event:3e041c88-088c-4015-a32e-5560561f6e26",
"start": "2355-02-27T11:09:15.918Z",
"end": "2355-02-27T13:52:59.010Z",
"status": "confirmed",
"invited": [
{
"@context": "https://meta.opencloud.eu/user.jsonld",
"@type": "urn:oc:type:user",
"@id": "urn:oc:user:adawes",
"name": "Anderson Dawes",
"email": "anderson@opa.org"
},
{
"@context": "https://meta.opencloud.eu/user.jsonld",
"@type": "urn:oc:type:user",
"@id": "urn:oc:user:kashford",
"name": "Klaes Ashford",
"email": "klaes@opa.org"
}
]
}
],
"members": [
{
"@context": "https://meta.opencloud.eu/contact.jsonld",
"@type": "urn:oc:type:contact",
"@id": "urn:oc:contact:9ccb247d-a728-4d8f-9259-c28cf6cef567",
"name": "Naomi Nagata",
"email": "naomo@opa.org"
},
{
"@context": "https://meta.opencloud.eu/user.jsonld",
"@type": "urn:oc:type:user",
"@id": "urn:oc:user:adawes",
"name": "Anderson Dawes",
"email": "anderson@opa.org"
},
{
"@context": "https://meta.opencloud.eu/user.jsonld",
"@type": "urn:oc:type:user",
"@id": "urn:oc:user:kashford",
"name": "Klaes Ashford",
"email": "klaes@opa.org"
}
],
"chat": {
"@context": "https://meta.opencloud.eu/file.jsonld",
"@type": "urn:oc:type:file",
"@id": "urn:oc:file:OPA:chatlogs/2355/02/27/a3f19df6-6c7d-45fa-b16c-6e168e2a2a43.md",
"href": "https://cloud.opencloud.eu/spaces/OPA/chatlogs/2355/02/27/a3f19df6-6c7d-45fa-b16c-6e168e2a2a43.md"
}
}
],
"link:mails": [
{
"@context": "https://meta.opencloud.eu/mail.jsonld",
"@type": "urn:oc:type:mail",
"@id": "583b9b66-c0b3-41ba-bf6c-a02ec5f4a638@smtp-07.opa.org",
"subject": "About bosmang Fred Johnson",
"date": "2355-01-03T09:39:44.919Z"
},
...
],
"link:shares": [
{
"@context": "https://meta.opencloud.eu/share.jsonld",
"@type": "urn:oc:type:share",
"@id": "841ef259-584d-4ce6-827f-b53f900c988d",
"filename": "remember the cant.jpg"
}
]
}
```
### Application Launchers
Have a UI that is comprised of multiple more-or-less separate applications, with an application launcher bar, with each application being an icon in itself in that launcher.
Similar to what e.g. Google does, or Open-Xchange App Suite.
* bad: does not make for an integrated application paradigm since users still have to context switch between those applications/views to perform tasks

View File

@@ -0,0 +1,62 @@
---
status: proposed
date: 2025-06-25
author: Pascal Bleser <p.bleser@opencloud.eu>
decision-makers:
consulted:
informed:
title: "Groupware Software Stack"
template: https://raw.githubusercontent.com/adr/madr/refs/tags/4.0.0/template/adr-template.md
---
* Status: draft
## Context
Which software stack to choose for the implementation of the OpenCloud Groupware service?
## Considered Options
* [Go](https://go.dev/) with the OpenCloud framework, as it is used in OpenCloud
* Rust, as it is a similarly modern language, with know-how in Opentalk
* Java with an opinionated microservice framework (e.g. [Micronaut](https://micronaut.io/))
## Decision Outcome
The decision was taken to go with the existing Go technology stack used in OpenCloud, since it allows for
* everyone in the Groupware backend team to contribute
* having a single technology stack across all OpenCloud backend features
* having the option of a single binary deployment
### Consequences
TODO
### Confirmation
TODO
## Pros and Cons of the Options
### Go
* good: established in the OpenCloud team, with expertise, potentially broadening the team that can contribute to Groupware development
* good: make use of the existing infrastructure and framework, including the single binary deployment option
* bad: less mature and capable technology stack, potentially problematic with regards to lack of asynchronous I/O and streamed HTTP processing
### Rust
* good: shared knowledge with the team of developers at OpenTalk
* bad: little to no experience in the current OpenCloud team
### Java
* bad: little to no experience in the current OpenCloud team, with exception of the Groupware members
* good: extensive experience with Micronaut with one OpenCloud developer
* good: opinionated and well documented
* good: cloud native
* good: mature technology stack
* good: asynchronous I/O and virtual threads make for efficient resource usage
* potentially bad: likely to not fit well into low resource environments (although native compilation using GraalVM is possible)
* potentially bad: prevents the single binary deployment option from including Groupware

View File

@@ -0,0 +1,73 @@
---
status: proposed
date: 2025-06-25
author: Pascal Bleser <p.bleser@opencloud.eu>
decision-makers:
consulted:
informed:
title: "Stalwart as Groupware Backend"
template: https://raw.githubusercontent.com/adr/madr/refs/tags/4.0.0/template/adr-template.md
---
* Status: draft
## Context
Which Groupware backend should be used?
## Considered Options
* [Stalwart](https://stalw.art/), contains not only mail but also collaborative features in an integrated package
* traditional IMAP/POP/SMTP stacks (e.g. [Dovecot](https://www.dovecot.org/) + [Postfix](https://www.postfix.org/))
## Decision Outcome
The decision was made to go with Stalwart, as it reduces the implementation effort on our end, allowing us for a much faster time-to-market with a significantly smaller team of developers.
### Consequences
We will most probably not need to develop much of a calendar or contact stack ourselves, as Stalwart is planning to implement those as part of the upcoming [JMAP](https://jmap.io/spec-core.html) specifications for [contacts](https://jmap.io/spec-contacts.html) and [calendars](https://jmap.io/spec-calendars.html).
The Groupware API will largely consist of a translation of high-level operations for the UI into JMAP operations sent to Stalwart.
#### Risks
On the flip side, there are a number of risks associated with that decision.
* Stalwart underdelivers on its promises
* calendaring provides insufficient features for our implementation (e.g. event series handling being too basic)
* not scaling for large deployments
* necessary adaptations (e.g. for authentication integration) are rejected upstream
* etc...
### Confirmation
TODO
## Pros and Cons of the Options
### Stalwart
* good: integrated package that contains IMAP/POP, SMTP, anti-spam, AI, encryption at rest and many other features in one
* good: modern stack
* good: capable of fault tolerance in large deployments through its use of [FoundationDB](https://www.foundationdb.org/)
* bad: relatively new project with few to no large scale productive deployments (yet)
* bad: significant human [SPoF](https://en.wikipedia.org/wiki/Single_point_of_failure)/[bus factor](https://en.wikipedia.org/wiki/Bus_factor) issue as the development team currently consists of one
* good: supports and drives the JMAP protocol ([JMAP Core](https://jmap.io/spec-core.html), [JMAP Mail](https://jmap.io/spec-mail.html), [JMAP Contacts](https://jmap.io/spec-contacts.html), [JMAP Calendars](https://jmap.io/spec-calendars.html), [JMAP Tasks](https://jmap.io/spec-tasks.html), ...),
* which provides more high-level operations that we don't need to implement ourselves,
* as well as a much cleaner specification that reduces efforts too,
* and additionally can be implemented with an efficient stateless HTTP I/O stack
* bad: no viable broad JMAP implementation alternatives in case Stalwart does not deliver ([Apache James](https://james.apache.org/) only seems to support a basic subset of JMAP)
* good: implements a lot of Groupware "business logic" on its own, reducing the implementation effort on our end,
* most notably by not having to deal with IMAP extensions and quirks,
* or the complexity of calendar events
### IMAP/SMTP
* good: there are a number of alternatives in case a specific implementation does not deliver
* good: the best implementation candidates are well-established, used in large amounts of productive deployments, supported by teams of developers
* bad: more complex stack composed of numerous components as opposed to an all-in-one implementation
* bad: the effort and complexity of having to deal with IMAP,
* its complexity due to its extensions and its many quirks,
* as well as a significantly less efficient I/O stack that requires stateful session handling
* bad: requires the complete implementation of contacts, calendars and tasks in our own stack, as none of those services are provided by IMAP/SMTP backends

View File

@@ -0,0 +1,512 @@
---
status: accepted
date: 2025-07-22
author: pbleser-oc
consulted: AlexAndBear, butonic, dragotin, fschade, JammingBen, kulmann, martinherfurth, micbar, rhafer
title: "API for the Groupware Web UI"
---
<!-- markdownlint-disable-file MD024 MD033 -->
## Context
We need a comprehensive HTTP API for the OpenCloud Web UI to provide access to the following (upcoming) modules and Groupware functionalities:
* Mail
* Contacts
* Calendar
* Tasks
* Chat
* Configuration
```mermaid
graph LR
subgraph clients
ui(OpenCloud UI)
muas(Other<br>MUAs)
end
subgraph Backend
subgraph OpenCloud
direction TB
groupware("OpenCloud<br>Groupware")
drive("OpenCloud<br>Drive")
end
stalwart(Stalwart)
end
subgraph Storage
drive_storage[(drive<br>storage)]
stalwart_metadata[(metadata<br>storage)]
stalwart_storage[(object<br>storage)]
end
ui x@==>|?|groupware
x@{ animate: true }
ui-->|Graph|drive
muas-->|IMAP,SMTP,*DAV|stalwart
groupware-->drive
groupware-->|JMAP|stalwart
drive-->drive_storage
stalwart-->stalwart_metadata
stalwart-->stalwart_storage
```
Additionally, the API must also be able to provide information about related resources and their relationships, as outlined in [the Resource Linking ADR](./0003-groupware-resource-linking.md).
For the OpenCloud Drive services, the communication between UI client and backend services is performed via the [LibreGraph API](https://github.com/opencloud-eu/libre-graph-api), which is based on [Microsoft Graph](https://developer.microsoft.com/en-us/graph). The goal of this ADR is **not** to question or change that decision, and the choice of an option is merely for the communication with the Groupware backend.
Communication between the OpenCloud Groupware and Stalwart will make use of the [JMAP (JSON Meta Application Protocol) protocol](https://jmap.io/spec-mail.html).
The API for the OpenCloud Web UI is **not** supposed to be an abstraction of that and thus may use JMAP data formats.
Other [MUAs (Mail User Agents)](https://en.wikipedia.org/wiki/Email_client) converse directly with Stalwart using [IMAP](https://en.wikipedia.org/wiki/Internet_Message_Access_Protocol) or [POP3](https://en.wikipedia.org/wiki/Post_Office_Protocol), [SMTP](https://en.wikipedia.org/wiki/Simple_Mail_Transfer_Protocol), [CalDAV](https://en.wikipedia.org/wiki/CalDAV), [CardDAV](https://en.wikipedia.org/wiki/CardDAV), or JMAP itself.
This ADR concerns the decision regarding which API approach/process/technology/specification to use, not the details of the data model and such, which will need to be fleshed out following the requirements and priorities of the OpenCloud UI Client development, regardless of the selected approach.
## Decision Drivers
### UI Driven
The decision must be significantly driven by the OpenCloud UI Client developers, since they are the primary consumers of the API.
They will also be the sole consumers for a foreseeable while until the OpenCloud Groupware UI reaches a stable feature-complete milestone, which is the earliest point in time for the APIs to be considered stable as well and potentially be consumed by third parties.
Backend developers are stakeholders in that aspect as well though, as the choice of API approach has an impact on the complexity, costs and maintainability of the backend services as well.
### Economic Awareness
Reduction of complexity and implementation efforts, albeit not at all costs, and not only on the short run.
It is obviously of advantage when an option requires less implementation, or less complexity in its implementation.
### Efficiency
Regarding efficiency, the goal is to design an API that is tailored to providing responsiveness ([pagination](https://apisyouwonthate.com/blog/api-design-basics-pagination/), [SSEs (Server-Side Events)](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events), ...) and good network performance.
The latter is achieved by minimizing the number of roundtrips between the client and the servers, which, in turn, is typically achieved through the use of higher level APIs as opposed to a granular API that provides more flexibility but also, by its very nature, requires the combination of multiple request-response roundtrips over the wire.
### Third Party Consumption
We are assuming that the APIs are public APIs (not just technically) and may be consumed by SDKs and third parties.
Implications are that care must be put into providing an API that is stable, versioned, has a changelog, and potentially provided as a product with [LTS (Long-term Support)](https://en.wikipedia.org/wiki/Long-term_support) options.
This also hints at the necessity of a capability exchange/discovery protocol between clients and the Groupware backend, as we will have different versions of clients and servers in the wild, and they need to be able to understand each other. Crucially, if locally running clients are developed, they can go a long time without being updated.
## Considered Options
* [LibreGraph](#libregraph)
* [JMAP](#jmap)
* [custom REST API](#custom-rest-api) (albeit potentially based on standards, at least partially)
## Decision Outcome
The decision was made to go with the custom REST implementation option, mainly due to
* the use of LibreGraph providing little benefits
* if would provide us with a fleshed out API for groupware
* but we would not implement it fully
* and it is really an API for Outlook and Exchange, not a generic groupware standard
* furthermore, a significant blocker is that it does not provide for a way to support multiple accounts for a user
* the experience of implementing and using the LibreGraph API for the Drive components has made light of some challenges that we would not like to repeat
* using JMAP directly
* is a very interesting option in terms of standards, as it is an RFC,
* but we currently see that approach as too risky as per the potential complexity of parsing payloads of JMAP commands and their backreferences, plugging those across commands that must be forwarded as-is to Stalwart and others that need to be handled by the Groupware middleware itself, but also the potential need to reverse engineer the high-level meaning of chained low-level JMAP commands in order to implement enrichment, caches, reverse indexes, etc...
* however, it might be a better path forward in the future, especially if JMAP becomes a viable option for replacing the current use of LibreGraph as well
### Consequences
* we will need to design an API on our own, from scratch, albeit maximally making use of JMAP data structures
* that API will need to be maintained as a product, with documentation, versioning, LTS
## Pros and Cons of the Options
* [LibreGraph](#proscons-libregraph)
* [JMAP](#proscons-jmap)
* [Custom REST API](#proscons-custom)
### <a id="proscons-libregraph"/>LibreGraph
[LibreGraph](https://github.com/opencloud-eu/libre-graph-api) is an API specification that is heavily inspired by and based on [Microsoft Graph](https://developer.microsoft.com/en-us/graph), of which it is a partial implementation, but also with modifications where necessary.
Example:
```text
GET /v1.0/me/messages?$select=sender,subject&$count=50&$orderby=received
```
#### Good
* is already in use as the API for OpenCloud Drive operations, with a small stack to use it in the OpenCloud Web UI
* provides an API and data model that has already been thought out and used in production (albeit with only few different implementations)
#### Neutral
* does not have to follow the Microsoft Graph API, can be customized to our own needs, but in which case it becomes doubtful that there is any benefit in mimicking the Graph API in the first place if we diverge from it
* there is no compatibility benefit
* the only MUA that uses the Microsoft Graph API is Microsoft Outlook, and it is not a goal to support Microsoft Outlook as a MUA beyond standard IMAP/SMTP/CalDAV/CardDAV services (and that would be Microsoft Graph, not LibreGraph nor any customizations we would require)
* we will not implement all of the Microsoft Graph API
* we will not implement parts of the Microsoft Graph API as-is either, but will require to make modifications
* if there is a requirement for considering that API as a public API for third party integrators, then the API also needs to be documented, maintained, versioned, and kept stable as much as possible (this is neutral because it is a requirement that exists with every option)
#### Bad
* not an easy API to implement
* although we have libraries that take care of some of the more complex parts, such as parsing [OData](https://www.odata.org/) expressions
* really only easy to use when backed by a relational database and an object relational mapping framework using [ASP.NET](https://dotnet.microsoft.com/en-us/apps/aspnet) or [JPA](https://en.wikipedia.org/wiki/Jakarta_Persistence)/[Hibernate](https://hibernate.org/)
* its data model and peculiar interpretation of REST are really not [idomatic](https://en.wikipedia.org/wiki/HATEOAS) at all, and are clearly the result of reverse engineering the capabilities of Microsoft SQL Server and Exchange into a "standard" from the back, and then Microsoft Outlook's features and capabilities from the front
* not tailored to our needs
* we will most probably have a lot of cases in which we have to twist the Graph API to express what the UI needs
* will require using complex filters, which then require complex parsing in the backend in order to translate them into JMAP
* as opposed to directly using an expressive and maximally matching API in the first place
* we are likely to encounter use-cases that are not covered by the Graph API (especially due to our resource linking approach)
* does not support multiple accounts per user
* would require the addition of an account parameter, as a query parameter or as part of the path, which would make every URL in the API incompatible with Microsoft Graph
* more implementation effort than JMAP
* the JMAP RFCs already provides a data model, and we would end up converting between them all the time, with incompatibilities (Graph has attributes JMAP doesn't, and the other way around)
* possibly (probably?) more implementation effort than a custom REST API, due to its complexity
#### Decision Drivers
* UI Driven
* some members the OpenCloud Web Team strongly prefers not to use LibreGraph due to its complexity and to the fact that we would have to reftrofit operations into an existing API that was designed by a third party
* one upside is that there is already a client stack for performing LibreGraph operations, which could be reused to some degree for the Groupware APIs as well; it does not amount to all that much code though
* Economic Awareness
* more complexity and more effort as the other options due to the inherent complexity of the specification
* a data model is already specified in full, which might save us some time on that front
* although probably not really either since the actual data model we will work with on the backend is prescribed by JMAP, and we will only be looking to map attributes betsween JMAP and LibreGraph
* the data model is not necessarily thorougly documented either, which will leave room for interpretation, also due to incompatibilities between JMAP and Graph
* there will be attributes that are defined in JMAP and that we will receive from Stalwart that will not have a corresponding attribute in Graph (or be a list of values as opposed to a single value), and those will require to either lose some data by squashing it into the Graph data model, or extending the Graph data model which renders us incompatible with it
* Efficiency
* since the API is not tailored to our needs, we are much more likely to end up performing multiple roundtrips for single high level operations
* Third Party Consumption
* for some of the operations, we could point to the Microsoft Graph documentation, although that would not make for a great experience either, we would probably need to replicate it
* our deviations and extensions will have to be maintained just like the other options
* LibreGraph doesn't help with API stability either since
* we don't implement all of it, and need to document what we implement and what we don't,
* won't be compatible either due to modifications (additional parameters, unsupported parameters, different interpretations),
* and will just as equally need to evolve it as the other options, requiring the documentation of changes as well
* will be required to be maintained as a public API
* documentation
* LTS
* versioning
### <a id="proscons-jmap"/>JMAP
[JMAP (JSON Meta Application Protocol)](https://jmap.io/spec.html) is a set of specifications that are codified in RFCs:
* [RFC 8620](https://tools.ietf.org/html/rfc8620): core JMAP protocol
* [RFC 8261](https://tools.ietf.org/html/rfc8621): JMAP Mail
* [RFC 8887](https://www.rfc-editor.org/rfc/rfc8887.html): JMAP subprotocol for WebSocket
* [RFC 9404](https://www.rfc-editor.org/rfc/rfc9404.html): JMAP Blob Management Extension
* [RFC 9425](https://www.rfc-editor.org/rfc/rfc9425.html): JMAP Quotas
* [RFC 9553](https://www.rfc-editor.org/rfc/rfc9553.html): uses JSContact
* [RFC 8984](https://www.rfc-editor.org/rfc/rfc8984.html): uses JSCalendar
of which some are still in development at the time of writing:
* [JMAP Contacts](https://jmap.io/spec-contacts.html)
* [JMAP Calendars](https://jmap.io/spec-calendars.html)
* [JMAP Sharing](https://jmap.io/spec-sharing.html)
* [JMAP Tasks](https://jmap.io/spec-tasks.html)
To exemplify the JMAP protocol, the following code block is a JMAP request that
* fetches the 30 last received emails from a mailbox (folder)
* the threads of those emails
* email metadata of all of those threads, including a preview
<details open>
<summary>Click here to toggle the display of this example.</summary>
```json
[[ "Email/query", {
"accountId": "ue150411c",
"filter": {
"inMailbox": "fb666a55"
},
"sort": [{
"isAscending": false,
"property": "receivedAt"
}],
"collapseThreads": true,
"position": 0,
"limit": 30,
"calculateTotal": true
}, "0" ],
[ "Email/get", {
"accountId": "ue150411c",
"#ids": {
"resultOf": "0",
"name": "Email/query",
"path": "/ids"
},
"properties": [
"threadId"
]
}, "1" ],
[ "Thread/get", {
"accountId": "ue150411c",
"#ids": {
"resultOf": "1",
"name": "Email/get",
"path": "/list/*/threadId"
}
}, "2" ],
[ "Email/get", {
"accountId": "ue150411c",
"#ids": {
"resultOf": "2",
"name": "Thread/get",
"path": "/list/*/emailIds"
},
"properties": [
"threadId",
"mailboxIds",
"keywords",
"hasAttachment",
"from",
"subject",
"receivedAt",
"size",
"preview"
]
}, "3" ]]
```
</details>
#### Good
* flexible protocol that can easily be implemented by clients
* potentially does not require implementation efforts on the backend side
* would obviously support the full potential of JMAP and Stalwart
* we could potentially extend JMAP with our own data models and operations based on the [JMAP Core Protocol](https://jmap.io/spec-core.html), possibly even propose them as RFCs
* we can start with JMAP request objects that contain only a few or even only one JMAP methods (indicated by the [maxCallsInRequest capability](https://datatracker.ietf.org/doc/html/rfc8620#section-2)), allowing more calls as we need
* clients could implement the funtionality they need using multiple requests in the beginning, then we implement missing functionality on the server
* this would allow us to speed up requests that we need while at the same time giving clients the ability to make any necessary individual calls
* probably only a partially useful approach since chaining JMAP requests is necessary for even the most mundane operations, to avoid the inefficiency of multiple roundtrips
#### Neutral
* the [existing JMAP specifications](https://jmap.io/spec.html) will not cover 100% of the Web UI API needs (e.g. configuration settings[^config], [resource linking](./0003-groupware-resource-linking.md), ...), but that does not prevent us from implementing additional custom APIs, either as non-JMAP REST APIs, or as extensions of JMAP
* we would need to gauge whether JMAP communication
* should occur directly between the OpenCloud UI and Stalwart,
* or whether an OpenCloud Groupware service should be used as an intermediary and as an [anti-corruption layer](https://ddd-practitioners.com/home/glossary/bounded-context/bounded-context-relationship/anticorruption-layer/)
* if there is a requirement for considering that API as a public API for third party integrators, then the API also needs to be documented, maintained, versioned, and kept stable as much as possible (this is neutral because it is a requirement that exists with every option)
[^config]: although Stalwart will most likely have a [JMAP API for application configuration settings as well](https://matrix.to/#/!blIcSTIPwfKMtOEWcg:matrix.org/$CD9C6IZN28bbmN0Arb_Y-RapgsS4XqAqnDgf15yJahM?via=matrix.org&via=mozilla.org&via=chat.opencloud.eu)
> Message from [Mauro](https://github.com/mdecimus):
>
> Hi everyone, I'm curious what you think about standardizing a simple protocol/extension for users to easily manage certain account settings directly from their email clients. For instance, such a protocol could handle:
>
> * Passwords, app passwords, and MFA settings
> * Locale preferences
> * Timezone configuration
> * Basic email forwarding (without needing custom Sieve scripts)
> * Vacation/auto-responses
> * Blocking specific email addresses
> * Spam reporting (though not strictly a setting)
> * Calendar-related preferences
> * Encryption-at-rest settings
> * Mail auto-expunge policies
> * ... and potentially more.
>
> My initial thought is to implement this as a JMAP extension rather than inventing another protocol similar to ManageSieve, which feels somewhat like a "Frankenstein" IMAP extension.
>
> Many mailbox providers already offer some or all of these settings through their web interfaces, but a standardized JMAP-based extension could let users adjust these directly within their preferred email clients or via APIs.
#### Bad
* potentially bad: most probably too flexible for its own good, as it makes it difficult to reverse-engineer the high-level meaning of a set of JMAP requests in order to capture its semantics, e.g. to implement caching or reverse indexes for performance
* since the OpenCloud Drive backends use the LibreGraph API, using a JMAP based API for Groupware bears the risk of having multiple APIs to do the same thing, which we need to be careful about, and avoid if possible
> [!NOTE]
> This seems like a mild "bad" item, but the risk risk here is significant: if it turns out that we need to capture the semantics of API requests to perform additional operations (e.g. caching or indexing for performance reasons, or to decorate the data from Stalwart with information from other services), then we would have to re-implement the whole API as JMAP is too complex to parse to extract semantics from.
#### Two Approaches
There are two approaches as to how to implement our protocol based on JMAP:
* either our clients must split JMAP operations and send some to Stalwart, and others to the Groupware backend (depending on which endpoint is in charge of which API)
* or our clients send all the JMAP operations to the Groupware backend, which is then in charge to relay JMAP commands that are to be handled by Stalwart to Stalwart
##### Directly to Stalwart
If the OpenCloud UI Client communicates directly with Stalwart (using JMAP), then
* good: we don't need to implement any sort of "bridge" in the OpenCloud Groupware service (although the implementation effort is likely to be low)
* good: we avoid an additional hop in the network, gaining on performance and potentially on throughput
* bad: it will have to perform additional API requests for data and features that are not provided by Stalwart with the OpenCloud Groupware service (e.g. [Resource Linking](./0003-groupware-resource-linking.md)) as well, which is likely to lead to an increase in the number of network roundtrips
* bad: would be unable to extend the protocol with OpenCloud Groupware specific models and data
* bad: would be unable to implement caching or similar performance improvements if necessary
* bad: prevents us from implementing infrastructure features that are not present in Stalwart and might never be in the way we would need them, e.g. sharding across multi-site redundancy
```mermaid
graph LR
subgraph clients
ui(OpenCloud UI)
muas(Other<br>MUAs)
end
subgraph Backend
subgraph OpenCloud
direction TB
groupware("OpenCloud<br>Groupware")
drive("OpenCloud<br>Drive")
end
stalwart(Stalwart)
end
subgraph Storage
drive_storage[(drive<br>storage)]
stalwart_metadata[(metadata<br>storage)]
stalwart_storage[(object<br>storage)]
end
ui x@==>|JMAP|stalwart
x@{ animate: true }
ui y@==>|JMAP or REST|groupware
y@{ animate: true }
ui-->|Graph|drive
muas-->|IMAP,SMTP,*DAV|stalwart
groupware-->drive
groupware-->|JMAP|stalwart
drive-->drive_storage
stalwart-->stalwart_metadata
stalwart-->stalwart_storage
```
##### Groupware intermediary
Alternatively, if the OpenCloud UI Client exclusively communicates with the OpenCloud Groupware service (using JMAP), then
* good: the OpenCloud Groupware service acts as a anti-corruption layer, which would allow us to
* implement caching and similar performance improvement measures if necessary (e.g. reverse indexing of costly data)
* implement infrastructure features that are not present in Stalwart and might never be in the way we would need them, e.g. sharding across multi-site redundancy
* extend the JMAP protocol
* good: it enables us to minimize network roundtrips between the OpenCloud UI Client and the OpenCloud Groupware backend as there is no need to perform additional requests elsewhere
* bad: we have an additional intermediary hop that "just" relays operations to Stalwart most of the time
* due to Go HTTP stack limitations (lack of zero-copy asynchronous I/O),
* that might incur a cost of "needlessly" copying data in memory
* as well as performing blocking I/O (at the very least since JMAP requests first need to be read in full by te OpenCloud Groupware before they then can be sent to Stalwart more or less as-is, and the same applies to the responses)
```mermaid
graph LR
subgraph clients
ui(OpenCloud UI)
muas(Other<br>MUAs)
end
subgraph Backend
subgraph OpenCloud
direction TB
groupware("OpenCloud<br>Groupware")
drive("OpenCloud<br>Drive")
end
stalwart(Stalwart)
end
subgraph Storage
drive_storage[(drive<br>storage)]
stalwart_metadata[(metadata<br>storage)]
stalwart_storage[(object<br>storage)]
end
ui y@==>|JMAP|groupware
y@{ animate: true }
ui-->|Graph|drive
muas-->|IMAP,SMTP,*DAV|stalwart
groupware-->drive
groupware-->|JMAP|stalwart
drive-->drive_storage
stalwart-->stalwart_metadata
stalwart-->stalwart_storage
```
#### Decision Drivers
* UI Driven
* the UI team did not express any particular preference for this option, but the JMAP protocol is simple to implement on any client
* Economic Awareness
* there would be less of a need to develop an API, but that doesn't put much into the balance
* developing a generic inbound JMAP command processing engine that is capable of resolving backreferences with requests that can be sent out to different backends (Stalwart, Drive, Groupware, OpenTalk, ...) seems risky in terms of complexity, also since Go doesn't have much of a [well-supported Reactive framework](https://github.com/ReactiveX/RxGo)
* Efficiency
* the ability of the JMAP protocol to chain multiple low-level commands provides for a very efficient way to compose higher-level operations without the need for multiple round-trips
* Third Party Consumption
* for some of the operations, we could point to the JMAP documentation and RFCs, although that would not make for a great experience either, we would probably need to replicate it
* our protocol extensions will have to be maintained just like the other options
* will be required to be maintained as a public API
* documentation
* LTS
* versioning
### <a id="proscons-custom"/>Custom REST API
A custom REST API would implement the resources and semantics as they are needed by the UI, and would be strongly if not fully UI-driven.
The data model should remain close or equal to JMAP's, to avoid data loss by converting back and forth.
We might look into existing specifications for formatting JSON payloads, such as [JSON:API](https://jsonapi.org/) or partial ones such as such as [JSON-LD](https://json-ld.org/) for relationships between resources, but this is currently outside of the scope of this ADR.
```mermaid
graph LR
subgraph clients
ui(OpenCloud UI)
muas(Other<br>MUAs)
end
subgraph Backend
subgraph OpenCloud
direction TB
groupware("OpenCloud<br>Groupware")
drive("OpenCloud<br>Drive")
end
stalwart(Stalwart)
end
subgraph Storage
drive_storage[(drive<br>storage)]
stalwart_metadata[(metadata<br>storage)]
stalwart_storage[(object<br>storage)]
end
ui y@==>|REST|groupware
y@{ animate: true }
ui-->|Graph|drive
muas-->|IMAP,SMTP,*DAV|stalwart
groupware-->drive
groupware-->|JMAP|stalwart
drive-->drive_storage
stalwart-->stalwart_metadata
stalwart-->stalwart_storage
```
Example:
```text
GET /groupware/startup/1/?mails=50
```
#### Good
* completely tailored to the needs of the OpenCloud UI
* a higher-level API allows for easily understanding the semantic of each operation, which enables the potential for keeping track of data in order to implement reverse indexes and caching, if necessary to achieve functional or performance goals, as opposed to using a lower-level API such as JMAP which is maximally flexible and difficult to reverse-engineer the meaning of the operation and data
* can also be tailored to the capabilities of JMAP without exposing all of its flexibility
* provides the potential for expanding upon what JMAP provides
* would support the full potential of JMAP and Stalwart since the API would be designed accordingly
* allows learning how to use and cache individual JMAP method call responses, allowing to make a better decision in the future if JMAP should be used by clients
#### Neutral
* if there is a requirement for considering that API as a public API for third party integrators, then the API also needs to be documented, maintained, versioned, and kept stable as much as possible (this is neutral because it is a requirement that exists with every option)
#### Bad
* only partially follows any standards (REST, JSON, JMAP for data models)
* requires designing the API from scratch, as opposed to using the Graph API which already prescribes one
* although it probably makes sense to re-use the data model of JMAP, which is prescribed in RFCs, also to avoid data loss and copying things around needlessly
* since the OpenCloud Drive backends use the LibreGraph API, using a custom REST API for Groupware bears the risk of having multiple APIs to do the same thing, which we need to be careful about, and avoid if possible
#### Decision Drivers
* UI Driven
* favoured solution for the OpenCloud Web UI team
* Economic Awareness
* designing a new custom API is not much effort since it is UI requirements driven
* maintaining a new custom API or JMAP extensions is not more effort either, since the exact same thing needs to be done with LibreGraph, as it will have numerous exceptions and will require documenting those, as well as which parts of the Microsoft Graph API are implemented and which aren't
* Efficiency
* the most efficient approach since it is tailored to what is actually needed for the OpenCloud UI, which will allow us to reduce the roundtrips to a minimum
* Third Party Consumption
* a custom API will be required to be maintained as a public API
* documentation
* LTS
* versioning

View File

@@ -0,0 +1,52 @@
---
status: proposed
date: 2025-07-07
author: Pascal Bleser <p.bleser@opencloud.eu>
decision-makers:
consulted:
informed:
title: "Groupware Configuration Settings"
template: https://raw.githubusercontent.com/adr/madr/refs/tags/4.0.0/template/adr-template.md
---
* Status: draft
## Context
User Preferences need to be configurable through the UI and persisted in a backend service in order to be reliably available and backed up.
Such configuration options have default values that need to be set on multiple levels:
* globally
* by tenant
* by sub-tenant
* by group of users
* by user
Some options might even be client-specific, e.g. differ between the OpenCloud Web UI on desktop and the OpenCloud Web UI on mobile.
Furthermore, some options might be enforced and may not be overridden on every level (e.g. only globally or by tenant, by not modifiable by users.)
Ideally, the configuration settings have an architecture that permits pluggable sources.
This level of necessary complexity has a few drawbacks, the primary one being that it can become difficult to find out why a user sees this or that behavior in their UI, and thus to trace down where a given configuration setting is made (globally, on tenant level, etc...). It is thus critical to include tooling that allows to debug them.
## Considered Options
TODO
## Decision Outcome
TODO
### Consequences
TODO
### Confirmation
TODO
## Pros and Cons of the Options
TODO

23
go.mod
View File

@@ -7,7 +7,9 @@ require (
github.com/CiscoM31/godata v1.0.11
github.com/KimMachineGun/automemlimit v0.7.5
github.com/Masterminds/semver v1.5.0
github.com/MicahParks/jwkset v0.8.0
github.com/MicahParks/keyfunc/v2 v2.1.0
github.com/MicahParks/keyfunc/v3 v3.3.11
github.com/Nerzal/gocloak/v13 v13.9.0
github.com/bbalet/stopwords v1.0.0
github.com/beevik/etree v1.6.0
@@ -19,6 +21,7 @@ require (
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8
github.com/dutchcoders/go-clamd v0.0.0-20170520113014-b970184f4d9e
github.com/gabriel-vasile/mimetype v1.4.11
github.com/emersion/go-imap/v2 v2.0.0-beta.5
github.com/ggwhite/go-masker v1.1.0
github.com/go-chi/chi/v5 v5.2.3
github.com/go-chi/render v1.0.3
@@ -64,7 +67,7 @@ require (
github.com/open-policy-agent/opa v1.10.1
github.com/opencloud-eu/icap-client v0.0.0-20250930132611-28a2afe62d89
github.com/opencloud-eu/libre-graph-api-go v1.0.8-0.20250724122329-41ba6b191e76
github.com/opencloud-eu/reva/v2 v2.41.0
github.com/opencloud-eu/reva/v2 v2.40.1
github.com/opensearch-project/opensearch-go/v4 v4.5.0
github.com/orcaman/concurrent-map v1.0.0
github.com/pkg/errors v0.9.1
@@ -137,6 +140,7 @@ require (
github.com/antithesishq/antithesis-sdk-go v0.4.3-default-no-op // indirect
github.com/armon/go-radix v1.0.0 // indirect
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bitly/go-simplejson v0.5.0 // indirect
github.com/bits-and-blooms/bitset v1.22.0 // indirect
@@ -159,9 +163,11 @@ require (
github.com/blevesearch/zapx/v16 v16.2.7 // indirect
github.com/bluele/gcache v0.0.2 // indirect
github.com/bombsimon/logrusr/v3 v3.1.0 // indirect
github.com/brianvoe/gofakeit/v7 v7.7.3 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/ceph/go-ceph v0.36.0 // indirect
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cevaris/ordered_map v0.0.0-20190319150403-3adeae072e73 // indirect
github.com/clipperhouse/displaywidth v0.3.1 // indirect
@@ -193,8 +199,11 @@ require (
github.com/docker/go-connections v0.6.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/dustinkirkland/golang-petname v0.0.0-20240428194347-eebcea082ee0 // indirect
github.com/ebitengine/purego v0.8.4 // indirect
github.com/egirna/icap v0.0.0-20181108071049-d5ee18bd70bc // indirect
github.com/emersion/go-message v0.18.1 // indirect
github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/emvi/iso-639-1 v1.1.1 // indirect
github.com/evanphx/json-patch/v5 v5.5.0 // indirect
@@ -204,6 +213,8 @@ require (
github.com/gdexlab/go-render v1.0.1 // indirect
github.com/go-acme/lego/v4 v4.4.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/go-crypt/crypt v0.4.5 // indirect
github.com/go-crypt/x v0.4.7 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.6.2 // indirect
github.com/go-git/go-git/v5 v5.13.2 // indirect
@@ -224,7 +235,7 @@ require (
github.com/go-sql-driver/mysql v1.9.3 // indirect
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
github.com/go-test/deep v1.1.0 // indirect
github.com/go-test/deep v1.1.1 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
@@ -235,6 +246,7 @@ require (
github.com/gofrs/flock v0.13.0 // indirect
github.com/gofrs/uuid v4.4.0+incompatible // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/snappy v0.0.4 // indirect
@@ -244,8 +256,10 @@ require (
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect
github.com/google/renameio/v2 v2.0.1 // indirect
github.com/gookit/goutil v0.7.1 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/gorilla/handlers v1.5.1 // indirect
github.com/gorilla/schema v1.4.1 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // indirect
github.com/hashicorp/go-hclog v1.6.3 // indirect
github.com/hashicorp/go-plugin v1.7.0 // indirect
@@ -253,8 +267,10 @@ require (
github.com/huandu/xstrings v1.5.0 // indirect
github.com/iancoleman/strcase v0.3.0 // indirect
github.com/imdario/mergo v0.3.15 // indirect
github.com/inbucket/html2text v0.9.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/jhillyerd/enmime/v2 v2.2.0 // indirect
github.com/jonboulle/clockwork v0.5.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/juliangruber/go-intersect v1.1.0 // indirect
@@ -285,6 +301,7 @@ require (
github.com/mattn/go-sqlite3 v1.14.32 // indirect
github.com/maxymania/go-system v0.0.0-20170110133659-647cc364bf0b // indirect
github.com/mendsley/gojwk v0.0.0-20141217222730-4d5ec6e58103 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/miekg/dns v1.1.57 // indirect
github.com/mileusna/useragent v1.3.5 // indirect
github.com/minio/crc64nvme v1.1.0 // indirect
@@ -352,6 +369,7 @@ require (
github.com/skeema/knownhosts v1.3.0 // indirect
github.com/spacewander/go-suffix-tree v0.0.0-20191010040751-0865e368c784 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/studio-b12/gowebdav v0.9.0 // indirect
github.com/tchap/go-patricia/v2 v2.3.3 // indirect
@@ -391,6 +409,7 @@ require (
google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 // indirect
gopkg.in/cenkalti/backoff.v1 v1.1.0 // indirect
gopkg.in/loremipsum.v1 v1.1.2 // indirect
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect

43
go.sum
View File

@@ -82,8 +82,12 @@ github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Masterminds/sprig v2.22.0+incompatible h1:z4yfnGrZ7netVz+0EDJ0Wi+5VZCSYp4Z0m2dk6cEM60=
github.com/Masterminds/sprig v2.22.0+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuNhlNS5hqE0NB0E6fgfo2Br3o=
github.com/MicahParks/jwkset v0.8.0 h1:jHtclI38Gibmu17XMI6+6/UB59srp58pQVxePHRK5o8=
github.com/MicahParks/jwkset v0.8.0/go.mod h1:fVrj6TmG1aKlJEeceAz7JsXGTXEn72zP1px3us53JrA=
github.com/MicahParks/keyfunc/v2 v2.1.0 h1:6ZXKb9Rp6qp1bDbJefnG7cTH8yMN1IC/4nf+GVjO99k=
github.com/MicahParks/keyfunc/v2 v2.1.0/go.mod h1:rW42fi+xgLJ2FRRXAfNx9ZA8WpD4OeE/yHVMteCkw9k=
github.com/MicahParks/keyfunc/v3 v3.3.11 h1:eA6wNltwdSRX2gtpTwZseBCC9nGeBkI9KxHtTyZbDbo=
github.com/MicahParks/keyfunc/v3 v3.3.11/go.mod h1:y6Ed3dMgNKTcpxbaQHD8mmrYDUZWJAxteddA6OQj+ag=
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
@@ -134,6 +138,8 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkY
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/aws/aws-sdk-go v1.37.27/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/bbalet/stopwords v1.0.0 h1:0TnGycCtY0zZi4ltKoOGRFIlZHv0WqpoIGUsObjztfo=
github.com/bbalet/stopwords v1.0.0/go.mod h1:sAWrQoDMfqARGIn4s6dp7OW7ISrshUD8IP2q3KoqPjc=
github.com/beevik/etree v1.6.0 h1:u8Kwy8pp9D9XeITj2Z0XtA5qqZEmtJtuXZRQi+j03eE=
@@ -194,6 +200,8 @@ github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dR
github.com/bombsimon/logrusr/v3 v3.1.0 h1:zORbLM943D+hDMGgyjMhSAz/iDz86ZV72qaak/CA0zQ=
github.com/bombsimon/logrusr/v3 v3.1.0/go.mod h1:PksPPgSFEL2I52pla2glgCyyd2OqOHAnFF5E+g8Ixco=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/brianvoe/gofakeit/v7 v7.7.3 h1:RWOATEGpJ5EVg2nN8nlaEyaV/aB4d6c3GqYrbqQekss=
github.com/brianvoe/gofakeit/v7 v7.7.3/go.mod h1:QXuPeBw164PJCzCUZVmgpgHJ3Llj49jSLVkKPMtxtxA=
github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw=
github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c=
github.com/butonic/go-micro/v4 v4.11.1-0.20241115112658-b5d4de5ed9b3 h1:h8Z0hBv5tg/uZMKu8V47+DKWYVQg0lYP8lXDQq7uRpE=
@@ -212,6 +220,8 @@ github.com/census-instrumentation/opencensus-proto v0.2.0/go.mod h1:f6KPmirojxKA
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/ceph/go-ceph v0.36.0 h1:IDE4vEF+4fmjve+CPjD1WStgfQ+Lh6vD+9PMUI712KI=
github.com/ceph/go-ceph v0.36.0/go.mod h1:fGCbndVDLuHW7q2954d6y+tgPFOBnRLqJRe2YXyngw4=
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a h1:MISbI8sU/PSK/ztvmWKFcI7UGb5/HQT7B+i3a2myKgI=
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a/go.mod h1:2GxOXOlEPAMFPfp014mK1SWq8G8BN8o7/dfYqJrVGn8=
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
@@ -318,6 +328,8 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/dustinkirkland/golang-petname v0.0.0-20240428194347-eebcea082ee0 h1:aYo8nnk3ojoQkP5iErif5Xxv0Mo0Ga/FR5+ffl/7+Nk=
github.com/dustinkirkland/golang-petname v0.0.0-20240428194347-eebcea082ee0/go.mod h1:8AuBTZBRSFqEYBPYULd+NN474/zZBLP+6WeT5S9xlAc=
github.com/dutchcoders/go-clamd v0.0.0-20170520113014-b970184f4d9e h1:rcHHSQqzCgvlwP0I/fQ8rQMn/MpHE5gWSLdtpxtP6KQ=
github.com/dutchcoders/go-clamd v0.0.0-20170520113014-b970184f4d9e/go.mod h1:Byz7q8MSzSPkouskHJhX0er2mZY/m0Vj5bMeMCkkyY4=
github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs=
@@ -329,6 +341,12 @@ github.com/egirna/icap v0.0.0-20181108071049-d5ee18bd70bc h1:6IxmRbXV8WXVkcYcTzk
github.com/egirna/icap v0.0.0-20181108071049-d5ee18bd70bc/go.mod h1:FdVN2WHg7zOHhJ7kZQdDorfFhIfqZaHttjAzDDvAXHE=
github.com/elazarl/goproxy v1.4.0 h1:4GyuSbFa+s26+3rmYNSuUVsx+HgPrV1bk1jXI0l9wjM=
github.com/elazarl/goproxy v1.4.0/go.mod h1:X/5W/t+gzDyLfHW4DrMdpjqYjpXsURlBt9lpBDxZZZQ=
github.com/emersion/go-imap/v2 v2.0.0-beta.5 h1:H3858DNmBuXyMK1++YrQIRdpKE1MwBc+ywBtg3n+0wA=
github.com/emersion/go-imap/v2 v2.0.0-beta.5/go.mod h1:BZTFHsS1hmgBkFlHqbxGLXk2hnRqTItUgwjSSCsYNAk=
github.com/emersion/go-message v0.18.1 h1:tfTxIoXFSFRwWaZsgnqS1DSZuGpYGzSmCZD8SK3QA2E=
github.com/emersion/go-message v0.18.1/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 h1:hH4PQfOndHDlpzYfLAAfl63E8Le6F2+EL/cdhlkyRJY=
github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/emvi/iso-639-1 v1.1.1 h1:7jrl1Sqw9ZYWmCOaH+cpQotLbGr/khwlLPXlBvE8WXU=
@@ -384,6 +402,10 @@ github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hH
github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4=
github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0=
github.com/go-cmd/cmd v1.0.5/go.mod h1:y8q8qlK5wQibcw63djSl/ntiHUHXHGdCkPk0j4QeW4s=
github.com/go-crypt/crypt v0.4.5 h1:cCR5vVejGk1kurwoGfkLxGORY+Pc9GiE7xKCpyHZ3n4=
github.com/go-crypt/crypt v0.4.5/go.mod h1:cQijpCkqavdF52J1bE0PObWwqKKjQCHASHQ2dtLzOJs=
github.com/go-crypt/x v0.4.7 h1:hObjW67nhq/GI1jaD7XCv5RoiVKzF46XIbULgzH71oU=
github.com/go-crypt/x v0.4.7/go.mod h1:K3q7VmLC0U1QFAPn0SQvXjkAtu6FJuH0rN9LNqobX6k=
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
@@ -471,6 +493,7 @@ github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1v
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg=
github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/gobs/pretty v0.0.0-20180724170744-09732c25a95b/go.mod h1:Xo4aNUOrJnVruqWQJBtW6+bTBDTniY8yZum5rF3b5jw=
@@ -499,6 +522,8 @@ github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7a
github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs=
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
github.com/goji/httpauth v0.0.0-20160601135302-2da839ab0f4d/go.mod h1:nnjvkQ9ptGaCkuDUx6wNykzzlUixGxvkme+H/lnzb+A=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
@@ -606,6 +631,8 @@ github.com/gophercloud/gophercloud v0.16.0/go.mod h1:wRtmUelyIIv3CSSDI47aUwbs075
github.com/gophercloud/utils v0.0.0-20210216074907-f6de111f2eae/go.mod h1:wx8HMD8oQD0Ryhz6+6ykq75PJ79iPyEqYHfwZ4l7OsA=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/gorilla/handlers v1.5.1 h1:9lRY6j8DEeeBT10CvO9hGW0gmky0BprnvDI5vfhUHH4=
github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q=
github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
@@ -615,6 +642,8 @@ github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWS
github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E=
github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI=
github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vbp88Yd8NsDy6rZz+RcrMPxvld8=
@@ -664,6 +693,8 @@ github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:
github.com/iij/doapi v0.0.0-20190504054126-0bbf12d6d7df/go.mod h1:QMZY7/J/KSQEhKWFeDesPjMj+wCHReeknARU3wqlyN4=
github.com/imdario/mergo v0.3.15 h1:M8XP7IuFNsqUx6VPK2P9OSmsYsI/YFaGil0uD21V3dM=
github.com/imdario/mergo v0.3.15/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY=
github.com/inbucket/html2text v0.9.0 h1:ULJmVcBEMAcmLE+/rN815KG1Fx6+a4HhbUxiDiN+qks=
github.com/inbucket/html2text v0.9.0/go.mod h1:QDaumzl+/OzlSVbNohhmg+yAy5pKjUjzCKW2BMvztKE=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
@@ -690,6 +721,8 @@ github.com/jellydator/ttlcache/v2 v2.11.1/go.mod h1:RtE5Snf0/57e+2cLWFYWCCsLas2H
github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY=
github.com/jellydator/ttlcache/v3 v3.4.0/go.mod h1:Hw9EgjymziQD3yGsQdf1FqFdpp7YjFMd4Srg5EJlgD4=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jhillyerd/enmime/v2 v2.2.0 h1:Pe35MB96eZK5Q0XjlvPftOgWypQpd1gcbfJKAt7rsB8=
github.com/jhillyerd/enmime/v2 v2.2.0/go.mod h1:SOBXlCemjhiV2DvHhAKnJiWrtJGS/Ffuw4Iy7NjBTaI=
github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94=
github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
@@ -842,6 +875,8 @@ github.com/mendsley/gojwk v0.0.0-20141217222730-4d5ec6e58103 h1:Z/i1e+gTZrmcGeZy
github.com/mendsley/gojwk v0.0.0-20141217222730-4d5ec6e58103/go.mod h1:o9YPB5aGP8ob35Vy6+vyq3P3bWe7NQWzf+JLiXCiMaE=
github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE=
github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
github.com/miekg/dns v1.1.40/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM=
github.com/miekg/dns v1.1.57 h1:Jzi7ApEIzwEPLHWRcafCN9LZSBbqQpxjt/wpgvg7wcM=
@@ -963,8 +998,8 @@ github.com/opencloud-eu/inotifywaitgo v0.0.0-20251111171128-a390bae3c5e9 h1:dIft
github.com/opencloud-eu/inotifywaitgo v0.0.0-20251111171128-a390bae3c5e9/go.mod h1:JWyDC6H+5oZRdUJUgKuaye+8Ph5hEs6HVzVoPKzWSGI=
github.com/opencloud-eu/libre-graph-api-go v1.0.8-0.20250724122329-41ba6b191e76 h1:vD/EdfDUrv4omSFjrinT8Mvf+8D7f9g4vgQ2oiDrVUI=
github.com/opencloud-eu/libre-graph-api-go v1.0.8-0.20250724122329-41ba6b191e76/go.mod h1:pzatilMEHZFT3qV7C/X3MqOa3NlRQuYhlRhZTL+hN6Q=
github.com/opencloud-eu/reva/v2 v2.41.0 h1:oie8+sxcA+drREXRTqm0LmfUdy/mmaa6pA6wkdF6tF4=
github.com/opencloud-eu/reva/v2 v2.41.0/go.mod h1:DGH08n2mvtsQLkt8o15FV6m51FwSJJGhjR8Ty+iIJww=
github.com/opencloud-eu/reva/v2 v2.40.1 h1:QwMkbGMhwDSwfk2WxbnTpIig2BugPBaVFjWcy2DSU3U=
github.com/opencloud-eu/reva/v2 v2.40.1/go.mod h1:DGH08n2mvtsQLkt8o15FV6m51FwSJJGhjR8Ty+iIJww=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
@@ -1163,6 +1198,8 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.7.0/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg=
github.com/spf13/viper v1.7.1/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg=
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo=
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.3.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
@@ -1784,6 +1821,8 @@ gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/ini.v1 v1.51.1/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/ini.v1 v1.57.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/loremipsum.v1 v1.1.2 h1:12APklfJKuGszqZsrArW5QoQh03/W+qyCCjvnDuS6Tw=
gopkg.in/loremipsum.v1 v1.1.2/go.mod h1:TuRvzFuzuejXj+odBU6Tubp/EPUyGb9wmSvHenyP2Ts=
gopkg.in/ns1/ns1-go.v2 v2.4.4/go.mod h1:GMnKY+ZuoJ+lVLL+78uSTjwTz2jMazq6AfGKQOYhsPk=
gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo=
gopkg.in/square/go-jose.v2 v2.5.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=

1
opencloud/cmd/opencloud/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/__debug_bin*

View File

@@ -3,7 +3,6 @@ ARG TARGETOS
ARG TARGETARCH
ARG VERSION
ARG STRING
ARG EDITION
RUN apk add bash make git curl gcc musl-dev libc-dev binutils-gold inotify-tools vips-dev

View File

@@ -13,6 +13,7 @@ import (
appprovider "github.com/opencloud-eu/opencloud/services/app-provider/pkg/command"
appregistry "github.com/opencloud-eu/opencloud/services/app-registry/pkg/command"
audit "github.com/opencloud-eu/opencloud/services/audit/pkg/command"
authapi "github.com/opencloud-eu/opencloud/services/auth-api/pkg/command"
authapp "github.com/opencloud-eu/opencloud/services/auth-app/pkg/command"
authbasic "github.com/opencloud-eu/opencloud/services/auth-basic/pkg/command"
authbearer "github.com/opencloud-eu/opencloud/services/auth-bearer/pkg/command"
@@ -25,6 +26,7 @@ import (
gateway "github.com/opencloud-eu/opencloud/services/gateway/pkg/command"
graph "github.com/opencloud-eu/opencloud/services/graph/pkg/command"
groups "github.com/opencloud-eu/opencloud/services/groups/pkg/command"
groupware "github.com/opencloud-eu/opencloud/services/groupware/pkg/command"
idm "github.com/opencloud-eu/opencloud/services/idm/pkg/command"
idp "github.com/opencloud-eu/opencloud/services/idp/pkg/command"
invitations "github.com/opencloud-eu/opencloud/services/invitations/pkg/command"
@@ -138,6 +140,11 @@ var svccmds = []register.Command{
cfg.Groups.Commons = cfg.Commons
})
},
func(cfg *config.Config) *cli.Command {
return ServiceCommand(cfg, cfg.Groupware.Service.Name, groupware.GetCommands(cfg.Groupware), func(c *config.Config) {
cfg.Groupware.Commons = cfg.Commons
})
},
func(cfg *config.Config) *cli.Command {
return ServiceCommand(cfg, cfg.IDM.Service.Name, idm.GetCommands(cfg.IDM), func(c *config.Config) {
cfg.IDM.Commons = cfg.Commons
@@ -263,6 +270,11 @@ var svccmds = []register.Command{
cfg.Webfinger.Commons = cfg.Commons
})
},
func(cfg *config.Config) *cli.Command {
return ServiceCommand(cfg, cfg.AuthApi.Service.Name, authapi.GetCommands(cfg.AuthApi), func(c *config.Config) {
cfg.AuthApi.Commons = cfg.Commons
})
},
}
// ServiceCommand is the entry point for the all service commands.

View File

@@ -33,7 +33,6 @@ func VersionCommand(cfg *config.Config) *cli.Command {
Category: "info",
Action: func(c *cli.Context) error {
fmt.Println("Version: " + version.GetString())
fmt.Printf("Edition: %s\n", version.Edition)
fmt.Printf("Compiled: %s\n", version.Compiled())
if c.Bool(_skipServiceListingFlagName) {

View File

@@ -32,6 +32,7 @@ type OpenCloudConfig struct {
AuthBearer AuthbearerService `yaml:"auth_bearer"`
Users UsersAndGroupsService `yaml:"users"`
Groups UsersAndGroupsService `yaml:"groups"`
Groupware GroupwareService `yaml:"groupware"`
Ocdav InsecureService `yaml:"ocdav"`
Ocm OcmService `yaml:"ocm"`
Thumbnails ThumbnailService `yaml:"thumbnails"`
@@ -126,6 +127,17 @@ type GraphService struct {
ServiceAccount ServiceAccount `yaml:"service_account"`
}
// GroupwareSettings is the configuration for the groupware settings
type GroupwareSettings struct {
WebdavAllowInsecure bool `yaml:"webdav_allow_insecure"`
Cs3AllowInsecure bool `yaml:"cs3_allow_insecure"`
}
// GroupwareService is the configuration for the groupware service
type GroupwareService struct {
Groupware GroupwareSettings
}
// IdmService is the configuration for the IDM service
type IdmService struct {
ServiceUserPasswords ServiceUserPasswordsSettings `yaml:"service_user_passwords"`

View File

@@ -24,6 +24,7 @@ import (
appProvider "github.com/opencloud-eu/opencloud/services/app-provider/pkg/command"
appRegistry "github.com/opencloud-eu/opencloud/services/app-registry/pkg/command"
audit "github.com/opencloud-eu/opencloud/services/audit/pkg/command"
authapi "github.com/opencloud-eu/opencloud/services/auth-api/pkg/command"
authapp "github.com/opencloud-eu/opencloud/services/auth-app/pkg/command"
authbasic "github.com/opencloud-eu/opencloud/services/auth-basic/pkg/command"
authmachine "github.com/opencloud-eu/opencloud/services/auth-machine/pkg/command"
@@ -35,6 +36,7 @@ import (
gateway "github.com/opencloud-eu/opencloud/services/gateway/pkg/command"
graph "github.com/opencloud-eu/opencloud/services/graph/pkg/command"
groups "github.com/opencloud-eu/opencloud/services/groups/pkg/command"
groupware "github.com/opencloud-eu/opencloud/services/groupware/pkg/command"
idm "github.com/opencloud-eu/opencloud/services/idm/pkg/command"
idp "github.com/opencloud-eu/opencloud/services/idp/pkg/command"
invitations "github.com/opencloud-eu/opencloud/services/invitations/pkg/command"
@@ -348,6 +350,16 @@ func NewService(ctx context.Context, options ...Option) (*Service, error) {
cfg.Notifications.Commons = cfg.Commons
return notifications.Execute(cfg.Notifications)
})
areg(opts.Config.AuthApi.Service.Name, func(ctx context.Context, cfg *occfg.Config) error {
cfg.AuthApi.Context = ctx
cfg.AuthApi.Commons = cfg.Commons
return authapi.Execute(cfg.AuthApi)
})
areg(opts.Config.Groupware.Service.Name, func(ctx context.Context, cfg *occfg.Config) error {
cfg.Groupware.Context = ctx
cfg.Groupware.Commons = cfg.Commons
return groupware.Execute(cfg.Groupware)
})
return s, nil
}

View File

@@ -7,6 +7,7 @@ import (
appProvider "github.com/opencloud-eu/opencloud/services/app-provider/pkg/config"
appRegistry "github.com/opencloud-eu/opencloud/services/app-registry/pkg/config"
audit "github.com/opencloud-eu/opencloud/services/audit/pkg/config"
authapi "github.com/opencloud-eu/opencloud/services/auth-api/pkg/config"
authapp "github.com/opencloud-eu/opencloud/services/auth-app/pkg/config"
authbasic "github.com/opencloud-eu/opencloud/services/auth-basic/pkg/config"
authbearer "github.com/opencloud-eu/opencloud/services/auth-bearer/pkg/config"
@@ -19,6 +20,7 @@ import (
gateway "github.com/opencloud-eu/opencloud/services/gateway/pkg/config"
graph "github.com/opencloud-eu/opencloud/services/graph/pkg/config"
groups "github.com/opencloud-eu/opencloud/services/groups/pkg/config"
groupware "github.com/opencloud-eu/opencloud/services/groupware/pkg/config"
idm "github.com/opencloud-eu/opencloud/services/idm/pkg/config"
idp "github.com/opencloud-eu/opencloud/services/idp/pkg/config"
invitations "github.com/opencloud-eu/opencloud/services/invitations/pkg/config"
@@ -100,6 +102,7 @@ type Config struct {
Gateway *gateway.Config `yaml:"gateway"`
Graph *graph.Config `yaml:"graph"`
Groups *groups.Config `yaml:"groups"`
Groupware *groupware.Config `yaml:"groupware"`
IDM *idm.Config `yaml:"idm"`
IDP *idp.Config `yaml:"idp"`
Invitations *invitations.Config `yaml:"invitations"`
@@ -125,4 +128,5 @@ type Config struct {
WebDAV *webdav.Config `yaml:"webdav"`
Webfinger *webfinger.Config `yaml:"webfinger"`
Search *search.Config `yaml:"search"`
AuthApi *authapi.Config `yaml:"authapi"`
}

View File

@@ -7,6 +7,7 @@ import (
appProvider "github.com/opencloud-eu/opencloud/services/app-provider/pkg/config/defaults"
appRegistry "github.com/opencloud-eu/opencloud/services/app-registry/pkg/config/defaults"
audit "github.com/opencloud-eu/opencloud/services/audit/pkg/config/defaults"
authapi "github.com/opencloud-eu/opencloud/services/auth-api/pkg/config/defaults"
authapp "github.com/opencloud-eu/opencloud/services/auth-app/pkg/config/defaults"
authbasic "github.com/opencloud-eu/opencloud/services/auth-basic/pkg/config/defaults"
authbearer "github.com/opencloud-eu/opencloud/services/auth-bearer/pkg/config/defaults"
@@ -19,6 +20,7 @@ import (
gateway "github.com/opencloud-eu/opencloud/services/gateway/pkg/config/defaults"
graph "github.com/opencloud-eu/opencloud/services/graph/pkg/config/defaults"
groups "github.com/opencloud-eu/opencloud/services/groups/pkg/config/defaults"
groupware "github.com/opencloud-eu/opencloud/services/groupware/pkg/config/defaults"
idm "github.com/opencloud-eu/opencloud/services/idm/pkg/config/defaults"
idp "github.com/opencloud-eu/opencloud/services/idp/pkg/config/defaults"
invitations "github.com/opencloud-eu/opencloud/services/invitations/pkg/config/defaults"
@@ -63,6 +65,7 @@ func DefaultConfig() *Config {
AppProvider: appProvider.DefaultConfig(),
AppRegistry: appRegistry.DefaultConfig(),
Audit: audit.DefaultConfig(),
AuthApi: authapi.DefaultConfig(),
AuthApp: authapp.DefaultConfig(),
AuthBasic: authbasic.DefaultConfig(),
AuthBearer: authbearer.DefaultConfig(),
@@ -75,6 +78,7 @@ func DefaultConfig() *Config {
Gateway: gateway.DefaultConfig(),
Graph: graph.DefaultConfig(),
Groups: groups.DefaultConfig(),
Groupware: groupware.DefaultConfig(),
IDM: idm.DefaultConfig(),
IDP: idp.DefaultConfig(),
Invitations: invitations.DefaultConfig(),

49
pkg/jmap/jmap_api.go Normal file
View File

@@ -0,0 +1,49 @@
package jmap
import (
"context"
"io"
"net/url"
"github.com/opencloud-eu/opencloud/pkg/log"
)
type ApiClient interface {
Command(ctx context.Context, logger *log.Logger, session *Session, request Request, acceptLanguage string) ([]byte, Language, Error)
io.Closer
}
type WsPushListener interface {
OnNotification(username string, stateChange StateChange)
}
type WsClient interface {
DisableNotifications() Error
io.Closer
}
type WsClientFactory interface {
EnableNotifications(pushState State, sessionProvider func() (*Session, error), listener WsPushListener) (WsClient, Error)
io.Closer
}
type SessionClient interface {
GetSession(baseurl *url.URL, username string, logger *log.Logger) (SessionResponse, Error)
io.Closer
}
type BlobClient interface {
UploadBinary(ctx context.Context, logger *log.Logger, session *Session, uploadUrl string, endpoint string, contentType string, acceptLanguage string, content io.Reader) (UploadedBlob, Language, Error)
DownloadBinary(ctx context.Context, logger *log.Logger, session *Session, downloadUrl string, endpoint string, acceptLanguage string) (*BlobDownload, Language, Error)
io.Closer
}
const (
logOperation = "operation"
logFetchBodies = "fetch-bodies"
logOffset = "offset"
logLimit = "limit"
logDownloadUrl = "download-url"
logBlobId = "blob-id"
logSinceState = "since-state"
)

137
pkg/jmap/jmap_api_blob.go Normal file
View File

@@ -0,0 +1,137 @@
package jmap
import (
"context"
"encoding/base64"
"io"
"strings"
"github.com/opencloud-eu/opencloud/pkg/log"
)
func (j *Client) GetBlobMetadata(accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, id string) (*Blob, SessionState, State, Language, Error) {
cmd, jerr := j.request(session, logger,
invocation(CommandBlobGet, BlobGetCommand{
AccountId: accountId,
Ids: []string{id},
// add BlobPropertyData to retrieve the data
Properties: []string{BlobPropertyDigestSha256, BlobPropertyDigestSha512, BlobPropertySize},
}, "0"),
)
if jerr != nil {
return nil, "", "", "", jerr
}
return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (*Blob, State, Error) {
var response BlobGetResponse
err := retrieveResponseMatchParameters(logger, body, CommandBlobGet, "0", &response)
if err != nil {
return nil, "", err
}
if len(response.List) != 1 {
logger.Error().Msgf("%T.List has %v entries instead of 1", response, len(response.List))
return nil, "", simpleError(err, JmapErrorInvalidJmapResponsePayload)
}
get := response.List[0]
return &get, response.State, nil
})
}
type UploadedBlobWithHash struct {
BlobId string `json:"blobId"`
Size int `json:"size,omitzero"`
Type string `json:"type,omitempty"`
Sha512 string `json:"sha:512,omitempty"`
}
func (j *Client) UploadBlobStream(accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, contentType string, body io.Reader) (UploadedBlob, Language, Error) {
logger = log.From(logger.With().Str(logEndpoint, session.UploadEndpoint))
// TODO(pbleser-oc) use a library for proper URL template parsing
uploadUrl := strings.ReplaceAll(session.UploadUrlTemplate, "{accountId}", accountId)
return j.blob.UploadBinary(ctx, logger, session, uploadUrl, session.UploadEndpoint, contentType, acceptLanguage, body)
}
func (j *Client) DownloadBlobStream(accountId string, blobId string, name string, typ string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string) (*BlobDownload, Language, Error) {
logger = log.From(logger.With().Str(logEndpoint, session.DownloadEndpoint))
// TODO(pbleser-oc) use a library for proper URL template parsing
downloadUrl := session.DownloadUrlTemplate
downloadUrl = strings.ReplaceAll(downloadUrl, "{accountId}", accountId)
downloadUrl = strings.ReplaceAll(downloadUrl, "{blobId}", blobId)
downloadUrl = strings.ReplaceAll(downloadUrl, "{name}", name)
downloadUrl = strings.ReplaceAll(downloadUrl, "{type}", typ)
logger = log.From(logger.With().Str(logDownloadUrl, downloadUrl).Str(logBlobId, blobId))
return j.blob.DownloadBinary(ctx, logger, session, downloadUrl, session.DownloadEndpoint, acceptLanguage)
}
func (j *Client) UploadBlob(accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, data []byte, contentType string) (UploadedBlobWithHash, SessionState, State, Language, Error) {
encoded := base64.StdEncoding.EncodeToString(data)
upload := BlobUploadCommand{
AccountId: accountId,
Create: map[string]UploadObject{
"0": {
Data: []DataSourceObject{{
DataAsBase64: encoded,
}},
Type: contentType,
},
},
}
getHash := BlobGetRefCommand{
AccountId: accountId,
IdRef: &ResultReference{
ResultOf: "0",
Name: CommandBlobUpload,
Path: "/ids",
},
Properties: []string{BlobPropertyDigestSha512},
}
cmd, jerr := j.request(session, logger,
invocation(CommandBlobUpload, upload, "0"),
invocation(CommandBlobGet, getHash, "1"),
)
if jerr != nil {
return UploadedBlobWithHash{}, "", "", "", jerr
}
return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (UploadedBlobWithHash, State, Error) {
var uploadResponse BlobUploadResponse
err := retrieveResponseMatchParameters(logger, body, CommandBlobUpload, "0", &uploadResponse)
if err != nil {
return UploadedBlobWithHash{}, "", err
}
var getResponse BlobGetResponse
err = retrieveResponseMatchParameters(logger, body, CommandBlobGet, "1", &getResponse)
if err != nil {
return UploadedBlobWithHash{}, "", err
}
if len(uploadResponse.Created) != 1 {
logger.Error().Msgf("%T.Created has %v entries instead of 1", uploadResponse, len(uploadResponse.Created))
return UploadedBlobWithHash{}, "", simpleError(err, JmapErrorInvalidJmapResponsePayload)
}
upload, ok := uploadResponse.Created["0"]
if !ok {
logger.Error().Msgf("%T.Created has no item '0'", uploadResponse)
return UploadedBlobWithHash{}, "", simpleError(err, JmapErrorInvalidJmapResponsePayload)
}
if len(getResponse.List) != 1 {
logger.Error().Msgf("%T.List has %v entries instead of 1", getResponse, len(getResponse.List))
return UploadedBlobWithHash{}, "", simpleError(err, JmapErrorInvalidJmapResponsePayload)
}
get := getResponse.List[0]
return UploadedBlobWithHash{
BlobId: upload.Id,
Size: upload.Size,
Type: upload.Type,
Sha512: get.DigestSha512,
}, getResponse.State, nil
})
}

View File

@@ -0,0 +1,75 @@
package jmap
import (
"context"
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/opencloud/pkg/structs"
)
type AccountBootstrapResult struct {
Identities []Identity `json:"identities,omitempty"`
Quotas []Quota `json:"quotas,omitempty"`
}
func (j *Client) GetBootstrap(accountIds []string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string) (map[string]AccountBootstrapResult, SessionState, State, Language, Error) {
uniqueAccountIds := structs.Uniq(accountIds)
logger = j.logger("GetBootstrap", session, logger)
calls := make([]Invocation, len(uniqueAccountIds)*2)
for i, accountId := range uniqueAccountIds {
calls[i*2+0] = invocation(CommandIdentityGet, IdentityGetCommand{AccountId: accountId}, mcid(accountId, "I"))
calls[i*2+1] = invocation(CommandQuotaGet, QuotaGetCommand{AccountId: accountId}, mcid(accountId, "Q"))
}
cmd, err := j.request(session, logger, calls...)
if err != nil {
return nil, "", "", "", err
}
return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (map[string]AccountBootstrapResult, State, Error) {
identityPerAccount := map[string][]Identity{}
quotaPerAccount := map[string][]Quota{}
identityStatesPerAccount := map[string]State{}
quotaStatesPerAccount := map[string]State{}
for _, accountId := range uniqueAccountIds {
var identityResponse IdentityGetResponse
err = retrieveResponseMatchParameters(logger, body, CommandIdentityGet, mcid(accountId, "I"), &identityResponse)
if err != nil {
return nil, "", err
} else {
identityPerAccount[accountId] = identityResponse.List
identityStatesPerAccount[accountId] = identityResponse.State
}
var quotaResponse QuotaGetResponse
err = retrieveResponseMatchParameters(logger, body, CommandQuotaGet, mcid(accountId, "Q"), &quotaResponse)
if err != nil {
return nil, "", err
} else {
quotaPerAccount[accountId] = quotaResponse.List
quotaStatesPerAccount[accountId] = quotaResponse.State
}
}
result := map[string]AccountBootstrapResult{}
for accountId, value := range identityPerAccount {
r, ok := result[accountId]
if !ok {
r = AccountBootstrapResult{}
}
r.Identities = value
result[accountId] = r
}
for accountId, value := range quotaPerAccount {
r, ok := result[accountId]
if !ok {
r = AccountBootstrapResult{}
}
r.Quotas = value
result[accountId] = r
}
return result, squashStateMaps(identityStatesPerAccount, quotaStatesPerAccount), nil
})
}

View File

@@ -0,0 +1,250 @@
package jmap
import (
"context"
"fmt"
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/opencloud/pkg/structs"
)
func (j *Client) ParseICalendarBlob(accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, blobIds []string) (CalendarEventParseResponse, SessionState, State, Language, Error) {
logger = j.logger("ParseICalendarBlob", session, logger)
cmd, err := j.request(session, logger,
invocation(CommandCalendarEventParse, CalendarEventParseCommand{AccountId: accountId, BlobIds: blobIds}, "0"),
)
if err != nil {
return CalendarEventParseResponse{}, "", "", "", err
}
return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (CalendarEventParseResponse, State, Error) {
var response CalendarEventParseResponse
err = retrieveResponseMatchParameters(logger, body, CommandCalendarEventParse, "0", &response)
if err != nil {
return CalendarEventParseResponse{}, "", err
}
return response, "", nil
})
}
type CalendarsResponse struct {
Calendars []Calendar `json:"calendars"`
NotFound []string `json:"notFound,omitempty"`
}
func (j *Client) GetCalendars(accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, ids []string) (CalendarsResponse, SessionState, State, Language, Error) {
return getTemplate(j, "GetCalendars", CommandCalendarGet,
func(accountId string, ids []string) CalendarGetCommand {
return CalendarGetCommand{AccountId: accountId, Ids: ids}
},
func(resp CalendarGetResponse) CalendarsResponse {
return CalendarsResponse{Calendars: resp.List, NotFound: resp.NotFound}
},
func(resp CalendarGetResponse) State { return resp.State },
accountId, session, ctx, logger, acceptLanguage, ids,
)
}
func (j *Client) QueryCalendarEvents(accountIds []string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string,
filter CalendarEventFilterElement, sortBy []CalendarEventComparator,
position uint, limit uint) (map[string][]CalendarEvent, SessionState, State, Language, Error) {
logger = j.logger("QueryCalendarEvents", session, logger)
uniqueAccountIds := structs.Uniq(accountIds)
if sortBy == nil {
sortBy = []CalendarEventComparator{{Property: CalendarEventPropertyStart, IsAscending: false}}
}
invocations := make([]Invocation, len(uniqueAccountIds)*2)
for i, accountId := range uniqueAccountIds {
query := CalendarEventQueryCommand{
AccountId: accountId,
Filter: filter,
Sort: sortBy,
}
if limit > 0 {
query.Limit = limit
}
if position > 0 {
query.Position = position
}
invocations[i*2+0] = invocation(CommandCalendarEventQuery, query, mcid(accountId, "0"))
invocations[i*2+1] = invocation(CommandCalendarEventGet, CalendarEventGetRefCommand{
AccountId: accountId,
IdsRef: &ResultReference{
Name: CommandCalendarEventQuery,
Path: "/ids/*",
ResultOf: mcid(accountId, "0"),
},
// Properties: CalendarEventProperties, // to also retrieve UTCStart and UTCEnd
}, mcid(accountId, "1"))
}
cmd, err := j.request(session, logger, invocations...)
if err != nil {
return nil, "", "", "", err
}
return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (map[string][]CalendarEvent, State, Error) {
resp := map[string][]CalendarEvent{}
stateByAccountId := map[string]State{}
for _, accountId := range uniqueAccountIds {
var response CalendarEventGetResponse
err = retrieveResponseMatchParameters(logger, body, CommandCalendarEventGet, mcid(accountId, "1"), &response)
if err != nil {
return nil, "", err
}
if len(response.NotFound) > 0 {
// TODO what to do when there are not-found emails here? potentially nothing, they could have been deleted between query and get?
}
resp[accountId] = response.List
stateByAccountId[accountId] = response.State
}
return resp, squashState(stateByAccountId), nil
})
}
func (j *Client) CreateCalendarEvent(accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, create CalendarEvent) (*CalendarEvent, SessionState, State, Language, Error) {
return createTemplate(j, "CreateCalendarEvent", CalendarEventType, CommandCalendarEventSet, CommandCalendarEventGet,
func(accountId string, create map[string]CalendarEvent) CalendarEventSetCommand {
return CalendarEventSetCommand{AccountId: accountId, Create: create}
},
func(accountId string, ref string) CalendarEventGetCommand {
return CalendarEventGetCommand{AccountId: accountId, Ids: []string{ref}}
},
func(resp CalendarEventSetResponse) map[string]*CalendarEvent {
return resp.Created
},
func(resp CalendarEventSetResponse) map[string]SetError {
return resp.NotCreated
},
func(resp CalendarEventGetResponse) []CalendarEvent {
return resp.List
},
func(resp CalendarEventSetResponse) State {
return resp.NewState
},
accountId, session, ctx, logger, acceptLanguage, create)
}
func (j *Client) DeleteCalendarEvent(accountId string, destroy []string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string) (map[string]SetError, SessionState, State, Language, Error) {
return deleteTemplate(j, "DeleteCalendarEvent", CommandCalendarEventSet,
func(accountId string, destroy []string) CalendarEventSetCommand {
return CalendarEventSetCommand{AccountId: accountId, Destroy: destroy}
},
func(resp CalendarEventSetResponse) map[string]SetError { return resp.NotDestroyed },
func(resp CalendarEventSetResponse) State { return resp.NewState },
accountId, destroy, session, ctx, logger, acceptLanguage)
}
func getTemplate[GETREQ any, GETRESP any, RESP any](
client *Client, name string, getCommand Command,
getCommandFactory func(string, []string) GETREQ,
mapper func(GETRESP) RESP,
stateMapper func(GETRESP) State,
accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, ids []string) (RESP, SessionState, State, Language, Error) {
logger = client.logger(name, session, logger)
var zero RESP
cmd, err := client.request(session, logger,
invocation(getCommand, getCommandFactory(accountId, ids), "0"),
)
if err != nil {
return zero, "", "", "", err
}
return command(client.api, logger, ctx, session, client.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (RESP, State, Error) {
var response GETRESP
err = retrieveResponseMatchParameters(logger, body, getCommand, "0", &response)
if err != nil {
return zero, "", err
}
return mapper(response), stateMapper(response), nil
})
}
func createTemplate[T any, SETREQ any, GETREQ any, SETRESP any, GETRESP any](
client *Client, name string, t ObjectType, setCommand Command, getCommand Command,
setCommandFactory func(string, map[string]T) 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) {
logger = client.logger(name, session, logger)
createMap := map[string]T{"c": create}
cmd, err := client.request(session, logger,
invocation(setCommand, setCommandFactory(accountId, createMap), "0"),
invocation(getCommand, getCommandFactory(accountId, "#c"), "1"),
)
if err != nil {
return nil, "", "", "", err
}
return command(client.api, logger, ctx, session, client.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (*T, State, Error) {
var setResponse SETRESP
err = retrieveResponseMatchParameters(logger, body, setCommand, "0", &setResponse)
if err != nil {
return nil, "", err
}
notCreatedMap := notCreatedMapper(setResponse)
setErr, notok := notCreatedMap["c"]
if notok {
logger.Error().Msgf("%T.NotCreated returned an error %v", setResponse, setErr)
return nil, "", setErrorError(setErr, t)
}
createdMap := createdMapper(setResponse)
if created, ok := createdMap["c"]; !ok || created == nil {
berr := fmt.Errorf("failed to find %s in %s response", string(t), string(setCommand))
logger.Error().Err(berr)
return nil, "", simpleError(berr, JmapErrorInvalidJmapResponsePayload)
}
var getResponse GETRESP
err = retrieveResponseMatchParameters(logger, body, getCommand, "1", &getResponse)
if err != nil {
return nil, "", err
}
list := listMapper(getResponse)
if len(list) < 1 {
berr := fmt.Errorf("failed to find %s in %s response", string(t), string(getCommand))
logger.Error().Err(berr)
return nil, "", simpleError(berr, JmapErrorInvalidJmapResponsePayload)
}
return &list[0], stateMapper(setResponse), nil
})
}
func deleteTemplate[REQ any, RESP any](client *Client, name string, c Command,
commandFactory func(string, []string) REQ,
notDestroyedMapper func(RESP) map[string]SetError,
stateMapper func(RESP) State,
accountId string, destroy []string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string) (map[string]SetError, SessionState, State, Language, Error) {
logger = client.logger(name, session, logger)
cmd, err := client.request(session, logger,
invocation(c, commandFactory(accountId, destroy), "0"),
)
if err != nil {
return nil, "", "", "", err
}
return command(client.api, logger, ctx, session, client.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (map[string]SetError, State, Error) {
var setResponse RESP
err = retrieveResponseMatchParameters(logger, body, c, "0", &setResponse)
if err != nil {
return nil, "", err
}
return notDestroyedMapper(setResponse), stateMapper(setResponse), nil
})
}

View File

@@ -0,0 +1,199 @@
package jmap
import (
"context"
"fmt"
"github.com/opencloud-eu/opencloud/pkg/jscontact"
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/opencloud/pkg/structs"
)
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,
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
})
}
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)
cmd, err := j.request(session, logger, invocation(CommandContactCardGet, ContactCardGetCommand{
Ids: contactIds,
AccountId: accountId,
}, "0"))
if err != nil {
return nil, "", "", "", err
}
return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (map[string]jscontact.ContactCard, State, Error) {
var response ContactCardGetResponse
err = retrieveResponseMatchParameters(logger, body, CommandContactCardGet, "0", &response)
if err != nil {
return nil, "", err
}
m := map[string]jscontact.ContactCard{}
for _, contact := range response.List {
m[contact.Id] = contact
}
return m, response.State, nil
})
}
func (j *Client) QueryContactCards(accountIds []string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string,
filter ContactCardFilterElement, sortBy []ContactCardComparator,
position uint, limit uint) (map[string][]jscontact.ContactCard, SessionState, State, Language, Error) {
logger = j.logger("QueryContactCards", session, logger)
uniqueAccountIds := structs.Uniq(accountIds)
if sortBy == nil {
sortBy = []ContactCardComparator{{Property: jscontact.ContactCardPropertyUpdated, IsAscending: false}}
}
invocations := make([]Invocation, len(uniqueAccountIds)*2)
for i, accountId := range uniqueAccountIds {
query := ContactCardQueryCommand{
AccountId: accountId,
Filter: filter,
Sort: sortBy,
}
if limit > 0 {
query.Limit = limit
}
if position > 0 {
query.Position = position
}
invocations[i*2+0] = invocation(CommandContactCardQuery, query, mcid(accountId, "0"))
invocations[i*2+1] = invocation(CommandContactCardGet, ContactCardGetRefCommand{
AccountId: accountId,
IdsRef: &ResultReference{
Name: CommandContactCardQuery,
Path: "/ids/*",
ResultOf: mcid(accountId, "0"),
},
}, mcid(accountId, "1"))
}
cmd, err := j.request(session, logger, invocations...)
if err != nil {
return nil, "", "", "", err
}
return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (map[string][]jscontact.ContactCard, State, Error) {
resp := map[string][]jscontact.ContactCard{}
stateByAccountId := map[string]State{}
for _, accountId := range uniqueAccountIds {
var response ContactCardGetResponse
err = retrieveResponseMatchParameters(logger, body, CommandContactCardGet, mcid(accountId, "1"), &response)
if err != nil {
return nil, "", err
}
if len(response.NotFound) > 0 {
// TODO what to do when there are not-found emails here? potentially nothing, they could have been deleted between query and get?
}
resp[accountId] = response.List
stateByAccountId[accountId] = response.State
}
return resp, squashState(stateByAccountId), nil
})
}
func (j *Client) CreateContactCard(accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, create jscontact.ContactCard) (*jscontact.ContactCard, SessionState, State, Language, Error) {
logger = j.logger("CreateContactCard", session, logger)
cmd, err := j.request(session, logger,
invocation(CommandContactCardSet, ContactCardSetCommand{
AccountId: accountId,
Create: map[string]jscontact.ContactCard{
"c": create,
},
}, "0"),
invocation(CommandContactCardGet, ContactCardGetCommand{
AccountId: accountId,
Ids: []string{"#c"},
}, "1"),
)
if err != nil {
return nil, "", "", "", err
}
return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (*jscontact.ContactCard, State, Error) {
var setResponse ContactCardSetResponse
err = retrieveResponseMatchParameters(logger, body, CommandContactCardSet, "0", &setResponse)
if err != nil {
return nil, "", err
}
setErr, notok := setResponse.NotCreated["c"]
if notok {
logger.Error().Msgf("%T.NotCreated returned an error %v", setResponse, setErr)
return nil, "", setErrorError(setErr, EmailType)
}
if created, ok := setResponse.Created["c"]; !ok || created == nil {
berr := fmt.Errorf("failed to find %s in %s response", string(ContactCardType), string(CommandContactCardSet))
logger.Error().Err(berr)
return nil, "", simpleError(berr, JmapErrorInvalidJmapResponsePayload)
}
var getResponse ContactCardGetResponse
err = retrieveResponseMatchParameters(logger, body, CommandContactCardGet, "1", &getResponse)
if err != nil {
return nil, "", err
}
if len(getResponse.List) < 1 {
berr := fmt.Errorf("failed to find %s in %s response", string(ContactCardType), string(CommandContactCardSet))
logger.Error().Err(berr)
return nil, "", simpleError(berr, JmapErrorInvalidJmapResponsePayload)
}
return &getResponse.List[0], setResponse.NewState, nil
})
}
func (j *Client) DeleteContactCard(accountId string, destroy []string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string) (map[string]SetError, SessionState, State, Language, Error) {
logger = j.logger("DeleteContactCard", session, logger)
cmd, err := j.request(session, logger,
invocation(CommandContactCardSet, ContactCardSetCommand{
AccountId: accountId,
Destroy: destroy,
}, "0"),
)
if err != nil {
return nil, "", "", "", err
}
return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (map[string]SetError, State, Error) {
var setResponse ContactCardSetResponse
err = retrieveResponseMatchParameters(logger, body, CommandContactCardSet, "0", &setResponse)
if err != nil {
return nil, "", err
}
return setResponse.NotDestroyed, setResponse.NewState, nil
})
}

1105
pkg/jmap/jmap_api_email.go Normal file
View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,208 @@
package jmap
import (
"context"
"strconv"
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/opencloud/pkg/structs"
)
func (j *Client) GetAllIdentities(accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string) ([]Identity, SessionState, State, Language, Error) {
logger = j.logger("GetAllIdentities", session, logger)
cmd, err := j.request(session, logger, invocation(CommandIdentityGet, IdentityGetCommand{AccountId: accountId}, "0"))
if err != nil {
return nil, "", "", "", err
}
return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) ([]Identity, State, Error) {
var response IdentityGetResponse
err = retrieveResponseMatchParameters(logger, body, CommandIdentityGet, "0", &response)
if err != nil {
return nil, "", err
}
return response.List, response.State, nil
})
}
func (j *Client) GetIdentities(accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, identityIds []string) ([]Identity, SessionState, State, Language, Error) {
logger = j.logger("GetIdentities", session, logger)
cmd, err := j.request(session, logger, invocation(CommandIdentityGet, IdentityGetCommand{AccountId: accountId, Ids: identityIds}, "0"))
if err != nil {
return nil, "", "", "", err
}
return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) ([]Identity, State, Error) {
var response IdentityGetResponse
err = retrieveResponseMatchParameters(logger, body, CommandIdentityGet, "0", &response)
if err != nil {
return nil, "", err
}
return response.List, response.State, nil
})
}
type IdentitiesGetResponse struct {
Identities map[string][]Identity `json:"identities,omitempty"`
NotFound []string `json:"notFound,omitempty"`
}
func (j *Client) GetIdentitiesForAllAccounts(accountIds []string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string) (IdentitiesGetResponse, SessionState, State, Language, Error) {
logger = j.logger("GetIdentitiesForAllAccounts", session, logger)
uniqueAccountIds := structs.Uniq(accountIds)
calls := make([]Invocation, len(uniqueAccountIds))
for i, accountId := range uniqueAccountIds {
calls[i] = invocation(CommandIdentityGet, IdentityGetCommand{AccountId: accountId}, strconv.Itoa(i))
}
cmd, err := j.request(session, logger, calls...)
if err != nil {
return IdentitiesGetResponse{}, "", "", "", err
}
return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (IdentitiesGetResponse, State, Error) {
identities := make(map[string][]Identity, len(uniqueAccountIds))
stateByAccountId := make(map[string]State, len(uniqueAccountIds))
notFound := []string{}
for i, accountId := range uniqueAccountIds {
var response IdentityGetResponse
err = retrieveResponseMatchParameters(logger, body, CommandIdentityGet, strconv.Itoa(i), &response)
if err != nil {
return IdentitiesGetResponse{}, "", err
} else {
identities[accountId] = response.List
}
stateByAccountId[accountId] = response.State
notFound = append(notFound, response.NotFound...)
}
return IdentitiesGetResponse{
Identities: identities,
NotFound: structs.Uniq(notFound),
}, squashState(stateByAccountId), nil
})
}
type IdentitiesAndMailboxesGetResponse struct {
Identities map[string][]Identity `json:"identities,omitempty"`
NotFound []string `json:"notFound,omitempty"`
Mailboxes []Mailbox `json:"mailboxes"`
}
func (j *Client) GetIdentitiesAndMailboxes(mailboxAccountId string, accountIds []string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string) (IdentitiesAndMailboxesGetResponse, SessionState, State, Language, Error) {
uniqueAccountIds := structs.Uniq(accountIds)
logger = j.logger("GetIdentitiesAndMailboxes", session, logger)
calls := make([]Invocation, len(uniqueAccountIds)+1)
calls[0] = invocation(CommandMailboxGet, MailboxGetCommand{AccountId: mailboxAccountId}, "0")
for i, accountId := range uniqueAccountIds {
calls[i+1] = invocation(CommandIdentityGet, IdentityGetCommand{AccountId: accountId}, strconv.Itoa(i+1))
}
cmd, err := j.request(session, logger, calls...)
if err != nil {
return IdentitiesAndMailboxesGetResponse{}, "", "", "", err
}
return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (IdentitiesAndMailboxesGetResponse, State, Error) {
identities := make(map[string][]Identity, len(uniqueAccountIds))
stateByAccountId := make(map[string]State, len(uniqueAccountIds))
notFound := []string{}
for i, accountId := range uniqueAccountIds {
var response IdentityGetResponse
err = retrieveResponseMatchParameters(logger, body, CommandIdentityGet, strconv.Itoa(i+1), &response)
if err != nil {
return IdentitiesAndMailboxesGetResponse{}, "", err
} else {
identities[accountId] = response.List
}
stateByAccountId[accountId] = response.State
notFound = append(notFound, response.NotFound...)
}
var mailboxResponse MailboxGetResponse
err = retrieveResponseMatchParameters(logger, body, CommandMailboxGet, "0", &mailboxResponse)
if err != nil {
return IdentitiesAndMailboxesGetResponse{}, "", err
}
return IdentitiesAndMailboxesGetResponse{
Identities: identities,
NotFound: structs.Uniq(notFound),
Mailboxes: mailboxResponse.List,
}, squashState(stateByAccountId), nil
})
}
func (j *Client) CreateIdentity(accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, identity Identity) (Identity, SessionState, State, Language, Error) {
logger = j.logger("CreateIdentity", session, logger)
cmd, err := j.request(session, logger, invocation(CommandIdentitySet, IdentitySetCommand{
AccountId: accountId,
Create: map[string]Identity{
"c": identity,
},
}, "0"))
if err != nil {
return Identity{}, "", "", "", err
}
return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (Identity, State, Error) {
var response IdentitySetResponse
err = retrieveResponseMatchParameters(logger, body, CommandIdentitySet, "0", &response)
if err != nil {
return Identity{}, response.NewState, err
}
setErr, notok := response.NotCreated["c"]
if notok {
logger.Error().Msgf("%T.NotCreated returned an error %v", response, setErr)
return Identity{}, "", setErrorError(setErr, IdentityType)
}
return response.Created["c"], response.NewState, nil
})
}
func (j *Client) UpdateIdentity(accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, identity Identity) (Identity, SessionState, State, Language, Error) {
logger = j.logger("UpdateIdentity", session, logger)
cmd, err := j.request(session, logger, invocation(CommandIdentitySet, IdentitySetCommand{
AccountId: accountId,
Update: map[string]PatchObject{
"c": identity.AsPatch(),
},
}, "0"))
if err != nil {
return Identity{}, "", "", "", err
}
return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (Identity, State, Error) {
var response IdentitySetResponse
err = retrieveResponseMatchParameters(logger, body, CommandIdentitySet, "0", &response)
if err != nil {
return Identity{}, response.NewState, err
}
setErr, notok := response.NotCreated["c"]
if notok {
logger.Error().Msgf("%T.NotCreated returned an error %v", response, setErr)
return Identity{}, "", setErrorError(setErr, IdentityType)
}
return response.Created["c"], response.NewState, nil
})
}
func (j *Client) DeleteIdentity(accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, ids []string) ([]string, SessionState, State, Language, Error) {
logger = j.logger("DeleteIdentity", session, logger)
cmd, err := j.request(session, logger, invocation(CommandIdentitySet, IdentitySetCommand{
AccountId: accountId,
Destroy: ids,
}, "0"))
if err != nil {
return nil, "", "", "", err
}
return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) ([]string, State, Error) {
var response IdentitySetResponse
err = retrieveResponseMatchParameters(logger, body, CommandIdentitySet, "0", &response)
if err != nil {
return nil, "", err
}
for _, setErr := range response.NotDestroyed {
// TODO only returning the first error here, we should probably aggregate them instead
logger.Error().Msgf("%T.NotCreated returned an error %v", response, setErr)
return nil, "", setErrorError(setErr, IdentityType)
}
return response.Destroyed, response.NewState, nil
})
}

View File

@@ -0,0 +1,518 @@
package jmap
import (
"context"
"fmt"
"slices"
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/opencloud/pkg/structs"
"github.com/rs/zerolog"
)
type MailboxesResponse struct {
Mailboxes []Mailbox `json:"mailboxes"`
NotFound []any `json:"notFound"`
}
// https://jmap.io/spec-mail.html#mailboxget
func (j *Client) GetMailbox(accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, ids []string) (MailboxesResponse, SessionState, State, Language, Error) {
logger = j.logger("GetMailbox", session, logger)
cmd, err := j.request(session, logger,
invocation(CommandMailboxGet, MailboxGetCommand{AccountId: accountId, Ids: ids}, "0"),
)
if err != nil {
return MailboxesResponse{}, "", "", "", err
}
return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (MailboxesResponse, State, Error) {
var response MailboxGetResponse
err = retrieveResponseMatchParameters(logger, body, CommandMailboxGet, "0", &response)
if err != nil {
return MailboxesResponse{}, "", err
}
return MailboxesResponse{
Mailboxes: response.List,
NotFound: response.NotFound,
}, response.State, nil
})
}
func (j *Client) GetAllMailboxes(accountIds []string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string) (map[string][]Mailbox, SessionState, State, Language, Error) {
logger = j.logger("GetAllMailboxes", session, logger)
uniqueAccountIds := structs.Uniq(accountIds)
n := len(uniqueAccountIds)
if n < 1 {
return nil, "", "", "", nil
}
invocations := make([]Invocation, n)
for i, accountId := range uniqueAccountIds {
invocations[i] = invocation(CommandMailboxGet, MailboxGetCommand{AccountId: accountId}, mcid(accountId, "0"))
}
cmd, err := j.request(session, logger, invocations...)
if err != nil {
return nil, "", "", "", err
}
return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (map[string][]Mailbox, State, Error) {
resp := map[string][]Mailbox{}
stateByAccountid := map[string]State{}
for _, accountId := range uniqueAccountIds {
var response MailboxGetResponse
err = retrieveResponseMatchParameters(logger, body, CommandMailboxGet, mcid(accountId, "0"), &response)
if err != nil {
return nil, "", err
}
resp[accountId] = response.List
stateByAccountid[accountId] = response.State
}
return resp, squashState(stateByAccountid), nil
})
}
func (j *Client) SearchMailboxes(accountIds []string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, filter MailboxFilterElement) (map[string][]Mailbox, SessionState, State, Language, Error) {
logger = j.logger("SearchMailboxes", session, logger)
uniqueAccountIds := structs.Uniq(accountIds)
invocations := make([]Invocation, len(uniqueAccountIds)*2)
for i, accountId := range uniqueAccountIds {
invocations[i*2+0] = invocation(CommandMailboxQuery, MailboxQueryCommand{AccountId: accountId, Filter: filter}, mcid(accountId, "0"))
invocations[i*2+1] = invocation(CommandMailboxGet, MailboxGetRefCommand{
AccountId: accountId,
IdsRef: &ResultReference{
Name: CommandMailboxQuery,
Path: "/ids/*",
ResultOf: mcid(accountId, "0"),
},
}, mcid(accountId, "1"))
}
cmd, err := j.request(session, logger, invocations...)
if err != nil {
return nil, "", "", "", err
}
return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (map[string][]Mailbox, State, Error) {
resp := map[string][]Mailbox{}
stateByAccountid := map[string]State{}
for _, accountId := range uniqueAccountIds {
var response MailboxGetResponse
err = retrieveResponseMatchParameters(logger, body, CommandMailboxGet, mcid(accountId, "1"), &response)
if err != nil {
return nil, "", err
}
resp[accountId] = response.List
stateByAccountid[accountId] = response.State
}
return resp, squashState(stateByAccountid), nil
})
}
func (j *Client) SearchMailboxIdsPerRole(accountIds []string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, roles []string) (map[string]map[string]string, SessionState, State, Language, Error) {
logger = j.logger("SearchMailboxIdsPerRole", session, logger)
uniqueAccountIds := structs.Uniq(accountIds)
invocations := make([]Invocation, len(uniqueAccountIds)*len(roles))
for i, accountId := range uniqueAccountIds {
for j, role := range roles {
invocations[i*len(roles)+j] = invocation(CommandMailboxQuery, MailboxQueryCommand{AccountId: accountId, Filter: MailboxFilterCondition{Role: role}}, mcid(accountId, role))
}
}
cmd, err := j.request(session, logger, invocations...)
if err != nil {
return nil, "", "", "", err
}
return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (map[string]map[string]string, State, Error) {
resp := map[string]map[string]string{}
stateByAccountid := map[string]State{}
for _, accountId := range uniqueAccountIds {
mailboxIdsByRole := map[string]string{}
for _, role := range roles {
var response MailboxQueryResponse
err = retrieveResponseMatchParameters(logger, body, CommandMailboxQuery, mcid(accountId, role), &response)
if err != nil {
return nil, "", err
}
if len(response.Ids) == 1 {
mailboxIdsByRole[role] = response.Ids[0]
}
if _, ok := stateByAccountid[accountId]; !ok {
stateByAccountid[accountId] = response.QueryState
}
}
resp[accountId] = mailboxIdsByRole
}
return resp, squashState(stateByAccountid), nil
})
}
type MailboxChanges struct {
Destroyed []string `json:"destroyed,omitzero"`
HasMoreChanges bool `json:"hasMoreChanges,omitzero"`
NewState State `json:"newState"`
Created []Email `json:"created,omitempty"`
Updated []Email `json:"updated,omitempty"`
}
// Retrieve Email changes in a given Mailbox since a given state.
func (j *Client) GetMailboxChanges(accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, mailboxId string, sinceState string, fetchBodies bool, maxBodyValueBytes uint, maxChanges uint) (MailboxChanges, SessionState, State, Language, Error) {
logger = j.loggerParams("GetMailboxChanges", session, logger, func(z zerolog.Context) zerolog.Context {
return z.Bool(logFetchBodies, fetchBodies).Str(logSinceState, sinceState)
})
changes := MailboxChangesCommand{
AccountId: accountId,
SinceState: sinceState,
}
if maxChanges > 0 {
changes.MaxChanges = maxChanges
}
getCreated := EmailGetRefCommand{
AccountId: accountId,
FetchAllBodyValues: fetchBodies,
IdsRef: &ResultReference{Name: CommandMailboxChanges, Path: "/created", ResultOf: "0"},
}
if maxBodyValueBytes > 0 {
getCreated.MaxBodyValueBytes = maxBodyValueBytes
}
getUpdated := EmailGetRefCommand{
AccountId: accountId,
FetchAllBodyValues: fetchBodies,
IdsRef: &ResultReference{Name: CommandMailboxChanges, Path: "/updated", ResultOf: "0"},
}
if maxBodyValueBytes > 0 {
getUpdated.MaxBodyValueBytes = maxBodyValueBytes
}
cmd, err := j.request(session, logger,
invocation(CommandMailboxChanges, changes, "0"),
invocation(CommandEmailGet, getCreated, "1"),
invocation(CommandEmailGet, getUpdated, "2"),
)
if err != nil {
return MailboxChanges{}, "", "", "", err
}
return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (MailboxChanges, State, Error) {
var mailboxResponse MailboxChangesResponse
err = retrieveResponseMatchParameters(logger, body, CommandMailboxChanges, "0", &mailboxResponse)
if err != nil {
return MailboxChanges{}, "", err
}
var createdResponse EmailGetResponse
err = retrieveResponseMatchParameters(logger, body, CommandEmailGet, "1", &createdResponse)
if err != nil {
logger.Error().Err(err).Send()
return MailboxChanges{}, "", err
}
var updatedResponse EmailGetResponse
err = retrieveResponseMatchParameters(logger, body, CommandEmailGet, "2", &updatedResponse)
if err != nil {
logger.Error().Err(err).Send()
return MailboxChanges{}, "", err
}
return MailboxChanges{
Destroyed: mailboxResponse.Destroyed,
HasMoreChanges: mailboxResponse.HasMoreChanges,
NewState: mailboxResponse.NewState,
Created: createdResponse.List,
Updated: createdResponse.List,
}, createdResponse.State, nil
})
}
// Retrieve Email changes in Mailboxes of multiple Accounts.
func (j *Client) GetMailboxChangesForMultipleAccounts(accountIds []string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, sinceStateMap map[string]string, fetchBodies bool, maxBodyValueBytes uint, maxChanges uint) (map[string]MailboxChanges, SessionState, State, Language, Error) {
logger = j.loggerParams("GetMailboxChangesForMultipleAccounts", session, logger, func(z zerolog.Context) zerolog.Context {
sinceStateLogDict := zerolog.Dict()
for k, v := range sinceStateMap {
sinceStateLogDict.Str(log.SafeString(k), log.SafeString(v))
}
return z.Bool(logFetchBodies, fetchBodies).Dict(logSinceState, sinceStateLogDict)
})
uniqueAccountIds := structs.Uniq(accountIds)
n := len(uniqueAccountIds)
if n < 1 {
return map[string]MailboxChanges{}, "", "", "", nil
}
invocations := make([]Invocation, n*3)
for i, accountId := range uniqueAccountIds {
changes := MailboxChangesCommand{
AccountId: accountId,
}
sinceState, ok := sinceStateMap[accountId]
if ok {
changes.SinceState = sinceState
}
if maxChanges > 0 {
changes.MaxChanges = maxChanges
}
getCreated := EmailGetRefCommand{
AccountId: accountId,
FetchAllBodyValues: fetchBodies,
IdsRef: &ResultReference{Name: CommandMailboxChanges, Path: "/created", ResultOf: mcid(accountId, "0")},
}
if maxBodyValueBytes > 0 {
getCreated.MaxBodyValueBytes = maxBodyValueBytes
}
getUpdated := EmailGetRefCommand{
AccountId: accountId,
FetchAllBodyValues: fetchBodies,
IdsRef: &ResultReference{Name: CommandMailboxChanges, Path: "/updated", ResultOf: mcid(accountId, "0")},
}
if maxBodyValueBytes > 0 {
getUpdated.MaxBodyValueBytes = maxBodyValueBytes
}
invocations[i*3+0] = invocation(CommandMailboxChanges, changes, mcid(accountId, "0"))
invocations[i*3+1] = invocation(CommandEmailGet, getCreated, mcid(accountId, "1"))
invocations[i*3+2] = invocation(CommandEmailGet, getUpdated, mcid(accountId, "2"))
}
cmd, err := j.request(session, logger, invocations...)
if err != nil {
return nil, "", "", "", err
}
return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (map[string]MailboxChanges, State, Error) {
resp := make(map[string]MailboxChanges, n)
stateByAccountId := make(map[string]State, n)
for _, accountId := range uniqueAccountIds {
var mailboxResponse MailboxChangesResponse
err = retrieveResponseMatchParameters(logger, body, CommandMailboxChanges, mcid(accountId, "0"), &mailboxResponse)
if err != nil {
return nil, "", err
}
var createdResponse EmailGetResponse
err = retrieveResponseMatchParameters(logger, body, CommandEmailGet, mcid(accountId, "1"), &createdResponse)
if err != nil {
return nil, "", err
}
var updatedResponse EmailGetResponse
err = retrieveResponseMatchParameters(logger, body, CommandEmailGet, mcid(accountId, "2"), &updatedResponse)
if err != nil {
return nil, "", err
}
resp[accountId] = MailboxChanges{
Destroyed: mailboxResponse.Destroyed,
HasMoreChanges: mailboxResponse.HasMoreChanges,
NewState: mailboxResponse.NewState,
Created: createdResponse.List,
Updated: createdResponse.List,
}
stateByAccountId[accountId] = createdResponse.State
}
return resp, squashState(stateByAccountId), nil
})
}
func (j *Client) GetMailboxRolesForMultipleAccounts(accountIds []string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string) (map[string][]string, SessionState, State, Language, Error) {
logger = j.logger("GetMailboxRolesForMultipleAccounts", session, logger)
uniqueAccountIds := structs.Uniq(accountIds)
n := len(uniqueAccountIds)
if n < 1 {
return nil, "", "", "", nil
}
t := true
invocations := make([]Invocation, n*2)
for i, accountId := range uniqueAccountIds {
invocations[i*2+0] = invocation(CommandMailboxQuery, MailboxQueryCommand{
AccountId: accountId,
Filter: MailboxFilterCondition{
HasAnyRole: &t,
},
}, mcid(accountId, "0"))
invocations[i*2+1] = invocation(CommandMailboxGet, MailboxGetRefCommand{
AccountId: accountId,
IdsRef: &ResultReference{
ResultOf: mcid(accountId, "0"),
Name: CommandMailboxQuery,
Path: "/ids",
},
}, mcid(accountId, "1"))
}
cmd, err := j.request(session, logger, invocations...)
if err != nil {
return nil, "", "", "", err
}
return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (map[string][]string, State, Error) {
resp := make(map[string][]string, n)
stateByAccountId := make(map[string]State, n)
for _, accountId := range uniqueAccountIds {
var getResponse MailboxGetResponse
err = retrieveResponseMatchParameters(logger, body, CommandMailboxGet, mcid(accountId, "1"), &getResponse)
if err != nil {
return nil, "", err
}
roles := make([]string, len(getResponse.List))
for i, mailbox := range getResponse.List {
roles[i] = mailbox.Role
}
slices.Sort(roles)
resp[accountId] = roles
stateByAccountId[accountId] = getResponse.State
}
return resp, squashState(stateByAccountId), nil
})
}
func (j *Client) GetInboxNameForMultipleAccounts(accountIds []string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string) (map[string]string, SessionState, State, Language, Error) {
logger = j.logger("GetInboxNameForMultipleAccounts", session, logger)
uniqueAccountIds := structs.Uniq(accountIds)
n := len(uniqueAccountIds)
if n < 1 {
return nil, "", "", "", nil
}
invocations := make([]Invocation, n*2)
for i, accountId := range uniqueAccountIds {
invocations[i*2+0] = invocation(CommandMailboxQuery, MailboxQueryCommand{
AccountId: accountId,
Filter: MailboxFilterCondition{
Role: JmapMailboxRoleInbox,
},
}, mcid(accountId, "0"))
}
cmd, err := j.request(session, logger, invocations...)
if err != nil {
return nil, "", "", "", err
}
return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (map[string]string, State, Error) {
resp := make(map[string]string, n)
stateByAccountId := make(map[string]State, n)
for _, accountId := range uniqueAccountIds {
var r MailboxQueryResponse
err = retrieveResponseMatchParameters(logger, body, CommandMailboxGet, mcid(accountId, "0"), &r)
if err != nil {
return nil, "", err
}
switch len(r.Ids) {
case 0:
// skip: account has no inbox?
case 1:
resp[accountId] = r.Ids[0]
stateByAccountId[accountId] = r.QueryState
default:
logger.Warn().Msgf("multiple ids for mailbox role='%v' for accountId='%v'", JmapMailboxRoleInbox, accountId)
resp[accountId] = r.Ids[0]
stateByAccountId[accountId] = r.QueryState
}
}
return resp, squashState(stateByAccountId), nil
})
}
func (j *Client) UpdateMailbox(accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, mailboxId string, ifInState string, update MailboxChange) (Mailbox, SessionState, State, Language, Error) {
logger = j.logger("UpdateMailbox", session, logger)
cmd, err := j.request(session, logger, invocation(CommandMailboxSet, MailboxSetCommand{
AccountId: accountId,
IfInState: ifInState,
Update: map[string]PatchObject{
mailboxId: update.AsPatch(),
},
}, "0"))
if err != nil {
return Mailbox{}, "", "", "", err
}
return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (Mailbox, State, Error) {
var setResp MailboxSetResponse
err = retrieveResponseMatchParameters(logger, body, CommandMailboxSet, "0", &setResp)
if err != nil {
return Mailbox{}, "", err
}
setErr, notok := setResp.NotUpdated["u"]
if notok {
logger.Error().Msgf("%T.NotUpdated returned an error %v", setResp, setErr)
return Mailbox{}, "", setErrorError(setErr, MailboxType)
}
return setResp.Updated["c"], setResp.NewState, nil
})
}
func (j *Client) CreateMailbox(accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, ifInState string, create MailboxChange) (Mailbox, SessionState, State, Language, Error) {
logger = j.logger("CreateMailbox", session, logger)
cmd, err := j.request(session, logger, invocation(CommandMailboxSet, MailboxSetCommand{
AccountId: accountId,
IfInState: ifInState,
Create: map[string]MailboxChange{
"c": create,
},
}, "0"))
if err != nil {
return Mailbox{}, "", "", "", err
}
return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (Mailbox, State, Error) {
var setResp MailboxSetResponse
err = retrieveResponseMatchParameters(logger, body, CommandMailboxSet, "0", &setResp)
if err != nil {
return Mailbox{}, "", err
}
setErr, notok := setResp.NotCreated["c"]
if notok {
logger.Error().Msgf("%T.NotCreated returned an error %v", setResp, setErr)
return Mailbox{}, "", setErrorError(setErr, MailboxType)
}
if mailbox, ok := setResp.Created["c"]; ok {
return mailbox, setResp.NewState, nil
} else {
return Mailbox{}, "", simpleError(fmt.Errorf("failed to find created %T in response", Mailbox{}), JmapErrorMissingCreatedObject)
}
})
}
func (j *Client) DeleteMailboxes(accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, ifInState string, mailboxIds []string) ([]string, SessionState, State, Language, Error) {
logger = j.logger("DeleteMailbox", session, logger)
cmd, err := j.request(session, logger, invocation(CommandMailboxSet, MailboxSetCommand{
AccountId: accountId,
IfInState: ifInState,
Destroy: mailboxIds,
}, "0"))
if err != nil {
return nil, "", "", "", err
}
return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) ([]string, State, Error) {
var setResp MailboxSetResponse
err = retrieveResponseMatchParameters(logger, body, CommandMailboxSet, "0", &setResp)
if err != nil {
return nil, "", err
}
setErr, notok := setResp.NotUpdated["u"]
if notok {
logger.Error().Msgf("%T.NotUpdated returned an error %v", setResp, setErr)
return nil, "", setErrorError(setErr, MailboxType)
}
return setResp.Destroyed, setResp.NewState, nil
})
}

View File

@@ -0,0 +1,35 @@
package jmap
import (
"context"
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/opencloud/pkg/structs"
)
func (j *Client) GetQuotas(accountIds []string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string) (map[string]QuotaGetResponse, SessionState, State, Language, Error) {
logger = j.logger("GetQuotas", session, logger)
uniqueAccountIds := structs.Uniq(accountIds)
invocations := make([]Invocation, len(uniqueAccountIds))
for i, accountId := range uniqueAccountIds {
invocations[i] = invocation(CommandQuotaGet, MailboxQueryCommand{AccountId: accountId}, mcid(accountId, "0"))
}
cmd, err := j.request(session, logger, invocations...)
if err != nil {
return nil, "", "", "", err
}
return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (map[string]QuotaGetResponse, State, Error) {
result := map[string]QuotaGetResponse{}
for _, accountId := range uniqueAccountIds {
var response QuotaGetResponse
err = retrieveResponseMatchParameters(logger, body, CommandQuotaGet, mcid(accountId, "0"), &response)
if err != nil {
return nil, "", err
}
result[accountId] = response
}
return result, squashStateFunc(result, func(q QuotaGetResponse) State { return q.State }), nil
})
}

View File

@@ -0,0 +1,108 @@
package jmap
import (
"context"
"fmt"
"time"
"github.com/opencloud-eu/opencloud/pkg/log"
)
const (
vacationResponseId = "singleton"
)
// https://jmap.io/spec-mail.html#vacationresponseget
func (j *Client) GetVacationResponse(accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string) (VacationResponseGetResponse, SessionState, State, Language, Error) {
logger = j.logger("GetVacationResponse", session, logger)
cmd, err := j.request(session, logger, invocation(CommandVacationResponseGet, VacationResponseGetCommand{AccountId: accountId}, "0"))
if err != nil {
return VacationResponseGetResponse{}, "", "", "", err
}
return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (VacationResponseGetResponse, State, Error) {
var response VacationResponseGetResponse
err = retrieveResponseMatchParameters(logger, body, CommandVacationResponseGet, "0", &response)
if err != nil {
return VacationResponseGetResponse{}, "", err
}
return response, response.State, nil
})
}
// Same as VacationResponse but without the id.
type VacationResponsePayload struct {
// Should a vacation response be sent if a message arrives between the "fromDate" and "toDate"?
IsEnabled bool `json:"isEnabled"`
// If "isEnabled" is true, messages that arrive on or after this date-time (but before the "toDate" if defined) should receive the
// user's vacation response. If null, the vacation response is effective immediately.
FromDate time.Time `json:"fromDate,omitzero"`
// If "isEnabled" is true, messages that arrive before this date-time but on or after the "fromDate" if defined) should receive the
// user's vacation response. If null, the vacation response is effective indefinitely.
ToDate time.Time `json:"toDate,omitzero"`
// The subject that will be used by the message sent in response to messages when the vacation response is enabled.
// If null, an appropriate subject SHOULD be set by the server.
Subject string `json:"subject,omitempty"`
// The plaintext body to send in response to messages when the vacation response is enabled.
// If this is null, the server SHOULD generate a plaintext body part from the "htmlBody" when sending vacation responses
// but MAY choose to send the response as HTML only. If both "textBody" and "htmlBody" are null, an appropriate default
// body SHOULD be generated for responses by the server.
TextBody string `json:"textBody,omitempty"`
// The HTML body to send in response to messages when the vacation response is enabled.
// If this is null, the server MAY choose to generate an HTML body part from the "textBody" when sending vacation responses
// or MAY choose to send the response as plaintext only.
HtmlBody string `json:"htmlBody,omitempty"`
}
func (j *Client) SetVacationResponse(accountId string, vacation VacationResponsePayload, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string) (VacationResponse, SessionState, State, Language, Error) {
logger = j.logger("SetVacationResponse", session, logger)
cmd, err := j.request(session, logger,
invocation(CommandVacationResponseSet, VacationResponseSetCommand{
AccountId: accountId,
Create: map[string]VacationResponse{
vacationResponseId: {
IsEnabled: vacation.IsEnabled,
FromDate: vacation.FromDate,
ToDate: vacation.ToDate,
Subject: vacation.Subject,
TextBody: vacation.TextBody,
HtmlBody: vacation.HtmlBody,
},
},
}, "0"),
// chain a second request to get the current complete VacationResponse object
// after performing the changes, as that makes for a better API
invocation(CommandVacationResponseGet, VacationResponseGetCommand{AccountId: accountId}, "1"),
)
if err != nil {
return VacationResponse{}, "", "", "", err
}
return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (VacationResponse, State, Error) {
var setResponse VacationResponseSetResponse
err = retrieveResponseMatchParameters(logger, body, CommandVacationResponseSet, "0", &setResponse)
if err != nil {
return VacationResponse{}, "", err
}
setErr, notok := setResponse.NotCreated[vacationResponseId]
if notok {
// this means that the VacationResponse was not updated
logger.Error().Msgf("%T.NotCreated contains an error: %v", setResponse, setErr)
return VacationResponse{}, "", setErrorError(setErr, VacationResponseType)
}
var getResponse VacationResponseGetResponse
err = retrieveResponseMatchParameters(logger, body, CommandVacationResponseGet, "1", &getResponse)
if err != nil {
return VacationResponse{}, "", err
}
if len(getResponse.List) != 1 {
berr := fmt.Errorf("failed to find %s in %s response", string(VacationResponseType), string(CommandVacationResponseGet))
logger.Error().Msg(berr.Error())
return VacationResponse{}, "", simpleError(berr, JmapErrorInvalidJmapResponsePayload)
}
return getResponse.List[0], setResponse.NewState, nil
})
}

9
pkg/jmap/jmap_api_ws.go Normal file
View File

@@ -0,0 +1,9 @@
package jmap
func (j *Client) EnablePushNotifications(pushState State, sessionProvider func() (*Session, error)) (WsClient, error) {
return j.ws.EnableNotifications(pushState, sessionProvider, j)
}
func (j *Client) AddWsPushListener(listener WsPushListener) {
j.wsPushListeners.add(listener)
}

103
pkg/jmap/jmap_client.go Normal file
View File

@@ -0,0 +1,103 @@
package jmap
import (
"errors"
"io"
"net/url"
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/rs/zerolog"
)
type Client struct {
session SessionClient
api ApiClient
blob BlobClient
ws WsClientFactory
sessionEventListeners *eventListeners[SessionEventListener]
wsPushListeners *eventListeners[WsPushListener]
io.Closer
WsPushListener
}
var _ io.Closer = &Client{}
var _ WsPushListener = &Client{}
func (j *Client) Close() error {
return errors.Join(j.api.Close(), j.session.Close(), j.blob.Close(), j.ws.Close())
}
func NewClient(session SessionClient, api ApiClient, blob BlobClient, ws WsClientFactory) Client {
return Client{
session: session,
api: api,
blob: blob,
ws: ws,
sessionEventListeners: newEventListeners[SessionEventListener](),
wsPushListeners: newEventListeners[WsPushListener](),
}
}
func (j *Client) AddSessionEventListener(listener SessionEventListener) {
j.sessionEventListeners.add(listener)
}
func (j *Client) onSessionOutdated(session *Session, newSessionState SessionState) {
j.sessionEventListeners.signal(func(listener SessionEventListener) {
listener.OnSessionOutdated(session, newSessionState)
})
}
func (j *Client) OnNotification(username string, stateChange StateChange) {
j.wsPushListeners.signal(func(listener WsPushListener) {
listener.OnNotification(username, stateChange)
})
}
// Retrieve JMAP well-known data from the Stalwart server and create a Session from that.
func (j *Client) FetchSession(sessionUrl *url.URL, username string, logger *log.Logger) (Session, Error) {
wk, err := j.session.GetSession(sessionUrl, username, logger)
if err != nil {
return Session{}, err
}
return newSession(wk)
}
func (j *Client) logger(operation string, _ *Session, logger *log.Logger) *log.Logger {
l := logger.With().Str(logOperation, operation)
return log.From(l)
}
func (j *Client) loggerParams(operation string, _ *Session, logger *log.Logger, params func(zerolog.Context) zerolog.Context) *log.Logger {
l := logger.With().Str(logOperation, operation)
if params != nil {
l = params(l)
}
return log.From(l)
}
func (j *Client) maxCallsCheck(calls int, session *Session, logger *log.Logger) Error {
if calls > session.Capabilities.Core.MaxCallsInRequest {
logger.Warn().
Int("max-calls-in-request", session.Capabilities.Core.MaxCallsInRequest).
Int("calls-in-request", calls).
Msgf("number of calls in request payload (%d) would exceed the allowed maximum (%d)", session.Capabilities.Core.MaxCallsInRequest, calls)
return simpleError(errTooManyMethodCalls, JmapErrorTooManyMethodCalls)
}
return nil
}
// Construct a Request from the given list of Invocation objects.
//
// If an issue occurs, then it is logged prior to returning it.
func (j *Client) request(session *Session, logger *log.Logger, methodCalls ...Invocation) (Request, Error) {
err := j.maxCallsCheck(len(methodCalls), session, logger)
if err != nil {
return Request{}, err
}
return Request{
Using: []string{JmapCore, JmapMail, JmapContacts},
MethodCalls: methodCalls,
CreatedIds: nil,
}, nil
}

89
pkg/jmap/jmap_error.go Normal file
View File

@@ -0,0 +1,89 @@
package jmap
import (
"errors"
"fmt"
"strings"
)
const (
JmapErrorAuthenticationFailed = iota
JmapErrorInvalidHttpRequest
JmapErrorServerResponse
JmapErrorReadingResponseBody
JmapErrorDecodingResponseBody
JmapErrorEncodingRequestBody
JmapErrorCreatingRequest
JmapErrorSendingRequest
JmapErrorInvalidSessionResponse
JmapErrorInvalidJmapRequestPayload
JmapErrorInvalidJmapResponsePayload
JmapErrorSetError
JmapErrorTooManyMethodCalls
JmapErrorUnspecifiedType
JmapErrorServerUnavailable
JmapErrorServerFail
JmapErrorUnknownMethod
JmapErrorInvalidArguments
JmapErrorInvalidResultReference
JmapErrorForbidden
JmapErrorAccountNotFound
JmapErrorAccountNotSupportedByMethod
JmapErrorAccountReadOnly
JmapErrorFailedToEstablishWssConnection
JmapErrorWssConnectionResponseMissingJmapSubprotocol
JmapErrorWssFailedToSendWebSocketPushEnable
JmapErrorWssFailedToSendWebSocketPushDisable
JmapErrorWssFailedToClose
JmapErrorWssFailedToRetrieveSession
JmapErrorSocketPushUnsupported
JmapErrorMissingCreatedObject
)
var (
errTooManyMethodCalls = errors.New("the amount of methodCalls in the request body would exceed the maximum that is configured in the session")
)
type Error interface {
Code() int
error
}
type SimpleError struct {
code int
err error
}
var _ Error = &SimpleError{}
func (e SimpleError) Code() int {
return e.code
}
func (e SimpleError) Unwrap() error {
return e.err
}
func (e SimpleError) Error() string {
if e.err != nil {
return e.err.Error()
} else {
return ""
}
}
func simpleError(err error, code int) Error {
if err != nil {
return SimpleError{code: code, err: err}
} else {
return nil
}
}
func setErrorError(err SetError, objectType ObjectType) Error {
var e error
if len(err.Properties) > 0 {
e = fmt.Errorf("failed to modify %s due to %s error in properties [%s]: %s", objectType, err.Type, strings.Join(err.Properties, ", "), err.Description)
} else {
e = fmt.Errorf("failed to modify %s due to %s error: %s", objectType, err.Type, err.Description)
}
return SimpleError{code: JmapErrorSetError, err: e}
}

651
pkg/jmap/jmap_http.go Normal file
View File

@@ -0,0 +1,651 @@
package jmap
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httputil"
"net/url"
"slices"
"strconv"
"github.com/gorilla/websocket"
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/opencloud/pkg/version"
)
// Implementation of ApiClient, SessionClient and BlobClient that uses
// HTTP to perform JMAP operations.
type HttpJmapClient struct {
client *http.Client
masterUser string
masterPassword string
userAgent string
listener HttpJmapApiClientEventListener
}
var (
_ ApiClient = &HttpJmapClient{}
_ SessionClient = &HttpJmapClient{}
_ BlobClient = &HttpJmapClient{}
)
const (
logEndpoint = "endpoint"
logHttpStatus = "status"
logHttpStatusCode = "status-code"
logHttpUrl = "url"
logProto = "proto"
logProtoJmap = "jmap"
logProtoJmapWs = "jmapws"
logType = "type"
logTypeRequest = "request"
logTypeResponse = "response"
logTypePush = "push"
)
/*
func bearer(req *http.Request, token string) {
req.Header.Add("Authorization", "Bearer "+base64.StdEncoding.EncodeToString([]byte(token)))
}
*/
// Record JMAP HTTP execution events that may occur, e.g. using metrics.
type HttpJmapApiClientEventListener interface {
OnSuccessfulRequest(endpoint string, status int)
OnFailedRequest(endpoint string, err error)
OnFailedRequestWithStatus(endpoint string, status int)
OnResponseBodyReadingError(endpoint string, err error)
OnResponseBodyUnmarshallingError(endpoint string, err error)
OnSuccessfulWsRequest(endpoint string, status int)
OnFailedWsHandshakeRequestWithStatus(endpoint string, status int)
}
type nullHttpJmapApiClientEventListener struct {
}
func (l nullHttpJmapApiClientEventListener) OnSuccessfulRequest(endpoint string, status int) {
}
func (l nullHttpJmapApiClientEventListener) OnFailedRequest(endpoint string, err error) {
}
func (l nullHttpJmapApiClientEventListener) OnFailedRequestWithStatus(endpoint string, status int) {
}
func (l nullHttpJmapApiClientEventListener) OnResponseBodyReadingError(endpoint string, err error) {
}
func (l nullHttpJmapApiClientEventListener) OnResponseBodyUnmarshallingError(endpoint string, err error) {
}
func (l nullHttpJmapApiClientEventListener) OnSuccessfulWsRequest(endpoint string, status int) {
}
func (l nullHttpJmapApiClientEventListener) OnFailedWsHandshakeRequestWithStatus(endpoint string, status int) {
}
var _ HttpJmapApiClientEventListener = nullHttpJmapApiClientEventListener{}
// An implementation of HttpJmapApiClientMetricsRecorder that does nothing.
func NullHttpJmapApiClientEventListener() HttpJmapApiClientEventListener {
return nullHttpJmapApiClientEventListener{}
}
func NewHttpJmapClient(client *http.Client, masterUser string, masterPassword string, listener HttpJmapApiClientEventListener) *HttpJmapClient {
return &HttpJmapClient{
client: client,
masterUser: masterUser,
masterPassword: masterPassword,
userAgent: "OpenCloud/" + version.GetString(),
listener: listener,
}
}
func (h *HttpJmapClient) Close() error {
h.client.CloseIdleConnections()
return nil
}
type AuthenticationError struct {
Err error
}
func (e AuthenticationError) Error() string {
return fmt.Sprintf("failed to find user for authentication: %v", e.Err.Error())
}
func (e AuthenticationError) Unwrap() error {
return e.Err
}
func (h *HttpJmapClient) auth(username string, _ *log.Logger, req *http.Request) error {
masterUsername := username + "%" + h.masterUser
req.SetBasicAuth(masterUsername, h.masterPassword)
return nil
}
var (
errNilBaseUrl = errors.New("sessionUrl is nil")
)
func (h *HttpJmapClient) GetSession(sessionUrl *url.URL, username string, logger *log.Logger) (SessionResponse, Error) {
if sessionUrl == nil {
logger.Error().Msg("sessionUrl is nil")
return SessionResponse{}, SimpleError{code: JmapErrorInvalidHttpRequest, err: errNilBaseUrl}
}
// See the JMAP specification on Service Autodiscovery: https://jmap.io/spec-core.html#service-autodiscovery
// There are two standardised autodiscovery methods in use for Internet protocols:
// - DNS SRV (see [@!RFC2782], [@!RFC6186], and [@!RFC6764])
// - .well-known/servicename (see [@!RFC8615])
// We are currently only supporting RFC8615, using the baseurl that was configured in this HttpJmapApiClient.
//sessionUrl := baseurl.JoinPath(".well-known", "jmap")
sessionUrlStr := sessionUrl.String()
endpoint := endpointOf(sessionUrl)
logger = log.From(logger.With().Str(logEndpoint, endpoint))
req, err := http.NewRequest(http.MethodGet, sessionUrlStr, nil)
if err != nil {
logger.Error().Err(err).Msgf("failed to create GET request for %v", sessionUrl)
return SessionResponse{}, SimpleError{code: JmapErrorInvalidHttpRequest, err: err}
}
h.auth(username, logger, req)
req.Header.Add("Cache-Control", "no-cache, no-store, must-revalidate") // spec recommendation
res, err := h.client.Do(req)
if err != nil {
h.listener.OnFailedRequest(endpoint, err)
logger.Error().Err(err).Msgf("failed to perform GET %v", sessionUrl)
return SessionResponse{}, SimpleError{code: JmapErrorInvalidHttpRequest, err: err}
}
if res.StatusCode < 200 || res.StatusCode > 299 {
h.listener.OnFailedRequestWithStatus(endpoint, res.StatusCode)
logger.Error().Str(logHttpStatus, log.SafeString(res.Status)).Int(logHttpStatusCode, res.StatusCode).Msg("HTTP response status code is not 200")
return SessionResponse{}, SimpleError{code: JmapErrorServerResponse, err: fmt.Errorf("JMAP API response status is %v", res.Status)}
}
h.listener.OnSuccessfulRequest(endpoint, res.StatusCode)
if res.Body != nil {
defer func(Body io.ReadCloser) {
err := Body.Close()
if err != nil {
logger.Error().Err(err).Msg("failed to close response body")
}
}(res.Body)
}
body, err := io.ReadAll(res.Body)
if err != nil {
logger.Error().Err(err).Msg("failed to read response body")
h.listener.OnResponseBodyReadingError(endpoint, err)
return SessionResponse{}, SimpleError{code: JmapErrorReadingResponseBody, err: err}
}
var data SessionResponse
err = json.Unmarshal(body, &data)
if err != nil {
logger.Error().Str(logHttpUrl, log.SafeString(sessionUrlStr)).Err(err).Msg("failed to decode JSON payload from .well-known/jmap response")
h.listener.OnResponseBodyUnmarshallingError(endpoint, err)
return SessionResponse{}, SimpleError{code: JmapErrorDecodingResponseBody, err: err}
}
return data, nil
}
func (h *HttpJmapClient) Command(ctx context.Context, logger *log.Logger, session *Session, request Request, acceptLanguage string) ([]byte, Language, Error) {
jmapUrl := session.JmapUrl.String()
endpoint := session.JmapEndpoint
logger = log.From(logger.With().Str(logEndpoint, endpoint))
bodyBytes, err := json.Marshal(request)
if err != nil {
logger.Error().Err(err).Msg("failed to marshall JSON payload")
return nil, "", SimpleError{code: JmapErrorEncodingRequestBody, err: err}
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, jmapUrl, bytes.NewBuffer(bodyBytes))
if err != nil {
logger.Error().Err(err).Msgf("failed to create POST request for %v", jmapUrl)
return nil, "", SimpleError{code: JmapErrorCreatingRequest, err: err}
}
// Some JMAP APIs use the Accept-Language header to determine which language to use to translate
// texts in attributes.
if acceptLanguage != "" {
req.Header.Add("Accept-Language", acceptLanguage)
}
req.Header.Add("Content-Type", "application/json")
req.Header.Add("User-Agent", h.userAgent)
if logger.Trace().Enabled() {
requestBytes, err := httputil.DumpRequestOut(req, true)
if err == nil {
logger.Trace().Str(logEndpoint, endpoint).Str(logProto, logProtoJmap).Str(logType, logTypeRequest).Msg(string(requestBytes))
}
}
h.auth(session.Username, logger, req)
res, err := h.client.Do(req)
if err != nil {
h.listener.OnFailedRequest(endpoint, err)
logger.Error().Err(err).Msgf("failed to perform POST %v", jmapUrl)
return nil, "", SimpleError{code: JmapErrorSendingRequest, err: err}
}
if logger.Trace().Enabled() {
responseBytes, err := httputil.DumpResponse(res, true)
if err == nil {
logger.Trace().Str(logEndpoint, endpoint).Str(logProto, logProtoJmap).Str(logType, logTypeResponse).
Str(logHttpStatus, log.SafeString(res.Status)).Int(logHttpStatusCode, res.StatusCode).
Msg(string(responseBytes))
}
}
language := Language(res.Header.Get("Content-Language"))
if res.StatusCode < 200 || res.StatusCode > 299 {
h.listener.OnFailedRequestWithStatus(endpoint, res.StatusCode)
logger.Error().Str(logEndpoint, endpoint).Str(logHttpStatus, log.SafeString(res.Status)).Msg("HTTP response status code is not 2xx")
return nil, language, SimpleError{code: JmapErrorServerResponse, err: err}
}
if res.Body != nil {
defer func(Body io.ReadCloser) {
err := Body.Close()
if err != nil {
logger.Error().Err(err).Msg("failed to close response body")
}
}(res.Body)
}
h.listener.OnSuccessfulRequest(endpoint, res.StatusCode)
body, err := io.ReadAll(res.Body)
if err != nil {
logger.Error().Err(err).Msg("failed to read response body")
h.listener.OnResponseBodyReadingError(endpoint, err)
return nil, language, SimpleError{code: JmapErrorServerResponse, err: err}
}
return body, language, nil
}
func (h *HttpJmapClient) UploadBinary(ctx context.Context, logger *log.Logger, session *Session, uploadUrl string, endpoint string, contentType string, acceptLanguage string, body io.Reader) (UploadedBlob, Language, Error) {
logger = log.From(logger.With().Str(logEndpoint, endpoint))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadUrl, body)
if err != nil {
logger.Error().Err(err).Msgf("failed to create POST request for %v", uploadUrl)
return UploadedBlob{}, "", SimpleError{code: JmapErrorCreatingRequest, err: err}
}
req.Header.Add("Content-Type", contentType)
req.Header.Add("User-Agent", h.userAgent)
if acceptLanguage != "" {
req.Header.Add("Accept-Language", acceptLanguage)
}
if logger.Trace().Enabled() {
requestBytes, err := httputil.DumpRequestOut(req, false)
if err == nil {
logger.Trace().Str(logEndpoint, endpoint).Str(logProto, logProtoJmap).Str(logType, logTypeRequest).Msg(string(requestBytes))
}
}
h.auth(session.Username, logger, req)
res, err := h.client.Do(req)
if err != nil {
h.listener.OnFailedRequest(endpoint, err)
logger.Error().Err(err).Msgf("failed to perform POST %v", uploadUrl)
return UploadedBlob{}, "", SimpleError{code: JmapErrorSendingRequest, err: err}
}
if logger.Trace().Enabled() {
responseBytes, err := httputil.DumpResponse(res, true)
if err == nil {
logger.Trace().Str(logEndpoint, endpoint).Str(logProto, logProtoJmap).Str(logType, logTypeResponse).
Str(logHttpStatus, log.SafeString(res.Status)).Int(logHttpStatusCode, res.StatusCode).
Msg(string(responseBytes))
}
}
language := Language(res.Header.Get("Content-Language"))
if res.StatusCode < 200 || res.StatusCode > 299 {
h.listener.OnFailedRequestWithStatus(endpoint, res.StatusCode)
logger.Error().Str(logHttpStatus, log.SafeString(res.Status)).Int(logHttpStatusCode, res.StatusCode).Msg("HTTP response status code is not 2xx")
return UploadedBlob{}, language, SimpleError{code: JmapErrorServerResponse, err: err}
}
if res.Body != nil {
defer func(Body io.ReadCloser) {
err := Body.Close()
if err != nil {
logger.Error().Err(err).Msg("failed to close response body")
}
}(res.Body)
}
h.listener.OnSuccessfulRequest(endpoint, res.StatusCode)
responseBody, err := io.ReadAll(res.Body)
if err != nil {
logger.Error().Err(err).Msg("failed to read response body")
h.listener.OnResponseBodyReadingError(endpoint, err)
return UploadedBlob{}, language, SimpleError{code: JmapErrorServerResponse, err: err}
}
logger.Trace()
var result UploadedBlob
err = json.Unmarshal(responseBody, &result)
if err != nil {
logger.Error().Str(logHttpUrl, log.SafeString(uploadUrl)).Err(err).Msg("failed to decode JSON payload from the upload response")
h.listener.OnResponseBodyUnmarshallingError(endpoint, err)
return UploadedBlob{}, language, SimpleError{code: JmapErrorDecodingResponseBody, err: err}
}
return result, language, nil
}
func (h *HttpJmapClient) DownloadBinary(ctx context.Context, logger *log.Logger, session *Session, downloadUrl string, endpoint string, acceptLanguage string) (*BlobDownload, Language, Error) {
logger = log.From(logger.With().Str(logEndpoint, endpoint))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadUrl, nil)
if err != nil {
logger.Error().Err(err).Msgf("failed to create GET request for %v", downloadUrl)
return nil, "", SimpleError{code: JmapErrorCreatingRequest, err: err}
}
req.Header.Add("User-Agent", h.userAgent)
if acceptLanguage != "" {
req.Header.Add("Accept-Language", acceptLanguage)
}
if logger.Trace().Enabled() {
requestBytes, err := httputil.DumpRequestOut(req, true)
if err == nil {
logger.Trace().Str(logEndpoint, endpoint).Str(logProto, logProtoJmap).Str(logType, logTypeRequest).Msg(string(requestBytes))
}
}
h.auth(session.Username, logger, req)
res, err := h.client.Do(req)
if err != nil {
h.listener.OnFailedRequest(endpoint, err)
logger.Error().Err(err).Msgf("failed to perform GET %v", downloadUrl)
return nil, "", SimpleError{code: JmapErrorSendingRequest, err: err}
}
if logger.Trace().Enabled() {
responseBytes, err := httputil.DumpResponse(res, false)
if err == nil {
logger.Trace().Str(logEndpoint, endpoint).Str(logProto, logProtoJmap).Str(logType, logTypeResponse).
Str(logHttpStatus, log.SafeString(res.Status)).Int(logHttpStatusCode, res.StatusCode).
Msg(string(responseBytes))
}
}
language := Language(res.Header.Get("Content-Language"))
if res.StatusCode == http.StatusNotFound {
return nil, language, nil
}
if res.StatusCode < 200 || res.StatusCode > 299 {
h.listener.OnFailedRequestWithStatus(endpoint, res.StatusCode)
logger.Error().Str(logHttpStatus, log.SafeString(res.Status)).Int(logHttpStatusCode, res.StatusCode).Msg("HTTP response status code is not 2xx")
return nil, language, SimpleError{code: JmapErrorServerResponse, err: err}
}
h.listener.OnSuccessfulRequest(endpoint, res.StatusCode)
sizeStr := res.Header.Get("Content-Length")
size := -1
if sizeStr != "" {
size, err = strconv.Atoi(sizeStr)
if err != nil {
logger.Warn().Err(err).Msgf("failed to parse Content-Length blob download response header value '%v'", sizeStr)
size = -1
}
}
return &BlobDownload{
Body: res.Body,
Size: size,
Type: res.Header.Get("Content-Type"),
ContentDisposition: res.Header.Get("Content-Disposition"),
CacheControl: res.Header.Get("Cache-Control"),
}, language, nil
}
type WebSocketPushEnableType string
type WebSocketPushDisableType string
const (
WebSocketPushTypeEnable = WebSocketPushEnableType("WebSocketPushEnable")
WebSocketPushTypeDisable = WebSocketPushDisableType("WebSocketPushDisable")
)
type WebSocketPushEnable struct {
// This MUST be the string "WebSocketPushEnable".
Type WebSocketPushEnableType `json:"@type"`
// A list of data type names (e.g., "Mailbox" or "Email") that the client is interested in.
//
// A StateChange notification will only be sent if the data for one of these types changes.
// Other types are omitted from the TypeState object.
//
// If null, changes will be pushed for all supported data types.
DataTypes *[]string `json:"dataTypes"`
// The last "pushState" token that the client received from the server.
// Upon receipt of a "pushState" token, the server SHOULD immediately send all changes since that state token.
PushState State `json:"pushState,omitempty"`
}
type WebSocketPushDisable struct {
// This MUST be the string "WebSocketPushDisable".
Type WebSocketPushDisableType `json:"@type"`
}
type HttpWsClientFactory struct {
dialer *websocket.Dialer
masterUser string
masterPassword string
logger *log.Logger
eventListener HttpJmapApiClientEventListener
}
var _ WsClientFactory = &HttpWsClientFactory{}
func NewHttpWsClientFactory(dialer *websocket.Dialer, masterUser string, masterPassword string, logger *log.Logger,
eventListener HttpJmapApiClientEventListener) (*HttpWsClientFactory, error) {
// RFC 8887: Section 4.2:
// Otherwise, the client MUST make an authenticated HTTP request [RFC7235] on the encrypted connection
// and MUST include the value "jmap" in the list of protocols for the "Sec-WebSocket-Protocol" header
// field.
dialer.Subprotocols = []string{"jmap"}
return &HttpWsClientFactory{
dialer: dialer,
masterUser: masterUser,
masterPassword: masterPassword,
logger: logger,
eventListener: eventListener,
}, nil
}
func (w *HttpWsClientFactory) auth(username string, h http.Header) error {
masterUsername := username + "%" + w.masterUser
h.Add("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(masterUsername+":"+w.masterPassword)))
return nil
}
func (w *HttpWsClientFactory) connect(sessionProvider func() (*Session, error)) (*websocket.Conn, string, string, Error) {
logger := w.logger
session, err := sessionProvider()
if err != nil {
return nil, "", "", SimpleError{code: JmapErrorWssFailedToRetrieveSession, err: err}
}
if session == nil {
return nil, "", "", SimpleError{code: JmapErrorWssFailedToRetrieveSession, err: nil}
}
if !session.SupportsWebsocketPush {
return nil, "", "", SimpleError{code: JmapErrorSocketPushUnsupported, err: nil}
}
username := session.Username
u := session.WebsocketUrl
endpoint := session.WebsocketEndpoint
ctx := context.Background() // TODO WS connection context with a timeout?
h := http.Header{}
w.auth(username, h)
c, res, err := w.dialer.DialContext(ctx, u.String(), h)
if err != nil {
return nil, "", endpoint, SimpleError{code: JmapErrorFailedToEstablishWssConnection, err: err}
}
if w.logger.Trace().Enabled() {
responseBytes, err := httputil.DumpResponse(res, true)
if err == nil {
logger.Trace().Str(logEndpoint, endpoint).Str(logProto, logProtoJmapWs).Str(logType, logTypeResponse).
Str(logHttpStatus, log.SafeString(res.Status)).Int(logHttpStatusCode, res.StatusCode).
Msg(string(responseBytes))
}
}
if res.StatusCode != 101 {
w.eventListener.OnFailedRequestWithStatus(endpoint, res.StatusCode)
logger.Error().Str(logHttpStatus, log.SafeString(res.Status)).Int(logHttpStatusCode, res.StatusCode).Msg("HTTP response status code is not 101")
return nil, "", endpoint, SimpleError{code: JmapErrorServerResponse, err: fmt.Errorf("JMAP WS API response status is %v", res.Status)}
} else {
w.eventListener.OnSuccessfulWsRequest(endpoint, res.StatusCode)
}
// RFC 8887: Section 4.2:
// The reply from the server MUST also contain a corresponding "Sec-WebSocket-Protocol" header
// field with a value of "jmap" in order for a JMAP subprotocol connection to be established.
if !slices.Contains(res.Header.Values("Sec-WebSocket-Protocol"), "jmap") {
return nil, "", endpoint, SimpleError{code: JmapErrorWssConnectionResponseMissingJmapSubprotocol}
}
return c, username, endpoint, nil
}
type HttpWsClient struct {
client *HttpWsClientFactory
username string
sessionProvider func() (*Session, error)
c *websocket.Conn
logger *log.Logger
endpoint string
listener WsPushListener
WsClient
}
func (w *HttpWsClient) readPump() {
defer func() {
w.c.Close()
}()
//w.c.SetReadLimit(maxMessageSize)
//c.conn.SetReadDeadline(time.Now().Add(pongWait))
//c.conn.SetPongHandler(func(string) error { c.conn.SetReadDeadline(time.Now().Add(pongWait)); return nil })
logger := log.From(w.logger.With().Str("username", w.username))
for {
if _, message, err := w.c.ReadMessage(); err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
logger.Error().Err(err).Msg("unexpected close")
}
break
} else {
if logger.Trace().Enabled() {
logger.Trace().Str(logEndpoint, w.endpoint).Str(logProto, logProtoJmapWs).Str(logType, logTypePush).Msg(string(message))
}
var peek struct {
Type string `json:"@type"`
}
if err := json.Unmarshal(message, &peek); err != nil {
logger.Error().Err(err).Msg("failed to deserialized pushed WS message")
continue
}
switch peek.Type {
case string(TypeOfStateChange):
var stateChange StateChange
if err := json.Unmarshal(message, &stateChange); err != nil {
logger.Error().Err(err).Msgf("failed to deserialized pushed WS message into a %T", stateChange)
continue
} else {
if w.listener != nil {
w.listener.OnNotification(w.username, stateChange)
} else {
logger.Warn().Msgf("no listener to be notified of %v", stateChange)
}
}
default:
logger.Warn().Msgf("unsupported pushed WS message JMAP @type: '%s'", peek.Type)
continue
}
}
}
}
func (w *HttpWsClientFactory) EnableNotifications(pushState State, sessionProvider func() (*Session, error), listener WsPushListener) (WsClient, Error) {
c, username, endpoint, jerr := w.connect(sessionProvider)
if jerr != nil {
return nil, jerr
}
msg := WebSocketPushEnable{
Type: WebSocketPushTypeEnable,
DataTypes: nil, // = all datatypes
PushState: pushState, // will be omitted if empty string
}
data, err := json.Marshal(msg)
if err != nil {
return nil, SimpleError{code: JmapErrorWssFailedToSendWebSocketPushEnable, err: err}
}
if w.logger.Trace().Enabled() {
w.logger.Trace().Str(logEndpoint, endpoint).Str(logProto, logProtoJmapWs).Str(logType, logTypeRequest).Msg(string(data))
}
if err := c.WriteMessage(websocket.TextMessage, data); err != nil {
return nil, SimpleError{code: JmapErrorWssFailedToSendWebSocketPushEnable, err: err}
}
wsc := &HttpWsClient{
client: w,
username: username,
sessionProvider: sessionProvider,
c: c,
logger: w.logger,
endpoint: endpoint,
listener: listener,
}
go wsc.readPump()
return wsc, nil
}
func (w *HttpWsClientFactory) Close() error {
return nil
}
func (c *HttpWsClient) DisableNotifications() Error {
if c.c == nil {
return nil
}
werr := c.c.WriteJSON(WebSocketPushDisable{Type: WebSocketPushTypeDisable})
merr := c.c.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
cerr := c.c.Close()
if werr != nil {
return SimpleError{code: JmapErrorWssFailedToClose, err: werr}
}
if merr != nil {
return SimpleError{code: JmapErrorWssFailedToClose, err: merr}
}
if cerr != nil {
return SimpleError{code: JmapErrorWssFailedToClose, err: cerr}
}
return nil
}
func (c *HttpWsClient) Close() error {
return c.DisableNotifications()
}

View File

@@ -0,0 +1,494 @@
package jmap
import (
"math/rand"
"regexp"
"testing"
"github.com/stretchr/testify/require"
"bytes"
"encoding/base64"
"fmt"
"log"
"math"
"strconv"
"strings"
"github.com/ProtonMail/go-crypto/openpgp"
"github.com/brianvoe/gofakeit/v7"
"github.com/opencloud-eu/opencloud/pkg/jscontact"
"github.com/opencloud-eu/opencloud/pkg/structs"
)
const (
// currently not supported, reported as https://github.com/stalwartlabs/stalwart/issues/2431
EnableMediaWithBlobId = false
)
func TestContacts(t *testing.T) {
if skip(t) {
return
}
count := uint(20 + rand.Intn(30))
require := require.New(t)
s, err := newStalwartTest(t)
require.NoError(err)
defer s.Close()
user := pickUser()
session := s.Session(user.name)
accountId, addressbookId, expectedContactCardsById, boxes, err := s.fillContacts(t, count, session, user)
require.NoError(err)
require.NotEmpty(accountId)
require.NotEmpty(addressbookId)
filter := ContactCardFilterCondition{
InAddressBook: addressbookId,
}
sortBy := []ContactCardComparator{
{Property: jscontact.ContactCardPropertyCreated, IsAscending: true},
}
contactsByAccount, _, _, _, err := s.client.QueryContactCards([]string{accountId}, session, t.Context(), s.logger, "", filter, sortBy, 0, 0)
require.NoError(err)
require.Len(contactsByAccount, 1)
require.Contains(contactsByAccount, accountId)
contacts := contactsByAccount[accountId]
require.Len(contacts, int(count))
for _, actual := range contacts {
expected, ok := expectedContactCardsById[actual.Id]
require.True(ok, "failed to find created contact by its id")
matchContact(t, actual, expected)
}
exceptions := []string{}
if !EnableMediaWithBlobId {
exceptions = append(exceptions, "mediaWithBlobId")
}
allBoxesAreTicked(t, boxes, exceptions...)
}
func matchContact(t *testing.T, actual jscontact.ContactCard, expected jscontact.ContactCard) {
// require.Equal(t, expected, actual)
deepEqual(t, expected, actual)
}
type ContactsBoxes struct {
nicknames bool
secondaryEmails bool
secondaryAddress bool
phones bool
onlineService bool
preferredLanguage bool
mediaWithBlobId bool
mediaWithDataUri bool
mediaWithExternalUri bool
organization bool
cryptoKey bool
link bool
}
var streetNumberRegex = regexp.MustCompile(`^(\d+)\s+(.+)$`)
func (s *StalwartTest) fillContacts(
t *testing.T,
count uint,
session *Session,
user User,
) (string, string, map[string]jscontact.ContactCard, ContactsBoxes, error) {
require := require.New(t)
c, err := NewTestJmapClient(session, user.name, user.password, true, true)
require.NoError(err)
defer c.Close()
boxes := ContactsBoxes{}
printer := func(s string) { log.Println(s) }
accountId := c.session.PrimaryAccounts.Contacts
require.NotEmpty(accountId, "no primary account for contacts in session")
addressbookId := ""
{
addressBooksById, err := c.objectsById(accountId, AddressBookType, JmapContacts)
require.NoError(err)
for id, addressbook := range addressBooksById {
if isDefault, ok := addressbook["isDefault"]; ok {
if isDefault.(bool) {
addressbookId = id
break
}
}
}
}
require.NotEmpty(addressbookId)
filled := map[string]jscontact.ContactCard{}
for i := range count {
person := gofakeit.Person()
nameMap, nameObj := createName(person)
language := pickLanguage()
contact := map[string]any{
"@type": "Card",
"version": "1.0",
"addressBookIds": toBoolMap([]string{addressbookId}),
"prodId": productName,
"language": language,
"kind": "individual",
"name": nameMap,
}
card := jscontact.ContactCard{
Type: jscontact.ContactCardType,
Version: "1.0",
AddressBookIds: toBoolMap([]string{addressbookId}),
ProdId: productName,
Language: language,
Kind: jscontact.ContactCardKindIndividual,
Name: &nameObj,
}
if i%3 == 0 {
nicknameMap, nicknameObj := createNickName(person)
id := id()
contact["nicknames"] = map[string]map[string]any{id: nicknameMap}
card.Nicknames = map[string]jscontact.Nickname{id: nicknameObj}
boxes.nicknames = true
}
{
emailMaps := map[string]map[string]any{}
emailObjs := map[string]jscontact.EmailAddress{}
emailId := id()
emailMap, emailObj := createEmail(person, 10)
emailMaps[emailId] = emailMap
emailObjs[emailId] = emailObj
for i := range rand.Intn(3) {
id := id()
m, o := createSecondaryEmail(gofakeit.Email(), i*100)
emailMaps[id] = m
emailObjs[id] = o
boxes.secondaryEmails = true
}
if len(emailMaps) > 0 {
contact["emails"] = emailMaps
card.Emails = emailObjs
}
}
if err := propmap(i%2 == 0, 1, 2, contact, "phones", &card.Phones, func(i int, id string) (map[string]any, jscontact.Phone, error) {
boxes.phones = true
num := person.Contact.Phone
if i > 0 {
num = gofakeit.Phone()
}
var features map[jscontact.PhoneFeature]bool = nil
if rand.Intn(3) < 2 {
features = toBoolMapS(jscontact.PhoneFeatureMobile, jscontact.PhoneFeatureVoice, jscontact.PhoneFeatureVideo, jscontact.PhoneFeatureText)
} else {
features = toBoolMapS(jscontact.PhoneFeatureVoice, jscontact.PhoneFeatureMainNumber)
}
contexts := map[jscontact.PhoneContext]bool{jscontact.PhoneContextWork: true}
if rand.Intn(2) < 1 {
contexts[jscontact.PhoneContextPrivate] = true
}
tel := "tel:" + "+1" + num
return map[string]any{
"@type": "Phone",
"number": tel,
"features": structs.MapKeys(features, func(f jscontact.PhoneFeature) string { return string(f) }),
"contexts": structs.MapKeys(contexts, func(c jscontact.PhoneContext) string { return string(c) }),
}, jscontact.Phone{
Type: jscontact.PhoneType,
Number: tel,
Features: features,
Contexts: contexts,
}, nil
}); err != nil {
return "", "", nil, boxes, err
}
if err := propmap(i%5 < 4, 1, 2, contact, "addresses", &card.Addresses, func(i int, id string) (map[string]any, jscontact.Address, error) {
var source *gofakeit.AddressInfo
if i == 0 {
source = person.Address
} else {
source = gofakeit.Address()
boxes.secondaryAddress = true
}
components := []jscontact.AddressComponent{}
m := streetNumberRegex.FindAllStringSubmatch(source.Street, -1)
if m != nil {
components = append(components, jscontact.AddressComponent{Type: jscontact.AddressComponentType, Kind: jscontact.AddressComponentKindName, Value: m[0][2]})
components = append(components, jscontact.AddressComponent{Type: jscontact.AddressComponentType, Kind: jscontact.AddressComponentKindNumber, Value: m[0][1]})
} else {
components = append(components, jscontact.AddressComponent{Type: jscontact.AddressComponentType, Kind: jscontact.AddressComponentKindName, Value: source.Street})
}
components = append(components,
jscontact.AddressComponent{Type: jscontact.AddressComponentType, Kind: jscontact.AddressComponentKindLocality, Value: source.City},
jscontact.AddressComponent{Type: jscontact.AddressComponentType, Kind: jscontact.AddressComponentKindCountry, Value: source.Country},
jscontact.AddressComponent{Type: jscontact.AddressComponentType, Kind: jscontact.AddressComponentKindRegion, Value: source.State},
jscontact.AddressComponent{Type: jscontact.AddressComponentType, Kind: jscontact.AddressComponentKindPostcode, Value: source.Zip},
)
tz := pickRandom(timezones...)
return map[string]any{
"@type": "Address",
"components": structs.Map(components, func(c jscontact.AddressComponent) map[string]string {
return map[string]string{"kind": string(c.Kind), "value": c.Value}
}),
"defaultSeparator": ", ",
"isOrdered": true,
"timeZone": tz,
}, jscontact.Address{
Type: jscontact.AddressType,
Components: components,
DefaultSeparator: ", ",
IsOrdered: true,
TimeZone: tz,
}, nil
}); err != nil {
return "", "", nil, boxes, err
}
if err := propmap(i%2 == 0, 1, 2, contact, "onlineServices", &card.OnlineServices, func(i int, id string) (map[string]any, jscontact.OnlineService, error) {
boxes.onlineService = true
switch rand.Intn(3) {
case 0:
return map[string]any{
"@type": "OnlineService",
"service": "Mastodon",
"user": "@" + person.Contact.Email,
"uri": "https://mastodon.example.com/@" + strings.ToLower(person.FirstName),
}, jscontact.OnlineService{
Type: jscontact.OnlineServiceType,
Service: "Mastodon",
User: "@" + person.Contact.Email,
Uri: "https://mastodon.example.com/@" + strings.ToLower(person.FirstName),
}, nil
case 1:
return map[string]any{
"@type": "OnlineService",
"uri": "xmpp:" + person.Contact.Email,
}, jscontact.OnlineService{
Type: jscontact.OnlineServiceType,
Uri: "xmpp:" + person.Contact.Email,
}, nil
default:
return map[string]any{
"@type": "OnlineService",
"service": "Discord",
"user": person.Contact.Email,
"uri": "https://discord.example.com/user/" + person.Contact.Email,
}, jscontact.OnlineService{
Type: jscontact.OnlineServiceType,
Service: "Discord",
User: person.Contact.Email,
Uri: "https://discord.example.com/user/" + person.Contact.Email,
}, nil
}
}); err != nil {
return "", "", nil, boxes, err
}
if err := propmap(i%3 == 0, 1, 2, contact, "preferredLanguages", &card.PreferredLanguages, func(i int, id string) (map[string]any, jscontact.LanguagePref, error) {
boxes.preferredLanguage = true
lang := pickRandom("en", "fr", "de", "es", "it")
contexts := pickRandoms1("work", "private")
return map[string]any{
"@type": "LanguagePref",
"language": lang,
"contexts": toBoolMap(contexts),
"pref": i + 1,
}, jscontact.LanguagePref{
Type: jscontact.LanguagePrefType,
Language: lang,
Contexts: toBoolMap(structs.Map(contexts, func(s string) jscontact.LanguagePrefContext { return jscontact.LanguagePrefContext(s) })),
Pref: uint(i + 1),
}, nil
}); err != nil {
return "", "", nil, boxes, err
}
if i%2 == 0 {
organizationMaps := map[string]map[string]any{}
organizationObjs := map[string]jscontact.Organization{}
titleMaps := map[string]map[string]any{}
titleObjs := map[string]jscontact.Title{}
for range 1 + rand.Intn(2) {
boxes.organization = true
orgId := id()
titleId := id()
organizationMaps[orgId] = map[string]any{
"@type": "Organization",
"name": person.Job.Company,
"contexts": toBoolMapS("work"),
}
organizationObjs[orgId] = jscontact.Organization{
Type: jscontact.OrganizationType,
Name: person.Job.Company,
Contexts: toBoolMapS(jscontact.OrganizationContextWork),
}
titleMaps[titleId] = map[string]any{
"@type": "Title",
"kind": "title",
"name": person.Job.Title,
"organizationId": orgId,
}
titleObjs[titleId] = jscontact.Title{
Type: jscontact.TitleType,
Kind: jscontact.TitleKindTitle,
Name: person.Job.Title,
OrganizationId: orgId,
}
}
contact["organizations"] = organizationMaps
contact["titles"] = titleMaps
card.Organizations = organizationObjs
card.Titles = titleObjs
}
if err := propmap(i%2 == 0, 1, 1, contact, "cryptoKeys", &card.CryptoKeys, func(i int, id string) (map[string]any, jscontact.CryptoKey, error) {
boxes.cryptoKey = true
entity, err := openpgp.NewEntity(person.FirstName+" "+person.LastName, "test", person.Contact.Email, nil)
if err != nil {
return nil, jscontact.CryptoKey{}, err
}
var b bytes.Buffer
err = entity.PrimaryKey.Serialize(&b)
if err != nil {
return nil, jscontact.CryptoKey{}, err
}
encoded := base64.RawStdEncoding.EncodeToString(b.Bytes())
return map[string]any{
"@type": "CryptoKey",
"uri": "data:application/pgp-keys;base64," + encoded,
"mediaType": "application/pgp-keys",
}, jscontact.CryptoKey{
Type: jscontact.CryptoKeyType,
Uri: "data:application/pgp-keys;base64," + encoded,
MediaType: "application/pgp-keys",
}, nil
}); err != nil {
return "", "", nil, boxes, err
}
if err := propmap(i%2 == 0, 1, 2, contact, "media", &card.Media, func(i int, id string) (map[string]any, jscontact.Media, error) {
label := fmt.Sprintf("photo-%d", 1000+rand.Intn(9000))
r := 0
if EnableMediaWithBlobId {
r = rand.Intn(3)
} else {
r = rand.Intn(2)
}
switch r {
case 0:
boxes.mediaWithDataUri = true
// use data uri
//size := 16 + rand.Intn(512-16+1) // <- let's not do that right now, makes debugging errors very difficult due to the ASCII wall noise
size := pickRandom(16, 24, 32, 48, 64)
img := gofakeit.ImagePng(size, size)
mime := "image/png"
uri := "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(img)
contexts := toBoolMapS(jscontact.MediaContextPrivate)
return map[string]any{
"@type": "Media",
"kind": string(jscontact.MediaKindPhoto),
"uri": uri,
"mediaType": mime,
"contexts": structs.MapKeys(contexts, func(c jscontact.MediaContext) string { return string(c) }),
"label": label,
}, jscontact.Media{
Type: jscontact.MediaType,
Kind: jscontact.MediaKindPhoto,
Uri: uri,
MediaType: mime,
Contexts: contexts,
Label: label,
}, nil
case 1:
boxes.mediaWithExternalUri = true
// use external uri
uri := externalImageUri()
contexts := toBoolMapS(jscontact.MediaContextWork)
return map[string]any{
"@type": "Media",
"kind": string(jscontact.MediaKindPhoto),
"uri": uri,
"contexts": structs.MapKeys(contexts, func(c jscontact.MediaContext) string { return string(c) }),
"label": label,
}, jscontact.Media{
Type: jscontact.MediaType,
Kind: jscontact.MediaKindPhoto,
Uri: uri,
Contexts: contexts,
Label: label,
}, nil
default:
boxes.mediaWithBlobId = true
size := pickRandom(16, 24, 32, 48, 64)
img := gofakeit.ImageJpeg(size, size)
blob, err := c.uploadBlob(accountId, img, "image/jpeg")
if err != nil {
return nil, jscontact.Media{}, err
}
contexts := toBoolMapS(jscontact.MediaContextPrivate)
return map[string]any{
"@type": "Media",
"kind": string(jscontact.MediaKindPhoto),
"blobId": blob.BlobId,
"contexts": structs.MapKeys(contexts, func(c jscontact.MediaContext) string { return string(c) }),
"label": label,
}, jscontact.Media{
Type: jscontact.MediaType,
Kind: jscontact.MediaKindPhoto,
BlobId: blob.BlobId,
MediaType: blob.Type,
Contexts: contexts,
Label: label,
}, nil
}
}); err != nil {
return "", "", nil, boxes, err
}
if err := propmap(i%2 == 0, 1, 1, contact, "links", &card.Links, func(i int, id string) (map[string]any, jscontact.Link, error) {
boxes.link = true
return map[string]any{
"@type": "Link",
"kind": "contact",
"uri": "mailto:" + person.Contact.Email,
"pref": (i + 1) * 10,
}, jscontact.Link{
Type: jscontact.LinkType,
Kind: jscontact.LinkKindContact,
Uri: "mailto:" + person.Contact.Email,
Pref: uint((i + 1) * 10),
}, nil
}); err != nil {
return "", "", nil, boxes, err
}
id, err := s.CreateContact(c, accountId, contact)
if err != nil {
return "", "", nil, boxes, err
}
card.Id = id
filled[id] = card
printer(fmt.Sprintf("🧑🏻 created %*s/%v id=%v", int(math.Log10(float64(count))+1), strconv.Itoa(int(i+1)), count, id))
}
return accountId, addressbookId, filled, boxes, nil
}
func (s *StalwartTest) CreateContact(j *TestJmapClient, accountId string, contact map[string]any) (string, error) {
return j.create1(accountId, ContactCardType, JmapContacts, contact)
}

View File

@@ -0,0 +1,748 @@
package jmap
import (
"maps"
"math/rand"
"slices"
"strings"
"testing"
"bytes"
"crypto/tls"
"fmt"
"log"
"net"
"net/mail"
"regexp"
"strconv"
"time"
"github.com/brianvoe/gofakeit/v7"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/imapclient"
"github.com/jhillyerd/enmime/v2"
"github.com/opencloud-eu/opencloud/pkg/structs"
"github.com/stretchr/testify/require"
)
func TestEmails(t *testing.T) {
if skip(t) {
return
}
count := 15 + rand.Intn(20)
require := require.New(t)
s, err := newStalwartTest(t)
require.NoError(err)
defer s.Close()
user := pickUser()
session := s.Session(user.name)
accountId := session.PrimaryAccounts.Mail
inboxId, inboxFolder := s.findInbox(t, accountId, session)
var threads int = 0
var mails []filledMail = nil
{
mails, threads, err = s.fillEmailsWithImap(inboxFolder, count, false, user)
require.NoError(err)
}
mailsByMessageId := structs.Index(mails, func(mail filledMail) string { return mail.messageId })
{
{
resp, sessionState, _, _, err := s.client.GetAllIdentities(accountId, session, s.ctx, s.logger, "")
require.NoError(err)
require.Equal(session.State, sessionState)
require.Len(resp, 1)
require.Equal(user.email, resp[0].Email)
require.Equal(user.description, resp[0].Name)
}
{
respByAccountId, sessionState, _, _, err := s.client.GetAllMailboxes([]string{accountId}, session, s.ctx, s.logger, "")
require.NoError(err)
require.Equal(session.State, sessionState)
require.Len(respByAccountId, 1)
require.Contains(respByAccountId, accountId)
resp := respByAccountId[accountId]
mailboxesUnreadByRole := map[string]int{}
for _, m := range resp {
if m.Role != "" {
mailboxesUnreadByRole[m.Role] = m.UnreadEmails
}
}
require.LessOrEqual(mailboxesUnreadByRole["inbox"], count)
}
{
resp, sessionState, _, _, err := s.client.GetAllEmailsInMailbox(accountId, session, s.ctx, s.logger, "", inboxId, 0, 0, true, false, 0, true)
require.NoError(err)
require.Equal(session.State, sessionState)
require.Equalf(threads, len(resp.Emails), "the number of collapsed emails in the inbox is expected to be %v, but is actually %v", threads, len(resp.Emails))
for _, e := range resp.Emails {
require.Len(e.MessageId, 1)
expectation, ok := mailsByMessageId[e.MessageId[0]]
require.True(ok)
matchEmail(t, e, expectation, false)
}
}
{
resp, sessionState, _, _, err := s.client.GetAllEmailsInMailbox(accountId, session, s.ctx, s.logger, "", inboxId, 0, 0, false, false, 0, true)
require.NoError(err)
require.Equal(session.State, sessionState)
require.Equalf(count, len(resp.Emails), "the number of emails in the inbox is expected to be %v, but is actually %v", count, len(resp.Emails))
for _, e := range resp.Emails {
require.Len(e.MessageId, 1)
expectation, ok := mailsByMessageId[e.MessageId[0]]
require.True(ok)
matchEmail(t, e, expectation, false)
}
}
}
}
func TestSendingEmails(t *testing.T) {
if skip(t) {
return
}
require := require.New(t)
s, err := newStalwartTest(t)
require.NoError(err)
defer s.Close()
from := pickUser()
session := s.Session(from.name)
accountId := session.PrimaryAccounts.Mail
var to User
{
others := structs.Filter(users[:], func(u User) bool { return u.name != from.name })
to = others[rand.Intn(len(others))]
}
toSession := s.Session(to.name)
toAccountId := toSession.PrimaryAccounts.Mail
var cc User
{
others := structs.Filter(users[:], func(u User) bool { return u.name != from.name && u.name != to.name })
cc = others[rand.Intn(len(others))]
}
ccSession := s.Session(cc.name)
ccAccountId := ccSession.PrimaryAccounts.Mail
var mailboxPerRole map[string]Mailbox
{
mailboxes, _, _, _, err := s.client.GetAllMailboxes([]string{accountId}, session, s.ctx, s.logger, "")
require.NoError(err)
mailboxPerRole = structs.Index(mailboxes[accountId], func(m Mailbox) string { return m.Role })
require.Contains(mailboxPerRole, JmapMailboxRoleInbox)
require.Contains(mailboxPerRole, JmapMailboxRoleDrafts)
require.Contains(mailboxPerRole, JmapMailboxRoleSent)
require.Contains(mailboxPerRole, JmapMailboxRoleTrash)
}
{
roles := []string{JmapMailboxRoleDrafts, JmapMailboxRoleSent, JmapMailboxRoleInbox}
m, _, _, _, err := s.client.SearchMailboxIdsPerRole([]string{accountId}, session, s.ctx, s.logger, "", roles)
require.NoError(err)
require.Contains(m, accountId)
a := m[accountId]
for _, role := range roles {
require.Contains(a, role)
}
}
// let's ensure that the recipients have zero emails in their mailboxes before we send them any
for _, u := range []struct {
accountId string
session *Session
}{{toAccountId, toSession}, {ccAccountId, ccSession}} {
mailboxes, _, _, _, err := s.client.GetAllMailboxes([]string{u.accountId}, u.session, s.ctx, s.logger, "")
require.NoError(err)
for _, mailbox := range mailboxes[u.accountId] {
require.Equal(0, mailbox.TotalEmails)
}
}
subject := fmt.Sprintf("Test Subject %d", 10000+rand.Intn(90000))
fromName := fmt.Sprintf("%s (test %d)", from.name, 1000+rand.Intn(9000))
sender := EmailAddress{Email: from.email, Name: from.description}
{
var identity Identity
{
identities, _, _, _, err := s.client.GetAllIdentities(accountId, session, s.ctx, s.logger, "")
require.NoError(err)
require.NotEmpty(identities)
identity = identities[0]
}
create := EmailCreate{
Keywords: toBoolMapS("test"),
Subject: subject,
MailboxIds: toBoolMapS(mailboxPerRole[JmapMailboxRoleDrafts].Id),
}
created, _, _, _, err := s.client.CreateEmail(accountId, create, "", session, s.ctx, s.logger, "")
require.NoError(err)
require.NotEmpty(created.Id)
{
emails, notFound, _, _, _, err := s.client.GetEmails(accountId, session, s.ctx, s.logger, "", []string{created.Id}, true, 0, false, false)
require.NoError(err)
require.Len(emails, 1)
require.Empty(notFound)
email := emails[0]
require.Equal(created.Id, email.Id)
require.Len(email.MailboxIds, 1)
require.Contains(email.MailboxIds, mailboxPerRole[JmapMailboxRoleDrafts].Id)
}
update := EmailCreate{
From: []EmailAddress{{Name: fromName, Email: from.email}},
To: []EmailAddress{{Name: to.description, Email: to.email}},
Cc: []EmailAddress{{Name: cc.description, Email: cc.email}},
Sender: []EmailAddress{sender},
Keywords: toBoolMapS("test"),
Subject: subject,
MailboxIds: toBoolMapS(mailboxPerRole[JmapMailboxRoleDrafts].Id),
}
updated, _, _, _, err := s.client.CreateEmail(accountId, update, created.Id, session, s.ctx, s.logger, "")
require.NoError(err)
require.NotEmpty(updated.Id)
require.NotEqual(created.Id, updated.Id)
var updatedMailboxId string
{
emails, notFound, _, _, _, err := s.client.GetEmails(accountId, session, s.ctx, s.logger, "", []string{created.Id, updated.Id}, true, 0, false, false)
require.NoError(err)
require.Len(emails, 1)
require.Len(notFound, 1)
email := emails[0]
require.Equal(updated.Id, email.Id)
require.Len(email.MailboxIds, 1)
require.Contains(email.MailboxIds, mailboxPerRole[JmapMailboxRoleDrafts].Id)
require.Equal(notFound[0], created.Id)
var ok bool
updatedMailboxId, ok = structs.FirstKey(email.MailboxIds)
require.True(ok)
}
move := MoveMail{
FromMailboxId: updatedMailboxId,
ToMailboxId: mailboxPerRole[JmapMailboxRoleSent].Id,
}
sub, _, _, _, err := s.client.SubmitEmail(accountId, identity.Id, updated.Id, &move, session, s.ctx, s.logger, "")
require.NoError(err)
require.NotEmpty(sub.Id)
require.NotEmpty(sub.ThreadId)
require.Equal(updated.Id, sub.EmailId)
require.Equal(identity.Id, sub.IdentityId)
require.Equal(sub.UndoStatus, UndoStatusPending) // this *might* be fragile: if the server is fast enough, would we get "final" here?
require.Empty(sub.DsnBlobIds)
require.Empty(sub.MdnBlobIds)
require.Equal(from.email, sub.Envelope.MailFrom.Email)
require.Nil(sub.Envelope.MailFrom.Parameters)
require.Len(sub.Envelope.RcptTo, 2)
require.Contains(sub.Envelope.RcptTo, Address{Email: to.email})
require.Contains(sub.Envelope.RcptTo, Address{Email: cc.email})
require.NotZero(sub.SendAt)
require.Len(sub.DeliveryStatus, 2)
require.Contains(sub.DeliveryStatus, to.email)
require.Contains(sub.DeliveryStatus, cc.email)
a := 0
maxAttempts := 3
delivery := sub.DeliveryStatus[to.email].Delivered
for delivery != DeliveredYes {
require.NotEqual(DeliveredNo, delivery)
a++
if a >= maxAttempts {
break
}
time.Sleep(1 * time.Second)
subs, notFound, _, _, _, err := s.client.GetEmailSubmissionStatus(accountId, []string{sub.Id}, session, s.ctx, s.logger, "")
require.NoError(err)
require.Empty(notFound)
require.Contains(subs, sub.Id)
delivery = subs[sub.Id].DeliveryStatus[to.email].Delivered
}
require.Contains([]DeliveryStatusDelivered{DeliveredYes, DeliveredUnknown}, delivery)
for _, r := range []struct {
user User
accountId string
session *Session
}{{to, toAccountId, toSession}, {cc, ccAccountId, ccSession}} {
mailboxes, _, _, _, err := s.client.GetAllMailboxes([]string{r.accountId}, r.session, s.ctx, s.logger, "")
require.NoError(err)
inboxId := ""
for _, mailbox := range mailboxes[r.accountId] {
if mailbox.Role == JmapMailboxRoleInbox {
inboxId = mailbox.Id
require.Equal(1, mailbox.TotalEmails)
}
}
require.NotEmpty(inboxId, "failed to find the Mailbox with the 'inbox' role for %v", r.user.name)
emails, _, _, _, err := s.client.QueryEmails([]string{r.accountId}, EmailFilterCondition{InMailbox: inboxId}, r.session, s.ctx, s.logger, "", 0, 0, true, 0)
require.NoError(err)
require.Contains(emails, r.accountId)
require.Len(emails[r.accountId].Emails, 1)
received := emails[r.accountId].Emails[0]
require.Len(received.From, 1)
require.Equal(from.email, received.From[0].Email)
require.Equal(fromName, received.From[0].Name)
require.Len(received.Sender, 1)
require.Equal(from.email, received.Sender[0].Email)
require.Equal(from.description, received.Sender[0].Name)
require.Len(received.To, 1)
require.Equal(to.email, received.To[0].Email)
require.Equal(to.description, received.To[0].Name)
require.Len(received.Cc, 1)
require.Equal(cc.email, received.Cc[0].Email)
require.Equal(cc.description, received.Cc[0].Name)
require.Equal(subject, received.Subject)
}
}
}
func matchEmail(t *testing.T, actual Email, expected filledMail, hasBodies bool) {
require := require.New(t)
require.Len(actual.MessageId, 1)
require.Equal(expected.messageId, actual.MessageId[0])
require.Equal(expected.subject, actual.Subject)
require.NotEmpty(actual.Preview)
if hasBodies {
require.Len(actual.TextBody, 1)
textBody := actual.TextBody[0]
partId := textBody.PartId
require.Contains(actual.BodyValues, partId)
content := actual.BodyValues[partId].Value
require.True(strings.Contains(content, actual.Preview), "text body contains preview")
} else {
require.Empty(actual.BodyValues)
}
require.ElementsMatch(slices.Collect(maps.Keys(actual.Keywords)), expected.keywords)
{
list := make([]filledAttachment, len(actual.Attachments))
for i, a := range actual.Attachments {
list[i] = filledAttachment{
name: a.Name,
size: a.Size,
mimeType: a.Type,
disposition: a.Disposition,
}
require.NotEmpty(a.BlobId)
require.NotEmpty(a.PartId)
}
require.ElementsMatch(list, expected.attachments)
}
}
func (s *StalwartTest) findInbox(t *testing.T, accountId string, session *Session) (string, string) {
require := require.New(t)
respByAccountId, sessionState, _, _, err := s.client.GetAllMailboxes([]string{accountId}, session, s.ctx, s.logger, "")
require.NoError(err)
require.Equal(session.State, sessionState)
require.Len(respByAccountId, 1)
require.Contains(respByAccountId, accountId)
resp := respByAccountId[accountId]
mailboxesNameByRole := map[string]string{}
mailboxesUnreadByRole := map[string]int{}
for _, m := range resp {
if m.Role != "" {
mailboxesNameByRole[m.Role] = m.Name
mailboxesUnreadByRole[m.Role] = m.UnreadEmails
}
}
require.Contains(mailboxesNameByRole, "inbox")
require.Contains(mailboxesUnreadByRole, "inbox")
require.Zero(mailboxesUnreadByRole["inbox"])
inboxId := mailboxId("inbox", resp)
require.NotEmpty(inboxId)
inboxFolder := mailboxesNameByRole["inbox"]
require.NotEmpty(inboxFolder)
return inboxId, inboxFolder
}
var emailSplitter = regexp.MustCompile("(.+)@(.+)$")
func htmlFormat(body string, msg enmime.MailBuilder) enmime.MailBuilder {
return msg.HTML([]byte(toHtml(body)))
}
func textFormat(body string, msg enmime.MailBuilder) enmime.MailBuilder {
return msg.Text([]byte(body))
}
func bothFormat(body string, msg enmime.MailBuilder) enmime.MailBuilder {
msg = htmlFormat(body, msg)
msg = textFormat(body, msg)
return msg
}
var formats = []func(string, enmime.MailBuilder) enmime.MailBuilder{
htmlFormat,
textFormat,
bothFormat,
}
type sender struct {
first string
last string
from string
sender string
}
func (s sender) inject(b enmime.MailBuilder) enmime.MailBuilder {
return b.From(s.first+" "+s.last, s.from).Header("Sender", s.sender)
}
type senderGenerator struct {
senders []sender
}
func newSenderGenerator(numSenders int) senderGenerator {
senders := make([]sender, numSenders)
for i := range numSenders {
person := gofakeit.Person()
senders[i] = sender{
first: person.FirstName,
last: person.LastName,
from: person.Contact.Email,
sender: person.FirstName + " " + person.LastName + "<" + person.Contact.Email + ">",
}
}
return senderGenerator{
senders: senders,
}
}
func (s senderGenerator) nextSender() *sender {
if len(s.senders) < 1 {
panic("failed to determine a sender to use")
} else {
return &s.senders[rand.Intn(len(s.senders))]
}
}
func fakeFilename(extension string) string {
return strings.ReplaceAll(gofakeit.Product().Name, " ", "_") + extension
}
func mailboxId(role string, mailboxes []Mailbox) string {
for _, m := range mailboxes {
if m.Role == role {
return m.Id
}
}
return ""
}
type filledAttachment struct {
name string
size int
mimeType string
disposition string
}
type filledMail struct {
uid int
attachments []filledAttachment
subject string
testId string
messageId string
keywords []string
}
var allKeywords = map[string]imap.Flag{
JmapKeywordAnswered: imap.FlagAnswered,
JmapKeywordDraft: imap.FlagDraft,
JmapKeywordFlagged: imap.FlagFlagged,
JmapKeywordForwarded: imap.FlagForwarded,
JmapKeywordJunk: imap.FlagJunk,
JmapKeywordMdnSent: imap.FlagMDNSent,
JmapKeywordNotJunk: imap.FlagNotJunk,
JmapKeywordPhishing: imap.FlagPhishing,
JmapKeywordSeen: imap.FlagSeen,
}
func (s *StalwartTest) fillEmailsWithImap(folder string, count int, empty bool, user User) ([]filledMail, int, error) {
to := fmt.Sprintf("%s <%s>", user.description, user.email)
ccEvery := 2
bccEvery := 3
attachmentEvery := 2
senders := max(count/4, 1)
maxThreadSize := 6
maxAttachments := 4
tlsConfig := &tls.Config{InsecureSkipVerify: true}
c, err := imapclient.DialTLS(net.JoinHostPort(s.ip, strconv.Itoa(s.imapPort)), &imapclient.Options{TLSConfig: tlsConfig})
if err != nil {
return nil, 0, err
}
defer func(imap *imapclient.Client) {
err := imap.Close()
if err != nil {
log.Fatal(err)
}
}(c)
if err = c.Login(user.name, user.password).Wait(); err != nil {
return nil, 0, err
}
if _, err = c.Select(folder, &imap.SelectOptions{ReadOnly: false}).Wait(); err != nil {
return nil, 0, err
}
if empty {
if ids, err := c.Search(&imap.SearchCriteria{}, nil).Wait(); err != nil {
return nil, 0, err
} else {
if len(ids.AllSeqNums()) > 0 {
storeFlags := imap.StoreFlags{
Op: imap.StoreFlagsAdd,
Flags: []imap.Flag{imap.FlagDeleted},
Silent: true,
}
if err = c.Store(ids.All, &storeFlags, nil).Close(); err != nil {
return nil, 0, err
}
if err = c.Expunge().Close(); err != nil {
return nil, 0, err
}
log.Printf("🗑️ deleted %d messages in %s", len(ids.AllSeqNums()), folder)
} else {
log.Printf(" did not delete any messages, %s is empty", folder)
}
}
}
address, err := mail.ParseAddress(to)
if err != nil {
return nil, 0, err
}
displayName := address.Name
addressParts := emailSplitter.FindAllStringSubmatch(address.Address, 3)
if len(addressParts) != 1 {
return nil, 0, fmt.Errorf("address does not have one part: '%v' -> %v", address.Address, addressParts)
}
if len(addressParts[0]) != 3 {
return nil, 0, fmt.Errorf("first address part does not have a size of 3: '%v'", addressParts[0])
}
domain := addressParts[0][2]
toName := displayName
toAddress := fmt.Sprintf("%s@%s", user.name, domain)
ccName1 := "Team Lead"
ccAddress1 := fmt.Sprintf("lead@%s", domain)
ccName2 := "Coworker"
ccAddress2 := fmt.Sprintf("coworker@%s", domain)
bccName := "HR"
bccAddress := fmt.Sprintf("corporate@%s", domain)
sg := newSenderGenerator(senders)
thread := 0
mails := make([]filledMail, count)
for i := 0; i < count; thread++ {
threadMessageId := fmt.Sprintf("%d.%d@%s", time.Now().Unix(), 1000000+rand.Intn(8999999), domain)
threadSubject := strings.Trim(gofakeit.SentenceSimple(), ".") // remove the . at the end, looks weird
threadSize := 1 + rand.Intn(maxThreadSize)
lastMessageId := ""
lastSubject := ""
for t := 0; i < count && t < threadSize; t++ {
sender := sg.nextSender()
format := formats[i%len(formats)]
text := gofakeit.Paragraph(2+rand.Intn(9), 1+rand.Intn(4), 1+rand.Intn(32), "\n")
msg := sender.inject(enmime.Builder().To(toName, toAddress))
messageId := ""
if lastMessageId == "" {
// start a new thread
msg = msg.Header("Message-ID", threadMessageId).Subject(threadSubject)
lastMessageId = threadMessageId
lastSubject = threadSubject
messageId = threadMessageId
} else {
// we're continuing a thread
messageId = fmt.Sprintf("%d.%d@%s", time.Now().Unix(), 1000000+rand.Intn(8999999), domain)
inReplyTo := ""
subject := ""
switch rand.Intn(2) {
case 0:
// reply to first post in thread
subject = "Re: " + threadSubject
inReplyTo = threadMessageId
default:
// reply to last addition to thread
subject = "Re: " + lastSubject
inReplyTo = lastMessageId
}
msg = msg.Header("Message-ID", messageId).Header("In-Reply-To", inReplyTo).Subject(subject)
lastMessageId = messageId
lastSubject = subject
}
if i%ccEvery == 0 {
msg = msg.CCAddrs([]mail.Address{{Name: ccName1, Address: ccAddress1}, {Name: ccName2, Address: ccAddress2}})
}
if i%bccEvery == 0 {
msg = msg.BCC(bccName, bccAddress)
}
numAttachments := 0
attachments := []filledAttachment{}
if maxAttachments > 0 && i%attachmentEvery == 0 {
numAttachments = rand.Intn(maxAttachments)
for a := range numAttachments {
switch rand.Intn(2) {
case 0:
filename := fakeFilename(".txt")
attachment := gofakeit.Paragraph(2+rand.Intn(4), 1+rand.Intn(4), 1+rand.Intn(32), "\n")
data := []byte(attachment)
msg = msg.AddAttachment(data, "text/plain", filename)
attachments = append(attachments, filledAttachment{
name: filename,
size: len(data),
mimeType: "text/plain",
disposition: "attachment",
})
default:
filename := ""
mimetype := ""
var image []byte = nil
switch rand.Intn(2) {
case 0:
filename = fakeFilename(".png")
mimetype = "image/png"
image = gofakeit.ImagePng(512, 512)
default:
filename = fakeFilename(".jpg")
mimetype = "image/jpeg"
image = gofakeit.ImageJpeg(400, 200)
}
disposition := ""
switch rand.Intn(2) {
case 0:
msg = msg.AddAttachment(image, mimetype, filename)
disposition = "attachment"
default:
msg = msg.AddInline(image, mimetype, filename, "c"+strconv.Itoa(a))
disposition = "inline"
}
attachments = append(attachments, filledAttachment{
name: filename,
size: len(image),
mimeType: mimetype,
disposition: disposition,
})
}
}
}
msg = format(text, msg)
flags := []imap.Flag{}
keywords := pickRandomlyFromMap(allKeywords, 0, len(allKeywords))
for _, f := range keywords {
flags = append(flags, f)
}
buf := new(bytes.Buffer)
part, _ := msg.Build()
part.Encode(buf)
mail := buf.String()
var options *imap.AppendOptions = nil
if len(flags) > 0 {
options = &imap.AppendOptions{Flags: flags}
}
size := int64(len(mail))
appendCmd := c.Append(folder, size, options)
if _, err := appendCmd.Write([]byte(mail)); err != nil {
return nil, 0, err
}
if err := appendCmd.Close(); err != nil {
return nil, 0, err
}
if appendData, err := appendCmd.Wait(); err != nil {
return nil, 0, err
} else {
attachmentStr := ""
if numAttachments > 0 {
attachmentStr = " " + strings.Repeat("📎", numAttachments)
}
log.Printf(" appended %v/%v [in thread %v] uid=%v%s", i+1, count, thread+1, appendData.UID, attachmentStr)
mails[i] = filledMail{
uid: int(appendData.UID),
attachments: attachments,
subject: msg.GetSubject(),
messageId: messageId,
keywords: slices.Collect(maps.Keys(keywords)),
}
}
i++
}
}
listCmd := c.List("", "%", &imap.ListOptions{
ReturnStatus: &imap.StatusOptions{
NumMessages: true,
NumUnseen: true,
},
})
countMap := map[string]int{}
for {
mbox := listCmd.Next()
if mbox == nil {
break
}
countMap[mbox.Mailbox] = int(*mbox.Status.NumMessages)
}
inboxCount := -1
for f, i := range countMap {
if strings.Compare(strings.ToLower(f), strings.ToLower(folder)) == 0 {
inboxCount = i
break
}
}
if err = listCmd.Close(); err != nil {
return nil, 0, err
}
if inboxCount == -1 {
return nil, 0, fmt.Errorf("failed to find folder '%v' via IMAP", folder)
}
if empty && count != inboxCount {
return nil, 0, fmt.Errorf("wrong number of emails in the inbox after filling, expecting %v, has %v", count, inboxCount)
}
return mails, thread, nil
}

View File

@@ -0,0 +1,621 @@
package jmap
import (
"encoding/base64"
"encoding/json"
"fmt"
"log"
"math"
"math/rand"
"strconv"
"strings"
"testing"
"time"
"github.com/brianvoe/gofakeit/v7"
"github.com/stretchr/testify/require"
"github.com/opencloud-eu/opencloud/pkg/jscalendar"
"github.com/opencloud-eu/opencloud/pkg/structs"
)
// fields that are currently unsupported in Stalwart
const (
EnableEventMayInviteFields = false
EnableEventParticipantDescriptionFields = false
)
func TestEvents(t *testing.T) {
if skip(t) {
return
}
count := uint(20 + rand.Intn(30))
require := require.New(t)
s, err := newStalwartTest(t)
require.NoError(err)
defer s.Close()
user := pickUser()
session := s.Session(user.name)
accountId, calendarId, expectedEventsById, boxes, err := s.fillEvents(t, count, session, user)
require.NoError(err)
require.NotEmpty(accountId)
require.NotEmpty(calendarId)
filter := CalendarEventFilterCondition{
InCalendar: calendarId,
}
sortBy := []CalendarEventComparator{
{Property: CalendarEventPropertyStart, IsAscending: true},
}
contactsByAccount, _, _, _, err := s.client.QueryCalendarEvents([]string{accountId}, session, t.Context(), s.logger, "", filter, sortBy, 0, 0)
require.NoError(err)
require.Len(contactsByAccount, 1)
require.Contains(contactsByAccount, accountId)
contacts := contactsByAccount[accountId]
require.Len(contacts, int(count))
for _, actual := range contacts {
expected, ok := expectedEventsById[actual.Id]
require.True(ok, "failed to find created contact by its id")
matchEvent(t, actual, expected)
}
exceptions := []string{}
if !EnableEventMayInviteFields {
exceptions = append(exceptions, "mayInvite")
}
allBoxesAreTicked(t, boxes, exceptions...)
}
func matchEvent(t *testing.T, actual CalendarEvent, expected CalendarEvent) {
//require.Equal(t, expected, actual)
deepEqual(t, expected, actual)
}
type EventsBoxes struct {
categories bool
keywords bool
mayInvite bool
}
func (s *StalwartTest) fillEvents(
t *testing.T,
count uint,
session *Session,
user User,
) (string, string, map[string]CalendarEvent, EventsBoxes, error) {
require := require.New(t)
c, err := NewTestJmapClient(session, user.name, user.password, true, true)
require.NoError(err)
defer c.Close()
boxes := EventsBoxes{}
printer := func(s string) { log.Println(s) }
accountId := c.session.PrimaryAccounts.Calendars
require.NotEmpty(accountId, "no primary account for calendars in session")
calendarId := ""
{
calendarsById, err := c.objectsById(accountId, CalendarType, JmapCalendars)
require.NoError(err)
for id, calendar := range calendarsById {
if isDefault, ok := calendar["isDefault"]; ok {
if isDefault.(bool) {
calendarId = id
break
}
}
}
}
require.NotEmpty(calendarId)
filled := map[string]CalendarEvent{}
for i := range count {
uid := gofakeit.UUID()
isDraft := false
mainLocationId := ""
locationIds := []string{}
locationMaps := map[string]map[string]any{}
locationObjs := map[string]jscalendar.Location{}
{
n := 1
if i%4 == 0 {
n++
}
for range n {
locationId, locationMap, locationObj := pickLocation()
locationMaps[locationId] = locationMap
locationObjs[locationId] = locationObj
locationIds = append(locationIds, locationId)
if n > 0 && mainLocationId == "" {
mainLocationId = locationId
}
}
}
virtualLocationId, virtualLocationMap, virtualLocationObj := pickVirtualLocation()
participantMaps, participantObjs, organizerEmail := createParticipants(uid, locationIds, []string{virtualLocationId})
duration := pickRandom("PT30M", "PT45M", "PT1H", "PT90M")
tz := pickRandom(timezones...)
daysDiff := rand.Intn(31) - 15
t := time.Now().Add(time.Duration(daysDiff) * time.Hour * 24)
h := pickRandom(9, 10, 11, 14, 15, 16, 18)
m := pickRandom(0, 30)
t = time.Date(t.Year(), t.Month(), t.Day(), h, m, 0, 0, t.Location())
start := strings.ReplaceAll(t.Format(time.DateTime), " ", "T")
title := gofakeit.Sentence(1)
description := gofakeit.Paragraph(1+rand.Intn(3), 1+rand.Intn(4), 1+rand.Intn(32), "\n")
descriptionFormat := pickRandom("text/plain", "text/html")
if descriptionFormat == "text/html" {
description = toHtml(description)
}
status := pickRandom(jscalendar.Statuses...)
freeBusy := pickRandom(jscalendar.FreeBusyStatuses...)
privacy := pickRandom(jscalendar.Privacies...)
color := pickRandom(basicColors...)
locale := pickLocale()
keywords := pickKeywords()
categories := pickCategories()
sequence := 0
alertId := id()
alertOffset := pickRandom("-PT5M", "-PT10M", "-PT15M")
event := map[string]any{
"@type": "Event",
"calendarIds": toBoolMapS(calendarId),
"isDraft": isDraft,
"start": start,
"duration": duration,
"status": string(status),
"uid": uid,
"prodId": productName,
"title": title,
"description": description,
"descriptionContentType": descriptionFormat,
"locale": locale,
"color": color,
"sequence": sequence,
"showWithoutTime": false,
"freeBusyStatus": string(freeBusy),
"privacy": string(privacy),
"sentBy": organizerEmail,
"participants": participantMaps,
"timeZone": tz,
"hideAttendees": false,
"replyTo": map[string]string{
"imip": "mailto:" + organizerEmail,
},
"locations": locationMaps,
"virtualLocations": map[string]any{
virtualLocationId: virtualLocationMap,
},
"alerts": map[string]map[string]any{
alertId: {
"@type": "Alert",
"trigger": map[string]any{
"@type": "OffsetTrigger",
"offset": alertOffset,
"relativeTo": "start",
},
},
},
}
obj := CalendarEvent{
Id: "",
CalendarIds: toBoolMapS(calendarId),
IsDraft: isDraft,
IsOrigin: true,
Event: jscalendar.Event{
Type: jscalendar.EventType,
Start: jscalendar.LocalDateTime(start),
Duration: jscalendar.Duration(duration),
Status: status,
Object: jscalendar.Object{
CommonObject: jscalendar.CommonObject{
Uid: uid,
ProdId: productName,
Title: title,
Description: description,
DescriptionContentType: descriptionFormat,
Locale: locale,
Color: color,
},
Sequence: uint(sequence),
ShowWithoutTime: false,
FreeBusyStatus: freeBusy,
Privacy: privacy,
SentBy: organizerEmail,
Participants: participantObjs,
TimeZone: tz,
HideAttendees: false,
ReplyTo: map[jscalendar.ReplyMethod]string{
jscalendar.ReplyMethodImip: "mailto:" + organizerEmail,
},
Locations: locationObjs,
VirtualLocations: map[string]jscalendar.VirtualLocation{
virtualLocationId: virtualLocationObj,
},
Alerts: map[string]jscalendar.Alert{
alertId: {
Type: jscalendar.AlertType,
Trigger: jscalendar.OffsetTrigger{
Type: jscalendar.OffsetTriggerType,
Offset: jscalendar.SignedDuration(alertOffset),
RelativeTo: jscalendar.RelativeToStart,
},
},
},
},
},
}
if EnableEventMayInviteFields {
event["mayInviteSelf"] = true
event["mayInviteOthers"] = true
obj.MayInviteSelf = true
obj.MayInviteOthers = true
boxes.mayInvite = true
}
if len(keywords) > 0 {
event["keywords"] = keywords
obj.Keywords = keywords
boxes.keywords = true
}
if len(categories) > 0 {
event["categories"] = categories
obj.Categories = categories
boxes.categories = true
}
if mainLocationId != "" {
event["mainLocationId"] = mainLocationId
obj.MainLocationId = mainLocationId
}
err = propmap(i%2 == 0, 1, 1, event, "links", &obj.Links, func(int, string) (map[string]any, jscalendar.Link, error) {
mime := ""
uri := ""
rel := jscalendar.RelAbout
switch rand.Intn(2) {
case 0:
size := pickRandom(16, 24, 32, 48, 64)
img := gofakeit.ImagePng(size, size)
mime = "image/png"
uri = "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(img)
default:
mime = "image/jpeg"
uri = externalImageUri()
}
return map[string]any{
"@type": "Link",
"href": uri,
"contentType": mime,
"rel": string(rel),
}, jscalendar.Link{
Type: jscalendar.LinkType,
Href: uri,
ContentType: mime,
Rel: rel,
}, nil
})
if rand.Intn(10) > 7 {
frequency := pickRandom(jscalendar.FrequencyWeekly, jscalendar.FrequencyDaily)
interval := pickRandom(1, 2)
count := 1
if frequency == jscalendar.FrequencyWeekly {
count = 1 + rand.Intn(8)
} else {
count = 1 + rand.Intn(4)
}
event["recurrenceRule"] = map[string]any{
"@type": "RecurrenceRule",
"frequency": string(frequency),
"interval": interval,
"rscale": string(jscalendar.RscaleIso8601),
"skip": string(jscalendar.SkipOmit),
"firstDayOfWeek": string(jscalendar.DayOfWeekMonday),
"count": count,
}
rr := jscalendar.RecurrenceRule{
Type: jscalendar.RecurrenceRuleType,
Frequency: frequency,
Interval: uint(interval),
Rscale: jscalendar.RscaleIso8601,
Skip: jscalendar.SkipOmit,
FirstDayOfWeek: jscalendar.DayOfWeekMonday,
Count: uint(count),
}
obj.RecurrenceRule = &rr
}
id, err := s.CreateEvent(c, accountId, event)
if err != nil {
return accountId, calendarId, nil, boxes, err
}
obj.Id = id
filled[id] = obj
printer(fmt.Sprintf("📅 created %*s/%v id=%v", int(math.Log10(float64(count))+1), strconv.Itoa(int(i+1)), count, uid))
}
return accountId, calendarId, filled, boxes, nil
}
func (s *StalwartTest) CreateEvent(j *TestJmapClient, accountId string, event map[string]any) (string, error) {
return j.create1(accountId, CalendarEventType, JmapCalendars, event)
}
var rooms = []jscalendar.Location{
{
Type: jscalendar.LocationType,
Name: "Office meeting room upstairs",
LocationTypes: toBoolMapS(jscalendar.LocationTypeOptionOffice),
Coordinates: "geo:52.5335389,13.4103296",
Links: map[string]jscalendar.Link{
"l1": {Href: "https://www.heinlein-support.de/"},
},
},
{
Type: jscalendar.LocationType,
Name: "office-nue",
LocationTypes: toBoolMapS(jscalendar.LocationTypeOptionOffice),
Coordinates: "geo:49.4723337,11.1042282",
Links: map[string]jscalendar.Link{
"l2": {Href: "https://www.workandpepper.de/"},
},
},
{
Type: jscalendar.LocationType,
Name: "Meetingraum Prenzlauer Berg",
LocationTypes: toBoolMapS(jscalendar.LocationTypeOptionOffice, jscalendar.LocationTypeOptionPublic),
Coordinates: "geo:52.554222,13.4142387",
Links: map[string]jscalendar.Link{
"l3": {Href: "https://www.spacebase.com/en/venue/meeting-room-prenzlauer-be-11499/"},
},
},
{
Type: jscalendar.LocationType,
Name: "Meetingraum LIANE 1",
LocationTypes: toBoolMapS(jscalendar.LocationTypeOptionOffice, jscalendar.LocationTypeOptionLibrary),
Coordinates: "geo:52.4854301,13.4224763",
Links: map[string]jscalendar.Link{
"l4": {Href: "https://www.spacebase.com/en/venue/rent-a-jungle-8372/"},
},
},
{
Type: jscalendar.LocationType,
Name: "Dark Horse",
LocationTypes: toBoolMapS(jscalendar.LocationTypeOptionOffice),
Coordinates: "geo:52.4942254,13.4346015",
Links: map[string]jscalendar.Link{
"l5": {Href: "https://www.spacebase.com/en/event-venue/workshop-white-space-2667/"},
},
},
}
var virtualRooms = []jscalendar.VirtualLocation{
{
Type: jscalendar.VirtualLocationType,
Name: "opentalk",
Uri: "https://meet.opentalk.eu/fake/room/06fb8f7d-42eb-4212-8112-769fac2cb111",
Features: toBoolMapS(
jscalendar.VirtualLocationFeatureAudio,
jscalendar.VirtualLocationFeatureChat,
jscalendar.VirtualLocationFeatureVideo,
jscalendar.VirtualLocationFeatureScreen,
),
},
}
func pickLocation() (string, map[string]any, jscalendar.Location) {
locationId := id()
room := rooms[rand.Intn(len(rooms))]
b, err := json.Marshal(room)
if err != nil {
panic(err)
}
var m map[string]any
err = json.Unmarshal(b, &m)
if err != nil {
panic(err)
}
return locationId, m, room
}
func pickVirtualLocation() (string, map[string]any, jscalendar.VirtualLocation) {
locationId := id()
vroom := virtualRooms[rand.Intn(len(virtualRooms))]
b, err := json.Marshal(vroom)
if err != nil {
panic(err)
}
var m map[string]any
err = json.Unmarshal(b, &m)
if err != nil {
panic(err)
}
return locationId, m, vroom
}
var ChairRoles = toBoolMapS(jscalendar.RoleChair, jscalendar.RoleOwner)
var RegularRoles = toBoolMapS(jscalendar.RoleOptional)
func createParticipants(uid string, locationIds []string, virtualLocationIds []string) (map[string]map[string]any, map[string]jscalendar.Participant, string) {
options := structs.Concat(locationIds, virtualLocationIds)
n := 1 + rand.Intn(4)
maps := map[string]map[string]any{}
objs := map[string]jscalendar.Participant{}
organizerId, organizerEmail, organizerMap, organizerObj := createParticipant(0, uid, pickRandom(options...), "", "")
maps[organizerId] = organizerMap
objs[organizerId] = organizerObj
for i := 1; i < n; i++ {
id, _, participantMap, participantObj := createParticipant(i, uid, pickRandom(options...), organizerId, organizerEmail)
maps[id] = participantMap
objs[id] = participantObj
}
return maps, objs, organizerEmail
}
func createParticipant(i int, uid string, locationId string, organizerEmail string, organizerId string) (string, string, map[string]any, jscalendar.Participant) {
participantId := id()
person := gofakeit.Person()
roles := RegularRoles
if i == 0 {
roles = ChairRoles
}
status := jscalendar.ParticipationStatusAccepted
if i != 0 {
status = pickRandom(
jscalendar.ParticipationStatusNeedsAction,
jscalendar.ParticipationStatusAccepted,
jscalendar.ParticipationStatusDeclined,
jscalendar.ParticipationStatusTentative,
)
//, delegated + set "delegatedTo"
}
statusComment := ""
if rand.Intn(5) >= 3 {
statusComment = gofakeit.HipsterSentence(1 + rand.Intn(5))
}
if i == 0 {
organizerEmail = person.Contact.Email
organizerId = participantId
}
name := person.FirstName + " " + person.LastName
email := person.Contact.Email
description := gofakeit.SentenceSimple()
descriptionContentType := pickRandom("text/html", "text/plain")
if descriptionContentType == "text/html" {
description = toHtml(description)
}
language := pickLanguage()
updated := "2025-10-01T01:59:12Z"
updatedTime, err := time.Parse(time.RFC3339, updated)
if err != nil {
panic(err)
}
var calendarAddress string
{
pos := strings.LastIndex(email, "@")
if pos < 0 {
calendarAddress = email
} else {
local := email[0:pos]
domain := email[pos+1:]
calendarAddress = local + "+itip+" + uid + "@" + "itip." + domain
}
}
m := map[string]any{
"@type": "Participant",
"name": name,
"email": email,
"calendarAddress": calendarAddress,
"kind": "individual",
"roles": structs.MapKeys(roles, func(r jscalendar.Role) string { return string(r) }),
"locationId": locationId,
"language": language,
"participationStatus": string(status),
"participationComment": statusComment,
"expectReply": true,
"scheduleAgent": "server",
"scheduleSequence": 1,
"scheduleStatus": []string{"1.0"},
"scheduleUpdated": updated,
"sentBy": organizerEmail,
"invitedBy": organizerId,
"scheduleId": "mailto:" + email,
}
o := jscalendar.Participant{
Type: jscalendar.ParticipantType,
Name: name,
Email: email,
Kind: jscalendar.ParticipantKindIndividual,
CalendarAddress: calendarAddress,
Roles: roles,
LocationId: locationId,
Language: language,
ParticipationStatus: status,
ParticipationComment: statusComment,
ExpectReply: true,
ScheduleAgent: jscalendar.ScheduleAgentServer,
ScheduleSequence: uint(1),
ScheduleStatus: []string{"1.0"},
ScheduleUpdated: updatedTime,
SentBy: organizerEmail,
InvitedBy: organizerId,
ScheduleId: "mailto:" + email,
}
if EnableEventParticipantDescriptionFields {
m["description"] = description
m["descriptionContentType"] = descriptionContentType
o.Description = description
o.DescriptionContentType = descriptionContentType
}
err = propmap(i%2 == 0, 1, 2, m, "links", &o.Links, func(int, string) (map[string]any, jscalendar.Link, error) {
href := externalImageUri()
title := person.FirstName + "'s Cake Day pick"
return map[string]any{
"@type": "Link",
"href": href,
"contentType": "image/jpeg",
"rel": "icon",
"display": "badge",
"title": title,
}, jscalendar.Link{
Type: jscalendar.LinkType,
Href: href,
ContentType: "image/jpeg",
Rel: jscalendar.RelIcon,
Display: jscalendar.DisplayBadge,
Title: title,
}, nil
})
if err != nil {
panic(err)
}
return participantId, person.Contact.Email, m, o
}
var Keywords = []string{
"office",
"important",
"sales",
"coordination",
"decision",
}
func pickKeywords() map[string]bool {
return toBoolMap(pickRandoms(Keywords...))
}
var Categories = []string{
"http://opencloud.eu/categories/secret",
"http://opencloud.eu/categories/internal",
}
func pickCategories() map[string]bool {
return toBoolMap(pickRandoms(Categories...))
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,230 @@
package jmap
import (
"sync"
"sync/atomic"
"testing"
"time"
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/opencloud/pkg/structs"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type testWsPushListener struct {
t *testing.T
logger *log.Logger
username string
mailAccountId string
calls atomic.Uint32
m sync.Mutex
emailStates []string
threadStates []string
mailboxStates []string
}
func (l *testWsPushListener) OnNotification(username string, pushState StateChange) {
assert.Equal(l.t, l.username, username)
l.calls.Add(1)
// pushState is currently not supported by Stalwart, let's use the object states instead
l.logger.Debug().Msgf("received %T: %v", pushState, pushState)
if changed, ok := pushState.Changed[l.mailAccountId]; ok {
l.m.Lock()
if st, ok := changed[EmailType]; ok {
l.emailStates = append(l.emailStates, st)
}
if st, ok := changed[ThreadType]; ok {
l.threadStates = append(l.threadStates, st)
}
if st, ok := changed[MailboxType]; ok {
l.mailboxStates = append(l.mailboxStates, st)
}
l.m.Unlock()
unsupportedKeys := structs.Filter(structs.Keys(changed), func(o ObjectType) bool { return o != EmailType && o != ThreadType && o != MailboxType })
assert.Empty(l.t, unsupportedKeys)
}
unsupportedAccounts := structs.Filter(structs.Keys(pushState.Changed), func(s string) bool { return s != l.mailAccountId })
assert.Empty(l.t, unsupportedAccounts)
}
var _ WsPushListener = &testWsPushListener{}
func TestWs(t *testing.T) {
if skip(t) {
return
}
assert.NoError(t, nil)
require := require.New(t)
s, err := newStalwartTest(t)
require.NoError(err)
defer s.Close()
user := pickUser()
session := s.Session(user.name)
mailAccountId := session.PrimaryAccounts.Mail
inboxFolder := ""
{
_, inboxFolder = s.findInbox(t, mailAccountId, session)
}
l := &testWsPushListener{t: t, username: user.name, logger: s.logger, mailAccountId: mailAccountId}
s.client.AddWsPushListener(l)
require.Equal(uint32(0), l.calls.Load())
{
l.m.Lock()
require.Len(l.emailStates, 0)
require.Len(l.mailboxStates, 0)
require.Len(l.threadStates, 0)
l.m.Unlock()
}
var initialState State
{
changes, sessionState, state, _, err := s.client.GetEmailChanges(mailAccountId, session, s.ctx, s.logger, "", "", 0)
require.NoError(err)
require.Equal(session.State, sessionState)
require.NotEmpty(state)
//fmt.Printf("\x1b[45;1;4mChanges [%s]:\x1b[0m\n", state)
//for _, c := range changes.Created { fmt.Printf("%s %s\n", c.Id, c.Subject) }
initialState = state
require.Empty(changes.Created)
require.Empty(changes.Destroyed)
require.Empty(changes.Updated)
}
require.NotEmpty(initialState)
{
changes, sessionState, state, _, err := s.client.GetEmailChanges(mailAccountId, session, s.ctx, s.logger, "", initialState, 0)
require.NoError(err)
require.Equal(session.State, sessionState)
require.Equal(initialState, state)
require.Equal(initialState, changes.NewState)
require.Empty(changes.Created)
require.Empty(changes.Destroyed)
require.Empty(changes.Updated)
}
wsc, err := s.client.EnablePushNotifications(initialState, func() (*Session, error) { return session, nil })
require.NoError(err)
defer wsc.Close()
require.Equal(uint32(0), l.calls.Load())
{
l.m.Lock()
require.Len(l.emailStates, 0)
require.Len(l.mailboxStates, 0)
require.Len(l.threadStates, 0)
l.m.Unlock()
}
emailIds := []string{}
{
_, n, err := s.fillEmailsWithImap(inboxFolder, 1, false, user)
require.NoError(err)
require.Equal(1, n)
}
require.Eventually(func() bool {
return l.calls.Load() == uint32(1)
}, 3*time.Second, 200*time.Millisecond, "WS push listener was not called after first email state change")
{
l.m.Lock()
require.Len(l.emailStates, 1)
require.Len(l.mailboxStates, 1)
require.Len(l.threadStates, 1)
l.m.Unlock()
}
var lastState State
{
changes, sessionState, state, _, err := s.client.GetEmailChanges(mailAccountId, session, s.ctx, s.logger, "", initialState, 0)
require.NoError(err)
require.Equal(session.State, sessionState)
require.NotEqual(initialState, state)
require.NotEqual(initialState, changes.NewState)
require.Equal(state, changes.NewState)
require.Len(changes.Created, 1)
require.Empty(changes.Destroyed)
require.Empty(changes.Updated)
lastState = state
emailIds = append(emailIds, changes.Created...)
}
{
_, n, err := s.fillEmailsWithImap(inboxFolder, 1, false, user)
require.NoError(err)
require.Equal(1, n)
}
require.Eventually(func() bool {
return l.calls.Load() == uint32(2)
}, 3*time.Second, 200*time.Millisecond, "WS push listener was not called after second email state change")
{
l.m.Lock()
require.Len(l.emailStates, 2)
require.Len(l.mailboxStates, 2)
require.Len(l.threadStates, 2)
assert.NotEqual(t, l.emailStates[0], l.emailStates[1])
assert.NotEqual(t, l.mailboxStates[0], l.mailboxStates[1])
assert.NotEqual(t, l.threadStates[0], l.threadStates[1])
l.m.Unlock()
}
{
changes, sessionState, state, _, err := s.client.GetEmailChanges(mailAccountId, session, s.ctx, s.logger, "", lastState, 0)
require.NoError(err)
require.Equal(session.State, sessionState)
require.NotEqual(lastState, state)
require.NotEqual(lastState, changes.NewState)
require.Equal(state, changes.NewState)
require.Len(changes.Created, 1)
require.Empty(changes.Destroyed)
require.Empty(changes.Updated)
lastState = state
emailIds = append(emailIds, changes.Created...)
}
{
_, n, err := s.fillEmailsWithImap(inboxFolder, 0, true, user)
require.NoError(err)
require.Equal(0, n)
}
require.Eventually(func() bool {
return l.calls.Load() == uint32(3)
}, 3*time.Second, 200*time.Millisecond, "WS push listener was not called after third email state change")
{
l.m.Lock()
require.Len(l.emailStates, 3)
require.Len(l.mailboxStates, 3)
require.Len(l.threadStates, 3)
assert.NotEqual(t, l.emailStates[1], l.emailStates[2])
assert.NotEqual(t, l.mailboxStates[1], l.mailboxStates[2])
assert.NotEqual(t, l.threadStates[1], l.threadStates[2])
l.m.Unlock()
}
{
changes, sessionState, state, _, err := s.client.GetEmailChanges(mailAccountId, session, s.ctx, s.logger, "", lastState, 0)
require.NoError(err)
require.Equal(session.State, sessionState)
require.NotEqual(lastState, state)
require.NotEqual(lastState, changes.NewState)
require.Equal(state, changes.NewState)
require.Empty(changes.Created)
require.Len(changes.Destroyed, 2)
require.EqualValues(emailIds, changes.Destroyed)
require.Empty(changes.Updated)
lastState = state
}
err = wsc.DisableNotifications()
require.NoError(err)
}

6045
pkg/jmap/jmap_model.go Normal file
View File

File diff suppressed because it is too large Load Diff

138
pkg/jmap/jmap_session.go Normal file
View File

@@ -0,0 +1,138 @@
package jmap
import (
"errors"
"fmt"
"net/url"
)
type SessionEventListener interface {
OnSessionOutdated(session *Session, newSessionState SessionState)
}
// Cached user related information
//
// This information is typically retrieved once (or at least for a certain period of time) from the
// JMAP well-known endpoint of Stalwart and then kept in cache to avoid the performance cost of
// retrieving it over and over again.
//
// This is really only needed due to the Graph API limitations, since ideally, the account ID should
// be passed as a request parameter by the UI, in order to support a user having multiple accounts.
//
// Keeping track of the JMAP URL might be useful though, in case of Stalwart sharding strategies making
// use of that, by providing different URLs for JMAP on a per-user basis, and that is not something
// we would want to query before every single JMAP request. On the other hand, that then also creates
// a risk of going out-of-sync, e.g. if a node is down and the user is reassigned to a different node.
// There might be webhooks to subscribe to in Stalwart to be notified of such situations, in which case
// the Session needs to be removed from the cache.
//
// The Username is only here for convenience, it could just as well be passed as a separate parameter
// instead of being part of the Session, since the username is always part of the request (typically in
// the authentication token payload.)
type Session struct {
// The name of the user to use to authenticate against Stalwart
Username string
// The base URL to use for JMAP operations towards Stalwart
JmapUrl url.URL
// An identifier of the JmapUrl to use in metrics and tracing
JmapEndpoint string
// The upload URL template
UploadUrlTemplate string
// An identifier of the UploadUrlTemplate to use in metrics and tracing
UploadEndpoint string
// The upload URL template
DownloadUrlTemplate string
// An identifier of the DownloadUrlTemplate to use in metrics and tracing
DownloadEndpoint string
WebsocketUrl *url.URL
SupportsWebsocketPush bool
WebsocketEndpoint string
SessionResponse
}
var (
invalidSessionResponseErrorMissingUsername = SimpleError{code: JmapErrorInvalidSessionResponse, err: errors.New("JMAP session response does not provide a username")}
invalidSessionResponseErrorMissingApiUrl = SimpleError{code: JmapErrorInvalidSessionResponse, err: errors.New("JMAP session response does not provide an API URL")}
invalidSessionResponseErrorInvalidApiUrl = SimpleError{code: JmapErrorInvalidSessionResponse, err: errors.New("JMAP session response provides an invalid API URL")}
invalidSessionResponseErrorMissingUploadUrl = SimpleError{code: JmapErrorInvalidSessionResponse, err: errors.New("JMAP session response does not provide an upload URL")}
invalidSessionResponseErrorMissingDownloadUrl = SimpleError{code: JmapErrorInvalidSessionResponse, err: errors.New("JMAP session response does not provide a download URL")}
invalidSessionResponseErrorInvalidWebsocketUrl = SimpleError{code: JmapErrorInvalidSessionResponse, err: errors.New("JMAP session response provides an invalid Websocket URL")}
)
// Create a new Session from a SessionResponse.
func newSession(sessionResponse SessionResponse) (Session, Error) {
username := sessionResponse.Username
if username == "" {
return Session{}, invalidSessionResponseErrorMissingUsername
}
apiStr := sessionResponse.ApiUrl
if apiStr == "" {
return Session{}, invalidSessionResponseErrorMissingApiUrl
}
apiUrl, err := url.Parse(apiStr)
if err != nil {
return Session{}, invalidSessionResponseErrorInvalidApiUrl
}
apiEndpoint := endpointOf(apiUrl)
uploadUrl := sessionResponse.UploadUrl
if uploadUrl == "" {
return Session{}, invalidSessionResponseErrorMissingUploadUrl
}
uploadEndpoint := toEndpoint(uploadUrl)
downloadUrl := sessionResponse.DownloadUrl
if downloadUrl == "" {
return Session{}, invalidSessionResponseErrorMissingDownloadUrl
}
downloadEndpoint := toEndpoint(downloadUrl)
var websocketUrl *url.URL = nil
websocketEndpoint := ""
supportsWebsocketPush := false
websocketUrlStr := sessionResponse.Capabilities.Websocket.Url
if websocketUrlStr != "" {
websocketUrl, err = url.Parse(websocketUrlStr)
if err != nil {
return Session{}, invalidSessionResponseErrorInvalidWebsocketUrl
}
supportsWebsocketPush = sessionResponse.Capabilities.Websocket.SupportsPush
websocketEndpoint = endpointOf(websocketUrl)
}
return Session{
Username: username,
JmapUrl: *apiUrl,
JmapEndpoint: apiEndpoint,
UploadUrlTemplate: uploadUrl,
UploadEndpoint: uploadEndpoint,
DownloadUrlTemplate: downloadUrl,
DownloadEndpoint: downloadEndpoint,
WebsocketUrl: websocketUrl,
SupportsWebsocketPush: supportsWebsocketPush,
WebsocketEndpoint: websocketEndpoint,
SessionResponse: sessionResponse,
}, nil
}
func endpointOf(u *url.URL) string {
if u != nil {
return fmt.Sprintf("%s://%s", u.Scheme, u.Host)
} else {
return ""
}
}
func toEndpoint(str string) string {
u, err := url.Parse(str)
if err == nil {
return endpointOf(u)
} else {
return str
}
}

336
pkg/jmap/jmap_tools.go Normal file
View File

@@ -0,0 +1,336 @@
package jmap
import (
"context"
"encoding/json"
"errors"
"fmt"
"reflect"
"slices"
"strings"
"sync"
"time"
"github.com/mitchellh/mapstructure"
"github.com/opencloud-eu/opencloud/pkg/jscalendar"
"github.com/opencloud-eu/opencloud/pkg/log"
)
type eventListeners[T any] struct {
listeners []T
m sync.Mutex
}
func (e *eventListeners[T]) add(listener T) {
e.m.Lock()
defer e.m.Unlock()
e.listeners = append(e.listeners, listener)
}
func (e *eventListeners[T]) signal(signal func(T)) {
e.m.Lock()
defer e.m.Unlock()
for _, listener := range e.listeners {
signal(listener)
}
}
func newEventListeners[T any]() *eventListeners[T] {
return &eventListeners[T]{
listeners: []T{},
}
}
// Create an identifier to use as a method call ID, from the specified accountId and additional
// tag, to make something unique within that API request.
func mcid(accountId string, tag string) string {
// https://jmap.io/spec-core.html#the-invocation-data-type
// May be any string of data:
// An arbitrary string from the client to be echoed back with the responses emitted by that method
// call (a method may return 1 or more responses, as it may make implicit calls to other methods;
// all responses initiated by this method call get the same method call id in the response).
return accountId + ":" + tag
}
func command[T any](api ApiClient,
logger *log.Logger,
ctx context.Context,
session *Session,
sessionOutdatedHandler func(session *Session, newState SessionState),
request Request,
acceptLanguage string,
mapper func(body *Response) (T, State, Error)) (T, SessionState, State, Language, Error) {
responseBody, language, jmapErr := api.Command(ctx, logger, session, request, acceptLanguage)
if jmapErr != nil {
var zero T
return zero, "", "", language, jmapErr
}
var response Response
err := json.Unmarshal(responseBody, &response)
if err != nil {
logger.Error().Err(err).Msgf("failed to deserialize body JSON payload into a %T", response)
var zero T
return zero, "", "", language, SimpleError{code: JmapErrorDecodingResponseBody, err: err}
}
if response.SessionState != session.State {
if sessionOutdatedHandler != nil {
sessionOutdatedHandler(session, response.SessionState)
}
}
// search for an "error" response
// https://jmap.io/spec-core.html#method-level-errors
for _, mr := range response.MethodResponses {
if mr.Command == ErrorCommand {
if errorParameters, ok := mr.Parameters.(ErrorResponse); ok {
code := JmapErrorServerFail
switch errorParameters.Type {
case MethodLevelErrorServerUnavailable:
code = JmapErrorServerUnavailable
case MethodLevelErrorServerFail, MethodLevelErrorServerPartialFail:
code = JmapErrorServerFail
case MethodLevelErrorUnknownMethod:
code = JmapErrorUnknownMethod
case MethodLevelErrorInvalidArguments:
code = JmapErrorInvalidArguments
case MethodLevelErrorInvalidResultReference:
code = JmapErrorInvalidResultReference
case MethodLevelErrorForbidden:
// there's a quirk here: when referencing an account that exists but that this
// user has no access to, Stalwart returns the 'forbidden' error, but this might
// leak the existence of an account to an attacker -- instead, we deem it safer to
// return a "account does not exist" error instead
if strings.HasPrefix(errorParameters.Description, "You do not have access to account") {
code = JmapErrorAccountNotFound
} else {
code = JmapErrorForbidden
}
case MethodLevelErrorAccountNotFound:
code = JmapErrorAccountNotFound
case MethodLevelErrorAccountNotSupportedByMethod:
code = JmapErrorAccountNotSupportedByMethod
case MethodLevelErrorAccountReadOnly:
code = JmapErrorAccountReadOnly
}
msg := fmt.Sprintf("found method level error in response '%v', type: '%v', description: '%v'", mr.Tag, errorParameters.Type, errorParameters.Description)
err = errors.New(msg)
logger.Warn().Int("code", code).Str("type", errorParameters.Type).Msg(msg)
var zero T
return zero, response.SessionState, "", language, SimpleError{code: code, err: err}
} else {
code := JmapErrorUnspecifiedType
msg := fmt.Sprintf("found method level error in response '%v'", mr.Tag)
err := errors.New(msg)
logger.Warn().Int("code", code).Msg(msg)
var zero T
return zero, response.SessionState, "", language, SimpleError{code: code, err: err}
}
}
}
result, state, jerr := mapper(&response)
sessionState := response.SessionState
return result, sessionState, state, language, jerr
}
func mapstructStringToTimeHook() mapstructure.DecodeHookFunc {
// mapstruct isn't able to properly map RFC3339 date strings into Time
// objects, which is why we require this custom hook,
// see https://github.com/mitchellh/mapstructure/issues/41
wanted := reflect.TypeOf(time.Time{})
return func(from reflect.Type, to reflect.Type, data any) (any, error) {
if to != wanted {
return data, nil
}
switch from.Kind() {
case reflect.String:
return time.Parse(time.RFC3339, data.(string))
case reflect.Float64:
return time.Unix(0, int64(data.(float64))*int64(time.Millisecond)), nil
case reflect.Int64:
return time.Unix(0, data.(int64)*int64(time.Millisecond)), nil
default:
return data, nil
}
}
}
func decodeMap(input map[string]any, target any) error {
// https://github.com/mitchellh/mapstructure/issues/41
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
Metadata: nil,
DecodeHook: mapstructure.ComposeDecodeHookFunc(
mapstructStringToTimeHook(),
jscalendar.MapstructTriggerHook(),
),
Result: &target,
ErrorUnused: false,
ErrorUnset: false,
IgnoreUntaggedFields: false,
Squash: true,
})
if err != nil {
return err
}
return decoder.Decode(input)
}
func decodeParameters(input any, target any) error {
m, ok := input.(map[string]any)
if !ok {
return fmt.Errorf("decodeParameters: parameters is not a map but a %T", input)
}
return decodeMap(m, target)
}
func retrieveResponseMatch(data *Response, command Command, tag string) (Invocation, bool) {
for _, inv := range data.MethodResponses {
if command == inv.Command && tag == inv.Tag {
return inv, true
}
}
return Invocation{}, false
}
func retrieveResponseMatchParameters[T any](logger *log.Logger, data *Response, command Command, tag string, target *T) Error {
match, ok := retrieveResponseMatch(data, command, tag)
if !ok {
err := fmt.Errorf("failed to find JMAP response invocation match for command '%v' and tag '%v'", command, tag)
logger.Error().Msg(err.Error())
return simpleError(err, JmapErrorInvalidJmapResponsePayload)
}
params := match.Parameters
typedParams, ok := params.(T)
if !ok {
err := fmt.Errorf("JMAP response invocation matches command '%v' and tag '%v' but the type %T does not match the expected %T", command, tag, params, *target)
logger.Error().Msg(err.Error())
return simpleError(err, JmapErrorInvalidJmapResponsePayload)
}
*target = typedParams
return nil
}
func (i *Invocation) MarshalJSON() ([]byte, error) {
// JMAP requests have a slightly unusual structure since they are not a JSON object
// but, instead, a three-element array composed of
// 0: the command (e.g. "Email/query")
// 1: the actual payload of the request (structure depends on the command)
// 2: a tag that can be used to identify the matching response payload
// That implementation aspect thus requires us to use a custom marshalling hook.
arr := []any{string(i.Command), i.Parameters, i.Tag}
return json.Marshal(arr)
}
func (i *Invocation) UnmarshalJSON(bs []byte) error {
// JMAP responses have a slightly unusual structure since they are not a JSON object
// but, instead, a three-element array composed of
// 0: the command (e.g. "Thread/get") this is a response to
// 1: the actual payload of the response (structure depends on the command)
// 2: the tag (same as in the request invocation)
// That implementation aspect thus requires us to use a custom unmarshalling hook.
arr := []any{}
err := json.Unmarshal(bs, &arr)
if err != nil {
return err
}
if len(arr) != 3 {
// JMAP response must really always be an array of three elements
return fmt.Errorf("Invocation array length ought to be 3 but is %d", len(arr))
}
// The first element in the array is the command:
i.Command = Command(arr[0].(string))
// The third element in the array is the tag:
i.Tag = arr[2].(string)
// Due to the dynamic nature of request and response types in JMAP, we
// switch to using mapstruct here to deserialize the payload in the "parameters"
// element of JMAP invocation response arrays, as their expected struct type
// is directly inferred from the command (e.g. "Mailbox/get")
payload := arr[1]
paramsFactory, ok := CommandResponseTypeMap[i.Command]
if !ok {
return fmt.Errorf("unsupported JMAP operation cannot be unmarshalled: %v", i.Command)
}
params := paramsFactory()
err = decodeParameters(payload, &params)
if err != nil {
return err
}
i.Parameters = params
return nil
}
func squashState(all map[string]State) State {
return squashStateFunc(all, func(s State) State { return s })
}
func squashStateFunc[V any](all map[string]V, mapper func(V) State) State {
n := len(all)
if n == 0 {
return State("")
}
if n == 1 {
for _, v := range all {
return mapper(v)
}
}
parts := make([]string, n)
sortedKeys := make([]string, n)
i := 0
for k := range all {
sortedKeys[i] = k
i++
}
slices.Sort(sortedKeys)
for i, k := range sortedKeys {
if v, ok := all[k]; ok {
parts[i] = k + ":" + string(mapper(v))
} else {
parts[i] = k + ":"
}
}
return State(strings.Join(parts, ","))
}
func squashStateMaps(first map[string]State, second map[string]State) State {
return squashStateFunc(mapPairs(first, second), func(p pair[State, State]) State {
if p.left != nil {
if p.right != nil {
return *p.left + ";" + *p.right
} else {
return *p.left + ";"
}
} else if p.right != nil {
return ";" + *p.right
} else {
return ";"
}
})
}
type pair[L any, R any] struct {
left *L
right *R
}
func mapPairs[K comparable, L, R any](left map[K]L, right map[K]R) map[K]pair[L, R] {
result := map[K]pair[L, R]{}
for k, l := range left {
if r, ok := right[k]; ok {
result[k] = pair[L, R]{left: &l, right: &r}
} else {
result[k] = pair[L, R]{left: &l, right: nil}
}
}
for k, r := range right {
if _, ok := left[k]; !ok {
result[k] = pair[L, R]{left: nil, right: &r}
}
}
return result
}

View File

@@ -0,0 +1,24 @@
package jmap
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
)
func TestUnmarshallingError(t *testing.T) {
require := require.New(t)
responseBody := `{"methodResponses":[["error",{"type":"forbidden","description":"You do not have access to account a"},"a:0"]],"sessionState":"3e25b2a0"}`
var response Response
err := json.Unmarshal([]byte(responseBody), &response)
require.NoError(err)
require.Len(response.MethodResponses, 1)
require.Equal(ErrorCommand, response.MethodResponses[0].Command)
require.Equal("a:0", response.MethodResponses[0].Tag)
require.IsType(ErrorResponse{}, response.MethodResponses[0].Parameters)
er, _ := response.MethodResponses[0].Parameters.(ErrorResponse)
require.Equal("forbidden", er.Type)
require.Equal("You do not have access to account a", er.Description)
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,716 @@
package jscalendar
import (
"encoding/json"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func jsoneq[X any](t *testing.T, expected string, object X) {
data, err := json.MarshalIndent(object, "", "")
require.NoError(t, err)
require.JSONEq(t, expected, string(data))
var rec X
err = json.Unmarshal(data, &rec)
require.NoError(t, err)
require.Equal(t, object, rec)
}
/*
func TestLocalDateTime(t *testing.T) {
ts, err := time.Parse(time.RFC3339, "2025-09-25T18:26:14+02:00")
require.NoError(t, err)
ldt := &LocalDateTime{ts}
str, err := json.MarshalIndent(ldt, "", "")
require.NoError(t, err)
require.Equal(t, "\"2025-09-25T16:26:14\"", string(str))
}
func TestLocalDateTimeUnmarshalling(t *testing.T) {
ts, err := time.Parse(RFC3339Local, "2025-09-25T18:26:14")
require.NoError(t, err)
u := ts.UTC()
var result LocalDateTime
err = json.Unmarshal([]byte("\"2025-09-25T18:26:14Z\""), &result)
require.NoError(t, err)
require.Equal(t, result, LocalDateTime{u})
}
*/
func TestRelation(t *testing.T) {
jsoneq(t, `{
"@type": "Relation",
"relation": {
"first": true,
"parent": true
}
}`, Relation{
Type: RelationType,
Relation: map[Relationship]bool{
RelationshipFirst: true,
RelationshipParent: true,
},
})
}
func TestLink(t *testing.T) {
jsoneq(t, `{
"@type": "Link",
"href": "https://opencloud.eu.example.com/f72ae875-40be-48a4-84ff-aea9aed3e085.png",
"contentType": "image/png",
"size": 128912,
"rel": "icon",
"display": "thumbnail",
"title": "the logo"
}`, Link{
Type: LinkType,
Href: "https://opencloud.eu.example.com/f72ae875-40be-48a4-84ff-aea9aed3e085.png",
ContentType: "image/png",
Size: 128912,
Rel: RelIcon,
Display: DisplayThumbnail,
Title: "the logo",
})
}
func TestLocation(t *testing.T) {
jsoneq(t, `{
"@type": "Location",
"name": "The Eiffel Tower",
"locationTypes": {
"landmark-address": true,
"industrial": true
},
"coordinates": "geo:48.8559324,2.2932441",
"links": {
"l1": {
"@type": "Link",
"href": "https://upload.wikimedia.org/wikipedia/commons/f/fd/Eiffel_blue.PNG",
"contentType": "image/png",
"size": 12345,
"rel": "icon",
"display": "A blue Eiffel tower",
"title": "Blue Eiffel Tower"
}
}
}`, Location{
Type: LocationType,
Name: "The Eiffel Tower",
LocationTypes: map[LocationTypeOption]bool{
LocationTypeOptionLandmarkAddress: true,
LocationTypeOptionIndustrial: true,
},
Coordinates: "geo:48.8559324,2.2932441",
Links: map[string]Link{
"l1": {
Type: LinkType,
Href: "https://upload.wikimedia.org/wikipedia/commons/f/fd/Eiffel_blue.PNG",
ContentType: "image/png",
Size: 12345,
Rel: RelIcon,
Display: "A blue Eiffel tower",
Title: "Blue Eiffel Tower",
},
},
})
}
func TestVirtualLocation(t *testing.T) {
jsoneq(t, `{
"@type": "VirtualLocation",
"name": "OpenTalk",
"uri": "https://opentalk.eu",
"features": {
"video": true,
"screen": true,
"audio": true
}
}`, VirtualLocation{
Type: VirtualLocationType,
Name: "OpenTalk",
Uri: "https://opentalk.eu",
Features: map[VirtualLocationFeature]bool{
VirtualLocationFeatureVideo: true,
VirtualLocationFeatureScreen: true,
VirtualLocationFeatureAudio: true,
},
})
}
func TestNDay(t *testing.T) {
jsoneq(t, `{
"@type": "NDay",
"day": "fr",
"nthOfPeriod": -1
}`, NDay{
Type: NDayType,
Day: DayOfWeekFriday,
NthOfPeriod: -1,
})
}
func TestRecurrenceRule(t *testing.T) {
ts, err := time.Parse(time.RFC3339, "2025-09-25T18:26:14+02:00")
require.NoError(t, err)
ts = ts.UTC()
l := LocalDateTime("2025-09-25T16:26:14")
jsoneq(t, `{
"@type": "RecurrenceRule",
"frequency": "daily",
"interval": 1,
"rscale": "iso8601",
"skip": "forward",
"firstDayOfWeek": "mo",
"byDay": [
{"@type": "NDay", "day": "mo", "nthOfPeriod": -1},
{"@type": "NDay", "day": "tu"},
{"day": "we"}
],
"byMonthDay": [1, 10, 31],
"byMonth": ["1", "31L"],
"byYearDay": [-1, 366],
"byWeekNo": [-53, 53],
"byHour": [0, 23],
"byMinute": [0, 59],
"bySecond": [0, 39],
"bySetPosition": [-3, 3],
"count": 2,
"until": "2025-09-25T16:26:14"
}`, RecurrenceRule{
Type: RecurrenceRuleType,
Frequency: FrequencyDaily,
Interval: 1,
Rscale: RscaleIso8601,
Skip: SkipForward,
FirstDayOfWeek: DayOfWeekMonday,
ByDay: []NDay{
{
Type: NDayType,
Day: DayOfWeekMonday,
NthOfPeriod: -1,
},
{
Type: NDayType,
Day: DayOfWeekTuesday,
},
{
Day: DayOfWeekWednesday,
NthOfPeriod: 0,
},
},
ByMonthDay: []int{1, 10, 31},
ByMonth: []string{"1", "31L"},
ByYearDay: []int{-1, 366},
ByWeekNo: []int{-53, 53},
ByHour: []uint{0, 23},
ByMinute: []uint{0, 59},
BySecond: []uint{0, 39},
BySetPosition: []int{-3, 3},
Count: 2,
Until: &l,
})
}
func TestParticipant(t *testing.T) {
ts, err := time.Parse(time.RFC3339, "2025-09-25T18:26:14+02:00")
require.NoError(t, err)
ts = ts.UTC()
ts2, err := time.Parse(time.RFC3339, "2025-09-29T14:32:19+02:00")
require.NoError(t, err)
ts2 = ts2.UTC()
jsoneq(t, `{
"@type": "Participant",
"name": "Camina Drummer",
"email": "camina@opa.org",
"description": "Camina Drummer is a Belter serving as the current President of the Transport Union.",
"calendarAddress": "cdrummer@itip.opa.org",
"kind": "individual",
"roles": {
"owner": true,
"chair": true
},
"locationId": "98faaa01-b6db-4ddb-9574-e28ab83104e6",
"language": "en-JM",
"participationStatus": "accepted",
"participationComment": "always there",
"expectReply": true,
"scheduleAgent": "server",
"scheduleForceSend": true,
"scheduleSequence": 3,
"scheduleStatus": [
"3.1",
"2.0"
],
"scheduleUpdated": "2025-09-25T16:26:14Z",
"sentBy": "adawes@opa.org",
"invitedBy": "346be402-c340-4f3f-ac51-e4aa9955af4f",
"delegatedTo": {
"93230b90-70c6-4027-b2c1-3629877bfea5": true,
"f5fae398-cfa3-4873-bbc7-0ca9d51de5b0": true
},
"delegatedFrom": {
"a9c1c1a1-fecf-4214-a803-1ee209e2dbec": true
},
"memberOf": {
"0f41473b-0edd-494d-b346-8d039009a2a5": true
},
"links":{
"l1": {
"@type": "Link",
"href": "https://opa.org/opa.png",
"contentType": "image/png",
"size": 182912,
"rel": "icon",
"display": "Logo",
"title": "OPA"
}
},
"progress": "in-process",
"progressUpdated": "2025-09-29T12:32:19Z",
"percentComplete": 42
}`, Participant{
Type: ParticipantType,
Name: "Camina Drummer",
Email: "camina@opa.org",
Description: "Camina Drummer is a Belter serving as the current President of the Transport Union.",
CalendarAddress: "cdrummer@itip.opa.org",
Kind: ParticipantKindIndividual,
Roles: map[Role]bool{
RoleOwner: true,
RoleChair: true,
},
LocationId: "98faaa01-b6db-4ddb-9574-e28ab83104e6",
Language: "en-JM",
ParticipationStatus: ParticipationStatusAccepted,
ParticipationComment: "always there",
ExpectReply: true,
ScheduleAgent: ScheduleAgentServer,
ScheduleForceSend: true,
ScheduleSequence: 3,
ScheduleStatus: []string{
"3.1",
"2.0",
},
ScheduleUpdated: ts,
SentBy: "adawes@opa.org",
InvitedBy: "346be402-c340-4f3f-ac51-e4aa9955af4f",
DelegatedTo: map[string]bool{
"93230b90-70c6-4027-b2c1-3629877bfea5": true,
"f5fae398-cfa3-4873-bbc7-0ca9d51de5b0": true,
},
DelegatedFrom: map[string]bool{
"a9c1c1a1-fecf-4214-a803-1ee209e2dbec": true,
},
MemberOf: map[string]bool{
"0f41473b-0edd-494d-b346-8d039009a2a5": true,
},
Links: map[string]Link{
"l1": {
Type: LinkType,
Href: "https://opa.org/opa.png",
ContentType: "image/png",
Size: 182912,
Rel: RelIcon,
Display: "Logo",
Title: "OPA",
},
},
Progress: ProgressInProcess,
ProgressUpdated: ts2,
PercentComplete: 42,
})
}
func TestAlertWithAbsoluteTrigger(t *testing.T) {
ts, err := time.Parse(time.RFC3339, "2025-09-25T18:26:14+02:00")
require.NoError(t, err)
ts = ts.UTC()
jsoneq(t, `{
"@type": "Alert",
"trigger": {
"@type": "AbsoluteTrigger",
"when": "2025-09-25T16:26:14Z"
},
"acknowledged": "2025-09-25T16:26:14Z",
"relatedTo": {
"a2e729eb-7d9c-4ea7-8514-93d2590ef0a2": {
"@type": "Relation",
"relation": {
"first": true
}
}
},
"action": "email"
}`, Alert{
Type: AlertType,
Trigger: &AbsoluteTrigger{
Type: AbsoluteTriggerType,
When: ts,
},
Acknowledged: ts,
RelatedTo: map[string]Relation{
"a2e729eb-7d9c-4ea7-8514-93d2590ef0a2": {
Type: RelationType,
Relation: map[Relationship]bool{
RelationshipFirst: true,
},
},
},
Action: AlertActionEmail,
})
}
func TestAlertWithOffsetTrigger(t *testing.T) {
ts, err := time.Parse(time.RFC3339, "2025-09-25T18:26:14+02:00")
require.NoError(t, err)
ts = ts.UTC()
jsoneq(t, `{
"@type": "Alert",
"trigger": {
"@type": "OffsetTrigger",
"offset": "-PT5M",
"relativeTo": "end"
},
"acknowledged": "2025-09-25T16:26:14Z",
"relatedTo": {
"a2e729eb-7d9c-4ea7-8514-93d2590ef0a2": {
"@type": "Relation",
"relation": {
"first": true
}
}
},
"action": "email"
}`, Alert{
Type: AlertType,
Trigger: &OffsetTrigger{
Type: OffsetTriggerType,
Offset: "-PT5M",
RelativeTo: RelativeToEnd,
},
Acknowledged: ts,
RelatedTo: map[string]Relation{
"a2e729eb-7d9c-4ea7-8514-93d2590ef0a2": {
Type: RelationType,
Relation: map[Relationship]bool{
RelationshipFirst: true,
},
},
},
Action: AlertActionEmail,
})
}
func TestAlertWithUnknownTrigger(t *testing.T) {
ts, err := time.Parse(time.RFC3339, "2025-09-25T18:26:14+02:00")
require.NoError(t, err)
ts = ts.UTC()
jsoneq(t, `{
"@type": "Alert",
"trigger": {
"@type": "XYZTRIGGER",
"abc": 123,
"xyz": "zzz"
},
"acknowledged": "2025-09-25T16:26:14Z",
"relatedTo": {
"a2e729eb-7d9c-4ea7-8514-93d2590ef0a2": {
"@type": "Relation",
"relation": {
"first": true
}
}
},
"action": "email"
}`, Alert{
Type: AlertType,
Trigger: &UnknownTrigger{
"@type": "XYZTRIGGER",
"abc": 123.0,
"xyz": "zzz",
},
Acknowledged: ts,
RelatedTo: map[string]Relation{
"a2e729eb-7d9c-4ea7-8514-93d2590ef0a2": {
Type: RelationType,
Relation: map[Relationship]bool{
RelationshipFirst: true,
},
},
},
Action: AlertActionEmail,
})
}
func TestTimeZoneRule(t *testing.T) {
l1 := LocalDateTime("2025-09-25T16:26:14")
jsoneq(t, `{
"@type": "TimeZoneRule",
"start": "2025-09-25T16:26:14",
"offsetFrom": "-0200",
"offsetTo": "+0200",
"recurrenceRules": [
{
"@type": "RecurrenceRule",
"frequency": "weekly",
"interval": 2,
"rscale": "iso8601",
"skip": "omit",
"firstDayOfWeek": "mo",
"byDay": [
{
"@type": "NDay",
"day": "fr"
}
],
"byHour": [14],
"byMinute": [0],
"count": 4
}
],
"recurrenceOverrides": {
"2025-09-25T16:26:14": {}
},
"names": {
"CEST": true
},
"comments": ["this is a comment"]
}`, TimeZoneRule{
Type: TimeZoneRuleType,
Start: l1,
OffsetFrom: "-0200",
OffsetTo: "+0200",
RecurrenceRules: []RecurrenceRule{
{
Type: RecurrenceRuleType,
Frequency: FrequencyWeekly,
Interval: 2,
Rscale: RscaleIso8601,
Skip: SkipOmit,
FirstDayOfWeek: DayOfWeekMonday,
ByDay: []NDay{
{
Type: NDayType,
Day: DayOfWeekFriday,
},
},
ByHour: []uint{
14,
},
ByMinute: []uint{
0,
},
Count: 4,
},
},
RecurrenceOverrides: map[LocalDateTime]PatchObject{
l1: {},
},
Names: map[string]bool{
"CEST": true,
},
Comments: []string{
"this is a comment",
},
})
}
func TestTimeZone(t *testing.T) {
ts, err := time.Parse(time.RFC3339, "2025-09-25T16:26:14+02:00")
require.NoError(t, err)
ts = ts.UTC()
l := LocalDateTime("2025-09-25T16:26:14")
jsoneq(t, `{
"@type": "TimeZone",
"tzId": "cest",
"updated": "2025-09-25T14:26:14Z",
"url": "https://timezones.net/cest",
"validUntil": "2025-09-25T14:26:14Z",
"aliases": {
"cet": true
},
"standard": [{
"@type": "TimeZoneRule",
"start": "2025-09-25T16:26:14",
"offsetFrom": "-0200",
"offsetTo": "+1245"
}],
"daylight": [{
"@type": "TimeZoneRule",
"start": "2025-09-25T16:26:14",
"offsetFrom": "-0200",
"offsetTo": "+1245"
}]
}`, TimeZone{
Type: TimeZoneType,
TzId: "cest",
Updated: ts,
Url: "https://timezones.net/cest",
ValidUntil: ts,
Aliases: map[string]bool{
"cet": true,
},
Standard: []TimeZoneRule{
{
Type: TimeZoneRuleType,
Start: l,
OffsetFrom: "-0200",
OffsetTo: "+1245",
},
},
Daylight: []TimeZoneRule{
{
Type: TimeZoneRuleType,
Start: l,
OffsetFrom: "-0200",
OffsetTo: "+1245",
},
},
})
}
func TestEvent(t *testing.T) {
local1 := "2025-09-25T16:26:14"
ts1, err := time.Parse(time.RFC3339, local1+"+02:00")
require.NoError(t, err)
ts1 = ts1.UTC()
local2 := "2025-09-29T13:53:01"
ts2, err := time.Parse(time.RFC3339, local2+"+02:00")
require.NoError(t, err)
ts2 = ts2.UTC()
l := LocalDateTime("2025-09-25T16:26:14")
jsoneq(t, `{
"@type": "Event",
"start": "2025-09-25T16:26:14",
"duration": "PT10M",
"status": "confirmed",
"uid": "b422cfec-f7b4-4e04-8ec6-b794007f63f1",
"prodId": "OpenCloud 1.0",
"created": "2025-09-25T16:26:14",
"updated": "2025-09-29T13:53:01",
"title": "End of year party",
"description": "It's the party at the end of the year.",
"descriptionContentType": "text/plain",
"links": {
"l1": {
"@type": "Link",
"href": "https://opencloud.eu/eoy-party/2025",
"contentType": "text/html",
"rel": "about"
}
},
"locale": "en-GB",
"keywords": {
"k1": true
},
"categories": {
"cat": true
},
"color": "oil",
"relatedTo": {
"a": {
"@type": "Relation",
"relation": {
"next": true
}
}
},
"sequence": 3,
"showWithoutTime": true,
"locations": {
"loc1": {
"@type": "Location",
"name": "Steel Cactus Mexican Grill",
"locationTypes": {
"bar": true
},
"coordinates": "geo:16.7685657,-4.8629852",
"links": {
"l1": {
"@type": "Link",
"href": "https://mars.gov/bars/steelcactus",
"rel": "about"
}
}
}
}
}`, Event{
Type: EventType,
Start: l,
Duration: "PT10M",
Status: "confirmed",
Object: Object{
CommonObject: CommonObject{
Uid: "b422cfec-f7b4-4e04-8ec6-b794007f63f1",
ProdId: "OpenCloud 1.0",
Created: UTCDateTime(local1),
Updated: UTCDateTime(local2),
Title: "End of year party",
Description: "It's the party at the end of the year.",
DescriptionContentType: "text/plain",
Links: map[string]Link{
"l1": {
Type: LinkType,
Href: "https://opencloud.eu/eoy-party/2025",
ContentType: "text/html",
Rel: RelAbout,
},
},
Locale: "en-GB",
Keywords: map[string]bool{
"k1": true,
},
Categories: map[string]bool{
"cat": true,
},
Color: "oil",
},
RelatedTo: map[string]Relation{
"a": {
Type: RelationType,
Relation: map[Relationship]bool{
RelationshipNext: true,
},
},
},
Sequence: 3,
ShowWithoutTime: true,
Locations: map[string]Location{
"loc1": {
Type: LocationType,
Name: "Steel Cactus Mexican Grill",
LocationTypes: map[LocationTypeOption]bool{
LocationTypeOptionBar: true,
},
Coordinates: "geo:16.7685657,-4.8629852",
Links: map[string]Link{
"l1": {
Type: LinkType,
Href: "https://mars.gov/bars/steelcactus",
Rel: RelAbout,
},
},
},
},
},
})
}

View File

File diff suppressed because it is too large Load Diff

View File

File diff suppressed because it is too large Load Diff

59
pkg/log/log_safely.go Normal file
View File

@@ -0,0 +1,59 @@
package log
import "github.com/rs/zerolog"
const (
logMaxStrLength = 512
logMaxStrArrayLength = 16 // 8kb
)
// Safely caps a string to a given size to avoid log bombing.
// Use this function to wrap strings that are user input (HTTP headers, path parameters, URI parameters, HTTP body, ...).
func SafeString(text string) string {
runes := []rune(text)
if len(runes) <= logMaxStrLength {
return text
} else {
return string(runes[0:logMaxStrLength-1]) + `\u2026` // hellip
}
}
type SafeLogStringArrayMarshaller struct {
array []string
}
func (m SafeLogStringArrayMarshaller) MarshalZerologArray(a *zerolog.Array) {
for i, elem := range m.array {
if i >= logMaxStrArrayLength {
return
}
a.Str(SafeString(elem))
}
}
var _ zerolog.LogArrayMarshaler = SafeLogStringArrayMarshaller{}
func SafeStringArray(array []string) SafeLogStringArrayMarshaller {
return SafeLogStringArrayMarshaller{array: array}
}
type StringArrayMarshaller struct {
array []string
}
func (m StringArrayMarshaller) MarshalZerologArray(a *zerolog.Array) {
for _, elem := range m.array {
a.Str(elem)
}
}
var _ zerolog.LogArrayMarshaler = StringArrayMarshaller{}
func StringArray(array []string) StringArrayMarshaller {
return StringArrayMarshaller{array: array}
}
func From(context zerolog.Context) *Logger {
return &Logger{Logger: context.Logger()}
}

View File

@@ -1,6 +1,13 @@
// Package structs provides some utility functions for dealing with structs.
package structs
import (
"maps"
"slices"
orderedmap "github.com/wk8/go-ordered-map"
)
// CopyOrZeroValue returns a copy of s if s is not nil otherwise the zero value of T will be returned.
func CopyOrZeroValue[T any](s *T) *T {
cp := new(T)
@@ -9,3 +16,238 @@ func CopyOrZeroValue[T any](s *T) *T {
}
return cp
}
// Returns a copy of an array with a unique set of elements.
//
// Element order is retained.
func Uniq[T comparable](source []T) []T {
m := orderedmap.New()
for _, v := range source {
m.Set(v, true)
}
set := make([]T, m.Len())
i := 0
for pair := m.Oldest(); pair != nil; pair = pair.Next() {
set[i] = pair.Key.(T)
i++
}
return set
}
func Keys[K comparable, V any](source map[K]V) []K {
if source == nil {
var zero []K
return zero
}
return slices.Collect(maps.Keys(source))
}
func Index[K comparable, V any](source []V, indexer func(V) K) map[K]V {
if source == nil {
var zero map[K]V
return zero
}
result := map[K]V{}
for _, v := range source {
k := indexer(v)
result[k] = v
}
return result
}
func Map[E any, R any](source []E, mapper func(E) R) []R {
if source == nil {
var zero []R
return zero
}
result := make([]R, len(source))
for i, e := range source {
result[i] = mapper(e)
}
return result
}
func MapValues[K comparable, S any, T any](m map[K]S, mapper func(S) T) map[K]T {
r := make(map[K]T, len(m))
for k, s := range m {
r[k] = mapper(s)
}
return r
}
func MapValues2[K comparable, S any, T any](m map[K]S, mapper func(K, S) T) map[K]T {
r := make(map[K]T, len(m))
for k, s := range m {
r[k] = mapper(k, s)
}
return r
}
func MapKeys[S comparable, T comparable, V any](m map[S]V, mapper func(S) T) map[T]V {
r := make(map[T]V, len(m))
for s, v := range m {
r[mapper(s)] = v
}
return r
}
func MapKeys2[S comparable, T comparable, V any](m map[S]V, mapper func(S, V) T) map[T]V {
r := make(map[T]V, len(m))
for s, v := range m {
r[mapper(s, v)] = v
}
return r
}
func ToBoolMap[E comparable](source []E) map[E]bool {
m := make(map[E]bool, len(source))
for _, v := range source {
m[v] = true
}
return m
}
func ToIntMap[E comparable](source []E) map[E]int {
m := make(map[E]int, len(source))
for _, v := range source {
if e, ok := m[v]; ok {
m[v] = e + 1
} else {
m[v] = 1
}
}
return m
}
func MapN[E any, R any](source []E, indexer func(E) *R) []R {
if source == nil {
var zero []R
return zero
}
result := []R{}
for _, e := range source {
opt := indexer(e)
if opt != nil {
result = append(result, *opt)
}
}
return result
}
// Check whether two slices contain the same elements, ignoring order.
func SameSlices[E comparable](x, y []E) bool {
// https://stackoverflow.com/a/36000696
if len(x) != len(y) {
return false
}
// create a map of string -> int
diff := make(map[E]int, len(x))
for _, _x := range x {
// 0 value for int is 0, so just increment a counter for the string
diff[_x]++
}
for _, _y := range y {
// If the string _y is not in diff bail out early
if _, ok := diff[_y]; !ok {
return false
}
diff[_y]--
if diff[_y] == 0 {
delete(diff, _y)
}
}
return len(diff) == 0
}
func Missing[E comparable](expected, actual []E) []E {
missing := []E{}
actualIndex := ToBoolMap(actual)
for _, e := range expected {
if _, ok := actualIndex[e]; !ok {
missing = append(missing, e)
}
}
return missing
}
func FirstKey[K comparable, V any](m map[K]V) (K, bool) {
for k := range m {
return k, true
}
var zero K
return zero, false
}
func Any[E any](s []E, predicate func(E) bool) bool {
if len(s) < 1 {
return false
}
for _, e := range s {
if predicate(e) {
return true
}
}
return false
}
func AnyKey[K comparable, V any](m map[K]V, predicate func(K) bool) bool {
if len(m) < 1 {
return false
}
for k := range m {
if predicate(k) {
return true
}
}
return false
}
func AnyValue[K comparable, V any](m map[K]V, predicate func(V) bool) bool {
if len(m) < 1 {
return false
}
for _, v := range m {
if predicate(v) {
return true
}
}
return false
}
func AnyItem[K comparable, V any](m map[K]V, predicate func(K, V) bool) bool {
if len(m) < 1 {
return false
}
for k, v := range m {
if predicate(k, v) {
return true
}
}
return false
}
func Concat[E any](arys ...[]E) []E {
l := 0
for _, ary := range arys {
l += len(ary)
}
r := make([]E, l)
i := 0
for _, ary := range arys {
if ary != nil {
i += copy(r[i:], ary)
}
}
return r
}
func Filter[E any](s []E, predicate func(E) bool) []E {
r := []E{}
for _, e := range s {
if predicate(e) {
r = append(r, e)
}
}
return r
}

View File

@@ -1,6 +1,12 @@
package structs
import "testing"
import (
"fmt"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
type example struct {
Attribute1 string
@@ -36,3 +42,132 @@ func TestCopyOrZeroValue(t *testing.T) {
t.Error("CopyOrZeroValue didn't correctly copy attributes")
}
}
func TestUniqWithInts(t *testing.T) {
tests := []struct {
input []int
expected []int
}{
{[]int{5, 1, 3, 1, 4}, []int{5, 1, 3, 4}},
{[]int{1, 1, 1}, []int{1}},
}
for i, tt := range tests {
t.Run(fmt.Sprintf("%d: testing %v", i+1, tt.input), func(t *testing.T) {
result := Uniq(tt.input)
assert.EqualValues(t, tt.expected, result)
})
}
}
type u struct {
x int
y string
}
var (
u1 = u{x: 1, y: "un"}
u2 = u{x: 2, y: "deux"}
u3 = u{x: 3, y: "trois"}
)
func TestUniqWithStructs(t *testing.T) {
tests := []struct {
input []u
expected []u
}{
{[]u{u3, u1, u2, u3, u2, u1}, []u{u3, u1, u2}},
}
for i, tt := range tests {
t.Run(fmt.Sprintf("%d: testing %v", i+1, tt.input), func(t *testing.T) {
result := Uniq(tt.input)
assert.EqualValues(t, tt.expected, result)
})
}
}
func TestKeys(t *testing.T) {
tests := []struct {
input map[int]string
expected []int
}{
{map[int]string{5: "cinq", 1: "un", 3: "trois", 4: "vier"}, []int{5, 1, 3, 4}},
{map[int]string{1: "un"}, []int{1}},
}
for i, tt := range tests {
t.Run(fmt.Sprintf("%d: testing %v", i+1, tt.input), func(t *testing.T) {
result := Keys(tt.input)
assert.ElementsMatch(t, tt.expected, result)
})
}
}
func TestMissing(t *testing.T) {
tests := []struct {
source []string
input []string
expected []string
}{
{[]string{"a", "b", "c"}, []string{"c", "b", "a"}, []string{}},
{[]string{"a", "b", "c"}, []string{"c", "b"}, []string{"a"}},
{[]string{"a", "b", "c"}, []string{"c", "b", "a", "d"}, []string{}},
{[]string{}, []string{"c", "b"}, []string{}},
{[]string{"a", "b", "c"}, []string{}, []string{"a", "b", "c"}},
{[]string{"a", "b", "b", "c"}, []string{"a", "b"}, []string{"c"}},
}
for i, tt := range tests {
t.Run(fmt.Sprintf("%d: testing [%v] <-> [%v] == [%v]", i+1, strings.Join(tt.source, ", "), strings.Join(tt.input, ", "), strings.Join(tt.expected, ", ")), func(t *testing.T) {
result := Missing(tt.source, tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
func TestAny(t *testing.T) {
always := func(s string) bool { return true }
never := func(s string) bool { return false }
assert.True(t, Any([]string{"a", "b", "c"}, always))
assert.False(t, Any([]string{}, always))
assert.False(t, Any(nil, always))
assert.False(t, Any([]string{"a", "b", "c"}, never))
assert.False(t, Any(nil, never))
}
func TestAnyKey(t *testing.T) {
always := func(s string) bool { return true }
never := func(s string) bool { return false }
assert.True(t, AnyKey(map[string]bool{"a": true, "b": false}, always))
assert.False(t, AnyKey(map[string]bool{}, always))
assert.False(t, AnyKey[string, bool](nil, always))
assert.False(t, AnyKey(map[string]bool{"a": true, "b": false}, never))
assert.False(t, AnyKey[string, bool](nil, never))
}
func TestAnyValue(t *testing.T) {
always := func(b bool) bool { return true }
never := func(b bool) bool { return false }
assert.True(t, AnyValue(map[string]bool{"a": true, "b": false}, always))
assert.False(t, AnyValue(map[string]bool{}, always))
assert.False(t, AnyValue[string](nil, always))
assert.False(t, AnyValue(map[string]bool{"a": true, "b": false}, never))
assert.False(t, AnyValue[string](nil, never))
}
func TestAnyItem(t *testing.T) {
always := func(s string, b bool) bool { return true }
never := func(s string, b bool) bool { return false }
assert.True(t, AnyItem(map[string]bool{"a": true, "b": false}, always))
assert.False(t, AnyItem(map[string]bool{}, always))
assert.False(t, AnyItem(nil, always))
assert.False(t, AnyItem(map[string]bool{"a": true, "b": false}, never))
assert.False(t, AnyItem(nil, never))
}
func TestConcat(t *testing.T) {
assert.Equal(t, []string{"a", "b", "c", "d", "e", "f"}, Concat([]string{"a", "b"}, []string{"c"}, []string{"d", "e", "f"}))
assert.Equal(t, []string{"a"}, Concat([]string{"a"}))
assert.Equal(t, []string{"a"}, Concat([]string{}, nil, []string{"a"}))
assert.Equal(t, []string{}, Concat[string]())
}

View File

@@ -1,4 +0,0 @@
package version
// InitEdition exports the private edition initialization func for testing
var InitEdition = initEdition

View File

@@ -1,27 +1,9 @@
package version
import (
"fmt"
"slices"
"strings"
"time"
"github.com/Masterminds/semver"
"github.com/opencloud-eu/reva/v2/pkg/logger"
)
const (
// Dev is used as a placeholder.
Dev = "dev"
// EditionDev indicates the development build channel was used to build the binary.
EditionDev = Dev
// EditionRolling indicates the rolling release build channel was used to build the binary.
EditionRolling = "rolling"
// EditionStable indicates the stable release build channel was used to build the binary.
EditionStable = "stable"
// EditionLTS indicates the lts release build channel was used to build the binary.
EditionLTS = "lts"
)
var (
@@ -39,56 +21,17 @@ var (
// Date indicates the build date.
// This has been removed, it looks like you can only replace static strings with recent go versions
//Date = time.Now().Format("20060102")
Date = Dev
Date = "dev"
// Legacy defines the old long 4 number OpenCloud version needed for some clients
Legacy = "0.1.0.0"
// LegacyString defines the old OpenCloud version needed for some clients
LegacyString = "0.1.0"
// Edition describes the build channel (stable, rolling, nightly, daily, dev)
Edition = Dev // default for self-compiled builds
)
func init() { //nolint:gochecknoinits
if err := initEdition(); err != nil {
logger.New().Error().Err(err).Msg("falling back to dev")
}
}
func initEdition() error {
regularEditions := []string{EditionDev, EditionRolling, EditionStable}
versionedEditions := []string{EditionLTS}
if !slices.ContainsFunc(slices.Concat(regularEditions, versionedEditions), func(s string) bool {
isRegularEdition := slices.Contains(regularEditions, Edition)
if isRegularEdition && s == Edition {
return true
}
// handle editions with a version
editionParts := strings.Split(Edition, "-")
if len(editionParts) != 2 { // a versioned edition channel must consist of exactly 2 parts.
return false
}
isVersionedEdition := slices.Contains(versionedEditions, editionParts[0])
if !isVersionedEdition { // not all channels can contain version information
return false
}
_, err := semver.NewVersion(editionParts[1])
return err == nil
}) {
Edition = Dev
return fmt.Errorf(`unknown edition channel "%s"`, Edition)
}
return nil
}
// Compiled returns the compile time of this service.
func Compiled() time.Time {
if Date == Dev {
if Date == "dev" {
return time.Now()
}
t, _ := time.Parse("20060102", Date)

View File

@@ -1,65 +0,0 @@
package version_test
import (
"fmt"
"testing"
"github.com/opencloud-eu/opencloud/pkg/version"
)
func TestChannel(t *testing.T) {
tests := map[string]struct {
got string
valid bool
}{
"no channel, defaults to dev": {
got: "",
valid: false,
},
"dev channel": {
got: version.EditionDev,
valid: true,
},
"rolling channel": {
got: version.EditionRolling,
valid: true,
},
"stable channel": {
got: version.EditionStable,
valid: true,
},
"lts channel without version": {
got: version.EditionLTS,
valid: false,
},
"lts-1.0.0 channel": {
got: fmt.Sprintf("%s-1", version.EditionLTS),
valid: true,
},
"lts-one invalid version": {
got: fmt.Sprintf("%s-one", version.EditionLTS),
valid: false,
},
"known channel with version": {
got: fmt.Sprintf("%s-1", version.EditionStable),
valid: false,
},
"unknown channel": {
got: "foo",
valid: false,
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
version.Edition = test.got
switch err := version.InitEdition(); {
case err != nil && !test.valid && version.Edition != version.Dev: // if a given edition is unknown, the value is always dev
fallthrough
case test.valid != (err == nil):
t.Fatalf("invalid edition: %s", version.Edition)
}
})
}
}

View File

@@ -11,7 +11,7 @@ msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2025-12-13 00:02+0000\n"
"POT-Creation-Date: 2025-11-17 00:02+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: Mário Machado, 2025\n"
"Language-Team: Portuguese (https://app.transifex.com/opencloud-eu/teams/204053/pt/)\n"

View File

@@ -0,0 +1,11 @@
SHELL := bash
NAME := auth-api
ifneq (, $(shell command -v go 2> /dev/null)) # suppress `command not found warnings` for non go targets in CI
include ../../.bingo/Variables.mk
endif
include ../../.make/default.mk
include ../../.make/go.mk
include ../../.make/release.mk
include ../../.make/docs.mk

View File

@@ -0,0 +1,19 @@
package main
import (
"context"
"os"
"os/signal"
"syscall"
"github.com/opencloud-eu/opencloud/services/auth-api/pkg/command"
"github.com/opencloud-eu/opencloud/services/auth-api/pkg/config/defaults"
)
func main() {
cfg := defaults.DefaultConfig()
cfg.Context, _ = signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT, syscall.SIGHUP)
if err := command.Execute(cfg); err != nil {
os.Exit(1)
}
}

View File

@@ -0,0 +1,27 @@
package command
import (
"os"
"github.com/opencloud-eu/opencloud/pkg/clihelper"
"github.com/opencloud-eu/opencloud/services/auth-api/pkg/config"
"github.com/urfave/cli/v2"
)
// GetCommands provides all commands for this service
func GetCommands(cfg *config.Config) cli.Commands {
return []*cli.Command{
Server(cfg),
Version(cfg),
}
}
func Execute(cfg *config.Config) error {
app := clihelper.DefaultApp(&cli.App{
Name: "auth-api",
Usage: "OpenCloud authentication API for external services",
Commands: GetCommands(cfg),
})
return app.RunContext(cfg.Context, os.Args)
}

View File

@@ -0,0 +1,91 @@
package command
import (
"context"
"fmt"
"github.com/oklog/run"
"github.com/opencloud-eu/opencloud/pkg/config/configlog"
"github.com/opencloud-eu/opencloud/pkg/version"
"github.com/opencloud-eu/opencloud/services/auth-api/pkg/config"
"github.com/opencloud-eu/opencloud/services/auth-api/pkg/config/parser"
"github.com/opencloud-eu/opencloud/services/auth-api/pkg/logging"
"github.com/opencloud-eu/opencloud/services/auth-api/pkg/metrics"
"github.com/opencloud-eu/opencloud/services/auth-api/pkg/server/debug"
"github.com/opencloud-eu/opencloud/services/auth-api/pkg/server/http"
"github.com/urfave/cli/v2"
)
// Server is the entrypoint for the server command.
func Server(cfg *config.Config) *cli.Command {
return &cli.Command{
Name: "server",
Usage: fmt.Sprintf("start the %s service without runtime (unsupervised mode)", cfg.Service.Name),
Category: "server",
Before: func(_ *cli.Context) error {
return configlog.ReturnFatal(parser.ParseConfig(cfg))
},
Action: func(c *cli.Context) error {
logger := logging.Configure(cfg.Service.Name, cfg.Log)
var (
gr = run.Group{}
ctx, cancel = context.WithCancel(c.Context)
m = metrics.New()
)
defer cancel()
m.BuildInfo.WithLabelValues(version.GetString()).Set(1)
server, err := debug.Server(
debug.Logger(logger),
debug.Config(cfg),
debug.Context(ctx),
)
if err != nil {
logger.Info().Err(err).Str("transport", "debug").Msg("Failed to initialize server")
return err
}
gr.Add(server.ListenAndServe, func(_ error) {
_ = server.Shutdown(ctx)
cancel()
})
httpServer, err := http.Server(
http.Logger(logger),
http.Context(ctx),
http.Config(cfg),
http.Metrics(m),
http.Namespace(cfg.HTTP.Namespace),
)
if err != nil {
logger.Info().
Err(err).
Str("transport", "http").
Msg("Failed to initialize server")
return err
}
gr.Add(httpServer.Run, func(_ error) {
if err == nil {
logger.Info().
Str("transport", "http").
Str("server", cfg.Service.Name).
Msg("Shutting down server")
} else {
logger.Error().Err(err).
Str("transport", "http").
Str("server", cfg.Service.Name).
Msg("Shutting down server")
}
cancel()
})
return gr.Run()
},
}
}

View File

@@ -0,0 +1,26 @@
package command
import (
"fmt"
"github.com/opencloud-eu/opencloud/pkg/version"
"github.com/opencloud-eu/opencloud/services/auth-api/pkg/config"
"github.com/urfave/cli/v2"
)
// Version prints the service versions of all running instances.
func Version(cfg *config.Config) *cli.Command {
return &cli.Command{
Name: "version",
Usage: "print the version of this binary and the running service instances",
Category: "info",
Action: func(c *cli.Context) error {
fmt.Println("Version: " + version.GetString())
fmt.Printf("Compiled: %s\n", version.Compiled())
fmt.Println("")
return nil
},
}
}

View File

@@ -0,0 +1,27 @@
package config
import (
"context"
"github.com/opencloud-eu/opencloud/pkg/shared"
)
// Config combines all available configuration parts.
type Config struct {
Commons *shared.Commons `yaml:"-"` // don't use this directly as configuration for a service
Service Service `yaml:"-"`
Log *Log `yaml:"log"`
Debug Debug `yaml:"debug"`
HTTP HTTP `yaml:"http"`
Authentication AuthenticationAPI `yaml:"authentication_api"`
Context context.Context `yaml:"-"`
}
type AuthenticationAPI struct {
JwkEndpoint string `yaml:"jwk_endpoint"`
}

View File

@@ -0,0 +1,9 @@
package config
// Debug defines the available debug configuration.
type Debug struct {
Addr string `yaml:"addr" env:"AUTHAPI_DEBUG_ADDR" desc:"Bind address of the debug server, where metrics, health, config and debug endpoints will be exposed." introductionVersion:"1.0.0"`
Token string `yaml:"token" env:"AUTHAPI_DEBUG_TOKEN" desc:"Token to secure the metrics endpoint." introductionVersion:"1.0.0"`
Pprof bool `yaml:"pprof" env:"AUTHAPI_DEBUG_PPROF" desc:"Enables pprof, which can be used for profiling." introductionVersion:"1.0.0"`
Zpages bool `yaml:"zpages" env:"AUTHAPI_DEBUG_ZPAGES" desc:"Enables zpages, which can be used for collecting and viewing in-memory traces." introductionVersion:"1.0.0"`
}

View File

@@ -0,0 +1,64 @@
package defaults
import (
"strings"
"github.com/opencloud-eu/opencloud/services/auth-api/pkg/config"
)
// FullDefaultConfig returns a fully initialized default configuration
func FullDefaultConfig() *config.Config {
cfg := DefaultConfig()
EnsureDefaults(cfg)
Sanitize(cfg)
return cfg
}
// DefaultConfig returns a basic default configuration
func DefaultConfig() *config.Config {
return &config.Config{
Debug: config.Debug{
Addr: "127.0.0.1:9202",
Token: "",
Pprof: false,
Zpages: false,
},
HTTP: config.HTTP{
Addr: "127.0.0.1:9278",
Root: "/auth",
Namespace: "eu.opencloud.web",
},
Service: config.Service{
Name: "auth-api",
},
Authentication: config.AuthenticationAPI{
JwkEndpoint: "https://keycloak.opencloud.test/realms/openCloud/protocol/openid-connect/certs",
},
}
}
// EnsureDefaults adds default values to the configuration if they are not set yet
func EnsureDefaults(cfg *config.Config) {
// provide with defaults for shared logging, since we need a valid destination address for "envdecode".
if cfg.Log == nil && cfg.Commons != nil && cfg.Commons.Log != nil {
cfg.Log = &config.Log{
Level: cfg.Commons.Log.Level,
Pretty: cfg.Commons.Log.Pretty,
Color: cfg.Commons.Log.Color,
File: cfg.Commons.Log.File,
}
} else if cfg.Log == nil {
cfg.Log = &config.Log{}
}
if cfg.Commons != nil {
cfg.HTTP.TLS = cfg.Commons.HTTPServiceTLS
}
}
// Sanitize sanitized the configuration
func Sanitize(cfg *config.Config) {
if cfg.HTTP.Root != "/" {
cfg.HTTP.Root = strings.TrimSuffix(cfg.HTTP.Root, "/")
}
}

View File

@@ -0,0 +1,11 @@
package config
import "github.com/opencloud-eu/opencloud/pkg/shared"
// HTTP defines the available http configuration.
type HTTP struct {
Addr string `yaml:"addr" env:"AUTHAPI_HTTP_ADDR" desc:"The bind address of the HTTP service." introductionVersion:"1.0.0"`
TLS shared.HTTPServiceTLS `yaml:"tls"`
Root string `yaml:"root" env:"AUTHAPI_HTTP_ROOT" desc:"Subdirectory that serves as the root for this HTTP service." introductionVersion:"1.0.0"`
Namespace string `yaml:"-"`
}

View File

@@ -0,0 +1,9 @@
package config
// Log defines the available log configuration.
type Log struct {
Level string `mapstructure:"level" env:"OC_LOG_LEVEL;AUTHAPI_LOG_LEVEL" desc:"The log level. Valid values are: 'panic', 'fatal', 'error', 'warn', 'info', 'debug', 'trace'." introductionVersion:"1.0.0"`
Pretty bool `mapstructure:"pretty" env:"OC_LOG_PRETTY;AUTHAPI_LOG_PRETTY" desc:"Activates pretty log output." introductionVersion:"1.0.0"`
Color bool `mapstructure:"color" env:"OC_LOG_COLOR;AUTHAPI_LOG_COLOR" desc:"Activates colorized log output." introductionVersion:"1.0.0"`
File string `mapstructure:"file" env:"OC_LOG_FILE;AUTHAPI_LOG_FILE" desc:"The path to the log file. Activates logging to this file if set." introductionVersion:"1.0.0"`
}

View File

@@ -0,0 +1,39 @@
package parser
import (
"errors"
occfg "github.com/opencloud-eu/opencloud/pkg/config"
"github.com/opencloud-eu/opencloud/services/auth-api/pkg/config"
"github.com/opencloud-eu/opencloud/services/auth-api/pkg/config/defaults"
"github.com/opencloud-eu/opencloud/pkg/config/envdecode"
)
// ParseConfig loads configuration from known paths.
func ParseConfig(cfg *config.Config) error {
err := occfg.BindSourcesToStructs(cfg.Service.Name, cfg)
if err != nil {
return err
}
defaults.EnsureDefaults(cfg)
// load all env variables relevant to the config in the current context.
if err := envdecode.Decode(cfg); err != nil {
// no environment variable set for this config is an expected "error"
if !errors.Is(err, envdecode.ErrNoTargetFieldsAreSet) {
return err
}
}
// sanitize config
defaults.Sanitize(cfg)
return Validate(cfg)
}
// Validate can validate the configuration
func Validate(_ *config.Config) error {
return nil
}

View File

@@ -0,0 +1,6 @@
package config
// Service defines the available service configuration.
type Service struct {
Name string `yaml:"-"`
}

View File

@@ -0,0 +1,17 @@
package logging
import (
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/opencloud/services/auth-api/pkg/config"
)
// Configure initializes a service-specific logger instance.
func Configure(name string, cfg *config.Log) log.Logger {
return log.NewLogger(
log.Name(name),
log.Level(cfg.Level),
log.Pretty(cfg.Pretty),
log.Color(cfg.Color),
log.File(cfg.File),
)
}

View File

@@ -0,0 +1,74 @@
package metrics
import "github.com/prometheus/client_golang/prometheus"
var (
// Namespace defines the namespace for the defines metrics.
Namespace = "opencloud"
// Subsystem defines the subsystem for the defines metrics.
Subsystem = "authapi"
)
// Metrics defines the available metrics of this service.
type Metrics struct {
BuildInfo *prometheus.GaugeVec
Duration *prometheus.HistogramVec
Attempts *prometheus.CounterVec
}
const (
TypeLabel = "type"
BasicType = "basic"
BearerType = "bearer"
UnsupportedType = "unsupported"
OutcomeLabel = "outcome"
AttemptSuccessOutcome = "success"
AttemptFailureOutcome = "failure"
)
// New initializes the available metrics.
func New(opts ...Option) *Metrics {
options := newOptions(opts...)
m := &Metrics{
BuildInfo: prometheus.NewGaugeVec(prometheus.GaugeOpts{
Namespace: Namespace,
Subsystem: Subsystem,
Name: "build_info",
Help: "Build information",
}, []string{"version"}),
Duration: prometheus.NewHistogramVec(prometheus.HistogramOpts{
Namespace: Namespace,
Subsystem: Subsystem,
Name: "authentication_duration_seconds",
Help: "Authentication processing time in seconds",
}, []string{"type"}),
Attempts: prometheus.NewCounterVec(prometheus.CounterOpts{
Namespace: Namespace,
Subsystem: Subsystem,
Name: "athentication_attempts_total",
Help: "How many authentication attempts were processed",
}, []string{"outcome"}),
}
if err := prometheus.Register(m.BuildInfo); err != nil {
options.Logger.Error().
Err(err).
Str("metric", "BuildInfo").
Msg("Failed to register prometheus metric")
}
if err := prometheus.Register(m.Duration); err != nil {
options.Logger.Error().
Err(err).
Str("metric", "Duration").
Msg("Failed to register prometheus metric")
}
if err := prometheus.Register(m.Attempts); err != nil {
options.Logger.Error().
Err(err).
Str("metric", "Attempts").
Msg("Failed to register prometheus metric")
}
return m
}

View File

@@ -0,0 +1,31 @@
package metrics
import (
"github.com/opencloud-eu/opencloud/pkg/log"
)
// Option defines a single option function.
type Option func(o *Options)
// Options defines the available options for this package.
type Options struct {
Logger log.Logger
}
// newOptions initializes the available default options.
func newOptions(opts ...Option) Options {
opt := Options{}
for _, o := range opts {
o(&opt)
}
return opt
}
// Logger provides a function to set the logger option.
func Logger(val log.Logger) Option {
return func(o *Options) {
o.Logger = val
}
}

View File

@@ -0,0 +1,50 @@
package debug
import (
"context"
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/opencloud/services/auth-api/pkg/config"
)
// Option defines a single option function.
type Option func(o *Options)
// Options defines the available options for this package.
type Options struct {
Logger log.Logger
Context context.Context
Config *config.Config
}
// newOptions initializes the available default options.
func newOptions(opts ...Option) Options {
opt := Options{}
for _, o := range opts {
o(&opt)
}
return opt
}
// Logger provides a function to set the logger option.
func Logger(val log.Logger) Option {
return func(o *Options) {
o.Logger = val
}
}
// Context provides a function to set the context option.
func Context(val context.Context) Option {
return func(o *Options) {
o.Context = val
}
}
// Config provides a function to set the config option.
func Config(val *config.Config) Option {
return func(o *Options) {
o.Config = val
}
}

View File

@@ -0,0 +1,24 @@
package debug
import (
"net/http"
"github.com/opencloud-eu/opencloud/pkg/handlers"
"github.com/opencloud-eu/opencloud/pkg/service/debug"
"github.com/opencloud-eu/opencloud/pkg/version"
)
// Server initializes the debug service and server.
func Server(opts ...Option) (*http.Server, error) {
options := newOptions(opts...)
readyHandlerConfiguration := handlers.NewCheckHandlerConfiguration().
WithLogger(options.Logger)
return debug.NewService(
debug.Logger(options.Logger),
debug.Name(options.Config.Service.Name),
debug.Version(version.GetString()),
debug.Ready(handlers.NewCheckHandler(readyHandlerConfiguration)),
), nil
}

View File

@@ -0,0 +1,83 @@
package http
import (
"context"
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/opencloud/services/auth-api/pkg/config"
"github.com/opencloud-eu/opencloud/services/auth-api/pkg/metrics"
"github.com/urfave/cli/v2"
"go.opentelemetry.io/otel/trace"
"go.opentelemetry.io/otel/trace/noop"
)
// Option defines a single option function.
type Option func(o *Options)
// Options defines the available options for this package.
type Options struct {
Namespace string
Logger log.Logger
Context context.Context
Config *config.Config
Metrics *metrics.Metrics
Flags []cli.Flag
TraceProvider trace.TracerProvider
}
// newOptions initializes the available default options.
func newOptions(opts ...Option) Options {
opt := Options{}
for _, o := range opts {
o(&opt)
}
return opt
}
// Logger provides a function to set the logger option.
func Logger(val log.Logger) Option {
return func(o *Options) {
o.Logger = val
}
}
// Context provides a function to set the context option.
func Context(val context.Context) Option {
return func(o *Options) {
o.Context = val
}
}
// Config provides a function to set the config option.
func Config(val *config.Config) Option {
return func(o *Options) {
o.Config = val
}
}
// Metrics provides a function to set the metrics option.
func Metrics(val *metrics.Metrics) Option {
return func(o *Options) {
o.Metrics = val
}
}
// Namespace provides a function to set the Namespace option.
func Namespace(val string) Option {
return func(o *Options) {
o.Namespace = val
}
}
// TraceProvider provides a function to configure the trace provider
func TraceProvider(traceProvider trace.TracerProvider) Option {
return func(o *Options) {
if traceProvider != nil {
o.TraceProvider = traceProvider
} else {
o.TraceProvider = noop.NewTracerProvider()
}
}
}

View File

@@ -0,0 +1,61 @@
package http
import (
"fmt"
"github.com/go-chi/chi/v5/middleware"
opencloudmiddleware "github.com/opencloud-eu/opencloud/pkg/middleware"
"github.com/opencloud-eu/opencloud/pkg/service/http"
"github.com/opencloud-eu/opencloud/pkg/version"
svc "github.com/opencloud-eu/opencloud/services/auth-api/pkg/service/http/v0"
"go-micro.dev/v4"
)
// Server initializes the http service and server.
func Server(opts ...Option) (http.Service, error) {
options := newOptions(opts...)
service, err := http.NewService(
http.TLSConfig(options.Config.HTTP.TLS),
http.Logger(options.Logger),
http.Name(options.Config.Service.Name),
http.Version(version.GetString()),
http.Namespace(options.Config.HTTP.Namespace),
http.Address(options.Config.HTTP.Addr),
http.Context(options.Context),
http.TraceProvider(options.TraceProvider),
)
if err != nil {
options.Logger.Error().
Err(err).
Msg("Error initializing http service")
return http.Service{}, fmt.Errorf("could not initialize http service: %w", err)
}
handle := svc.NewService(
svc.Logger(options.Logger),
svc.Config(options.Config),
svc.Metrics(options.Metrics),
svc.TraceProvider(options.TraceProvider),
svc.Middleware(
middleware.RealIP,
middleware.RequestID,
opencloudmiddleware.Version(
options.Config.Service.Name,
version.GetString(),
),
opencloudmiddleware.Logger(options.Logger),
),
)
{
handle = svc.NewInstrument(handle, options.Metrics)
handle = svc.NewLogging(handle, options.Logger)
}
if err := micro.RegisterHandler(service.Server(), handle); err != nil {
return http.Service{}, err
}
return service, nil
}

View File

@@ -0,0 +1,25 @@
package svc
import (
"net/http"
"github.com/opencloud-eu/opencloud/services/auth-api/pkg/metrics"
)
// NewInstrument returns a service that instruments metrics.
func NewInstrument(next Service, metrics *metrics.Metrics) Service {
return instrument{
next: next,
metrics: metrics,
}
}
type instrument struct {
next Service
metrics *metrics.Metrics
}
// ServeHTTP implements the Service interface.
func (i instrument) ServeHTTP(w http.ResponseWriter, r *http.Request) {
i.next.ServeHTTP(w, r)
}

View File

@@ -0,0 +1,25 @@
package svc
import (
"net/http"
"github.com/opencloud-eu/opencloud/pkg/log"
)
// NewLogging returns a service that logs messages.
func NewLogging(next Service, logger log.Logger) Service {
return logging{
next: next,
logger: logger,
}
}
type logging struct {
next Service
logger log.Logger
}
// ServeHTTP implements the Service interface.
func (l logging) ServeHTTP(w http.ResponseWriter, r *http.Request) {
l.next.ServeHTTP(w, r)
}

View File

@@ -0,0 +1,66 @@
package svc
import (
"net/http"
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/opencloud/services/auth-api/pkg/config"
"github.com/opencloud-eu/opencloud/services/auth-api/pkg/metrics"
"go.opentelemetry.io/otel/trace"
)
// Option defines a single option function.
type Option func(o *Options)
// Options defines the available options for this package.
type Options struct {
Logger log.Logger
Config *config.Config
Middleware []func(http.Handler) http.Handler
Metrics *metrics.Metrics
TraceProvider trace.TracerProvider
}
// newOptions initializes the available default options.
func newOptions(opts ...Option) Options {
opt := Options{}
for _, o := range opts {
o(&opt)
}
return opt
}
// Logger provides a function to set the logger option.
func Logger(val log.Logger) Option {
return func(o *Options) {
o.Logger = val
}
}
// Config provides a function to set the config option.
func Config(val *config.Config) Option {
return func(o *Options) {
o.Config = val
}
}
// Middleware provides a function to set the middleware option.
func Middleware(val ...func(http.Handler) http.Handler) Option {
return func(o *Options) {
o.Middleware = val
}
}
func TraceProvider(tp trace.TracerProvider) Option {
return func(o *Options) {
o.TraceProvider = tp
}
}
func Metrics(m *metrics.Metrics) Option {
return func(o *Options) {
o.Metrics = m
}
}

View File

@@ -0,0 +1,266 @@
package svc
import (
"context"
"crypto/tls"
"net/http"
"regexp"
"time"
"github.com/MicahParks/jwkset"
"github.com/MicahParks/keyfunc/v3"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
"github.com/golang-jwt/jwt/v5"
"github.com/riandyrn/otelchi"
"go.opentelemetry.io/otel/attribute"
oteltrace "go.opentelemetry.io/otel/trace"
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/opencloud/pkg/tracing"
"github.com/opencloud-eu/opencloud/services/auth-api/pkg/config"
"github.com/opencloud-eu/opencloud/services/auth-api/pkg/metrics"
)
// Service defines the service handlers.
type Service interface {
ServeHTTP(w http.ResponseWriter, r *http.Request)
}
// NewService returns a service implementation for Service.
func NewService(opts ...Option) Service {
options := newOptions(opts...)
m := chi.NewMux()
m.Use(options.Middleware...)
m.Use(
otelchi.Middleware(
"auth-api",
otelchi.WithChiRoutes(m),
otelchi.WithTracerProvider(options.TraceProvider),
otelchi.WithPropagators(tracing.GetPropagator()),
otelchi.WithTraceResponseHeaders(otelchi.TraceHeaderConfig{}),
),
)
svc, err := NewAuthenticationApi(options.Config, &options.Logger, options.Metrics, options.TraceProvider, m)
if err != nil {
panic(err) // TODO p.bleser what to do when we encounter an error in a NewService() ?
}
m.Route(options.Config.HTTP.Root, func(r chi.Router) {
r.Post("/", svc.Authenticate)
})
_ = chi.Walk(m, func(method string, route string, _ http.Handler, middlewares ...func(http.Handler) http.Handler) error {
options.Logger.Debug().Str("method", method).Str("route", route).Int("middlewares", len(middlewares)).Msg("serving endpoint")
return nil
})
return svc
}
type AuthenticationApi struct {
config *config.Config
logger *log.Logger
metrics *metrics.Metrics
tracer oteltrace.Tracer
mux *chi.Mux
refreshCtx context.Context
jwksFunc keyfunc.Keyfunc
}
func NewAuthenticationApi(
config *config.Config,
logger *log.Logger,
metrics *metrics.Metrics,
tracerProvider oteltrace.TracerProvider,
mux *chi.Mux,
) (*AuthenticationApi, error) {
tracer := tracerProvider.Tracer("instrumentation/" + config.HTTP.Namespace + "/" + config.Service.Name)
var httpClient *http.Client
{
tr := http.DefaultTransport.(*http.Transport).Clone()
tr.ResponseHeaderTimeout = time.Duration(10) * time.Second
tlsConfig := &tls.Config{InsecureSkipVerify: true}
tr.TLSClientConfig = tlsConfig
h := http.DefaultClient
h.Transport = tr
httpClient = h
}
refreshCtx := context.Background()
storage, err := jwkset.NewStorageFromHTTP(config.Authentication.JwkEndpoint, jwkset.HTTPClientStorageOptions{
Client: httpClient,
Ctx: refreshCtx,
HTTPExpectedStatus: http.StatusOK,
HTTPMethod: http.MethodGet,
HTTPTimeout: time.Duration(10) * time.Second,
NoErrorReturnFirstHTTPReq: true,
RefreshInterval: time.Duration(10) * time.Minute,
RefreshErrorHandler: func(ctx context.Context, err error) {
logger.Error().Err(err).Ctx(ctx).Str("url", config.Authentication.JwkEndpoint).Msg("failed to refresh JWK Set from IDP")
},
//ValidateOptions: jwkset.JWKValidateOptions{},
})
if err != nil {
return nil, err
}
jwksFunc, err := keyfunc.New(keyfunc.Options{
Ctx: refreshCtx,
UseWhitelist: []jwkset.USE{jwkset.UseSig},
Storage: storage,
})
if err != nil {
return nil, err
}
return &AuthenticationApi{
config: config,
mux: mux,
logger: logger,
metrics: metrics,
tracer: tracer,
refreshCtx: refreshCtx,
jwksFunc: jwksFunc,
}, nil
}
func (a AuthenticationApi) ServeHTTP(w http.ResponseWriter, r *http.Request) {
a.mux.ServeHTTP(w, r)
}
type SuccessfulAuthResponse struct {
Subject string `json:"subject"`
Roles []string `json:"roles,omitempty"`
}
func (SuccessfulAuthResponse) Render(w http.ResponseWriter, r *http.Request) error {
return nil
}
type FailedAuthResponse struct {
Reason string `json:"reason,omitempty"`
}
func (FailedAuthResponse) Render(w http.ResponseWriter, r *http.Request) error {
return nil
}
type CustomClaims struct {
Roles []string `json:"roles,omitempty"`
AuthorizedParties jwt.ClaimStrings `json:"azp,omitempty"`
SessionId string `json:"sid,omitempty"`
AuthenticationContextClassReference string `json:"acr,omitempty"`
Scope jwt.ClaimStrings `json:"scope,omitempty"`
EmailVerified bool `json:"email_verified,omitempty"`
Name string `json:"name,omitempty"`
Groups jwt.ClaimStrings `json:"groups,omitempty"`
PreferredUsername string `json:"preferred_username,omitempty"`
GivenName string `json:"given_name,omitempty"`
FamilyName string `json:"family_name,omitempty"`
Uuid string `json:"uuid,omitempty"`
Email string `json:"email,omitempty"`
jwt.RegisteredClaims
}
var authRegex = regexp.MustCompile(`(?i)^(Basic|Bearer)\s+(.+)$`)
func (a AuthenticationApi) failedAuth() {
a.metrics.Attempts.WithLabelValues(metrics.OutcomeLabel, metrics.AttemptFailureOutcome).Inc()
}
func (a AuthenticationApi) succeededAuth() {
a.metrics.Attempts.WithLabelValues(metrics.OutcomeLabel, metrics.AttemptSuccessOutcome).Inc()
}
func (a AuthenticationApi) Authenticate(w http.ResponseWriter, r *http.Request) {
_, span := a.tracer.Start(r.Context(), "authenticate")
defer span.End()
auth := r.Header.Get("Authorization")
if auth == "" {
a.logger.Warn().Msg("missing Authorization header")
w.WriteHeader(http.StatusBadRequest) // authentication header is missing altogether
_ = render.Render(w, r, FailedAuthResponse{Reason: "Missing Authorization header"})
a.failedAuth()
return
}
matches := authRegex.FindStringSubmatch(auth)
if matches == nil || len(matches) != 3 {
a.logger.Warn().Msg("unsupported Authorization header")
w.WriteHeader(http.StatusBadRequest) // authentication header is unsupported
_ = render.Render(w, r, FailedAuthResponse{Reason: "Unsupported Authorization header"})
a.failedAuth()
return
}
if matches[1] == "Basic" {
span.SetAttributes(attribute.String("authenticate.scheme", "basic"))
a.metrics.Attempts.WithLabelValues(metrics.TypeLabel, metrics.BasicType).Inc()
username, password, ok := r.BasicAuth()
if !ok {
a.logger.Warn().Msg("failed to decode basic credentials")
w.WriteHeader(http.StatusBadRequest) // failed to decode the basic credentials
_ = render.Render(w, r, FailedAuthResponse{Reason: "Failed to decode basic credentials"})
a.failedAuth()
return
}
if password == "secret" {
_ = render.Render(w, r, SuccessfulAuthResponse{Subject: username})
a.succeededAuth()
} else {
a.logger.Info().Str("username", username).Msg("authentication failed")
w.WriteHeader(http.StatusUnauthorized)
_ = render.Render(w, r, FailedAuthResponse{Reason: "Unauthorized credentials"})
a.failedAuth()
return
}
} else if matches[1] == "Bearer" {
span.SetAttributes(attribute.String("authenticate.scheme", "bearer"))
a.metrics.Attempts.WithLabelValues(metrics.TypeLabel, metrics.BearerType).Inc()
claims := &CustomClaims{}
tokenString := matches[2]
token, err := jwt.ParseWithClaims(tokenString, claims, a.jwksFunc.Keyfunc, jwt.WithExpirationRequired(), jwt.WithLeeway(5*time.Second))
if err != nil {
a.logger.Warn().Err(err).Msg("failed to parse bearer token")
w.WriteHeader(http.StatusBadRequest) // failed to parse bearer token
_ = render.Render(w, r, FailedAuthResponse{Reason: "Failed to parse bearer token"})
return
}
a.logger.Info().Str("type", matches[1]).Interface("header", token.Header).Interface("claims", token.Claims).Bool("valid", token.Valid).Msgf("successfully parsed token")
if typedClaims, ok := token.Claims.(*CustomClaims); ok && token.Valid {
sub := typedClaims.PreferredUsername
if sub == "" {
sub, err = typedClaims.GetSubject()
if err != nil {
a.logger.Warn().Err(err).Msg("failed to retrieve sub claim from token")
w.WriteHeader(http.StatusBadRequest) // failed to extract sub claim from bearer token
_ = render.Render(w, r, FailedAuthResponse{Reason: "Failed to extract sub claim from bearer token"})
return
}
}
_ = render.Render(w, r, SuccessfulAuthResponse{Subject: sub, Roles: claims.Roles})
} else {
w.WriteHeader(http.StatusBadRequest) // failed to extract sub claim from bearer token
_ = render.Render(w, r, FailedAuthResponse{Reason: "Failed to parse bearer token"})
return
}
} else {
a.metrics.Attempts.WithLabelValues(metrics.TypeLabel, metrics.UnsupportedType).Inc()
w.WriteHeader(http.StatusBadRequest) // authentication header is unsupported
_ = render.Render(w, r, FailedAuthResponse{Reason: "Unsupported Authorization type"})
return
}
}

View File

@@ -0,0 +1,17 @@
package svc
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestRegex(t *testing.T) {
require := require.New(t)
matches := authRegex.FindStringSubmatch("Basic abc")
require.NotNil(matches)
require.Len(matches, 3)
require.Equal("Basic", matches[1])
require.Equal("abc", matches[2])
}

View File

@@ -33,7 +33,7 @@ type Config struct {
EnableFederatedSharingIncoming bool `yaml:"enable_federated_sharing_incoming" env:"OC_ENABLE_OCM;FRONTEND_ENABLE_FEDERATED_SHARING_INCOMING" desc:"Changing this value is NOT supported. Enables support for incoming federated sharing for clients. The backend behaviour is not changed." introductionVersion:"1.0.0"`
EnableFederatedSharingOutgoing bool `yaml:"enable_federated_sharing_outgoing" env:"OC_ENABLE_OCM;FRONTEND_ENABLE_FEDERATED_SHARING_OUTGOING" desc:"Changing this value is NOT supported. Enables support for outgoing federated sharing for clients. The backend behaviour is not changed." introductionVersion:"1.0.0"`
SearchMinLength int `yaml:"search_min_length" env:"FRONTEND_SEARCH_MIN_LENGTH" desc:"Minimum number of characters to enter before a client should start a search for Share receivers. This setting can be used to customize the user experience if e.g too many results are displayed." introductionVersion:"1.0.0"`
Edition string `desc:"Edition of OpenCloud. Used for branding purposes." introductionVersion:"1.0.0"`
Edition string `yaml:"edition" env:"OC_EDITION;FRONTEND_EDITION" desc:"Edition of OpenCloud. Used for branding purposes." introductionVersion:"1.0.0"`
DisableSSE bool `yaml:"disable_sse" env:"OC_DISABLE_SSE;FRONTEND_DISABLE_SSE" desc:"When set to true, clients are informed that the Server-Sent Events endpoint is not accessible." introductionVersion:"1.0.0"`
DisableRadicale bool `yaml:"disable_radicale" env:"FRONTEND_DISABLE_RADICALE" desc:"When set to true, clients are informed that the Radicale (CalDAV/CardDAV) is not accessible." introductionVersion:"4.0.0"`
DefaultLinkPermissions int `yaml:"default_link_permissions" env:"FRONTEND_DEFAULT_LINK_PERMISSIONS" desc:"Defines the default permissions a link is being created with. Possible values are 0 (= internal link, for instance members only) and 1 (= public link with viewer permissions). Defaults to 1." introductionVersion:"1.0.0"`

View File

@@ -5,7 +5,6 @@ import (
"github.com/opencloud-eu/opencloud/pkg/shared"
"github.com/opencloud-eu/opencloud/pkg/structs"
"github.com/opencloud-eu/opencloud/pkg/version"
"github.com/opencloud-eu/opencloud/services/frontend/pkg/config"
)
@@ -88,7 +87,7 @@ func DefaultConfig() *config.Config {
DefaultUploadProtocol: "tus",
DefaultLinkPermissions: 1,
SearchMinLength: 3,
Edition: version.Edition,
Edition: "",
CheckForUpdates: true,
Checksums: config.Checksums{
SupportedTypes: []string{"sha1", "md5", "adler32"},

View File

@@ -346,7 +346,7 @@ func FrontendConfigFromStruct(cfg *config.Config, logger log.Logger) (map[string
},
"version": map[string]interface{}{
"product": "OpenCloud",
"edition": version.Edition,
"edition": "",
"major": version.ParsedLegacy().Major(),
"minor": version.ParsedLegacy().Minor(),
"micro": version.ParsedLegacy().Patch(),

View File

@@ -177,3 +177,4 @@ type Store struct {
AuthUsername string `yaml:"username" env:"OC_PERSISTENT_STORE_AUTH_USERNAME;GRAPH_STORE_AUTH_USERNAME" desc:"The username to authenticate with the store. Only applies when store type 'nats-js-kv' is configured." introductionVersion:"1.0.0"`
AuthPassword string `yaml:"password" env:"OC_PERSISTENT_STORE_AUTH_PASSWORD;GRAPH_STORE_AUTH_PASSWORD" desc:"The password to authenticate with the store. Only applies when store type 'nats-js-kv' is configured." introductionVersion:"1.0.0"`
}

View File

@@ -11,7 +11,7 @@ msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2025-12-13 00:02+0000\n"
"POT-Creation-Date: 2025-11-15 00:02+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: Stephan Paternotte <stephan@paternottes.net>, 2025\n"
"Language-Team: Dutch (https://app.transifex.com/opencloud-eu/teams/204053/nl/)\n"

View File

@@ -14,7 +14,7 @@ msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2025-12-13 00:02+0000\n"
"POT-Creation-Date: 2025-11-15 00:02+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: Radoslaw Posim, 2025\n"
"Language-Team: Polish (https://app.transifex.com/opencloud-eu/teams/204053/pl/)\n"

Some files were not shown because too many files have changed in this diff Show More