Task ↔ Email Links
Design proposal · taskservice, notificationservicev2, task frontend, email frontend · 2026-10-15
task_links row, and let both the email detail view and the task view read from taskservice. Notificationservicev2 only gains two
aggregate fields on the thread.
1. Where things stand
Tasks
One single-valued entity link (entityType, entityCode, entityNumber) on the task row, validated at write time by calling the owning service. List is filterable by entity type and code.
Email threads
A thread groups an outbound email with its replies and bounces. It also carries one entity link. The thread detail page in the email frontend is the "email details view".
Between them
Nothing. Taskservice has no gRPC client for notificationservice, and notificationservice has none for taskservice.
2. Decisions
message_code can be added later if a specific message ever needs pinning.EmailThread for now.
Mirrors the existing EntityRef {type, code}. Documents or comments can be linked later without a new table or new endpoints.
The alternative, a dedicated task_email_threads table, costs the same today and one more table next time.3. Data model (taskservice)
| Column | Type | Notes |
|---|---|---|
id | serial | |
task_id | int | FK → tasks.id, ON DELETE CASCADE |
link_type | varchar | enum, only EmailThread initially |
link_code | varchar | the thread code, e.g. thr_a1b2c3d4e5 |
owner, tenant_hierarchy | varchar | same defaults as tasks (RLS) |
created_by, created_at | who linked it and when | |
Unique (task_id, link_type, link_code) · Index (link_type, link_code) for the "tasks for this thread" lookup | ||
Taskservice stores only the code. Subject, status and activity of the thread are fetched live, so nothing goes stale.
4. API changes
Protorepo · taskservice/v1
message TaskLink {
string type = 1; // "EmailThread"
string code = 2;
}
message Task {
...
repeated TaskLink links = 22; // always returned, like category_slugs
}
message CreateTaskRequest {
...
repeated TaskLink links = 12; // "create task from this email" in one call
}
message AddTaskLinkRequest { string code = 1; TaskLink link = 2; }
message AddTaskLinkResponse { Task task = 1; }
message RemoveTaskLinkRequest { string code = 1; string link_type = 2; string link_code = 3; }
message RemoveTaskLinkResponse{ Task task = 1; }
service TaskService {
...
rpc AddTaskLink(AddTaskLinkRequest) returns (AddTaskLinkResponse) {}
rpc RemoveTaskLink(RemoveTaskLinkRequest) returns (RemoveTaskLinkResponse) {}
}
Protorepo · notificationservice/v1
message EmailThread {
...
uint32 message_count = 10;
optional google.protobuf.Timestamp last_message_at = 11;
}
Cheap aggregates on the thread query. They let the task view show activity without loading messages and are useful on the thread list as well.
REST · taskservice
| Method | Path | Purpose |
|---|---|---|
POST | /tasks/:code/links | Body {type, code}. Validates the thread exists via notificationservice gRPC GetEmailThread. Returns the task. |
DELETE | /tasks/:code/links/:type/:linkCode | Unlink. Returns the task. |
GET | /tasks?filters=links.code==thr_x | Tasks for a thread. New filterable columns links.type, links.code in the paginate config. |
POST | /tasks | Accepts links[] on create. |
REST · notificationservicev2
No new endpoints. The email frontend calls taskservice directly, as the entity task widget already does for claims and policies. Avoids a notificationservice → taskservice dependency.
Validation and audit
- Taskservice gets a notificationservice gRPC client config and a resolver for
EmailThread, following the existingLinkedEntityResolverServicepattern. Unknown thread → 404. - Linking requires the task update permission. Viewing linked threads requires the email view permission, which the user already needs to open the thread page.
tasks_historyis trigger-based on the tasks table and will not see link rows. The changelog producer should emit link and unlink as task updates so the audit trail stays complete.
5. Request flow
6. UI
Email thread detail → "Tasks" panel
Reuses the existing entity task list widget, pinned to the thread code instead of an entity. The create button prefills the thread link and copies the thread's entity ref into the task's entity link, so a task created from a claim email also lands on that claim.
Task detail / edit → "Emails" section
Lists linked threads with subject, entity number, thread status, last message date and message count. Each row links to the thread page. "Link email" opens a thread search backed by the notificationservice list endpoint. Unlink calls the delete endpoint.
Threads are fetched in one call with a code $in filter. A code that does not come back is rendered as "unavailable" rather than failing the task load.
7. Edge cases
| Case | Behaviour |
|---|---|
| Thread deleted or not visible | Task view shows the code marked unavailable. Link stays until someone removes it. |
| Task deleted | Links cascade. |
| Thread reassigned to another entity | The task's own entity link is independent and stays as is. Deliberate: the email link is additional to the entity link. |
| Same thread linked twice | Unique constraint → 409 or idempotent no-op. Recommend idempotent. |
| Thread from another tenant | Taskservice validates with the caller's headers, so the lookup is tenant-scoped and returns 404. |
8. Rollout order
- Protorepo:
TaskLink, new rpcs, thread aggregate fields. Merge first, PR pipelines compile against master. - Taskservice: migration, entity, notificationservice client + resolver, endpoints, list filter, changelog, e2e specs.
- Notificationservicev2:
message_countandlast_message_aton the thread. - Frontends: tasks panel on the thread page, emails section on the task page.
9. Open question for the team
Generic task_links versus a dedicated task_email_threads table. The recommendation is generic: identical cost now, and it spares a second table and a second set of endpoints the next time something needs to hang off a task.