fix(pop3): batch delete in one session (#1292)

FromSi created

POP3 batch deletion now sends requests like this:

```text
CONNECT
AUTH
UIDL
DELE 1
DELE 2
DELE 3
QUIT
```

`QUIT` is sent once at the end, so all `DELE` commands are committed in
one POP3 session.

Closes #555

Previously, batch deletion sent requests like this:

```text
CONNECT
AUTH
UIDL
DELE 1
QUIT

CONNECT
AUTH
UIDL
DELE 2
QUIT

CONNECT
AUTH
UIDL
DELE 3
QUIT
```

So deleting multiple emails repeated the full connection/authentication
cycle for every message.

Signed-off-by: drew <me@andrinoff.com>

Change summary

backend/pop3/pop3.go | 91 ++++++++++++++++++++++++++-------------------
1 file changed, 53 insertions(+), 38 deletions(-)

Detailed changes

backend/pop3/pop3.go 🔗

@@ -188,25 +188,8 @@ func (p *Provider) MarkAsUnread(_ context.Context, _ string, _ uint32) error {
 	return nil
 }
 
-func (p *Provider) DeleteEmail(_ context.Context, _ string, uid uint32) error {
-	conn, err := p.connect()
-	if err != nil {
-		return err
-	}
-
-	msgID, err := p.findMessageByUID(conn, uid)
-	if err != nil {
-		conn.Quit()
-		return err
-	}
-
-	if err := conn.Dele(msgID); err != nil {
-		conn.Quit()
-		return fmt.Errorf("pop3 dele: %w", err)
-	}
-
-	// Quit commits the deletion
-	return conn.Quit()
+func (p *Provider) DeleteEmail(ctx context.Context, folder string, uid uint32) error {
+	return p.DeleteEmails(ctx, folder, []uint32{uid})
 }
 
 func (p *Provider) ArchiveEmail(_ context.Context, _ string, _ uint32) error {
@@ -217,14 +200,34 @@ func (p *Provider) MoveEmail(_ context.Context, _ uint32, _, _ string) error {
 	return backend.ErrNotSupported
 }
 
-func (p *Provider) DeleteEmails(ctx context.Context, folder string, uids []uint32) error {
-	// POP3 doesn't support batch - loop through individual operations
+func (p *Provider) DeleteEmails(_ context.Context, _ string, uids []uint32) error {
+	if len(uids) == 0 {
+		return nil
+	}
+
+	conn, err := p.connect()
+	if err != nil {
+		return err
+	}
+
+	messageIDsByUID, err := p.buildMessageIDsByUID(conn)
+	if err != nil {
+		conn.Quit()
+		return err
+	}
+
 	for _, uid := range uids {
-		if err := p.DeleteEmail(ctx, folder, uid); err != nil {
-			return err
+		msgID, ok := messageIDsByUID[uid]
+		if !ok {
+			return fmt.Errorf("pop3: message with UID %d not found", uid)
+		}
+
+		if err := conn.Dele(msgID); err != nil {
+			return fmt.Errorf("pop3 dele: %w", err)
 		}
 	}
-	return nil
+
+	return conn.Quit()
 }
 
 func (p *Provider) ArchiveEmails(_ context.Context, _ string, _ []uint32) error {
@@ -261,31 +264,40 @@ func (p *Provider) Close() error {
 	return nil
 }
 
-// Verify interface compliance at compile time.
-var _ backend.Provider = (*Provider)(nil)
-
-// findMessageByUID finds a POP3 message ID by matching the UID hash.
-func (p *Provider) findMessageByUID(conn *pop3client.Conn, uid uint32) (int, error) {
+func (p *Provider) buildMessageIDsByUID(conn *pop3client.Conn) (map[uint32]int, error) {
 	msgs, err := conn.Uidl(0)
 	if err != nil {
 		msgs, err = conn.List(0)
 		if err != nil {
-			return 0, fmt.Errorf("pop3 list: %w", err)
+			return nil, fmt.Errorf("pop3 list: %w", err)
 		}
+
+		messageIDsByUID := make(map[uint32]int, len(msgs))
 		for _, m := range msgs {
-			if hashUID(fmt.Sprintf("%d", m.ID)) == uid {
-				return m.ID, nil
-			}
+			messageIDsByUID[hashUID(fmt.Sprintf("%d", m.ID))] = m.ID
 		}
-		return 0, fmt.Errorf("pop3: message with UID %d not found", uid)
+		return messageIDsByUID, nil
 	}
 
+	messageIDsByUID := make(map[uint32]int, len(msgs))
 	for _, m := range msgs {
-		if hashUID(m.UID) == uid {
-			return m.ID, nil
-		}
+		messageIDsByUID[hashUID(m.UID)] = m.ID
 	}
-	return 0, fmt.Errorf("pop3: message with UID %d not found", uid)
+	return messageIDsByUID, nil
+}
+
+// findMessageByUID finds a POP3 message ID by matching the UID hash.
+func (p *Provider) findMessageByUID(conn *pop3client.Conn, uid uint32) (int, error) {
+	messageIDsByUID, err := p.buildMessageIDsByUID(conn)
+	if err != nil {
+		return 0, err
+	}
+
+	msgID, ok := messageIDsByUID[uid]
+	if !ok {
+		return 0, fmt.Errorf("pop3: message with UID %d not found", uid)
+	}
+	return msgID, nil
 }
 
 // hashUID converts a POP3 UIDL string to a uint32 hash.
@@ -300,6 +312,9 @@ func hashUID(uidl string) uint32 {
 	return hash
 }
 
+// Verify interface compliance at compile time.
+var _ backend.Provider = (*Provider)(nil)
+
 // entityToEmail converts message headers to a backend.Email.
 func entityToEmail(header *message.Header, msgInfo pop3client.MessageID, accountID string) backend.Email {
 	from := header.Get("From")