Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
d948528
feat(email): preserve dedupe provenance from genuine Date headers
seonghobae Aug 4, 2026
8682434
chore(pr1195): materialize reviewed provenance fixes
seonghobae Aug 4, 2026
e249788
ci: fix source-bound email deduplication
seonghobae Aug 4, 2026
a2124fb
chore(pr1195): remove superseded review-fix workflow
seonghobae Aug 4, 2026
6580d40
chore(pr1195): repair source-dedupe materializer
seonghobae Aug 4, 2026
138157b
fix(ci): trigger PR 1195 materializer repair on review transition
seonghobae Aug 4, 2026
f9efcf7
fix(ci): make PR 1195 materializer repair structural
seonghobae Aug 4, 2026
1c4c781
fix(ci): preserve escaped POP3 assertion in PR 1195 repair
seonghobae Aug 4, 2026
112c2d5
ci: make source-bound dedupe repair deterministic
seonghobae Aug 4, 2026
7a76bb4
ci: replace brittle PR 1195 materializer
seonghobae Aug 4, 2026
cd68cbe
ci: remove superseded PR 1195 repair helper
seonghobae Aug 4, 2026
08a7cb1
ci: finalize PR 1195 source-bound dedupe
seonghobae Aug 4, 2026
3a35907
ci: harden PR 1195 source-identity finalizer
seonghobae Aug 4, 2026
97bf6d4
ci: repair PR 1195 source-identity finalizer
seonghobae Aug 4, 2026
3b7fa3c
ci: remove superseded PR 1195 repair helper
seonghobae Aug 4, 2026
1f72d4d
ci: repair PR 1195 finalizer date cleanup
seonghobae Aug 4, 2026
0cc519f
fix(ci): execute PR 1195 date cleanup helper
seonghobae Aug 4, 2026
8557cad
ci(pr-1195): bootstrap finalizer guard repair
seonghobae Aug 4, 2026
d05a500
ci(pr-1195): activate finalizer bootstrap
seonghobae Aug 4, 2026
0508cc7
fix(ci): trigger PR 1195 cleanup on synchronization
seonghobae Aug 4, 2026
1ab3939
fix(ci): consolidate PR 1195 finalizer repair
seonghobae Aug 4, 2026
4d584bf
fix(ci): make PR 1195 helper workflow valid
seonghobae Aug 4, 2026
e4b0c59
ci: repair empty replacement handling in PR 1195 finalizer
seonghobae Aug 4, 2026
121d5ac
chore(pr-1195): remove temporary finalizer bootstrap
seonghobae Aug 4, 2026
fa48028
chore(pr-1195): remove temporary finalizer hotfix
seonghobae Aug 4, 2026
ca63640
chore(pr-1195): remove temporary date repair workflow
seonghobae Aug 4, 2026
7fd7d48
chore(pr-1195): remove temporary source finalizer
seonghobae Aug 4, 2026
c51131c
test(email): prove source-bound fallback identity
seonghobae Aug 5, 2026
6e69b8e
chore(email): add one-shot source identity repair script
seonghobae Aug 5, 2026
96fd422
test(email): add source-bound fallback regressions
seonghobae Aug 5, 2026
64d5a84
fix(ci): make source-identity repair workflow valid
seonghobae Aug 5, 2026
898201e
docs(email): record source-bound identity contract
seonghobae Aug 5, 2026
2412770
ci(email): repair POP3 source-byte reconstruction
seonghobae Aug 5, 2026
4c6b21a
fix(email): bind fallback dedupe to immutable source
github-actions[bot] Aug 5, 2026
6622f65
Merge branch 'develop' into claude/contextualwisdomlab-audit-governan…
opencode-agent[bot] Aug 5, 2026
4369f80
test(email): reject unsupported canonical source values
seonghobae Aug 5, 2026
375d6c5
fix(email): validate canonical source value types
seonghobae Aug 5, 2026
24efad3
docs(email): summarize record-linkage evidence model
seonghobae Aug 5, 2026
596aeb6
Merge protected develop into email dedupe provenance
seonghobae Aug 14, 2026
26dfa22
Merge branch 'develop' into claude/contextualwisdomlab-audit-governan…
opencode-agent[bot] Aug 15, 2026
bee6519
merge(develop): refresh email dedupe provenance
seonghobae Aug 15, 2026
510ec77
Merge branch 'develop' into claude/contextualwisdomlab-audit-governan…
seonghobae Aug 17, 2026
f6e60ef
Merge branch 'develop' into claude/contextualwisdomlab-audit-governan…
seonghobae Aug 17, 2026
f98c6fe
Merge branch 'develop' into claude/contextualwisdomlab-audit-governan…
cursoragent Aug 17, 2026
6aeda8f
Merge branch 'develop' into claude/contextualwisdomlab-audit-governan…
opencode-agent[bot] Aug 20, 2026
c7aedc6
Merge remote-tracking branch 'origin/develop' into HEAD
seonghobae Aug 21, 2026
0b74bef
Merge branch 'develop' into claude/contextualwisdomlab-audit-governan…
seonghobae Aug 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions backend/alembic/versions/0018_email_date_provenance.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""add date_provenance to email_records

