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
+1053
-30
No files matched your search
@@ -6,7 +6,9 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"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)
|
||||
}
|
||||
|
||||
// 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
|
||||
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
|
||||
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)
|
||||
}
|
||||
|
||||
// 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 (
|
||||
mailbox_id, folder_id, uid, modseq, blob_id,
|
||||
sender_address, recipient_addresses, size_bytes,
|
||||
subject, snippet, from_display_name, message_id_header, has_attachments,
|
||||
spf_result, dkim_result, dmarc_result
|
||||
) 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.SenderAddress, []string{input.RecipientAddress}, input.Blob.SizeBytes,
|
||||
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 -
|
||||
// and it failed the whole insert. Sent stayed empty because that write
|
||||
// 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 {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// 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, `
|
||||
SELECT id::text, name, COALESCE(special_use, ''), unread_count, total_count
|
||||
FROM folders WHERE mailbox_id = $1
|
||||
ORDER BY CASE special_use
|
||||
SELECT f.id::text, f.name, COALESCE(f.special_use, ''),
|
||||
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,
|
||||
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 'archive' THEN 3 WHEN 'junk' THEN 4 WHEN 'trash' THEN 5
|
||||
ELSE 6 END, name
|
||||
ELSE 6 END, f.name
|
||||
`, mailboxID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list folders: %w", err)
|
||||
@@ -224,7 +246,9 @@ func (r *WebmailRepository) Expunge(
|
||||
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.flag = '\Seen')
|
||||
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+`
|
||||
@@ -260,3 +284,110 @@ func (r *WebmailRepository) Expunge(
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user