ultisuite-backend/internal/api/mail/handlers_attachments.go
R3D347HR4Y 95196f7777 Add mail attachment and draft management features
- Introduced new functionality for managing email attachments and drafts in the mail API.
- Added handlers for listing, uploading, and downloading message attachments in `internal/api/mail/handlers_attachments.go`.
- Implemented draft management endpoints for creating, updating, and deleting drafts in `internal/api/mail/handlers_drafts.go`.
- Created new service methods for handling draft and attachment operations in `internal/api/mail/drafts.go` and `internal/api/mail/storage.go`.
- Added validation and error handling for draft and attachment operations.
- Included unit tests for draft and folder functionalities in `internal/api/mail/drafts_test.go` and `internal/api/mail/folders_test.go`.
- Updated API routes to support new draft and attachment features, enhancing overall mail management capabilities.
2026-05-22 17:14:36 +02:00

204 lines
6.4 KiB
Go

package mail
import (
"errors"
"fmt"
"io"
"mime"
"net/http"
"path/filepath"
"strings"
"github.com/go-chi/chi/v5"
"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
"github.com/ultisuite/ulti-backend/internal/api/middleware"
)
const maxMultipartBody = 26 << 20 // 26 MiB
func (h *Handler) ListMessageAttachments(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
messageID := chi.URLParam(r, "messageID")
list, err := h.svc.ListMessageAttachments(r.Context(), claims.Sub, messageID)
if err != nil {
if errors.Is(err, ErrNotFound) {
apivalidate.WriteNotFound(w, r, "not found")
return
}
h.logger.Error("list attachments", "error", err)
apivalidate.WriteInternal(w, r)
return
}
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"attachments": list})
}
func (h *Handler) MessageAttachmentCIDMap(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
messageID := chi.URLParam(r, "messageID")
mapping, err := h.svc.MessageAttachmentCIDMap(r.Context(), claims.Sub, messageID)
if err != nil {
if errors.Is(err, ErrNotFound) {
apivalidate.WriteNotFound(w, r, "not found")
return
}
h.logger.Error("attachment cid map", "error", err)
apivalidate.WriteInternal(w, r)
return
}
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"cid_map": mapping})
}
func (h *Handler) UploadMessageAttachment(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
messageID := chi.URLParam(r, "messageID")
if err := r.ParseMultipartForm(maxMultipartBody); err != nil {
apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, "invalid multipart form", nil)
return
}
file, header, err := r.FormFile("file")
if err != nil {
apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, "file field required", nil)
return
}
defer file.Close()
filename := filepath.Base(header.Filename)
contentType := header.Header.Get("Content-Type")
if contentType == "" {
contentType = mime.TypeByExtension(filepath.Ext(filename))
}
if contentType == "" {
contentType = "application/octet-stream"
}
contentID := strings.TrimSpace(r.FormValue("content_id"))
isInline := strings.EqualFold(r.FormValue("inline"), "true") || contentID != ""
id, err := h.svc.UploadMessageAttachment(
r.Context(), claims.Sub, messageID, filename, contentType, contentID, isInline,
file, header.Size,
)
if err != nil {
if errors.Is(err, ErrNotFound) {
apivalidate.WriteNotFound(w, r, "not found")
return
}
if errors.Is(err, ErrAttachmentTooLarge) {
apiresponse.WriteError(w, r, http.StatusRequestEntityTooLarge, apiresponse.CodeInvalidRequest, "attachment too large", nil)
return
}
h.logger.Error("upload attachment", "error", err)
apivalidate.WriteInternal(w, r)
return
}
apiresponse.WriteJSON(w, http.StatusCreated, map[string]string{"id": id})
}
func (h *Handler) DownloadAttachment(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
inline := strings.HasSuffix(r.URL.Path, "/inline") || r.URL.Query().Get("inline") == "true"
attachmentID := chi.URLParam(r, "attachmentID")
filename, contentType, size, isInline, body, err := h.svc.OpenAttachment(r.Context(), claims.Sub, attachmentID)
if err != nil {
if errors.Is(err, ErrAttachmentNotFound) {
apivalidate.WriteNotFound(w, r, "not found")
return
}
h.logger.Error("download attachment", "error", err)
apivalidate.WriteInternal(w, r)
return
}
defer body.Close()
disposition := "attachment"
if inline || isInline {
disposition = "inline"
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Disposition", fmt.Sprintf(`%s; filename="%s"`, disposition, filename))
if size > 0 {
w.Header().Set("Content-Length", fmt.Sprintf("%d", size))
}
_, _ = io.Copy(w, body)
}
func (h *Handler) UploadDraftAttachment(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
draftID := chi.URLParam(r, "draftID")
if err := r.ParseMultipartForm(maxMultipartBody); err != nil {
apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, "invalid multipart form", nil)
return
}
file, header, err := r.FormFile("file")
if err != nil {
apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, "file field required", nil)
return
}
defer file.Close()
filename := filepath.Base(header.Filename)
contentType := header.Header.Get("Content-Type")
if contentType == "" {
contentType = mime.TypeByExtension(filepath.Ext(filename))
}
if contentType == "" {
contentType = "application/octet-stream"
}
contentID := strings.TrimSpace(r.FormValue("content_id"))
isInline := strings.EqualFold(r.FormValue("inline"), "true") || contentID != ""
id, err := h.svc.UploadDraftAttachment(
r.Context(), claims.Sub, draftID, filename, contentType, contentID, isInline,
file, header.Size,
)
if err != nil {
if errors.Is(err, ErrNotFound) {
apivalidate.WriteNotFound(w, r, "not found")
return
}
if errors.Is(err, ErrAttachmentTooLarge) {
apiresponse.WriteError(w, r, http.StatusRequestEntityTooLarge, apiresponse.CodeInvalidRequest, "attachment too large", nil)
return
}
h.logger.Error("upload draft attachment", "error", err)
apivalidate.WriteInternal(w, r)
return
}
apiresponse.WriteJSON(w, http.StatusCreated, map[string]string{"id": id})
}
func (h *Handler) DownloadDraftAttachment(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
draftID := chi.URLParam(r, "draftID")
attachmentID := chi.URLParam(r, "attachmentID")
inline := strings.HasSuffix(r.URL.Path, "/inline") || r.URL.Query().Get("inline") == "true"
filename, contentType, body, err := h.svc.OpenDraftAttachment(r.Context(), claims.Sub, draftID, attachmentID)
if err != nil {
if errors.Is(err, ErrNotFound) || errors.Is(err, ErrAttachmentNotFound) {
apivalidate.WriteNotFound(w, r, "not found")
return
}
h.logger.Error("download draft attachment", "error", err)
apivalidate.WriteInternal(w, r)
return
}
defer body.Close()
disposition := "attachment"
if inline {
disposition = "inline"
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Disposition", fmt.Sprintf(`%s; filename="%s"`, disposition, filename))
_, _ = io.Copy(w, body)
}