feat(insights): the radar's skeleton — entity, extractor seam, harvester, pipeline step

MailInsight rows are facts read out of mail (a parcel, a flight, a pull
request), upserted by an extractor-scoped dedupe key so a carrier's five
status mails are one card, and dismissal outlives every later upsert. The
extractor seam is a tagged interface collected by a registry that also
drives the settings page, so a new kind of insight is one class — no
registry edit, no settings edit, no pipeline edit. Harvesting rides the
existing post-ingest pipeline; `app:backfill insights` walks stored mail
for existing mailboxes. No extractors yet: this commit is the seam.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Paul LütznerandClaude Fable 5 committed 2026-08-17 18:57:16 +02:00
1 parent 82d1eb749a
commit bb036306db
11 files changed
+733

No files matched your search

+53
View File
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* The radar's table: mail_insight, one row per fact read out of somebody's
* mail — a parcel, a flight, a ticket, a pull request.
*
* Additive; nothing existing is touched and no backfill runs here. Existing
* mailboxes get their rows from `app:backfill insights`, which the harvester
* makes idempotent by the unique (account_id, dedupe_key) below — that
* constraint is also what turns a carrier's five status mails into one row
* under concurrent ingestion.
*
* thread_id and message_id are SET NULL on purpose: the insight can outlive
* the mail that announced it (a deleted shipping confirmation does not
* un-ship the parcel); it merely loses its link. account_id CASCADEs — no
* account, no radar.
*/
final class Version20260817165438 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create mail_insight — the extracted parcels, flights, tickets and code review behind the radar';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE mail_insight (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, kind VARCHAR(32) NOT NULL, title VARCHAR(255) NOT NULL, payload JSON NOT NULL, happens_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, extractor VARCHAR(32) NOT NULL, dedupe_key VARCHAR(160) NOT NULL, dismissed_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, account_id INT NOT NULL, thread_id INT DEFAULT NULL, message_id INT DEFAULT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE INDEX IDX_248992B49B6B5FBA ON mail_insight (account_id)');
$this->addSql('CREATE INDEX IDX_248992B4E2904019 ON mail_insight (thread_id)');
$this->addSql('CREATE INDEX IDX_248992B4537A1329 ON mail_insight (message_id)');
$this->addSql('CREATE INDEX idx_mail_insight_happens_at ON mail_insight (happens_at)');
$this->addSql('CREATE INDEX idx_mail_insight_created_at ON mail_insight (created_at)');
$this->addSql('CREATE UNIQUE INDEX uniq_mail_insight_account_dedupe ON mail_insight (account_id, dedupe_key)');
$this->addSql('ALTER TABLE mail_insight ADD CONSTRAINT FK_248992B49B6B5FBA FOREIGN KEY (account_id) REFERENCES account (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('ALTER TABLE mail_insight ADD CONSTRAINT FK_248992B4E2904019 FOREIGN KEY (thread_id) REFERENCES message_thread (id) ON DELETE SET NULL NOT DEFERRABLE');
$this->addSql('ALTER TABLE mail_insight ADD CONSTRAINT FK_248992B4537A1329 FOREIGN KEY (message_id) REFERENCES message (id) ON DELETE SET NULL NOT DEFERRABLE');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE mail_insight DROP CONSTRAINT FK_248992B49B6B5FBA');
$this->addSql('ALTER TABLE mail_insight DROP CONSTRAINT FK_248992B4E2904019');
$this->addSql('ALTER TABLE mail_insight DROP CONSTRAINT FK_248992B4537A1329');
$this->addSql('DROP TABLE mail_insight');
}
}
@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace App\Command\Backfill;
use App\Entity\Mail\Message;
use App\Repository\Mail\MessageRepository;
use App\Service\Insight\InsightHarvester;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Re-read stored mail through the insight extractors — how existing mailboxes
* get their parcels, flights and pull requests without a resync, and how a
* better extractor next release upgrades everyone's cards.
*
* Idempotent by the harvester's upsert: the same mail lands on the same
* dedupe key and refreshes the same row, and a dismissed insight stays
* dismissed (see InsightHarvester). Extractors read persisted data only, so
* no provider is asked anything.
*
* Every category is walked, unlike the proposal task's Primary-only rule,
* because the interesting senders live exactly where proposals do not look:
* carriers file under Updates, GitHub under Forums or Updates, ticket shops
* under Promotions. supports() is the cheap gate that keeps the walk from
* parsing every newsletter body.
*
* Offset paging with the same licence EventProposalBackfillTask claims: this
* writes into mail_insight and never touches `message`, so the set it pages
* over cannot shift under it.
*/
final readonly class InsightsBackfillTask implements BackfillTaskInterface
{
private const int BATCH_SIZE = 200;
public function __construct(
private MessageRepository $messages,
private InsightHarvester $harvester,
private EntityManagerInterface $em,
) {
}
public function getName(): string
{
return 'insights';
}
public function getDescription(): string
{
return 'Re-read stored mail for parcels, flights, tickets and code review — the cards on the radar.';
}
public function run(SymfonyStyle $io): int
{
$total = $this->messages->count([]);
if (0 === $total) {
$io->success('No mail to read.');
return Command::SUCCESS;
}
$io->progressStart($total);
$offset = 0;
$written = 0;
while (true) {
/** @var list<Message> $batch */
$batch = $this->messages->findBy([], ['id' => 'ASC'], self::BATCH_SIZE, $offset);
if (0 === count($batch)) {
break;
}
foreach ($batch as $message) {
$offset++;
// The harvester shields per extractor; a failed flush is the
// one fault left, and the manager-closed guard below is what
// answers it.
$count = $this->harvester->harvest($message);
if ($count > 0) {
$written += $count;
$this->em->flush();
}
if (false === $this->em->isOpen()) {
$io->progressFinish();
$io->error(sprintf(
'Stopped at message %d: the entity manager closed, so nothing further could be saved.',
(int) $message->id,
));
return Command::FAILURE;
}
$io->progressAdvance();
}
$this->em->clear();
}
$io->progressFinish();
$io->success(sprintf('%d insight(s) written or refreshed.', $written));
return Command::SUCCESS;
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Domain\Enum\Insight;
/**
* What an extracted mail insight is about.
*
* The sibling of Calendar\ExtractionKind, and deliberately a separate enum
* rather than more cases on it: that one classifies CALENDAR EVENTS — things
* with an occurrence on a timeline — and doubles as the Happening Soon
* filter. An insight is the other family: a fact read out of a mail that is
* worth surfacing whether or not it carries a date. A parcel has an ETA some
* days wide, a pull request has none at all; forcing either into an
* occurrence row would put fake times on real things.
*/
enum InsightKind: string
{
case Parcel = 'parcel';
case Flight = 'flight';
case Ticket = 'ticket';
case GithubIssue = 'github-issue';
case GithubPullRequest = 'github-pr';
/** Font Awesome icon, so a card or a chip can render without a match arm. */
public function icon(): string
{
return match ($this) {
self::Parcel => 'fa-solid fa-box',
self::Flight => 'fa-solid fa-plane',
self::Ticket => 'fa-solid fa-ticket',
self::GithubIssue => 'fa-solid fa-circle-dot',
self::GithubPullRequest => 'fa-solid fa-code-pull-request',
};
}
/** Translation key for the user-facing name. */
public function transKey(): string
{
return 'insights.kind.' . $this->value;
}
}
+111
View File
@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace App\Entity\Insight;
use App\Domain\Enum\Insight\InsightKind;
use App\Domain\Trait\TimestampableTrait;
use App\Entity\Mail\Account;
use App\Entity\Mail\Message;
use App\Entity\Mail\MessageThread;
use App\Repository\Insight\MailInsightRepository;
use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
/**
* A fact read out of a mail that is worth showing before the mail is: a parcel
* on its way, a flight with a time, a ticket, a pull request asking for eyes.
*
* The sibling of EventProposal with the opposite lifecycle. A proposal is a
* GUESS held until a person answers it, and answering destroys the row; an
* insight is a STATEMENT the extractor stands behind — nobody accepts a parcel
* — so the row lives, updates itself as follow-up mails arrive, and retires by
* time or by the user waving it away. That is why dismissal is a column here
* rather than an EventSuppression: suppression exists to stop a guess being
* re-offered, while a dismissed insight must still absorb its own follow-ups
* (the DHL "it's here" mail upserts the same dedupe key) without coming back.
*
* $dedupeKey is what makes re-extraction an update instead of a duplicate.
* The extractor derives it from the thing, not the mail — tracking number,
* flight number + date, repo + issue number — so five mails about one parcel
* are one card. Unique per ACCOUNT rather than per user: two accounts may
* genuinely both receive tracking for the same parcel, and merging them would
* make one account's dismissal eat the other's card.
*
* $payload is per-kind and deliberately schemaless at this layer; the
* extractor that wrote it is named in $extractor, and the card template for
* the kind is the reader. Versioned re-extraction (a better parser next
* release) is the backfill's job, which is why nothing here forbids
* overwriting a payload with a richer one.
*/
#[ORM\Entity(repositoryClass: MailInsightRepository::class)]
#[ORM\HasLifecycleCallbacks]
#[ORM\Table(name: 'mail_insight')]
#[ORM\UniqueConstraint(name: 'uniq_mail_insight_account_dedupe', columns: ['account_id', 'dedupe_key'])]
// The one sweep this table serves: "what is coming up / recent for this
// user's accounts". happens_at is nullable and the undated rows are reached
// by created_at, so both carry an index; kind rides along free in reads.
#[ORM\Index(name: 'idx_mail_insight_happens_at', columns: ['happens_at'])]
#[ORM\Index(name: 'idx_mail_insight_created_at', columns: ['created_at'])]
class MailInsight
{
use TimestampableTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
public private(set) ?int $id = null;
#[ORM\ManyToOne(targetEntity: Account::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
public Account $account;
/**
* Where the card links to. SET NULL rather than CASCADE: the insight can
* outlive the mail that announced it — a deleted shipping confirmation
* does not un-ship the parcel — it merely loses its "open the mail" link.
*/
#[ORM\ManyToOne(targetEntity: MessageThread::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
public ?MessageThread $thread = null;
#[ORM\ManyToOne(targetEntity: Message::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
public ?Message $message = null;
#[ORM\Column(type: 'string', length: 32, enumType: InsightKind::class)]
public InsightKind $kind;
/** The card's one line: "DHL parcel from Zalando", "LH 1234 to JFK". */
#[ORM\Column(type: 'string', length: 255)]
public string $title = '';
/** @var array<string, mixed> per-kind detail the card template reads */
#[ORM\Column(type: Types::JSON)]
public array $payload = [];
/**
* When the thing happens, if it has a when at all. A flight departs, a
* ticket admits, a parcel estimates; a pull request just is — null.
*/
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
public ?DateTimeImmutable $happensAt = null;
/** Which extractor wrote this — see InsightExtractorInterface::key(). */
#[ORM\Column(type: 'string', length: 32)]
public string $extractor = '';
/** Identity of the THING, not the mail — see the class doc. */
#[ORM\Column(type: 'string', length: 160)]
public string $dedupeKey = '';
/**
* Waved away by the user. The row stays so its dedupe key keeps absorbing
* follow-up mails silently — deleting it would resurrect the card on the
* carrier's next status update.
*/
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
public ?DateTimeImmutable $dismissedAt = null;
}
+11
View File
@@ -473,6 +473,17 @@ class User extends UserEntityModel implements UserInterface, PasswordAuthenticat
*/
public const string SETTING_APPEARANCE_PREVIEW_WIDTH = 'appearance.preview_width';
/**
* Insight extractors this user has switched OFF, as a list of extractor
* keys. Disabled rather than enabled, so an extractor shipped next
* release starts working without every user finding a new toggle — the
* same rule SETTING_ADMIN_COLLAPSED_PANELS records. The catalogue of
* legal keys is the extractor registry, not this class; an unknown key in
* the list is inert rather than an error, which is what lets an extractor
* be removed from a build without a data migration.
*/
public const string SETTING_INSIGHTS_DISABLED = 'insights.disabled_extractors';
/**
* The default is 19rem — what the column was fixed at before it could move.
*
@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace App\Repository\Insight;
use App\Entity\Insight\MailInsight;
use App\Entity\Mail\Account;
use App\Entity\Mail\MessageThread;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\Security\Core\User\UserInterface;
class MailInsightRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, MailInsight::class);
}
/** The upsert's read half — see InsightHarvester. */
public function findOneByDedupe(Account $account, string $dedupeKey): ?MailInsight
{
return $this->findOneBy(['account' => $account, 'dedupeKey' => $dedupeKey]);
}
/**
* Dated insights inside the window: a little of the recent past — a
* parcel that landed this morning is still the answer to "where is my
* parcel?" — and the near future. Undismissed, active accounts only.
*
* @return list<MailInsight> soonest first
*/
public function upcomingForUser(
UserInterface $user,
\DateTimeImmutable $now,
int $daysAhead = 14,
int $hoursBack = 18,
int $limit = 30,
): array {
return $this->createQueryBuilder('i')
->join('i.account', 'a')
->where('a.usr = :user')
->andWhere('a.isActive = true')
->andWhere('i.dismissedAt IS NULL')
->andWhere('i.happensAt IS NOT NULL')
->andWhere('i.happensAt >= :from')
->andWhere('i.happensAt <= :until')
->orderBy('i.happensAt', 'ASC')
->setMaxResults($limit)
->setParameter('user', $user)
->setParameter('from', $now->modify(sprintf('-%d hours', $hoursBack)))
->setParameter('until', $now->modify(sprintf('+%d days', $daysAhead)))
->getQuery()
->getResult();
}
/**
* The undated family — GitHub activity and whatever joins it. Recency is
* the only timeline these have, so the window is on createdAt and the
* newest speak first.
*
* @return list<MailInsight>
*/
public function recentUndatedForUser(
UserInterface $user,
\DateTimeImmutable $now,
int $daysBack = 7,
int $limit = 20,
): array {
return $this->createQueryBuilder('i')
->join('i.account', 'a')
->where('a.usr = :user')
->andWhere('a.isActive = true')
->andWhere('i.dismissedAt IS NULL')
->andWhere('i.happensAt IS NULL')
->andWhere('i.createdAt >= :from')
->orderBy('i.createdAt', 'DESC')
->setMaxResults($limit)
->setParameter('user', $user)
->setParameter('from', $now->modify(sprintf('-%d days', $daysBack)))
->getQuery()
->getResult();
}
/**
* Every insight a conversation carries, for the card above the message.
* Dismissed rows included on purpose: dismissal hides a card from the
* upcoming panel, not from the mail that plainly states the fact.
*
* @return list<MailInsight>
*/
public function forThread(MessageThread $thread): array
{
return $this->findBy(['thread' => $thread], ['happensAt' => 'ASC', 'id' => 'ASC']);
}
}
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Service\Insight;
use App\Domain\DTO\Mail\PostIngestResult;
use App\Domain\Interface\PostIngestStepInterface;
use Doctrine\ORM\EntityManagerInterface;
/**
* The insight pipeline's foothold in the sync: every freshly ingested message
* is offered to the extractors, exactly the way ProposeEventsStep offers them
* to the proposal detectors.
*
* The harvester already shields the batch per extractor and per message, so
* the only job left here is the flush — once, after the batch, and only when
* something was actually written. The pipeline's own catch shields the sync
* from this step entirely; see PostIngestStepInterface.
*/
final readonly class ExtractInsightsStep implements PostIngestStepInterface
{
public function __construct(
private InsightHarvester $harvester,
private EntityManagerInterface $em,
) {
}
public function afterCommit(PostIngestResult $result): void
{
$written = 0;
foreach ($result->messages as $message) {
$written += $this->harvester->harvest($message);
}
if (0 === $written) {
return;
}
$this->em->flush();
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Service\Insight;
use App\Domain\Enum\Insight\InsightKind;
/**
* What an extractor hands back: the fact, before it is a row.
*
* A DTO rather than a MailInsight so extractors can be tested as pure
* functions of a Message — no entity manager, no account wiring, no
* lifecycle. The harvester owns turning drafts into rows and is the only
* place upsert semantics live.
*/
final readonly class InsightDraft
{
/**
* @param string $dedupeKey identity of the THING (tracking number,
* flight+date, repo#number) — never of the mail.
* The harvester scopes it by extractor key, so
* extractors need not name themselves in it.
* @param array<string, mixed> $payload per-kind card detail; keep it JSON-safe scalars
*/
public function __construct(
public InsightKind $kind,
public string $title,
public string $dedupeKey,
public array $payload = [],
public ?\DateTimeImmutable $happensAt = null,
) {
}
}
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Service\Insight;
use App\Entity\Mail\Message;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
/**
* Something that can read a fact out of a mail — a parcel, a flight, a pull
* request — deterministically.
*
* The extension seam, and everything hangs off the tag: implementations are
* collected by InsightExtractorRegistry, run by InsightHarvester after every
* ingest, and listed BY THE REGISTRY on the settings page, where each can be
* switched off per user. Adding a kind of insight is one class implementing
* this — no registry edit, no settings edit, no pipeline edit. That is the
* "extendable" the feature promises, so keep it true.
*
* Deterministic on purpose, like DeterministicDateDetector next door: sender
* domains, headers, and regular shapes (tracking numbers, flight codes,
* "#123"). An extractor that guesses puts invented parcels on someone's
* radar; return nothing rather than probably-something. A model-backed
* extractor would be one more implementation with a lower priority — the same
* seam ProposalDetectorInterface documents — and nothing else would change.
*
* supports() is the cheap gate (headers, sender), extract() the real work;
* the harvester never calls extract() without supports() saying yes. Neither
* may throw for ordinary mail: unparseable is `[]`, not an exception — the
* pipeline shields the sync either way, but a throwing extractor costs every
* OTHER extractor its facts for that message.
*/
#[AutoconfigureTag('app.insight_extractor')]
interface InsightExtractorInterface
{
/**
* Stable registry id: stored on every row it writes, the settings toggle
* key, and the dedupe scope. Short kebab-case ("parcel", "github").
* Renaming it orphans rows and forgets who switched it off.
*/
public static function key(): string;
/**
* Presentation for the settings page, resolved as translation keys
* `settings.insights.extractor.<key>` / `...<key>_hint` — declared here
* only through key(); this method names the Font Awesome icon the
* settings row and the panel's section heading wear.
*/
public function icon(): string;
/** Higher runs first; matters only when two claim the same mail. */
public function priority(): int;
/** The cheap gate: headers and addresses, no body parsing. */
public function supports(Message $message): bool;
/**
* @return list<InsightDraft> empty is the normal outcome and never an
* error: most mail states no fact worth a card
*/
public function extract(Message $message): array;
}
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Service\Insight;
use App\Entity\User\User;
use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;
/**
* Every extractor the build knows, and which of them a given user has left
* on.
*
* The settings page renders from all() and the harvester runs enabledFor(),
* which is what makes a new extractor appear in settings the moment its class
* exists — the page lists the registry, not a hand-kept catalogue.
*
* Everything is ON until switched off, and the preference stores the
* DISABLED keys (User::SETTING_INSIGHTS_DISABLED): an extractor added next
* release starts working for everyone instead of waiting for each user to
* discover a new toggle — the same rule the sidebar's collapse state follows,
* for the same reason.
*/
final readonly class InsightExtractorRegistry
{
/** @param iterable<InsightExtractorInterface> $extractors */
public function __construct(
#[AutowireIterator('app.insight_extractor')]
private iterable $extractors,
) {
}
/** @return list<InsightExtractorInterface> highest priority first */
public function all(): array
{
$all = [...$this->extractors];
usort($all, static fn (InsightExtractorInterface $a, InsightExtractorInterface $b): int => $b->priority() <=> $a->priority());
return $all;
}
/** @return list<InsightExtractorInterface> */
public function enabledFor(User $user): array
{
$disabled = $this->disabledKeys($user);
return array_values(array_filter(
$this->all(),
static fn (InsightExtractorInterface $extractor): bool => false === in_array($extractor::key(), $disabled, true),
));
}
public function isEnabledFor(User $user, string $key): bool
{
return false === in_array($key, $this->disabledKeys($user), true);
}
/** @return list<string> */
private function disabledKeys(User $user): array
{
$stored = $user->getSetting(User::SETTING_INSIGHTS_DISABLED);
return is_array($stored) ? array_values(array_filter($stored, is_string(...))) : [];
}
}
+99
View File
@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace App\Service\Insight;
use App\Entity\Insight\MailInsight;
use App\Entity\Mail\Message;
use App\Entity\User\User;
use App\Repository\Insight\MailInsightRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
/**
* Runs the enabled extractors over a message and lands their drafts as rows —
* the one place upsert semantics live.
*
* Upsert by (account, extractor-scoped dedupe key), because the interesting
* mails come in series: shipped, out for delivery, delivered are three mails
* and one parcel. A later draft OVERWRITES title, payload and happensAt —
* carriers revise their estimates and the newest statement is the card worth
* showing — but never clears dismissedAt: the user's "stop showing me this"
* outranks the carrier's enthusiasm (see MailInsight's class doc).
*
* The dedupe key is prefixed with the extractor's key here rather than by
* each extractor, so two extractors reading the same number cannot collide
* and no extractor can forget the scoping.
*/
final readonly class InsightHarvester
{
public function __construct(
private InsightExtractorRegistry $registry,
private MailInsightRepository $insights,
private EntityManagerInterface $em,
private LoggerInterface $logger,
) {
}
/** @return int how many insights were written or refreshed */
public function harvest(Message $message): int
{
$account = $message->account;
$user = $account->usr;
if (!$user instanceof User) {
return 0;
}
$written = 0;
foreach ($this->registry->enabledFor($user) as $extractor) {
// Per extractor, so one parser choking on one strange mail costs
// its own facts and nobody else's.
try {
if (false === $extractor->supports($message)) {
continue;
}
foreach ($extractor->extract($message) as $draft) {
$this->land($message, $extractor::key(), $draft);
$written++;
}
} catch (\Throwable $e) {
$this->logger->warning('Insight extractor failed on a message', [
'extractor' => $extractor::key(),
'message' => $message->id,
'error' => $e->getMessage(),
]);
}
}
return $written;
}
private function land(Message $message, string $extractorKey, InsightDraft $draft): void
{
$dedupeKey = mb_substr($extractorKey . ':' . $draft->dedupeKey, 0, 160);
$insight = $this->insights->findOneByDedupe($message->account, $dedupeKey);
if (null === $insight) {
$insight = new MailInsight();
$insight->account = $message->account;
$insight->extractor = $extractorKey;
$insight->dedupeKey = $dedupeKey;
$this->em->persist($insight);
}
$insight->kind = $draft->kind;
$insight->title = mb_substr($draft->title, 0, 255);
$insight->payload = $draft->payload;
$insight->happensAt = $draft->happensAt;
// The newest mail in the series owns the link: "open the mail" should
// land on "delivered", not on "shipped".
$insight->message = $message;
$insight->thread = $message->thread;
}
}