Revision ID: 0018_email_date_provenance
Revises: 0017_merge_newsdom_carddav_heads
Create Date: 2026-07-30 00:00:00.000000

Records the provenance of each stored email ``date`` so a synthetic
collection-time fallback (missing/invalid RFC822 Date header) is never treated
as original sender metadata when seeding a strong auto-dedupe fingerprint
(naruon#1086). Nullable-free with a ``"unknown"`` server default so existing
rows backfill safely: their date provenance is genuinely unknown, and only
``"parsed"`` rows are eligible to seed a strong fingerprint, so the backfill is
conservative (it can only widen review, never manufacture a duplicate).
"""

from alembic import op
import sqlalchemy as sa

revision = "0018_email_date_provenance"
down_revision = "0017_merge_newsdom_carddav_heads"

_EMAIL_TABLE = "email_records"
_PROVENANCE_COLUMN = "date_provenance"


def upgrade() -> None:
"""Add the ``date_provenance`` column, backfilling existing rows to unknown."""
connection = op.get_bind()
inspector = sa.inspect(connection)
columns = {column["name"] for column in inspector.get_columns(_EMAIL_TABLE)}
if _PROVENANCE_COLUMN not in columns:
op.add_column(
_EMAIL_TABLE,
sa.Column(
_PROVENANCE_COLUMN,
sa.String(),
nullable=False,
server_default="unknown",
),
)


def downgrade() -> None:
"""Drop the ``date_provenance`` column if present."""
connection = op.get_bind()
inspector = sa.inspect(connection)
columns = {column["name"] for column in inspector.get_columns(_EMAIL_TABLE)}
if _PROVENANCE_COLUMN in columns:
op.drop_column(_EMAIL_TABLE, _PROVENANCE_COLUMN)
7 changes: 7 additions & 0 deletions backend/db/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -801,6 +801,13 @@ def owner_filters(cls, user_id: str, organization_id: str | None):
in_reply_to: Mapped[str | None] = mapped_column(String, nullable=True)
references: Mapped[str | None] = mapped_column(String, nullable=True)
date: Mapped[datetime.datetime] = mapped_column(DateTime(timezone=True), index=True)
# Provenance of the stored ``date``: "parsed" (genuine RFC822 Date header),
# "missing"/"invalid" (a synthetic collection-time fallback), or "unknown"
# (rows stored before provenance tracking). Only "parsed" rows may seed a
# strong auto-dedupe fingerprint (naruon#1086).
date_provenance: Mapped[str] = mapped_column(
String, nullable=False, server_default="unknown", default="unknown"
)
body: Mapped[str] = mapped_column(Text)
# IMAP \Seen read state; defaults read so historical/file imports don't nag.
is_read: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
Expand Down
246 changes: 246 additions & 0 deletions backend/services/email_dedupe_service.py
Original file line number Diff line number Diff line change
@@ -1,35 +1,150 @@
"""Email de-duplication fingerprints and the Fellegi-Sunter decision classifier.

This module is the deterministic core the import/IMAP paths compose to decide
whether an incoming email is a duplicate of a stored one, keeping strong
(auto-merge) evidence gated on genuine Date provenance (naruon#1086).
"""

import datetime
import hashlib
import json
import math
from collections.abc import Iterable, Mapping
from dataclasses import dataclass
from typing import Literal

from db.models import Email
from services.email_service import generate_email_fingerprint
from services.threading_service import normalize_message_id

# Fellegi & Sunter (1969) partition each candidate/record pair into three
# decision zones: a positive link (A1), a non-link (A3), and an indeterminate
# "possible match" band (A2) reserved for clerical review. naruon#1086 maps that
# rule onto email de-duplication: a reliable identity link auto-merges, a
# probable duplicate that lacks a reliable link is held for review instead of
# being silently kept or silently merged, and everything else is distinct.
DedupeDecision = Literal["auto_link", "review_required", "distinct"]


@dataclass(frozen=True)
class EmailDedupeCandidate:
"""An incoming email reduced to the fields the dedupe decision needs.

``date_provenance`` mirrors the parser's classification of the ``date``
field (``parsed`` for a genuine RFC822 Date, otherwise a synthetic
collection-time fallback); only ``parsed`` may seed a strong auto-dedupe
match (naruon#1086).
"""

candidate_key: str
message_id: str | None = None
sender: str | None = None
recipients: str | None = None
subject: str | None = None
date: datetime.datetime | None = None
body: str | None = None
date_provenance: str = "unknown"


def _date_to_fingerprint_value(value: datetime.datetime | None) -> str:
"""Render a datetime as its ISO-8601 fingerprint token (``""`` when None)."""
if value is None:
return ""
return value.isoformat()


_CANONICAL_SOURCE_FIELDS = (
"message_id",
"sender",
"recipients",
"subject",
"body",
"reply_to",
"in_reply_to",
"references",
"attachments",
)


def _validate_canonical_source_value(value: object, *, path: str) -> None:
"""Reject values outside the deterministic JSON-native EmailData surface.

Silent ``str()`` coercion is unsafe for identity material because distinct
runtime types can render to the same text. Parsed canonical-source fields
therefore accept only JSON-native scalars, lists, and string-keyed mappings;
every nested value is checked before serialization.
"""
if value is None or isinstance(value, (str, bool, int)):
return
if isinstance(value, float):
if not math.isfinite(value):
raise TypeError(f"{path}: non-finite floats are not canonical")
return
if isinstance(value, list):
for index, item in enumerate(value):
_validate_canonical_source_value(item, path=f"{path}[{index}]")
return
if isinstance(value, dict):
for key, item in value.items():
if not isinstance(key, str):
raise TypeError(
f"{path}: canonical mapping keys must be strings, got "
f"{type(key).__name__}"
)
_validate_canonical_source_value(item, path=f"{path}.{key}")
return
raise TypeError(
f"{path}: unsupported canonical email source value type "
f"{type(value).__name__}"
)


def canonical_email_source_content(email_data: Mapping[str, object]) -> bytes:
"""Serialize stable parsed fields when raw transport bytes are unavailable.

Collection-time ``date`` values and their provenance are deliberately
excluded. Transport-backed paths should provide exact RFC822 bytes. Values
outside the parsed ``EmailData`` JSON surface fail closed rather than being
string-coerced into potentially colliding identities.
"""
payload = {field: email_data.get(field) for field in _CANONICAL_SOURCE_FIELDS}
for field, value in payload.items():
_validate_canonical_source_value(value, path=field)
return json.dumps(
payload,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
allow_nan=False,
).encode("utf-8", errors="surrogatepass")


def source_email_fingerprint(
source_content: bytes,
*,
source_kind: Literal["raw", "canonical"] = "raw",
) -> str:
"""Return a domain-separated SHA-256 identity for stable source bytes."""
digest = hashlib.sha256()
digest.update(b"naruon-email-source-v1\0")
digest.update(source_kind.encode("ascii"))
digest.update(b"\0")
digest.update(source_content)
return digest.hexdigest()


def strong_email_fingerprint(
*,
sender: str | None,
subject: str | None,
date: datetime.datetime | None,
body: str | None,
) -> str | None:
"""Return the strong (sender+subject+Date+body) auto-dedupe fingerprint.

Requires a body; ``None`` for an empty body so bodyless rows cannot collapse
to a shared hash. Callers gate this on genuine Date provenance.
"""
if not body:
return None
return generate_email_fingerprint(
Expand All @@ -43,13 +158,15 @@ def strong_email_fingerprint(


def candidate_message_lookup_values(candidate: EmailDedupeCandidate) -> set[str]:
"""Return the bracketed and bare Message-ID lookup forms (empty if none)."""
normalized = normalize_message_id(candidate.message_id)
if not normalized:
return set()
return {normalized, f"<{normalized}>"}


def candidate_strong_fingerprint(candidate: EmailDedupeCandidate) -> str | None:
"""Return the candidate's strong fingerprint (see strong_email_fingerprint)."""
return strong_email_fingerprint(
sender=candidate.sender,
subject=candidate.subject,
Expand All @@ -59,9 +176,138 @@ def candidate_strong_fingerprint(candidate: EmailDedupeCandidate) -> str | None:


def email_strong_fingerprint(email_row: Email) -> str | None:
"""Return a stored row's strong fingerprint, gated on genuine Date provenance.

A stored row may seed a strong (auto-dedupe) fingerprint only when its date
is genuinely parsed sender metadata; rows with a synthetic or
unknown-provenance date are excluded so they cannot manufacture a strong
duplicate match (naruon#1086).
"""
if getattr(email_row, "date_provenance", None) != "parsed":
return None
return strong_email_fingerprint(
sender=email_row.sender,
subject=email_row.subject,
date=email_row.date,
body=email_row.body,
)


def content_email_fingerprint(
*,
sender: str | None,
subject: str | None,
body: str | None,
) -> str | None:
"""Return a Date-independent content fingerprint (sender+subject+body).

Unlike the strong fingerprint it omits the Date, so it survives an
untrustworthy Date provenance (naruon#1086) and can flag a probable
duplicate that the strong path deliberately withholds. Requires a body so
empty-body rows cannot collapse to a shared hash.
"""
if not body:
return None
return generate_email_fingerprint(
{
"sender": sender or "",
"subject": subject or "",
"date": "",
"body": body,
}
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.


def candidate_content_fingerprint(candidate: EmailDedupeCandidate) -> str | None:
"""Return the candidate's Date-independent content fingerprint."""
return content_email_fingerprint(
sender=candidate.sender,
subject=candidate.subject,
body=candidate.body,
)


def email_content_fingerprint(email_row: Email) -> str | None:
"""Return a stored row's Date-independent content fingerprint."""
return content_email_fingerprint(
sender=email_row.sender,
subject=email_row.subject,
body=email_row.body,
)


def classify_dedupe_decision(
candidate: EmailDedupeCandidate, existing_row: Email
) -> DedupeDecision:
"""Assign a Fellegi-Sunter (1969) decision zone to a candidate/existing pair.

- ``auto_link`` (A1, positive link): the pair shares a reliable identity
link -- the same normalized Message-ID, or a genuine strong match
(identical sender/subject/Date/body with a trusted, parsed Date on *both*
sides). These are safe to merge automatically.
- ``review_required`` (A2, possible match): the pair shares a
provenance-independent content signal (same sender/subject/body) but has
no reliable identity link -- typically because at least one side's Date
provenance is synthetic or unknown, so the strong fingerprint was withheld
(naruon#1086). This is the clerical-review band: a probable duplicate that
must not be silently merged or silently kept.
- ``distinct`` (A3, non-link): no shared identity or content signal.
"""
candidate_message = normalize_message_id(candidate.message_id)
existing_message = normalize_message_id(existing_row.message_id)
if candidate_message and existing_message and candidate_message == existing_message:
return "auto_link"

candidate_strong = candidate_strong_fingerprint(candidate)
existing_strong = email_strong_fingerprint(existing_row)
if (
candidate.date_provenance == "parsed"
and candidate_strong is not None
and existing_strong is not None
and candidate_strong == existing_strong
):
return "auto_link"

candidate_content = candidate_content_fingerprint(candidate)
existing_content = email_content_fingerprint(existing_row)
if (
candidate_content is not None
and existing_content is not None
and candidate_content == existing_content
):
return "review_required"

return "distinct"


def resolve_candidate_disposition(
candidate: EmailDedupeCandidate, existing_rows: Iterable[Email]
) -> tuple[DedupeDecision, Email | None]:
"""Resolve a candidate against many stored rows to one Fellegi-Sunter disposition.

Real de-duplication compares one incoming email against the *set* of stored
rows it might duplicate, not a single row, so this collapses the per-pair
``classify_dedupe_decision`` results by the Fellegi & Sunter (1969) zone
priority A1 > A2 > A3:

- the first stored row that yields ``auto_link`` (A1, a reliable identity
link) wins immediately -- a positive link cannot be outranked;
- absent any link, the first ``review_required`` match (A2) is held for
clerical review rather than silently merged or silently kept;
- only when no stored row shares any identity or content signal is the
candidate ``distinct`` (A3).

Returns the decision together with the stored row that drove a link or a
review hold (``None`` when distinct), so the import/IMAP paths know which
email the disposition targets without re-deriving the match.
"""
review_match: Email | None = None
for existing_row in existing_rows:
decision = classify_dedupe_decision(candidate, existing_row)
if decision == "auto_link":
return "auto_link", existing_row
if decision == "review_required" and review_match is None:
review_match = existing_row
if review_match is not None:
return "review_required", review_match
return "distinct", None
Loading
Loading