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/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": 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 }