AW-05 — Agent email end to end · Task breakdown
Ordered, executable tasks derived from
plan.md. Each carries explicit file paths, a definition of done, and its phase. An engineer or coding agent should be able to run these top to bottom without guessing.
Epic ID: AW-05-agent-email
Spec: ./spec.md · Plan: ./plan.md
Status: Draft
Last updated: 2026-09-06
How to use
- Tasks are sequential unless marked
(parallel). - Every task ships with its tests in the same PR (Constitution VI). A task whose "Done" clause names a spec file is not done until that file passes.
- Phase boundaries (
P1/P2/P3) are shippable cut lines. Do not start a P2 task before every P1 task is merged anddevelopis green. - Add new tasks at the bottom rather than renumbering.
- Repo root is the worktree root; all paths are relative to it.
- Before any migration work:
cd apps/api— migrations are generated from there againsttypeorm.config.ts.
Phase 1 — the loop
1.1 Contracts and types
-
T1
(P1)Add the email contracts package.- Create
packages/contracts/src/email/email.types.tswith:EmailMessageStatus('received'|'draft'|'revising'|'scheduled'|'sending'|'sent'|'failed'|'escalated'|'discarded'|'blocked'),AgentInboxMode,AgentInboxState,EmailRuleType,EmailRuleMatchKind,EmailRuleDirection,EmailAllowListMode,EmailSendingDomainStatusValue, plus the canonicalreadonlyarrays for@IsInvalidators (mirror the shape ofpackages/contracts/src/agents/escalation.types.ts). - Create
packages/contracts/src/email/email.constants.tswith every numeric limit fromplan.md§3.4 — this is the single source of truth for all of them. - Create
packages/contracts/src/email/email.dto.tswithAgentInboxDto,EmailThreadDto,EmailMessageDto,EmailRuleDto,EmailSendingDomainDto,EmailCapMeterDto(ISO date strings on the wire, matchingInboxItemDto). - Create
packages/contracts/src/email/index.ts; export it frompackages/contracts/src/index.tsnext to the existing./inbox/index.jsline. - Done:
pnpm --filter @ever-works/contracts buildis green and every constant inplan.md§3.4 exists exactly once in the repo.
- Create
-
T2
(P1, parallel with T1)Add the email escalation reason code.packages/contracts/src/agents/escalation.types.ts: append'email-escalated'toAgentEscalationReasonCodeand toAGENT_ESCALATION_REASON_CODES(append only — the array order feeds@IsInvalidators; never reorder).- Done: no migration needed (the column is
varchar(32));pnpm --filter @ever-works/contracts type-checkgreen.
-
T3
(P1, parallel with T1)Add the email activity action types.packages/agent/src/entities/activity-log.types.ts: append the 14 members listed inplan.md§3.2 toActivityActionType.- Done: appended, nothing reordered;
actionTypestays a freevarchar(50)so no migration is required — state that in the enum's block comment.
1.2 Entities and migrations
-
T4
(P1)Add the three new entities.packages/agent/src/entities/agent-inbox.entity.ts— fields exactly asplan.md§3.1. No@ManyToOnetoAgent(EW-654 cycle avoidance);@ManyToOnetoTenantEmailAddresswithonDelete: 'RESTRICT'. UsePortableDateColumnfrom./_typesfor every date.packages/agent/src/entities/email-rule.entity.tspackages/agent/src/entities/email-sending-domain.entity.ts- Register all three in four places — missing any one breaks boot:
packages/agent/src/entities/index.ts,packages/agent/src/database/_entities-inventory.ts(concrete per-file import, never the barrel — see that file's header comment),packages/agent/src/database/_entity-names.ts,packages/agent/src/database/_repository-inventory.ts. - Done:
pnpm --filter @ever-works/agent buildgreen;apps/apiboots against a fresh database.
-
T5
(P1)Additive columns on the two existing email tables.packages/agent/src/entities/email-message.entity.ts— add the 16 columns fromplan.md§3.2 and the three new@Indexdeclarations. Update the class JSDoc: the "eithertaskIdORconversationId(never both)" paragraph is superseded — every message now carries aconversationId;taskIdis an additional link. Leave the prompt-injection warning intact.packages/agent/src/entities/email-conversation.entity.ts— add the 10 columns and theidx_email_conversations_inbox_state_lastindex.- Done: entities compile; no existing column touched.
-
T6
(P1)Migration 1 — new tables.cd apps/api && pnpm typeorm migration:generate -d typeorm.config.ts src/migrations/AddAgentInboxesAndEmailRules- Land it as
apps/api/src/migrations/1791050000000-AddAgentInboxesAndEmailRules.ts(AW-05 slot 00, README §5 rule 10). - Hand-edit the generated file to match the house style of
apps/api/src/migrations/1789100000000-AddTaskGraphFanout.ts: a block comment explaining each table and why it is not an existing one, portableTableColumn/TableDDL, existence guards (await queryRunner.getTable(...)), and a realdown(). - Done: applies cleanly on Postgres and on the better-sqlite3 CI stack; re-running is a
no-op;
down()drops only whatup()created.
-
T7
(P1)Migration 2 — message and thread lifecycle columns + status backfill.apps/api/src/migrations/1791050100000-AddEmailMessageLifecycle.ts.- Adds the columns from T5 and the four new indices.
- Backfill, batched at 5,000 rows: inbound →
status='received', outbound →status='sent', outbound →readAt = createdAt. - Done: on a database with existing
email_messagesrows, every row has a non-nullstatusafter the migration and no row's existing data changed.
-
T8
(P1)Migration 3 — thread backfill.apps/api/src/migrations/1791050200000-BackfillEmailThreads.ts.- For every
email_messagesrow withconversationId IS NULL, find-or-create anemail_conversationsrow keyed(agentId, deriveThreadKey(subject))— import the existingderiveThreadKeyfrompackages/agent/src/notifications/agent-inbound-email-dispatcher.ts, do not reimplement it. Then setconversationId, and recomputesubject/messageCount/hasAttachments/lastMessageAt/lastInboundAtper thread. - Batched at 2,000 rows, idempotent (guards on
IS NULL), re-runnable. - Done: after running,
SELECT count(*) FROM email_messages WHERE conversationId IS NULLis 0; running it twice changes nothing; test:apps/api/src/migrations/__tests__/backfill-email-threads.spec.tsseeds 3 messages sharing a subject and asserts one thread withmessageCount = 3.
1.3 Repositories
-
T9
(P1)Repositories for the three new entities.packages/agent/src/database/repositories/agent-inbox.repository.ts—findByAgent,findByIdForUser,findByAddress,listForUser,updateState,casSetState(id, from, to).packages/agent/src/database/repositories/email-rule.repository.ts—listForEvaluation(userId, inboxId, direction)(one query returning both scopes),countForUser,countForInbox,bumpMatch(id, at).packages/agent/src/database/repositories/email-sending-domain.repository.ts—listForUser,findDue(now, limit),countForUser.- Done: each has a
.spec.tsbeside it asserting owner scoping (a foreignuserIdreturns nothing, never someone else's row).
-
T10
(P1)Extend the two existing email repositories.packages/agent/src/database/repositories/email-message.repository.ts— addcasTransition(id, from, to, patch?)returning the affected-row count (this is the primitive every race guard in the epic is built on),countSentInWindow(inboxId, sinceMs),countHeldScheduled(inboxId),listRecipientsInWindow(inboxId, sinceMs),findDueScheduled(now, limit),listForThread(threadId),listStaleDrafts(olderThan).packages/agent/src/database/repositories/email-conversation.repository.ts— addlistThreads(userId, {inboxId, filter, q, limit, cursor})using keyset pagination on(lastMessageAt, id)— neverOFFSET— plusrecomputeCounters(threadId).- Done:
casTransitionhas a spec proving that two concurrent calls with the samefromyield exactly one affected row.
1.4 Domain services
-
T11
(P1)EmailRuleResolver— pure and separately testable.packages/agent/src/email/email-rule-resolver.ts.- Export a pure
resolveRuleVerdict(rules, {address, direction, allowListMode})implementing the score function inplan.md§3.1 (inbox 4 · exact 2 · allow 1), plus an injectableEmailRuleResolverservice that loads rules through the repository and calls it. - Normalise: lower-case, trim, strip display names, treat
@example.comandexample.comas the same domain match. - Test:
packages/agent/src/email/__tests__/email-rule-resolver.spec.ts, table-driven over every case inplan.md§10.1 including S12 and both allow-list modes. - Done: the pure function has zero Nest imports and 100% branch coverage.
-
T12
(P1)EmailSendCapGuard.packages/agent/src/email/email-send-cap-guard.service.tsandpackages/agent/src/email/email-send-cap-exceeded.exception.ts.checkOrThrow({userId, inboxId, recipients, now})evaluates, in this order: recipients-per-message (50) → inbox 60s burst (10) → inbox 5m distinct recipients (20) → inbox rolling 24h (dailySendCap, countingstatus='sent' AND sentAt > now-24hplus every outstandingstatus='scheduled'row, per FR-46) → workspace rolling 24h (500) → workspace rolling 30d (10,000).- On refusal throw
EmailSendCapExceededException(HTTP 429) with thedetailsshape inplan.md§4.6 — model it onpackages/agent/src/budgets/budget-exceeded.exception.ts. - Also expose
getMeter(inboxId)returningEmailCapMeterDtofor the UI, andmarkPausedIfNeeded/clearPauseIfClearedwritingagent_inboxes.capPausedUntil. - Never read a ceiling or a count from anything a model can write (FR-67) — counts come from
email_messages, ceilings fromagent_inboxesand the constants module. - Test:
packages/agent/src/email/__tests__/email-send-cap-guard.spec.tswith a frozen clock, covering every row of the FR-60 table plus reservation hold/release andretryAfterSeconds.
-
T13
(P1)AgentInboxService.packages/agent/src/email/agent-inbox.service.ts.provision(userId, {agentId, localPart?, sendingDomainId?})— idempotent (FR-2); allocates<localPart>@<workspaceSlug>.<EVER_WORKS_AGENT_MAIL_DOMAIN>(defaultagents.ever.works, read viapackages/agent/src/config/); creates the backingtenant_email_addressesrow withdirection='both',verified=true(the platform owns the domain), and bothagent_email_assignmentsrows (inbound + outbound, priority 100) so the existing dispatcher andsendEmailresolution keep working unchanged.update,remove(cancels outstanding scheduled sends),rename(pushes the old address intopreviousAddresseswith a 30-dayexpiresAt, max 5),releaseForArchivedAgent.- Test:
packages/agent/src/email/__tests__/agent-inbox.service.spec.ts— FR-1…FR-9.
-
T14
(P1)EmailDraftService— the state machine.packages/agent/src/email/email-draft.service.ts.submit(input)— the draft gate (plan.md§2.1 seam 2): reads the inbox mode, persists the message, creates theagent_action_proposalsrow (actionType:'send_message', payload{kind:'email-draft', emailMessageId, inboxId, threadId}) throughAgentApprovalsService.createProposal, and either holds or auto-decides.approve(userId, messageId, edit?)— applies the inline edit, pushes the prior version intodraftHistory(cap 10),casTransition('draft' → 'sending'), sends throughEmailFacadeService.send, transitions tosentorfailed.revise(userId, messageId, notes)— incrementsreviseCount(max 5),casTransition('draft' → 'revising'), dispatches a Run via the existingAGENT_TASK_EXECUTE_DISPATCHER.discard,markStaleOnInbound(threadId),expireStaleDrafts(now).- Test:
packages/agent/src/email/__tests__/email-draft.service.spec.ts— every legal transition, every illegal one rejected, the approval race (FR-33), the revise ceiling, the version-history cap.
-
T15
(P1)Emit an approval-decided event and listen for it.packages/agent/src/agent-approvals/agent-approvals.service.ts: emitAgentActionProposalDecidedEventfromdecide()andapproveAll()via an@Optional()injectedEventEmitter2(the patternBudgetGuardServicealready uses). New event class atpackages/agent/src/agent-approvals/agent-action-proposal-decided.event.ts.packages/agent/src/email/email-approval.listener.ts:@OnEvent(...); whenpayload.kind === 'email-draft', approve →EmailDraftService.approve, reject →discard. This is what makes approving from My Decisions send exactly once (FR-23).- Done: existing approvals tests stay green; a new spec proves a decision from either surface sends exactly one message.
-
T16
(P1)EmailEscalationBridge.packages/agent/src/email/email-escalation.service.ts— creates anagent_escalationsrow withreasonCode: 'email-escalated', links it fromemail_messages.escalationIdandemail_conversations.escalationId, and suppresses further agent replies on the thread while it is open (FR-36).- Extend
packages/agent/src/agents/agent-escalation-tools.tsso an Agent handling mail can escalate with a thread reference. - Done: resolving via the existing
POST /api/escalations/:id/resolveclears the thread badge (FR-39) — asserted by a spec.
-
T17
(P1)Wire the cap gate and the outbound rule check into the choke point.packages/agent/src/facades/email.facade.ts: injectEmailSendCapGuardandEmailRuleResolveras@Optional()constructor params (matching the five optional repos already there so bare test contexts keep constructing). At the top ofsend(), beforeresolveOutboundPlugin: evaluate outbound rules against everyto/cc/bccrecipient (throwEmailRecipientBlockedException, HTTP 403), thencheckOrThrow.- New exception at
packages/agent/src/email/email-recipient-blocked.exception.ts. - Test:
packages/agent/src/facades/__tests__/email.facade.cap.spec.tsproves the guard runs before any plugin resolution and that every send path is refused identically (FR-63).
-
T18
(P1)Wire the draft gate into the agent tool path.apps/api/src/agents/agents.module.ts: theAGENT_EMAIL_FACADEadapter callsEmailDraftService.submitinstead ofEmailFacadeService.senddirectly.packages/agent/src/agents/agent-tool.service.tsbuildSendEmailTool: on a held draft return a structured, model-readable result ({held: true, reason: 'awaiting-approval', messageId}); on no inbox returnThis agent has no email inbox. Ask your owner to give it an address.(S22). Update the tooldescriptionto say replies may be held for approval.- Done: an agent-initiated send with the inbox in
draft-reviewmakes zero provider calls — asserted with a mocked facade, not inferred.
-
T19
(P1)Wire the inbound rule check.packages/agent/src/notifications/default-inbound-email-dispatcher.service.ts: as step 1 ofdispatch(), resolve the recipient to anagent_inboxesrow and evaluate inbound rules. Onblock: persist the message withstatus='blocked'+blockedByRuleId, bump the rule's match counters, emitEMAIL_BLOCKED_BY_RULE, and return before any thread write, Task spawn or Run (FR-51).- Also: always resolve/create the thread and set
conversationIdfor inbound messages, regardless of dispatch mode (§2.2), and recordattachmentsMeta(max 25). - Test:
packages/agent/src/notifications/__tests__/default-inbound-email-dispatcher.rules.spec.tsasserts the spawner mock was not called and no thread row was created.
-
T20
(P1)Module wiring.- New
packages/agent/src/email/email-domain.module.tsexporting the five services; imported bypackages/agent/src/facades/and byapps/api/src/email/email.module.ts. - Barrel
packages/agent/src/email/index.ts; add the sub-path export topackages/agent/package.jsonif the package's export map is explicit. - Done:
apps/apiboots;pnpm --filter @ever-works/agent testgreen.
- New
1.5 API surface
-
T21
(P1)AgentInboxController.apps/api/src/email/agent-inbox.controller.ts+dto/agent-inbox.dto.tswith the validators inplan.md§4.1. Six routes.@ThrottleonPOST /.- Register in
apps/api/src/email/email.module.ts. - Test:
apps/api/src/email/agent-inbox.controller.spec.ts— owner scoping, idempotent provisioning,localPartvalidation, the workspace-owner gate onmode/dailySendCap/sendingDomainId.
-
T22
(P1)EmailThreadsController.apps/api/src/email/email-threads.controller.ts+dto/email-thread.dto.ts.GET /with the six filters, keyset cursor and per-filter counts;GET /:id;PATCH /:id.- Test:
apps/api/src/email/email-threads.controller.spec.ts— each filter returns the right set, the cursor round-trips, a foreigninboxIdreturns the same 404 as a missing one.
-
T23
(P1)Message lifecycle routes on the existing controller.apps/api/src/email/email.controller.ts: addapprove,revise,discard,read. Declare them before the existingmessages/:idroute — the file already carries a route-order note formessages/stream; extend that comment.- Add
@Throttle({ long: { ttl: 60_000, limit: 30 } })to the existingPOST /api/email/messages(closing the "no throttle on compose" gap) and optionalsaveAsDrafttoSendMessageInputinapps/api/src/email/email.service.ts. - Test: extend
apps/api/src/email/email.controller.spec.tswith the 409 bodies for the approval race and the revise ceiling, plus a case proving the throttle decorator is present.
-
T24
(P1)EmailRulesController.apps/api/src/email/email-rules.controller.ts+dto/email-rule.dto.ts.- Five routes including
GET /blocked. Workspace-owner gate on writes; activity-log emit on every write (FR-59). Enforce the 500-per-workspace / 200-per-inbox ceilings server-side. - Test:
apps/api/src/email/email-rules.controller.spec.ts.
1.6 Web surface
-
T25
(P1)Route constants and shell entries.apps/web/src/lib/constants.ts: addDASHBOARD_EMAIL,DASHBOARD_EMAIL_THREAD,DASHBOARD_EMAIL_COMPOSE,DASHBOARD_AGENT_INBOX,SETTINGS_EMAIL_DOMAINS.apps/web/src/components/dashboard/DashboardSidebar.tsx: onenavigationentry after the existinginboxentry. Remove nothing.apps/web/src/components/dashboard/SidebarEmailBadge.tsx: copy the structure ofSidebarInboxBadge.tsx(30s poll).apps/web/src/components/agents/AgentDetailTabs.tsx: append theinboxtab — this alone closes the orphan-route gap.- Done: the Agent Inbox page is reachable by clicking, not only by typing a URL.
-
T26
(P1)Data plumbing.apps/web/src/lib/api/agent-inboxes.tsandapps/web/src/lib/api/email-threads.ts— server-onlyserverFetch,X-Scope-Slugattached, mirroringapps/web/src/lib/api/email-addresses.ts.apps/web/src/app/actions/dashboard/email.ts— the read server actions.- BFF route handlers under
apps/web/src/app/api/email/:threads/route.ts,messages/[id]/approve/route.ts,.../revise/route.ts,.../discard/route.ts. Each copies the token handling inapps/web/src/app/api/email/messages/route.tsexactly —getAuthAccessCookie(), 401 on no token, never expose the token to the browser. - Test:
apps/web/src/app/api/email/threads/route.unit.spec.tsasserts the 401 path and that noSet-Cookieleaks.
-
T27
(P1)useEmailStream— the hook the SSE endpoint never got.apps/web/src/components/email/useEmailStream.ts:EventSourceagainst/api/email/messages/stream,mutate()per event, exponential-backoff reconnect (1s → 30s), a 30-second polling fallback whenEventSourceis unavailable or the stream errors twice.- Done: a new inbound message appears within 30s with no manual refresh (FR-20).
-
T28
(P1)The Email screen shell.apps/web/src/app/[locale]/(dashboard)/email/page.tsx(RSC entry) andapps/web/src/components/email/EmailScreen.tsx('use client').InboxSwitcher.tsx,EmailFilterBar.tsx,ThreadList.tsx,ThreadRow.tsx.- URL state
?inbox=&filter=&thread=; six filters; keyset "Load more". - All copy from
dashboard.email.*keys — zero hard-coded strings. - Done: matches the wireframe in
spec.md§6.1 including the per-filter empty states and the skeleton/error states in §6.2.
-
T29
(P1)Thread view and message cards.apps/web/src/components/email/ThreadView.tsx,MessageCard.tsx,SanitizedHtmlBody.tsx(sandboxed<iframe srcDoc>withsandbox="", remote images stripped behind Load images — this is the posture the comments inapps/web/src/components/agents/MessageDetail.tsxalready demand), quoted-history disclosure, the Run link and the sticky cap footer.- Done: no path renders inbound HTML via
dangerouslySetInnerHTML(FR-19).
-
T30
(P1)The draft card and its controls.apps/web/src/components/email/DraftCard.tsx,SendGraceToast.tsx,ReviseDialog.tsx,VersionHistory.tsx.- Inline editor, the four controls, the 5-second Undo grace, the stale banner and its second
confirmation, the
alreadyApproved409 handling, the revision counter. - Done: matches
spec.md§6.3 and §6.4 exactly, including every copy string.
-
T31
(P1)The escalation card.apps/web/src/components/email/EscalationCard.tsx— reason, decision-needed, Dismiss (with its confirmation), Instruct (deep-links into the Agent's chat with the thread referenced), and the Also in My Decisions link.- Done: matches
spec.md§6.5.
-
T32
(P1)Inbox settings.apps/web/src/components/email/InboxSettingsSheet.tsxwith four sections, plusCapMeter.tsxandRulesTable.tsx.- Identity (address editor, previous-address notice), Standing instructions (8,000-char counter, edit history disclosure), Rules & lists (table, allow-list-mode radio, the precedence explainer, the Blocked view link), Sending limits (three bars, the cap field, the workspace line, the "enforced at send" note).
- Auto-send confirmation dialog with the exact copy from
spec.md§6.8. - Done: matches
spec.md§6.8; read-only collaborators see disabled controls with thea11y.readOnlytooltip.
-
T33
(P1)Compose.apps/web/src/app/[locale]/(dashboard)/email/compose/page.tsx+apps/web/src/components/email/ComposeSheet.tsx.- Send-as selector with the live cap line, to/cc/bcc, blocked-recipient inline error, Save as
draft. The legacy
agents/[id]/inbox/composepage andComposer.tsxstay untouched. - Done: matches
spec.md§6.7; a send from here counts against the Agent's cap (FR-34), proven by an e2e assertion on the meter.
-
T34
(P1)Keyboard affordances and accessibility.apps/web/src/components/email/useEmailShortcuts.tsimplementing the full table inspec.md§6.10, with a?overlay listing them.- Status badges carry text, never colour alone; countdown announced at 1h / 10m / 1m only;
full
Taborder. - Done:
apps/web/e2e/accessibility-email.spec.tspasses axe and walks every shortcut.
1.7 i18n, telemetry, tests
-
T35
(P1)i18n keys.- Add the complete
dashboard.email.*tree fromplan.md§8 toapps/web/messages/en.json, plusdashboard.sidebar.navigation.email,dashboard.sidebar.emailUnreadanddashboard.agentsPage.tabs.inbox. - Mirror the key tree into all 20 sibling locale files in
apps/web/messages/(ar, bg, de, es, fr, he, hi, id, it, ja, ko, nl, pl, pt, ru, th, tr, uk, vi, zh). - Hard rule: every leaf key name is camelCase and contains no literal dot. A dotted leaf is rejected by next-intl at runtime and the hydration spec turns that into a five-shard e2e failure.
- Leave
notifications-v2.*untouched. - Done:
pnpm --filter web lintgreen; no missing-key console error in any locale; a grep for"[a-z]+\.[a-z]" *:in the new subtree returns nothing.
- Add the complete
-
T36
(P1)Activity log, notifications and Sentry.- Emit every action type from T3 through
ActivityLogServicewith ids only — never bodies. - Register the six notification events with the FR-83 defaults in
packages/agent/src/notifications/notification.service.tsand the preferences surface. - Sentry tags and breadcrumbs per
plan.md§9.2; assert in a spec that no subject, body or address local part is attached. - Done: a cap refusal and a rule block are both visible in the Activity feed with the actor and the matched rule.
- Emit every action type from T3 through
-
T37
(P1)End-to-end specs, wave one.apps/web/e2e/flow-agent-inbox-provisioning.spec.tsapps/web/e2e/flow-email-draft-approve.spec.tsapps/web/e2e/flow-email-draft-revise.spec.tsapps/web/e2e/flow-email-escalation.spec.tsapps/web/e2e/flow-email-rules-precedence.spec.tsapps/web/e2e/flow-email-send-cap.spec.tsapps/web/e2e/flow-email-unified-view.spec.tsapps/web/e2e/sec-email-inbox-cross-tenant.spec.ts- Use role-based locators sparingly and prefer stable
data-testidfor list rows —*ByRoleis the known flake source in this suite under CI load. - Done: all eight green locally and in CI; the five pre-existing email specs
(
notifications-v2-inbox,flow-agent-inbox-messaging,flow-email-addresses-deep,email-bounce-handling,sec-pin-email-agent-ownership) still green untouched — that is the proof this epic is additive.
-
T38
(P1)P1 close-out.- Update
docs/specs/features/agent-workspace/TRACKER.md: AW-05 specDraft, implPR open. - Run
pnpm format && pnpm lint && pnpm type-check && pnpm test && pnpm build. - Done: all green;
developshippable with drafts, escalations, rules and caps live, and no Schedule button rendered anywhere (P1 hides it rather than disabling it).
- Update
Phase 2 — later and elsewhere
-
T39
(P2)Scheduled-send domain logic.packages/agent/src/email/email-schedule.service.ts—schedule,cancel,reschedule,fire. Enforce the 60-second minimum lead, the 90-day horizon and the 200-outstanding ceiling. Reserve capacity via the existing cap-guard accounting (ascheduledrow already counts — no extra bookkeeping).canceliscasTransition('scheduled' → 'draft'); 0 rows affected → 409TooLate.- Test:
packages/agent/src/email/__tests__/email-schedule.service.spec.tsincluding the cancel/fire race.
-
T40
(P2)Scheduled-send job.- Declare
EMAIL_SCHEDULED_SEND_DISPATCHERinpackages/agent/src/email/email-schedule.service.tsand re-export frompackages/agent/src/facades/index.ts. - Bind it in
packages/tasks/src/trigger/trigger.module.tsusing the exactJobRuntimeProviderRegistryfactory shape theNOTIFICATION_CHANNEL_DELIVERY_DISPATCHERbinding uses, including the?.()opt-out guard. packages/tasks/src/tasks/trigger/email-scheduled-send.task.ts— enqueued withdelay, CAS-guarded, providermessageRefreused.packages/tasks/src/tasks/trigger/email-scheduled-send-sweeper.task.ts— cron*/5 * * * *, re-enqueues anything overdue by 2 minutes.- Register both in
packages/tasks/src/tasks/trigger/index.ts. - No call site imports
@trigger.dev/sdk(Constitution IV). - Done: running the task twice for one message sends once (FR-49), proven by a spec.
- Declare
-
T41
(P2)Scheduled-send API + UI.apps/api/src/email/email.controller.ts:POST /messages/:id/schedule,POST /messages/:id/cancel-send,POST /messages/:id/retry; DTO validators perplan.md§4.3. Controller spec extended.apps/web/src/components/email/ScheduledCard.tsx(onesetIntervalper view, not per card) and theSchedule…control onDraftCard.tsx; the Scheduled filter becomes populated.- Test:
apps/web/e2e/flow-email-scheduled-send.spec.ts.
-
T42
(P2)Sending-domain plugin capability.packages/plugin/src/contracts/capabilities/email-provider.interface.ts: addEmailSendingDomainRecord,EmailSendingDomainStatusand the two optional methods.packages/plugin/src/contracts/facade-capabilities.ts: addEMAIL_SENDING_DOMAINandEMAIL_SCHEDULED_SENDcapability strings.- Implement
describeSendingDomain/verifySendingDomaininpackages/plugins/postmark/src/andpackages/plugins/mailgun/src/, declaring the new capability in eachpackage.jsoneverworks.pluginblock. - Test:
packages/plugins/postmark/src/postmark.sending-domain.spec.tsand the Mailgun equivalent (Vitest) — SPF/DKIM/DMARC/MX records returned, the four states mapped, a provider error becomesfailureReasonrather than a throw. - Done: optional methods mean every other plugin still compiles (Constitution X).
-
T43
(P2)Sending-domain service, endpoints and sweeper.packages/agent/src/email/email-sending-domain.service.ts— add / verify / assign / remove, the 15-minute cadence, the 288-attempt give-up, the 24-hour re-verify of verified domains, and the fallback-on-removal path (FR-74).apps/api/src/email/email-domains.controller.ts+ DTOs +GET /:id/impact.packages/tasks/src/tasks/trigger/email-domain-verify-sweeper.task.ts, cron*/5 * * * *, wrapped inDistributedTaskLockService.runExclusive; registered inpackages/tasks/src/tasks/trigger/index.ts.apps/web/src/app/[locale]/(dashboard)/settings/integrations/email-domains/page.tsx+apps/web/src/components/email/SendingDomainsSettings.tsx— the records table with copy buttons, the purpose explainer, the failed state, the remove-impact dialog.- The domain resolver must never name a plugin: ask the facade, then feature-detect the method;
if absent, render
domains.unsupported. - Test:
apps/api/src/email/email-domains.controller.spec.ts,packages/agent/src/email/__tests__/email-sending-domain.service.spec.ts,apps/web/e2e/flow-email-domain-verify.spec.ts.
-
T44
(P2)The maintenance sweeper.packages/tasks/src/tasks/trigger/email-draft-staleness-sweeper.task.ts, cron17 3 * * *(off-the-hour so it does not collide with the per-minute crons oranonymous-user-cleanup).- Does four things: 7-day draft warning, 14-day auto-discard,
email_rules.matchCount7droll, and expiry of address aliases plus blocked-mail past 30 days. - Registered in
packages/tasks/src/tasks/trigger/index.ts. - Test:
packages/agent/src/email/__tests__/email-maintenance.service.spec.tswith a frozen clock at each boundary.
-
T45
(P2)Fix the tenant-address verification loop.packages/email-templates/src/components/address-verification.tsx+ export frompackages/email-templates/src/index.ts.apps/api/src/email/email.service.tstriggerVerification: persist the token the plugin returns instead of discarding it, and actually send the verification message throughEmailFacadeService.send.- Done: the pre-existing
GET /api/email/verify/:tokenendpoint becomes reachable by a real user for the first time;apps/web/e2e/flow-email-verification-deep.spec.tsextended.
-
T46
(P2)Bounce handling.- In the delivery-event path in
apps/api/src/email/email.controller.ts/packages/agent/src/facades/email.facade.ts: surface the provider reason on the card, count hard bounces per inbox, and at 3 in 24 hours forcemode='draft-review', notify the owner and logEMAIL_MODE_CHANGEDwith the reason. - Done:
apps/web/e2e/email-bounce-handling.spec.tsextended, still green.
- In the delivery-event path in
-
T47
(P2)P2 close-out — tracker, format, lint, type-check, test, build.
Phase 3 — depth
- T48
(P3)describeSendingDomain/verifySendingDomainonpackages/plugins/resend/src/andpackages/plugins/sendgrid/src/. - T49
(P3)DeclareEMAIL_INBOUNDonpackages/plugins/sendgrid/src/via its Inbound Parse API, closing the long-standing inbound-coverage shortfall (2 of 5 providers today). - T50
(P3)One-click DNS publish. ExtendDnsRecordTypeinpackages/plugin/src/contracts/capabilities/dns.interface.tswith'TXT'and'MX', implement inpackages/plugins/cloudflare-dns/src/, and add a Publish for me button to the records table when a DNS connection covers the zone. Additive to the union; audit every existing switch onDnsRecordTypefirst. - T51
(P3)Attachment content: storage, download endpoint, outbound attachments — currently metadata-only perspec.md§7. - T52
(P3)Body search behind a real index rather thanILIKE. - T53
(P3)"Learn from my edits" consolidation against AW-07's memory load meter — cap style facts per Agent and consolidate older ones. Blocked on AW-07 confirming the mechanism. - T54
(P3)Restore action on the Blocked view ("this was not spam, deliver it") with a one-click rule correction.
Definition of done
- Every checkbox above for the phases being shipped.
- All new tests green locally and in CI; all five pre-existing email e2e specs still green, untouched — the additivity proof.
pnpm format:check,pnpm lint,pnpm type-check,pnpm test,pnpm buildall green.- Every
[NEEDS CLARIFICATION: …]inspec.md§9 either answered in the spec or explicitly deferred with a linked follow-up issue. - Every acceptance-criteria line in
spec.md§8 checked off by a reviewer against a running build. docs/specs/features/agent-workspace/TRACKER.mdupdated;spec.mdstatus moved toImplemented;plan.mdandtasks.mdmoved toDone.docs/plugin-system/built-in-plugins.mdupdated with the two new capability strings against the five email providers (Constitution VIII — the canonical doc, and only that doc).