be6cbf90d7
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>
149 lines
7.0 KiB
Markdown
149 lines
7.0 KiB
Markdown
# 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 `AutomationRule`s. 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.
|
|
|
|
```csharp
|
|
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. `Block` proposes Trash on each new message. `Screening`
|
|
> applies the `InboxIntel/Screener` label + 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
|
|
`ScreenerEnabledUtc` on the `User` when 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
|
|
`ScreenerEnabledUtc`** and **no disposition** → create `SenderPolicy { Disposition =
|
|
Screening }`, apply `InboxIntel/Screener` + skip inbox, log `AutomationAction(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`; remove `InboxIntel/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.
|
|
- Screener is **opt-in** (a `User.ScreenerEnabled` flag, 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
|
|
|
|
```csharp
|
|
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 `AutomationRule`s 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 tagged `InboxIntel/Read Later`.
|
|
|
|
## 8. Security & safety
|
|
|
|
- [ ] Policy rows verified to belong to `UserId` before 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`.
|