API documentation changes for groupware-apidocs

* add example generator infrastructure, with some examples for pkg/jmap
   and pkg/groupware, with more needing to be done

 * alter the apidoc Makefile to stop using go-swagger but, instead, use
   the openapi.yml file that must be dropped into that directory using
   groupware-apidocs (will improve the integration there later)

 * add Makefile target to generate examples

 * bump redocly from 2.4.0 to 2.14.5

 * introduce Request.PathParam() and .PathParamDoc() to improve API
   documentation, as well as future-proofing

 * improve X-Request-ID and Trace-Id header handling in the middleware
   by logging it safely when an error occurs in the middleware
This commit is contained in:
Pascal Bleser
2026-01-22 09:26:19 +01:00
parent 073eec198e
commit 5d5dd031b9
31 changed files with 1701 additions and 780 deletions

1
pkg/jmap/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/apidoc-examples.json

View File

@@ -14,7 +14,7 @@ func (j *Client) GetQuotas(accountIds []string, session *Session, ctx context.Co
invocations := make([]Invocation, len(uniqueAccountIds))
for i, accountId := range uniqueAccountIds {
invocations[i] = invocation(CommandQuotaGet, MailboxQueryCommand{AccountId: accountId}, mcid(accountId, "0"))
invocations[i] = invocation(CommandQuotaGet, QuotaGetCommand{AccountId: accountId}, mcid(accountId, "0"))
}
cmd, err := j.request(session, logger, invocations...)
if err != nil {

View File

@@ -134,6 +134,7 @@ const (
CalendarEventType = ObjectType("CalendarEvent")
CalendarEventNotificationType = ObjectType("CalendarEventNotification")
ParticipantIdentityType = ObjectType("ParticipantIdentity")
FileNodeType = ObjectType("FileNode")
JmapKeywordPrefix = "$"
JmapKeywordSeen = "$seen"
@@ -2116,7 +2117,11 @@ const (
EmailPropertyHtmlBody = "htmlBody"
EmailPropertyAttachments = "attachments"
EmailPropertyHasAttachment = "hasAttachment"
EmailPropertyHasKeyword = "hasKeyword"
EmailPropertyPreview = "preview"
EmailSortPropertyAllInThreadHaveKeyword = "allInThreadHaveKeyword"
EmailSortPropertySomeInThreadHaveKeyword = "someInThreadHaveKeyword"
)
var EmailProperties = []string{
@@ -3317,12 +3322,13 @@ type IdentitySetResponse struct {
NotDestroyed map[string]SetError `json:"notDestroyed,omitempty"`
}
// An Identity object stores information about an email address or domain the user may send from.
type Identity struct {
// The id of the Identity.
Id string `json:"id,omitempty"`
Id string `json:"id,omitempty" doc:"!request"`
// The “From” name the client SHOULD use when creating a new Email from this Identity.
Name string `json:"name,omitempty"`
Name string `json:"name,omitempty" doc:"req"`
// The “From” email address the client MUST use when creating a new Email from this Identity.
//
@@ -4879,7 +4885,7 @@ type Shareable struct {
// It then shows as well the current usage in regard to that limit.
type Quota struct {
// The unique identifier for this object.
Id string `json:"id"`
Id string `json:"id" doc:"!request"`
// The resource type of the quota.
ResourceType ResourceType `json:"resourceType"`
@@ -4897,9 +4903,9 @@ type Quota struct {
// The Scope data type is used to represent the entities the quota applies to.
//
// It is defined as a "String" with values from the following set:
// !- `account`: The quota information applies to just the client's account.
// !- `domain`: The quota information applies to all accounts sharing this domain.
// !- `global`: The quota information applies to all accounts belonging to the server.
// - `account`: The quota information applies to just the client's account.
// - `domain`: The quota information applies to all accounts sharing this domain.
// - `global`: The quota information applies to all accounts belonging to the server.
Scope Scope `json:"scope"`
// The name of the quota.

View File

@@ -0,0 +1,694 @@
//go:build groupware_examples
package jmap
import (
"encoding/json"
"fmt"
"os"
"reflect"
"strings"
)
type Exampler struct {
AccountId string
SharedAccountId string
IdentityId string
IdentityName string
EmailAddress string
BccName string
BccAddress string
OtherIdentityId string
OtherIdentityName string
OtherIdentityEmailAddress string
SharedIdentityId string
SharedIdentityName string
SharedIdentityEmailAddress string
ThreadId string
EmailId string
EmailIds []string
QuotaId string
SharedQuotaId string
Username string
SharedAccountName string
TextSignature string
MailboxInboxId string
MailboxDraftsId string
MailboxSentId string
MailboxJunkId string
MailboxDeletedId string
MailboxProjectId string
SenderEmailAddress string
SenderName string
}
var ExamplerInstance = Exampler{
AccountId: "b",
Username: "cdrummer",
IdentityId: "aemua9ai",
IdentityName: "Camina Drummer",
EmailAddress: "cdrummer@opa.example.com",
BccName: "OPA Secretary",
BccAddress: "secretary@opa.example.com",
OtherIdentityId: "reogh7ia",
OtherIdentityName: "Transport Union President",
OtherIdentityEmailAddress: "pres@tu.example.com",
SharedAccountId: "s",
SharedAccountName: "OPA Leadership",
SharedIdentityId: "eeyie4qu",
SharedIdentityName: "OPA",
SharedIdentityEmailAddress: "bosmangs@opa.example.com",
ThreadId: "soh0aixi",
EmailId: "oot8eev2",
EmailIds: []string{"oot8eev2", "phah6ang", "fo7raidi", "kahsha6p"},
QuotaId: "iezuu7ah",
SharedQuotaId: "voos4oht",
TextSignature: strings.Join([]string{"Camina Drummer", "President of the Transport Union"}, "\n"),
MailboxInboxId: "a",
MailboxDraftsId: "d",
MailboxSentId: "e",
MailboxJunkId: "c",
MailboxDeletedId: "b",
MailboxProjectId: "i",
SenderEmailAddress: "klaes@opa.example.com",
SenderName: "Klaes Ashford",
}
func (e Exampler) SessionMailAccountCapabilities() SessionMailAccountCapabilities {
return SessionMailAccountCapabilities{
MaxMailboxesPerEmail: 0,
MaxMailboxDepth: 10,
MaxSizeMailboxName: 255,
MaxSizeAttachmentsPerEmail: 50000000,
EmailQuerySortOptions: []string{
EmailPropertyReceivedAt,
EmailPropertySize,
EmailPropertyFrom,
EmailPropertyTo,
EmailPropertySubject,
EmailPropertySentAt,
EmailPropertyHasAttachment,
EmailSortPropertyAllInThreadHaveKeyword,
EmailSortPropertySomeInThreadHaveKeyword,
},
MayCreateTopLevelMailbox: true,
}
}
func (e Exampler) SessionSubmissionAccountCapabilities() SessionSubmissionAccountCapabilities {
return SessionSubmissionAccountCapabilities{
MaxDelayedSend: 2592000,
SubmissionExtensions: map[string][]string{
"DELIVERYBY": {},
"DSN": {},
"FUTURERELEASE": {},
"MT-PRIORITY": {"MIXER"},
"REQUIRETLS": {},
"SIZE": {},
},
}
}
func (e Exampler) SessionVacationResponseAccountCapabilities() SessionVacationResponseAccountCapabilities {
return SessionVacationResponseAccountCapabilities{}
}
func (e Exampler) SessionSieveAccountCapabilities() SessionSieveAccountCapabilities {
return SessionSieveAccountCapabilities{
MaxSizeScriptName: 512,
MaxSizeScript: 1048576,
MaxNumberScripts: 100,
MaxNumberRedirects: 1,
SieveExtensions: []string{
"body",
"comparator-elbonia",
"comparator-i;ascii-casemap",
"comparator-i;ascii-numeric",
"comparator-i;octet",
"convert",
"copy",
"date",
"duplicate",
"editheader",
"enclose",
"encoded-character",
"enotify",
"envelope",
"envelope-deliverby",
"envelope-dsn",
"environment",
"ereject",
"extlists",
"extracttext",
"fcc",
"fileinto",
"foreverypart",
"ihave",
"imap4flags",
"imapsieve",
"include",
"index",
"mailbox",
"mailboxid",
"mboxmetadata",
"mime",
"redirect-deliverby",
"redirect-dsn",
"regex",
"reject",
"relational",
"replace",
"servermetadata",
"spamtest",
"spamtestplus",
"special-use",
"subaddress",
"vacation",
"vacation-seconds",
"variables",
"virustest",
},
NotificationMethods: []string{"mailto"},
ExternalLists: nil,
}
}
func (e Exampler) SessionBlobAccountCapabilities() SessionBlobAccountCapabilities {
return SessionBlobAccountCapabilities{
MaxSizeBlobSet: 7499488,
MaxDataSources: 16,
SupportedTypeNames: []string{"Email", "Thread", "SieveScript"},
SupportedDigestAlgorithms: []HttpDigestAlgorithm{
HttpDigestAlgorithmSha,
HttpDigestAlgorithmSha256,
HttpDigestAlgorithmSha512,
},
}
}
func (e Exampler) SessionQuotaAccountCapabilities() SessionQuotaAccountCapabilities {
return SessionQuotaAccountCapabilities{}
}
func (e Exampler) SessionContactsAccountCapabilities() SessionContactsAccountCapabilities {
return SessionContactsAccountCapabilities{
MaxAddressBooksPerCard: 128,
MayCreateAddressBook: true,
}
}
func (e Exampler) SessionCalendarsAccountCapabilities() SessionCalendarsAccountCapabilities {
var maxCals uint = 128
var maxParticipants uint = 20
minDate := UTCDate("0001-01-01T00:00:00Z")
maxDate := UTCDate("65534-12-31T23:59:59Z")
maxExp := Duration("P52W1D")
create := true
return SessionCalendarsAccountCapabilities{
MaxCalendarsPerEvent: &maxCals,
MinDateTime: &minDate,
MaxDateTime: &maxDate,
MaxExpandedQueryDuration: maxExp,
MaxParticipantsPerEvent: &maxParticipants,
MayCreateCalendar: &create,
}
}
func (e Exampler) SessionCalendarsParseAccountCapabilities() SessionCalendarsParseAccountCapabilities {
return SessionCalendarsParseAccountCapabilities{}
}
func (e Exampler) sessionPrincipalsAccountCapabilities(accountId string) SessionPrincipalsAccountCapabilities {
return SessionPrincipalsAccountCapabilities{
CurrentUserPrincipalId: accountId,
}
}
func (e Exampler) SessionPrincipalsAccountCapabilities() SessionPrincipalsAccountCapabilities {
return e.sessionPrincipalsAccountCapabilities(e.AccountId)
}
func (e Exampler) SessionPrincipalAvailabilityAccountCapabilities() SessionPrincipalAvailabilityAccountCapabilities {
return SessionPrincipalAvailabilityAccountCapabilities{
MaxAvailabilityDuration: Duration("P52W1D"),
}
}
func (e Exampler) SessionTasksAccountCapabilities() SessionTasksAccountCapabilities {
return SessionTasksAccountCapabilities{
MinDateTime: LocalDate("0001-01-01T00:00:00Z"),
MaxDateTime: LocalDate("65534-12-31T23:59:59Z"),
MayCreateTaskList: true,
}
}
func (e Exampler) SessionTasksAlertsAccountCapabilities() SessionTasksAlertsAccountCapabilities {
return SessionTasksAlertsAccountCapabilities{}
}
func (e Exampler) SessionTasksAssigneesAccountCapabilities() SessionTasksAssigneesAccountCapabilities {
return SessionTasksAssigneesAccountCapabilities{}
}
func (e Exampler) SessionTasksRecurrencesAccountCapabilities() SessionTasksRecurrencesAccountCapabilities {
return SessionTasksRecurrencesAccountCapabilities{
MaxExpandedQueryDuration: Duration("P260W1D"),
}
}
func (e Exampler) SessionTasksMultilingualAccountCapabilities() SessionTasksMultilingualAccountCapabilities {
return SessionTasksMultilingualAccountCapabilities{}
}
func (e Exampler) SessionTasksCustomTimezonesAccountCapabilities() SessionTasksCustomTimezonesAccountCapabilities {
return SessionTasksCustomTimezonesAccountCapabilities{}
}
func (e Exampler) SessionPrincipalsOwnerAccountCapabilities() SessionPrincipalsOwnerAccountCapabilities {
return SessionPrincipalsOwnerAccountCapabilities{
AccountIdForPrincipal: e.AccountId,
PrincipalId: e.AccountId,
}
}
func (e Exampler) SessionMDNAccountCapabilities() SessionMDNAccountCapabilities {
return SessionMDNAccountCapabilities{}
}
func (e Exampler) SessionAccountCapabilities() SessionAccountCapabilities {
return e.sessionAccountCapabilities(e.AccountId)
}
func (e Exampler) sessionAccountCapabilities(accountId string) SessionAccountCapabilities {
mail := e.SessionMailAccountCapabilities()
submission := e.SessionSubmissionAccountCapabilities()
vacationResponse := e.SessionVacationResponseAccountCapabilities()
sieve := e.SessionSieveAccountCapabilities()
blob := e.SessionBlobAccountCapabilities()
quota := e.SessionQuotaAccountCapabilities()
contacts := e.SessionContactsAccountCapabilities()
calendars := e.SessionCalendarsAccountCapabilities()
calendarsParse := e.SessionCalendarsParseAccountCapabilities()
principals := e.sessionPrincipalsAccountCapabilities(accountId)
principalsAvailability := e.SessionPrincipalAvailabilityAccountCapabilities()
tasks := e.SessionTasksAccountCapabilities()
tasksAlerts := e.SessionTasksAlertsAccountCapabilities()
tasksAssignees := e.SessionTasksAssigneesAccountCapabilities()
tasksRecurrences := e.SessionTasksRecurrencesAccountCapabilities()
tasksMultilingual := e.SessionTasksMultilingualAccountCapabilities()
tasksCustomTimezones := e.SessionTasksCustomTimezonesAccountCapabilities()
principalsOwner := e.SessionPrincipalsOwnerAccountCapabilities()
mdn := e.SessionMDNAccountCapabilities()
return SessionAccountCapabilities{
Mail: &mail,
Submission: &submission,
VacationResponse: &vacationResponse,
Sieve: &sieve,
Blob: &blob,
Quota: &quota,
Contacts: &contacts,
Calendars: &calendars,
CalendarsParse: &calendarsParse,
Principals: &principals,
PrincipalsAvailability: &principalsAvailability,
Tasks: &tasks,
TasksAlerts: &tasksAlerts,
TasksAssignees: &tasksAssignees,
TasksRecurrences: &tasksRecurrences,
TasksMultilingual: &tasksMultilingual,
TasksCustomTimezones: &tasksCustomTimezones,
PrincipalsOwner: &principalsOwner,
MDN: &mdn,
}
}
func (e Exampler) Account() (Account, string) {
return Account{
Name: e.Username,
IsPersonal: true,
IsReadOnly: false,
AccountCapabilities: e.SessionAccountCapabilities(),
}, "A personal account"
}
func (e Exampler) SharedAccount() (Account, string, string) {
return Account{
Name: e.SharedAccountId,
IsPersonal: false,
IsReadOnly: true,
AccountCapabilities: e.sessionAccountCapabilities(e.SharedAccountId),
}, "A read-only shared account", "shared"
}
func (e Exampler) Accounts() []Account {
a, _ := e.Account()
s, _, _ := e.SharedAccount()
return []Account{a, s}
}
func (e Exampler) Quota() Quota {
return Quota{
Id: e.QuotaId,
ResourceType: "octets",
Scope: "account",
Used: 11696865,
HardLimit: 20000000000,
Name: e.Username,
Types: []ObjectType{
EmailType,
SieveScriptType,
FileNodeType,
CalendarEventType,
ContactCardType,
},
Description: e.IdentityName,
SoftLimit: 19000000000,
WarnLimit: 10000000000,
}
}
func (e Exampler) Quotas() []Quota {
return []Quota{
e.Quota(),
{
Id: e.SharedQuotaId,
ResourceType: "octets",
Scope: "account",
Used: 29102918,
HardLimit: 50000000000,
Name: e.SharedAccountId,
Types: []ObjectType{
EmailType,
SieveScriptType,
FileNodeType,
CalendarEventType,
ContactCardType,
},
Description: e.SharedAccountName,
SoftLimit: 90000000000,
WarnLimit: 100000000000,
},
}
}
func (e Exampler) Identity() Identity {
return Identity{
Id: e.IdentityId,
Name: e.IdentityName,
Email: e.EmailAddress,
Bcc: &[]EmailAddress{
{Name: e.BccName, Email: e.BccAddress},
},
MayDelete: true,
TextSignature: &e.TextSignature,
}
}
func (e Exampler) OtherIdentity() (Identity, string, string) {
return Identity{
Id: e.OtherIdentityId,
Name: e.OtherIdentityName,
Email: e.OtherIdentityEmailAddress,
MayDelete: false,
}, "Another Identity", "other"
}
func (e Exampler) Identities() []Identity {
a := e.Identity()
b, _, _ := e.OtherIdentity()
return []Identity{a, b}
}
func (e Exampler) Identity_req() Identity {
return Identity{
Name: e.IdentityName,
Email: e.EmailAddress,
Bcc: &[]EmailAddress{
{Name: e.BccName, Email: e.BccAddress},
},
TextSignature: &e.TextSignature,
}
}
func (e Exampler) Thread() Thread {
return Thread{
Id: e.ThreadId,
EmailIds: e.EmailIds,
}
}
func (e Exampler) MailboxInbox() (Mailbox, string, string) {
return Mailbox{
Id: e.MailboxInboxId,
Name: "Inbox",
Role: JmapMailboxRoleInbox,
SortOrder: intPtr(0),
TotalEmails: 1291,
UnreadEmails: 82,
TotalThreads: 891,
UnreadThreads: 55,
MyRights: &MailboxRights{
MayReadItems: true,
MayAddItems: true,
MayRemoveItems: true,
MaySetSeen: true,
MaySetKeywords: true,
MayCreateChild: true,
MayRename: true,
MayDelete: true,
MaySubmit: true,
},
IsSubscribed: boolPtr(true),
}, "An Inbox Mailbox", "inbox"
}
func (e Exampler) MailboxInboxProjects() (Mailbox, string, string) {
return Mailbox{
Id: e.MailboxProjectId,
ParentId: e.MailboxInboxId,
Name: "Projects",
SortOrder: intPtr(0),
TotalEmails: 112,
UnreadEmails: 3,
TotalThreads: 85,
UnreadThreads: 2,
MyRights: &MailboxRights{
MayReadItems: true,
MayAddItems: true,
MayRemoveItems: true,
MaySetSeen: true,
MaySetKeywords: true,
MayCreateChild: true,
MayRename: true,
MayDelete: true,
MaySubmit: true,
},
IsSubscribed: boolPtr(true),
}, "A Projects Mailbox under the Inbox", "projects"
}
func (e Exampler) MailboxDrafts() (Mailbox, string, string) {
return Mailbox{
Id: e.MailboxDraftsId,
Name: "Drafts",
Role: JmapMailboxRoleDrafts,
SortOrder: intPtr(0),
TotalEmails: 12,
UnreadEmails: 1,
TotalThreads: 12,
UnreadThreads: 1,
MyRights: &MailboxRights{
MayReadItems: true,
MayAddItems: true,
MayRemoveItems: true,
MaySetSeen: true,
MaySetKeywords: true,
MayCreateChild: true,
MayRename: true,
MayDelete: true,
MaySubmit: true,
},
IsSubscribed: boolPtr(true),
}, "A Drafts Mailbox", "drafts"
}
func (e Exampler) MailboxSent() (Mailbox, string, string) {
return Mailbox{
Id: e.MailboxSentId,
Name: "Sent Items",
Role: JmapMailboxRoleSent,
SortOrder: intPtr(0),
TotalEmails: 1621,
UnreadEmails: 0,
TotalThreads: 1621,
UnreadThreads: 0,
MyRights: &MailboxRights{
MayReadItems: true,
MayAddItems: true,
MayRemoveItems: true,
MaySetSeen: true,
MaySetKeywords: true,
MayCreateChild: true,
MayRename: true,
MayDelete: true,
MaySubmit: true,
},
IsSubscribed: boolPtr(true),
}, "A Sent Mailbox", "sent"
}
func (e Exampler) MailboxJunk() (Mailbox, string, string) {
return Mailbox{
Id: e.MailboxJunkId,
Name: "Junk Mail",
Role: JmapMailboxRoleJunk,
SortOrder: intPtr(0),
TotalEmails: 251,
UnreadEmails: 0,
TotalThreads: 251,
UnreadThreads: 0,
MyRights: &MailboxRights{
MayReadItems: true,
MayAddItems: true,
MayRemoveItems: true,
MaySetSeen: true,
MaySetKeywords: true,
MayCreateChild: true,
MayRename: true,
MayDelete: true,
MaySubmit: true,
},
IsSubscribed: boolPtr(true),
}, "A Junk Mailbox", "junk"
}
func (e Exampler) MailboxDeleted() (Mailbox, string, string) {
return Mailbox{
Id: e.MailboxDeletedId,
Name: "Deleted Items",
Role: JmapMailboxRoleTrash,
SortOrder: intPtr(0),
TotalEmails: 99,
UnreadEmails: 0,
TotalThreads: 91,
UnreadThreads: 0,
MyRights: &MailboxRights{
MayReadItems: true,
MayAddItems: true,
MayRemoveItems: true,
MaySetSeen: true,
MaySetKeywords: true,
MayCreateChild: true,
MayRename: true,
MayDelete: true,
MaySubmit: true,
},
IsSubscribed: boolPtr(true),
}, "A Trash Mailbox", "deleted"
}
func (e Exampler) Mailboxes() []Mailbox {
a, _, _ := e.MailboxInbox()
b, _, _ := e.MailboxDrafts()
c, _, _ := e.MailboxSent()
d, _, _ := e.MailboxJunk()
f, _, _ := e.MailboxDeleted()
g, _, _ := e.MailboxInboxProjects()
return []Mailbox{a, b, c, d, f, g}
}
func SerializeExamples(e any) {
type example struct {
Type string `json:"type"`
Key string `json:"key,omitempty"`
Title string `json:"title,omitempty"`
Scope string `json:"scope,omitempty"`
Origin string `json:"origin,omitempty"`
Example any `json:"example"`
}
filename := os.Getenv("EXAMPLE_OUTPUT_FILE")
if filename == "" {
filename = "apidoc-examples.json"
}
funcs := map[string]func() (example, error){}
reflected := reflect.ValueOf(e)
r := reflect.TypeOf(e)
conflicts := map[string]example{}
for i := 0; i < r.NumMethod(); i++ {
name := r.Method(i).Name
m := reflected.MethodByName(name)
funcs[name] = func() (example, error) {
results := m.Call(nil)
title := ""
key := "default"
typ := ""
uniqueId := ""
var result reflect.Value
switch len(results) {
case 1:
result = results[0]
case 2:
result = results[0]
title = results[1].String()
case 3:
result = results[0]
title = results[1].String()
key = results[2].String()
case 4:
result = results[0]
title = results[1].String()
key = results[2].String()
typ = results[3].String()
default:
return example{}, fmt.Errorf("method result does not have 1 or 2 or 3 or 4 results but %d", len(results))
}
t := result.Type()
scope := "" // same as "any"
if strings.HasSuffix(name, "_req") {
scope = "request"
}
origin := fmt.Sprintf("%s:%s", r.String(), name)
if typ == "" {
typ = t.String()
}
if uniqueId == "" {
uniqueId = typ + "." + key
}
conflictKey := uniqueId + "/" + scope
if conflict, ok := conflicts[conflictKey]; ok {
panic(fmt.Errorf("conflicting examples with the same unique identifier '%s', consider adding a key to either of '%s' or '%s'", conflictKey, conflict.Origin, origin))
}
ex := example{
Type: typ,
Key: uniqueId,
Title: title,
Scope: scope,
Origin: origin,
Example: result.Interface(),
}
conflicts[conflictKey] = ex
return ex, nil
}
}
examples := []example{}
for name, f := range funcs {
if ex, err := f(); err != nil {
panic(fmt.Errorf("the example producing method '%s' produced an error: %w", name, err))
} else {
examples = append(examples, ex)
}
}
if b, err := json.MarshalIndent(examples, "", " "); err != nil {
panic(fmt.Errorf("failed to serialize to JSON: %w", err))
} else {
if err := os.WriteFile(filename, b, 0644); err != nil {
panic(fmt.Errorf("failed to write the serialized JSON output to the file '%s': %w", filename, err))
}
}
}

View File

@@ -0,0 +1,8 @@
//go:build groupware_examples
package jmap
func Example() {
SerializeExamples(ExamplerInstance)
//Output:
}

View File

@@ -334,3 +334,11 @@ func mapPairs[K comparable, L, R any](left map[K]L, right map[K]R) map[K]pair[L,
}
return result
}
func intPtr(i int) *int {
return &i
}
func boolPtr(b bool) *bool {
return &b
}

1
pkg/jscalendar/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/apidoc-examples.json

1
pkg/jscontact/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/apidoc-examples.json

View File

@@ -1,4 +1,5 @@
/swagger.yml
/openapi.yml
/api.html
/api.html.template
/node_modules

View File

@@ -21,8 +21,10 @@ node_modules:
pnpm install
.PHONY: swagger.yml
swagger.yml: apidoc.yml tsnode
swagger generate spec --include='groupware' --include='jmap' --include='jscalendar' --include='jscontact' --scan-models --input=$< | NODE_OPTIONS='--no-warnings --loader ts-node/esm' pnpm exec ts-node apidoc-process.ts > $@
swagger.yml: apidoc.yml tsnode openapi.yml
#swagger generate spec --include='groupware' --include='jmap' --include='jscalendar' --include='jscontact' --scan-models --input=$< | NODE_OPTIONS='--no-warnings --loader ts-node/esm' pnpm exec ts-node apidoc-process.ts > $@
#NODE_OPTIONS='--no-warnings --loader ts-node/esm' pnpm exec ts-node apidoc-process.ts > $@ < openapi.yml
cp openapi.yml $@
APIDOC_PORT=9999
@@ -37,3 +39,10 @@ api.html: swagger.yml favicon.png tsnode
.PHONY: apidoc-static
apidoc-static: api.html
.PHONY: examples
examples:
cd ../../pkg/jmap/ && go test -tags=groupware_examples . -v -count=1 -run Example
cd ../../pkg/jscontact/ && go test -tags=groupware_examples . -v -count=1 -run Example
cd ../../pkg/jscalendar/ && go test -tags=groupware_examples . -v -count=1 -run Example
cd ./pkg/groupware/ && go test -tags=groupware_examples . -v -count=1 -run Example

View File

@@ -1,7 +1,11 @@
openapi: 3.0.4
servers:
- url: https://localhost:9200/
description: Local Development Server
tags:
- name: untagged
x-displayName: Uncategorized
description: APIs that are not categorized yet
- name: bootstrap
x-displayName: Bootstrapping
description: Initialization APIs
@@ -37,7 +41,14 @@ tags:
description: APIs about tasks
- name: quota
x-displayName: Quota
description: APIs about quotas
description: |-
Quotas are objects that display the limits set to an account usage.
They also indicate the current usage in regard to those limits.
JMAP Quotas are defined in [RFC9524](https://www.rfc-editor.org/rfc/rfc9425.html).
This sectiong roups the APIs that pertain to retrieving and modifying quotas.
- name: vacation
x-displayName: Vacation Responses
description: APIs about vacation responses
@@ -69,6 +80,9 @@ x-tagGroups:
- name: Quotas
tags:
- quota
- name: Uncategorized
tags:
- untagged
components:
securitySchemes:
api:

View File

@@ -1,15 +1,15 @@
{
"dependencies": {
"@redocly/cli": "^2.4.0",
"@redocly/cli": "^2.14.5",
"@types/js-yaml": "^4.0.9",
"cheerio": "^1.1.2",
"js-yaml": "^4.1.0",
"js-yaml": "^4.1.1",
"ts-node": "^10.9.2",
"typescript": "^5.9.2"
"typescript": "^5.9.3"
},
"packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34",
"packageManager": "pnpm@10.28.0+sha512.05df71d1421f21399e053fde567cea34d446fa02c76571441bfc1c7956e98e363088982d940465fd34480d4d90a0668bc12362f8aa88000a64e83d0b0e47be48",
"type": "module",
"devDependencies": {
"@types/node": "^24.3.1"
"@types/node": "^24.10.7"
}
}

View File

@@ -0,0 +1 @@
/apidoc-examples.json

View File

@@ -33,7 +33,8 @@ func (g *Groupware) GetAccount(w http.ResponseWriter, r *http.Request) {
if err != nil {
return errorResponse(single(accountId), err)
}
return etagResponse(single(accountId), account, req.session.State, AccountResponseObjectType, jmap.State(req.session.State), "")
var body jmap.Account = account
return etagResponse(single(accountId), body, req.session.State, AccountResponseObjectType, jmap.State(req.session.State), "")
})
}
@@ -66,7 +67,8 @@ func (g *Groupware) GetAccounts(w http.ResponseWriter, r *http.Request) {
}
// sort on accountId to have a stable order that remains the same with every query
slices.SortFunc(list, func(a, b AccountWithId) int { return strings.Compare(a.AccountId, b.AccountId) })
return etagResponse(structs.Map(list, func(a AccountWithId) string { return a.AccountId }), list, req.session.State, AccountResponseObjectType, jmap.State(req.session.State), "")
var RBODY []AccountWithId = list
return etagResponse(structs.Map(list, func(a AccountWithId) string { return a.AccountId }), RBODY, req.session.State, AccountResponseObjectType, jmap.State(req.session.State), "")
})
}
@@ -94,7 +96,8 @@ func (g *Groupware) GetAccountsWithTheirIdentities(w http.ResponseWriter, r *htt
}
// sort on accountId to have a stable order that remains the same with every query
slices.SortFunc(list, func(a, b AccountWithIdAndIdentities) int { return strings.Compare(a.AccountId, b.AccountId) })
return etagResponse(structs.Map(list, func(a AccountWithIdAndIdentities) string { return a.AccountId }), list, sessionState, AccountResponseObjectType, state, lang)
var RBODY []AccountWithIdAndIdentities = list
return etagResponse(structs.Map(list, func(a AccountWithIdAndIdentities) string { return a.AccountId }), RBODY, sessionState, AccountResponseObjectType, state, lang)
})
}
@@ -108,34 +111,3 @@ type AccountWithIdAndIdentities struct {
jmap.Account
Identities []jmap.Identity `json:"identities,omitempty"`
}
type AccountBootstrapResponse struct {
// The API version.
Version string `json:"version"`
// A list of capabilities of this API version.
Capabilities []string `json:"capabilities"`
// API limits.
Limits IndexLimits `json:"limits"`
// Accounts that are available to the user.
//
// The key of the mapis the identifier.
Accounts map[string]IndexAccount `json:"accounts"`
// Primary accounts for usage types.
PrimaryAccounts IndexPrimaryAccounts `json:"primaryAccounts"`
// Mailboxes.
Mailboxes map[string][]jmap.Mailbox `json:"mailboxes"`
}
// When the request suceeds.
// swagger:response GetAccountBootstrapResponse200
type SwaggerAccountBootstrapResponse struct {
// in: body
Body struct {
*AccountBootstrapResponse
}
}

