ultisuite-backend/internal/api/mail/folders.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

215 lines
6.3 KiB
Go

package mail
import (
"context"
"errors"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/ultisuite/ulti-backend/internal/api/query"
"github.com/ultisuite/ulti-backend/internal/securityaudit"
)
var (
ErrFolderProtected = errors.New("system folder cannot be deleted")
ErrDuplicateFolder = errors.New("duplicate folder remote_name")
ErrDuplicateLabel = errors.New("duplicate label name")
)
type FoldersList struct {
Folders []map[string]any `json:"folders"`
Pagination query.PaginationMeta `json:"pagination,omitempty"`
}
func scanFolderRow(id, accountID, name, remoteName, folderType string, uidvalidity int64, messageCount, unreadCount int, createdAt, updatedAt any) map[string]any {
return map[string]any{
"id": id,
"account_id": accountID,
"name": name,
"remote_name": remoteName,
"folder_type": folderType,
"uidvalidity": uidvalidity,
"message_count": messageCount,
"unread_count": unreadCount,
"created_at": createdAt,
"updated_at": updatedAt,
}
}
func (s *Service) ListFolders(ctx context.Context, externalID, accountID string, params query.ListParams) (FoldersList, error) {
var owned bool
if err := s.db.QueryRow(ctx, `
SELECT EXISTS(
SELECT 1 FROM mail_accounts
WHERE id = $1 AND user_id = (SELECT id FROM users WHERE external_id = $2)
)
`, accountID, externalID).Scan(&owned); err != nil {
return FoldersList{}, err
}
if !owned {
return FoldersList{}, ErrAccountNotFound
}
var total int64
if err := s.db.QueryRow(ctx, `
SELECT COUNT(*) FROM mail_folders WHERE account_id = $1
`, accountID).Scan(&total); err != nil {
return FoldersList{}, err
}
rows, err := s.db.Query(ctx, `
SELECT id, account_id, name, remote_name, folder_type, uidvalidity, message_count, unread_count, created_at, updated_at
FROM mail_folders
WHERE account_id = $1
ORDER BY name ASC
LIMIT $2 OFFSET $3
`, accountID, params.Limit(), params.Offset())
if err != nil {
return FoldersList{}, err
}
defer rows.Close()
folders := make([]map[string]any, 0)
for rows.Next() {
var id, acctID, name, remoteName, folderType string
var uidvalidity int64
var messageCount, unreadCount int
var createdAt, updatedAt any
if err := rows.Scan(&id, &acctID, &name, &remoteName, &folderType, &uidvalidity, &messageCount, &unreadCount, &createdAt, &updatedAt); err != nil {
return FoldersList{}, err
}
folders = append(folders, scanFolderRow(id, acctID, name, remoteName, folderType, uidvalidity, messageCount, unreadCount, createdAt, updatedAt))
}
if err := rows.Err(); err != nil {
return FoldersList{}, err
}
return FoldersList{
Folders: folders,
Pagination: params.Meta(&total),
}, nil
}
func (s *Service) GetFolder(ctx context.Context, externalID, folderID string) (map[string]any, error) {
var id, accountID, name, remoteName, folderType string
var uidvalidity int64
var messageCount, unreadCount int
var createdAt, updatedAt any
err := s.db.QueryRow(ctx, `
SELECT f.id, f.account_id, f.name, f.remote_name, f.folder_type, f.uidvalidity, f.message_count, f.unread_count, f.created_at, f.updated_at
FROM mail_folders f
JOIN mail_accounts ma ON f.account_id = ma.id
WHERE f.id = $1 AND ma.user_id = (SELECT id FROM users WHERE external_id = $2)
`, folderID, externalID).Scan(&id, &accountID, &name, &remoteName, &folderType, &uidvalidity, &messageCount, &unreadCount, &createdAt, &updatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
return scanFolderRow(id, accountID, name, remoteName, folderType, uidvalidity, messageCount, unreadCount, createdAt, updatedAt), nil
}
func (s *Service) CreateFolder(ctx context.Context, userID string, req *createFolderRequest) (string, error) {
var owned bool
if err := s.db.QueryRow(ctx, `
SELECT EXISTS(SELECT 1 FROM mail_accounts WHERE id = $1 AND user_id = $2)
`, req.AccountID, userID).Scan(&owned); err != nil {
return "", err
}
if !owned {
return "", ErrAccountNotFound
}
remoteName := strings.TrimSpace(req.RemoteName)
if remoteName == "" {
remoteName = strings.TrimSpace(req.Name)
}
folderType := normalizeFolderType(req.FolderType)
var id string
err := s.db.QueryRow(ctx, `
INSERT INTO mail_folders (account_id, name, remote_name, folder_type)
VALUES ($1, $2, $3, $4)
RETURNING id
`, req.AccountID, strings.TrimSpace(req.Name), remoteName, folderType).Scan(&id)
if err != nil {
if isUniqueViolation(err) {
return "", ErrDuplicateFolder
}
return "", err
}
return id, nil
}
func (s *Service) UpdateFolder(ctx context.Context, externalID, folderID string, req *updateFolderRequest) error {
folderType := normalizeFolderType(req.FolderType)
result, err := s.db.Exec(ctx, `
UPDATE mail_folders f SET
name = $1,
remote_name = $2,
folder_type = $3,
updated_at = NOW()
FROM mail_accounts ma
WHERE f.id = $4
AND f.account_id = ma.id
AND ma.user_id = (SELECT id FROM users WHERE external_id = $5)
`, strings.TrimSpace(req.Name), strings.TrimSpace(req.RemoteName), folderType, folderID, externalID)
if err != nil {
if isUniqueViolation(err) {
return ErrDuplicateFolder
}
return err
}
if result.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
func (s *Service) DeleteFolder(ctx context.Context, externalID, folderID string) error {
var folderType string
err := s.db.QueryRow(ctx, `
SELECT f.folder_type
FROM mail_folders f
JOIN mail_accounts ma ON f.account_id = ma.id
WHERE f.id = $1 AND ma.user_id = (SELECT id FROM users WHERE external_id = $2)
`, folderID, externalID).Scan(&folderType)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
return err
}
if folderType != "custom" {
return ErrFolderProtected
}
result, err := s.db.Exec(ctx, `
DELETE FROM mail_folders f
USING mail_accounts ma
WHERE f.id = $1
AND f.account_id = ma.id
AND ma.user_id = (SELECT id FROM users WHERE external_id = $2)
`, folderID, 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_folder", "folder_id": folderID,
})
}
return nil
}
func isUniqueViolation(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == "23505"
}