feat(regras): aplica as regras na entrega e envia a resposta de ausência

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>
This commit is contained in:
Lucas RayanandClaude Opus 5 committed 2026-08-22 13:35:17 -03:00
1 parent b9ebf606e1
commit 42420a7de2
14 files changed
+1082 -6

No files matched your search

@@ -26,6 +26,9 @@ const maxComposeBytes = 32 << 20 // 32 MiB
type mailAPI struct {
useCase *usecase.WebmailUseCase
sessions *WebSessionVerifier
// folders is optional: without it the folder routes report that the
// feature is not configured rather than 404ing like a typo.
folders *usecase.ManageFoldersUseCase
}
func writeJSON(w http.ResponseWriter, status int, payload any) {
@@ -291,6 +294,49 @@ func (m *mailAPI) handleDelete(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
// handleFolderAdmin creates, renames and deletes the folders a mailbox owner
// keeps for themselves.
func (m *mailAPI) handleFolderAdmin(w http.ResponseWriter, r *http.Request) {
session, ok := m.authenticate(w, r)
if !ok {
return
}
if m.folders == nil {
writeError(w, http.StatusServiceUnavailable, "FOLDERS_UNAVAILABLE",
"Folder management is not configured")
return
}
var body struct {
Action string `json:"action"`
Name string `json:"name"`
NewName string `json:"new_name"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "Invalid JSON")
return
}
var err error
switch strings.ToLower(body.Action) {
case "create":
err = m.folders.Create(r.Context(), session.Email, body.Name)
case "rename":
err = m.folders.Rename(r.Context(), session.Email, body.Name, body.NewName)
case "delete":
err = m.folders.Delete(r.Context(), session.Email, body.Name)
default:
writeError(w, http.StatusBadRequest, "BAD_REQUEST",
"action must be create, rename or delete")
return
}
if err != nil {
m.fail(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
// handleMove files one message into another folder.
func (m *mailAPI) handleMove(w http.ResponseWriter, r *http.Request) {
session, ok := m.authenticate(w, r)
@@ -402,6 +448,15 @@ func (m *mailAPI) fail(w http.ResponseWriter, err error) {
writeError(w, http.StatusTooManyRequests, "SEND_LIMIT_EXCEEDED", "Hourly recipient limit reached")
case errors.Is(err, usecase.ErrSenderNotOwned):
writeError(w, http.StatusForbidden, "FORBIDDEN", "Sender address does not belong to this account")
case errors.Is(err, usecase.ErrFolderExists):
writeError(w, http.StatusConflict, "FOLDER_EXISTS", "A folder with that name already exists")
case errors.Is(err, usecase.ErrFolderReserved):
writeError(w, http.StatusForbidden, "FOLDER_RESERVED",
"That folder belongs to the mail system and cannot be changed")
case errors.Is(err, usecase.ErrInvalidFolderName):
writeError(w, http.StatusBadRequest, "INVALID_FOLDER_NAME", "That folder name cannot be used")
case errors.Is(err, usecase.ErrFolderNotFound), errors.Is(err, port.ErrFolderMissing):
writeError(w, http.StatusNotFound, "NOT_FOUND", "No folder with that name")
case errors.Is(err, usecase.ErrFolderNotATarget):
writeError(w, http.StatusBadRequest, "FOLDER_NOT_A_TARGET",
"Messages cannot be moved into Sent or Drafts")
@@ -91,6 +91,7 @@ func (r *Router) registerRoutes() {
r.mux.HandleFunc("/api/v1/mail/draft", r.handleMailDraft)
r.mux.HandleFunc("/api/v1/mail/delete", r.handleMailDelete)
r.mux.HandleFunc("/api/v1/mail/move", r.handleMailMove)
r.mux.HandleFunc("/api/v1/mail/folders/manage", r.handleMailFolderAdmin)
r.mux.HandleFunc("/api/v1/reports/dmarc", r.handleDmarcIngest)
r.mux.HandleFunc("/api/v1/reports/tlsrpt", r.handleTlsRptIngest)
}
@@ -317,6 +318,24 @@ func (r *Router) handleMailDelete(w http.ResponseWriter, req *http.Request) {
}
}
// handleMailFolderAdmin creates, renames and deletes a user's own folders.
func (r *Router) handleMailFolderAdmin(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "POST required")
return
}
if r.mailReady(w) {
r.mail.handleFolderAdmin(w, req)
}
}
// SetFolderAdmin enables managing a mailbox's own folders.
func (r *Router) SetFolderAdmin(folders *appusecase.ManageFoldersUseCase) {
if r.mail != nil {
r.mail.folders = folders
}
}
// handleMailMove files one message into another folder.
func (r *Router) handleMailMove(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {