ultisuite-backend/internal/api/mail/drafts.go
R3D347HR4Y 4eadb91a64 Enhance mail API with rate limiting, idempotency, and attachment management
- 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.
2026-05-22 17:19:16 +02:00

278 lines
8.0 KiB
Go

package mail
import (
"context"
"encoding/json"
"errors"
"github.com/jackc/pgx/v5"
"github.com/ultisuite/ulti-backend/internal/api/query"
"github.com/ultisuite/ulti-backend/internal/mail/sanitize"
"github.com/ultisuite/ulti-backend/internal/mail/threading"
)
type DraftsList struct {
Drafts []map[string]any `json:"drafts"`
Pagination query.PaginationMeta `json:"pagination,omitempty"`
}
func (s *Service) ListDrafts(ctx context.Context, externalID string, params query.ListParams) (DraftsList, error) {
var total int64
err := s.db.QueryRow(ctx, `
SELECT COUNT(*) FROM outbox o
WHERE o.user_id = (SELECT id FROM users WHERE external_id = $1)
AND o.status = 'draft'
`, externalID).Scan(&total)
if err != nil {
return DraftsList{}, err
}
rows, err := s.db.Query(ctx, `
SELECT o.id, o.account_id, o.identity_id, o.to_addrs, o.cc_addrs, o.bcc_addrs,
o.subject, o.body_text, o.updated_at, o.created_at
FROM outbox o
WHERE o.user_id = (SELECT id FROM users WHERE external_id = $1)
AND o.status = 'draft'
ORDER BY o.updated_at DESC
LIMIT $2 OFFSET $3
`, externalID, params.Limit(), params.Offset())
if err != nil {
return DraftsList{}, err
}
defer rows.Close()
drafts := make([]map[string]any, 0)
for rows.Next() {
entry, err := scanDraftListRow(rows)
if err != nil {
return DraftsList{}, err
}
drafts = append(drafts, entry)
}
if err := rows.Err(); err != nil {
return DraftsList{}, err
}
return DraftsList{
Drafts: drafts,
Pagination: params.Meta(&total),
}, nil
}
func (s *Service) GetDraft(ctx context.Context, externalID, draftID string) (map[string]any, error) {
var (
id, accountID, subject, bodyText, bodyHTML, inReplyTo string
identityID *string
toAddrs, ccAddrs, bccAddrs, attachments []byte
references []string
createdAt, updatedAt any
)
err := s.db.QueryRow(ctx, `
SELECT o.id, o.account_id, o.identity_id, o.to_addrs, o.cc_addrs, o.bcc_addrs,
o.subject, o.body_text, o.body_html, o.in_reply_to, o.references_header,
o.attachments, o.created_at, o.updated_at
FROM outbox o
WHERE o.id = $1
AND o.user_id = (SELECT id FROM users WHERE external_id = $2)
AND o.status = 'draft'
`, draftID, externalID).Scan(
&id, &accountID, &identityID, &toAddrs, &ccAddrs, &bccAddrs,
&subject, &bodyText, &bodyHTML, &inReplyTo, &references,
&attachments, &createdAt, &updatedAt,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
return draftDetailMap(id, accountID, identityID, toAddrs, ccAddrs, bccAddrs, subject, bodyText, bodyHTML, inReplyTo, references, attachments, createdAt, updatedAt), nil
}
func (s *Service) CreateDraft(ctx context.Context, userID string, req *draftRequest) (string, error) {
if err := s.validateDraftAccountAndIdentity(ctx, userID, req.AccountID, req.IdentityID); err != nil {
return "", err
}
toJSON, _ := json.Marshal(req.To)
ccJSON, _ := json.Marshal(req.Cc)
bccJSON, _ := json.Marshal(req.Bcc)
attachmentsJSON, _ := json.Marshal(req.Attachments)
if req.Attachments == nil {
attachmentsJSON = []byte("[]")
}
inReplyTo := threading.NormalizeMessageID(req.InReplyTo)
var id string
err := s.db.QueryRow(ctx, `
INSERT INTO outbox (
user_id, account_id, identity_id, to_addrs, cc_addrs, bcc_addrs,
subject, body_text, body_html, in_reply_to, references_header, attachments, status
)
SELECT $1, ma.id, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, 'draft'
FROM mail_accounts ma
WHERE ma.id = $2 AND ma.user_id = $1
RETURNING id
`, userID, req.AccountID, nilIfEmpty(req.IdentityID), toJSON, ccJSON, bccJSON,
req.Subject, req.BodyText, req.BodyHTML, inReplyTo, []string{}, attachmentsJSON).Scan(&id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return "", ErrAccountNotFound
}
return "", err
}
return id, nil
}
func (s *Service) UpdateDraft(ctx context.Context, externalID, draftID string, req *draftRequest) error {
userID, err := s.ResolveUserID(ctx, externalID)
if err != nil {
return err
}
accountID := req.AccountID
if accountID == "" && req.IdentityID != "" {
err := s.db.QueryRow(ctx, `
SELECT account_id FROM outbox
WHERE id = $1 AND user_id = $2 AND status = 'draft'
`, draftID, userID).Scan(&accountID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
return err
}
}
if accountID != "" {
if err := s.validateDraftAccountAndIdentity(ctx, userID, accountID, req.IdentityID); err != nil {
return err
}
}
toJSON, _ := json.Marshal(req.To)
ccJSON, _ := json.Marshal(req.Cc)
bccJSON, _ := json.Marshal(req.Bcc)
attachmentsJSON, _ := json.Marshal(req.Attachments)
if req.Attachments == nil {
attachmentsJSON = []byte("[]")
}
inReplyTo := threading.NormalizeMessageID(req.InReplyTo)
result, err := s.db.Exec(ctx, `
UPDATE outbox o SET
account_id = COALESCE($1, o.account_id),
identity_id = CASE WHEN $2 <> '' THEN $2::uuid ELSE o.identity_id END,
to_addrs = $3,
cc_addrs = $4,
bcc_addrs = $5,
subject = $6,
body_text = $7,
body_html = $8,
in_reply_to = $9,
references_header = $10,
attachments = $11,
updated_at = NOW()
WHERE o.id = $12
AND o.user_id = $13
AND o.status = 'draft'
`, nilIfEmpty(req.AccountID), req.IdentityID, toJSON, ccJSON, bccJSON,
req.Subject, req.BodyText, req.BodyHTML, inReplyTo, []string{}, attachmentsJSON,
draftID, userID)
if err != nil {
return err
}
if result.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
func (s *Service) DeleteDraft(ctx context.Context, externalID, draftID string) error {
result, err := s.db.Exec(ctx, `
DELETE FROM outbox o
WHERE o.id = $1
AND o.user_id = (SELECT id FROM users WHERE external_id = $2)
AND o.status = 'draft'
`, draftID, externalID)
if err != nil {
return err
}
if result.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
func (s *Service) validateDraftAccountAndIdentity(ctx context.Context, userID, accountID, identityID string) error {
var exists bool
err := s.db.QueryRow(ctx, `
SELECT EXISTS(SELECT 1 FROM mail_accounts WHERE id = $1 AND user_id = $2)
`, accountID, userID).Scan(&exists)
if err != nil {
return err
}
if !exists {
return ErrAccountNotFound
}
if identityID == "" {
return nil
}
err = s.db.QueryRow(ctx, `
SELECT EXISTS(SELECT 1 FROM mail_identities WHERE id = $1 AND account_id = $2)
`, identityID, accountID).Scan(&exists)
if err != nil {
return err
}
if !exists {
return ErrNotFound
}
return nil
}
type draftListScanner interface {
Scan(dest ...any) error
}
func scanDraftListRow(rows draftListScanner) (map[string]any, error) {
var id, accountID, subject, bodyText string
var identityID *string
var toAddrs, ccAddrs, bccAddrs []byte
var updatedAt, createdAt any
if err := rows.Scan(&id, &accountID, &identityID, &toAddrs, &ccAddrs, &bccAddrs, &subject, &bodyText, &updatedAt, &createdAt); err != nil {
return nil, err
}
entry := map[string]any{
"id": id, "account_id": accountID, "subject": subject,
"to": json.RawMessage(toAddrs), "cc": json.RawMessage(ccAddrs), "bcc": json.RawMessage(bccAddrs),
"body_text": bodyText, "updated_at": updatedAt, "created_at": createdAt,
}
if identityID != nil {
entry["identity_id"] = *identityID
}
return entry, nil
}
func draftDetailMap(
id, accountID string,
identityID *string,
toAddrs, ccAddrs, bccAddrs []byte,
subject, bodyText, bodyHTML, inReplyTo string,
references []string,
attachments []byte,
createdAt, updatedAt any,
) map[string]any {
out := map[string]any{
"id": id, "account_id": accountID, "subject": subject,
"to": json.RawMessage(toAddrs), "cc": json.RawMessage(ccAddrs), "bcc": json.RawMessage(bccAddrs),
"body_text": bodyText, "body_html": sanitize.SanitizeHTML(bodyHTML),
"in_reply_to": inReplyTo, "references": references,
"attachments": json.RawMessage(attachments),
"created_at": createdAt, "updated_at": updatedAt,
}
if identityID != nil {
out["identity_id"] = *identityID
}
return out
}