- 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.
218 lines
6.4 KiB
Go
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
|
|
}
|