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" driveapi "github.com/ultisuite/ulti-backend/internal/api/drive" "github.com/ultisuite/ulti-backend/internal/api/middleware" "github.com/ultisuite/ulti-backend/internal/mail/limits" ) const maxJSONRequestBody = 32 << 10 func (h *Handler) ListMessageAttachments(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) messageID := chi.URLParam(r, "messageID") if h.denyUnlessMessageInScope(w, r, messageID) { return } 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") if h.denyUnlessMessageInScope(w, r, messageID) { return } 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) ReindexMessageAttachments(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) messageID := chi.URLParam(r, "messageID") if h.denyUnlessMessageInScope(w, r, messageID) { return } if h.accountSync == nil { apiresponse.WriteError(w, r, http.StatusServiceUnavailable, "sync_unavailable", "mail sync is not configured", nil) return } if _, err := h.svc.GetMessage(r.Context(), claims.Sub, messageID); err != nil { if errors.Is(err, ErrNotFound) { apivalidate.WriteNotFound(w, r, "not found") return } h.logger.Error("load message for attachment reindex", "message_id", messageID, "error", err) apivalidate.WriteInternal(w, r) return } if err := h.accountSync.ReindexMessageAttachments(r.Context(), claims.Sub, messageID); err != nil { if strings.Contains(err.Error(), "not found") { apivalidate.WriteNotFound(w, r, "not found") return } h.logger.Error("reindex message attachments", "message_id", messageID, "error", err) apiresponse.WriteError(w, r, http.StatusBadGateway, "reindex_failed", "attachment reindex failed", nil) return } apiresponse.WriteJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } func (h *Handler) UploadMessageAttachment(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) messageID := chi.URLParam(r, "messageID") if h.denyUnlessMessageInScope(w, r, messageID) { return } if err := r.ParseMultipartForm(limits.MaxMultipartUploadBytes); 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 writeAttachmentUploadError(w, r, err) { 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") if h.denyUnlessAttachmentInScope(w, r, attachmentID) { return } 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(limits.MaxMultipartUploadBytes); 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 writeAttachmentUploadError(w, r, err) { 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) } type saveToDriveRequest struct { FolderPath string `json:"folder_path"` } func (h *Handler) SaveAttachmentToDrive(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) messageID := chi.URLParam(r, "messageID") attachmentID := chi.URLParam(r, "attachmentID") if h.denyUnlessMessageInScope(w, r, messageID) { return } var req saveToDriveRequest if err := apivalidate.DecodeJSON(w, r, maxJSONRequestBody, &req); err != nil { return } drivePath, err := h.svc.SaveAttachmentToDrive( r.Context(), claims.Sub, claims.Email, claims.Sub, claims.Name, messageID, attachmentID, req.FolderPath, ) if err != nil { if writeSaveToDriveError(w, r, h, err) { return } h.logger.Error("save attachment to drive", "error", err) apivalidate.WriteInternal(w, r) return } apiresponse.WriteJSON(w, http.StatusOK, map[string]string{"drive_path": drivePath}) } func (h *Handler) SaveMessageAttachmentsToDrive(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) messageID := chi.URLParam(r, "messageID") if h.denyUnlessMessageInScope(w, r, messageID) { return } var req saveToDriveRequest if err := apivalidate.DecodeJSON(w, r, maxJSONRequestBody, &req); err != nil { return } list, err := h.svc.SaveMessageAttachmentsToDrive( r.Context(), claims.Sub, claims.Email, claims.Sub, claims.Name, messageID, req.FolderPath, ) if err != nil { if writeSaveToDriveError(w, r, h, err) { return } h.logger.Error("save message attachments to drive", "error", err) apivalidate.WriteInternal(w, r) return } apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"attachments": list}) } func writeSaveToDriveError(w http.ResponseWriter, r *http.Request, h *Handler, err error) bool { switch { case errors.Is(err, ErrNotFound), errors.Is(err, ErrAttachmentNotFound): apivalidate.WriteNotFound(w, r, "not found") return true case errors.Is(err, ErrDriveUnavailable): apiresponse.WriteError(w, r, http.StatusServiceUnavailable, "drive_unavailable", "drive is not available", nil) return true case errors.Is(err, driveapi.ErrQuotaExceeded): apiresponse.WriteError(w, r, http.StatusInsufficientStorage, "drive.quota_exceeded", "drive quota exceeded", nil) return true case errors.Is(err, driveapi.ErrForbidden): apiresponse.WriteError(w, r, http.StatusForbidden, apiresponse.CodeAuthForbidden, "drive access denied", nil) return true default: return false } } func writeAttachmentUploadError(w http.ResponseWriter, r *http.Request, err error) bool { switch { case errors.Is(err, limits.ErrAttachmentTooLarge), errors.Is(err, limits.ErrAttachmentsTotalTooLarge): apiresponse.WriteError(w, r, http.StatusRequestEntityTooLarge, apiresponse.CodeInvalidRequest, "attachment too large", nil) return true case errors.Is(err, limits.ErrTooManyAttachments): apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, "too many attachments", nil) return true default: return false } }