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=
})
})