- Introduced new functionality for managing email attachments and drafts in the mail API. - Added handlers for listing, uploading, and downloading message attachments in `internal/api/mail/handlers_attachments.go`. - Implemented draft management endpoints for creating, updating, and deleting drafts in `internal/api/mail/handlers_drafts.go`. - Created new service methods for handling draft and attachment operations in `internal/api/mail/drafts.go` and `internal/api/mail/storage.go`. - Added validation and error handling for draft and attachment operations. - Included unit tests for draft and folder functionalities in `internal/api/mail/drafts_test.go` and `internal/api/mail/folders_test.go`. - Updated API routes to support new draft and attachment features, enhancing overall mail management capabilities.
277 lines
7.9 KiB
Go
277 lines
7.9 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/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
|
|
}
|