From 3c4f34a2f376e2e436d75e893183a2808665c9a7 Mon Sep 17 00:00:00 2001 From: Pascal Bleser Date: Fri, 12 Sep 2025 11:32:53 +0200 Subject: [PATCH] groupware: improved attachment APIs * feat(groupware): add /accounts/{}/emails/{}/attachments * feat(groupware): add /accounts/{}/emails/{}/attachments?partId=&name=&blobId= --- .../pkg/groupware/groupware_api_blob.go | 13 +- .../pkg/groupware/groupware_api_messages.go | 161 ++++++++++++++++++ .../pkg/groupware/groupware_framework.go | 3 + .../pkg/groupware/groupware_route.go | 6 +- 4 files changed, 178 insertions(+), 5 deletions(-) diff --git a/services/groupware/pkg/groupware/groupware_api_blob.go b/services/groupware/pkg/groupware/groupware_api_blob.go index bff79d6eff..6187a585f2 100644 --- a/services/groupware/pkg/groupware/groupware_api_blob.go +++ b/services/groupware/pkg/groupware/groupware_api_blob.go @@ -15,7 +15,7 @@ const ( DefaultBlobDownloadType = "application/octet-stream" ) -func (g *Groupware) GetBlob(w http.ResponseWriter, r *http.Request) { +func (g *Groupware) GetBlobMeta(w http.ResponseWriter, r *http.Request) { g.respond(w, r, func(req Request) Response { blobId := chi.URLParam(req.r, UriParamBlobId) if blobId == "" { @@ -28,15 +28,20 @@ func (g *Groupware) GetBlob(w http.ResponseWriter, r *http.Request) { } logger := log.From(req.logger.With().Str(logAccountId, accountId)) - res, _, jerr := g.jmap.GetBlob(accountId, req.session, req.ctx, logger, blobId) + res, sessionState, jerr := g.jmap.GetBlobMetadata(accountId, req.session, req.ctx, logger, blobId) if jerr != nil { return req.errorResponseFromJmap(jerr) } blob := res.Blob if blob == nil { - return notFoundResponse("") + return notFoundResponse(sessionState) + } + digest := blob.Digest() + if digest != "" { + return etagResponse(res, sessionState, jmap.State(digest)) + } else { + return response(res, sessionState) } - return etagOnlyResponse(res, jmap.State(blob.Digest())) }) } diff --git a/services/groupware/pkg/groupware/groupware_api_messages.go b/services/groupware/pkg/groupware/groupware_api_messages.go index 292ee50ea0..3130ddb093 100644 --- a/services/groupware/pkg/groupware/groupware_api_messages.go +++ b/services/groupware/pkg/groupware/groupware_api_messages.go @@ -3,11 +3,14 @@ package groupware import ( "context" "fmt" + "io" "net/http" + "strconv" "strings" "time" "github.com/go-chi/chi/v5" + "github.com/rs/zerolog" "github.com/opencloud-eu/opencloud/pkg/jmap" "github.com/opencloud-eu/opencloud/pkg/log" @@ -159,6 +162,164 @@ func (g *Groupware) GetEmailsById(w http.ResponseWriter, r *http.Request) { }) } +type attachmentPicker interface { + pick(parts []jmap.EmailBodyPart) *jmap.EmailBodyPart +} + +type partIdAttachmentPicker struct { + partId string +} + +var _ attachmentPicker = partIdAttachmentPicker{} + +func (p partIdAttachmentPicker) pick(parts []jmap.EmailBodyPart) *jmap.EmailBodyPart { + for _, part := range parts { + if part.PartId == p.partId { + return &part + } + } + return nil +} + +type nameAttachmentPicker struct { + name string +} + +var _ attachmentPicker = nameAttachmentPicker{} + +func (p nameAttachmentPicker) pick(parts []jmap.EmailBodyPart) *jmap.EmailBodyPart { + for _, part := range parts { + if part.Name == p.name { + return &part + } + } + return nil +} + +type blobIdAttachmentPicker struct { + blobId string +} + +var _ attachmentPicker = blobIdAttachmentPicker{} + +func (p blobIdAttachmentPicker) pick(parts []jmap.EmailBodyPart) *jmap.EmailBodyPart { + for _, part := range parts { + if part.BlobId == p.blobId { + return &part + } + } + return nil +} + +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 picker attachmentPicker = nil + if q.Has(QueryParamPartId) { + str := q.Get(QueryParamPartId) + picker = partIdAttachmentPicker{partId: str} + contextAppender = func(l zerolog.Context) zerolog.Context { return l.Str(QueryParamPartId, log.SafeString(str)) } + } + if q.Has(QueryParamAttachmentName) { + str := q.Get(QueryParamAttachmentName) + picker = nameAttachmentPicker{name: str} + contextAppender = func(l zerolog.Context) zerolog.Context { return l.Str(QueryParamAttachmentName, log.SafeString(str)) } + } + if q.Has(QueryParamAttachmentBlobId) { + str := q.Get(QueryParamAttachmentBlobId) + picker = blobIdAttachmentPicker{blobId: str} + contextAppender = func(l zerolog.Context) zerolog.Context { return l.Str(QueryParamAttachmentBlobId, log.SafeString(str)) } + } + + if picker == nil { + g.respond(w, r, func(req Request) Response { + accountId, err := req.GetAccountIdForMail() + if err != nil { + return errorResponse(err) + } + l := req.logger.With().Str(logAccountId, accountId) + logger := log.From(l) + emails, sessionState, jerr := g.jmap.GetEmails(accountId, req.session, req.ctx, logger, []string{id}, false, 0) + if jerr != nil { + return req.errorResponseFromJmap(jerr) + } + if len(emails.Emails) < 1 { + return notFoundResponse(sessionState) + } + email := emails.Emails[0] + return etagResponse(email.Attachments, sessionState, emails.State) + }) + } else { + g.stream(w, r, func(req Request, w http.ResponseWriter) *Error { + mailAccountId, gwerr := req.GetAccountIdForMail() + if gwerr != nil { + return gwerr + } + blobAccountId, gwerr := req.GetAccountIdForBlob() + if gwerr != nil { + return gwerr + } + + l := req.logger.With().Str(logAccountId, mailAccountId).Str(logBlobAccountId, log.SafeString(blobAccountId)) + l = contextAppender(l) + logger := log.From(l) + + emails, _, jerr := g.jmap.GetEmails(mailAccountId, req.session, req.ctx, logger, []string{id}, false, 0) + if jerr != nil { + return req.apiErrorFromJmap(req.observeJmapError(jerr)) + } + if len(emails.Emails) < 1 { + return nil + } + + email := emails.Emails[0] + attachment := picker.pick(email.Attachments) + if attachment == nil { + return nil + } + + blob, jerr := g.jmap.DownloadBlobStream(blobAccountId, attachment.BlobId, attachment.Name, attachment.Type, req.session, req.ctx, logger) + if blob != nil && blob.Body != nil { + defer func(Body io.ReadCloser) { + err := Body.Close() + if err != nil { + logger.Error().Err(err).Msg("failed to close response body") + } + }(blob.Body) + } + if jerr != nil { + return req.apiErrorFromJmap(jerr) + } + if blob == nil { + w.WriteHeader(http.StatusNotFound) + return nil + } + + if blob.Type != "" { + w.Header().Add("Content-Type", blob.Type) + } + if blob.CacheControl != "" { + w.Header().Add("Cache-Control", blob.CacheControl) + } + if blob.ContentDisposition != "" { + w.Header().Add("Content-Disposition", blob.ContentDisposition) + } + if blob.Size >= 0 { + w.Header().Add("Content-Size", strconv.Itoa(blob.Size)) + } + + _, err := io.Copy(w, blob.Body) + if err != nil { + return req.observedParameterError(ErrorStreamingResponse) + } + + return nil + }) + } +} + func (g *Groupware) getEmailsSince(w http.ResponseWriter, r *http.Request, since string) { g.respond(w, r, func(req Request) Response { l := req.logger.With().Str(QueryParamSince, since) diff --git a/services/groupware/pkg/groupware/groupware_framework.go b/services/groupware/pkg/groupware/groupware_framework.go index 1baf84ca92..0293408599 100644 --- a/services/groupware/pkg/groupware/groupware_framework.go +++ b/services/groupware/pkg/groupware/groupware_framework.go @@ -32,6 +32,7 @@ const ( logUserId = "user-id" logSessionState = "session-state" logAccountId = "account-id" + logBlobAccountId = "blob-account-id" // if the blob accountId is needed as well logErrorId = "error-id" logErrorCode = "code" logErrorStatus = "status" @@ -624,6 +625,8 @@ func (g *Groupware) stream(w http.ResponseWriter, r *http.Request, handler func( decoratedLogger := decorateLogger(logger, session) req := Request{ + g: g, + user: user, r: r, ctx: ctx, logger: decoratedLogger, diff --git a/services/groupware/pkg/groupware/groupware_route.go b/services/groupware/pkg/groupware/groupware_route.go index b87126b38a..d7988bfb96 100644 --- a/services/groupware/pkg/groupware/groupware_route.go +++ b/services/groupware/pkg/groupware/groupware_route.go @@ -41,6 +41,9 @@ const ( QueryParamOffset = "offset" QueryParamLimit = "limit" QueryParamDays = "days" + QueryParamPartId = "partId" + QueryParamAttachmentName = "name" + QueryParamAttachmentBlobId = "blobId" HeaderSince = "if-none-match" ) @@ -75,9 +78,10 @@ func (g *Groupware) Route(r chi.Router) { r.Patch("/{emailid}", g.UpdateEmail) r.Delete("/{emailid}", g.DeleteEmail) Report(r, "/{emailid}", g.RelatedToEmail) + r.Get("/{emailid}/attachments", g.GetEmailAttachments) // ?partId=&name=?&blobId=? }) r.Route("/blobs", func(r chi.Router) { - r.Get("/{blobid}", g.GetBlob) + r.Get("/{blobid}", g.GetBlobMeta) r.Get("/{blobid}/{blobname}", g.DownloadBlob) // ?type= }) })