has_attachments so era definido para mensagens multipart. Um relatorio agregado DMARC do Google e de parte unica: o corpo E o anexo, um application/zip sem qualquer parte de texto. Ficava guardado como nao tendo anexos, ao lado dos relatorios TLS-RPT do mesmo remetente e na mesma pasta, que trazem uma linha de texto e por isso sao multipart/mixed e apareciam marcados. A regra passa a olhar tambem para a parte de topo: uma disposicao explicita decide (um .txt enviado como a mensagem inteira e anexo, uma parte inline nao e), e sem ela conta como anexo tudo o que nao seja texto nem multipart -- um zip, um PDF, uma mensagem reencaminhada. As mensagens ja guardadas mantinham a marca antiga, e sao justamente aquelas que o utilizador esta a olhar. Ha um backfill que as re-le uma vez, em segundo plano, sem atrasar o arranque. So examina as que estao marcadas como sem anexos: a regra antiga nunca inventou um anexo que nao existisse, portanto uma marca verdadeira nao precisa de segunda opiniao e a varredura fica do tamanho do defeito. A tabela backfills guarda o marcador para nao repetir a cada arranque. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
88 lines
2.6 KiB
Go
88 lines
2.6 KiB
Go
package postgres
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
// AttachmentBackfill repairs has_attachments on messages already stored.
|
|
//
|
|
// The flag was only ever set for multipart messages, so a single-part
|
|
// attachment - a DMARC aggregate report, whose entire body is a zip - was
|
|
// filed as having none, and showed up without a paperclip beside TLS-RPT
|
|
// reports from the same sender that happened to carry a text part.
|
|
type AttachmentBackfill struct {
|
|
pool *pgxpool.Pool
|
|
}
|
|
|
|
func NewAttachmentBackfill(pool *pgxpool.Pool) *AttachmentBackfill {
|
|
return &AttachmentBackfill{pool: pool}
|
|
}
|
|
|
|
// Done reports whether this repair has already run.
|
|
func (b *AttachmentBackfill) Done(ctx context.Context, name string) (bool, error) {
|
|
var exists bool
|
|
err := b.pool.QueryRow(ctx,
|
|
`SELECT EXISTS (SELECT 1 FROM backfills WHERE name = $1)`, name).Scan(&exists)
|
|
if err != nil {
|
|
return false, fmt.Errorf("read backfill marker %s: %w", name, err)
|
|
}
|
|
return exists, nil
|
|
}
|
|
|
|
func (b *AttachmentBackfill) MarkDone(ctx context.Context, name, detail string) error {
|
|
_, err := b.pool.Exec(ctx,
|
|
`INSERT INTO backfills (name, detail) VALUES ($1, $2) ON CONFLICT (name) DO NOTHING`,
|
|
name, detail)
|
|
if err != nil {
|
|
return fmt.Errorf("record backfill marker %s: %w", name, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Candidate is one message worth re-reading.
|
|
type Candidate struct {
|
|
ID uuid.UUID
|
|
BlobID uuid.UUID
|
|
}
|
|
|
|
// Candidates lists messages currently flagged as having no attachment.
|
|
//
|
|
// Only those: the old rule never claimed an attachment that was not there, so
|
|
// a true flag needs no second opinion and the scan stays as small as the bug.
|
|
func (b *AttachmentBackfill) Candidates(ctx context.Context, limit int) ([]Candidate, error) {
|
|
rows, err := b.pool.Query(ctx, `
|
|
SELECT id, blob_id FROM email_messages
|
|
WHERE has_attachments = false AND expunged_at IS NULL
|
|
ORDER BY received_at DESC
|
|
LIMIT $1
|
|
`, limit)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list attachment backfill candidates: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := []Candidate{}
|
|
for rows.Next() {
|
|
var c Candidate
|
|
if err := rows.Scan(&c.ID, &c.BlobID); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, c)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// SetHasAttachments records the recomputed flag.
|
|
func (b *AttachmentBackfill) SetHasAttachments(ctx context.Context, messageID uuid.UUID) error {
|
|
_, err := b.pool.Exec(ctx,
|
|
`UPDATE email_messages SET has_attachments = true WHERE id = $1`, messageID)
|
|
if err != nil {
|
|
return fmt.Errorf("set has_attachments for %s: %w", messageID, err)
|
|
}
|
|
return nil
|
|
}
|