ultisuite-backend/internal/api/mail/identities.go
R3D347HR4Y 95196f7777 Add mail attachment and draft management features
- 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.
2026-05-22 17:14:36 +02:00

218 lines
6.4 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/securityaudit"
)
type IdentitiesList struct {
Identities []map[string]any `json:"identities"`
Pagination query.PaginationMeta `json:"pagination,omitempty"`
}
const identitySelectColumns = `
mi.id, mi.account_id, mi.email, mi.name, mi.is_default, mi.signature_html, mi.reply_to_addrs, mi.created_at, mi.updated_at
`
func (s *Service) verifyAccountOwnership(ctx context.Context, externalID, accountID string) error {
var exists bool
err := s.db.QueryRow(ctx, `
SELECT EXISTS(
SELECT 1 FROM mail_accounts ma
JOIN users u ON ma.user_id = u.id
WHERE ma.id = $1 AND u.external_id = $2
)
`, accountID, externalID).Scan(&exists)
if err != nil {
return err
}
if !exists {
return ErrAccountNotFound
}
return nil
}
func identityOwnershipJoin() string {
return `
FROM mail_identities mi
JOIN mail_accounts ma ON mi.account_id = ma.id
JOIN users u ON ma.user_id = u.id
`
}
func scanIdentity(id, accountID, email, name, signatureHTML string, isDefault bool, replyToJSON []byte, createdAt, updatedAt any) map[string]any {
replyTo := parseReplyToAddrs(replyToJSON)
return map[string]any{
"id": id,
"account_id": accountID,
"email": email,
"name": name,
"is_default": isDefault,
"signature_html": signatureHTML,
"reply_to_addrs": replyTo,
"created_at": createdAt,
"updated_at": updatedAt,
}
}
func parseReplyToAddrs(raw []byte) []string {
if len(raw) == 0 {
return []string{}
}
var addrs []string
if err := json.Unmarshal(raw, &addrs); err != nil || addrs == nil {
return []string{}
}
return addrs
}
func (s *Service) clearDefaultIdentities(ctx context.Context, accountID string) error {
_, err := s.db.Exec(ctx, `
UPDATE mail_identities SET is_default = false, updated_at = NOW()
WHERE account_id = $1 AND is_default = true
`, accountID)
return err
}
func (s *Service) ListIdentities(ctx context.Context, externalID, accountID string, params query.ListParams) (IdentitiesList, error) {
if err := s.verifyAccountOwnership(ctx, externalID, accountID); err != nil {
return IdentitiesList{}, err
}
var total int64
countQuery := "SELECT COUNT(*) " + identityOwnershipJoin() + " WHERE mi.account_id = $1 AND u.external_id = $2"
if err := s.db.QueryRow(ctx, countQuery, accountID, externalID).Scan(&total); err != nil {
return IdentitiesList{}, err
}
listQuery := "SELECT " + identitySelectColumns + identityOwnershipJoin() +
" WHERE mi.account_id = $1 AND u.external_id = $2 ORDER BY mi.is_default DESC, mi.created_at ASC LIMIT $3 OFFSET $4"
rows, err := s.db.Query(ctx, listQuery, accountID, externalID, params.Limit(), params.Offset())
if err != nil {
return IdentitiesList{}, err
}
defer rows.Close()
identities := make([]map[string]any, 0)
for rows.Next() {
var id, acctID, email, name, signatureHTML string
var isDefault bool
var replyToJSON []byte
var createdAt, updatedAt any
if err := rows.Scan(&id, &acctID, &email, &name, &isDefault, &signatureHTML, &replyToJSON, &createdAt, &updatedAt); err != nil {
return IdentitiesList{}, err
}
identities = append(identities, scanIdentity(id, acctID, email, name, signatureHTML, isDefault, replyToJSON, createdAt, updatedAt))
}
if err := rows.Err(); err != nil {
return IdentitiesList{}, err
}
return IdentitiesList{
Identities: identities,
Pagination: params.Meta(&total),
}, nil
}
func (s *Service) GetIdentity(ctx context.Context, externalID, identityID string) (map[string]any, error) {
query := "SELECT " + identitySelectColumns + identityOwnershipJoin() + " WHERE mi.id = $1 AND u.external_id = $2"
var id, accountID, email, name, signatureHTML string
var isDefault bool
var replyToJSON []byte
var createdAt, updatedAt any
err := s.db.QueryRow(ctx, query, identityID, externalID).Scan(
&id, &accountID, &email, &name, &isDefault, &signatureHTML, &replyToJSON, &createdAt, &updatedAt,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
return scanIdentity(id, accountID, email, name, signatureHTML, isDefault, replyToJSON, createdAt, updatedAt), nil
}
func (s *Service) CreateIdentity(ctx context.Context, externalID, accountID string, req *createIdentityRequest) (string, error) {
if err := s.verifyAccountOwnership(ctx, externalID, accountID); err != nil {
return "", err
}
if req.IsDefault {
if err := s.clearDefaultIdentities(ctx, accountID); err != nil {
return "", err
}
}
replyToJSON, _ := json.Marshal(req.ReplyToAddrs)
var id string
err := s.db.QueryRow(ctx, `
INSERT INTO mail_identities (account_id, email, name, is_default, signature_html, reply_to_addrs)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id
`, accountID, req.Email, req.Name, req.IsDefault, req.SignatureHTML, replyToJSON).Scan(&id)
if err != nil {
return "", err
}
return id, nil
}
func (s *Service) UpdateIdentity(ctx context.Context, externalID, identityID string, req *updateIdentityRequest) error {
identity, err := s.GetIdentity(ctx, externalID, identityID)
if err != nil {
return err
}
accountID, _ := identity["account_id"].(string)
if req.IsDefault {
if err := s.clearDefaultIdentities(ctx, accountID); err != nil {
return err
}
}
replyToJSON, _ := json.Marshal(req.ReplyToAddrs)
result, err := s.db.Exec(ctx, `
UPDATE mail_identities mi SET
email = $1, name = $2, is_default = $3, signature_html = $4, reply_to_addrs = $5, updated_at = NOW()
FROM mail_accounts ma
JOIN users u ON ma.user_id = u.id
WHERE mi.id = $6 AND mi.account_id = ma.id AND u.external_id = $7
`, req.Email, req.Name, req.IsDefault, req.SignatureHTML, replyToJSON, identityID, externalID)
if err != nil {
return err
}
if result.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
func (s *Service) DeleteIdentity(ctx context.Context, externalID, identityID string) error {
result, err := s.db.Exec(ctx, `
DELETE FROM mail_identities mi
USING mail_accounts ma, users u
WHERE mi.id = $1 AND mi.account_id = ma.id AND ma.user_id = u.id AND u.external_id = $2
`, identityID, externalID)
if err != nil {
return err
}
if result.RowsAffected() == 0 {
return ErrNotFound
}
if s.audit != nil {
s.audit.Log(ctx, externalID, securityaudit.ActionCriticalDeletion, map[string]any{
"target": "mail_identity", "identity_id": identityID,
})
}
return nil
}