From 42420a7de27ed90e3e1693ae31230c0624ee67f7 Mon Sep 17 00:00:00 2001 From: Lucas Rayan Date: Sat, 22 Aug 2026 13:35:17 -0300 Subject: [PATCH] =?UTF-8?q?feat(regras):=20aplica=20as=20regras=20na=20ent?= =?UTF-8?q?rega=20e=20envia=20a=20resposta=20de=20aus=C3=AAncia?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../cmd/lambdamail-protocols/main.go | 15 ++ .../port/inbound_message_repository.go | 4 +- .../application/port/report_repository.go | 10 + .../application/port/webmail_repository.go | 17 ++ .../application/usecase/apply_rules.go | 102 ++++++++ .../application/usecase/apply_rules_test.go | 233 ++++++++++++++++++ .../application/usecase/manage_folders.go | 158 ++++++++++++ .../usecase/manage_folders_test.go | 207 ++++++++++++++++ .../usecase/process_inbound_email.go | 58 ++++- .../application/usecase/vacation_reply.go | 112 +++++++++ .../postgres/sieve_repository.go | 25 ++ .../postgres/webmail_repository.go | 73 ++++++ .../internal/presentation/http/mail_api.go | 55 +++++ .../internal/presentation/http/router.go | 19 ++ 14 files changed, 1082 insertions(+), 6 deletions(-) create mode 100644 services/protocols/internal/application/usecase/apply_rules.go create mode 100644 services/protocols/internal/application/usecase/apply_rules_test.go create mode 100644 services/protocols/internal/application/usecase/manage_folders.go create mode 100644 services/protocols/internal/application/usecase/manage_folders_test.go create mode 100644 services/protocols/internal/application/usecase/vacation_reply.go diff --git a/services/protocols/cmd/lambdamail-protocols/main.go b/services/protocols/cmd/lambdamail-protocols/main.go index 632f8fc..fa08180 100644 --- a/services/protocols/cmd/lambdamail-protocols/main.go +++ b/services/protocols/cmd/lambdamail-protocols/main.go @@ -131,6 +131,11 @@ func run(cfg config) { reportsUC := usecase.NewIngestReportsUseCase(reportRepo) 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 var signer port.DkimSigner if dkimRepo != nil { @@ -138,6 +143,12 @@ func run(cfg config) { } 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() outboundWorker := usecase.NewOutboundWorkerUseCase(outboundRepo, mxResolver, blobReader, inboundUC, mailboxes, cfg.PrimaryMailHost) 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") } 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 { router.SetAdminDkimAPI(dkimRepo, generateDkimKey, cfg.JwtSecret) } diff --git a/services/protocols/internal/application/port/inbound_message_repository.go b/services/protocols/internal/application/port/inbound_message_repository.go index 1082bfe..27e219a 100644 --- a/services/protocols/internal/application/port/inbound_message_repository.go +++ b/services/protocols/internal/application/port/inbound_message_repository.go @@ -28,8 +28,8 @@ type PersistInboundMessageInput struct { // reader ever opens their own outgoing copy, so the flag was never set. AlreadySeen bool SPFResult string - DKIMResult string - DMARCResult string + DKIMResult string + DMARCResult string } // InboundMessageRepository durably records an accepted inbound message: for diff --git a/services/protocols/internal/application/port/report_repository.go b/services/protocols/internal/application/port/report_repository.go index cbb7a90..33e0e09 100644 --- a/services/protocols/internal/application/port/report_repository.go +++ b/services/protocols/internal/application/port/report_repository.go @@ -31,3 +31,13 @@ type DeliveredReportStore interface { // MoveToReportsFolder files a backfilled message out of the inbox. 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) +} diff --git a/services/protocols/internal/application/port/webmail_repository.go b/services/protocols/internal/application/port/webmail_repository.go index b6c106e..5f436f3 100644 --- a/services/protocols/internal/application/port/webmail_repository.go +++ b/services/protocols/internal/application/port/webmail_repository.go @@ -74,6 +74,10 @@ type WebmailRepository interface { 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 // than the message being destroyed instead. 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 // already in Trash has no further destination. 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 +} diff --git a/services/protocols/internal/application/usecase/apply_rules.go b/services/protocols/internal/application/usecase/apply_rules.go new file mode 100644 index 0000000..7a32bec --- /dev/null +++ b/services/protocols/internal/application/usecase/apply_rules.go @@ -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 +} diff --git a/services/protocols/internal/application/usecase/apply_rules_test.go b/services/protocols/internal/application/usecase/apply_rules_test.go new file mode 100644 index 0000000..7a7be20 --- /dev/null +++ b/services/protocols/internal/application/usecase/apply_rules_test.go @@ -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: \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) } diff --git a/services/protocols/internal/application/usecase/manage_folders.go b/services/protocols/internal/application/usecase/manage_folders.go new file mode 100644 index 0000000..92dc7a3 --- /dev/null +++ b/services/protocols/internal/application/usecase/manage_folders.go @@ -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 +} diff --git a/services/protocols/internal/application/usecase/manage_folders_test.go b/services/protocols/internal/application/usecase/manage_folders_test.go new file mode 100644 index 0000000..2c97152 --- /dev/null +++ b/services/protocols/internal/application/usecase/manage_folders_test.go @@ -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) + } +} diff --git a/services/protocols/internal/application/usecase/process_inbound_email.go b/services/protocols/internal/application/usecase/process_inbound_email.go index 9510692..a489128 100644 --- a/services/protocols/internal/application/usecase/process_inbound_email.go +++ b/services/protocols/internal/application/usecase/process_inbound_email.go @@ -66,12 +66,22 @@ type ProcessInboundEmailUseCase struct { folders port.ImapFolderRepository trackerManager *MailboxTrackerManager reportIngestor *IngestDeliveredReportsUseCase + rules *ApplyRulesUseCase + submission *ProcessOutboundEmailUseCase + auth port.AuthRepository + replyHost string } func NewProcessInboundEmailUseCase(mailboxes port.MailboxRepository, blobs port.BlobStorage, messages port.InboundMessageRepository) *ProcessInboundEmailUseCase { 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 // that carries them. Optional: with no ingestor a report is delivered like any // 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 // 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. + var vacationReplies []pendingVacation + alreadySeen := false if uc.reportIngestor != nil { 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. headers := ExtractMessageHeaders(payload) - persistInputs := make([]port.PersistInboundMessageInput, len(input.Recipients)) + persistInputs := make([]port.PersistInboundMessageInput, 0, len(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, Blob: blob, SenderAddress: input.Sender, RecipientAddress: input.RecipientAddresses[i], - TargetFolderName: targetFolder, + TargetFolderName: folder, Subject: headers.Subject, Snippet: headers.Snippet, FromDisplayName: headers.FromDisplayName, @@ -293,13 +332,24 @@ func (uc *ProcessInboundEmailUseCase) Handle(ctx context.Context, input ProcessI SPFResult: authResult.SPF, DKIMResult: authResult.DKIM, 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 { 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 // message that then rolled back would show one that does not exist. for _, recipient := range input.Recipients { diff --git a/services/protocols/internal/application/usecase/vacation_reply.go b/services/protocols/internal/application/usecase/vacation_reply.go new file mode 100644 index 0000000..8382267 --- /dev/null +++ b/services/protocols/internal/application/usecase/vacation_reply.go @@ -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()) +} diff --git a/services/protocols/internal/infrastructure/postgres/sieve_repository.go b/services/protocols/internal/infrastructure/postgres/sieve_repository.go index 8ba53a1..7b61b08 100644 --- a/services/protocols/internal/infrastructure/postgres/sieve_repository.go +++ b/services/protocols/internal/infrastructure/postgres/sieve_repository.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" + "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" @@ -118,3 +119,27 @@ func (r *SieveRepository) RenameScript(ctx context.Context, mailboxID string, ol } 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 +} diff --git a/services/protocols/internal/infrastructure/postgres/webmail_repository.go b/services/protocols/internal/infrastructure/postgres/webmail_repository.go index 3d502f9..d7df3ae 100644 --- a/services/protocols/internal/infrastructure/postgres/webmail_repository.go +++ b/services/protocols/internal/infrastructure/postgres/webmail_repository.go @@ -413,3 +413,76 @@ func boolToInt(b bool) int { } 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) +} diff --git a/services/protocols/internal/presentation/http/mail_api.go b/services/protocols/internal/presentation/http/mail_api.go index 8e1a75f..94781ef 100644 --- a/services/protocols/internal/presentation/http/mail_api.go +++ b/services/protocols/internal/presentation/http/mail_api.go @@ -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") diff --git a/services/protocols/internal/presentation/http/router.go b/services/protocols/internal/presentation/http/router.go index 5c15b01..4ca457f 100644 --- a/services/protocols/internal/presentation/http/router.go +++ b/services/protocols/internal/presentation/http/router.go @@ -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 {