- 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.
215 lines
6.3 KiB
Go
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"
|
|
}
|