Clean.Email-parity feature build-out plus a Stripe/Notion-style UI rebuild on Tailwind + shadcn-style primitives. Locks the hybrid-automation, Gmail-only, light+dark, incremental-rollout decisions and lays out the backend/frontend build sequence. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
7.0 KiB
Spec: Sender Policy — Block, Whitelist, Screener, Pause, Read-Later, Keep-Newest, Deliver-To
Per-sender ongoing behaviors. These are toggles the user sets from the Senders UI (or
the Screener queue), distinct from the condition-based AutomationRules. They share the
same execution engine and AutomationAction queue/log (feature-rules-engine.md).
1. Domain model
1.1 SenderPolicy (new entity)
One row per sender that has any non-default policy. Created lazily.
public class SenderPolicy : AuditableEntity
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid UserId { get; set; }
public Guid SenderId { get; set; }
public Sender? Sender { get; set; }
public SenderDisposition Disposition { get; set; } = SenderDisposition.None;
// Independent toggles (a sender can be Read-Later AND Keep-Newest, etc.)
public bool ReadLater { get; set; } // route new mail to InboxIntel/Read Later, skip inbox
public bool Paused { get; set; } // hold new mail in InboxIntel/Paused, skip inbox
public int? KeepNewestCount { get; set; } // trash all-but-newest-N (destructive → proposed)
public int? TrashOlderThanDays { get; set; } // per-sender trash-by-age (destructive → proposed)
public string? DeliverToLabel { get; set; } // auto-apply this label to new mail
public bool DeliverToSkipInbox { get; set; } // move vs. tag for Deliver-To
public DateTimeOffset? UpdatedUtc { get; set; }
}
public enum SenderDisposition
{
None = 0,
Allow = 1, // Whitelist — exempt from ALL automation, always reaches inbox
Block = 2, // auto-trash new mail (destructive → proposed)
Screening= 3, // unknown sender quarantined pending approve/block
}
Allow(Whitelist) is checked first in the engine and short-circuits every other rule/policy for that sender.Blockproposes Trash on each new message.Screeningapplies theInboxIntel/Screenerlabel + skip-inbox and surfaces in the Screener queue.
1.2 Managed labels (created via EnsureLabelAsync)
| Feature | Label | On apply |
|---|---|---|
| Screener | InboxIntel/Screener |
+ skip inbox |
| Pause | InboxIntel/Paused |
+ skip inbox |
| Read-Later | InboxIntel/Read Later |
+ skip inbox |
| Deliver-To | user-chosen (any Gmail label) | optional skip inbox |
Whitelist/Block need no label (exempt / trash).
2. Screener semantics
The Screener catches mail from first-seen senders.
- A sender is "known" if the user has ever received mail from them before the
screener was enabled, or has explicitly Allowed/Blocked them. Establish a baseline
ScreenerEnabledUtcon theUserwhen the user turns Screener on, so existing contacts aren't all quarantined retroactively. - During automation, a candidate email from a sender with no prior history before
ScreenerEnabledUtcand no disposition → createSenderPolicy { Disposition = Screening }, applyInboxIntel/Screener+ skip inbox, logAutomationAction(Source=Screener, Applied)(this is a safe action — just labeling/skip-inbox, nothing destroyed). - Screener queue UI: lists screening senders with a sample subject + count.
- Approve →
Disposition = Allow; removeInboxIntel/Screener, restore to inbox for held mail; future mail flows normally. - Block →
Disposition = Block; propose Trash for held mail; future mail auto-proposed for trash.
- Approve →
- Screener is opt-in (a
User.ScreenerEnabledflag, default false) because it actively reroutes mail.
User additions (migration): ScreenerEnabled (bool), ScreenerEnabledUtc (DateTimeOffset?).
3. Pause vs Read-Later vs Deliver-To
All three are safe (label + optional skip-inbox), so they auto-apply:
- Pause: temporarily stop a sender cluttering the inbox without unsubscribing. New
mail →
InboxIntel/Paused+ skip inbox. Resume removes the policy and (optionally) re-inboxes held mail. - Read-Later: newsletters you want to read on your own time →
InboxIntel/Read Later- skip inbox. A "Read Later" view in-app lists them.
- Deliver-To: auto-file a sender's mail under a chosen label (e.g. "Receipts"), optionally skipping the inbox.
4. Block & Keep-Newest & per-sender Trash-by-Age
Destructive → proposed, surfaced in the Review queue:
- Block: each new message from a blocked sender → propose Trash. (We never hard-delete; Gmail Trash auto-purges after 30 days.)
- Keep-Newest / per-sender Trash-by-Age: evaluated in the daily full sweep
(
feature-rules-engine.md§5), proposing Trash for the cull set.
5. Application layer
public interface ISenderPolicyService
{
Task<SenderPolicyDto> GetAsync(Guid userId, Guid senderId, CancellationToken ct = default);
Task<SenderPolicyDto> SetAsync(Guid userId, Guid senderId, SenderPolicyInputDto input, CancellationToken ct = default);
Task ClearAsync(Guid userId, Guid senderId, CancellationToken ct = default);
// Screener
Task<IReadOnlyList<ScreenerEntryDto>> GetScreenerQueueAsync(Guid userId, CancellationToken ct = default);
Task ApproveSenderAsync(Guid userId, Guid senderId, CancellationToken ct = default); // → Allow
Task BlockSenderAsync(Guid userId, Guid senderId, CancellationToken ct = default); // → Block
Task SetScreenerEnabledAsync(Guid userId, bool enabled, CancellationToken ct = default);
}
The engine reads SenderPolicy rows alongside AutomationRules in RunAsync. Policy
evaluation order: Allow (exempt) → Block → Pause → Read-Later → Deliver-To →
Keep-Newest/Trash-by-Age. A whitelisted sender exits immediately.
6. API (SenderPolicyController or extend an existing senders controller)
| Method | Route | Purpose |
|---|---|---|
| GET | /senders/{id}/policy |
current policy |
| PUT | /senders/{id}/policy |
set disposition/toggles |
| DELETE | /senders/{id}/policy |
clear |
| GET | /screener |
screener queue |
| POST | /screener/{senderId}/approve |
whitelist |
| POST | /screener/{senderId}/block |
block + propose trash |
| PUT | /screener/enabled |
enable/disable screener |
7. Frontend
- Senders page: each sender row gets a policy menu (DropdownMenu): Whitelist, Block, Pause, Read-Later, Deliver-To→(label picker), Keep-Newest→(N), Trash-by-Age→(days). Active policies shown as small badges on the row.
- Screener page (
/app/screener): queue of screening senders, Approve/Block per row, bulk approve/block, and a master enable toggle with an explainer. - Read Later view (
/app/read-later): mail taggedInboxIntel/Read Later.
8. Security & safety
- Policy rows verified to belong to
UserIdbefore any read/write. - Block/Keep-Newest/Trash-by-Age only ever propose (hybrid rule) — never auto-trash.
- Whitelist exemption enforced before any other policy/rule, with a test.
- Screener baseline (
ScreenerEnabledUtc) prevents retroactive mass-quarantine. - Resume/Approve restores inbox state from
AutomationAction.UndoStateJson.