package usecase import ( "context" "errors" "strconv" "strings" "testing" "github.com/google/uuid" "lambdamail/protocols/internal/application/port" ) // Every one of these reproduces a way the composer's mail was being mangled or // lost between the browser and the queue. func TestComposedMessageCarriesTheBody(t *testing.T) { // The HTTP layer read a field called "body" while the composer had always // sent "html", so the message that reached this function had an empty body // and every mail sent from the webmail arrived blank. payload := string(buildMimeMessage(ComposeInput{ From: "me@example.test", To: []string{"you@example.test"}, Subject: "Hello", HTML: "
Real content
", }, []string{"you@example.test"}, "mail.example.test")) if !strings.Contains(payload, "Real content") { t.Fatalf("the body is missing from the message:\n%s", payload) } } func TestHtmlIsSentAsHtml(t *testing.T) { payload := string(buildMimeMessage(ComposeInput{ From: "me@example.test", To: []string{"you@example.test"}, Subject: "Formatted", HTML: "Hello there
", }, []string{"you@example.test"}, "mail.example.test")) if !strings.Contains(payload, "multipart/alternative") { t.Errorf("want multipart/alternative for an HTML message, got:\n%s", payload) } if !strings.Contains(payload, "Content-Type: text/html; charset=utf-8") { t.Errorf("the HTML part is not declared as text/html:\n%s", payload) } // A plain-text alternative must exist and must not be empty: an // HTML-only multipart message scores badly with every spam filter. if !strings.Contains(payload, "Content-Type: text/plain; charset=utf-8") { t.Errorf("no plain-text alternative:\n%s", payload) } if !strings.Contains(payload, "Hello there") { t.Errorf("the plain-text alternative did not get a readable fallback:\n%s", payload) } // text/plain must come first - least preferred first, RFC 2046 5.1.4. if strings.Index(payload, "text/plain") > strings.Index(payload, "text/html") { t.Error("text/plain must precede text/html in multipart/alternative") } } func TestPlainTextOnlyMessageStaysSimple(t *testing.T) { payload := string(buildMimeMessage(ComposeInput{ From: "me@example.test", To: []string{"you@example.test"}, Subject: "Plain", Text: "just words", }, []string{"you@example.test"}, "mail.example.test")) if strings.Contains(payload, "multipart") { t.Errorf("a text-only message should not be multipart:\n%s", payload) } if !strings.Contains(payload, "just words") { t.Errorf("body missing:\n%s", payload) } } func TestBccNeverAppearsInTheHeaders(t *testing.T) { // Bcc belongs to the envelope alone. Leaking it into the headers would // show every recipient who else was blind-copied. input := ComposeInput{ From: "me@example.test", To: []string{"you@example.test"}, Bcc: []string{"secret@example.test"}, Subject: "Quiet", Text: "hi", } recipients := normaliseRecipients( append(append(append([]string{}, input.To...), input.Cc...), input.Bcc...)) // It does reach the envelope, or the blind copy is simply not delivered - // which is what happened while Bcc was not read from the request at all. found := false for _, r := range recipients { if r == "secret@example.test" { found = true } } if !found { t.Fatalf("bcc recipient missing from the envelope: %v", recipients) } payload := string(buildMimeMessage(input, recipients, "mail.example.test")) headers, _, _ := strings.Cut(payload, "\r\n\r\n") // The field, not the letters. Searching the whole block for "bcc" matched // the random Message-ID whenever its UUID happened to contain that run of // characters - <1bcc5e82-...> is a real example - so this failed a few // times in a thousand on changes that had nothing to do with it. for _, line := range strings.Split(headers, "\r\n") { name, _, found := strings.Cut(line, ":") if found && strings.EqualFold(strings.TrimSpace(name), "bcc") { t.Errorf("a Bcc header was emitted:\n%s", headers) } } // The address itself must not appear anywhere, under any field. if strings.Contains(headers, "secret@example.test") { t.Errorf("the blind copy address leaked into the headers:\n%s", headers) } } func TestHtmlToPlainTextIsReadable(t *testing.T) { got := htmlToPlainText("First line
Second & last
writ
", }, 0) if err != nil { t.Fatalf("first save: %v", err) } if len(repo.expunged) != 0 { t.Errorf("the first save has nothing to replace, expunged %v", repo.expunged) } if _, err := uc.SaveDraft(context.Background(), ComposeInput{ From: "me@example.test", Subject: "Half", HTML: "written
", }, first); err != nil { t.Fatalf("second save: %v", err) } if len(repo.expunged) != 1 || repo.expunged[0] != first { t.Errorf("the superseded draft was not removed: expunged %v, want [%d]", repo.expunged, first) } if len(messages.persisted) != 2 { t.Fatalf("want 2 writes, got %d", len(messages.persisted)) } if messages.persisted[0].TargetFolderName != "Drafts" { t.Errorf("draft filed in %q, want Drafts", messages.persisted[0].TargetFolderName) } } // A draft is a half-written message by definition; it must save with no // recipients, where Send would rightly refuse. func TestDraftSavesWithoutRecipients(t *testing.T) { mailboxID := uuid.New() uc := (&WebmailUseCase{ repo: &stubWebmailRepo{mailboxID: mailboxID.String()}, auth: &stubAuthRepo{account: &port.MailboxAuth{ID: mailboxID, EmailAddress: "me@example.test"}}, localHost: "mail.example.test", }).WithLocalFiling( &stubBlobStorage{ref: port.BlobRef{ID: uuid.New(), SHA256: "d", SizeBytes: 5}}, &recordingMessageRepository{}, ) if _, err := uc.SaveDraft(context.Background(), ComposeInput{ From: "me@example.test", Subject: "no recipients yet", }, 0); err != nil { t.Fatalf("a draft with no recipients must still save: %v", err) } } // --- sending, and what it leaves behind ---------------------------------- // The bug as the user hit it: write a message, send it, and the draft the // autosave had stored was still sitting in Drafts afterwards - a duplicate of // a message already on its way, that nothing on any screen could remove. func TestSendingDiscardsTheDraftItCameFrom(t *testing.T) { mailboxID := uuid.New() repo := &stubWebmailRepo{mailboxID: mailboxID.String()} uc := newSendableUseCase(t, mailboxID, repo) if err := uc.Send(context.Background(), ComposeInput{ From: "me@example.test", To: []string{"you@example.test"}, Subject: "Finished", HTML: "done
", DraftUID: 42, }); err != nil { t.Fatalf("send: %v", err) } if len(repo.expunged) != 1 || repo.expunged[0] != 42 { t.Errorf("the draft was left in Drafts after sending: expunged %v, want [42]", repo.expunged) } } // A message composed from scratch has no draft behind it, and must not try to // remove UID 0 - which is not a UID at all. func TestSendingWithoutADraftExpungesNothing(t *testing.T) { mailboxID := uuid.New() repo := &stubWebmailRepo{mailboxID: mailboxID.String()} uc := newSendableUseCase(t, mailboxID, repo) if err := uc.Send(context.Background(), ComposeInput{ From: "me@example.test", To: []string{"you@example.test"}, Text: "hi", }); err != nil { t.Fatalf("send: %v", err) } if len(repo.expunged) != 0 { t.Errorf("nothing should have been expunged, got %v", repo.expunged) } } // The Sent copy is a message the user wrote; it is not unread mail, and filing // it as unread is what left the Sent folder with a badge nothing could clear. func TestSentCopyIsFiledAsAlreadyRead(t *testing.T) { mailboxID := uuid.New() repo := &stubWebmailRepo{mailboxID: mailboxID.String()} messages := &recordingMessageRepository{} uc := newSendableUseCaseWith(t, mailboxID, repo, messages) if err := uc.Send(context.Background(), ComposeInput{ From: "me@example.test", To: []string{"you@example.test"}, Text: "hi", }); err != nil { t.Fatalf("send: %v", err) } if len(messages.persisted) != 1 { t.Fatalf("want one filed copy, got %d", len(messages.persisted)) } if !messages.persisted[0].AlreadySeen { t.Error("the Sent copy was filed as unread mail") } } // Likewise a draft: nothing ever opens one, so if it is filed unread its // folder carries a badge forever. func TestDraftIsFiledAsAlreadyRead(t *testing.T) { mailboxID := uuid.New() messages := &recordingMessageRepository{} uc := (&WebmailUseCase{ repo: &stubWebmailRepo{mailboxID: mailboxID.String()}, auth: &stubAuthRepo{account: &port.MailboxAuth{ID: mailboxID, EmailAddress: "me@example.test"}}, localHost: "mail.example.test", }).WithLocalFiling( &stubBlobStorage{ref: port.BlobRef{ID: uuid.New(), SHA256: "d", SizeBytes: 5}}, messages) if _, err := uc.SaveDraft(context.Background(), ComposeInput{ From: "me@example.test", Subject: "half", }, 0); err != nil { t.Fatalf("save draft: %v", err) } if len(messages.persisted) != 1 || !messages.persisted[0].AlreadySeen { t.Error("the draft was filed as unread mail") } } // --- deleting ------------------------------------------------------------ // Deleting from an ordinary folder moves the message to Trash. func TestDeleteMovesToTrash(t *testing.T) { mailboxID := uuid.New() repo := &stubWebmailRepo{mailboxID: mailboxID.String()} uc := &WebmailUseCase{ repo: repo, auth: &stubAuthRepo{account: &port.MailboxAuth{ID: mailboxID, EmailAddress: "me@example.test"}}, localHost: "mail.example.test", } if err := uc.Delete(context.Background(), "me@example.test", "inbox", 7); err != nil { t.Fatalf("delete: %v", err) } if len(repo.movedToTrash) != 1 || repo.movedToTrash[0] != 7 { t.Errorf("the message was not moved to Trash: %v", repo.movedToTrash) } if len(repo.expunged) != 0 { t.Errorf("deleting from the inbox must not destroy the message: expunged %v", repo.expunged) } } // Deleting from Trash is the second press, and that one really removes it. func TestDeleteFromTrashExpunges(t *testing.T) { mailboxID := uuid.New() repo := &stubWebmailRepo{mailboxID: mailboxID.String(), moveErr: port.ErrAlreadyInTrash} uc := &WebmailUseCase{ repo: repo, auth: &stubAuthRepo{account: &port.MailboxAuth{ID: mailboxID, EmailAddress: "me@example.test"}}, localHost: "mail.example.test", } if err := uc.Delete(context.Background(), "me@example.test", "trash", 3); err != nil { t.Fatalf("delete from trash: %v", err) } if len(repo.expunged) != 1 || repo.expunged[0] != 3 { t.Errorf("emptying from Trash did not remove the message: %v", repo.expunged) } } // --- moving between folders --------------------------------------------- // Filing a message somewhere else is the one ordinary mail action the webmail // still had no way to perform. func TestMoveFilesTheMessageInTheTargetFolder(t *testing.T) { mailboxID := uuid.New() repo := &stubWebmailRepo{mailboxID: mailboxID.String()} uc := &WebmailUseCase{ repo: repo, auth: &stubAuthRepo{account: &port.MailboxAuth{ID: mailboxID, EmailAddress: "me@example.test"}}, localHost: "mail.example.test", } if err := uc.Move(context.Background(), "me@example.test", "inbox", 5, "Archive"); err != nil { t.Fatalf("move: %v", err) } if len(repo.moved) != 1 || repo.moved[0] != "inbox->Archive/5" { t.Errorf("moved %v", repo.moved) } } // Sent and Drafts describe how a message came to exist, not where the reader // filed it. Moving arbitrary mail into them would make the folder lie: Sent // would hold messages that were never sent, and a draft that cannot be edited // would sit among the unfinished ones. func TestMoveRefusesSentAndDrafts(t *testing.T) { mailboxID := uuid.New() repo := &stubWebmailRepo{mailboxID: mailboxID.String()} uc := &WebmailUseCase{ repo: repo, auth: &stubAuthRepo{account: &port.MailboxAuth{ID: mailboxID, EmailAddress: "me@example.test"}}, localHost: "mail.example.test", } for _, target := range []string{"Sent", "sent", "Drafts", "DRAFTS"} { err := uc.Move(context.Background(), "me@example.test", "inbox", 5, target) if !errors.Is(err, ErrFolderNotATarget) { t.Errorf("moving into %q returned %v, want ErrFolderNotATarget", target, err) } } if len(repo.moved) != 0 { t.Errorf("a refused move still touched the repository: %v", repo.moved) } } // Moving a message into the folder it is already in is a no-op, not an error: // it is what a double click on the same target produces. func TestMoveIntoTheSameFolderDoesNothing(t *testing.T) { mailboxID := uuid.New() repo := &stubWebmailRepo{mailboxID: mailboxID.String()} uc := &WebmailUseCase{ repo: repo, auth: &stubAuthRepo{account: &port.MailboxAuth{ID: mailboxID, EmailAddress: "me@example.test"}}, localHost: "mail.example.test", } if err := uc.Move(context.Background(), "me@example.test", "inbox", 5, "inbox"); err != nil { t.Fatalf("move into the same folder: %v", err) } if len(repo.moved) != 0 { t.Errorf("a no-op move still touched the repository: %v", repo.moved) } } func TestMoveRejectsAnEmptyTarget(t *testing.T) { mailboxID := uuid.New() uc := &WebmailUseCase{ repo: &stubWebmailRepo{mailboxID: mailboxID.String()}, auth: &stubAuthRepo{account: &port.MailboxAuth{ID: mailboxID, EmailAddress: "me@example.test"}}, localHost: "mail.example.test", } if err := uc.Move(context.Background(), "me@example.test", "inbox", 5, " "); err == nil { t.Error("an empty target should be refused") } }