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" }