Communications tab: show matched investors only (v0.1.0:81)
The email-activity panel surfaced every captured message, including cold/ unknown-sender email with no investor association. Gate query_email_activity on EXISTS(email_investor_links) so the panel shows only email tied to a known investor/contact. Capture is unchanged — unmatched email is still stored (metadata-only) and will appear automatically if its sender is later added as an investor; this is a read-side filter only. Graveyard investors are unaffected (their email has a link), so they remain visible/searchable as an audit surface, hidden only from the filter picker.
This commit is contained in:
@@ -405,6 +405,10 @@ def query_email_activity(conn: sqlite3.Connection, *, investor_id: Optional[str]
|
|||||||
investor (matched fundraising investor) and/or mailbox, with free-text search
|
investor (matched fundraising investor) and/or mailbox, with free-text search
|
||||||
over subject/snippet/sender. Returns the email rows plus the filter facets.
|
over subject/snippet/sender. Returns the email rows plus the filter facets.
|
||||||
|
|
||||||
|
Matched-only: the panel shows ONLY email that links to a known investor/contact
|
||||||
|
(an `email_investor_links` row exists). Unmatched cold/unknown-sender email is
|
||||||
|
still captured for completeness but is never surfaced here.
|
||||||
|
|
||||||
Soft-delete: an email is live only if it still has a non-tombstoned per-mailbox
|
Soft-delete: an email is live only if it still has a non-tombstoned per-mailbox
|
||||||
sighting (`email_account_messages.deleted_at IS NULL`) — the `emails` row itself
|
sighting (`email_account_messages.deleted_at IS NULL`) — the `emails` row itself
|
||||||
carries no deleted_at, so deletion lives on the sighting. Direction is decided at
|
carries no deleted_at, so deletion lives on the sighting. Direction is decided at
|
||||||
@@ -418,7 +422,10 @@ def query_email_activity(conn: sqlite3.Connection, *, investor_id: Optional[str]
|
|||||||
own.discard("")
|
own.discard("")
|
||||||
|
|
||||||
where = ["EXISTS (SELECT 1 FROM email_account_messages eam "
|
where = ["EXISTS (SELECT 1 FROM email_account_messages eam "
|
||||||
"WHERE eam.email_id = e.id AND eam.deleted_at IS NULL)"]
|
"WHERE eam.email_id = e.id AND eam.deleted_at IS NULL)",
|
||||||
|
# Matched-only: surface email that links to a known investor/contact.
|
||||||
|
# Unmatched (unknown-sender) email is captured but never shown here.
|
||||||
|
"EXISTS (SELECT 1 FROM email_investor_links l WHERE l.email_id = e.id)"]
|
||||||
params: list = []
|
params: list = []
|
||||||
if account_id:
|
if account_id:
|
||||||
where.append("EXISTS (SELECT 1 FROM email_account_messages eam "
|
where.append("EXISTS (SELECT 1 FROM email_account_messages eam "
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Test the admin-only email-activity panel (Communications tab, v0.1.0:80).
|
"""Test the admin-only email-activity panel (Communications tab, v0.1.0:80).
|
||||||
|
|
||||||
Covers the pure query (`db.query_email_activity`): investor/mailbox/search/direction
|
Covers the pure query (`db.query_email_activity`): matched-only scope (unmatched
|
||||||
|
cold/unknown-sender email is never surfaced), investor/mailbox/search/direction
|
||||||
filters, per-sighting soft-delete, direction at the email level, mailbox + investor
|
filters, per-sighting soft-delete, direction at the email level, mailbox + investor
|
||||||
roll-ups (incl. unmatched fallback to the matched address), and the filter facets.
|
roll-ups, and the filter facets.
|
||||||
Also asserts the route handler enforces admin server-side. Synthetic data only.
|
Also asserts the route handler enforces admin server-side. Synthetic data only.
|
||||||
|
|
||||||
Run: cd backend && python3 email_integration/test_email_activity_panel.py
|
Run: cd backend && python3 email_integration/test_email_activity_panel.py
|
||||||
@@ -55,7 +56,7 @@ def make_db():
|
|||||||
# e1 outbound (from us) -> Harbor, seen by grant
|
# e1 outbound (from us) -> Harbor, seen by grant
|
||||||
# e2 inbound (from LP) -> Harbor, seen by grant + jonathan
|
# e2 inbound (from LP) -> Harbor, seen by grant + jonathan
|
||||||
# e3 inbound (from LP) -> Pacific via contact link, seen by jonathan
|
# e3 inbound (from LP) -> Pacific via contact link, seen by jonathan
|
||||||
# e4 inbound, UNMATCHED (no investor link), seen by grant
|
# e4 inbound, UNMATCHED (no investor link), seen by grant -> must be excluded (matched-only)
|
||||||
# e5 inbound, only sighting is tombstoned -> must be excluded
|
# e5 inbound, only sighting is tombstoned -> must be excluded
|
||||||
conn.executemany(
|
conn.executemany(
|
||||||
"INSERT INTO emails (id,subject,from_name,from_email,sent_at,snippet,has_attachments,is_matched,match_status) VALUES (?,?,?,?,?,?,?,?,?)",
|
"INSERT INTO emails (id,subject,from_name,from_email,sent_at,snippet,has_attachments,is_matched,match_status) VALUES (?,?,?,?,?,?,?,?,?)",
|
||||||
@@ -98,10 +99,12 @@ def ids(res):
|
|||||||
def main():
|
def main():
|
||||||
conn = make_db()
|
conn = make_db()
|
||||||
|
|
||||||
# --- baseline: live emails only, newest first, tombstoned excluded ---
|
# --- baseline: matched live emails only, newest first, tombstoned excluded ---
|
||||||
res = _db.query_email_activity(conn)
|
res = _db.query_email_activity(conn)
|
||||||
check(ids(res) == ["e4", "e3", "e2", "e1", "e6"], f"live emails newest-first, e5 (tombstoned) excluded; got {ids(res)}")
|
check(ids(res) == ["e3", "e2", "e1", "e6"],
|
||||||
check(res["count"] == 5 and res["truncated"] is False, "count + not truncated")
|
f"matched live emails newest-first; e5 (tombstoned) + e4 (unmatched) excluded; got {ids(res)}")
|
||||||
|
check(res["count"] == 4 and res["truncated"] is False, "count + not truncated")
|
||||||
|
check("e4" not in ids(res), "unmatched email (no investor link) never surfaces in the panel")
|
||||||
|
|
||||||
# --- direction at the email level ---
|
# --- direction at the email level ---
|
||||||
e1 = next(e for e in res["emails"] if e["id"] == "e1")
|
e1 = next(e for e in res["emails"] if e["id"] == "e1")
|
||||||
@@ -111,8 +114,8 @@ def main():
|
|||||||
check(_db.query_email_activity(conn, direction="outbound")["emails"][0]["id"] == "e1"
|
check(_db.query_email_activity(conn, direction="outbound")["emails"][0]["id"] == "e1"
|
||||||
and len(_db.query_email_activity(conn, direction="outbound")["emails"]) == 1,
|
and len(_db.query_email_activity(conn, direction="outbound")["emails"]) == 1,
|
||||||
"direction=outbound returns only e1")
|
"direction=outbound returns only e1")
|
||||||
check(ids(_db.query_email_activity(conn, direction="inbound")) == ["e4", "e3", "e2", "e6"],
|
check(ids(_db.query_email_activity(conn, direction="inbound")) == ["e3", "e2", "e6"],
|
||||||
"direction=inbound excludes the outbound e1")
|
"direction=inbound excludes the outbound e1 (and unmatched e4)")
|
||||||
|
|
||||||
# --- mailbox roll-up + per-account filter ---
|
# --- mailbox roll-up + per-account filter ---
|
||||||
check(set(e2["mailboxes"]) == {"grant@ten31.xyz", "jonathan@ten31.xyz"}, "e2 seen by both mailboxes")
|
check(set(e2["mailboxes"]) == {"grant@ten31.xyz", "jonathan@ten31.xyz"}, "e2 seen by both mailboxes")
|
||||||
@@ -129,13 +132,14 @@ def main():
|
|||||||
check(e1["investors"] == [{"id": "inv-harbor", "name": "Harbor & Vine"}], "e1 investor resolved to name")
|
check(e1["investors"] == [{"id": "inv-harbor", "name": "Harbor & Vine"}], "e1 investor resolved to name")
|
||||||
e3 = next(e for e in res["emails"] if e["id"] == "e3")
|
e3 = next(e for e in res["emails"] if e["id"] == "e3")
|
||||||
check(e3["investors"] == [{"id": "inv-pacific", "name": "Pacific Capital"}], "e3 investor resolved via contact")
|
check(e3["investors"] == [{"id": "inv-pacific", "name": "Pacific Capital"}], "e3 investor resolved via contact")
|
||||||
e4 = next(e for e in res["emails"] if e["id"] == "e4")
|
check("e4" not in ids(res), "e4 unmatched -> excluded from the matched-only panel")
|
||||||
check(e4["investors"] == [] and e4["investor_labels"] == [], "e4 unmatched -> no investor, no link")
|
|
||||||
|
|
||||||
# --- free-text search over subject / snippet / sender ---
|
# --- free-text search over subject / snippet / sender ---
|
||||||
check(set(ids(_db.query_email_activity(conn, search="Fund III"))) == {"e1", "e2"}, "search subject")
|
check(set(ids(_db.query_email_activity(conn, search="Fund III"))) == {"e1", "e2"}, "search subject")
|
||||||
check(ids(_db.query_email_activity(conn, search="pacificcap")) == ["e3"], "search sender address")
|
check(ids(_db.query_email_activity(conn, search="pacificcap")) == ["e3"], "search sender address")
|
||||||
check(ids(_db.query_email_activity(conn, search="buy now")) == ["e4"], "search snippet")
|
check(ids(_db.query_email_activity(conn, search="deck")) == ["e1"], "search snippet (matched email)")
|
||||||
|
check(ids(_db.query_email_activity(conn, search="buy now")) == [],
|
||||||
|
"unmatched email never surfaces, even by free-text search")
|
||||||
|
|
||||||
# --- facets ---
|
# --- facets ---
|
||||||
check([a["email_address"] for a in res["accounts"]] == ["grant@ten31.xyz", "jonathan@ten31.xyz"],
|
check([a["email_address"] for a in res["accounts"]] == ["grant@ten31.xyz", "jonathan@ten31.xyz"],
|
||||||
|
|||||||
@@ -89,6 +89,11 @@ the manual-log path). Backed by **`GET /api/email/activity`** (`routes.py:_h_act
|
|||||||
query). Filters: `investor_id`, `account_id` (mailbox), `direction` (`inbound`/`outbound`),
|
query). Filters: `investor_id`, `account_id` (mailbox), `direction` (`inbound`/`outbound`),
|
||||||
`q` (free-text over subject/snippet/from). Non-obvious semantics to preserve:
|
`q` (free-text over subject/snippet/from). Non-obvious semantics to preserve:
|
||||||
|
|
||||||
|
- **Matched-only:** the panel surfaces ONLY email that links to a known
|
||||||
|
investor/contact (`query_email_activity` gates on `EXISTS email_investor_links`).
|
||||||
|
Capture still stores unmatched cold/unknown-sender email (metadata only, see "match-only
|
||||||
|
full storage"), but it is never shown here — the Communications tab is the
|
||||||
|
investor-relationship view, not the raw mailbox.
|
||||||
- **Soft-delete lives on the per-mailbox sighting**, not the email: `emails` has no
|
- **Soft-delete lives on the per-mailbox sighting**, not the email: `emails` has no
|
||||||
`deleted_at`. An email is "live" iff it has a sighting with `email_account_messages.
|
`deleted_at`. An email is "live" iff it has a sighting with `email_account_messages.
|
||||||
deleted_at IS NULL` — the query gates on `EXISTS(... deleted_at IS NULL)`. (Investor
|
deleted_at IS NULL` — the query gates on `EXISTS(... deleted_at IS NULL)`. (Investor
|
||||||
|
|||||||
@@ -45,8 +45,9 @@ export const PACKAGE_TITLE = 'Ten31 Database'
|
|||||||
// * 0.1.0:77 (daily activity digest — Phase B: digest_builder builds by-team-member [per-user Spark narrative, never Claude] + by-investor [inbound+outbound, deduped] sections; always-on digest_scheduler reads a DB-backed policy; enable/send-time in Settings→Admin via GET/PATCH /api/admin/digest/policy; POST /api/admin/digest/send-now + "Send Digest Now" button)
|
// * 0.1.0:77 (daily activity digest — Phase B: digest_builder builds by-team-member [per-user Spark narrative, never Claude] + by-investor [inbound+outbound, deduped] sections; always-on digest_scheduler reads a DB-backed policy; enable/send-time in Settings→Admin via GET/PATCH /api/admin/digest/policy; POST /api/admin/digest/send-now + "Send Digest Now" button)
|
||||||
// * 0.1.0:78 (retire legacy lp_profiles + orphaned LP Tracker; Dashboard "Total Committed" repointed onto the fundraising grid [graveyard-excluded], "Total Funded" dropped; /api/lp-profiles* + lp-breakdown report removed; contact-dossier LP section + demo-seed LP block removed)
|
// * 0.1.0:78 (retire legacy lp_profiles + orphaned LP Tracker; Dashboard "Total Committed" repointed onto the fundraising grid [graveyard-excluded], "Total Funded" dropped; /api/lp-profiles* + lp-breakdown report removed; contact-dossier LP section + demo-seed LP block removed)
|
||||||
// * 0.1.0:79 (HOTFIX blank-screen: pin @babel/standalone@7.29.7 — the unpinned CDN upgraded to Babel 8, whose preset-react automatic JSX runtime emits an ESM import that blanks the classic inline-script app; plus close 3 server-side admin gaps: GET /api/users, /api/email/status, /api/email/accounts now require_admin)
|
// * 0.1.0:79 (HOTFIX blank-screen: pin @babel/standalone@7.29.7 — the unpinned CDN upgraded to Babel 8, whose preset-react automatic JSX runtime emits an ESM import that blanks the classic inline-script app; plus close 3 server-side admin gaps: GET /api/users, /api/email/status, /api/email/accounts now require_admin)
|
||||||
// * Current: 0.1.0:80 (repurpose Communications tab as the admin-only email-activity panel: new GET /api/email/activity [admin-enforced] over the email_* tables, filterable by investor/mailbox/direction + free-text search; classic manual log form retired; code-only, no schema change)
|
// * 0.1.0:80 (repurpose Communications tab as the admin-only email-activity panel: new GET /api/email/activity [admin-enforced] over the email_* tables, filterable by investor/mailbox/direction + free-text search; classic manual log form retired; code-only, no schema change)
|
||||||
export const PACKAGE_VERSION = '0.1.0:80'
|
// * Current: 0.1.0:81 (Communications tab is matched-only: query_email_activity gates on EXISTS email_investor_links, so unmatched cold/unknown-sender email is captured but never surfaced in the panel; code-only, no schema change)
|
||||||
|
export const PACKAGE_VERSION = '0.1.0:81'
|
||||||
|
|
||||||
export const DATA_MOUNT_PATH = '/data'
|
export const DATA_MOUNT_PATH = '/data'
|
||||||
export const WEB_PORT = 8080
|
export const WEB_PORT = 8080
|
||||||
|
|||||||
@@ -41,8 +41,9 @@ import { v_0_1_0_77 } from './v0.1.0.77'
|
|||||||
import { v_0_1_0_78 } from './v0.1.0.78'
|
import { v_0_1_0_78 } from './v0.1.0.78'
|
||||||
import { v_0_1_0_79 } from './v0.1.0.79'
|
import { v_0_1_0_79 } from './v0.1.0.79'
|
||||||
import { v_0_1_0_80 } from './v0.1.0.80'
|
import { v_0_1_0_80 } from './v0.1.0.80'
|
||||||
|
import { v_0_1_0_81 } from './v0.1.0.81'
|
||||||
|
|
||||||
export const versionGraph = VersionGraph.of({
|
export const versionGraph = VersionGraph.of({
|
||||||
current: v_0_1_0_80,
|
current: v_0_1_0_81,
|
||||||
other: [v_0_1_0_39, v_0_1_0_40, v_0_1_0_41, v_0_1_0_42, v_0_1_0_43, v_0_1_0_44, v_0_1_0_45, v_0_1_0_46, v_0_1_0_47, v_0_1_0_48, v_0_1_0_49, v_0_1_0_50, v_0_1_0_51, v_0_1_0_52, v_0_1_0_53, v_0_1_0_54, v_0_1_0_55, v_0_1_0_56, v_0_1_0_57, v_0_1_0_58, v_0_1_0_59, v_0_1_0_60, v_0_1_0_61, v_0_1_0_62, v_0_1_0_63, v_0_1_0_64, v_0_1_0_65, v_0_1_0_66, v_0_1_0_67, v_0_1_0_68, v_0_1_0_69, v_0_1_0_70, v_0_1_0_71, v_0_1_0_72, v_0_1_0_73, v_0_1_0_74, v_0_1_0_75, v_0_1_0_76, v_0_1_0_77, v_0_1_0_78, v_0_1_0_79],
|
other: [v_0_1_0_39, v_0_1_0_40, v_0_1_0_41, v_0_1_0_42, v_0_1_0_43, v_0_1_0_44, v_0_1_0_45, v_0_1_0_46, v_0_1_0_47, v_0_1_0_48, v_0_1_0_49, v_0_1_0_50, v_0_1_0_51, v_0_1_0_52, v_0_1_0_53, v_0_1_0_54, v_0_1_0_55, v_0_1_0_56, v_0_1_0_57, v_0_1_0_58, v_0_1_0_59, v_0_1_0_60, v_0_1_0_61, v_0_1_0_62, v_0_1_0_63, v_0_1_0_64, v_0_1_0_65, v_0_1_0_66, v_0_1_0_67, v_0_1_0_68, v_0_1_0_69, v_0_1_0_70, v_0_1_0_71, v_0_1_0_72, v_0_1_0_73, v_0_1_0_74, v_0_1_0_75, v_0_1_0_76, v_0_1_0_77, v_0_1_0_78, v_0_1_0_79, v_0_1_0_80],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
|
// Communications tab is matched-only. Code-only, no schema change (migrations are no-ops):
|
||||||
|
// * query_email_activity now gates on EXISTS(email_investor_links), so the admin
|
||||||
|
// email-activity panel surfaces ONLY email that links to a known investor/contact.
|
||||||
|
// Unmatched cold/unknown-sender email is still captured (metadata-only) but never shown.
|
||||||
|
// * Graveyard investors are unaffected (their email has a link) — still hidden from the
|
||||||
|
// picker but visible/searchable as an audit surface.
|
||||||
|
export const v_0_1_0_81 = VersionInfo.of({
|
||||||
|
version: '0.1.0:81',
|
||||||
|
releaseNotes: {
|
||||||
|
en_US: [
|
||||||
|
'The Communications tab now shows only email matched to a known investor or contact.',
|
||||||
|
'Unmatched cold/unknown-sender email is captured but no longer surfaced. No data changes.',
|
||||||
|
].join(' '),
|
||||||
|
},
|
||||||
|
migrations: { up: async () => {}, down: async () => {} },
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user