feat: implement message deletion functionality and improve draft handling
- Added `MoveToTrash` method in `WebmailRepository` to move messages to the Trash folder instead of deleting them permanently. - Introduced `handleDelete` method in `mailAPI` to handle message deletion requests from the client. - Updated router to include a new route for message deletion. - Enhanced the compose page to manage draft integrity, ensuring drafts are correctly associated with sent messages. - Implemented UI changes to allow users to delete messages from the mail folder view. - Added tests to verify the correct behavior of message deletion and draft handling. - Updated database migration scripts to reconcile folder counters and ensure accurate unread counts. - Added tests for TLS-RPT report parsing and webmail repository functionalities.
This commit is contained in:
1 parent
4f1b9c5217
commit
880dced9e4
20 files changed
+1042
-19
No files matched your search
@@ -34,3 +34,5 @@ Thumbs.db
|
|||||||
|
|
||||||
# TypeScript incremental build cache
|
# TypeScript incremental build cache
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
*json
|
||||||
|
*.json.gz
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- Irreversible by nature: this migration replaced incorrect counter values and
|
||||||
|
-- added the flags that describe what a message is. Restoring the old numbers
|
||||||
|
-- would mean restoring known-wrong data, and dropping the \Seen and \Draft
|
||||||
|
-- flags would discard read state the user may since have set by hand.
|
||||||
|
SELECT 1;
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
-- Reconcile folders.unread_count and folders.total_count with the messages
|
||||||
|
-- that are actually in each folder.
|
||||||
|
--
|
||||||
|
-- These columns are a denormalised cache. Every write path maintained them,
|
||||||
|
-- but one did not: a message the user composed themselves - their own Sent
|
||||||
|
-- copy, and every draft autosave - was filed through the inbound path, which
|
||||||
|
-- raised unread_count unconditionally. Nothing ever opens your own outgoing
|
||||||
|
-- copy, so the flag that would bring the counter back down was never set and
|
||||||
|
-- the drift only accumulated.
|
||||||
|
--
|
||||||
|
-- The webmail now derives these numbers at read time and is self-healing, but
|
||||||
|
-- IMAP's STATUS answers from these columns, so the stored values are corrected
|
||||||
|
-- here once for the mailboxes that already drifted.
|
||||||
|
UPDATE folders f
|
||||||
|
SET unread_count = c.unread,
|
||||||
|
total_count = c.total
|
||||||
|
FROM (
|
||||||
|
SELECT f2.id,
|
||||||
|
COUNT(e.id) FILTER (
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM message_flags mf
|
||||||
|
WHERE mf.message_id = e.id
|
||||||
|
AND mf.received_at = e.received_at
|
||||||
|
AND mf.flag = '\Seen')
|
||||||
|
)::int AS unread,
|
||||||
|
COUNT(e.id)::int AS total
|
||||||
|
FROM folders f2
|
||||||
|
LEFT JOIN email_messages e
|
||||||
|
ON e.folder_id = f2.id AND e.expunged_at IS NULL
|
||||||
|
GROUP BY f2.id
|
||||||
|
) AS c
|
||||||
|
WHERE f.id = c.id
|
||||||
|
AND (f.unread_count IS DISTINCT FROM c.unread
|
||||||
|
OR f.total_count IS DISTINCT FROM c.total);
|
||||||
|
|
||||||
|
-- Existing Sent and Drafts messages were filed without the flags that say what
|
||||||
|
-- they are, so they read as unread mail that nothing could clear.
|
||||||
|
INSERT INTO message_flags (message_id, received_at, flag)
|
||||||
|
SELECT e.id, e.received_at, '\Seen'
|
||||||
|
FROM email_messages e
|
||||||
|
JOIN folders f ON f.id = e.folder_id
|
||||||
|
WHERE e.expunged_at IS NULL
|
||||||
|
AND (f.special_use IN ('sent', 'drafts')
|
||||||
|
OR LOWER(f.name) IN ('sent', 'drafts'))
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO message_flags (message_id, received_at, flag)
|
||||||
|
SELECT e.id, e.received_at, '\Draft'
|
||||||
|
FROM email_messages e
|
||||||
|
JOIN folders f ON f.id = e.folder_id
|
||||||
|
WHERE e.expunged_at IS NULL
|
||||||
|
AND (f.special_use = 'drafts' OR LOWER(f.name) = 'drafts')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Re-run the reconciliation now those flags exist, so the Sent and Drafts
|
||||||
|
-- counters land on zero unread rather than on their pre-flag values.
|
||||||
|
UPDATE folders f
|
||||||
|
SET unread_count = c.unread
|
||||||
|
FROM (
|
||||||
|
SELECT f2.id,
|
||||||
|
COUNT(e.id) FILTER (
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM message_flags mf
|
||||||
|
WHERE mf.message_id = e.id
|
||||||
|
AND mf.received_at = e.received_at
|
||||||
|
AND mf.flag = '\Seen')
|
||||||
|
)::int AS unread
|
||||||
|
FROM folders f2
|
||||||
|
LEFT JOIN email_messages e
|
||||||
|
ON e.folder_id = f2.id AND e.expunged_at IS NULL
|
||||||
|
GROUP BY f2.id
|
||||||
|
) AS c
|
||||||
|
WHERE f.id = c.id
|
||||||
|
AND f.unread_count IS DISTINCT FROM c.unread;
|
||||||
@@ -22,6 +22,11 @@ type PersistInboundMessageInput struct {
|
|||||||
FromDisplayName string
|
FromDisplayName string
|
||||||
MessageIDHeader string
|
MessageIDHeader string
|
||||||
HasAttachments bool
|
HasAttachments bool
|
||||||
|
// AlreadySeen marks a copy the user themselves composed - their own Sent
|
||||||
|
// copy, or a draft. It is not unread mail, and counting it as such is why
|
||||||
|
// the Sent folder carried an unread badge that nothing could clear: no
|
||||||
|
// reader ever opens their own outgoing copy, so the flag was never set.
|
||||||
|
AlreadySeen bool
|
||||||
SPFResult string
|
SPFResult string
|
||||||
DKIMResult string
|
DKIMResult string
|
||||||
DMARCResult string
|
DMARCResult string
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package port
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -63,4 +64,16 @@ type WebmailRepository interface {
|
|||||||
MarkSeen(ctx context.Context, mailboxID, folderName string, uid uint32, seen bool) error
|
MarkSeen(ctx context.Context, mailboxID, folderName string, uid uint32, seen bool) error
|
||||||
// Expunge soft-deletes one message, keeping the folder counters in step.
|
// Expunge soft-deletes one message, keeping the folder counters in step.
|
||||||
Expunge(ctx context.Context, mailboxID, folderName string, uid uint32) error
|
Expunge(ctx context.Context, mailboxID, folderName string, uid uint32) error
|
||||||
|
// MoveToTrash relocates one message into Trash and returns its new UID
|
||||||
|
// there. This is what deleting a message means everywhere but in Trash
|
||||||
|
// itself, where Expunge is the operation instead.
|
||||||
|
MoveToTrash(ctx context.Context, mailboxID, folderName string, uid uint32) (uint32, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
|
||||||
|
// 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")
|
||||||
@@ -124,6 +124,8 @@ type stubWebmailRepo struct {
|
|||||||
mailboxID string
|
mailboxID string
|
||||||
expunged []uint32
|
expunged []uint32
|
||||||
expungeErr error
|
expungeErr error
|
||||||
|
movedToTrash []uint32
|
||||||
|
moveErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *stubWebmailRepo) FindMailboxIDByAddress(context.Context, string) (string, error) {
|
func (s *stubWebmailRepo) FindMailboxIDByAddress(context.Context, string) (string, error) {
|
||||||
@@ -143,6 +145,39 @@ func (s *stubWebmailRepo) Expunge(_ context.Context, _, _ string, uid uint32) er
|
|||||||
s.expunged = append(s.expunged, uid)
|
s.expunged = append(s.expunged, uid)
|
||||||
return s.expungeErr
|
return s.expungeErr
|
||||||
}
|
}
|
||||||
|
func (s *stubWebmailRepo) MoveToTrash(_ context.Context, _, _ string, uid uint32) (uint32, error) {
|
||||||
|
if s.moveErr != nil {
|
||||||
|
return 0, s.moveErr
|
||||||
|
}
|
||||||
|
s.movedToTrash = append(s.movedToTrash, uid)
|
||||||
|
return uid, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// newSendableUseCase wires the smallest use case that can actually complete a
|
||||||
|
// Send: a real submission path over recording stubs.
|
||||||
|
func newSendableUseCase(t *testing.T, mailboxID uuid.UUID, repo *stubWebmailRepo) *WebmailUseCase {
|
||||||
|
t.Helper()
|
||||||
|
return newSendableUseCaseWith(t, mailboxID, repo, &recordingMessageRepository{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSendableUseCaseWith(
|
||||||
|
t *testing.T, mailboxID uuid.UUID, repo *stubWebmailRepo, messages *recordingMessageRepository,
|
||||||
|
) *WebmailUseCase {
|
||||||
|
t.Helper()
|
||||||
|
account := &port.MailboxAuth{
|
||||||
|
ID: mailboxID, EmailAddress: "me@example.test",
|
||||||
|
DomainName: "example.test", MaxRecipientsPerHour: 100,
|
||||||
|
}
|
||||||
|
submission := NewProcessOutboundEmailUseCase(
|
||||||
|
nil, &countingOutboundRepo{}, &capturingBlobStorage{}, &recordingSigner{})
|
||||||
|
return (&WebmailUseCase{
|
||||||
|
repo: repo,
|
||||||
|
auth: &stubAuthRepo{account: account},
|
||||||
|
submission: submission,
|
||||||
|
localHost: "mail.example.test",
|
||||||
|
}).WithLocalFiling(
|
||||||
|
&stubBlobStorage{ref: port.BlobRef{ID: uuid.New(), SHA256: "d", SizeBytes: 5}}, messages)
|
||||||
|
}
|
||||||
|
|
||||||
type stubAuthRepo struct{ account *port.MailboxAuth }
|
type stubAuthRepo struct{ account *port.MailboxAuth }
|
||||||
|
|
||||||
@@ -210,3 +245,130 @@ func TestDraftSavesWithoutRecipients(t *testing.T) {
|
|||||||
t.Fatalf("a draft with no recipients must still save: %v", err)
|
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: "<p>done</p>",
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -138,6 +138,13 @@ type ComposeInput struct {
|
|||||||
// that cannot or will not render HTML still has something to show.
|
// that cannot or will not render HTML still has something to show.
|
||||||
Text string
|
Text string
|
||||||
HTML string
|
HTML string
|
||||||
|
// DraftUID is the autosaved draft this message grew out of, or 0 when it
|
||||||
|
// was composed without one ever being stored.
|
||||||
|
//
|
||||||
|
// Sending used to ignore it entirely, so a message that had been autosaved
|
||||||
|
// went out and its draft stayed in Drafts: a duplicate of mail already on
|
||||||
|
// its way, which no screen in the webmail could then delete.
|
||||||
|
DraftUID uint32
|
||||||
}
|
}
|
||||||
|
|
||||||
// ErrNoRecipients rejects a submission with nobody to deliver to, before it
|
// ErrNoRecipients rejects a submission with nobody to deliver to, before it
|
||||||
@@ -185,9 +192,46 @@ func (uc *WebmailUseCase) Send(ctx context.Context, input ComposeInput) error {
|
|||||||
if err := uc.fileLocalCopy(ctx, account.ID, account.EmailAddress, "Sent", payload); err != nil {
|
if err := uc.fileLocalCopy(ctx, account.ID, account.EmailAddress, "Sent", payload); err != nil {
|
||||||
log.Printf("webmail: could not file the Sent copy for %s: %v", account.EmailAddress, err)
|
log.Printf("webmail: could not file the Sent copy for %s: %v", account.EmailAddress, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The draft this message grew out of has been superseded by the real
|
||||||
|
// thing. Swallowed like the Sent copy above and for the same reason: the
|
||||||
|
// mail is already queued, and failing the request now would only invite a
|
||||||
|
// second copy to be sent.
|
||||||
|
if input.DraftUID != 0 {
|
||||||
|
if mailboxID, idErr := uc.mailboxID(ctx, account.EmailAddress); idErr == nil {
|
||||||
|
if err := uc.repo.Expunge(ctx, mailboxID, "Drafts", input.DraftUID); err != nil {
|
||||||
|
log.Printf("webmail: could not discard the sent draft %d: %v", input.DraftUID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete removes one message the way a mail client does: from any ordinary
|
||||||
|
// folder it goes to Trash, and from Trash it is really expunged.
|
||||||
|
//
|
||||||
|
// The webmail had no delete at all. Expunge existed but was reachable only
|
||||||
|
// when an autosave superseded a draft, so anything the user wanted rid of -
|
||||||
|
// most visibly the draft left behind by a sent message - stayed put.
|
||||||
|
func (uc *WebmailUseCase) Delete(ctx context.Context, address, folder string, uid uint32) error {
|
||||||
|
mailboxID, err := uc.mailboxID(ctx, address)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = uc.repo.MoveToTrash(ctx, mailboxID, folder, uid)
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
return nil
|
||||||
|
case errors.Is(err, port.ErrAlreadyInTrash), errors.Is(err, port.ErrNoTrashFolder):
|
||||||
|
// Already in Trash, or nowhere to move it to. Either way the second
|
||||||
|
// press is the one that removes it for good.
|
||||||
|
return uc.repo.Expunge(ctx, mailboxID, folder, uid)
|
||||||
|
default:
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// fileLocalCopy stores a message the user themselves composed into one of
|
// fileLocalCopy stores a message the user themselves composed into one of
|
||||||
// their own folders. It reuses the inbound persistence path so the copy gets a
|
// their own folders. It reuses the inbound persistence path so the copy gets a
|
||||||
// real IMAP UID and shows up over IMAP as well as in the webmail.
|
// real IMAP UID and shows up over IMAP as well as in the webmail.
|
||||||
@@ -213,6 +257,7 @@ func (uc *WebmailUseCase) fileLocalCopy(
|
|||||||
FromDisplayName: headers.FromDisplayName,
|
FromDisplayName: headers.FromDisplayName,
|
||||||
MessageIDHeader: headers.MessageID,
|
MessageIDHeader: headers.MessageID,
|
||||||
HasAttachments: headers.HasAttachments,
|
HasAttachments: headers.HasAttachments,
|
||||||
|
AlreadySeen: true,
|
||||||
}})
|
}})
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -261,6 +306,7 @@ func (uc *WebmailUseCase) SaveDraft(ctx context.Context, input ComposeInput, rep
|
|||||||
FromDisplayName: headers.FromDisplayName,
|
FromDisplayName: headers.FromDisplayName,
|
||||||
MessageIDHeader: headers.MessageID,
|
MessageIDHeader: headers.MessageID,
|
||||||
HasAttachments: headers.HasAttachments,
|
HasAttachments: headers.HasAttachments,
|
||||||
|
AlreadySeen: true,
|
||||||
}})
|
}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ func TestParseTlsRptReport_ValidJSON(t *testing.T) {
|
|||||||
{
|
{
|
||||||
"policy": {
|
"policy": {
|
||||||
"policy-type": "sts",
|
"policy-type": "sts",
|
||||||
"domain": "example.test"
|
"policy-domain": "example.test"
|
||||||
},
|
},
|
||||||
"summary": {
|
"summary": {
|
||||||
"total-successful-session-count": 100,
|
"total-successful-session-count": 100,
|
||||||
|
|||||||
@@ -34,7 +34,10 @@ type tlsRptJsonReport struct {
|
|||||||
Policies []struct {
|
Policies []struct {
|
||||||
Policy struct {
|
Policy struct {
|
||||||
PolicyType string `json:"policy-type"`
|
PolicyType string `json:"policy-type"`
|
||||||
Domain string `json:"domain"`
|
// RFC 8460 section 4.4 names this "policy-domain". Reading it as
|
||||||
|
// "domain" parsed every real report to a blank domain, so a stored
|
||||||
|
// report could not be attributed to the domain it was about.
|
||||||
|
Domain string `json:"policy-domain"`
|
||||||
} `json:"policy"`
|
} `json:"policy"`
|
||||||
Summary struct {
|
Summary struct {
|
||||||
TotalSuccessfulSessionCount int `json:"total-successful-session-count"`
|
TotalSuccessfulSessionCount int `json:"total-successful-session-count"`
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package entity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This is a real report, as Google sends it daily: the gzipped JSON that
|
||||||
|
// arrives attached to a message addressed to the TLS-RPT rua.
|
||||||
|
const googleTlsRptReport = `{"organization-name":"Google Inc.",` +
|
||||||
|
`"date-range":{"start-datetime":"2026-08-19T00:00:00Z","end-datetime":"2026-08-19T23:59:59Z"},` +
|
||||||
|
`"contact-info":"smtp-tls-reporting@google.com","report-id":"2026-08-19T00:00:00Z_example.test",` +
|
||||||
|
`"policies":[{"policy":{"policy-type":"no-policy-found","policy-domain":"example.test"},` +
|
||||||
|
`"summary":{"total-successful-session-count":2,"total-failure-session-count":0}}]}`
|
||||||
|
|
||||||
|
// The field is "policy-domain" (RFC 8460 section 4.4), not "domain". Reading
|
||||||
|
// the wrong name parsed every report to a blank domain, so a stored report
|
||||||
|
// could not be attributed to the domain it was about.
|
||||||
|
func TestTlsRptReportCarriesThePolicyDomain(t *testing.T) {
|
||||||
|
report, err := ParseTlsRptReport([]byte(googleTlsRptReport))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
if report.Domain != "example.test" {
|
||||||
|
t.Errorf("domain is %q, want example.test", report.Domain)
|
||||||
|
}
|
||||||
|
if report.OrganizationName != "Google Inc." {
|
||||||
|
t.Errorf("organization is %q", report.OrganizationName)
|
||||||
|
}
|
||||||
|
if len(report.Policies) != 1 {
|
||||||
|
t.Fatalf("want 1 policy, got %d", len(report.Policies))
|
||||||
|
}
|
||||||
|
if report.Policies[0].SuccessCount != 2 {
|
||||||
|
t.Errorf("success count is %d, want 2", report.Policies[0].SuccessCount)
|
||||||
|
}
|
||||||
|
if report.Policies[0].PolicyType != "no-policy-found" {
|
||||||
|
t.Errorf("policy type is %q", report.Policies[0].PolicyType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reports arrive gzipped, which is how they land as a .json.gz attachment.
|
||||||
|
func TestTlsRptReportParsesGzippedPayload(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
gz := gzip.NewWriter(&buf)
|
||||||
|
if _, err := gz.Write([]byte(googleTlsRptReport)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := gz.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
report, err := ParseTlsRptReport(buf.Bytes())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse gzipped: %v", err)
|
||||||
|
}
|
||||||
|
if report.Domain != "example.test" {
|
||||||
|
t.Errorf("domain is %q, want example.test", report.Domain)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,9 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
|
||||||
@@ -99,23 +101,39 @@ func persistOne(ctx context.Context, tx pgx.Tx, input port.PersistInboundMessage
|
|||||||
return 0, fmt.Errorf("find folder %q for mailbox %s: %w", targetFolder, input.MailboxID, err)
|
return 0, fmt.Errorf("find folder %q for mailbox %s: %w", targetFolder, input.MailboxID, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A copy the user wrote themselves arrives already read, so it must not
|
||||||
|
// raise the unread counter. Incrementing unconditionally is what left the
|
||||||
|
// Sent and Drafts folders advertising unread mail forever: nothing ever
|
||||||
|
// opens your own outgoing copy, so the counter had no way back down.
|
||||||
|
unreadDelta := 1
|
||||||
|
if input.AlreadySeen {
|
||||||
|
unreadDelta = 0
|
||||||
|
}
|
||||||
|
|
||||||
var modseq int64
|
var modseq int64
|
||||||
if err := tx.QueryRow(ctx, `
|
if err := tx.QueryRow(ctx, `
|
||||||
UPDATE folders SET uid_next = uid_next + 1, highest_modseq = highest_modseq + 1, unread_count = unread_count + 1, total_count = total_count + 1
|
UPDATE folders SET uid_next = uid_next + 1, highest_modseq = highest_modseq + 1,
|
||||||
|
unread_count = unread_count + $2, total_count = total_count + 1
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING highest_modseq
|
RETURNING highest_modseq
|
||||||
`, folderID).Scan(&modseq); err != nil {
|
`, folderID, unreadDelta).Scan(&modseq); err != nil {
|
||||||
return 0, fmt.Errorf("advance uid_next, highest_modseq and folder counters: %w", err)
|
return 0, fmt.Errorf("advance uid_next, highest_modseq and folder counters: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// email_messages.id defaults to gen_random_uuid() per the migration - no explicit value needed.
|
// email_messages.id defaults to gen_random_uuid() per the migration - no explicit value needed.
|
||||||
_, err = tx.Exec(ctx, `
|
// id and received_at come back because message_flags is keyed on both:
|
||||||
|
// email_messages is partitioned by received_at, so the flag rows cannot be
|
||||||
|
// written without the same timestamp the row landed on.
|
||||||
|
var messageID uuid.UUID
|
||||||
|
var receivedAt time.Time
|
||||||
|
err = tx.QueryRow(ctx, `
|
||||||
INSERT INTO email_messages (
|
INSERT INTO email_messages (
|
||||||
mailbox_id, folder_id, uid, modseq, blob_id,
|
mailbox_id, folder_id, uid, modseq, blob_id,
|
||||||
sender_address, recipient_addresses, size_bytes,
|
sender_address, recipient_addresses, size_bytes,
|
||||||
subject, snippet, from_display_name, message_id_header, has_attachments,
|
subject, snippet, from_display_name, message_id_header, has_attachments,
|
||||||
spf_result, dkim_result, dmarc_result
|
spf_result, dkim_result, dmarc_result
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
|
||||||
|
RETURNING id, received_at
|
||||||
`, input.MailboxID, folderID, uid, modseq, input.Blob.ID,
|
`, input.MailboxID, folderID, uid, modseq, input.Blob.ID,
|
||||||
input.SenderAddress, []string{input.RecipientAddress}, input.Blob.SizeBytes,
|
input.SenderAddress, []string{input.RecipientAddress}, input.Blob.SizeBytes,
|
||||||
nullIfEmpty(input.Subject), nullIfEmpty(input.Snippet), nullIfEmpty(input.FromDisplayName),
|
nullIfEmpty(input.Subject), nullIfEmpty(input.Snippet), nullIfEmpty(input.FromDisplayName),
|
||||||
@@ -129,11 +147,24 @@ func persistOne(ctx context.Context, tx pgx.Tx, input port.PersistInboundMessage
|
|||||||
// Sent copy and drafts have no authentication results by definition -
|
// Sent copy and drafts have no authentication results by definition -
|
||||||
// and it failed the whole insert. Sent stayed empty because that write
|
// and it failed the whole insert. Sent stayed empty because that write
|
||||||
// is deliberately non-fatal, and saving a draft reported an error.
|
// is deliberately non-fatal, and saving a draft reported an error.
|
||||||
nullIfEmpty(input.SPFResult), nullIfEmpty(input.DKIMResult), nullIfEmpty(input.DMARCResult))
|
nullIfEmpty(input.SPFResult), nullIfEmpty(input.DKIMResult), nullIfEmpty(input.DMARCResult)).
|
||||||
|
Scan(&messageID, &receivedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("insert email_messages: %w", err)
|
return 0, fmt.Errorf("insert email_messages: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The flags that describe what this copy is. Without \Seen on an outgoing
|
||||||
|
// copy the unread counters never settle; without \Draft an IMAP client
|
||||||
|
// shows a half-written message as ordinary mail and offers to reply to it.
|
||||||
|
for _, flag := range messageFlagsFor(input, targetFolder) {
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
INSERT INTO message_flags (message_id, received_at, flag)
|
||||||
|
VALUES ($1, $2, $3) ON CONFLICT DO NOTHING
|
||||||
|
`, messageID, receivedAt, flag); err != nil {
|
||||||
|
return 0, fmt.Errorf("set flag %s: %w", flag, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if _, err := tx.Exec(ctx, `UPDATE message_blobs SET ref_count = ref_count + 1 WHERE id = $1`, input.Blob.ID); err != nil {
|
if _, err := tx.Exec(ctx, `UPDATE message_blobs SET ref_count = ref_count + 1 WHERE id = $1`, input.Blob.ID); err != nil {
|
||||||
return 0, fmt.Errorf("increment blob ref_count: %w", err)
|
return 0, fmt.Errorf("increment blob ref_count: %w", err)
|
||||||
}
|
}
|
||||||
@@ -151,3 +182,15 @@ func persistOne(ctx context.Context, tx pgx.Tx, input port.PersistInboundMessage
|
|||||||
|
|
||||||
return uid, nil
|
return uid, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// messageFlagsFor returns the IMAP flags a newly filed copy is born with.
|
||||||
|
func messageFlagsFor(input port.PersistInboundMessageInput, folder string) []string {
|
||||||
|
flags := []string{}
|
||||||
|
if input.AlreadySeen {
|
||||||
|
flags = append(flags, `\Seen`)
|
||||||
|
}
|
||||||
|
if strings.EqualFold(folder, "Drafts") {
|
||||||
|
flags = append(flags, `\Draft`)
|
||||||
|
}
|
||||||
|
return flags
|
||||||
|
}
|
||||||
@@ -40,13 +40,35 @@ func (r *WebmailRepository) FindMailboxIDByAddress(ctx context.Context, address
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *WebmailRepository) ListFolders(ctx context.Context, mailboxID string) ([]port.WebmailFolder, error) {
|
func (r *WebmailRepository) ListFolders(ctx context.Context, mailboxID string) ([]port.WebmailFolder, error) {
|
||||||
|
// The counts are derived from the messages themselves rather than read out
|
||||||
|
// of folders.unread_count/total_count.
|
||||||
|
//
|
||||||
|
// Those columns are a denormalised cache maintained by every write path,
|
||||||
|
// and once any one of them was wrong nothing brought it back: the number
|
||||||
|
// the user saw stayed wrong through a reload, a logout and a fresh login,
|
||||||
|
// because the wrongness was stored. Deriving here is authoritative and
|
||||||
|
// self-healing - a folder can only ever report what is actually in it.
|
||||||
|
//
|
||||||
|
// The columns stay maintained for IMAP's sake (STATUS answers from them),
|
||||||
|
// so this is a correction at the read side, not their removal.
|
||||||
rows, err := r.pool.Query(ctx, `
|
rows, err := r.pool.Query(ctx, `
|
||||||
SELECT id::text, name, COALESCE(special_use, ''), unread_count, total_count
|
SELECT f.id::text, f.name, COALESCE(f.special_use, ''),
|
||||||
FROM folders WHERE mailbox_id = $1
|
COUNT(e.id) FILTER (
|
||||||
ORDER BY CASE special_use
|
WHERE NOT EXISTS (SELECT 1 FROM message_flags mf
|
||||||
|
WHERE mf.message_id = e.id
|
||||||
|
AND mf.received_at = e.received_at
|
||||||
|
AND mf.flag = '\Seen')
|
||||||
|
)::int AS unread_count,
|
||||||
|
COUNT(e.id)::int AS total_count
|
||||||
|
FROM folders f
|
||||||
|
LEFT JOIN email_messages e
|
||||||
|
ON e.folder_id = f.id AND e.expunged_at IS NULL
|
||||||
|
WHERE f.mailbox_id = $1
|
||||||
|
GROUP BY f.id, f.name, f.special_use
|
||||||
|
ORDER BY CASE f.special_use
|
||||||
WHEN 'inbox' THEN 0 WHEN 'drafts' THEN 1 WHEN 'sent' THEN 2
|
WHEN 'inbox' THEN 0 WHEN 'drafts' THEN 1 WHEN 'sent' THEN 2
|
||||||
WHEN 'archive' THEN 3 WHEN 'junk' THEN 4 WHEN 'trash' THEN 5
|
WHEN 'archive' THEN 3 WHEN 'junk' THEN 4 WHEN 'trash' THEN 5
|
||||||
ELSE 6 END, name
|
ELSE 6 END, f.name
|
||||||
`, mailboxID)
|
`, mailboxID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list folders: %w", err)
|
return nil, fmt.Errorf("list folders: %w", err)
|
||||||
@@ -224,7 +246,9 @@ func (r *WebmailRepository) Expunge(
|
|||||||
err = tx.QueryRow(ctx, `
|
err = tx.QueryRow(ctx, `
|
||||||
SELECT e.id, f.id::text,
|
SELECT e.id, f.id::text,
|
||||||
NOT EXISTS (SELECT 1 FROM message_flags mf
|
NOT EXISTS (SELECT 1 FROM message_flags mf
|
||||||
WHERE mf.message_id = e.id AND mf.flag = '\Seen')
|
WHERE mf.message_id = e.id
|
||||||
|
AND mf.received_at = e.received_at
|
||||||
|
AND mf.flag = '\Seen')
|
||||||
FROM email_messages e
|
FROM email_messages e
|
||||||
JOIN folders f ON f.id = e.folder_id
|
JOIN folders f ON f.id = e.folder_id
|
||||||
WHERE e.mailbox_id = $1 AND `+folderCondition+`
|
WHERE e.mailbox_id = $1 AND `+folderCondition+`
|
||||||
@@ -260,3 +284,110 @@ func (r *WebmailRepository) Expunge(
|
|||||||
|
|
||||||
return tx.Commit(ctx)
|
return tx.Commit(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MoveToTrash relocates one message into the mailbox's Trash folder and
|
||||||
|
// returns the UID it was given there.
|
||||||
|
//
|
||||||
|
// This is what "delete" means in a mail client, and the webmail had no way to
|
||||||
|
// do it at all: the only removal in the codebase was Expunge, reachable solely
|
||||||
|
// when an autosave superseded a draft. A message the user wanted rid of - the
|
||||||
|
// empty draft left behind by a sent message, most visibly - could not be
|
||||||
|
// removed from any screen.
|
||||||
|
//
|
||||||
|
// The message keeps its identity and its blob; only the folder and the UID
|
||||||
|
// change, because a UID is meaningful only within one folder. Deleting from
|
||||||
|
// Trash itself is Expunge's job, not this one's.
|
||||||
|
func (r *WebmailRepository) MoveToTrash(
|
||||||
|
ctx context.Context, mailboxID, folderName string, uid uint32,
|
||||||
|
) (uint32, error) {
|
||||||
|
tx, err := r.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
var messageID uuid.UUID
|
||||||
|
var sourceFolderID string
|
||||||
|
var wasUnread bool
|
||||||
|
err = tx.QueryRow(ctx, `
|
||||||
|
SELECT e.id, f.id::text,
|
||||||
|
NOT EXISTS (SELECT 1 FROM message_flags mf
|
||||||
|
WHERE mf.message_id = e.id
|
||||||
|
AND mf.received_at = e.received_at
|
||||||
|
AND mf.flag = '\Seen')
|
||||||
|
FROM email_messages e
|
||||||
|
JOIN folders f ON f.id = e.folder_id
|
||||||
|
WHERE e.mailbox_id = $1 AND `+folderCondition+`
|
||||||
|
AND e.uid = $3 AND e.expunged_at IS NULL
|
||||||
|
`, mailboxID, folderName, int64(uid)).Scan(&messageID, &sourceFolderID, &wasUnread)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return 0, ErrMessageNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deleting something already in Trash has no further destination, so the
|
||||||
|
// caller is told to expunge instead of the message being moved onto itself.
|
||||||
|
var trashFolderID string
|
||||||
|
var trashUID int64
|
||||||
|
err = tx.QueryRow(ctx, `
|
||||||
|
SELECT id::text, uid_next FROM folders
|
||||||
|
WHERE mailbox_id = $1 AND (special_use = 'trash' OR LOWER(name) = 'trash')
|
||||||
|
ORDER BY special_use IS NOT NULL DESC
|
||||||
|
LIMIT 1
|
||||||
|
FOR UPDATE
|
||||||
|
`, mailboxID).Scan(&trashFolderID, &trashUID)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return 0, port.ErrNoTrashFolder
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if trashFolderID == sourceFolderID {
|
||||||
|
return 0, port.ErrAlreadyInTrash
|
||||||
|
}
|
||||||
|
|
||||||
|
var modseq int64
|
||||||
|
if err := tx.QueryRow(ctx, `
|
||||||
|
UPDATE folders SET uid_next = uid_next + 1, highest_modseq = highest_modseq + 1,
|
||||||
|
total_count = total_count + 1, unread_count = unread_count + $2
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING highest_modseq
|
||||||
|
`, trashFolderID, boolToInt(wasUnread)).Scan(&modseq); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
UPDATE email_messages SET folder_id = $2, uid = $3, modseq = $4
|
||||||
|
WHERE id = $1
|
||||||
|
`, messageID, trashFolderID, trashUID, modseq); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
unreadDelta := 0
|
||||||
|
if wasUnread {
|
||||||
|
unreadDelta = -1
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
UPDATE folders
|
||||||
|
SET total_count = GREATEST(0, total_count - 1),
|
||||||
|
unread_count = GREATEST(0, unread_count + $2),
|
||||||
|
highest_modseq = highest_modseq + 1
|
||||||
|
WHERE id = $1
|
||||||
|
`, sourceFolderID, unreadDelta); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return uint32(trashUID), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolToInt(b bool) int {
|
||||||
|
if b {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"lambdamail/protocols/internal/application/port"
|
||||||
|
)
|
||||||
|
|
||||||
|
// These reproduce the counters the webmail was showing wrong: an unread badge
|
||||||
|
// that never came down however many messages were read, and a Sent folder that
|
||||||
|
// advertised unread mail the user had written themselves.
|
||||||
|
|
||||||
|
// seedMailbox creates an isolated domain, mailbox and folder set for one test,
|
||||||
|
// so tests cannot see each other's counters.
|
||||||
|
func seedMailbox(t *testing.T, pool *pgxpool.Pool) string {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
domainID := uuid.New()
|
||||||
|
mbID := uuid.New()
|
||||||
|
name := "t" + uuid.NewString()[:8] + ".test"
|
||||||
|
|
||||||
|
if _, err := pool.Exec(ctx,
|
||||||
|
`INSERT INTO domains (id, name, punycode_name, is_active) VALUES ($1,$2,$3,true)`,
|
||||||
|
domainID, name, name); err != nil {
|
||||||
|
t.Fatalf("seed domain: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(ctx, `
|
||||||
|
INSERT INTO mailboxes (id, domain_id, local_part, email_address, password_hash, is_active)
|
||||||
|
VALUES ($1,$2,'user',$3,'x',true)`,
|
||||||
|
mbID, domainID, "user@"+name); err != nil {
|
||||||
|
t.Fatalf("seed mailbox: %v", err)
|
||||||
|
}
|
||||||
|
for _, f := range [][2]string{{"INBOX", "inbox"}, {"Sent", "sent"}, {"Drafts", "drafts"}, {"Trash", "trash"}} {
|
||||||
|
if _, err := pool.Exec(ctx,
|
||||||
|
`INSERT INTO folders (mailbox_id, name, special_use) VALUES ($1,$2,$3)`,
|
||||||
|
mbID, f[0], f[1]); err != nil {
|
||||||
|
t.Fatalf("seed folder %s: %v", f[0], err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_, _ = pool.Exec(context.Background(), `DELETE FROM domains WHERE id = $1`, domainID)
|
||||||
|
})
|
||||||
|
return mbID.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// deliver files a message the way the inbound path does.
|
||||||
|
func deliver(t *testing.T, pool *pgxpool.Pool, mailboxID, folder, subject string, alreadySeen bool) uint32 {
|
||||||
|
t.Helper()
|
||||||
|
repo := NewInboundMessageRepository(pool)
|
||||||
|
blobID := storeTestBlob(t, pool)
|
||||||
|
uids, err := repo.PersistAll(context.Background(), []port.PersistInboundMessageInput{{
|
||||||
|
MailboxID: uuid.MustParse(mailboxID),
|
||||||
|
Blob: port.BlobRef{ID: blobID, SHA256: uuid.NewString(), SizeBytes: 10},
|
||||||
|
SenderAddress: "someone@example.test",
|
||||||
|
RecipientAddress: "user@example.test",
|
||||||
|
TargetFolderName: folder,
|
||||||
|
Subject: subject,
|
||||||
|
AlreadySeen: alreadySeen,
|
||||||
|
}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("persist: %v", err)
|
||||||
|
}
|
||||||
|
return uint32(uids[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
func storeTestBlob(t *testing.T, pool *pgxpool.Pool) uuid.UUID {
|
||||||
|
t.Helper()
|
||||||
|
id := uuid.New()
|
||||||
|
if _, err := pool.Exec(context.Background(), `
|
||||||
|
INSERT INTO message_blobs (id, content_sha256, storage_driver, storage_path, size_bytes, ref_count)
|
||||||
|
VALUES ($1, $2, 'local', $3, 10, 0)`,
|
||||||
|
id, strings.ReplaceAll(uuid.NewString()+uuid.NewString(), "-", "")[:64], "/dev/null/"+id.String()); err != nil {
|
||||||
|
t.Fatalf("seed blob: %v", err)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func folderByRole(t *testing.T, folders []port.WebmailFolder, role string) port.WebmailFolder {
|
||||||
|
t.Helper()
|
||||||
|
for _, f := range folders {
|
||||||
|
if f.SpecialUse == role {
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("no %q folder in %v", role, folders)
|
||||||
|
return port.WebmailFolder{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The bug the user reported: read a message, the unread number does not move,
|
||||||
|
// and it is still wrong after logging out and back in.
|
||||||
|
func TestUnreadCountFollowsWhatWasActuallyRead(t *testing.T) {
|
||||||
|
pool := testPool(t)
|
||||||
|
repo := NewWebmailRepository(pool)
|
||||||
|
mailboxID := seedMailbox(t, pool)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
uid := deliver(t, pool, mailboxID, "INBOX", "first", false)
|
||||||
|
deliver(t, pool, mailboxID, "INBOX", "second", false)
|
||||||
|
|
||||||
|
folders, err := repo.ListFolders(ctx, mailboxID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list folders: %v", err)
|
||||||
|
}
|
||||||
|
if got := folderByRole(t, folders, "inbox").UnreadCount; got != 2 {
|
||||||
|
t.Fatalf("two unread messages delivered, folder reports %d", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := repo.MarkSeen(ctx, mailboxID, "inbox", uid, true); err != nil {
|
||||||
|
t.Fatalf("mark seen: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
folders, _ = repo.ListFolders(ctx, mailboxID)
|
||||||
|
if got := folderByRole(t, folders, "inbox").UnreadCount; got != 1 {
|
||||||
|
t.Errorf("after reading one of two, unread is %d, want 1", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A counter that has already drifted must correct itself, or the number stays
|
||||||
|
// wrong forever no matter what the user does.
|
||||||
|
func TestUnreadCountSelfHealsAfterDrift(t *testing.T) {
|
||||||
|
pool := testPool(t)
|
||||||
|
repo := NewWebmailRepository(pool)
|
||||||
|
mailboxID := seedMailbox(t, pool)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
deliver(t, pool, mailboxID, "INBOX", "only one", false)
|
||||||
|
|
||||||
|
// Simulate the drift the old write path produced.
|
||||||
|
if _, err := pool.Exec(ctx, `
|
||||||
|
UPDATE folders SET unread_count = 47, total_count = 99
|
||||||
|
WHERE mailbox_id = $1 AND special_use = 'inbox'`, mailboxID); err != nil {
|
||||||
|
t.Fatalf("force drift: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
folders, err := repo.ListFolders(ctx, mailboxID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list folders: %v", err)
|
||||||
|
}
|
||||||
|
inbox := folderByRole(t, folders, "inbox")
|
||||||
|
if inbox.UnreadCount != 1 {
|
||||||
|
t.Errorf("unread did not self-heal: got %d, want 1", inbox.UnreadCount)
|
||||||
|
}
|
||||||
|
if inbox.TotalCount != 1 {
|
||||||
|
t.Errorf("total did not self-heal: got %d, want 1", inbox.TotalCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A message the user wrote themselves is not unread mail.
|
||||||
|
func TestOwnSentCopyIsNotCountedAsUnread(t *testing.T) {
|
||||||
|
pool := testPool(t)
|
||||||
|
repo := NewWebmailRepository(pool)
|
||||||
|
mailboxID := seedMailbox(t, pool)
|
||||||
|
|
||||||
|
deliver(t, pool, mailboxID, "Sent", "something I wrote", true)
|
||||||
|
|
||||||
|
folders, err := repo.ListFolders(context.Background(), mailboxID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list folders: %v", err)
|
||||||
|
}
|
||||||
|
sent := folderByRole(t, folders, "sent")
|
||||||
|
if sent.UnreadCount != 0 {
|
||||||
|
t.Errorf("the Sent folder reports %d unread; a message you sent is not unread mail", sent.UnreadCount)
|
||||||
|
}
|
||||||
|
if sent.TotalCount != 1 {
|
||||||
|
t.Errorf("Sent total is %d, want 1", sent.TotalCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deleting from an ordinary folder moves the message to Trash rather than
|
||||||
|
// destroying it, which is what every mail client does and what makes the
|
||||||
|
// action safe to offer.
|
||||||
|
func TestDeleteMovesToTrashThenExpungesFromTrash(t *testing.T) {
|
||||||
|
pool := testPool(t)
|
||||||
|
repo := NewWebmailRepository(pool)
|
||||||
|
mailboxID := seedMailbox(t, pool)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
uid := deliver(t, pool, mailboxID, "INBOX", "unwanted", false)
|
||||||
|
|
||||||
|
trashUID, err := repo.MoveToTrash(ctx, mailboxID, "inbox", uid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("move to trash: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
inboxRows, _ := repo.ListMessages(ctx, mailboxID, "inbox", "", 50, 0)
|
||||||
|
if len(inboxRows) != 0 {
|
||||||
|
t.Errorf("the message is still in the inbox after being deleted: %d rows", len(inboxRows))
|
||||||
|
}
|
||||||
|
trashRows, _ := repo.ListMessages(ctx, mailboxID, "trash", "", 50, 0)
|
||||||
|
if len(trashRows) != 1 {
|
||||||
|
t.Fatalf("the deleted message did not arrive in Trash: %d rows", len(trashRows))
|
||||||
|
}
|
||||||
|
|
||||||
|
folders, _ := repo.ListFolders(ctx, mailboxID)
|
||||||
|
if got := folderByRole(t, folders, "inbox").UnreadCount; got != 0 {
|
||||||
|
t.Errorf("inbox still counts the moved message as unread: %d", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := repo.Expunge(ctx, mailboxID, "trash", trashUID); err != nil {
|
||||||
|
t.Fatalf("expunge from trash: %v", err)
|
||||||
|
}
|
||||||
|
trashRows, _ = repo.ListMessages(ctx, mailboxID, "trash", "", 50, 0)
|
||||||
|
if len(trashRows) != 0 {
|
||||||
|
t.Errorf("the message survived being emptied from Trash: %d rows", len(trashRows))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -259,6 +259,37 @@ func (m *mailAPI) handleSend(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusAccepted, map[string]bool{"queued": true})
|
writeJSON(w, http.StatusAccepted, map[string]bool{"queued": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleDelete removes one message: to Trash from anywhere else, and for good
|
||||||
|
// from Trash itself.
|
||||||
|
func (m *mailAPI) handleDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
session, ok := m.authenticate(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
Folder string `json:"folder"`
|
||||||
|
UID uint32 `json:"uid"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&body); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "Invalid JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Folder == "" {
|
||||||
|
body.Folder = "inbox"
|
||||||
|
}
|
||||||
|
if body.UID == 0 {
|
||||||
|
writeError(w, http.StatusBadRequest, "INVALID_UID", "Message UID is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.useCase.Delete(r.Context(), session.Email, body.Folder, body.UID); err != nil {
|
||||||
|
m.fail(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||||||
|
}
|
||||||
|
|
||||||
// handleSaveDraft stores the message being composed in the Drafts folder.
|
// handleSaveDraft stores the message being composed in the Drafts folder.
|
||||||
func (m *mailAPI) handleSaveDraft(w http.ResponseWriter, r *http.Request) {
|
func (m *mailAPI) handleSaveDraft(w http.ResponseWriter, r *http.Request) {
|
||||||
session, ok := m.authenticate(w, r)
|
session, ok := m.authenticate(w, r)
|
||||||
@@ -299,6 +330,9 @@ func (m *mailAPI) decodeCompose(
|
|||||||
// previous shape - still sends a readable message rather than a blank
|
// previous shape - still sends a readable message rather than a blank
|
||||||
// one. Only used when neither text nor html is present.
|
// one. Only used when neither text nor html is present.
|
||||||
LegacyBody string `json:"body"`
|
LegacyBody string `json:"body"`
|
||||||
|
// DraftUID is the autosaved draft this message came from, so sending
|
||||||
|
// can clear it. Without it the draft outlived the message it became.
|
||||||
|
DraftUID uint32 `json:"draft_uid"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxComposeBytes)).Decode(&body); err != nil {
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxComposeBytes)).Decode(&body); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "Invalid JSON")
|
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "Invalid JSON")
|
||||||
@@ -321,6 +355,7 @@ func (m *mailAPI) decodeCompose(
|
|||||||
Subject: body.Subject,
|
Subject: body.Subject,
|
||||||
Text: text,
|
Text: text,
|
||||||
HTML: body.HTML,
|
HTML: body.HTML,
|
||||||
|
DraftUID: body.DraftUID,
|
||||||
}, true
|
}, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ func (r *Router) registerRoutes() {
|
|||||||
r.mux.HandleFunc("/api/v1/mail/seen", r.handleMailSeen)
|
r.mux.HandleFunc("/api/v1/mail/seen", r.handleMailSeen)
|
||||||
r.mux.HandleFunc("/api/v1/mail/send", r.handleMailSend)
|
r.mux.HandleFunc("/api/v1/mail/send", r.handleMailSend)
|
||||||
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/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)
|
||||||
}
|
}
|
||||||
@@ -303,6 +304,18 @@ func (r *Router) handleMailSend(w http.ResponseWriter, req *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleMailDelete removes one message. There was no route for this at all,
|
||||||
|
// so nothing the webmail displayed could be deleted from it.
|
||||||
|
func (r *Router) handleMailDelete(w http.ResponseWriter, req *http.Request) {
|
||||||
|
if req.Method != http.MethodPost && req.Method != http.MethodDelete {
|
||||||
|
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "POST or DELETE required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.mailReady(w) {
|
||||||
|
r.mail.handleDelete(w, req)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Router) handleMailDraft(w http.ResponseWriter, req *http.Request) {
|
func (r *Router) handleMailDraft(w http.ResponseWriter, req *http.Request) {
|
||||||
if req.Method != http.MethodPost {
|
if req.Method != http.MethodPost {
|
||||||
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "POST required")
|
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "POST required")
|
||||||
|
|||||||
@@ -16,6 +16,28 @@ const nextConfig: NextConfig = {
|
|||||||
source: "/api/v1/events",
|
source: "/api/v1/events",
|
||||||
destination: `${PROTOCOLS_SERVICE_URL}/api/v1/events`,
|
destination: `${PROTOCOLS_SERVICE_URL}/api/v1/events`,
|
||||||
},
|
},
|
||||||
|
// The well-known documents belong to the protocols service, but the
|
||||||
|
// hostnames senders ask for - mta-sts.<domain> above all - resolve to
|
||||||
|
// this app. Without these rewrites the Next 404 page was served in
|
||||||
|
// place of the MTA-STS policy: the _mta-sts TXT record advertised a
|
||||||
|
// policy that could not be fetched, so every sender fell back to
|
||||||
|
// "no-policy-found" and no TLS policy was enforced on inbound mail.
|
||||||
|
// Google's daily TLS-RPT report reports precisely that.
|
||||||
|
//
|
||||||
|
// A rewrite rather than a redirect, because RFC 8461 section 3.3 has
|
||||||
|
// the sender fetch that exact HTTPS path and it need not follow one.
|
||||||
|
{
|
||||||
|
source: "/.well-known/mta-sts.txt",
|
||||||
|
destination: `${PROTOCOLS_SERVICE_URL}/.well-known/mta-sts.txt`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: "/.well-known/security.txt",
|
||||||
|
destination: `${PROTOCOLS_SERVICE_URL}/.well-known/security.txt`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: "/.well-known/autoconfig/mail/config-v1.1.xml",
|
||||||
|
destination: `${PROTOCOLS_SERVICE_URL}/.well-known/autoconfig/mail/config-v1.1.xml`,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -58,11 +58,21 @@ export default function ComposePage({
|
|||||||
const draftUidRef = useRef<number>(0);
|
const draftUidRef = useRef<number>(0);
|
||||||
|
|
||||||
const editorRef = useRef<HTMLDivElement>(null);
|
const editorRef = useRef<HTMLDivElement>(null);
|
||||||
|
// The body, mirrored into React state.
|
||||||
|
//
|
||||||
|
// It used to live only inside the contenteditable, read through the ref at
|
||||||
|
// save time. Nothing observed it, so the autosave effect - which depends on
|
||||||
|
// what it reads - never re-ran when the message itself was typed. The draft
|
||||||
|
// was written once, moments after the address was entered, and never again:
|
||||||
|
// that is why a draft left behind by a sent message was empty.
|
||||||
|
const [body, setBody] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const savedSig = localStorage.getItem("lm_user_signature");
|
const savedSig = localStorage.getItem("lm_user_signature");
|
||||||
if (editorRef.current && savedSig) {
|
if (editorRef.current && savedSig) {
|
||||||
editorRef.current.innerHTML = `<br/><br/>--<br/>${savedSig}`;
|
const initial = `<br/><br/>--<br/>${savedSig}`;
|
||||||
|
editorRef.current.innerHTML = initial;
|
||||||
|
setBody(initial);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -70,8 +80,8 @@ export default function ComposePage({
|
|||||||
// "draft saved automatically" and nothing else - no request, no storage - so
|
// "draft saved automatically" and nothing else - no request, no storage - so
|
||||||
// closing the tab lost the message while the screen had just said otherwise.
|
// closing the tab lost the message while the screen had just said otherwise.
|
||||||
const saveDraft = useCallback(async () => {
|
const saveDraft = useCallback(async () => {
|
||||||
const html = editorRef.current?.innerHTML ?? "";
|
const html = editorRef.current?.innerHTML ?? body;
|
||||||
if (!to && !subject && !html.trim()) return;
|
if (!to && !subject && !body.trim() && !html.trim()) return;
|
||||||
setDraftStatus(t("mail.draftSaving"));
|
setDraftStatus(t("mail.draftSaving"));
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/v1/mail/draft", {
|
const res = await fetch("/api/v1/mail/draft", {
|
||||||
@@ -94,20 +104,26 @@ export default function ComposePage({
|
|||||||
} catch {
|
} catch {
|
||||||
setDraftStatus(t("mail.draftSaveFailed"));
|
setDraftStatus(t("mail.draftSaveFailed"));
|
||||||
}
|
}
|
||||||
}, [to, cc, bcc, subject, t]);
|
}, [to, cc, bcc, subject, body, t]);
|
||||||
|
|
||||||
// Debounced so a draft is written after a pause in typing rather than on
|
// Debounced so a draft is written after a pause in typing rather than on
|
||||||
// every keystroke. Skipped once the message is on its way out.
|
// every keystroke. Skipped once the message is on its way out.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sending) return;
|
if (sending) return;
|
||||||
if (!to && !subject) return;
|
// The body alone is enough: a message typed before its recipient is still
|
||||||
|
// worth keeping, and requiring an address here is half of why drafts were
|
||||||
|
// being stored with nothing in them.
|
||||||
|
if (!to && !subject && !body.trim()) return;
|
||||||
const timer = setTimeout(() => void saveDraft(), 2500);
|
const timer = setTimeout(() => void saveDraft(), 2500);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [to, subject, cc, bcc, sending, saveDraft]);
|
}, [to, subject, cc, bcc, body, sending, saveDraft]);
|
||||||
|
|
||||||
const executeFormat = (command: string, value: string | undefined = undefined) => {
|
const executeFormat = (command: string, value: string | undefined = undefined) => {
|
||||||
document.execCommand(command, false, value);
|
document.execCommand(command, false, value);
|
||||||
editorRef.current?.focus();
|
editorRef.current?.focus();
|
||||||
|
// execCommand edits the DOM directly, so the mirrored body is re-read
|
||||||
|
// here rather than relying on an input event that may not be dispatched.
|
||||||
|
setBody(editorRef.current?.innerHTML ?? "");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@@ -130,7 +146,7 @@ export default function ComposePage({
|
|||||||
setSending(true);
|
setSending(true);
|
||||||
setUndoSeconds(10);
|
setUndoSeconds(10);
|
||||||
|
|
||||||
const bodyHtml = editorRef.current?.innerHTML || "";
|
const bodyHtml = editorRef.current?.innerHTML || body;
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
setUndoSeconds((prev) => {
|
setUndoSeconds((prev) => {
|
||||||
@@ -155,6 +171,10 @@ export default function ComposePage({
|
|||||||
bcc: splitAddresses(bcc),
|
bcc: splitAddresses(bcc),
|
||||||
subject,
|
subject,
|
||||||
html: htmlBody,
|
html: htmlBody,
|
||||||
|
// The draft this message grew out of, so the server can discard it.
|
||||||
|
// Without this the draft outlived the message it became and sat in
|
||||||
|
// Drafts as a duplicate of mail already sent.
|
||||||
|
draft_uid: draftUidRef.current,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -369,6 +389,8 @@ export default function ComposePage({
|
|||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
contentEditable
|
contentEditable
|
||||||
suppressContentEditableWarning
|
suppressContentEditableWarning
|
||||||
|
onInput={(e) => setBody((e.target as HTMLDivElement).innerHTML)}
|
||||||
|
onBlur={(e) => setBody((e.target as HTMLDivElement).innerHTML)}
|
||||||
aria-label={t("ui.messageBody")}
|
aria-label={t("ui.messageBody")}
|
||||||
role="textbox"
|
role="textbox"
|
||||||
aria-multiline="true"
|
aria-multiline="true"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
MailOpen,
|
MailOpen,
|
||||||
Download,
|
Download,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
|
Trash2,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useI18n } from "../../../../../i18n/provider";
|
import { useI18n } from "../../../../../i18n/provider";
|
||||||
@@ -202,6 +203,39 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
|||||||
[folder]
|
[folder]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes one message: to Trash from an ordinary folder, and for good from
|
||||||
|
* Trash itself, which is what the server does with the same call.
|
||||||
|
*
|
||||||
|
* The webmail had no delete at all - no button and no route behind one - so
|
||||||
|
* anything the user wanted rid of stayed. The draft a sent message left
|
||||||
|
* behind was the case that made it impossible to ignore.
|
||||||
|
*/
|
||||||
|
const deleteMessage = useCallback(
|
||||||
|
async (uid: number) => {
|
||||||
|
// Removed from the list first so the row goes at the moment of the
|
||||||
|
// click; the reload below is what makes the server's answer final.
|
||||||
|
setMessages((current) => current.filter((m) => m.uid !== uid));
|
||||||
|
setSelected((current) => (current?.uid === uid ? null : current));
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/v1/mail/delete", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ folder, uid }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(t("errors.serverError"));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : t("errors.serverError"));
|
||||||
|
// The optimistic removal was wrong, so put the folder back as it is.
|
||||||
|
void loadMessages(searchQuery);
|
||||||
|
} finally {
|
||||||
|
// Both the source folder and Trash moved, so the badges must re-read.
|
||||||
|
notifyMailStateChanged();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[folder, t, loadMessages, searchQuery]
|
||||||
|
);
|
||||||
|
|
||||||
const openMessage = async (uid: number) => {
|
const openMessage = async (uid: number) => {
|
||||||
setLoadRemoteImages(false);
|
setLoadRemoteImages(false);
|
||||||
setReaderHeight(420);
|
setReaderHeight(420);
|
||||||
@@ -359,8 +393,12 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
|||||||
filteredMessages.map((msg) => {
|
filteredMessages.map((msg) => {
|
||||||
const isSelected = selected?.uid === msg.uid;
|
const isSelected = selected?.uid === msg.uid;
|
||||||
return (
|
return (
|
||||||
|
/* The row and its delete control are siblings inside a
|
||||||
|
positioned wrapper rather than nested: a button inside a
|
||||||
|
button is invalid markup, and the browser drops the inner
|
||||||
|
one, so the delete would never have been clickable. */
|
||||||
|
<div key={msg.uid} className="group relative">
|
||||||
<button
|
<button
|
||||||
key={msg.uid}
|
|
||||||
onClick={() => void openMessage(msg.uid)}
|
onClick={() => void openMessage(msg.uid)}
|
||||||
data-active={isSelected}
|
data-active={isSelected}
|
||||||
className="lm-row w-full text-left"
|
className="lm-row w-full text-left"
|
||||||
@@ -417,6 +455,19 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
|||||||
{!msg.seen && <span className="block h-[7px] w-[7px] rounded-full bg-indigo-500" />}
|
{!msg.seen && <span className="block h-[7px] w-[7px] rounded-full bg-indigo-500" />}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
{/* Reachable without opening the message first, which is what
|
||||||
|
the leftover empty draft needed: there was nothing worth
|
||||||
|
reading in it, and no other way to get rid of it. */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void deleteMessage(msg.uid)}
|
||||||
|
title={t("common.delete")}
|
||||||
|
aria-label={t("common.delete")}
|
||||||
|
className="absolute right-2 top-2 hidden h-7 w-7 items-center justify-center rounded-md text-slate-400 transition-colors hover:bg-white/[0.09] hover:text-slate-100 focus:flex focus-visible:flex group-hover:flex"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
@@ -466,6 +517,15 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
|||||||
<MailOpen className="h-3.5 w-3.5" />
|
<MailOpen className="h-3.5 w-3.5" />
|
||||||
<span>{selectedRow?.seen === false ? t("mail.markRead") : t("mail.markUnread")}</span>
|
<span>{selectedRow?.seen === false ? t("mail.markRead") : t("mail.markUnread")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void deleteMessage(selected.uid)}
|
||||||
|
title={t("common.delete")}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
<span>{t("common.delete")}</span>
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The composer's draft handling, and the delete action the mail list was
|
||||||
|
* missing entirely.
|
||||||
|
*
|
||||||
|
* Every check here is a defect the user hit: a message sent from the composer
|
||||||
|
* left its autosaved draft behind, that draft was empty because the autosave
|
||||||
|
* never saw the body, and no screen offered any way to remove it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const read = (path: string) => readFileSync(resolve(process.cwd(), path), "utf8");
|
||||||
|
const code = (path: string) =>
|
||||||
|
read(path)
|
||||||
|
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||||
|
.replace(/\{\/\*[\s\S]*?\*\/\}/g, "")
|
||||||
|
.replace(/^\s*\/\/.*$/gm, "");
|
||||||
|
|
||||||
|
const COMPOSE = "src/app/(user)/user/compose/page.tsx";
|
||||||
|
const MAIL_PAGE = "src/app/(user)/user/mail/[folder]/page.tsx";
|
||||||
|
|
||||||
|
describe("the draft a sent message leaves behind", () => {
|
||||||
|
// Sending posted to /mail/send without ever naming the draft it came from,
|
||||||
|
// so the draft stayed in Drafts as a duplicate of mail already on its way.
|
||||||
|
it("tells the server which draft the sent message came from", () => {
|
||||||
|
const compose = code(COMPOSE);
|
||||||
|
expect(compose).toMatch(/draft_uid:\s*draftUidRef\.current/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("posts the draft UID on the send request, not only on the autosave", () => {
|
||||||
|
const compose = code(COMPOSE);
|
||||||
|
const sendCall = compose.slice(compose.indexOf('"/api/v1/mail/send"'));
|
||||||
|
expect(sendCall.slice(0, 600)).toContain("draft_uid");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("what the autosave actually stores", () => {
|
||||||
|
// The body lived in a contenteditable read through a ref, and the debounce
|
||||||
|
// depended only on the header fields. Typing a message therefore never
|
||||||
|
// re-triggered a save, so the stored draft held the recipient and nothing
|
||||||
|
// else - the empty draft the user could see and could not delete.
|
||||||
|
it("re-runs when the body changes, not only the recipient and subject", () => {
|
||||||
|
const compose = code(COMPOSE);
|
||||||
|
const effect = compose.slice(compose.indexOf("const timer = setTimeout(() => void saveDraft()"));
|
||||||
|
const deps = effect.slice(effect.indexOf("}, ["), effect.indexOf("]);") + 3);
|
||||||
|
expect(deps).toContain("body");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the body in state so a change can be observed at all", () => {
|
||||||
|
const compose = code(COMPOSE);
|
||||||
|
expect(compose).toMatch(/useState.*\n?.*body|const \[body, setBody\]/);
|
||||||
|
expect(compose).toContain("setBody");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not save a draft that has no content anywhere", () => {
|
||||||
|
const compose = code(COMPOSE);
|
||||||
|
expect(compose).toMatch(/if \(!to && !subject && !body/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deleting a message", () => {
|
||||||
|
// There was no delete anywhere in the webmail: no button, and no route
|
||||||
|
// behind one. Anything the user wanted rid of stayed forever.
|
||||||
|
it("offers a delete action on the message list", () => {
|
||||||
|
const page = code(MAIL_PAGE);
|
||||||
|
expect(page).toContain("/api/v1/mail/delete");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refreshes the counters after deleting, so the badges follow", () => {
|
||||||
|
const page = code(MAIL_PAGE);
|
||||||
|
const del = page.slice(page.indexOf('"/api/v1/mail/delete"'));
|
||||||
|
expect(del.slice(0, 900)).toContain("notifyMailStateChanged");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes the deleted row from the list without a full reload", () => {
|
||||||
|
const page = code(MAIL_PAGE);
|
||||||
|
const del = page.slice(page.indexOf("const deleteMessage"));
|
||||||
|
expect(del.slice(0, 1200)).toMatch(/setMessages\(/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The well-known documents senders fetch over HTTPS.
|
||||||
|
*
|
||||||
|
* The protocols service serves /.well-known/mta-sts.txt, but the hostname a
|
||||||
|
* sender asks - mta-sts.<domain> - lands on this Next app, which had no route
|
||||||
|
* for it and answered its own 404 page. The DNS half of MTA-STS was published
|
||||||
|
* and the HTTPS half was not, so every sender saw "no-policy-found" and no
|
||||||
|
* TLS policy was ever enforced. Google's daily TLS-RPT report says exactly
|
||||||
|
* that.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const config = readFileSync(resolve(process.cwd(), "next.config.ts"), "utf8");
|
||||||
|
|
||||||
|
describe("the MTA-STS policy senders fetch", () => {
|
||||||
|
it("is routed to the service that serves it", () => {
|
||||||
|
expect(config).toContain("/.well-known/mta-sts.txt");
|
||||||
|
expect(config).toMatch(/mta-sts\.txt[\s\S]{0,200}PROTOCOLS_SERVICE_URL/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes the other well-known documents the same way", () => {
|
||||||
|
expect(config).toContain("/.well-known/security.txt");
|
||||||
|
});
|
||||||
|
|
||||||
|
// A rewrite, not a redirect: RFC 8461 section 3.3 requires the policy to be
|
||||||
|
// fetched over HTTPS from that exact path, and a sender is not obliged to
|
||||||
|
// follow a redirect to get it.
|
||||||
|
it("rewrites rather than redirects", () => {
|
||||||
|
expect(config).not.toMatch(/redirects\(\)/);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user