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