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:
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 {
|
||||||
|
|||||||
Reference in new issue
Block a user