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 }