- Added rate limiting for outbound email sends to prevent abuse, implemented in `internal/api/mail/sendguard`. - Introduced idempotency key support for email sending to avoid duplicate submissions. - Enhanced attachment handling with new limits and validation in `internal/api/mail/limits`. - Updated outbox processing to include retry logic and circuit breaker for SMTP failures. - Improved HTML sanitization for email content to enhance security. - Added unit tests for new features, ensuring robust functionality and error handling. - Updated configuration options in `.env.example` for new mail settings.
214 lines
6.8 KiB
Go
214 lines
6.8 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"
|
|
"github.com/ultisuite/ulti-backend/internal/mail/limits"
|
|
)
|
|
|
|
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(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")
|
|
|
|
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)
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|