fix(mail): den samme mail blev en opgave igen og igen
Release / Build and publish image (push) Failing after 9m58s
Release / Build and publish image (push) Failing after 9m58s
Rapporteret fra drift med et skærmbillede af den samme mail seks gange i træk. **IMAP: flaget kommer af, når mailen er blevet en opgave.** Markøren var et uid — husk det højeste, begynd over det næste gang — og den holdt kun, hvis uids var, hvad serveren sagde. Den brød på et fetch-svar uden uid, på en UIDVALIDITY, der skiftede under en genopbygget postkasse, og på to postkasse-rækker, der pegede samme sted og hver især førte deres eget regnskab. Alle tre gav det samme symptom. Så regnskabet flytter derhen, hvor spørgsmålet bliver stillet. "Flaget" betyder "lav det her om til en opgave"; når den er en opgave, har flaget gjort sit arbejde og er ikke sandt længere. Så er der ingenting at huske, og dermed ingenting, der kan komme ud af trit — en anden synkronisering, en anden postkasse-række eller en database gendannet fra en kopi finder alle det samme svar, fordi svaret ligger i postkassen. Flaget tages af *efter* opgaven er skrevet. Et flag fjernet fra en mail, hvis opgave aldrig blev gemt, er en mail, ingen ser igen. **Gmail havde den samme fejl af en anden grund.** Listen over allerede behandlede beskeder blev bygget om ved hver kørsel af *kun de id'er, Gmail svarede med den gang* — for at den ikke skulle vokse uendeligt. Men forespørgslen er begrænset, så på en konto med mere stjernemarkeret post, end der er plads til i ét svar, glemte hver kørsel det, den forrige havde husket. De kom tilbage som ubehandlede og blev opgaver igen. Det var ikke en grænse, det var en måde at glemme på. Listen har nu et loft på antal i stedet, nyeste først. Gmail kan ikke få taget stjernen af: scopet er `gmail.readonly`, med vilje, fordi det er dét, der gør samtykkeskærmen til noget, et menneske kan sige ja til. Derfor skal regnskabet blive på vores side dér — men det skal så være et regnskab, der kan holde. Prøven kører hele vejen mod en rigtig IMAP-server: find de flagede, tag flaget af, og spørg så forfra med markøren sat til nul — altså det værst tænkelige tilfælde, hvor alt, vi selv havde husket, er væk. Der kommer ingenting tilbage. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
78891bae6f
commit
241c5ed37d
@@ -251,6 +251,17 @@ func (s *Server) handleGmailSyncNow(w http.ResponseWriter, r *http.Request) {
|
||||
//
|
||||
// Returns how many were made. Also used by the background job, which is why it
|
||||
// takes a context and a user rather than a request.
|
||||
// maxGmailSeen bounds the list of message ids already turned into tasks.
|
||||
//
|
||||
// Gmail is read-only here — the scope is `gmail.readonly`, deliberately, because
|
||||
// asking for less is what makes the consent screen something a person can agree
|
||||
// to — so verdande cannot unstar a message the way the IMAP side unflags one. The
|
||||
// record has to live on this side, and a record on this side has to be bounded.
|
||||
//
|
||||
// A count rather than the previous rule of "whatever Gmail returned this run",
|
||||
// which was not a bound but a way of forgetting.
|
||||
const maxGmailSeen = 2000
|
||||
|
||||
func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
|
||||
box, err := s.db.MailboxOfKind(ctx, user.ID, "gmail")
|
||||
if err != nil {
|
||||
@@ -362,14 +373,35 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
|
||||
}
|
||||
|
||||
if created > 0 {
|
||||
// Only the ids Gmail still returns are kept, so the list cannot grow
|
||||
// without bound as messages fall out of the 30-day window.
|
||||
keep := make([]string, 0, len(ids))
|
||||
// The ids this run saw, on top of the ones already known.
|
||||
//
|
||||
// It used to keep only the ids Gmail returned *this run*, to stop the list
|
||||
// growing without bound. That threw away every id that was not on the page
|
||||
// it happened to fetch — and the query is capped, so on an account with more
|
||||
// starred mail than one page, each run forgot the messages the last one had
|
||||
// remembered. They came back unseen, and became tasks again, and again. That
|
||||
// is the duplicate.
|
||||
//
|
||||
// Bounded by a count instead. Newest first, and a ceiling far above any
|
||||
// plausible page, so an id leaves the list because it is old rather than
|
||||
// because it was not in the last answer.
|
||||
keep := make([]string, 0, len(ids)+len(box.Seen))
|
||||
added := map[string]bool{}
|
||||
for _, id := range ids {
|
||||
if seen[id] {
|
||||
if seen[id] && !added[id] {
|
||||
keep = append(keep, id)
|
||||
added[id] = true
|
||||
}
|
||||
}
|
||||
for _, id := range box.Seen {
|
||||
if !added[id] {
|
||||
keep = append(keep, id)
|
||||
added[id] = true
|
||||
}
|
||||
}
|
||||
if len(keep) > maxGmailSeen {
|
||||
keep = keep[:maxGmailSeen]
|
||||
}
|
||||
box.Seen = keep
|
||||
box.LastSyncAt = time.Now()
|
||||
if err := s.db.SaveMailbox(ctx, box); err != nil {
|
||||
|
||||
@@ -175,6 +175,10 @@ func (s *Server) SyncMailbox(ctx context.Context, user *store.User, m *store.Mai
|
||||
|
||||
created := 0
|
||||
highest := m.LastUID
|
||||
// The uids of the mails that actually became tasks. Only those get unflagged,
|
||||
// and only after the task is committed: a flag removed from a mail whose task
|
||||
// was never written is a mail nobody will ever see again.
|
||||
done := make([]uint32, 0, len(messages))
|
||||
for _, msg := range messages {
|
||||
if ctx.Err() != nil {
|
||||
// What was made so far is already committed and counted, and the marker
|
||||
@@ -204,12 +208,27 @@ func (s *Server) SyncMailbox(ctx context.Context, user *store.User, m *store.Mai
|
||||
continue
|
||||
}
|
||||
created++
|
||||
done = append(done, msg.UID)
|
||||
if msg.UID > highest {
|
||||
highest = msg.UID
|
||||
}
|
||||
s.hub.Publish(projectID, "task.created", toTaskJSON(*task))
|
||||
}
|
||||
|
||||
// The flag comes off what became a task. This is what actually stops a mail
|
||||
// arriving twice; the marker below is now a second line rather than the only
|
||||
// one. See imap.Unflag for why the record belongs in the mailbox and not here.
|
||||
//
|
||||
// Outside the request's context, like the marker: a cancelled request that
|
||||
// already created tasks must still take the flags off them, or the next run
|
||||
// makes them all again.
|
||||
if err := client.Unflag(done); err != nil {
|
||||
// Not fatal, and not the caller's problem. A folder somebody opened
|
||||
// read-only, or a server that refuses the STORE, still gets the uid marker
|
||||
// underneath — worse, but not broken.
|
||||
s.log.Warn("could not unflag imported mail", "err", err, "mailbox", m.ID, "count", len(done))
|
||||
}
|
||||
|
||||
// A run that got all the way through moves the marker to the whole of what the
|
||||
// search considered, not merely to the last message it happened to read. That
|
||||
// is what stops the same mail becoming a task again on the next run when the
|
||||
|
||||
Reference in New Issue
Block a user