diff --git a/cmd/ultid/main.go b/cmd/ultid/main.go index 17dc092..bd9b5ab 100644 --- a/cmd/ultid/main.go +++ b/cmd/ultid/main.go @@ -151,7 +151,13 @@ func main() { sender := smtp.NewSender(pool, credentialManager) smtpCircuit := smtp.NewCircuitBreaker(cfg.MailSMTPCircuitFailures, cfg.MailSMTPCircuitCooldown) guardedSender := smtp.NewGuardedSender(sender, smtpCircuit) - go smtp.NewOutboxProcessor(pool, guardedSender, cfg.MailOutboxInterval, cfg.MailOutboxMaxRetries).Start(ctx) + go smtp.NewOutboxProcessor( + pool, + guardedSender, + cfg.MailOutboxInterval, + cfg.MailOutboxMaxRetries, + smtp.WithAttachmentLoader(&smtp.StorageAttachmentLoader{Client: attachmentStorage}), + ).Start(ctx) sendRateLimiter := sendguard.NewRateLimiter(cfg.MailSendRatePerMinute, cfg.MailSendBurst) diff --git a/internal/api/mail/handlers.go b/internal/api/mail/handlers.go index 391306f..450c0b1 100644 --- a/internal/api/mail/handlers.go +++ b/internal/api/mail/handlers.go @@ -10,11 +10,11 @@ import ( "github.com/ultisuite/ulti-backend/internal/api/apiresponse" "github.com/ultisuite/ulti-backend/internal/api/apivalidate" + "github.com/ultisuite/ulti-backend/internal/api/mail/sendguard" "github.com/ultisuite/ulti-backend/internal/api/middleware" "github.com/ultisuite/ulti-backend/internal/api/query" "github.com/ultisuite/ulti-backend/internal/mail/credentials" "github.com/ultisuite/ulti-backend/internal/mail/limits" - "github.com/ultisuite/ulti-backend/internal/api/mail/sendguard" "github.com/ultisuite/ulti-backend/internal/mail/storage" "github.com/ultisuite/ulti-backend/internal/securityaudit" ) @@ -88,6 +88,10 @@ func (h *Handler) Routes() chi.Router { r.Post("/send", h.SendMessage) + r.Post("/outbox/{outboxID}/send-now", h.SendOutboxNow) + r.Post("/outbox/{outboxID}/reschedule", h.RescheduleOutbox) + r.Post("/outbox/{outboxID}/cancel", h.CancelScheduledOutbox) + r.Get("/rules", h.ListRules) r.Post("/rules", h.CreateRule) r.Put("/rules/{ruleID}", h.UpdateRule) diff --git a/internal/api/mail/handlers_outbox.go b/internal/api/mail/handlers_outbox.go new file mode 100644 index 0000000..f6a393c --- /dev/null +++ b/internal/api/mail/handlers_outbox.go @@ -0,0 +1,97 @@ +package mail + +import ( + "errors" + "net/http" + + "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" +) + +func (h *Handler) SendOutboxNow(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFromContext(r.Context()) + userID, err := h.svc.ResolveUserID(r.Context(), claims.Sub) + if err != nil { + h.writeUserResolveError(w, r, err) + return + } + + outboxID := chi.URLParam(r, "outboxID") + status, err := h.svc.SendOutboxNow(r.Context(), userID, outboxID) + if err != nil { + if h.writeOutboxActionError(w, r, err) { + return + } + h.logger.Error("send outbox now", "error", err, "outbox_id", outboxID) + apivalidate.WriteInternal(w, r) + return + } + apiresponse.WriteJSON(w, http.StatusAccepted, map[string]string{"id": outboxID, "status": status}) +} + +func (h *Handler) RescheduleOutbox(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFromContext(r.Context()) + userID, err := h.svc.ResolveUserID(r.Context(), claims.Sub) + if err != nil { + h.writeUserResolveError(w, r, err) + return + } + + var req rescheduleOutboxRequest + if err := apivalidate.DecodeJSON(w, r, maxOutboxScheduleBody, &req); err != nil { + return + } + scheduledAt, verr := validateRescheduleOutbox(&req) + if verr != nil { + apivalidate.WriteValidationError(w, r, verr) + return + } + + outboxID := chi.URLParam(r, "outboxID") + status, err := h.svc.RescheduleOutbox(r.Context(), userID, outboxID, *scheduledAt) + if err != nil { + if h.writeOutboxActionError(w, r, err) { + return + } + h.logger.Error("reschedule outbox", "error", err, "outbox_id", outboxID) + apivalidate.WriteInternal(w, r) + return + } + apiresponse.WriteJSON(w, http.StatusOK, map[string]string{"id": outboxID, "status": status}) +} + +func (h *Handler) CancelScheduledOutbox(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFromContext(r.Context()) + userID, err := h.svc.ResolveUserID(r.Context(), claims.Sub) + if err != nil { + h.writeUserResolveError(w, r, err) + return + } + + outboxID := chi.URLParam(r, "outboxID") + status, err := h.svc.CancelScheduledOutbox(r.Context(), userID, outboxID) + if err != nil { + if h.writeOutboxActionError(w, r, err) { + return + } + h.logger.Error("cancel scheduled outbox", "error", err, "outbox_id", outboxID) + apivalidate.WriteInternal(w, r) + return + } + apiresponse.WriteJSON(w, http.StatusOK, map[string]string{"id": outboxID, "status": status}) +} + +func (h *Handler) writeOutboxActionError(w http.ResponseWriter, r *http.Request, err error) bool { + if errors.Is(err, ErrNotFound) { + apivalidate.WriteNotFound(w, r, "not found") + return true + } + if errors.Is(err, ErrInvalidOutboxStatus) { + apiresponse.WriteError(w, r, http.StatusConflict, apiresponse.CodeInvalidRequest, "invalid outbox status", nil) + return true + } + return false +} diff --git a/internal/api/mail/handlers_test.go b/internal/api/mail/handlers_test.go index e509b77..adbe129 100644 --- a/internal/api/mail/handlers_test.go +++ b/internal/api/mail/handlers_test.go @@ -8,6 +8,7 @@ import ( "net/http" "net/http/httptest" "testing" + "time" "github.com/go-chi/chi/v5" @@ -129,6 +130,78 @@ func (f *fakeMailService) SendMessage(_ context.Context, userID string, req *sen return "outbox-1", "queued", nil } +func (f *fakeMailService) SendOutboxNow(context.Context, string, string) (string, error) { + return "", ErrNotFound +} + +func (f *fakeMailService) RescheduleOutbox(context.Context, string, string, time.Time) (string, error) { + return "", ErrNotFound +} + +func (f *fakeMailService) CancelScheduledOutbox(context.Context, string, string) (string, error) { + return "", ErrNotFound +} + +type outboxFakeService struct { + fakeMailService + items map[string]string +} + +func newOutboxFakeService() *outboxFakeService { + return &outboxFakeService{ + fakeMailService: *newFakeMailService(), + items: map[string]string{ + "outbox-scheduled": "scheduled", + "outbox-queued": "queued", + }, + } +} + +func (f *outboxFakeService) SendOutboxNow(_ context.Context, userID, outboxID string) (string, error) { + if userID != testUserID { + return "", ErrNotFound + } + status, ok := f.items[outboxID] + if !ok { + return "", ErrNotFound + } + if status != "scheduled" { + return "", ErrInvalidOutboxStatus + } + f.items[outboxID] = "queued" + return "queued", nil +} + +func (f *outboxFakeService) RescheduleOutbox(_ context.Context, userID, outboxID string, _ time.Time) (string, error) { + if userID != testUserID { + return "", ErrNotFound + } + status, ok := f.items[outboxID] + if !ok { + return "", ErrNotFound + } + if status != "scheduled" && status != "queued" { + return "", ErrInvalidOutboxStatus + } + f.items[outboxID] = "scheduled" + return "scheduled", nil +} + +func (f *outboxFakeService) CancelScheduledOutbox(_ context.Context, userID, outboxID string) (string, error) { + if userID != testUserID { + return "", ErrNotFound + } + status, ok := f.items[outboxID] + if !ok { + return "", ErrNotFound + } + if status != "scheduled" { + return "", ErrInvalidOutboxStatus + } + f.items[outboxID] = "cancelled" + return "cancelled", nil +} + func (f *fakeMailService) ListDrafts(context.Context, string, query.ListParams) (DraftsList, error) { return DraftsList{}, nil } @@ -568,3 +641,158 @@ func TestDeleteMessage(t *testing.T) { t.Fatalf("get after delete status = %d, want %d", rec.Code, http.StatusNotFound) } } + +func TestSendOutboxNow(t *testing.T) { + svc := newOutboxFakeService() + router := newTestMailRouter(svc) + + t.Run("happy path", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/outbox/outbox-scheduled/send-now", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusAccepted { + t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusAccepted, rec.Body.String()) + } + + var resp map[string]string + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode body: %v", err) + } + if resp["id"] != "outbox-scheduled" || resp["status"] != "queued" { + t.Fatalf("response = %#v", resp) + } + if svc.items["outbox-scheduled"] != "queued" { + t.Fatalf("item status = %q, want queued", svc.items["outbox-scheduled"]) + } + }) + + t.Run("invalid status", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/outbox/outbox-queued/send-now", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusConflict { + t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusConflict, rec.Body.String()) + } + }) + + t.Run("not found", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/outbox/missing/send-now", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusNotFound, rec.Body.String()) + } + }) +} + +func TestRescheduleOutbox(t *testing.T) { + svc := newOutboxFakeService() + router := newTestMailRouter(svc) + + future := time.Now().Add(2 * time.Hour).UTC().Format(time.RFC3339) + + t.Run("happy path", func(t *testing.T) { + body, err := json.Marshal(map[string]string{"schedule_at": future}) + if err != nil { + t.Fatalf("marshal payload: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/outbox/outbox-scheduled/reschedule", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp map[string]string + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode body: %v", err) + } + if resp["status"] != "scheduled" { + t.Fatalf("response = %#v", resp) + } + }) + + t.Run("invalid schedule_at", func(t *testing.T) { + body, err := json.Marshal(map[string]string{"schedule_at": "not-a-date"}) + if err != nil { + t.Fatalf("marshal payload: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/outbox/outbox-scheduled/reschedule", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } + }) + + t.Run("invalid status", func(t *testing.T) { + svc.items["outbox-sent"] = "sent" + body, err := json.Marshal(map[string]string{"schedule_at": future}) + if err != nil { + t.Fatalf("marshal payload: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/outbox/outbox-sent/reschedule", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusConflict { + t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusConflict, rec.Body.String()) + } + }) +} + +func TestCancelScheduledOutbox(t *testing.T) { + svc := newOutboxFakeService() + router := newTestMailRouter(svc) + + t.Run("happy path", func(t *testing.T) { + svc.items["outbox-cancel-me"] = "scheduled" + + req := httptest.NewRequest(http.MethodPost, "/outbox/outbox-cancel-me/cancel", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp map[string]string + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode body: %v", err) + } + if resp["status"] != "cancelled" { + t.Fatalf("response = %#v", resp) + } + }) + + t.Run("invalid status", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/outbox/outbox-queued/cancel", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusConflict { + t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusConflict, rec.Body.String()) + } + }) + + t.Run("not found", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/outbox/missing/cancel", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusNotFound, rec.Body.String()) + } + }) +} diff --git a/internal/api/mail/outbox_scheduling.go b/internal/api/mail/outbox_scheduling.go new file mode 100644 index 0000000..8776cac --- /dev/null +++ b/internal/api/mail/outbox_scheduling.go @@ -0,0 +1,73 @@ +package mail + +import ( + "context" + "errors" + "time" + + "github.com/jackc/pgx/v5" +) + +var ErrInvalidOutboxStatus = errors.New("invalid outbox status") + +func (s *Service) SendOutboxNow(ctx context.Context, userID, outboxID string) (string, error) { + var status string + err := s.db.QueryRow(ctx, ` + UPDATE outbox SET status = 'queued', scheduled_at = NULL, updated_at = NOW() + WHERE id = $1 AND user_id = $2 AND status = 'scheduled' + RETURNING status + `, outboxID, userID).Scan(&status) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return "", s.outboxActionError(ctx, userID, outboxID) + } + return "", err + } + return status, nil +} + +func (s *Service) RescheduleOutbox(ctx context.Context, userID, outboxID string, scheduledAt time.Time) (string, error) { + var status string + err := s.db.QueryRow(ctx, ` + UPDATE outbox SET status = 'scheduled', scheduled_at = $3, updated_at = NOW() + WHERE id = $1 AND user_id = $2 AND status IN ('scheduled', 'queued') + RETURNING status + `, outboxID, userID, scheduledAt).Scan(&status) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return "", s.outboxActionError(ctx, userID, outboxID) + } + return "", err + } + return status, nil +} + +func (s *Service) CancelScheduledOutbox(ctx context.Context, userID, outboxID string) (string, error) { + var status string + err := s.db.QueryRow(ctx, ` + UPDATE outbox SET status = 'cancelled', scheduled_at = NULL, updated_at = NOW() + WHERE id = $1 AND user_id = $2 AND status = 'scheduled' + RETURNING status + `, outboxID, userID).Scan(&status) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return "", s.outboxActionError(ctx, userID, outboxID) + } + return "", err + } + return status, nil +} + +func (s *Service) outboxActionError(ctx context.Context, userID, outboxID string) error { + var status string + err := s.db.QueryRow(ctx, ` + SELECT status FROM outbox WHERE id = $1 AND user_id = $2 + `, outboxID, userID).Scan(&status) + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + if err != nil { + return err + } + return ErrInvalidOutboxStatus +} diff --git a/internal/api/mail/service_iface.go b/internal/api/mail/service_iface.go index 78d9cfa..271c9ad 100644 --- a/internal/api/mail/service_iface.go +++ b/internal/api/mail/service_iface.go @@ -3,6 +3,7 @@ package mail import ( "context" "io" + "time" "github.com/ultisuite/ulti-backend/internal/api/query" ) @@ -21,6 +22,9 @@ type ServiceAPI interface { DeleteMessage(ctx context.Context, externalID, messageID string) error GetThread(ctx context.Context, externalID, threadID string) (map[string]any, error) SendMessage(ctx context.Context, userID string, req *sendMessageRequest) (id, status string, err error) + SendOutboxNow(ctx context.Context, userID, outboxID string) (status string, err error) + RescheduleOutbox(ctx context.Context, userID, outboxID string, scheduledAt time.Time) (status string, err error) + CancelScheduledOutbox(ctx context.Context, userID, outboxID string) (status string, err error) ListDrafts(ctx context.Context, externalID string, params query.ListParams) (DraftsList, error) GetDraft(ctx context.Context, externalID, draftID string) (map[string]any, error) CreateDraft(ctx context.Context, userID string, req *draftRequest) (string, error) diff --git a/internal/api/mail/validate.go b/internal/api/mail/validate.go index 0cdf7f6..1538c11 100644 --- a/internal/api/mail/validate.go +++ b/internal/api/mail/validate.go @@ -8,6 +8,7 @@ import ( "net/url" "strconv" "strings" + "time" "unicode" "github.com/ultisuite/ulti-backend/internal/api/apivalidate" @@ -19,14 +20,15 @@ const ( maxWebhookRequestBody = 128 << 10 // 128 KiB maxRulesRequestBody = 256 << 10 // 256 KiB maxFlagsLabelsBody = 32 << 10 // 32 KiB + maxOutboxScheduleBody = 4 << 10 // 4 KiB maxWebhookBodyTemplate = 64 << 10 // 64 KiB maxWebhookHeaders = 20 maxHeaderNameLen = 256 maxHeaderValueLen = 8192 - maxSubjectLen = 998 - maxEmailLen = 320 + maxSubjectLen = 998 + maxEmailLen = 320 maxHostLen = 253 maxAccountName = 128 maxUsernameLen = 256 @@ -193,6 +195,25 @@ type sendMessageRequest struct { IdempotencyKey string `json:"-"` } +type rescheduleOutboxRequest struct { + ScheduleAt string `json:"schedule_at"` +} + +func validateRescheduleOutbox(req *rescheduleOutboxRequest) (*time.Time, *apivalidate.ValidationError) { + scheduleAt := strings.TrimSpace(req.ScheduleAt) + if scheduleAt == "" { + return nil, apivalidate.NewValidationError(apivalidate.FieldDetail{Field: "schedule_at", Message: "required"}) + } + parsed, err := time.Parse(time.RFC3339, scheduleAt) + if err != nil { + return nil, apivalidate.NewValidationError(apivalidate.FieldDetail{Field: "schedule_at", Message: "invalid"}) + } + if !parsed.After(time.Now()) { + return nil, apivalidate.NewValidationError(apivalidate.FieldDetail{Field: "schedule_at", Message: "must be in the future"}) + } + return &parsed, nil +} + func validateSendMessage(req *sendMessageRequest) *apivalidate.ValidationError { var details []apivalidate.FieldDetail if strings.TrimSpace(req.AccountID) == "" { diff --git a/internal/mail/smtp/attachments.go b/internal/mail/smtp/attachments.go new file mode 100644 index 0000000..8501e95 --- /dev/null +++ b/internal/mail/smtp/attachments.go @@ -0,0 +1,92 @@ +package smtp + +import ( + "context" + "encoding/json" + "fmt" + "io" + + "github.com/ultisuite/ulti-backend/internal/mail/storage" +) + +// SendAttachment is a MIME body part ready for embedding in an outbound message. +type SendAttachment struct { + Filename string + ContentType string + ContentID string + IsInline bool + Data []byte +} + +// AttachmentLoader fetches attachment bytes referenced by outbox JSON. +type AttachmentLoader interface { + Load(ctx context.Context, s3Key string) ([]byte, error) +} + +// StorageAttachmentLoader loads attachment bytes from object storage. +type StorageAttachmentLoader struct { + Client *storage.Client +} + +func (l *StorageAttachmentLoader) Load(ctx context.Context, s3Key string) ([]byte, error) { + if l == nil || l.Client == nil { + return nil, fmt.Errorf("object storage not configured") + } + obj, err := l.Client.Get(ctx, s3Key) + if err != nil { + return nil, err + } + defer obj.Close() + return io.ReadAll(obj) +} + +type outboxAttachmentRef struct { + ID string `json:"id"` + Filename string `json:"filename"` + ContentType string `json:"content_type"` + Size int64 `json:"size"` + S3Bucket string `json:"s3_bucket"` + S3Key string `json:"s3_key"` + ContentID string `json:"content_id,omitempty"` + IsInline bool `json:"is_inline"` +} + +func parseOutboxAttachmentsJSON(data []byte) ([]outboxAttachmentRef, error) { + if len(data) == 0 || string(data) == "[]" { + return nil, nil + } + var refs []outboxAttachmentRef + if err := json.Unmarshal(data, &refs); err != nil { + return nil, fmt.Errorf("parse outbox attachments: %w", err) + } + return refs, nil +} + +func resolveOutboxAttachments(ctx context.Context, loader AttachmentLoader, data []byte) ([]SendAttachment, error) { + refs, err := parseOutboxAttachmentsJSON(data) + if err != nil { + return nil, err + } + if len(refs) == 0 { + return nil, nil + } + if loader == nil { + return nil, fmt.Errorf("attachment loader not configured") + } + + attachments := make([]SendAttachment, 0, len(refs)) + for _, ref := range refs { + payload, err := loader.Load(ctx, ref.S3Key) + if err != nil { + return nil, fmt.Errorf("load attachment %q: %w", ref.Filename, err) + } + attachments = append(attachments, SendAttachment{ + Filename: ref.Filename, + ContentType: ref.ContentType, + ContentID: ref.ContentID, + IsInline: ref.IsInline, + Data: payload, + }) + } + return attachments, nil +} diff --git a/internal/mail/smtp/attachments_test.go b/internal/mail/smtp/attachments_test.go new file mode 100644 index 0000000..13227d4 --- /dev/null +++ b/internal/mail/smtp/attachments_test.go @@ -0,0 +1,92 @@ +package smtp + +import ( + "context" + "errors" + "reflect" + "testing" +) + +func TestParseOutboxAttachmentsJSON(t *testing.T) { + data := []byte(`[{ + "id":"att-1", + "filename":"doc.pdf", + "content_type":"application/pdf", + "size":12, + "s3_bucket":"mail-attachments", + "s3_key":"user/drafts/id/doc.pdf", + "content_id":"logo@ultimail", + "is_inline":true + }]`) + + refs, err := parseOutboxAttachmentsJSON(data) + if err != nil { + t.Fatalf("parseOutboxAttachmentsJSON() error = %v", err) + } + if len(refs) != 1 { + t.Fatalf("len(refs) = %d, want 1", len(refs)) + } + if refs[0].Filename != "doc.pdf" || !refs[0].IsInline || refs[0].ContentID != "logo@ultimail" { + t.Fatalf("unexpected ref: %+v", refs[0]) + } +} + +func TestParseOutboxAttachmentsJSON_empty(t *testing.T) { + for _, data := range [][]byte{nil, []byte{}, []byte("[]")} { + refs, err := parseOutboxAttachmentsJSON(data) + if err != nil { + t.Fatalf("parseOutboxAttachmentsJSON(%q) error = %v", data, err) + } + if refs != nil { + t.Fatalf("parseOutboxAttachmentsJSON(%q) = %v, want nil", data, refs) + } + } +} + +type stubAttachmentLoader struct { + data map[string][]byte + err error +} + +func (s stubAttachmentLoader) Load(_ context.Context, s3Key string) ([]byte, error) { + if s.err != nil { + return nil, s.err + } + payload, ok := s.data[s3Key] + if !ok { + return nil, errors.New("missing object") + } + return payload, nil +} + +func TestResolveOutboxAttachments(t *testing.T) { + data := []byte(`[{ + "filename":"note.txt", + "content_type":"text/plain", + "s3_key":"drafts/note.txt" + }]`) + loader := stubAttachmentLoader{data: map[string][]byte{ + "drafts/note.txt": []byte("hello"), + }} + + got, err := resolveOutboxAttachments(context.Background(), loader, data) + if err != nil { + t.Fatalf("resolveOutboxAttachments() error = %v", err) + } + want := []SendAttachment{{ + Filename: "note.txt", + ContentType: "text/plain", + Data: []byte("hello"), + }} + if !reflect.DeepEqual(got, want) { + t.Fatalf("resolveOutboxAttachments() = %+v, want %+v", got, want) + } +} + +func TestResolveOutboxAttachments_requiresLoader(t *testing.T) { + data := []byte(`[{"filename":"note.txt","s3_key":"drafts/note.txt"}]`) + _, err := resolveOutboxAttachments(context.Background(), nil, data) + if err == nil { + t.Fatal("expected error when loader is nil") + } +} diff --git a/internal/mail/smtp/mime.go b/internal/mail/smtp/mime.go new file mode 100644 index 0000000..740fa90 --- /dev/null +++ b/internal/mail/smtp/mime.go @@ -0,0 +1,187 @@ +package smtp + +import ( + "bytes" + "encoding/base64" + "fmt" + "io" + "mime" + "mime/multipart" + "net/textproto" + "strings" + "time" +) + +func buildMessage(req *SendRequest) string { + if len(req.Attachments) == 0 { + return buildMessageWithoutAttachments(req) + } + return buildMessageWithAttachments(req) +} + +func buildMessageWithoutAttachments(req *SendRequest) string { + var b strings.Builder + writeCommonHeaders(&b, req) + + if req.BodyHTML != "" { + boundary := fmt.Sprintf("----=_Part_%d", time.Now().UnixNano()) + b.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=\"%s\"\r\n", boundary)) + b.WriteString("\r\n") + b.WriteString(fmt.Sprintf("--%s\r\n", boundary)) + b.WriteString("Content-Type: text/plain; charset=UTF-8\r\n\r\n") + b.WriteString(req.BodyText) + b.WriteString(fmt.Sprintf("\r\n--%s\r\n", boundary)) + b.WriteString("Content-Type: text/html; charset=UTF-8\r\n\r\n") + b.WriteString(req.BodyHTML) + b.WriteString(fmt.Sprintf("\r\n--%s--\r\n", boundary)) + } else { + b.WriteString("Content-Type: text/plain; charset=UTF-8\r\n\r\n") + b.WriteString(req.BodyText) + } + + return b.String() +} + +func buildMessageWithAttachments(req *SendRequest) string { + body := &bytes.Buffer{} + mixed := multipart.NewWriter(body) + + _ = writeMessageBodyPart(mixed, req.BodyText, req.BodyHTML) + for _, att := range req.Attachments { + _ = writeAttachmentPart(mixed, att) + } + _ = mixed.Close() + + var head strings.Builder + writeCommonHeaders(&head, req) + head.WriteString(fmt.Sprintf("Content-Type: multipart/mixed; boundary=\"%s\"\r\n\r\n", mixed.Boundary())) + head.Write(body.Bytes()) + return head.String() +} + +func writeCommonHeaders(b *strings.Builder, req *SendRequest) { + b.WriteString(fmt.Sprintf("From: %s\r\n", req.From)) + b.WriteString(fmt.Sprintf("To: %s\r\n", strings.Join(req.To, ", "))) + if len(req.Cc) > 0 { + b.WriteString(fmt.Sprintf("Cc: %s\r\n", strings.Join(req.Cc, ", "))) + } + b.WriteString(fmt.Sprintf("Subject: %s\r\n", req.Subject)) + b.WriteString(fmt.Sprintf("Date: %s\r\n", time.Now().Format(time.RFC1123Z))) + b.WriteString(fmt.Sprintf("Message-ID: %s\r\n", generateMessageID(req.From))) + b.WriteString("MIME-Version: 1.0\r\n") + + if req.InReplyTo != "" { + b.WriteString(fmt.Sprintf("In-Reply-To: %s\r\n", req.InReplyTo)) + } + if len(req.References) > 0 { + b.WriteString(fmt.Sprintf("References: %s\r\n", strings.Join(req.References, " "))) + } +} + +func writeMessageBodyPart(w *multipart.Writer, text, html string) error { + if html != "" { + altBuf := &bytes.Buffer{} + alt := multipart.NewWriter(altBuf) + + if err := writeTextPart(alt, "text/plain; charset=UTF-8", text); err != nil { + return err + } + if err := writeTextPart(alt, "text/html; charset=UTF-8", html); err != nil { + return err + } + if err := alt.Close(); err != nil { + return err + } + + h := textproto.MIMEHeader{} + h.Set("Content-Type", fmt.Sprintf("multipart/alternative; boundary=\"%s\"", alt.Boundary())) + part, err := w.CreatePart(h) + if err != nil { + return err + } + _, err = part.Write(altBuf.Bytes()) + return err + } + + h := textproto.MIMEHeader{} + h.Set("Content-Type", "text/plain; charset=UTF-8") + part, err := w.CreatePart(h) + if err != nil { + return err + } + _, err = part.Write([]byte(text)) + return err +} + +func writeTextPart(w *multipart.Writer, contentType, body string) error { + h := textproto.MIMEHeader{} + h.Set("Content-Type", contentType) + part, err := w.CreatePart(h) + if err != nil { + return err + } + _, err = part.Write([]byte(body)) + return err +} + +func writeAttachmentPart(w *multipart.Writer, att SendAttachment) error { + contentType := att.ContentType + if contentType == "" { + contentType = "application/octet-stream" + } + + h := textproto.MIMEHeader{} + if att.Filename != "" { + h.Set("Content-Type", mime.FormatMediaType(contentType, map[string]string{ + "name": att.Filename, + })) + } else { + h.Set("Content-Type", contentType) + } + h.Set("Content-Transfer-Encoding", "base64") + + disposition := "attachment" + if att.IsInline { + disposition = "inline" + } + if att.Filename != "" { + h.Set("Content-Disposition", mime.FormatMediaType(disposition, map[string]string{ + "filename": att.Filename, + })) + } else { + h.Set("Content-Disposition", disposition) + } + + if att.IsInline && att.ContentID != "" { + cid := att.ContentID + if !strings.HasPrefix(cid, "<") { + cid = "<" + cid + ">" + } + h.Set("Content-ID", cid) + } + + part, err := w.CreatePart(h) + if err != nil { + return err + } + return writeBase64(part, att.Data) +} + +func writeBase64(w io.Writer, data []byte) error { + encoded := base64.StdEncoding.EncodeToString(data) + for i := 0; i < len(encoded); i += 76 { + end := i + 76 + if end > len(encoded) { + end = len(encoded) + } + if _, err := io.WriteString(w, encoded[i:end]); err != nil { + return err + } + if end < len(encoded) { + if _, err := io.WriteString(w, "\r\n"); err != nil { + return err + } + } + } + return nil +} diff --git a/internal/mail/smtp/mime_test.go b/internal/mail/smtp/mime_test.go new file mode 100644 index 0000000..5e37e9c --- /dev/null +++ b/internal/mail/smtp/mime_test.go @@ -0,0 +1,267 @@ +package smtp + +import ( + "bytes" + "encoding/base64" + "io" + "mime" + "mime/multipart" + "net/mail" + "strings" + "testing" +) + +func TestBuildMessage_plainOnly(t *testing.T) { + msg := buildMessage(&SendRequest{ + From: "alice@example.com", + To: []string{"bob@example.com"}, + Subject: "Hello", + BodyText: "Plain body", + }) + + if strings.Contains(msg, "multipart/") { + t.Fatal("plain-only message must not be multipart") + } + if !strings.Contains(msg, "Content-Type: text/plain; charset=UTF-8") { + t.Fatal("missing text/plain content type") + } + if !strings.Contains(msg, "Plain body") { + t.Fatal("missing body text") + } +} + +func TestBuildMessage_alternativeOnly(t *testing.T) { + msg := buildMessage(&SendRequest{ + From: "alice@example.com", + To: []string{"bob@example.com"}, + Subject: "Hello", + BodyText: "Plain body", + BodyHTML: "
HTML body
", + }) + + if !strings.Contains(msg, "Content-Type: multipart/alternative") { + t.Fatal("missing multipart/alternative top-level content type") + } + if strings.Contains(msg, "multipart/mixed") { + t.Fatal("alternative-only message must not be multipart/mixed") + } + if !strings.Contains(msg, "Content-Type: text/html; charset=UTF-8") { + t.Fatal("missing text/html part") + } +} + +func TestBuildMessage_mixedWithPlainAttachment(t *testing.T) { + payload := []byte("file-bytes") + msg := buildMessage(&SendRequest{ + From: "alice@example.com", + To: []string{"bob@example.com"}, + Subject: "With file", + BodyText: "See attached", + Attachments: []SendAttachment{{ + Filename: "doc.pdf", + ContentType: "application/pdf", + Data: payload, + }}, + }) + + parsed, err := mail.ReadMessage(strings.NewReader(msg)) + if err != nil { + t.Fatalf("ReadMessage: %v", err) + } + + mediaType, params, err := mime.ParseMediaType(parsed.Header.Get("Content-Type")) + if err != nil { + t.Fatalf("ParseMediaType: %v", err) + } + if mediaType != "multipart/mixed" { + t.Fatalf("top-level content type = %q, want multipart/mixed", mediaType) + } + + parts := readAllParts(t, parsed.Body, params["boundary"]) + if len(parts) != 2 { + t.Fatalf("part count = %d, want 2", len(parts)) + } + + if got := parts[0].Header.Get("Content-Type"); got != "text/plain; charset=UTF-8" { + t.Fatalf("body part content type = %q", got) + } + if string(parts[0].Body) != "See attached" { + t.Fatalf("body part = %q", parts[0].Body) + } + + attType, attParams, err := mime.ParseMediaType(parts[1].Header.Get("Content-Type")) + if err != nil { + t.Fatalf("ParseMediaType attachment: %v", err) + } + if attType != "application/pdf" { + t.Fatalf("attachment content type = %q", attType) + } + if attParams["name"] != "doc.pdf" { + t.Fatalf("attachment name = %q", attParams["name"]) + } + if parts[1].Header.Get("Content-Transfer-Encoding") != "base64" { + t.Fatal("attachment missing base64 transfer encoding") + } + disp, dispParams, err := mime.ParseMediaType(parts[1].Header.Get("Content-Disposition")) + if err != nil { + t.Fatalf("ParseMediaType disposition: %v", err) + } + if disp != "attachment" { + t.Fatalf("disposition = %q, want attachment", disp) + } + if dispParams["filename"] != "doc.pdf" { + t.Fatalf("filename = %q", dispParams["filename"]) + } + if !bytes.Equal(decodeBase64Part(t, parts[1].Body), payload) { + t.Fatal("attachment payload mismatch") + } +} + +func TestBuildMessage_mixedWithAlternativeAndAttachment(t *testing.T) { + msg := buildMessage(&SendRequest{ + From: "alice@example.com", + To: []string{"bob@example.com"}, + Subject: "Rich mail", + BodyText: "Plain", + BodyHTML: "HTML", + Attachments: []SendAttachment{{ + Filename: "note.txt", + ContentType: "text/plain", + Data: []byte("attachment"), + }}, + }) + + parsed, err := mail.ReadMessage(strings.NewReader(msg)) + if err != nil { + t.Fatalf("ReadMessage: %v", err) + } + + mediaType, params, err := mime.ParseMediaType(parsed.Header.Get("Content-Type")) + if err != nil { + t.Fatalf("ParseMediaType: %v", err) + } + if mediaType != "multipart/mixed" { + t.Fatalf("top-level content type = %q, want multipart/mixed", mediaType) + } + + parts := readAllParts(t, parsed.Body, params["boundary"]) + if len(parts) != 2 { + t.Fatalf("part count = %d, want 2", len(parts)) + } + + altType, altParams, err := mime.ParseMediaType(parts[0].Header.Get("Content-Type")) + if err != nil { + t.Fatalf("ParseMediaType alternative: %v", err) + } + if altType != "multipart/alternative" { + t.Fatalf("first part type = %q, want multipart/alternative", altType) + } + + altParts := readAllParts(t, bytes.NewReader(parts[0].Body), altParams["boundary"]) + if len(altParts) != 2 { + t.Fatalf("alternative part count = %d, want 2", len(altParts)) + } + if string(altParts[0].Body) != "Plain" { + t.Fatalf("plain part = %q", altParts[0].Body) + } + if string(altParts[1].Body) != "HTML" { + t.Fatalf("html part = %q", altParts[1].Body) + } + + disp, _, err := mime.ParseMediaType(parts[1].Header.Get("Content-Disposition")) + if err != nil { + t.Fatalf("ParseMediaType disposition: %v", err) + } + if disp != "attachment" { + t.Fatalf("attachment disposition = %q", disp) + } +} + +func TestBuildMessage_inlineAttachmentHeaders(t *testing.T) { + msg := buildMessage(&SendRequest{ + From: "alice@example.com", + To: []string{"bob@example.com"}, + Subject: "Inline", + BodyHTML: `