View File

@@ -1,12 +1,10 @@
package groupware
import (
"fmt"
"io"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"github.com/opencloud-eu/opencloud/pkg/log"
)
@@ -22,9 +20,9 @@ func (g *Groupware) GetBlobMeta(w http.ResponseWriter, r *http.Request) {
}
l := req.logger.With().Str(logAccountId, accountId)
blobId := chi.URLParam(req.r, UriParamBlobId)
if blobId == "" {
return req.parameterErrorResponse(single(accountId), UriParamBlobId, fmt.Sprintf("Invalid value for path parameter '%v': empty", UriParamBlobId))
blobId, err := req.PathParam(UriParamBlobId)
if err != nil {
return errorResponse(single(accountId), err)
}
l = l.Str(UriParamBlobId, blobId)
@@ -34,8 +32,7 @@ func (g *Groupware) GetBlobMeta(w http.ResponseWriter, r *http.Request) {
if jerr != nil {
return req.errorResponseFromJmap(single(accountId), jerr)
}
blob := res
if blob == nil {
if res == nil {
return notFoundResponse(single(accountId), sessionState)
}
return etagResponse(single(accountId), res, sessionState, BlobResponseObjectType, state, lang)
@@ -72,10 +69,15 @@ func (g *Groupware) UploadBlob(w http.ResponseWriter, r *http.Request) {
func (g *Groupware) DownloadBlob(w http.ResponseWriter, r *http.Request) {
g.stream(w, r, func(req Request, w http.ResponseWriter) *Error {
blobId := chi.URLParam(req.r, UriParamBlobId)
name := chi.URLParam(req.r, UriParamBlobName)
q := req.r.URL.Query()
typ := q.Get(QueryParamBlobType)
blobId, err := req.PathParam(UriParamBlobId)
if err != nil {
return err
}
name, err := req.PathParam(UriParamBlobName)
if err != nil {
return err
}
typ, _ := req.getStringParam(QueryParamBlobType, "")
accountId, gwerr := req.GetAccountIdForBlob()
if gwerr != nil {

View File

@@ -4,7 +4,6 @@ import (
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/opencloud-eu/opencloud/pkg/jmap"
"github.com/opencloud-eu/opencloud/pkg/log"
)
@@ -68,7 +67,10 @@ func (g *Groupware) GetCalendarById(w http.ResponseWriter, r *http.Request) {
l := req.logger.With()
calendarId := chi.URLParam(r, UriParamCalendarId)
calendarId, err := req.PathParam(UriParamCalendarId)
if err != nil {
return errorResponse(single(accountId), err)
}
l = l.Str(UriParamCalendarId, log.SafeString(calendarId))
logger := log.From(l)
@@ -110,7 +112,10 @@ func (g *Groupware) GetEventsInCalendar(w http.ResponseWriter, r *http.Request)
l := req.logger.With()
calendarId := chi.URLParam(r, UriParamCalendarId)
calendarId, err := req.PathParam(UriParamCalendarId)
if err != nil {
return errorResponse(single(accountId), err)
}
l = l.Str(UriParamCalendarId, log.SafeString(calendarId))
offset, ok, err := req.parseUIntParam(QueryParamOffset, 0)
@@ -157,9 +162,6 @@ func (g *Groupware) CreateCalendarEvent(w http.ResponseWriter, r *http.Request)
l := req.logger.With()
calendarId := chi.URLParam(r, UriParamCalendarId)
l = l.Str(UriParamCalendarId, log.SafeString(calendarId))
var create jmap.CalendarEvent
err := req.body(&create)
if err != nil {
@@ -175,6 +177,7 @@ func (g *Groupware) CreateCalendarEvent(w http.ResponseWriter, r *http.Request)
})
}
// @api:tag XYZ
func (g *Groupware) DeleteCalendarEvent(w http.ResponseWriter, r *http.Request) {
g.respond(w, r, func(req Request) Response {
ok, accountId, resp := req.needCalendarWithAccount()
@@ -183,9 +186,11 @@ func (g *Groupware) DeleteCalendarEvent(w http.ResponseWriter, r *http.Request)
}
l := req.logger.With().Str(accountId, log.SafeString(accountId))
calendarId := chi.URLParam(r, UriParamCalendarId)
eventId := chi.URLParam(r, UriParamEventId)
l.Str(UriParamCalendarId, log.SafeString(calendarId)).Str(UriParamEventId, log.SafeString(eventId))
eventId, err := req.PathParam(UriParamEventId)
if err != nil {
return errorResponse(single(accountId), err)
}
l.Str(UriParamEventId, log.SafeString(eventId))
logger := log.From(l)
@@ -220,7 +225,10 @@ func (g *Groupware) ParseIcalBlob(w http.ResponseWriter, r *http.Request) {
return errorResponse(single(accountId), err)
}
blobId := chi.URLParam(r, UriParamBlobId)
blobId, err := req.PathParam(UriParamBlobId)
if err != nil {
return errorResponse(single(accountId), err)
}
blobIds := strings.Split(blobId, ",")
l := req.logger.With().Array(UriParamBlobId, log.SafeStringArray(blobIds))

View File

@@ -3,7 +3,6 @@ package groupware
import (
"net/http"
"github.com/go-chi/chi/v5"
"github.com/opencloud-eu/opencloud/pkg/jmap"
"github.com/opencloud-eu/opencloud/pkg/jscontact"
"github.com/opencloud-eu/opencloud/pkg/log"
@@ -68,7 +67,10 @@ func (g *Groupware) GetAddressbook(w http.ResponseWriter, r *http.Request) {
l := req.logger.With()
addressBookId := chi.URLParam(r, UriParamAddressBookId)
addressBookId, err := req.PathParam(UriParamAddressBookId)
if err != nil {
return errorResponse(single(accountId), err)
}
l = l.Str(UriParamAddressBookId, log.SafeString(addressBookId))
logger := log.From(l)
@@ -110,7 +112,10 @@ func (g *Groupware) GetContactsInAddressbook(w http.ResponseWriter, r *http.Requ
l := req.logger.With()
addressBookId := chi.URLParam(r, UriParamAddressBookId)
addressBookId, err := req.PathParam(UriParamAddressBookId)
if err != nil {
return errorResponse(single(accountId), err)
}
l = l.Str(UriParamAddressBookId, log.SafeString(addressBookId))
offset, ok, err := req.parseUIntParam(QueryParamOffset, 0)
@@ -157,7 +162,10 @@ func (g *Groupware) GetContactById(w http.ResponseWriter, r *http.Request) {
l := req.logger.With()
contactId := chi.URLParam(r, UriParamContactId)
contactId, err := req.PathParam(UriParamContactId)
if err != nil {
return errorResponse(single(accountId), err)
}
l = l.Str(UriParamContactId, log.SafeString(contactId))
logger := log.From(l)
@@ -183,11 +191,14 @@ func (g *Groupware) CreateContact(w http.ResponseWriter, r *http.Request) {
l := req.logger.With()
addressBookId := chi.URLParam(r, UriParamAddressBookId)
addressBookId, err := req.PathParam(UriParamAddressBookId)
if err != nil {
return errorResponse(single(accountId), err)
}
l = l.Str(UriParamAddressBookId, log.SafeString(addressBookId))
var create jscontact.ContactCard
err := req.body(&create)
err = req.bodydoc(&create, "The contact to create, which may not have its id attribute set")
if err != nil {
return errorResponse(single(accountId), err)
}
@@ -209,7 +220,10 @@ func (g *Groupware) DeleteContact(w http.ResponseWriter, r *http.Request) {
}
l := req.logger.With().Str(accountId, log.SafeString(accountId))
contactId := chi.URLParam(r, UriParamContactId)
contactId, err := req.PathParam(UriParamContactId)
if err != nil {
return errorResponse(single(accountId), err)
}
l.Str(UriParamContactId, log.SafeString(contactId))
logger := log.From(l)

View File

@@ -11,7 +11,6 @@ import (
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/microcosm-cc/bluemonday"
"github.com/rs/zerolog"
@@ -39,6 +38,54 @@ type SwaggerGetAllEmailsInMailboxSince200 struct {
}
}
// swagger:route GET /groupware/accounts/{account}/mailboxes/{mailbox}/emails/since/{since} email get_all_emails_in_mailbox_since
// Get all the emails in a mailbox since a given state.
//
// Retrieve the list of all the emails that are in a given mailbox since a given state.
//
// The mailbox must be specified by its id, as part of the request URL path.
//
// A limit and an offset may be specified using the query parameters 'limit' and 'offset',
// respectively.
//
// responses:
//
// 200: GetAllEmailsInMailboxSince200
// 400: ErrorResponse400
// 404: ErrorResponse404
// 500: ErrorResponse500
func (g *Groupware) GetAllEmailsInMailboxSince(w http.ResponseWriter, r *http.Request) {
maxChanges := uint(0)
g.respond(w, r, func(req Request) Response {
accountId, err := req.GetAccountIdForMail()
if err != nil {
return errorResponse(single(accountId), err)
}
mailboxId, err := req.PathParam(UriParamMailboxId)
if err != nil {
return errorResponse(single(accountId), err)
}
since, err := req.PathParamDoc(UriParamSince, "State identifier that indicates the coordinate from whence on to list mailbox changes")
if err != nil {
return errorResponse(single(accountId), err)
}
logger := log.From(req.logger.With().Str(HeaderParamSince, log.SafeString(since)).Str(logAccountId, log.SafeString(accountId)))
changes, sessionState, state, lang, jerr := g.jmap.GetMailboxChanges(accountId, req.session, req.ctx, logger, req.language(), mailboxId, since, true, g.config.maxBodyValueBytes, maxChanges)
if jerr != nil {
return req.errorResponseFromJmap(single(accountId), jerr)
}
return etagResponse(single(accountId), changes, sessionState, EmailResponseObjectType, state, lang)
})
}
// swagger:route GET /groupware/accounts/{account}/mailboxes/{mailbox}/emails email get_all_emails_in_mailbox
// Get all the emails in a mailbox.
//
@@ -49,108 +96,79 @@ type SwaggerGetAllEmailsInMailboxSince200 struct {
// A limit and an offset may be specified using the query parameters 'limit' and 'offset',
// respectively.
//
// When the query parameter 'since' or the 'if-none-match' header is specified, then the
// request behaves differently, performing a changes query to determine what has changed in
// that mailbox since a given state identifier.
//
// responses:
//
// 200: GetAllEmailsInMailbox200
// 200: GetAllEmailsInMailboxSince200
// 400: ErrorResponse400
// 404: ErrorResponse404
// 500: ErrorResponse500
// 200: GetAllEmailsInMailbox200
// 400: ErrorResponse400
// 404: ErrorResponse404
// 500: ErrorResponse500
func (g *Groupware) GetAllEmailsInMailbox(w http.ResponseWriter, r *http.Request) {
mailboxId := chi.URLParam(r, UriParamMailboxId)
since := r.Header.Get(HeaderSince)
g.respond(w, r, func(req Request) Response {
l := req.logger.With()
if since != "" {
// ... then it's a completely different operation
maxChanges := uint(0)
g.respond(w, r, func(req Request) Response {
accountId, err := req.GetAccountIdForMail()
if err != nil {
return errorResponse(single(accountId), err)
}
accountId, err := req.GetAccountIdForMail()
if err != nil {
return errorResponse(single(accountId), err)
}
l = l.Str(logAccountId, accountId)
if mailboxId == "" {
return req.parameterErrorResponse(single(accountId), UriParamMailboxId, fmt.Sprintf("Missing required mailbox ID path parameter '%v'", UriParamMailboxId))
}
mailboxId, err := req.PathParam(UriParamMailboxId)
if err != nil {
return errorResponse(single(accountId), err)
}
logger := log.From(req.logger.With().Str(HeaderSince, log.SafeString(since)).Str(logAccountId, log.SafeString(accountId)))
offset, ok, err := req.parseIntParam(QueryParamOffset, 0)
if err != nil {
return errorResponse(single(accountId), err)
}
if ok {
l = l.Int(QueryParamOffset, offset)
}
changes, sessionState, state, lang, jerr := g.jmap.GetMailboxChanges(accountId, req.session, req.ctx, logger, req.language(), mailboxId, since, true, g.config.maxBodyValueBytes, maxChanges)
if jerr != nil {
return req.errorResponseFromJmap(single(accountId), jerr)
}
limit, ok, err := req.parseUIntParam(QueryParamLimit, g.defaults.emailLimit)
if err != nil {
return errorResponse(single(accountId), err)
}
if ok {
l = l.Uint(QueryParamLimit, limit)
}
return etagResponse(single(accountId), changes, sessionState, EmailResponseObjectType, state, lang)
})
} else {
g.respond(w, r, func(req Request) Response {
l := req.logger.With()
logger := log.From(l)
accountId, err := req.GetAccountIdForMail()
if err != nil {
return errorResponse(single(accountId), err)
}
l = l.Str(logAccountId, accountId)
collapseThreads := false
fetchBodies := false
withThreads := true
if mailboxId == "" {
return req.parameterErrorResponse(single(accountId), UriParamMailboxId, fmt.Sprintf("Missing required mailbox ID path parameter '%v'", UriParamMailboxId))
}
emails, sessionState, state, lang, jerr := g.jmap.GetAllEmailsInMailbox(accountId, req.session, req.ctx, logger, req.language(), mailboxId, offset, limit, collapseThreads, fetchBodies, g.config.maxBodyValueBytes, withThreads)
if jerr != nil {
return req.errorResponseFromJmap(single(accountId), jerr)
}
offset, ok, err := req.parseIntParam(QueryParamOffset, 0)
if err != nil {
return errorResponse(single(accountId), err)
}
if ok {
l = l.Int(QueryParamOffset, offset)
}
sanitized, err := req.sanitizeEmails(emails.Emails)
if err != nil {
return errorResponseWithSessionState(single(accountId), err, sessionState)
}
limit, ok, err := req.parseUIntParam(QueryParamLimit, g.defaults.emailLimit)
if err != nil {
return errorResponse(single(accountId), err)
}
if ok {
l = l.Uint(QueryParamLimit, limit)
}
safe := jmap.Emails{
Emails: sanitized,
Total: emails.Total,
Limit: emails.Limit,
Offset: emails.Offset,
}
logger := log.From(l)
collapseThreads := false
fetchBodies := false
withThreads := true
emails, sessionState, state, lang, jerr := g.jmap.GetAllEmailsInMailbox(accountId, req.session, req.ctx, logger, req.language(), mailboxId, offset, limit, collapseThreads, fetchBodies, g.config.maxBodyValueBytes, withThreads)
if jerr != nil {
return req.errorResponseFromJmap(single(accountId), jerr)
}
sanitized, err := req.sanitizeEmails(emails.Emails)
if err != nil {
return errorResponseWithSessionState(single(accountId), err, sessionState)
}
safe := jmap.Emails{
Emails: sanitized,
Total: emails.Total,
Limit: emails.Limit,
Offset: emails.Offset,
}
return etagResponse(single(accountId), safe, sessionState, EmailResponseObjectType, state, lang)
})
}
return etagResponse(single(accountId), safe, sessionState, EmailResponseObjectType, state, lang)
})
}
func (g *Groupware) GetEmailsById(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, UriParamEmailId)
ids := strings.Split(id, ",")
accept := r.Header.Get("Accept")
if accept == "message/rfc822" {
g.stream(w, r, func(req Request, w http.ResponseWriter) *Error {
id, err := req.PathParam(UriParamEmailId)
if err != nil {
return err
}
ids := strings.Split(id, ",")
if len(ids) != 1 {
return req.parameterError(UriParamEmailId, fmt.Sprintf("when the Accept header is set to '%s', the API only supports serving a single email id", accept))
}
@@ -194,6 +212,11 @@ func (g *Groupware) GetEmailsById(w http.ResponseWriter, r *http.Request) {
}
l := req.logger.With().Str(logAccountId, log.SafeString(accountId))
id, err := req.PathParam(UriParamEmailId)
if err != nil {
return errorResponse(single(accountId), err)
}
ids := strings.Split(id, ",")
if len(ids) < 1 {
return req.parameterErrorResponse(single(accountId), UriParamEmailId, fmt.Sprintf("Invalid value for path parameter '%v': '%s': %s", UriParamEmailId, log.SafeString(id), "empty list of mail ids"))
}
@@ -244,8 +267,6 @@ func (g *Groupware) GetEmailsById(w http.ResponseWriter, r *http.Request) {
}
func (g *Groupware) GetEmailAttachments(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, UriParamEmailId)
contextAppender := func(l zerolog.Context) zerolog.Context { return l }
q := r.URL.Query()
var attachmentSelector func(jmap.EmailBodyPart) bool = nil
@@ -274,6 +295,12 @@ func (g *Groupware) GetEmailAttachments(w http.ResponseWriter, r *http.Request)
return errorResponse(single(accountId), err)
}
l := req.logger.With().Str(logAccountId, log.SafeString(accountId))
id, err := req.PathParam(UriParamEmailId)
if err != nil {
return errorResponse(single(accountId), err)
}
logger := log.From(l)
emails, _, sessionState, state, lang, jerr := g.jmap.GetEmails(accountId, req.session, req.ctx, logger, req.language(), []string{id}, false, 0, false, false)
if jerr != nil {
@@ -286,20 +313,29 @@ func (g *Groupware) GetEmailAttachments(w http.ResponseWriter, r *http.Request)
if err != nil {
return errorResponseWithSessionState(single(accountId), err, sessionState)
}
return etagResponse(single(accountId), email.Attachments, sessionState, EmailResponseObjectType, state, lang)
var body []jmap.EmailBodyPart = email.Attachments
return etagResponse(single(accountId), body, sessionState, EmailResponseObjectType, state, lang)
})
} else {
g.stream(w, r, func(req Request, w http.ResponseWriter) *Error {
mailAccountId, gwerr := req.GetAccountIdForMail()
if gwerr != nil {
return gwerr
mailAccountId, err := req.GetAccountIdForMail()
if err != nil {
return err
}
blobAccountId, gwerr := req.GetAccountIdForBlob()
if gwerr != nil {
return gwerr
blobAccountId, err := req.GetAccountIdForBlob()
if err != nil {
return err
}
l := req.logger.With().Str(logAccountId, log.SafeString(mailAccountId)).Str(logBlobAccountId, log.SafeString(blobAccountId))
id, err := req.PathParam(UriParamEmailId)
if err != nil {
return err
}
l := req.logger.With().
Str(logAccountId, log.SafeString(mailAccountId)).
Str(logBlobAccountId, log.SafeString(blobAccountId)).
Str(UriParamEmailId, log.SafeString(id))
l = contextAppender(l)
logger := log.From(l)
@@ -434,25 +470,30 @@ type EmailSearchResults struct {
QueryState jmap.State `json:"queryState,omitempty"`
}
func (g *Groupware) buildFilter(req Request) (bool, jmap.EmailFilterElement, bool, int, uint, *log.Logger, *Error) {
q := req.r.URL.Query()
mailboxId := q.Get(QueryParamMailboxId)
notInMailboxIds := q[QueryParamNotInMailboxId]
text := q.Get(QueryParamSearchText)
from := q.Get(QueryParamSearchFrom)
to := q.Get(QueryParamSearchTo)
cc := q.Get(QueryParamSearchCc)
bcc := q.Get(QueryParamSearchBcc)
subject := q.Get(QueryParamSearchSubject)
body := q.Get(QueryParamSearchBody)
keywords := q[QueryParamSearchKeyword]
messageId := q.Get(QueryParamSearchMessageId)
func (g *Groupware) buildEmailFilter(req Request) (bool, jmap.EmailFilterElement, bool, int, uint, *log.Logger, *Error) {
mailboxId, _ := req.getStringParam(QueryParamMailboxId, "") // the identifier of the Mailbox to which to restrict the search
text, _ := req.getStringParam(QueryParamSearchText, "") // text that must be included in the Email, specifically in From, To, Cc, Bcc, Subject and any text/* body part
from, _ := req.getStringParam(QueryParamSearchFrom, "") // text that must be included in the From header of the Email
to, _ := req.getStringParam(QueryParamSearchTo, "") // text that must be included in the To header of the Email
cc, _ := req.getStringParam(QueryParamSearchCc, "") // text that must be included in the Cc header of the Email
bcc, _ := req.getStringParam(QueryParamSearchBcc, "") // text that must be included in the Bcc header of the Email
subject, _ := req.getStringParam(QueryParamSearchSubject, "") // text that must be included in the Subject of the Email
body, _ := req.getStringParam(QueryParamSearchBody, "") // text that must be included in any text/* part of the body of the Email
messageId, _ := req.getStringParam(QueryParamSearchMessageId, "") // value of the Message-ID header of the Email
notInMailboxIds, _, err := req.parseOptStringListParam(QueryParamNotInMailboxId) // a comma-separated list of identifiers of Mailboxes the Email must *not* be in
if err != nil {
return false, nil, false, 0, 0, nil, err
}
keywords, _, err := req.parseOptStringListParam(QueryParamSearchKeyword) // the Email must have all those keywords
if err != nil {
return false, nil, false, 0, 0, nil, err
}
snippets := false
l := req.logger.With()
offset, ok, err := req.parseIntParam(QueryParamOffset, 0)
offset, ok, err := req.parseIntParam(QueryParamOffset, 0) // pagination element offset
if err != nil {
return false, nil, snippets, 0, 0, nil, err
}
@@ -460,7 +501,7 @@ func (g *Groupware) buildFilter(req Request) (bool, jmap.EmailFilterElement, boo
l = l.Int(QueryParamOffset, offset)
}
limit, ok, err := req.parseUIntParam(QueryParamLimit, g.defaults.emailLimit)
limit, ok, err := req.parseUIntParam(QueryParamLimit, g.defaults.emailLimit) // maximum number of results (size of a page)
if err != nil {
return false, nil, snippets, 0, 0, nil, err
}
@@ -468,7 +509,7 @@ func (g *Groupware) buildFilter(req Request) (bool, jmap.EmailFilterElement, boo
l = l.Uint(QueryParamLimit, limit)
}
before, ok, err := req.parseDateParam(QueryParamSearchBefore)
before, ok, err := req.parseDateParam(QueryParamSearchBefore) // the Email must have been received before this date-time
if err != nil {
return false, nil, snippets, 0, 0, nil, err
}
@@ -476,7 +517,7 @@ func (g *Groupware) buildFilter(req Request) (bool, jmap.EmailFilterElement, boo
l = l.Time(QueryParamSearchBefore, before)
}
after, ok, err := req.parseDateParam(QueryParamSearchAfter)
after, ok, err := req.parseDateParam(QueryParamSearchAfter) // the Email must have been received after this date-time
if err != nil {
return false, nil, snippets, 0, 0, nil, err
}
@@ -515,7 +556,7 @@ func (g *Groupware) buildFilter(req Request) (bool, jmap.EmailFilterElement, boo
l = l.Str(QueryParamSearchMessageId, log.SafeString(messageId))
}
minSize, ok, err := req.parseIntParam(QueryParamSearchMinSize, 0)
minSize, ok, err := req.parseIntParam(QueryParamSearchMinSize, 0) // the minimum size of the Email
if err != nil {
return false, nil, snippets, 0, 0, nil, err
}
@@ -523,7 +564,7 @@ func (g *Groupware) buildFilter(req Request) (bool, jmap.EmailFilterElement, boo
l = l.Int(QueryParamSearchMinSize, minSize)
}
maxSize, ok, err := req.parseIntParam(QueryParamSearchMaxSize, 0)
maxSize, ok, err := req.parseIntParam(QueryParamSearchMaxSize, 0) // the maximum size of the Email
if err != nil {
return false, nil, snippets, 0, 0, nil, err
}
@@ -586,7 +627,7 @@ func (g *Groupware) GetEmails(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
since := q.Get(QueryParamSince)
if since == "" {
since = r.Header.Get(HeaderSince)
since = r.Header.Get(HeaderParamSince)
}
if since != "" {
// get email changes since a given state
@@ -599,7 +640,7 @@ func (g *Groupware) GetEmails(w http.ResponseWriter, r *http.Request) {
return errorResponse(single(accountId), err)
}
ok, filter, makesSnippets, offset, limit, logger, err := g.buildFilter(req)
ok, filter, makesSnippets, offset, limit, logger, err := g.buildEmailFilter(req)
if !ok {
return errorResponse(single(accountId), err)
}
@@ -658,7 +699,7 @@ func (g *Groupware) GetEmailsForAllAccounts(w http.ResponseWriter, r *http.Reque
g.respond(w, r, func(req Request) Response {
allAccountIds := req.AllAccountIds()
ok, filter, makesSnippets, offset, limit, logger, err := g.buildFilter(req)
ok, filter, makesSnippets, offset, limit, logger, err := g.buildEmailFilter(req)
if !ok {
return errorResponse(allAccountIds, err)
}
@@ -698,12 +739,14 @@ func (g *Groupware) GetEmailsForAllAccounts(w http.ResponseWriter, r *http.Reque
// TODO offset and limit over the aggregated results by account
return etagResponse(allAccountIds, EmailSearchSnippetsResults{
body := EmailSearchSnippetsResults{
Results: flattened,
Total: totalOverAllAccounts,
Limit: limit,
QueryState: state,
}, sessionState, EmailResponseObjectType, state, lang)
}
return etagResponse(allAccountIds, body, sessionState, EmailResponseObjectType, state, lang)
} else {
withThreads := true
@@ -735,12 +778,14 @@ func (g *Groupware) GetEmailsForAllAccounts(w http.ResponseWriter, r *http.Reque
// TODO offset and limit over the aggregated results by account
return etagResponse(allAccountIds, EmailSearchResults{
body := EmailSearchResults{
Results: flattened,
Total: totalAcrossAllAccounts,
Limit: limit,
QueryState: state,
}, sessionState, EmailResponseObjectType, state, lang)
}
return etagResponse(allAccountIds, body, sessionState, EmailResponseObjectType, state, lang)
}
})
}
@@ -845,12 +890,15 @@ func (g *Groupware) ReplaceEmail(w http.ResponseWriter, r *http.Request) {
return errorResponse(single(accountId), gwerr)
}
replaceId := chi.URLParam(r, UriParamEmailId)
replaceId, err := req.PathParam(UriParamEmailId)
if err != nil {
return errorResponse(single(accountId), err)
}
logger = log.From(logger.With().Str(logAccountId, log.SafeString(accountId)))
var body jmap.EmailCreate
err := req.body(&body)
err = req.body(&body)
if err != nil {
return errorResponse(single(accountId), err)
}
@@ -883,10 +931,7 @@ type SwaggerUpdateEmailBody struct {
func (g *Groupware) UpdateEmail(w http.ResponseWriter, r *http.Request) {
g.respond(w, r, func(req Request) Response {
emailId := chi.URLParam(r, UriParamEmailId)
l := req.logger.With()
l.Str(UriParamEmailId, log.SafeString(emailId))
accountId, gwerr := req.GetAccountIdForMail()
if gwerr != nil {
@@ -894,10 +939,16 @@ func (g *Groupware) UpdateEmail(w http.ResponseWriter, r *http.Request) {
}
l.Str(logAccountId, accountId)
emailId, err := req.PathParam(UriParamEmailId)
if err != nil {
return errorResponse(single(accountId), err)
}
l.Str(UriParamEmailId, log.SafeString(emailId))
logger := log.From(l)
var body map[string]any
err := req.body(&body)
err = req.body(&body)
if err != nil {
return errorResponse(single(accountId), err)
}
@@ -936,10 +987,7 @@ func (e emailKeywordUpdates) IsEmpty() bool {
func (g *Groupware) UpdateEmailKeywords(w http.ResponseWriter, r *http.Request) {
g.respond(w, r, func(req Request) Response {
emailId := chi.URLParam(r, UriParamEmailId)
l := req.logger.With()
l.Str(UriParamEmailId, log.SafeString(emailId))
accountId, gwerr := req.GetAccountIdForMail()
if gwerr != nil {
@@ -947,10 +995,16 @@ func (g *Groupware) UpdateEmailKeywords(w http.ResponseWriter, r *http.Request)
}
l.Str(logAccountId, accountId)
emailId, err := req.PathParam(UriParamEmailId)
if err != nil {
return errorResponse(single(accountId), err)
}
l.Str(UriParamEmailId, log.SafeString(emailId))
logger := log.From(l)
var body emailKeywordUpdates
err := req.body(&body)
err = req.body(&body)
if err != nil {
return errorResponse(single(accountId), err)
}
@@ -1000,10 +1054,7 @@ func (g *Groupware) UpdateEmailKeywords(w http.ResponseWriter, r *http.Request)
// 500: ErrorResponse500
func (g *Groupware) AddEmailKeywords(w http.ResponseWriter, r *http.Request) {
g.respond(w, r, func(req Request) Response {
emailId := chi.URLParam(r, UriParamEmailId)
l := req.logger.With()
l.Str(UriParamEmailId, log.SafeString(emailId))
accountId, gwerr := req.GetAccountIdForMail()
if gwerr != nil {
@@ -1011,10 +1062,16 @@ func (g *Groupware) AddEmailKeywords(w http.ResponseWriter, r *http.Request) {
}
l.Str(logAccountId, accountId)
emailId, err := req.PathParam(UriParamEmailId)
if err != nil {
return errorResponse(single(accountId), err)
}
l.Str(UriParamEmailId, log.SafeString(emailId))
logger := log.From(l)
var body []string
err := req.body(&body)
err = req.body(&body)
if err != nil {
return errorResponse(single(accountId), err)
}
@@ -1065,21 +1122,24 @@ func (g *Groupware) AddEmailKeywords(w http.ResponseWriter, r *http.Request) {
// 500: ErrorResponse500
func (g *Groupware) RemoveEmailKeywords(w http.ResponseWriter, r *http.Request) {
g.respond(w, r, func(req Request) Response {
emailId := chi.URLParam(r, UriParamEmailId)
l := req.logger.With()
l.Str(UriParamEmailId, log.SafeString(emailId))
accountId, gwerr := req.GetAccountIdForMail()
if gwerr != nil {
return errorResponse(single(accountId), gwerr)
accountId, err := req.GetAccountIdForMail()
if err != nil {
return errorResponse(single(accountId), err)
}
l.Str(logAccountId, accountId)
emailId, err := req.PathParam(UriParamEmailId)
if err != nil {
return errorResponse(single(accountId), err)
}
l.Str(UriParamEmailId, log.SafeString(emailId))
logger := log.From(l)
var body []string
err := req.body(&body)
err = req.body(&body)
if err != nil {
return errorResponse(single(accountId), err)
}
@@ -1130,16 +1190,19 @@ func (g *Groupware) RemoveEmailKeywords(w http.ResponseWriter, r *http.Request)
// 500: ErrorResponse500
func (g *Groupware) DeleteEmail(w http.ResponseWriter, r *http.Request) {
g.respond(w, r, func(req Request) Response {
emailId := chi.URLParam(r, UriParamEmailId)
l := req.logger.With()
l.Str(UriParamEmailId, emailId)
accountId, gwerr := req.GetAccountIdForMail()
if gwerr != nil {
return errorResponse(single(accountId), gwerr)
}
l.Str(logAccountId, accountId)
l.Str(logAccountId, log.SafeString(accountId))
emailId, err := req.PathParam(UriParamEmailId)
if err != nil {
return errorResponse(single(accountId), err)
}
l.Str(UriParamEmailId, log.SafeString(emailId))
logger := log.From(l)
@@ -1188,6 +1251,7 @@ type SwaggerDeleteEmailsBody struct {
// 404: ErrorResponse404
// 500: ErrorResponse500
func (g *Groupware) DeleteEmails(w http.ResponseWriter, r *http.Request) {
/// @api body
g.respond(w, r, func(req Request) Response {
l := req.logger.With()
@@ -1237,7 +1301,10 @@ func (g *Groupware) SendEmail(w http.ResponseWriter, r *http.Request) {
}
l.Str(logAccountId, accountId)
emailId := chi.URLParam(r, UriParamEmailId)
emailId, err := req.PathParam(UriParamEmailId)
if err != nil {
return errorResponse(single(accountId), err)
}
l.Str(UriParamEmailId, log.SafeString(emailId))
identityId, err := req.getMandatoryStringParam(QueryParamIdentityId)
@@ -1333,10 +1400,8 @@ func relatedEmailsFilter(email jmap.Email, beacon time.Time, days uint) jmap.Ema
}
func (g *Groupware) RelatedToEmail(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, UriParamEmailId)
g.respond(w, r, func(req Request) Response {
l := req.logger.With().Str(logEmailId, log.SafeString(id))
l := req.logger.With()
accountId, gwerr := req.GetAccountIdForMail()
if gwerr != nil {
@@ -1344,6 +1409,12 @@ func (g *Groupware) RelatedToEmail(w http.ResponseWriter, r *http.Request) {
}
l = l.Str(logAccountId, log.SafeString(accountId))
id, err := req.PathParam(UriParamEmailId)
if err != nil {
return errorResponse(single(accountId), err)
}
l = l.Str(logEmailId, log.SafeString(id))
limit, ok, err := req.parseUIntParam(QueryParamLimit, 10) // TODO configurable default limit
if err != nil {
return errorResponse(single(accountId), err)
@@ -1715,7 +1786,7 @@ func (g *Groupware) GetLatestEmailsSummaryForAllAccounts(w http.ResponseWriter,
return errorResponse(allAccountIds, err)
}
if offset > 0 {
return notImplementesResponse()
return notImplementedResponse()
}
if ok {
l = l.Uint(QueryParamOffset, limit)

View File

@@ -5,7 +5,6 @@ import (
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/opencloud-eu/opencloud/pkg/jmap"
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/opencloud/pkg/structs"
@@ -48,7 +47,10 @@ func (g *Groupware) GetIdentityById(w http.ResponseWriter, r *http.Request) {
if err != nil {
return errorResponse(single(accountId), err)
}
id := chi.URLParam(r, UriParamIdentityId)
id, err := req.PathParam(UriParamIdentityId)
if err != nil {
return errorResponse(single(accountId), err)
}
logger := log.From(req.logger.With().Str(logAccountId, accountId).Str(logIdentityId, id))
res, sessionState, state, lang, jerr := g.jmap.GetIdentities(accountId, req.session, req.ctx, logger, req.language(), []string{id})
if jerr != nil {
@@ -57,7 +59,8 @@ func (g *Groupware) GetIdentityById(w http.ResponseWriter, r *http.Request) {
if len(res) < 1 {
return notFoundResponse(single(accountId), sessionState)
}
return etagResponse(single(accountId), res[0], sessionState, IdentityResponseObjectType, state, lang)
var body jmap.Identity = res[0]
return etagResponse(single(accountId), body, sessionState, IdentityResponseObjectType, state, lang)
})
}
@@ -105,6 +108,7 @@ func (g *Groupware) ModifyIdentity(w http.ResponseWriter, r *http.Request) {
})
}
// Delete an identity.
func (g *Groupware) DeleteIdentity(w http.ResponseWriter, r *http.Request) {
g.respond(w, r, func(req Request) Response {
accountId, err := req.GetAccountIdForMail()
@@ -113,10 +117,13 @@ func (g *Groupware) DeleteIdentity(w http.ResponseWriter, r *http.Request) {
}
logger := log.From(req.logger.With().Str(logAccountId, accountId))
id := chi.URLParam(r, UriParamIdentityId)
id, err := req.PathParam(UriParamIdentityId)
if err != nil {
return errorResponse(single(accountId), err)
}
ids := strings.Split(id, ",")
if len(ids) < 1 {
return req.parameterErrorResponse(single(accountId), UriParamEmailId, fmt.Sprintf("Invalid value for path parameter '%v': '%s': %s", UriParamIdentityId, log.SafeString(id), "empty list of identity ids"))
return req.parameterErrorResponse(single(accountId), UriParamIdentityId, fmt.Sprintf("Invalid value for path parameter '%v': '%s': %s", UriParamIdentityId, log.SafeString(id), "empty list of identity ids"))
}
deletion, sessionState, state, _, jerr := g.jmap.DeleteIdentity(accountId, req.session, req.ctx, logger, req.language(), ids)

View File

@@ -153,6 +153,7 @@ type SwaggerIndexResponse struct {
// swagger:route GET /groupware bootstrap index
// Get initial bootstrapping information for a user.
// @api:tag bootstrap
//
// responses:
//
@@ -166,13 +167,14 @@ func (g *Groupware) Index(w http.ResponseWriter, r *http.Request) {
return req.errorResponseFromJmap(accountIds, err)
}
return etagResponse(accountIds, IndexResponse{
var RBODY IndexResponse = IndexResponse{
Version: Version,
Capabilities: Capabilities,
Limits: buildIndexLimits(req.session),
Accounts: buildIndexAccounts(req.session, boot),
PrimaryAccounts: buildIndexPrimaryAccounts(req.session),
}, sessionState, IndexResponseObjectType, state, lang)
}
return etagResponse(accountIds, RBODY, sessionState, IndexResponseObjectType, state, lang)
})
}

View File

@@ -5,7 +5,6 @@ import (
"slices"
"strings"
"github.com/go-chi/chi/v5"
"github.com/rs/zerolog"
"github.com/opencloud-eu/opencloud/pkg/jmap"
@@ -35,13 +34,17 @@ type SwaggerGetMailboxById200 struct {
// 404: ErrorResponse404
// 500: ErrorResponse500
func (g *Groupware) GetMailbox(w http.ResponseWriter, r *http.Request) {
mailboxId := chi.URLParam(r, UriParamMailboxId)
g.respond(w, r, func(req Request) Response {
accountId, err := req.GetAccountIdForMail()
if err != nil {
return errorResponse(single(accountId), err)
}
mailboxId, err := req.PathParam(UriParamMailboxId)
if err != nil {
return errorResponse(single(accountId), err)
}
mailboxes, sessionState, state, lang, jerr := g.jmap.GetMailbox(accountId, req.session, req.ctx, req.logger, req.language(), []string{mailboxId})
if jerr != nil {
return req.errorResponseFromJmap(single(accountId), jerr)
@@ -92,22 +95,21 @@ type SwaggerMailboxesResponse200 struct {
// 400: ErrorResponse400
// 500: ErrorResponse500
func (g *Groupware) GetMailboxes(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
var filter jmap.MailboxFilterCondition
hasCriteria := false
name := q.Get(QueryParamMailboxSearchName)
if name != "" {
filter.Name = name
hasCriteria = true
}
role := q.Get(QueryParamMailboxSearchRole)
if role != "" {
filter.Role = role
hasCriteria = true
}
g.respond(w, r, func(req Request) Response {
var filter jmap.MailboxFilterCondition
hasCriteria := false
name, ok := req.getStringParam(QueryParamMailboxSearchName, "") // the mailbox name to filter on
if ok && name != "" {
filter.Name = name
hasCriteria = true
}
role, ok := req.getStringParam(QueryParamMailboxSearchRole, "") // the mailbox role to filter on
if role != "" {
filter.Role = role
hasCriteria = true
}
accountId, err := req.GetAccountIdForMail()
if err != nil {
return errorResponse(single(accountId), err)
@@ -157,8 +159,7 @@ type SwaggerMailboxesForAllAccountsResponse200 struct {
}
// swagger:route GET /groupware/accounts/all/mailboxes mailboxesforallaccounts mailbox
// Get the list of all the mailboxes of all accounts of a user, potentially filtering on the
// role of the mailboxes.
// Get the list of all the mailboxes of all accounts of a user, potentially filtering on the role of the mailboxes.
//
// responses:
//
@@ -166,28 +167,24 @@ type SwaggerMailboxesForAllAccountsResponse200 struct {
// 400: ErrorResponse400
// 500: ErrorResponse500
func (g *Groupware) GetMailboxesForAllAccounts(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
var filter jmap.MailboxFilterCondition
hasCriteria := false
role := q.Get(QueryParamMailboxSearchRole)
if role != "" {
filter.Role = role
hasCriteria = true
}
g.respond(w, r, func(req Request) Response {
accountIds := req.AllAccountIds()
if len(accountIds) < 1 {
return noContentResponse(nil, "")
return noContentResponse(nil, "") // when the user has no accounts
}
logger := log.From(req.logger.With().Array(logAccountId, log.SafeStringArray(accountIds)))
subscribed, set, err := req.parseBoolParam(QueryParamMailboxSearchSubscribed, false)
if err != nil {
return errorResponse(accountIds, err)
var filter jmap.MailboxFilterCondition
hasCriteria := false
if role, set := req.getStringParam(QueryParamMailboxSearchRole, ""); set {
filter.Role = role
hasCriteria = true
}
if set {
if subscribed, set, err := req.parseBoolParam(QueryParamMailboxSearchSubscribed, false); err != nil {
return errorResponse(accountIds, err)
} else if set {
filter.IsSubscribed = &subscribed
hasCriteria = true
}
@@ -208,22 +205,28 @@ func (g *Groupware) GetMailboxesForAllAccounts(w http.ResponseWriter, r *http.Re
})
}
// Retrieve Mailboxes by their role for all accounts.
func (g *Groupware) GetMailboxByRoleForAllAccounts(w http.ResponseWriter, r *http.Request) {
role := chi.URLParam(r, UriParamRole)
g.respond(w, r, func(req Request) Response {
accountIds := req.AllAccountIds()
if len(accountIds) < 1 {
return noContentResponse(nil, "")
return noContentResponse(nil, "") // when the user has no accounts
}
role, err := req.PathParamDoc(UriParamRole, "Role of the mailboxes to retrieve across all accounts")
if err != nil {
return errorResponse(accountIds, err)
}
logger := log.From(req.logger.With().Array(logAccountId, log.SafeStringArray(accountIds)).Str("role", role))
filter := jmap.MailboxFilterCondition{
Role: role,
}
mailboxesByAccountId, sessionState, state, lang, err := g.jmap.SearchMailboxes(accountIds, req.session, req.ctx, logger, req.language(), filter)
if err != nil {
return req.errorResponseFromJmap(accountIds, err)
mailboxesByAccountId, sessionState, state, lang, jerr := g.jmap.SearchMailboxes(accountIds, req.session, req.ctx, logger, req.language(), filter)
if jerr != nil {
return req.errorResponseFromJmap(accountIds, jerr)
}
return etagResponse(accountIds, sortMailboxesMap(mailboxesByAccountId), sessionState, MailboxResponseObjectType, state, lang)
})
@@ -245,11 +248,8 @@ type SwaggerMailboxChangesResponse200 struct {
// 400: ErrorResponse400
// 500: ErrorResponse500
func (g *Groupware) GetMailboxChanges(w http.ResponseWriter, r *http.Request) {
mailboxId := chi.URLParam(r, UriParamMailboxId)
sinceState := r.Header.Get(HeaderSince)
g.respond(w, r, func(req Request) Response {
l := req.logger.With().Str(HeaderSince, sinceState)
l := req.logger.With()
accountId, err := req.GetAccountIdForMail()
if err != nil {
@@ -257,6 +257,11 @@ func (g *Groupware) GetMailboxChanges(w http.ResponseWriter, r *http.Request) {
}
l = l.Str(logAccountId, accountId)
mailboxId, err := req.PathParam(UriParamMailboxId)
if err != nil {
return errorResponse(single(accountId), err)
}
maxChanges, ok, err := req.parseUIntParam(QueryParamMaxChanges, 0)
if err != nil {
return errorResponse(single(accountId), err)
@@ -265,6 +270,12 @@ func (g *Groupware) GetMailboxChanges(w http.ResponseWriter, r *http.Request) {
l = l.Uint(QueryParamMaxChanges, maxChanges)
}
sinceState, err := req.HeaderParamDoc(HeaderParamSince, "Specifies the state identifier from which on to list mailbox changes")
if err != nil {
return errorResponse(single(accountId), err)
}
l = l.Str(HeaderParamSince, log.SafeString(sinceState))
logger := log.From(l)
changes, sessionState, state, lang, jerr := g.jmap.GetMailboxChanges(accountId, req.session, req.ctx, logger, req.language(), mailboxId, sinceState, true, g.config.maxBodyValueBytes, maxChanges)
@@ -329,6 +340,8 @@ func (g *Groupware) GetMailboxChangesForAllAccounts(w http.ResponseWriter, r *ht
})
}
// Retrieve the roles of all the Mailboxes of all Accounts.
// @api:example mailboxrolesbyaccount
func (g *Groupware) GetMailboxRoles(w http.ResponseWriter, r *http.Request) {
g.respond(w, r, func(req Request) Response {
l := req.logger.With()
@@ -346,10 +359,8 @@ func (g *Groupware) GetMailboxRoles(w http.ResponseWriter, r *http.Request) {
}
func (g *Groupware) UpdateMailbox(w http.ResponseWriter, r *http.Request) {
mailboxId := chi.URLParam(r, UriParamMailboxId)
g.respond(w, r, func(req Request) Response {
l := req.logger.With().Str(UriParamMailboxId, log.SafeString(mailboxId))
l := req.logger.With()
accountId, err := req.GetAccountIdForMail()
if err != nil {
@@ -357,6 +368,12 @@ func (g *Groupware) UpdateMailbox(w http.ResponseWriter, r *http.Request) {
}
l = l.Str(logAccountId, accountId)
mailboxId, err := req.PathParamDoc(UriParamMailboxId, "the identifier of the mailbox to update")
if err != nil {
return errorResponse(single(accountId), err)
}
l = l.Str(UriParamMailboxId, log.SafeString(mailboxId))
var body jmap.MailboxChange
err = req.body(&body)
if err != nil {
@@ -398,10 +415,12 @@ func (g *Groupware) CreateMailbox(w http.ResponseWriter, r *http.Request) {
})
}
// Delete Mailboxes by their unique identifiers.
//
// Returns the identifiers of the Mailboxes that have successfully been deleted.
//
// @api:example deletedmailboxes
func (g *Groupware) DeleteMailbox(w http.ResponseWriter, r *http.Request) {
mailboxId := chi.URLParam(r, UriParamMailboxId)
mailboxIds := strings.Split(mailboxId, ",")
g.respond(w, r, func(req Request) Response {
l := req.logger.With()
accountId, err := req.GetAccountIdForMail()
@@ -410,11 +429,16 @@ func (g *Groupware) DeleteMailbox(w http.ResponseWriter, r *http.Request) {
}
l = l.Str(logAccountId, accountId)
mailboxIds, err := req.PathListParamDoc(UriParamMailboxId, "the identifier of the mailbox to delete")
if err != nil {
return errorResponse(single(accountId), err)
}
l = l.Array(UriParamMailboxId, log.SafeStringArray(mailboxIds))
if len(mailboxIds) < 1 {
return noContentResponse(single(accountId), req.session.State)
return noContentResponse(single(accountId), req.session.State) // no mailbox identifiers were mentioned in the request
}
l = l.Array(UriParamMailboxId, log.SafeStringArray(mailboxIds))
logger := log.From(l)
deleted, sessionState, state, lang, jerr := g.jmap.DeleteMailboxes(accountId, req.session, req.ctx, logger, req.language(), "", mailboxIds)
@@ -441,8 +465,8 @@ func scoreMailbox(m jmap.Mailbox) int {
return 1000
}
func sortMailboxesMap[K comparable](mailboxesByAccountId map[K][]jmap.Mailbox) map[K][]jmap.Mailbox {
sortedByAccountId := make(map[K][]jmap.Mailbox, len(mailboxesByAccountId))
func sortMailboxesMap(mailboxesByAccountId map[string][]jmap.Mailbox) map[string][]jmap.Mailbox {
sortedByAccountId := make(map[string][]jmap.Mailbox, len(mailboxesByAccountId))
for accountId, unsorted := range mailboxesByAccountId {
mailboxes := make([]jmap.Mailbox, len(unsorted))
copy(mailboxes, unsorted)

View File

@@ -17,6 +17,10 @@ type SwaggerGetQuotaResponse200 struct {
// swagger:route GET /groupware/accounts/{account}/quota quota get_quota
// Get quota limits.
//
// Retrieves the list of Quota configurations for a given account.
//
// Note that there may be multiple Quota objects for different resource types.
//
// responses:
//
// 200: GetQuotaResponse200
@@ -35,7 +39,8 @@ func (g *Groupware) GetQuota(w http.ResponseWriter, r *http.Request) {
return req.errorResponseFromJmap(single(accountId), jerr)
}
for _, v := range res {
return etagResponse(single(accountId), v.List, sessionState, QuotaResponseObjectType, state, lang)
body := v.List
return etagResponse(single(accountId), body, sessionState, QuotaResponseObjectType, state, lang)
}
return notFoundResponse(single(accountId), sessionState)
})
@@ -56,6 +61,9 @@ type SwaggerGetQuotaForAllAccountsResponse200 struct {
// swagger:route GET /groupware/accounts/all/quota quota get_quota_for_all_accounts
// Get quota limits for all accounts.
//
// Retrieves the Quota configuration for all the accounts the user currently has access to,
// as a dictionary that has the account identifier as its key and an array of Quotas as its value.
//
// responses:
//
// 200: GetQuotaForAllAccountsResponse200
@@ -65,7 +73,7 @@ func (g *Groupware) GetQuotaForAllAccounts(w http.ResponseWriter, r *http.Reques
g.respond(w, r, func(req Request) Response {
accountIds := req.AllAccountIds()
if len(accountIds) < 1 {
return noContentResponse(accountIds, "")
return noContentResponse(accountIds, "") // user has no accounts
}
logger := log.From(req.logger.With().Array(logAccountId, log.SafeStringArray(accountIds)))

View File

@@ -3,7 +3,6 @@ package groupware
import (
"net/http"
"github.com/go-chi/chi/v5"
"github.com/opencloud-eu/opencloud/pkg/jmap"
)
@@ -31,7 +30,8 @@ func (g *Groupware) GetTaskLists(w http.ResponseWriter, r *http.Request) {
}
var _ string = accountId
return etagResponse(single(accountId), AllTaskLists, req.session.State, TaskListResponseObjectType, TaskListsState, "")
var body []jmap.TaskList = AllTaskLists
return etagResponse(single(accountId), body, req.session.State, TaskListResponseObjectType, TaskListsState, "")
})
}
@@ -61,7 +61,10 @@ func (g *Groupware) GetTaskListById(w http.ResponseWriter, r *http.Request) {
}
var _ string = accountId
tasklistId := chi.URLParam(r, UriParamTaskListId)
tasklistId, err := req.PathParam(UriParamTaskListId)
if err != nil {
return errorResponse(single(accountId), err)
}
// TODO replace with proper implementation
for _, tasklist := range AllTaskLists {
if tasklist.Id == tasklistId {
@@ -96,7 +99,10 @@ func (g *Groupware) GetTasksInTaskList(w http.ResponseWriter, r *http.Request) {
}
var _ string = accountId
tasklistId := chi.URLParam(r, UriParamTaskListId)
tasklistId, err := req.PathParam(UriParamTaskListId)
if err != nil {
return errorResponse(single(accountId), err)
}
// TODO replace with proper implementation
tasks, ok := TaskMapByTaskListId[tasklistId]
if !ok {

View File

@@ -194,7 +194,7 @@ const (
ErrorCodeMissingContactsSessionCapability = "MSCCON"
ErrorCodeMissingContactsAccountCapability = "MACCON"
ErrorCodeMissingTasksSessionCapability = "MSCTSK"
ErrorCodeMissingTaskAccountCapability = "MACTSK"
ErrorCodeMissingTasksAccountCapability = "MACTSK"
ErrorCodeFailedToDeleteEmail = "DELEML"
ErrorCodeFailedToDeleteSomeIdentities = "DELSID"
ErrorCodeFailedToSanitizeEmail = "FSANEM"
@@ -318,12 +318,6 @@ var (
Title: "Invalid Request",
Detail: "The request is invalid.",
}
ErrorIndeterminateAccount = GroupwareError{
Status: http.StatusBadRequest,
Code: ErrorCodeNonExistingAccount,
Title: "Invalid Account Parameter",
Detail: "The account the request is for does not exist.",
}
ErrorNonExistingAccount = GroupwareError{
Status: http.StatusBadRequest,
Code: ErrorCodeIndeterminateAccount,
@@ -392,39 +386,39 @@ var (
}
ErrorMissingCalendarsSessionCapability = GroupwareError{
Status: http.StatusExpectationFailed,
Code: ErrorCodeMissingCalendarsSessionCapability,
Title: "Session is missing the task capability '" + jmap.JmapCalendars + "'",
Detail: "The JMAP Session of the user does not have the required capability '" + jmap.JmapTasks + "'.",
Code: ErrorCodeMissingCalendarsAccountCapability,
Title: "Session is missing the calendars session capability",
Detail: "The JMAP Session of the user does not have the required capability for calendars.",
}
ErrorMissingCalendarsAccountCapability = GroupwareError{
Status: http.StatusExpectationFailed,
Code: ErrorCodeMissingCalendarsSessionCapability,
Title: "Account is missing the task capability '" + jmap.JmapCalendars + "'",
Detail: "The JMAP Account of the user does not have the required capability '" + jmap.JmapTasks + "'.",
Title: "Account is missing the calendars capability",
Detail: "The JMAP Account of the user does not have the required capability for calendars.",
}
ErrorMissingContactsSessionCapability = GroupwareError{
Status: http.StatusExpectationFailed,
Code: ErrorCodeMissingContactsSessionCapability,
Title: "Session is missing the task capability '" + jmap.JmapContacts + "'",
Detail: "The JMAP Session of the user does not have the required capability '" + jmap.JmapContacts + "'.",
Code: ErrorCodeMissingContactsAccountCapability,
Title: "Session is missing the contacts capability",
Detail: "The JMAP Session of the user does not have the required capability for accounts.",
}
ErrorMissingContactsAccountCapability = GroupwareError{
Status: http.StatusExpectationFailed,
Code: ErrorCodeMissingContactsSessionCapability,
Title: "Account is missing the task capability '" + jmap.JmapContacts + "'",
Detail: "The JMAP Account of the user does not have the required capability '" + jmap.JmapContacts + "'.",
Code: ErrorCodeMissingContactsAccountCapability,
Title: "Account is missing the contacts capability",
Detail: "The JMAP Account of the user does not have the required capability for accounts.",
}
ErrorMissingTasksSessionCapability = GroupwareError{
Status: http.StatusExpectationFailed,
Code: ErrorCodeMissingTasksSessionCapability,
Title: "Session is missing the task capability '" + jmap.JmapTasks + "'",
Detail: "The JMAP Session of the user does not have the required capability '" + jmap.JmapTasks + "'.",
Title: "Session is missing the tasks capability",
Detail: "The JMAP Session of the user does not have the required capability for tasks.",
}
ErrorMissingTasksAccountCapability = GroupwareError{
Status: http.StatusExpectationFailed,
Code: ErrorCodeMissingTasksSessionCapability,
Title: "Account is missing the task capability '" + jmap.JmapTasks + "'",
Detail: "The JMAP Account of the user does not have the required capability '" + jmap.JmapTasks + "'.",
Code: ErrorCodeMissingTasksAccountCapability,
Title: "Account is missing the tasks capability",
Detail: "The JMAP Account of the user does not have the required capability for tasks",
}
ErrorFailedToDeleteEmail = GroupwareError{
Status: http.StatusInternalServerError,

View File

@@ -0,0 +1,219 @@
//go:build groupware_examples
package groupware
import (
"time"
"github.com/opencloud-eu/opencloud/pkg/jmap"
"github.com/opencloud-eu/opencloud/pkg/structs"
)
var (
exampleQuotaState = "veiv8iez"
)
type Exampler struct{}
var j = jmap.ExamplerInstance
func Example() {
jmap.SerializeExamples(Exampler{})
//Output:
}
func (e Exampler) AccountQuota() AccountQuota {
return AccountQuota{
Quotas: []jmap.Quota{j.Quota()},
State: jmap.State(exampleQuotaState),
}
}
func (e Exampler) AccountQuotaMap() map[string]AccountQuota {
return map[string]AccountQuota{
j.AccountId: e.AccountQuota(),
}
}
func (e Exampler) AccountWithId() AccountWithId {
a, _ := j.Account()
return AccountWithId{
AccountId: j.AccountId,
Account: a,
}
}
func (e Exampler) AccountWithIdAndIdentities() AccountWithIdAndIdentities {
a, _ := j.Account()
return AccountWithIdAndIdentities{
AccountId: j.AccountId,
Account: a,
Identities: j.Identities(),
}
}
func (e Exampler) IndexAccountMailCapabilities() IndexAccountMailCapabilities {
m := j.SessionMailAccountCapabilities()
s := j.SessionSubmissionAccountCapabilities()
return IndexAccountMailCapabilities{
MaxMailboxDepth: m.MaxMailboxDepth,
MaxSizeMailboxName: m.MaxSizeMailboxName,
MaxMailboxesPerEmail: m.MaxMailboxesPerEmail,
MaxSizeAttachmentsPerEmail: m.MaxSizeAttachmentsPerEmail,
MayCreateTopLevelMailbox: m.MayCreateTopLevelMailbox,
MaxDelayedSend: s.MaxDelayedSend,
}
}
func (e Exampler) IndexAccountSieveCapabilities() IndexAccountSieveCapabilities {
s := j.SessionSieveAccountCapabilities()
return IndexAccountSieveCapabilities{
MaxSizeScriptName: s.MaxSizeScriptName,
MaxSizeScript: s.MaxSizeScript,
MaxNumberScripts: s.MaxNumberScripts,
MaxNumberRedirects: s.MaxNumberRedirects,
}
}
func (e Exampler) IndexAccountCapabilities() IndexAccountCapabilities {
return IndexAccountCapabilities{
Mail: e.IndexAccountMailCapabilities(),
Sieve: e.IndexAccountSieveCapabilities(),
}
}
func (e Exampler) IndexAccount() IndexAccount {
a, _ := j.Account()
return IndexAccount{
AccountId: j.AccountId,
Name: a.Name,
IsPersonal: a.IsPersonal,
IsReadOnly: a.IsReadOnly,
Capabilities: e.IndexAccountCapabilities(),
Identities: j.Identities(),
Quotas: j.Quotas(),
}
}
func (e Exampler) IndexAccounts() []IndexAccount {
return []IndexAccount{
e.IndexAccount(),
{
AccountId: j.SharedAccountId,
Name: j.SharedAccountName,
IsPersonal: false,
IsReadOnly: true,
Capabilities: e.IndexAccountCapabilities(),
Identities: []jmap.Identity{
{
Id: j.SharedIdentityId,
Name: j.SharedIdentityName,
Email: j.SharedIdentityEmailAddress,
},
},
Quotas: j.Quotas(),
},
}
}
func (e Exampler) IndexPrimaryAccounts() IndexPrimaryAccounts {
return IndexPrimaryAccounts{
Mail: j.AccountId,
Submission: j.AccountId,
Blob: j.AccountId,
VacationResponse: j.AccountId,
Sieve: j.AccountId,
}
}
func (e Exampler) IndexResponse() IndexResponse {
return IndexResponse{
Version: "4.0.0",
Capabilities: []string{"mail:1"},
Limits: IndexLimits{
MaxSizeUpload: 50000000,
MaxConcurrentUpload: 4,
MaxSizeRequest: 10000000,
MaxConcurrentRequests: 4,
},
PrimaryAccounts: e.IndexPrimaryAccounts(),
Accounts: []IndexAccount{e.IndexAccount()},
}
}
func (e Exampler) ErrorResponse() ErrorResponse {
err := apiError("6d9c65d1-0368-4833-b09f-885aa0171b95", ErrorNoMailboxWithDraftRole)
return ErrorResponse{
Errors: []Error{
*err,
},
}
}
func (e Exampler) MailboxesByAccountId() (map[string][]jmap.Mailbox, string) {
j := jmap.ExamplerInstance
return map[string][]jmap.Mailbox{
j.AccountId: j.Mailboxes(),
}, "All mailboxes for all accounts, without a role filter"
}
func (e Exampler) MailboxesByAccountIdFilteredOnInboxRole() (map[string][]jmap.Mailbox, string, string) {
j := jmap.ExamplerInstance
return map[string][]jmap.Mailbox{
j.AccountId: structs.Filter(j.Mailboxes(), func(m jmap.Mailbox) bool { return m.Role == jmap.JmapMailboxRoleInbox }),
}, "All mailboxes for all accounts, filtered on the 'inbox' role", "inboxrole"
}
func (e Exampler) EmailSearchResults() EmailSearchResults {
sent, _ := time.Parse(time.RFC3339, "2026-01-12T21:46:01Z")
received, _ := time.Parse(time.RFC3339, "2026-01-12T21:47:21Z")
j := jmap.ExamplerInstance
return EmailSearchResults{
Results: []jmap.Email{
{
Id: "ov7ienge",
BlobId: "ccyxndo0fxob1jnm3z2lroex131oj7eo2ezo1djhlfgtsu7jgucfeaiasiba",
ThreadId: "is",
MailboxIds: map[string]bool{j.MailboxInboxId: true},
Keywords: map[string]bool{jmap.JmapKeywordAnswered: true},
Size: 1084,
ReceivedAt: received,
MessageId: []string{"1768845021.1753110@example.com"},
Sender: []jmap.EmailAddress{
{Name: j.SenderName, Email: j.SenderEmailAddress},
},
From: []jmap.EmailAddress{
{Name: j.SenderName, Email: j.SenderEmailAddress},
},
To: []jmap.EmailAddress{
{Name: j.IdentityName, Email: j.EmailAddress},
},
Subject: "Remember the Cant",
SentAt: sent,
TextBody: []jmap.EmailBodyPart{
{PartId: "1", BlobId: "ckyxndo0fxob1jnm3z2lroex131oj7eo2ezo1djhlfgtsu7jgucfeaiasibnebdw", Size: 115, Type: "text/plain", Charset: "utf-8"},
},
HtmlBody: []jmap.EmailBodyPart{
{PartId: "2", BlobId: "ckyxndo0fxob1jnm3z2lroex131oj7eo2ezo1djhlfgtsu7jgucfeaiasibnsbvjae", Size: 163, Type: "text/html", Charset: "utf-8"},
},
Preview: "The Canterbury was destroyed while investigating a false distress call from the Scopuli.",
},
},
Total: 132,
Limit: 1,
QueryState: "seehug3p",
}
}
func (e Exampler) MailboxRolesByAccounts() (map[string][]string, string, string, string) {
j := jmap.ExamplerInstance
return map[string][]string{
j.AccountId: jmap.JmapMailboxRoles,
j.SharedAccountId: jmap.JmapMailboxRoles,
}, "Roles of the Mailboxes of each Account", "", "mailboxrolesbyaccount"
}
func (e Exampler) DeletedMailboxes() ([]string, string, string, string) {
j := jmap.ExamplerInstance
return []string{j.MailboxProjectId, j.MailboxJunkId}, "Identifiers of the Mailboxes that have successfully been deleted", "", "deletedmailboxes"
}

View File

@@ -68,14 +68,64 @@ var (
errNoPrimaryAccountForBlob = errors.New("no primary account for blob")
errNoPrimaryAccountForVacationResponse = errors.New("no primary account for vacation response")
errNoPrimaryAccountForSubmission = errors.New("no primary account for submission")
errNoPrimaryAccountForTask = errors.New("no primary account for task")
errNoPrimaryAccountForCalendar = errors.New("no primary account for calendar")
errNoPrimaryAccountForContact = errors.New("no primary account for contact")
errNoPrimaryAccountForQuota = errors.New("no primary account for quota")
// errNoPrimaryAccountForTask = errors.New("no primary account for task")
// errNoPrimaryAccountForCalendar = errors.New("no primary account for calendar")
// errNoPrimaryAccountForContact = errors.New("no primary account for contact")
// errNoPrimaryAccountForSieve = errors.New("no primary account for sieve")
// errNoPrimaryAccountForWebsocket = errors.New("no primary account for websocket")
)
func (r Request) HeaderParam(name string) (string, *Error) {
value := r.r.Header.Get(name)
if value == "" {
msg := fmt.Sprintf("Missing mandatory request header '%s'", name)
return "", r.observedParameterError(ErrorInvalidRequestParameter,
withDetail(msg),
withSource(&ErrorSource{Header: name}),
)
} else {
return value, nil
}
}
func (r Request) HeaderParamDoc(name string, _ string) (string, *Error) {
return r.HeaderParam(name)
}
func (r Request) OptHeaderParam(name string) string {
return r.r.Header.Get(name)
}
func (r Request) OptHeaderParamDoc(name string, _ string) string {
return r.OptHeaderParam(name)
}
func (r Request) PathParam(name string) (string, *Error) {
value := chi.URLParam(r.r, name)
if value == "" {
msg := fmt.Sprintf("Missing mandatory path parameter '%s'", name)
return "", r.observedParameterError(ErrorInvalidRequestParameter,
withDetail(msg),
withSource(&ErrorSource{Parameter: name}),
)
} else {
return value, nil
}
}
func (r Request) PathParamDoc(name string, _ string) (string, *Error) {
return r.PathParam(name)
}
func (r Request) PathListParamDoc(name string, _ string) ([]string, *Error) {
value, err := r.PathParam(name)
if err != nil {
return nil, err
}
return strings.Split(value, ","), nil
}
func (r Request) AllAccountIds() []string {
// TODO potentially filter on "subscribed" accounts?
return structs.Uniq(structs.Keys(r.session.Accounts))
@@ -313,6 +363,26 @@ func (r Request) parseMapParam(param string) (map[string]string, bool, *Error) {
return result, true, nil
}
func (r Request) parseOptStringListParam(param string) ([]string, bool, *Error) {
result := []string{}
q := r.r.URL.Query()
if !q.Has(param) {
return nil, false, nil
}
for _, value := range q[param] {
for _, v := range strings.Split(value, ",") {
if strings.TrimSpace(v) != "" {
result = append(result, v)
}
}
}
return result, true, nil
}
func (r Request) bodydoc(target any, _ string) *Error {
return r.body(target)
}
func (r Request) body(target any) *Error {
body := r.r.Body
defer func(b io.ReadCloser) {

View File

@@ -163,7 +163,7 @@ func etagNotFoundResponse(accountIds []string, sessionState jmap.SessionState, o
}
}
func notImplementesResponse() Response {
func notImplementedResponse() Response {
return Response{
body: nil,
status: http.StatusNotImplemented,

View File

@@ -11,19 +11,20 @@ var (
)
const (
UriParamAccountId = "accountid"
UriParamMailboxId = "mailboxid"
UriParamEmailId = "emailid"
UriParamIdentityId = "identityid"
UriParamBlobId = "blobid"
UriParamAccountId = "accountid" // Identifier of the account
UriParamMailboxId = "mailboxid" // Identifier of the mailbox
UriParamEmailId = "emailid" // Identifier of the email
UriParamIdentityId = "identityid" // Identifier of the identity
UriParamBlobId = "blobid" // Identifier of theblob
UriParamStreamId = "stream" // Identifier of the stream
UriParamAddressBookId = "addressbookid" // Identifier of the address book
UriParamCalendarId = "calendarid" // Identifier of the calendar
UriParamTaskListId = "tasklistid" // Identifier of the tasklist
UriParamContactId = "contactid" // Identifier of the contact
UriParamEventId = "eventid" // Idenfitier of the event
UriParamBlobName = "blobname"
UriParamStreamId = "stream"
UriParamSince = "since"
UriParamRole = "role"
UriParamAddressBookId = "addressbookid"
UriParamCalendarId = "calendarid"
UriParamTaskListId = "tasklistid"
UriParamContactId = "contactid"
UriParamEventId = "eventid"
QueryParamMailboxSearchName = "name"
QueryParamMailboxSearchRole = "role"
QueryParamMailboxSearchSubscribed = "subscribed"
@@ -57,7 +58,7 @@ const (
QueryParamSeen = "seen"
QueryParamUndesirable = "undesirable"
QueryParamMarkAsSeen = "markAsSeen"
HeaderSince = "if-none-match"
HeaderParamSince = "if-none-match"
)
func (g *Groupware) Route(r chi.Router) {
@@ -100,6 +101,7 @@ func (g *Groupware) Route(r chi.Router) {
r.Route("/{mailboxid}", func(r chi.Router) {
r.Get("/", g.GetMailbox)
r.Get("/emails", g.GetAllEmailsInMailbox)
r.Get("/emails/since/{since}", g.GetAllEmailsInMailboxSince)
r.Get("/changes", g.GetMailboxChanges)
r.Patch("/", g.UpdateMailbox)
r.Delete("/", g.DeleteMailbox)

View File

@@ -29,7 +29,7 @@ func Auth(opts ...account.Option) func(http.Handler) http.Handler {
opt := authOptions(opts...)
tokenManager, err := jwt.New(map[string]any{
"secret": opt.JWTSecret,
"expires": int64(24 * 60 * 60),
"expires": int64(24 * 60 * 60), // token expiration in seconds
})
if err != nil {
opt.Logger.Fatal().Err(err).Msgf("Could not initialize token-manager")
@@ -39,19 +39,37 @@ func Auth(opts ...account.Option) func(http.Handler) http.Handler {
ctx := r.Context()
t := r.Header.Get(revactx.TokenHeader)
if t == "" {
opt.Logger.Error().Str(log.RequestIDString, r.Header.Get("X-Request-ID")).Msgf("missing access token in header %v", revactx.TokenHeader)
requestID := r.Header.Get("X-Request-ID")
traceID := GetTraceID(ctx)
l := opt.Logger.Error().Str(log.RequestIDString, log.SafeString(requestID))
if traceID != "" {
l = l.Str(LogTraceID, log.SafeString(traceID))
}
l.Msgf("missing access token in header %v", revactx.TokenHeader)
w.WriteHeader(http.StatusUnauthorized) // missing access token
return
}
u, tokenScope, err := tokenManager.DismantleToken(r.Context(), t)
if err != nil {
opt.Logger.Error().Str(log.RequestIDString, r.Header.Get("X-Request-ID")).Err(err).Msgf("invalid access token in header %v", revactx.TokenHeader)
requestID := r.Header.Get("X-Request-ID")
traceID := GetTraceID(ctx)
l := opt.Logger.Error().Str(log.RequestIDString, log.SafeString(requestID))
if traceID != "" {
l = l.Str(LogTraceID, log.SafeString(traceID))
}
l.Err(err).Msgf("invalid access token in header %v", revactx.TokenHeader)
w.WriteHeader(http.StatusUnauthorized) // invalid token
return
}
if ok, err := scope.VerifyScope(ctx, tokenScope, r); err != nil || !ok {
opt.Logger.Error().Str(log.RequestIDString, r.Header.Get("X-Request-ID")).Err(err).Msg("verifying scope failed")
requestID := r.Header.Get("X-Request-ID")
traceID := GetTraceID(ctx)
l := opt.Logger.Error().Str(log.RequestIDString, log.SafeString(requestID))
if traceID != "" {
l = l.Str(LogTraceID, log.SafeString(traceID))
}
l.Err(err).Msg("verifying scope failed")
w.WriteHeader(http.StatusUnauthorized) // invalid scope
return
}

View File

@@ -8,6 +8,10 @@ import (
"github.com/opencloud-eu/opencloud/pkg/log"
)
var (
LogTraceID = "traceId"
)
func GroupwareLogger(logger log.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -37,12 +41,10 @@ func GroupwareLogger(logger log.Logger) func(http.Handler) http.Handler {
ctx := r.Context()
requestID := middleware.GetReqID(ctx)
level = level.Str(log.RequestIDString, log.SafeString(requestID))
traceID := GetTraceID(ctx)
level.Str(log.RequestIDString, requestID)
if traceID != "" {
level.Str("traceId", traceID)
level = level.Str(LogTraceID, log.SafeString(traceID))
}
level.

File diff suppressed because it is too large Load Diff