2525
2626(def email-pull-pattern
2727 '[:db/id :email/id :email/source :email/subject :email/message-id
28- :email/in-reply-to :email/references
28+ :email/in-reply-to :email/references :email/ancestor-mids
29+ :email/pending-thread?
2930 :email/author-address :email/author-name
3031 :email/from-address :email/from-name
3132 :email/date-sent :email/ingested-at
472473 (log/info " Auto-created single-member series for"
473474 (count patches) " patch attachments" ))))
474475
476+ ; ; ---------------------------------------------------------------------------
477+ ; ; Pending-thread retry (out-of-order delivery rescue)
478+ ; ; ---------------------------------------------------------------------------
479+
480+ (defn- in-reply-to-resolved?
481+ " True when the email's In-Reply-To target is nil (root) or already
482+ stored in the DB. When false, the email is held as pending — its
483+ threading and commands are deferred until the missing ancestor (or
484+ any thread sibling) arrives."
485+ [db email]
486+ (let [irt (:email/in-reply-to email)]
487+ (or (nil? irt)
488+ (some? (d/entid db [:email/message-id irt])))))
489+
490+ (declare process-email! )
491+
492+ (defn- retry-pending-in-shared-thread!
493+ " After processing `email` normally, retry any pending email that
494+ shares at least one ancestor mid with it (or that has `email`'s own
495+ mid in its ancestors). The recursive `process-email!` call retracts
496+ the pending flag if its In-Reply-To is now resolvable."
497+ [conn email source-map sources]
498+ (let [own-mid (:email/message-id email)
499+ ancestors (cond-> (set (:email/ancestor-mids email))
500+ own-mid (conj own-mid))]
501+ (when (seq ancestors)
502+ (let [pendings (d/q '[:find [?e ...] :in $ [?mid ...]
503+ :where
504+ [?e :email/pending-thread? true ]
505+ [?e :email/ancestor-mids ?mid]]
506+ (d/db conn) (vec ancestors))]
507+ (doseq [pending-eid pendings]
508+ (let [pending-email (d/pull (d/db conn) email-pull-pattern pending-eid)]
509+ (log/info " Retrying pending email" (:email/message-id pending-email)
510+ " (triggered by" own-mid " )" )
511+ (process-email! conn source-map sources pending-email)))))))
512+
475513; ; ---------------------------------------------------------------------------
476514; ; Single-email processing — orchestrator
477515; ; ---------------------------------------------------------------------------
478516
479517(defn process-email!
480518 " Process a single email: classify source, detect report, thread,
481- apply commands, manage series. Called after store-email! succeeds."
482- [conn source-map sources email]
483- (let [message-id (:email/message-id email)
484- eid (:db/id email)
485- from-addr (:email/author-address email)
519+ apply commands, manage series. Called after store-email! succeeds.
520+
521+ When the email's In-Reply-To points to a message-id absent from
522+ the DB, Phase 3 (threading + commands) and Phase 4 (post-creation
523+ hooks) are skipped and the email is flagged
524+ `:email/pending-thread? true`. A later arrival sharing the same
525+ thread triggers the retry via `retry-pending-in-shared-thread!`,
526+ or the TTL flush forces processing after N days.
527+
528+ Opts:
529+ :force-thread? — bypass the pending-thread guard, threading the
530+ email with whatever ancestors currently exist in
531+ the DB. Used by the TTL flush."
532+ ([conn source-map sources email] (process-email! conn source-map sources email {}))
533+ ([conn source-map sources email {:keys [force-thread?]}]
534+ (let [message-id (:email/message-id email)
535+ eid (:db/id email)
536+ from-addr (:email/author-address email)
537+ was-pending? (:email/pending-thread? email)
486538 [source-name email delivery] (resolve-email-source! conn email sources)]
487539 (if-not source-name
488540 (log/debug " No matching source for" message-id " — skipping" )
499551 [report-eid report-info]
500552 (maybe-create-report! conn eid message-id email from-addr
501553 source-name source-cfg via-channel? rroles)
554+ db (d/db conn)]
555+
556+ (if (or force-thread? (in-reply-to-resolved? db email))
557+ ; ; --- Normal path: threading, commands, post-creation hooks ---
558+ (do
559+ (when was-pending?
560+ (d/transact! conn [[:db/retract eid :email/pending-thread? true ]])
561+ (log/info " Cleared pending flag on" message-id))
562+ (let [parent-eids (find-reports-for-email email db)
563+ nearest-eids (find-nearest-report email db)
564+ ; ; Recover the existing report-eid on retry so Phase 4
565+ ; ; hooks (link-related, close-superseded-thread, …) can
566+ ; ; run for pending emails that created a report on first
567+ ; ; pass but were skipped past Phase 3/4.
568+ report-eid (or report-eid
569+ (when (and was-pending?
570+ (report-exists? db message-id))
571+ (d/entid db [:report/message-id message-id])))]
572+
573+ (when (and (seq parent-eids) via-channel?)
574+ (thread-and-apply-commands! conn eid email from-addr source-name rroles
575+ source-map delivery parent-eids nearest-eids
576+ (some? report-eid)))
577+
578+ ; ; Phase 4: post-creation hooks (plan is pure, execution is effectful)
579+ (when report-eid
580+ (let [patches (detect/build-patch-entities email)
581+ plan (post-creation-plan report-info nearest-eids parent-eids patches)]
582+ (run-post-creation-hooks! conn report-eid eid email from-addr report-info
583+ parent-eids nearest-eids patches plan)))
584+
585+ ; ; Mark email as fully digested so future re-fetches can skip it.
586+ (d/transact! conn [{:db/id eid :email/digested-at (Date. )}])
587+
588+ ; ; Out-of-order rescue: this email may have unblocked pending
589+ ; ; siblings/descendants. Run AFTER the digested-at write so
590+ ; ; the recursive call sees a consistent state.
591+ (retry-pending-in-shared-thread! conn email source-map sources)))
592+
593+ ; ; --- Pending path: defer threading and commands ---
594+ (do (d/transact! conn [{:db/id eid
595+ :email/pending-thread? true
596+ :email/digested-at (Date. )}])
597+ (log/info " Pending:" message-id " — in-reply-to"
598+ (:email/in-reply-to email) " absent from DB" )))))))))
599+
600+ ; ; ---------------------------------------------------------------------------
601+ ; ; TTL flush — force-process pending emails older than max-age-days
602+ ; ; ---------------------------------------------------------------------------
502603
503- ; ; Phase 3: threading and commands
504- db (d/db conn)
505- parent-eids (find-reports-for-email email db)
506- nearest-eids (find-nearest-report email db)]
507-
508- (when (and (seq parent-eids) via-channel?)
509- (thread-and-apply-commands! conn eid email from-addr source-name rroles
510- source-map delivery parent-eids nearest-eids
511- (some? report-eid)))
512-
513- ; ; Phase 4: post-creation hooks (plan is pure, execution is effectful)
514- (when report-eid
515- (let [patches (detect/build-patch-entities email)
516- plan (post-creation-plan report-info nearest-eids parent-eids patches)]
517- (run-post-creation-hooks! conn report-eid eid email from-addr report-info
518- parent-eids nearest-eids patches plan)))
519-
520- ; ; Mark email as fully digested so future re-fetches can skip it.
521- (d/transact! conn [{:db/id eid :email/digested-at (Date. )}]))))))
604+ (defn flush-stale-pending!
605+ " Force-process pending emails older than `max-age-days`. The pending
606+ flag is retracted and threading runs against whatever ancestors
607+ currently exist in the DB. Returns the count of flushed emails."
608+ [conn source-map sources max-age-days]
609+ (let [cutoff (Date. (- (System/currentTimeMillis )
610+ (* max-age-days 24 60 60 1000 )))
611+ pendings (d/q '[:find [?e ...] :in $ ?cutoff
612+ :where
613+ [?e :email/pending-thread? true ]
614+ [?e :email/ingested-at ?ts]
615+ [(.before ^java.util.Date ?ts ^java.util.Date ?cutoff)]]
616+ (d/db conn) cutoff)]
617+ (when (seq pendings)
618+ (log/info " Flushing" (count pendings)
619+ " stale pending email(s) older than" max-age-days " day(s)" ))
620+ (doseq [eid pendings]
621+ (d/transact! conn [[:db/retract eid :email/pending-thread? true ]])
622+ (let [email (d/pull (d/db conn) email-pull-pattern eid)]
623+ (log/info " TTL-flush" (:email/message-id email))
624+ (process-email! conn source-map sources email {:force-thread? true })))
625+ (count pendings)))
0 commit comments