groupware: improved attachment APIs

* feat(groupware): add /accounts/{}/emails/{}/attachments

 * feat(groupware): add
   /accounts/{}/emails/{}/attachments?partId=&name=&blobId=
This commit is contained in:
Pascal Bleser
2025-09-12 11:32:53 +02:00
parent e07be50674
commit 3c4f34a2f3
4 changed files with 178 additions and 5 deletions

View File

@@ -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()))
})
}

View File

@@ -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)

View File

@@ -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,

View File

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