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
13 files changed
+1079 -3

No files matched your search

@@ -131,6 +131,11 @@ func run(cfg config) {
reportsUC := usecase.NewIngestReportsUseCase(reportRepo) reportsUC := usecase.NewIngestReportsUseCase(reportRepo)
inboundUC.SetReportIngestor(usecase.NewIngestDeliveredReportsUseCase(reportsUC)) inboundUC.SetReportIngestor(usecase.NewIngestDeliveredReportsUseCase(reportsUC))
// The mailbox's own filing rules, applied as mail arrives. Until this was
// wired the scripts were stored and never read, so a vacation responder
// that was switched on never replied and no filter ever filed anything.
inboundUC.SetRules(usecase.NewApplyRulesUseCase(postgres.NewSieveRepository(pool)))
// ------------------------------------------------------------ outbound // ------------------------------------------------------------ outbound
var signer port.DkimSigner var signer port.DkimSigner
if dkimRepo != nil { if dkimRepo != nil {
@@ -138,6 +143,12 @@ func run(cfg config) {
} }
submissionUC := usecase.NewProcessOutboundEmailUseCase(authRepo, outboundRepo, blobs, signer) submissionUC := usecase.NewProcessOutboundEmailUseCase(authRepo, outboundRepo, blobs, signer)
// The vacation responder needs a way to actually send. Wired after the
// submission use case exists, because an automatic reply goes out through
// the same queue, signing and send limits as any other message this
// server sends - it is not a special path.
inboundUC.SetVacationSender(submissionUC, authRepo, cfg.PrimaryMailHost)
mxResolver := netdns.NewNetMXResolver() mxResolver := netdns.NewNetMXResolver()
outboundWorker := usecase.NewOutboundWorkerUseCase(outboundRepo, mxResolver, blobReader, inboundUC, mailboxes, cfg.PrimaryMailHost) outboundWorker := usecase.NewOutboundWorkerUseCase(outboundRepo, mxResolver, blobReader, inboundUC, mailboxes, cfg.PrimaryMailHost)
outboundWorker.SetPolicyResolver(buildPolicyResolver(cfg)) outboundWorker.SetPolicyResolver(buildPolicyResolver(cfg))
@@ -191,6 +202,10 @@ func run(cfg config) {
log.Printf("JWT_SECRET is not set: the webmail mail API stays disabled") log.Printf("JWT_SECRET is not set: the webmail mail API stays disabled")
} }
router.SetMailAPI(webmailUC, cfg.JwtSecret) router.SetMailAPI(webmailUC, cfg.JwtSecret)
// Folders the mailbox owner keeps for themselves, which the filing rules
// can then name as a destination.
router.SetFolderAdmin(usecase.NewManageFoldersUseCase(
postgres.NewWebmailRepository(pool), postgres.NewWebmailRepository(pool)))
if dkimRepo != nil { if dkimRepo != nil {
router.SetAdminDkimAPI(dkimRepo, generateDkimKey, cfg.JwtSecret) router.SetAdminDkimAPI(dkimRepo, generateDkimKey, cfg.JwtSecret)
} }
@@ -31,3 +31,13 @@ type DeliveredReportStore interface {
// MoveToReportsFolder files a backfilled message out of the inbox. // MoveToReportsFolder files a backfilled message out of the inbox.
MoveToReportsFolder(ctx context.Context, messageID uuid.UUID) error MoveToReportsFolder(ctx context.Context, messageID uuid.UUID) error
} }
// SieveScriptReader reads the active rule script for a mailbox.
//
// The delivery path needs this and nothing else about Sieve: the scripts are
// written by ManageSieve and by the settings screen, and read here.
type SieveScriptReader interface {
// ActiveScriptFor returns the mailbox's active script, or empty when it
// has none.
ActiveScriptFor(ctx context.Context, mailboxID uuid.UUID) (string, error)
}
@@ -74,6 +74,10 @@ type WebmailRepository interface {
MoveToTrash(ctx context.Context, mailboxID, folderName string, uid uint32) (uint32, error) MoveToTrash(ctx context.Context, mailboxID, folderName string, uid uint32) (uint32, error)
} }
// ErrFolderMissing reports a folder that is not there, or that is a standard
// folder and therefore not the user's to change.
var ErrFolderMissing = errors.New("folders: no such folder for this mailbox")
// ErrNoTrashFolder reports a mailbox with nowhere to put deleted mail, rather // ErrNoTrashFolder reports a mailbox with nowhere to put deleted mail, rather
// than the message being destroyed instead. // than the message being destroyed instead.
var ErrNoTrashFolder = errors.New("webmail: this mailbox has no Trash folder") var ErrNoTrashFolder = errors.New("webmail: this mailbox has no Trash folder")
@@ -81,3 +85,16 @@ var ErrNoTrashFolder = errors.New("webmail: this mailbox has no Trash folder")
// ErrAlreadyInTrash tells the caller to expunge rather than move: a message // ErrAlreadyInTrash tells the caller to expunge rather than move: a message
// already in Trash has no further destination. // already in Trash has no further destination.
var ErrAlreadyInTrash = errors.New("webmail: message is already in Trash") var ErrAlreadyInTrash = errors.New("webmail: message is already in Trash")
// FolderAdmin manages the folders a mailbox owner creates for themselves.
//
// Separate from WebmailRepository because these are writes to the folder list
// itself rather than reads of the mail inside it, and because the reserved
// folders are protected in the use case above this, not here.
type FolderAdmin interface {
ListFolders(ctx context.Context, mailboxID string) ([]WebmailFolder, error)
CreateFolder(ctx context.Context, mailboxID, name string) error
RenameFolder(ctx context.Context, mailboxID, from, to string) error
// DeleteFolder removes the folder and the messages filed in it.
DeleteFolder(ctx context.Context, mailboxID, name string) error
}
@@ -0,0 +1,102 @@
package usecase
import (
"bytes"
"context"
"log"
"net/mail"
"strings"
"github.com/google/uuid"
"lambdamail/protocols/internal/application/port"
"lambdamail/protocols/internal/domain/sieve"
)
// ApplyRulesUseCase runs a mailbox's own rules over an arriving message.
//
// Nothing did this. The scripts were stored by ManageSieve and written by the
// settings screen, and the delivery path never read either - so a vacation
// responder that was switched on never replied, and a filter that said "file
// this in Faturas" filed nothing, ever.
type ApplyRulesUseCase struct {
scripts port.SieveScriptReader
}
func NewApplyRulesUseCase(scripts port.SieveScriptReader) *ApplyRulesUseCase {
return &ApplyRulesUseCase{scripts: scripts}
}
// RuleDecision is what the mailbox's rules decided about one message.
type RuleDecision struct {
// Folder overrides where the message is filed, or is empty to leave the
// delivery path's own choice alone.
Folder string
// Discard drops the message after it has been accepted. The sender is
// still told the message was delivered, because a filing rule is not a
// rejection - telling them otherwise would have their server retry.
Discard bool
Flags []string
// Vacation is the automatic reply to send, or nil.
Vacation *sieve.VacationReply
}
// For evaluates the receiving mailbox's rules against one message.
//
// It never returns an error, and that is deliberate. Filing is a convenience;
// delivery is not. A script that does not parse, or a database that cannot be
// read, must leave the message delivered unfiltered rather than bouncing mail
// that the sender would then retry forever.
func (uc *ApplyRulesUseCase) For(
ctx context.Context, mailboxID uuid.UUID, recipient, sender string, payload []byte,
) RuleDecision {
if uc == nil || uc.scripts == nil {
return RuleDecision{}
}
source, err := uc.scripts.ActiveScriptFor(ctx, mailboxID)
if err != nil {
log.Printf("rules: could not read the rules for %s, delivering unfiltered: %v", recipient, err)
return RuleDecision{}
}
if strings.TrimSpace(source) == "" {
return RuleDecision{}
}
script, err := sieve.Parse(source)
if err != nil {
// Logged rather than swallowed silently: the user wrote a rule that
// cannot run, and that is worth knowing about.
log.Printf("rules: the rules for %s could not be read, delivering unfiltered: %v", recipient, err)
return RuleDecision{}
}
outcome := sieve.Evaluate(script, sieve.Message{
Headers: headersOf(payload),
Sender: sender,
Recipient: recipient,
})
return RuleDecision{
Folder: outcome.Folder,
Discard: outcome.Discard,
Flags: outcome.Flags,
Vacation: outcome.Vacation,
}
}
// headersOf reads the message's headers, keyed lowercased.
//
// A message whose headers cannot be parsed still gets evaluated, against an
// empty set: a rule simply will not match, which is the harmless outcome.
func headersOf(payload []byte) map[string][]string {
parsed, err := mail.ReadMessage(bytes.NewReader(payload))
if err != nil {
return map[string][]string{}
}
out := make(map[string][]string, len(parsed.Header))
for name, values := range parsed.Header {
out[strings.ToLower(name)] = values
}
return out
}
@@ -0,0 +1,233 @@
package usecase
import (
"bytes"
"context"
"errors"
"io"
"testing"
"github.com/google/uuid"
"lambdamail/protocols/internal/application/port"
)
// The rules, applied to mail as it is delivered.
type stubSieveScripts struct {
script string
err error
asked []uuid.UUID
}
func (s *stubSieveScripts) ActiveScriptFor(_ context.Context, mailboxID uuid.UUID) (string, error) {
s.asked = append(s.asked, mailboxID)
return s.script, s.err
}
func newRules(scripts *stubSieveScripts) *ApplyRulesUseCase {
return NewApplyRulesUseCase(scripts)
}
const filingRule = `if header :contains "Subject" "Fatura" { fileinto "Faturas"; }`
func TestRuleDecidesTheFolder(t *testing.T) {
uc := newRules(&stubSieveScripts{script: filingRule})
payload := []byte("From: banco@example.test\r\nSubject: Fatura de agosto\r\n\r\ncorpo")
decision := uc.For(context.Background(), uuid.New(), "me@example.test", "banco@example.test", payload)
if decision.Folder != "Faturas" {
t.Errorf("folder %q, want Faturas", decision.Folder)
}
}
func TestMessageWithNoMatchingRuleIsUntouched(t *testing.T) {
uc := newRules(&stubSieveScripts{script: filingRule})
payload := []byte("From: amigo@example.test\r\nSubject: Almoço\r\n\r\ncorpo")
decision := uc.For(context.Background(), uuid.New(), "me@example.test", "amigo@example.test", payload)
if decision.Folder != "" || decision.Discard {
t.Errorf("an unmatched message was acted on: %+v", decision)
}
}
// A mailbox with no rules is the common case and must cost nothing.
func TestNoScriptMeansNoDecision(t *testing.T) {
uc := newRules(&stubSieveScripts{script: ""})
decision := uc.For(context.Background(), uuid.New(), "me@example.test", "a@b.test",
[]byte("Subject: x\r\n\r\ny"))
if decision.Folder != "" || decision.Discard || decision.Vacation != nil {
t.Errorf("a mailbox with no rules got a decision: %+v", decision)
}
}
// The rules must never be able to stop mail arriving. A broken script, or a
// database that cannot be read, means the message is delivered unfiltered -
// not that it bounces.
func TestABrokenScriptStillDeliversTheMessage(t *testing.T) {
uc := newRules(&stubSieveScripts{script: `if header :contains "Subject" {{{`})
decision := uc.For(context.Background(), uuid.New(), "me@example.test", "a@b.test",
[]byte("Subject: x\r\n\r\ny"))
if decision.Folder != "" || decision.Discard {
t.Errorf("a broken script changed the delivery: %+v", decision)
}
}
func TestAFailingLookupStillDeliversTheMessage(t *testing.T) {
uc := newRules(&stubSieveScripts{err: errors.New("database is down")})
decision := uc.For(context.Background(), uuid.New(), "me@example.test", "a@b.test",
[]byte("Subject: x\r\n\r\ny"))
if decision.Folder != "" || decision.Discard {
t.Errorf("an unreadable script changed the delivery: %+v", decision)
}
}
// --- vacation ------------------------------------------------------------
const vacationRule = `require ["vacation"];
vacation :subject "Fora do escritório" "Volto dia 30.";`
func TestVacationRepliesToAPerson(t *testing.T) {
uc := newRules(&stubSieveScripts{script: vacationRule})
payload := []byte("From: amigo@example.test\r\nSubject: Oi\r\n\r\ncorpo")
decision := uc.For(context.Background(), uuid.New(), "me@example.test", "amigo@example.test", payload)
if decision.Vacation == nil {
t.Fatal("no reply was produced")
}
if decision.Vacation.Subject != "Fora do escritório" {
t.Errorf("subject %q", decision.Vacation.Subject)
}
}
// The header the message carries decides, not just the envelope: a mailing
// list is recognised by List-Id, which lives in the headers.
func TestVacationStaysSilentForAList(t *testing.T) {
uc := newRules(&stubSieveScripts{script: vacationRule})
payload := []byte("From: lista@example.test\r\nList-Id: <l.example.test>\r\nSubject: Oi\r\n\r\ncorpo")
decision := uc.For(context.Background(), uuid.New(), "me@example.test", "lista@example.test", payload)
if decision.Vacation != nil {
t.Error("replied to a mailing list")
}
}
func TestVacationStaysSilentForABounce(t *testing.T) {
uc := newRules(&stubSieveScripts{script: vacationRule})
payload := []byte("From: MAILER-DAEMON@example.test\r\nSubject: failed\r\n\r\ncorpo")
// Empty envelope sender: what a bounce actually arrives with.
decision := uc.For(context.Background(), uuid.New(), "me@example.test", "", payload)
if decision.Vacation != nil {
t.Error("replied to a bounce")
}
}
// The rules belong to the mailbox being delivered to, not to the sender.
func TestRulesAreLookedUpForTheReceivingMailbox(t *testing.T) {
scripts := &stubSieveScripts{script: filingRule}
uc := newRules(scripts)
mailboxID := uuid.New()
uc.For(context.Background(), mailboxID, "me@example.test", "a@b.test",
[]byte("Subject: Fatura\r\n\r\ny"))
if len(scripts.asked) != 1 || scripts.asked[0] != mailboxID {
t.Errorf("looked up %v, want the receiving mailbox %v", scripts.asked, mailboxID)
}
}
var _ = port.MailboxRecord{}
// --- the delivery path ---------------------------------------------------
// The rule has to reach the message that is actually filed.
func TestDeliveryFilesWhereTheRuleSays(t *testing.T) {
messages := &recordingMessageRepository{}
uc := NewProcessInboundEmailUseCase(nil, &capturingBlobStorage{}, messages)
uc.SetRules(newRules(&stubSieveScripts{script: filingRule}))
if err := uc.Handle(context.Background(), ProcessInboundEmailInput{
Sender: "banco@example.test",
Recipients: []port.MailboxRecord{{ID: uuid.New()}},
RecipientAddresses: []string{"me@example.test"},
Body: bytesReader("From: banco@example.test\r\nSubject: Fatura de agosto\r\n\r\ncorpo"),
}); err != nil {
t.Fatalf("Handle: %v", err)
}
if len(messages.persisted) != 1 {
t.Fatalf("want one filed copy, got %d", len(messages.persisted))
}
if messages.persisted[0].TargetFolderName != "Faturas" {
t.Errorf("filed in %q, want Faturas", messages.persisted[0].TargetFolderName)
}
}
// Each recipient gets their own rules: two mailboxes on this server receiving
// the same message may file it in different places.
func TestEachRecipientGetsTheirOwnRules(t *testing.T) {
messages := &recordingMessageRepository{}
uc := NewProcessInboundEmailUseCase(nil, &capturingBlobStorage{}, messages)
uc.SetRules(newRules(&stubSieveScripts{script: filingRule}))
if err := uc.Handle(context.Background(), ProcessInboundEmailInput{
Sender: "banco@example.test",
Recipients: []port.MailboxRecord{{ID: uuid.New()}, {ID: uuid.New()}},
RecipientAddresses: []string{"a@example.test", "b@example.test"},
Body: bytesReader("Subject: Fatura\r\n\r\ncorpo"),
}); err != nil {
t.Fatalf("Handle: %v", err)
}
if len(messages.persisted) != 2 {
t.Fatalf("want two copies, got %d", len(messages.persisted))
}
for i, filed := range messages.persisted {
if filed.TargetFolderName != "Faturas" {
t.Errorf("copy %d filed in %q", i, filed.TargetFolderName)
}
}
}
// A discarded message is accepted and then dropped: the sender is told it was
// delivered, because a filing rule is not a rejection.
func TestDiscardAcceptsTheMessageAndFilesNothing(t *testing.T) {
messages := &recordingMessageRepository{}
uc := NewProcessInboundEmailUseCase(nil, &capturingBlobStorage{}, messages)
uc.SetRules(newRules(&stubSieveScripts{
script: `if header :contains "Subject" "spam" { discard; }`,
}))
if err := uc.Handle(context.Background(), ProcessInboundEmailInput{
Sender: "spammer@example.test",
Recipients: []port.MailboxRecord{{ID: uuid.New()}},
RecipientAddresses: []string{"me@example.test"},
Body: bytesReader("Subject: spam barato\r\n\r\ncorpo"),
}); err != nil {
t.Fatalf("a discarded message must still be accepted: %v", err)
}
if len(messages.persisted) != 0 {
t.Errorf("a discarded message was filed: %d copies", len(messages.persisted))
}
}
// Without rules configured, delivery behaves exactly as it did before.
func TestDeliveryIsUnchangedWithoutRules(t *testing.T) {
messages := &recordingMessageRepository{}
uc := NewProcessInboundEmailUseCase(nil, &capturingBlobStorage{}, messages)
if err := uc.Handle(context.Background(), ProcessInboundEmailInput{
Sender: "banco@example.test",
Recipients: []port.MailboxRecord{{ID: uuid.New()}},
RecipientAddresses: []string{"me@example.test"},
Body: bytesReader("Subject: Fatura\r\n\r\ncorpo"),
}); err != nil {
t.Fatalf("Handle: %v", err)
}
if messages.persisted[0].TargetFolderName == "Faturas" {
t.Error("a rule ran with no rules configured")
}
}
// bytesReader is the message body as the delivery path receives it.
func bytesReader(s string) io.Reader { return bytes.NewBufferString(s) }
@@ -0,0 +1,158 @@
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
}
@@ -0,0 +1,207 @@
package usecase
import (
"context"
"errors"
"strings"
"testing"
"github.com/google/uuid"
"lambdamail/protocols/internal/application/port"
)
// Folders the mailbox owner creates for themselves.
//
// The six standard folders were all a mailbox could ever have, so a rule that
// said "file this in Faturas" pointed at a folder that could not be brought
// into existence from anywhere in the interface.
type stubFolderStore struct {
existing []port.WebmailFolder
created []string
renamed []string
deleted []string
err error
}
func (s *stubFolderStore) ListFolders(context.Context, string) ([]port.WebmailFolder, error) {
return s.existing, s.err
}
func (s *stubFolderStore) CreateFolder(_ context.Context, _, name string) error {
if s.err != nil {
return s.err
}
s.created = append(s.created, name)
s.existing = append(s.existing, port.WebmailFolder{Name: name})
return nil
}
func (s *stubFolderStore) RenameFolder(_ context.Context, _, from, to string) error {
if s.err != nil {
return s.err
}
s.renamed = append(s.renamed, from+"->"+to)
return nil
}
func (s *stubFolderStore) DeleteFolder(_ context.Context, _, name string) error {
if s.err != nil {
return s.err
}
s.deleted = append(s.deleted, name)
return nil
}
func newFolders(store *stubFolderStore) *ManageFoldersUseCase {
return NewManageFoldersUseCase(store, &stubWebmailRepo{mailboxID: uuid.NewString()})
}
func standardFolders() []port.WebmailFolder {
return []port.WebmailFolder{
{Name: "INBOX", SpecialUse: "inbox"},
{Name: "Sent", SpecialUse: "sent"},
{Name: "Drafts", SpecialUse: "drafts"},
{Name: "Trash", SpecialUse: "trash"},
}
}
func TestCreatesAFolder(t *testing.T) {
store := &stubFolderStore{existing: standardFolders()}
if err := newFolders(store).Create(context.Background(), "me@example.test", "Faturas"); err != nil {
t.Fatalf("create: %v", err)
}
if len(store.created) != 1 || store.created[0] != "Faturas" {
t.Errorf("created %v", store.created)
}
}
func TestFolderNameIsTrimmed(t *testing.T) {
store := &stubFolderStore{existing: standardFolders()}
if err := newFolders(store).Create(context.Background(), "me@example.test", " Faturas "); err != nil {
t.Fatalf("create: %v", err)
}
if store.created[0] != "Faturas" {
t.Errorf("stored %q with its surrounding spaces", store.created[0])
}
}
func TestRefusesAnEmptyName(t *testing.T) {
store := &stubFolderStore{existing: standardFolders()}
for _, name := range []string{"", " ", "\t"} {
if err := newFolders(store).Create(context.Background(), "me@example.test", name); !errors.Is(err, ErrInvalidFolderName) {
t.Errorf("creating %q returned %v", name, err)
}
}
}
// A second folder with the same name would make "file this in Faturas"
// ambiguous, and IMAP addresses folders by name.
func TestRefusesADuplicateName(t *testing.T) {
store := &stubFolderStore{existing: standardFolders()}
if err := newFolders(store).Create(context.Background(), "me@example.test", "Faturas"); err != nil {
t.Fatal(err)
}
if err := newFolders(store).Create(context.Background(), "me@example.test", "Faturas"); !errors.Is(err, ErrFolderExists) {
t.Errorf("a duplicate name returned %v", err)
}
}
func TestDuplicateCheckIgnoresCase(t *testing.T) {
store := &stubFolderStore{existing: append(standardFolders(), port.WebmailFolder{Name: "Faturas"})}
if err := newFolders(store).Create(context.Background(), "me@example.test", "FATURAS"); !errors.Is(err, ErrFolderExists) {
t.Errorf("returned %v", err)
}
}
// The standard folders have meaning to the protocol: INBOX is where delivery
// goes and Trash is where deletion goes. Letting a user create, rename or
// delete one would break the mailbox in ways no error message explains.
func TestRefusesToTouchTheStandardFolders(t *testing.T) {
store := &stubFolderStore{existing: standardFolders()}
uc := newFolders(store)
for _, name := range []string{"INBOX", "inbox", "Sent", "Drafts", "Trash", "Junk", "Archive"} {
if err := uc.Delete(context.Background(), "me@example.test", name); !errors.Is(err, ErrFolderReserved) {
t.Errorf("deleting %q returned %v", name, err)
}
if err := uc.Rename(context.Background(), "me@example.test", name, "Outra"); !errors.Is(err, ErrFolderReserved) {
t.Errorf("renaming %q returned %v", name, err)
}
}
if len(store.deleted) != 0 || len(store.renamed) != 0 {
t.Errorf("a reserved folder was still touched: deleted=%v renamed=%v", store.deleted, store.renamed)
}
}
// Reports is created by this server for its own use, and delivery files into
// it by name.
func TestRefusesToTouchTheReportsFolder(t *testing.T) {
store := &stubFolderStore{existing: append(standardFolders(), port.WebmailFolder{Name: "Reports"})}
if err := newFolders(store).Delete(context.Background(), "me@example.test", "Reports"); !errors.Is(err, ErrFolderReserved) {
t.Errorf("returned %v", err)
}
}
func TestRenamesACustomFolder(t *testing.T) {
store := &stubFolderStore{existing: append(standardFolders(), port.WebmailFolder{Name: "Faturas"})}
if err := newFolders(store).Rename(context.Background(), "me@example.test", "Faturas", "Financeiro"); err != nil {
t.Fatalf("rename: %v", err)
}
if len(store.renamed) != 1 || store.renamed[0] != "Faturas->Financeiro" {
t.Errorf("renamed %v", store.renamed)
}
}
func TestRenameRefusesAnExistingName(t *testing.T) {
store := &stubFolderStore{existing: append(standardFolders(),
port.WebmailFolder{Name: "Faturas"}, port.WebmailFolder{Name: "Financeiro"})}
if err := newFolders(store).Rename(context.Background(), "me@example.test", "Faturas", "Financeiro"); !errors.Is(err, ErrFolderExists) {
t.Errorf("returned %v", err)
}
}
func TestDeletesACustomFolder(t *testing.T) {
store := &stubFolderStore{existing: append(standardFolders(), port.WebmailFolder{Name: "Faturas"})}
if err := newFolders(store).Delete(context.Background(), "me@example.test", "Faturas"); err != nil {
t.Fatalf("delete: %v", err)
}
if len(store.deleted) != 1 || store.deleted[0] != "Faturas" {
t.Errorf("deleted %v", store.deleted)
}
}
func TestDeletingAFolderThatIsNotThereIsReported(t *testing.T) {
store := &stubFolderStore{existing: standardFolders()}
if err := newFolders(store).Delete(context.Background(), "me@example.test", "Faturas"); !errors.Is(err, ErrFolderNotFound) {
t.Errorf("returned %v", err)
}
}
// A name with a delimiter in it would look like a nested folder to IMAP, and
// this server does not create those.
func TestRefusesADelimiterInTheName(t *testing.T) {
store := &stubFolderStore{existing: standardFolders()}
for _, name := range []string{"a/b", "a.b", `a\b`} {
if err := newFolders(store).Create(context.Background(), "me@example.test", name); !errors.Is(err, ErrInvalidFolderName) {
t.Errorf("creating %q returned %v", name, err)
}
}
}
func TestRefusesAnAbsurdlyLongName(t *testing.T) {
store := &stubFolderStore{existing: standardFolders()}
err := newFolders(store).Create(context.Background(), "me@example.test", strings.Repeat("a", 300))
if !errors.Is(err, ErrInvalidFolderName) {
t.Errorf("returned %v", err)
}
}
// Accents are ordinary in a Portuguese folder name.
func TestAcceptsAccentedNames(t *testing.T) {
store := &stubFolderStore{existing: standardFolders()}
if err := newFolders(store).Create(context.Background(), "me@example.test", "Relatórios de Março"); err != nil {
t.Errorf("an accented name was refused: %v", err)
}
}
@@ -66,12 +66,22 @@ type ProcessInboundEmailUseCase struct {
folders port.ImapFolderRepository folders port.ImapFolderRepository
trackerManager *MailboxTrackerManager trackerManager *MailboxTrackerManager
reportIngestor *IngestDeliveredReportsUseCase reportIngestor *IngestDeliveredReportsUseCase
rules *ApplyRulesUseCase
submission *ProcessOutboundEmailUseCase
auth port.AuthRepository
replyHost string
} }
func NewProcessInboundEmailUseCase(mailboxes port.MailboxRepository, blobs port.BlobStorage, messages port.InboundMessageRepository) *ProcessInboundEmailUseCase { func NewProcessInboundEmailUseCase(mailboxes port.MailboxRepository, blobs port.BlobStorage, messages port.InboundMessageRepository) *ProcessInboundEmailUseCase {
return &ProcessInboundEmailUseCase{mailboxes: mailboxes, blobs: blobs, messages: messages} return &ProcessInboundEmailUseCase{mailboxes: mailboxes, blobs: blobs, messages: messages}
} }
// SetRules enables the mailbox's own filing rules. Optional: without it a
// message is delivered exactly as it was before rules existed.
func (uc *ProcessInboundEmailUseCase) SetRules(rules *ApplyRulesUseCase) {
uc.rules = rules
}
// SetReportIngestor enables reading DMARC and TLS-RPT reports out of the mail // SetReportIngestor enables reading DMARC and TLS-RPT reports out of the mail
// that carries them. Optional: with no ingestor a report is delivered like any // that carries them. Optional: with no ingestor a report is delivered like any
// other message, which is what happened before this existed. // other message, which is what happened before this existed.
@@ -250,6 +260,8 @@ func (uc *ProcessInboundEmailUseCase) Handle(ctx context.Context, input ProcessI
// was sent to is what makes it one, and leaving a broken report in the // was sent to is what makes it one, and leaving a broken report in the
// inbox instead would put back exactly the noise this removes. The bytes // inbox instead would put back exactly the noise this removes. The bytes
// are kept either way, so it can be re-ingested once the cause is fixed. // are kept either way, so it can be re-ingested once the cause is fixed.
var vacationReplies []pendingVacation
alreadySeen := false alreadySeen := false
if uc.reportIngestor != nil { if uc.reportIngestor != nil {
outcome, err := uc.reportIngestor.Ingest(ctx, input.RecipientAddresses, payload) outcome, err := uc.reportIngestor.Ingest(ctx, input.RecipientAddresses, payload)
@@ -276,14 +288,41 @@ func (uc *ProcessInboundEmailUseCase) Handle(ctx context.Context, input ProcessI
// same headers, and the list view reads them instead of the blob. // same headers, and the list view reads them instead of the blob.
headers := ExtractMessageHeaders(payload) headers := ExtractMessageHeaders(payload)
persistInputs := make([]port.PersistInboundMessageInput, len(input.Recipients)) persistInputs := make([]port.PersistInboundMessageInput, 0, len(input.Recipients))
for i, recipient := range input.Recipients { for i, recipient := range input.Recipients {
persistInputs[i] = port.PersistInboundMessageInput{ // Each recipient's own rules decide their own copy: two mailboxes on
// this server receiving the same message may file it differently.
folder := targetFolder
var decision RuleDecision
if uc.rules != nil {
decision = uc.rules.For(ctx, recipient.ID, input.RecipientAddresses[i], input.Sender, payload)
if decision.Discard {
// Accepted and then dropped. The sender is told the message was
// delivered, because a filing rule is not a rejection - saying
// otherwise would have their server retry it forever.
log.Printf("rules: %s discarded a message from %s",
input.RecipientAddresses[i], input.Sender)
continue
}
if decision.Folder != "" {
folder = decision.Folder
}
if decision.Vacation != nil {
vacationReplies = append(vacationReplies, pendingVacation{
To: input.Sender,
From: input.RecipientAddresses[i],
Subject: decision.Vacation.Subject,
Body: decision.Vacation.Body,
})
}
}
persistInputs = append(persistInputs, port.PersistInboundMessageInput{
MailboxID: recipient.ID, MailboxID: recipient.ID,
Blob: blob, Blob: blob,
SenderAddress: input.Sender, SenderAddress: input.Sender,
RecipientAddress: input.RecipientAddresses[i], RecipientAddress: input.RecipientAddresses[i],
TargetFolderName: targetFolder, TargetFolderName: folder,
Subject: headers.Subject, Subject: headers.Subject,
Snippet: headers.Snippet, Snippet: headers.Snippet,
FromDisplayName: headers.FromDisplayName, FromDisplayName: headers.FromDisplayName,
@@ -293,13 +332,24 @@ func (uc *ProcessInboundEmailUseCase) Handle(ctx context.Context, input ProcessI
SPFResult: authResult.SPF, SPFResult: authResult.SPF,
DKIMResult: authResult.DKIM, DKIMResult: authResult.DKIM,
DMARCResult: authResult.DMARC, DMARCResult: authResult.DMARC,
})
} }
// Every recipient's rules discarded it: nothing to file, and the sender is
// still told the message was accepted.
if len(persistInputs) == 0 {
uc.sendVacationReplies(ctx, vacationReplies)
return nil
} }
if _, err := uc.messages.PersistAll(ctx, persistInputs); err != nil { if _, err := uc.messages.PersistAll(ctx, persistInputs); err != nil {
return err return err
} }
// Only after the message is safely stored: a reply sent for a delivery
// that then rolled back would answer mail the mailbox never received.
uc.sendVacationReplies(ctx, vacationReplies)
// Notifications happen only after the commit: an IDLE client told about a // Notifications happen only after the commit: an IDLE client told about a
// message that then rolled back would show one that does not exist. // message that then rolled back would show one that does not exist.
for _, recipient := range input.Recipients { for _, recipient := range input.Recipients {
@@ -0,0 +1,112 @@
package usecase
import (
"bytes"
"context"
"fmt"
"log"
"strings"
"time"
"github.com/google/uuid"
"lambdamail/protocols/internal/application/port"
)
// pendingVacation is one automatic reply the rules asked for, held until the
// message that triggered it is safely stored.
type pendingVacation struct {
To string
From string
Subject string
Body string
}
// SetVacationSender enables actually sending the out-of-office replies the
// rules produce. Without it a reply is logged and not sent, which is what
// happens on a deployment with no submission path configured.
func (uc *ProcessInboundEmailUseCase) SetVacationSender(
submission *ProcessOutboundEmailUseCase, auth port.AuthRepository, host string,
) {
uc.submission = submission
uc.auth = auth
uc.replyHost = host
}
// sendVacationReplies queues the replies the rules produced.
//
// Failures are logged and swallowed. The reply is a courtesy; the message that
// triggered it is already delivered, and returning an error here would tell
// the sender their mail failed when it did not.
func (uc *ProcessInboundEmailUseCase) sendVacationReplies(ctx context.Context, replies []pendingVacation) {
if len(replies) == 0 {
return
}
if uc.submission == nil || uc.auth == nil {
for _, reply := range replies {
log.Printf("vacation: no submission path configured, not replying to %s on behalf of %s",
reply.To, reply.From)
}
return
}
for _, reply := range replies {
if err := uc.sendVacationReply(ctx, reply); err != nil {
log.Printf("vacation: could not reply to %s on behalf of %s: %v", reply.To, reply.From, err)
}
}
}
func (uc *ProcessInboundEmailUseCase) sendVacationReply(ctx context.Context, reply pendingVacation) error {
account, err := uc.auth.FindByAddress(ctx, reply.From)
if err != nil {
return err
}
if account == nil {
return fmt.Errorf("no mailbox for %s", reply.From)
}
payload := buildVacationMessage(reply, uc.replyHost)
return uc.submission.Submit(ctx, ProcessOutboundEmailInput{
MailboxID: account.ID,
SenderAddr: account.EmailAddress,
DomainName: account.DomainName,
Recipients: []string{reply.To},
Body: bytes.NewReader(payload),
MaxRecipientsPerHour: account.MaxRecipientsPerHour,
})
}
// buildVacationMessage assembles the reply.
//
// Auto-Submitted: auto-replied is what stops two servers answering each other
// forever: RFC 3834 requires it on an automatic reply, and this server's own
// responder refuses to answer anything carrying it. Without the header, two
// mailboxes both on holiday would reply to each other until one of them ran
// out of send quota.
func buildVacationMessage(reply pendingVacation, host string) []byte {
if host == "" {
host = "localhost"
}
subject := reply.Subject
if strings.TrimSpace(subject) == "" {
subject = "Out of office"
}
var b strings.Builder
b.WriteString("From: " + reply.From + "\r\n")
b.WriteString("To: " + reply.To + "\r\n")
b.WriteString("Subject: " + encodeHeaderValue(subject) + "\r\n")
b.WriteString("Date: " + time.Now().Format(time.RFC1123Z) + "\r\n")
b.WriteString(fmt.Sprintf("Message-ID: <%s@%s>\r\n", uuid.NewString(), host))
b.WriteString("Auto-Submitted: auto-replied\r\n")
// Tells Exchange and Outlook not to generate their own automatic answer.
b.WriteString("X-Auto-Response-Suppress: All\r\n")
// Marks it as unimportant to anything that sorts by precedence.
b.WriteString("Precedence: bulk\r\n")
b.WriteString("MIME-Version: 1.0\r\n")
b.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
b.WriteString("Content-Transfer-Encoding: 8bit\r\n\r\n")
b.WriteString(normaliseEOL(reply.Body))
return []byte(b.String())
}
@@ -5,6 +5,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
@@ -118,3 +119,27 @@ func (r *SieveRepository) RenameScript(ctx context.Context, mailboxID string, ol
} }
return nil return nil
} }
// ActiveScriptFor returns the mailbox's active script, for the delivery path.
//
// Delivery needs only this one question answered, so it is a method of its own
// rather than the delivery path reaching for the whole script-management
// surface that ManageSieve uses.
//
// A mailbox with no active script returns empty rather than an error: having
// no rules is the ordinary case, not a failure.
func (r *SieveRepository) ActiveScriptFor(ctx context.Context, mailboxID uuid.UUID) (string, error) {
var script string
err := r.pool.QueryRow(ctx, `
SELECT script FROM sieve_scripts
WHERE mailbox_id = $1 AND is_active
LIMIT 1
`, mailboxID).Scan(&script)
if errors.Is(err, pgx.ErrNoRows) {
return "", nil
}
if err != nil {
return "", fmt.Errorf("read the active script for %s: %w", mailboxID, err)
}
return script, nil
}
@@ -413,3 +413,76 @@ func boolToInt(b bool) int {
} }
return 0 return 0
} }
// --- folders the mailbox owner creates for themselves --------------------
// CreateFolder adds a folder with no special-use role.
//
// No role, because IMAP defines roles only for the standard set: a folder a
// user invents is addressed by name, which is why the move and the rules match
// on names as well as roles.
func (r *WebmailRepository) CreateFolder(ctx context.Context, mailboxID, name string) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO folders (mailbox_id, name, special_use) VALUES ($1, $2, NULL)
`, mailboxID, name)
if err != nil {
return fmt.Errorf("create folder %q: %w", name, err)
}
return nil
}
// RenameFolder changes a folder's name, keeping its messages and its UIDs.
//
// The messages are not touched: a UID belongs to a folder, and the folder is
// still the same one, so renaming must not renumber anything or an IMAP client
// holding those UIDs would lose track of every message in it.
func (r *WebmailRepository) RenameFolder(ctx context.Context, mailboxID, from, to string) error {
tag, err := r.pool.Exec(ctx, `
UPDATE folders SET name = $3
WHERE mailbox_id = $1 AND LOWER(name) = LOWER($2) AND special_use IS NULL
`, mailboxID, from, to)
if err != nil {
return fmt.Errorf("rename folder %q: %w", from, err)
}
if tag.RowsAffected() == 0 {
return port.ErrFolderMissing
}
return nil
}
// DeleteFolder removes a folder and the mail filed in it.
//
// The messages are expunged rather than deleted outright, matching what the
// rest of the schema does: the blobs stay referenced until the retention sweep,
// so a folder deleted by mistake is still recoverable from the database.
func (r *WebmailRepository) DeleteFolder(ctx context.Context, mailboxID, name string) error {
tx, err := r.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
var folderID string
err = tx.QueryRow(ctx, `
SELECT id::text FROM folders
WHERE mailbox_id = $1 AND LOWER(name) = LOWER($2) AND special_use IS NULL
`, mailboxID, name).Scan(&folderID)
if errors.Is(err, pgx.ErrNoRows) {
return port.ErrFolderMissing
}
if err != nil {
return err
}
if _, err := tx.Exec(ctx, `
UPDATE email_messages SET expunged_at = NOW()
WHERE folder_id = $1 AND expunged_at IS NULL
`, folderID); err != nil {
return fmt.Errorf("expunge the folder's messages: %w", err)
}
if _, err := tx.Exec(ctx, `DELETE FROM folders WHERE id = $1`, folderID); err != nil {
return fmt.Errorf("delete folder %q: %w", name, err)
}
return tx.Commit(ctx)
}
@@ -26,6 +26,9 @@ const maxComposeBytes = 32 << 20 // 32 MiB
type mailAPI struct { type mailAPI struct {
useCase *usecase.WebmailUseCase useCase *usecase.WebmailUseCase
sessions *WebSessionVerifier 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) { 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}) 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. // handleMove files one message into another folder.
func (m *mailAPI) handleMove(w http.ResponseWriter, r *http.Request) { func (m *mailAPI) handleMove(w http.ResponseWriter, r *http.Request) {
session, ok := m.authenticate(w, r) 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") writeError(w, http.StatusTooManyRequests, "SEND_LIMIT_EXCEEDED", "Hourly recipient limit reached")
case errors.Is(err, usecase.ErrSenderNotOwned): case errors.Is(err, usecase.ErrSenderNotOwned):
writeError(w, http.StatusForbidden, "FORBIDDEN", "Sender address does not belong to this account") 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): case errors.Is(err, usecase.ErrFolderNotATarget):
writeError(w, http.StatusBadRequest, "FOLDER_NOT_A_TARGET", writeError(w, http.StatusBadRequest, "FOLDER_NOT_A_TARGET",
"Messages cannot be moved into Sent or Drafts") "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/draft", r.handleMailDraft)
r.mux.HandleFunc("/api/v1/mail/delete", r.handleMailDelete) r.mux.HandleFunc("/api/v1/mail/delete", r.handleMailDelete)
r.mux.HandleFunc("/api/v1/mail/move", r.handleMailMove) 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/dmarc", r.handleDmarcIngest)
r.mux.HandleFunc("/api/v1/reports/tlsrpt", r.handleTlsRptIngest) 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. // handleMailMove files one message into another folder.
func (r *Router) handleMailMove(w http.ResponseWriter, req *http.Request) { func (r *Router) handleMailMove(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost { if req.Method != http.MethodPost {