250 lines
7.5 KiB
Go
250 lines
7.5 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.default_signature_id, 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, defaultSignatureID *string, replyToJSON []byte, createdAt, updatedAt any) map[string]any {
|
|
replyTo := parseReplyToAddrs(replyToJSON)
|
|
out := 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,
|
|
}
|
|
if defaultSignatureID != nil && *defaultSignatureID != "" {
|
|
out["default_signature_id"] = *defaultSignatureID
|
|
}
|
|
return out
|
|
}
|
|
|
|
func nullableUUID(id string) any {
|
|
if id == "" {
|
|
return nil
|
|
}
|
|
return id
|
|
}
|
|
|
|
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 defaultSignatureID *string
|
|
var replyToJSON []byte
|
|
var createdAt, updatedAt any
|
|
if err := rows.Scan(&id, &acctID, &email, &name, &isDefault, &signatureHTML, &defaultSignatureID, &replyToJSON, &createdAt, &updatedAt); err != nil {
|
|
return IdentitiesList{}, err
|
|
}
|
|
identities = append(identities, scanIdentity(id, acctID, email, name, signatureHTML, isDefault, defaultSignatureID, 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 defaultSignatureID *string
|
|
var replyToJSON []byte
|
|
var createdAt, updatedAt any
|
|
err := s.db.QueryRow(ctx, query, identityID, externalID).Scan(
|
|
&id, &accountID, &email, &name, &isDefault, &signatureHTML, &defaultSignatureID, &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, defaultSignatureID, 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
|
|
}
|
|
}
|
|
|
|
if req.DefaultSignatureID == "" {
|
|
sigID, err := s.ensureIdentityDefaultSignature(ctx, externalID, req.Email)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
req.DefaultSignatureID = sigID
|
|
} else if err := s.verifySignatureOwnership(ctx, externalID, req.DefaultSignatureID); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
replyToJSON, _ := json.Marshal(req.ReplyToAddrs)
|
|
defaultSigID := nullableUUID(req.DefaultSignatureID)
|
|
|
|
var id string
|
|
err := s.db.QueryRow(ctx, `
|
|
INSERT INTO mail_identities (account_id, email, name, is_default, signature_html, default_signature_id, reply_to_addrs)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
RETURNING id
|
|
`, accountID, req.Email, req.Name, req.IsDefault, req.SignatureHTML, defaultSigID, 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
|
|
}
|
|
}
|
|
|
|
if req.DefaultSignatureID != "" {
|
|
if err := s.verifySignatureOwnership(ctx, externalID, req.DefaultSignatureID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
replyToJSON, _ := json.Marshal(req.ReplyToAddrs)
|
|
defaultSigID := nullableUUID(req.DefaultSignatureID)
|
|
|
|
result, err := s.db.Exec(ctx, `
|
|
UPDATE mail_identities mi SET
|
|
email = $1, name = $2, is_default = $3, signature_html = $4,
|
|
default_signature_id = $5, reply_to_addrs = $6, updated_at = NOW()
|
|
FROM mail_accounts ma
|
|
JOIN users u ON ma.user_id = u.id
|
|
WHERE mi.id = $7 AND mi.account_id = ma.id AND u.external_id = $8
|
|
`, req.Email, req.Name, req.IsDefault, req.SignatureHTML, defaultSigID, 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
|
|
}
|