A metade que faltava. O motor já existia e nada o chamava; agora o caminho de entrega consulta as regras da caixa que está a receber, e a resposta de ausência é mesmo enviada. Cada destinatário tem as suas próprias regras, avaliadas sobre a sua própria cópia: duas caixas neste servidor que recebem a mesma mensagem podem arquivá-la em sítios diferentes. Nada disto pode impedir o correio de chegar. Um script que não faz parse, ou uma base de dados que não responde, deixa a mensagem ser entregue sem filtro em vez de a recusar - recusar diria ao remetente que a mensagem falhou, e o servidor dele voltaria a tentar para sempre. discard é aceitar e depois deitar fora, pela mesma razão: uma regra de arquivo não é uma rejeição. A resposta de ausência sai pela mesma fila, assinatura e limites de envio que qualquer outra mensagem deste servidor - não é um caminho especial - e só depois de a mensagem que a desencadeou estar guardada, para não responder a correio que a caixa afinal não recebeu. Leva Auto-Submitted: auto-replied, que é o que impede duas caixas ambas de férias de se responderem uma à outra até uma esgotar a quota. Pastas próprias. Só existiam as seis padrão, portanto uma regra que dizia "arquiva isto em Faturas" nomeava uma pasta que não havia como criar. Criar, renomear e apagar, com as reservadas protegidas: cada uma delas significa alguma coisa para o protocolo ou para este servidor, e deixar renomear a INBOX partia caminhos que nenhuma mensagem de erro explica. Renomear não mexe nas mensagens nem nos UIDs, senão um cliente IMAP perdia de vista tudo o que lá está dentro. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
159 lines
4.5 KiB
Go
159 lines
4.5 KiB
Go
package usecase
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"unicode/utf8"
|
|
|
|
"lambdamail/protocols/internal/application/port"
|
|
)
|
|
|
|
// ManageFoldersUseCase lets a mailbox owner keep folders of their own.
|
|
//
|
|
// Only the six standard folders existed, so a rule that said "file this in
|
|
// Faturas" named a folder there was no way to create.
|
|
type ManageFoldersUseCase struct {
|
|
folders port.FolderAdmin
|
|
webmail port.WebmailRepository
|
|
}
|
|
|
|
func NewManageFoldersUseCase(folders port.FolderAdmin, webmail port.WebmailRepository) *ManageFoldersUseCase {
|
|
return &ManageFoldersUseCase{folders: folders, webmail: webmail}
|
|
}
|
|
|
|
var (
|
|
ErrInvalidFolderName = errors.New("folders: that name cannot be used")
|
|
ErrFolderExists = errors.New("folders: a folder with that name already exists")
|
|
ErrFolderReserved = errors.New("folders: that folder belongs to the mail system")
|
|
ErrFolderNotFound = errors.New("folders: no folder with that name")
|
|
)
|
|
|
|
// reservedFolders may not be created, renamed or deleted by a user.
|
|
//
|
|
// Each one means something to the protocol or to this server: INBOX is where
|
|
// delivery lands, Trash is where deleting puts things, Drafts and Sent record
|
|
// what a message is, and Reports is filled by the report ingestion. Letting a
|
|
// user rename one would break those paths in ways no error message explains.
|
|
var reservedFolders = map[string]bool{
|
|
"inbox": true, "sent": true, "drafts": true,
|
|
"trash": true, "junk": true, "archive": true, "reports": true,
|
|
}
|
|
|
|
// maxFolderNameLength keeps a name inside the column and inside what an IMAP
|
|
// client will show.
|
|
const maxFolderNameLength = 100
|
|
|
|
func (uc *ManageFoldersUseCase) Create(ctx context.Context, address, name string) error {
|
|
name = strings.TrimSpace(name)
|
|
if err := validateFolderName(name); err != nil {
|
|
return err
|
|
}
|
|
|
|
mailboxID, err := uc.mailbox(ctx, address)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
existing, err := uc.folders.ListFolders(ctx, mailboxID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if findFolder(existing, name) != nil {
|
|
return ErrFolderExists
|
|
}
|
|
return uc.folders.CreateFolder(ctx, mailboxID, name)
|
|
}
|
|
|
|
func (uc *ManageFoldersUseCase) Rename(ctx context.Context, address, from, to string) error {
|
|
from = strings.TrimSpace(from)
|
|
to = strings.TrimSpace(to)
|
|
if reservedFolders[strings.ToLower(from)] {
|
|
return ErrFolderReserved
|
|
}
|
|
if err := validateFolderName(to); err != nil {
|
|
return err
|
|
}
|
|
|
|
mailboxID, err := uc.mailbox(ctx, address)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
existing, err := uc.folders.ListFolders(ctx, mailboxID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if findFolder(existing, from) == nil {
|
|
return ErrFolderNotFound
|
|
}
|
|
if findFolder(existing, to) != nil {
|
|
return ErrFolderExists
|
|
}
|
|
return uc.folders.RenameFolder(ctx, mailboxID, from, to)
|
|
}
|
|
|
|
// Delete removes a folder the user created.
|
|
//
|
|
// The messages inside it go with it, which is why the interface has to say so
|
|
// before calling this.
|
|
func (uc *ManageFoldersUseCase) Delete(ctx context.Context, address, name string) error {
|
|
name = strings.TrimSpace(name)
|
|
if reservedFolders[strings.ToLower(name)] {
|
|
return ErrFolderReserved
|
|
}
|
|
|
|
mailboxID, err := uc.mailbox(ctx, address)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
existing, err := uc.folders.ListFolders(ctx, mailboxID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if findFolder(existing, name) == nil {
|
|
return ErrFolderNotFound
|
|
}
|
|
return uc.folders.DeleteFolder(ctx, mailboxID, name)
|
|
}
|
|
|
|
func (uc *ManageFoldersUseCase) mailbox(ctx context.Context, address string) (string, error) {
|
|
id, err := uc.webmail.FindMailboxIDByAddress(ctx, address)
|
|
if err != nil || id == "" {
|
|
return "", ErrNoSuchMailbox
|
|
}
|
|
return id, nil
|
|
}
|
|
|
|
func findFolder(folders []port.WebmailFolder, name string) *port.WebmailFolder {
|
|
for i, folder := range folders {
|
|
if strings.EqualFold(strings.TrimSpace(folder.Name), name) {
|
|
return &folders[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validateFolderName rejects names that would confuse IMAP or the routing.
|
|
func validateFolderName(name string) error {
|
|
if strings.TrimSpace(name) == "" {
|
|
return ErrInvalidFolderName
|
|
}
|
|
if utf8.RuneCountInString(name) > maxFolderNameLength {
|
|
return ErrInvalidFolderName
|
|
}
|
|
if reservedFolders[strings.ToLower(name)] {
|
|
return ErrFolderReserved
|
|
}
|
|
// A delimiter would read as a nested folder to an IMAP client, and this
|
|
// server does not create hierarchies.
|
|
if strings.ContainsAny(name, `/\.`) {
|
|
return ErrInvalidFolderName
|
|
}
|
|
// Control characters have no place in a name that travels over IMAP.
|
|
for _, r := range name {
|
|
if r < 0x20 || r == 0x7f {
|
|
return ErrInvalidFolderName
|
|
}
|
|
}
|
|
return nil
|
|
}
|