Merge pull request #705 from warmbly/fix/action-required-automated-mail-visibility

feat: keep automated notifications that need the recipient's action in the inbox with an Action required label, view and notification
This commit is contained in:
Matthew Meszaros
2026-09-27 04:45:43 +00:00
committed by GitHub
39 changed files with 889 additions and 126 deletions
+6 -4
View File
@@ -58,6 +58,7 @@ func runInboxTagBackfill(ctx context.Context, args []string) error {
limit := fs.Int("limit", 200, "most messages to classify in this run")
dryRun := fs.Bool("dry-run", false, "list what would be classified and call nothing")
recheck := fs.Bool("recheck-cold-inbound", false, "re-classify mail stored as cold_inbound in threads a campaign send belongs to")
recheckNotices := fs.Bool("recheck-notifications", false, "ask notifications stored before the check whether they need action, and keep the ones that do in the inbox")
if err := fs.Parse(args); err != nil {
return err
}
@@ -109,10 +110,11 @@ func runInboxTagBackfill(ctx context.Context, args []string) error {
start := time.Now()
p, err := svc.Backfill(ctx, orgID, inboxtag.BackfillOptions{
Since: since,
Limit: *limit,
DryRun: *dryRun,
RecheckColdInbound: *recheck,
Since: since,
Limit: *limit,
DryRun: *dryRun,
RecheckColdInbound: *recheck,
RecheckNotifications: *recheckNotices,
OnProgress: func(p inboxtag.BackfillProgress, subject string) {
// One line per message. A long run that goes quiet looks hung, and
// the subject is what tells an operator it is working on real mail
@@ -543,6 +543,7 @@ Auth: Session only (not available to API keys).
"campaign_paused": { "enabled": true, "channels": { "in_app": true, "email": true, "slack": false, "push": true } },
"placement_finished": { "enabled": true, "channels": { "in_app": true, "email": false, "slack": false, "push": true } },
"placement_alert": { "enabled": true, "channels": { "in_app": true, "email": true, "slack": false, "push": true } },
"inbox_action_required": { "enabled": true, "channels": { "in_app": true, "email": true, "slack": false, "push": true } },
"email_digest_minutes": 30
}
}
@@ -577,6 +578,7 @@ Auth: Session only (not available to API keys).
"campaign_paused": { "enabled": true, "channels": { "in_app": true, "email": true, "slack": false, "push": true } },
"placement_finished": { "enabled": true, "channels": { "in_app": true, "email": false, "slack": false, "push": true } },
"placement_alert": { "enabled": true, "channels": { "in_app": true, "email": true, "slack": false, "push": true } },
"inbox_action_required": { "enabled": true, "channels": { "in_app": true, "email": true, "slack": false, "push": true } },
"email_digest_minutes": 30
}
}
@@ -106,11 +106,15 @@ The `unsubscribe` block is the workspace default for the opt-out appended after
`send_time_optimization.enabled` defaults to `false`. Set it to `true` and campaign scheduling holds each send until the recipient's local clock reaches one of `preferred_hours`, resolving the recipient's timezone from the contact's `timezone` custom field, then the country-code suffix of its email domain, then `default_contact_timezone`. It can only delay a send: the campaign window, the mailbox's sending profile, its daily cap, and the campaign end date all still bind. See [Sending behavior](/guides/sending-behavior/).
</Callout>
<Callout type="info" title="Mail that needs action stays in the inbox by default">
`inbox_tagging.action_required_in_inbox` defaults to `true`. With automatic inbox tagging on, each automated notification is also asked whether it needs someone to act (a failed payment, a suspended account, a suspicious sign-in, a sending limit, a service about to expire), and the ones that do stay in the inbox labelled Action required instead of moving to the Automated view. A question in `inbox_tagging.questions` with `automated: true` is also asked of notifications. See [Mail that needs your action](/guides/inbox-tagging/#mail-that-needs-your-action).
</Callout>
## Update outreach settings
`PATCH /outreach/settings`
Replaces the organization's advanced outreach settings with the supplied object. Send the full settings block (the value is upserted, not deep-merged). Returns no body on success.
Replaces the organization's advanced outreach settings with the supplied object. Send the full settings block (the value is upserted, not deep-merged). A field the object omits takes its default, not its previous value. Returns no body on success.
Auth: **Scope** `WRITE_CAMPAIGNS` · **Org permission** `manage_settings`
@@ -78,7 +78,7 @@ Mail over a daily budget is deferred, never dropped: the provider cursor is held
| Judgment | On by | What is sent | Where the verdict lives |
|---|---|---|---|
| Automatic inbox tagging | `INBOX_TAGGING_ENABLED=true` | The inbound subject and bounded plain-text body, plus the previous outbound message in the thread and the name of the campaign it belongs to when one exists, the workspace's own tagging questions, and the names of its tagging languages when any are chosen | `inbox_tag_results`, with the raw probabilities and the actions taken. Completed results travel with the Inbox group in a workspace export; processing claims do not |
| Automatic inbox tagging | `INBOX_TAGGING_ENABLED=true` | The inbound subject and bounded plain-text body (for a notification recognised offline by its sender, only while the workspace asks notifications whether they need action, or asks one of its own questions of them), plus the previous outbound message in the thread and the name of the campaign it belongs to when one exists, the workspace's own tagging questions, and the names of its tagging languages when any are chosen | `inbox_tag_results`, with the raw probabilities and the actions taken. Completed results travel with the Inbox group in a workspace export; processing claims do not |
| Reply classification | The key alone | The subject and body of a reply to a campaign, only when no tagging verdict exists for it and the offline layers could not decide | `campaign_contact_progress.reply_class` and `reply_intent` |
| Copy judgment | The key alone | The subject and body of a campaign step, at Advisor evaluation and on the editor's re-check | `copy_judgments`, keyed by a hash of the copy and never exported |
| Warmup content lint | The key alone | A generated warmup thread before it enters the bank | Rejected threads are counted on the generation job; nothing else is kept |
+34 -7
View File
@@ -9,19 +9,19 @@ Classification sends message content to TypeSafe, so it is opt-in twice: an oper
## What it does
It writes labels and a relevance score for every inbound message, and moves mail nobody wrote (security alerts, notifications, bounces, auto-replies) out of the inbox into the **Automated** view. That part needs nothing from you beyond turning the feature on.
It writes labels and a relevance score for every inbound message, and moves mail nobody wrote (sign-in codes, receipts, notifications, bounces, auto-replies) out of the inbox into the **Automated** view. Automated mail that needs your action, such as a failed payment or a suspended account, stays in the inbox labelled **Action required**. That part needs nothing from you beyond turning the feature on.
It can also act on a confident verdict: hold a contact who said not now, stop a contact who declined, open a task when someone asks for a call, and suppress a sender who asked to be removed. Each of those is its own switch under **Settings > Sending > Classified replies**. The three reversible ones are on from the start; the suppression is off until you turn it on, because it is the one the system cannot undo. See [Acting on a verdict](#acting-on-a-verdict).
## Nothing to set up
The whole label set is created when a workspace is created (and, on an instance that turned the feature on later, within the hour by the follow-up sweep), so the inbox can filter for **Unsubscribe** before anyone has asked. The premade **Views** in the inbox rail (Hot leads, Needs a reply, Follow up, Declined, Automated) are built on those labels and fill as mail is classified; see [Unified inbox](/guides/unibox/). Every label has a hover explanation, so nobody has to learn the taxonomy first.
The whole label set is created when a workspace is created (and, on an instance that turned the feature on later, within the hour by the follow-up sweep), so the inbox can filter for **Unsubscribe** before anyone has asked. The premade **Views** in the inbox rail (Action required, Hot leads, Needs a reply, Follow up, Declined, Automated) are built on those labels and fill as mail is classified; see [Unified inbox](/guides/unibox/). Every label has a hover explanation, so nobody has to learn the taxonomy first.
## The labels
Labels are plain words, so anyone on the team can read them without learning a taxonomy first. They are ordinary workspace labels: the inbox filters on them like any label you made yourself. A workspace that already had a label with the same name keeps it and the automatic one files under it.
**What the message is.** At most one: **Bounced**, **Out of office**, **Auto-reply**, **Notification** or **Sales pitch**. A reply from a person carries no label for being a reply, because nearly every thread is one.
**What the message is.** At most one: **Bounced**, **Out of office**, **Auto-reply**, **Notification** or **Sales pitch**. A reply from a person carries no label for being a reply, because nearly every thread is one. A notification that needs your action also carries **Action required**; see [Mail that needs your action](#mail-that-needs-your-action).
**What a reply wants.** Only on a person's reply:
@@ -44,11 +44,28 @@ The classifier answers more questions than this (whether the sender is the decis
## Automated mail stays out of the inbox
Security alerts, sign-in and verification codes, account and billing notices, receipts, newsletters, bounces and auto-replies are not mail anyone has to answer. When the verdict is that no person wrote a message, its conversation leaves **Inbox**, Unread, every mailbox and tag view and the unread badge, and is listed under the **Automated** view instead. **All mail** still shows everything.
Sign-in and verification codes, routine security, account and billing notices, receipts, newsletters, bounces and auto-replies are not mail anyone has to answer. When the verdict is that no person wrote a message, its conversation leaves **Inbox**, Unread, every mailbox and tag view and the unread badge, and is listed under the **Automated** view instead. **All mail** still shows everything.
Only a confident verdict moves a conversation: a message the classifier was unsure about stays in the inbox. A conversation counts as automated only while every message in it that is not yours was judged automated, so a reply from a person brings it back to the inbox the moment it arrives, before it is even classified. Nothing is moved in the mailbox itself; the provider's folders are untouched.
Common senders and headers (no-reply addresses, bulk mail, out-of-office subjects, delivery failures) are decided offline without a model call. Everything else is judged by the model.
Common senders and headers (no-reply addresses, bulk mail, out-of-office subjects, delivery failures) are decided offline. Everything else is judged by the model.
## Mail that needs your action
Mailboxes used only for campaigns still receive mail a person has to deal with: a failed payment for the mailbox's subscription, a suspended or restricted account, a suspicious sign-in, a sending limit or policy notice from the provider, a domain or service about to expire. Nobody logs into those mailboxes one by one, so if that mail went to Automated with the receipts it would never be seen.
So every notification is also asked one question: does it tell the recipient about a problem with their own account, payment, domain or service that they must act on? When the answer is yes:
- the conversation **stays in the inbox**, and in Unread and the unread badge, instead of moving to Automated
- it is labelled **Action required** (next to **Notification**), and the **Action required** view in the rail lists every such conversation with its unread count
- it sorts with the replies due today when the inbox is sorted by relevance
- members who can both manage mailboxes and use the inbox get a **Mail that needs action** [notification](/guides/notifications/), by email too unless they turned it off. It names the mailbox and links to the conversation but never quotes the message, since mail that claims an account is suspended is also what phishing looks like. It is tied to the message, so reading the message reads the notification
Routine notices are not affected: a sign-in code, a receipt, a newsletter or a "your settings were changed" confirmation still goes to Automated. Archive the conversation once the problem is dealt with. The label is an ordinary label: removing it takes the conversation out of the **Action required** view but leaves it in the inbox.
A notification recognised offline by its sender alone costs one small call for this question and nothing else; a notification the model judged has it in the same call as every other question. If that call fails, the notification is filed under Automated as it was before the check existed, and `--recheck-notifications` (below) asks it later.
It is on by default for every workspace. To turn it off, clear **Keep mail that needs action in the inbox** under **Settings > Sending > Automated mail**; notifications then go to Automated as before and no call is made for them. To file other automated mail your own way, ask one of [your own questions](#your-own-questions) of notifications too.
## Follow-ups: who owes whom a reply
@@ -112,6 +129,8 @@ Write each question the way the classifier reads it: literally. One judgment per
Each label, or each option, can also act, with the same primitives as the switches above: **hold** the contact's sequences for 1 to 365 days, **stop** them for a year, or **open a task** for the mailbox owner. These run whatever the built-in switches say, and follow the same rules: only a confident human reply acts, never a verdict marked **Needs review**, and a suppression ends every other action. A yes/no question labels at 60% and acts at 80%; an option labels and acts at the 70% floor. When two holds apply, the longer one wins.
A question can also be asked of automated notifications: tick **Also ask about automated notifications** on it. Notifications are otherwise never asked your questions, since they are about what a person said. A notification the question matches gets its label and stays in the inbox instead of the Automated view, like one that needs your action. It never holds, stops or opens a task: those still apply to replies only. A notification recognised offline by its sender is asked only these questions and the action check, in one small call.
Your questions ride in the same call as the built-in ones, so they cost input tokens on every classified message but never a second request. A workspace can ask up to 10. They add labels and nothing else to a verdict: the kind, the intent and the relevance score are calibrated on the built-in questions alone and are never moved by yours. The labels are created the first time they apply, or within the hour by the hourly sweep. Questions are part of the workspace settings, so they travel with a [workspace export](/guides/workspace-export-import/).
## Tagging languages
@@ -152,7 +171,7 @@ The campaign is what separates a reply to your outreach from someone pitching yo
This matters more than it sounds. Given only a message body, the model classified one of our own outbound sends as a human reply at 0.94 confidence. The answer was reasonable for the question; the question should never have been asked. Your own sends are filtered out before anything is asked about them.
Common out-of-office subject prefixes and automated sender addresses are checked offline. When one is decisive, no model call is made. The review page shows which verdicts were decided offline and which came from the model.
Common out-of-office subject prefixes and automated sender addresses are checked offline. When one is decisive, the kind is not asked; only a notification is still asked whether it needs your action. The review page shows which verdicts were decided offline and which came from the model.
## Follow-ups without TypeSafe
@@ -197,10 +216,18 @@ warmblyctl inbox-tag backfill --org you@example.com --days 30 --recheck-cold-inb
Only messages stored as a sales pitch in a thread one of your campaign sends belongs to are asked again; nothing else is sent. Any automatic label the old verdict wrote and the new one does not comes off the thread, unless another message in it still carries it. A label a teammate applied by hand is never removed. A message the classifier still reads as a sales pitch with the campaign in front of it keeps that verdict, and is asked about again if you run the command again. Like any backfill, it labels and never acts.
Notifications classified before the action check existed are sitting in Automated without having been asked. To ask them now, so a failed payment among them comes back to the inbox:
```bash
warmblyctl inbox-tag backfill --org you@example.com --days 30 --recheck-notifications
```
Only messages stored as a notification by a verdict that never asked the action check are covered, and each is asked again, so the ones that need action return to the inbox labelled **Action required**. The command refuses when **Keep mail that needs action in the inbox** is off. A notification with no text to read is skipped. A message whose call fails is offered again by the next run, or by a plain backfill. Like any backfill, it labels, moves and never notifies.
## What it costs
One call contains every question for a message and currently uses about 1,300 input tokens on the recorded fixture set. TypeSafe evaluates the questions in parallel, but each question still contributes tokens, so check its current pricing before a large backfill. Output tokens are not billed.
One call contains every question for a message and currently uses about 1,300 input tokens on the recorded fixture set. A notification recognised offline costs one smaller call, for the action check and any of your questions asked of notifications, and nothing when neither is on. TypeSafe evaluates the questions in parallel, but each question still contributes tokens, so check its current pricing before a large backfill. Output tokens are not billed.
A message with no Message-ID is skipped. A database claim prevents two concurrent deliveries of the same Message-ID from making duplicate calls, and an abandoned claim becomes eligible for retry after 15 minutes.
+4 -3
View File
@@ -18,6 +18,7 @@ The feed and the bell count update live, without refreshing.
| Bounce detected | Health | On | A campaign starts bouncing |
| Spam complaint | Health | On | A complaint lands on a campaign |
| Worker downtime | Health | On | A sender worker stops responding |
| Mail that needs action | Health | On, including email | [Automatic inbox tagging](/guides/inbox-tagging/#mail-that-needs-your-action) found automated mail in a mailbox that needs someone to act, such as a failed payment, a suspended account or a suspicious sign-in |
| Campaign auto-paused | Health | On, including email | An [auto-pause guardrail](/guides/campaigns/) stopped a campaign |
| Placement monitor alert | Health | On, including email | A campaign's scheduled [placement test](/guides/placement-tests/#campaign-placement-monitors) landed less of its mail in the primary inbox than the monitor's threshold |
| Placement test finished | Health | On | A [placement test](/guides/placement-tests/) you started has a verdict for every copy |
@@ -30,12 +31,12 @@ The feed and the bell count update live, without refreshing.
Health defaults **on** because those alerts are operationally important and low volume. Inbound defaults **off** because a large campaign would generate one notification per recipient and flood the feed. Turn **Reply received** on only for smaller, higher-touch campaigns.
<Callout type="info" title="Who gets notified">
Workspace events never fan out to everyone. Reply and bounce alerts go to the owner of the mailbox or campaign, worker downtime to members who can manage mailboxes, a finished placement test to whoever started it, a placement monitor alert to members who can view campaigns, billing to those who can manage billing, and team changes to those who can manage the team. The person who joined is not notified about themselves.
Workspace events never fan out to everyone. Reply and bounce alerts go to the owner of the mailbox or campaign, worker downtime to members who can manage mailboxes, mailbox mail that needs action to members who can both manage mailboxes and use the inbox, a finished placement test to whoever started it, a placement monitor alert to members who can view campaigns, billing to those who can manage billing, and team changes to those who can manage the team. The person who joined is not notified about themselves.
</Callout>
## Reply notifications follow the message
## Notifications about a message follow it
A **Reply received** or **Out-of-office detected** notification is about one message in the [Unibox](/guides/unibox/), and it stays in step with it:
A **Reply received**, **Out-of-office detected** or **Mail that needs action** notification is about one message in the [Unibox](/guides/unibox/), and it stays in step with it:
- **Clicking it opens that conversation**, not the inbox in general.
- **Reading the message reads the notification**, wherever you read it: in the Unibox, through the API, or in Gmail or Outlook, which Warmbly picks up on the next sync. A notification read this way is not emailed later either.
+3 -2
View File
@@ -78,15 +78,16 @@ Below those sit the premade **Views**, which answer the questions a pipeline tur
| View | Shows |
| --- | --- |
| Action required | Automated mail that needs someone to act: a failed payment, a suspended account, a suspicious sign-in, a sending limit, a service about to expire |
| Hot leads | Replies labelled Interested, Meeting or Pricing |
| Needs a reply | They wrote last and nobody has answered for two days or more |
| Follow up | You wrote last and heard nothing, or an interested thread went quiet |
| Declined | Not interested, wrong person, or asked to unsubscribe |
| Automated | Mail nobody wrote: security alerts, sign-in codes, notifications, newsletters, bounces and auto-replies |
| Automated | Mail nobody wrote: sign-in codes, receipts, notifications, newsletters, bounces and auto-replies |
A view is a filter over its labels, so a conversation appears in it as soon as a label lands and leaves it when the label does. Without a classifier key on the instance only the two follow-up views fill, from sent-message dates alone.
**Automated** is different: it is where machine mail lives instead of the inbox. When automatic tagging judges that nobody wrote a conversation, it leaves **Inbox**, Unread, every mailbox and tag view, and the unread badge, and shows only under Automated (and in All mail). A message the classifier was not sure about stays in the inbox. A reply from a person brings the conversation straight back, before it is even classified.
**Automated** is different: it is where machine mail lives instead of the inbox. When automatic tagging judges that nobody wrote a conversation, it leaves **Inbox**, Unread, every mailbox and tag view, and the unread badge, and shows only under Automated (and in All mail). A message the classifier was not sure about stays in the inbox. A reply from a person brings the conversation straight back, before it is even classified. Automated mail that needs your action, such as a failed payment for a mailbox's subscription or a suspended account, never goes to Automated: it stays in the inbox labelled **Action required**, and the **Action required** view lists it with its unread count (see [Mail that needs your action](/guides/inbox-tagging/#mail-that-needs-your-action)).
Below the views, the rail lists each mailbox with its unread count, plus your **Labels** and **Tags**. Long lists collapse to the first few with a `Show all` toggle and an inline filter. For a slice of time (today, the last week), use the date range in the filters rather than a view; the list's title shows which view you are in, with how many conversations are loaded.
+11 -1
View File
@@ -18812,7 +18812,7 @@
"patch": {
"operationId": "deliverability-ops_update_settings",
"summary": "Update outreach settings",
"description": "Replaces the organization's advanced outreach settings with the supplied object (upserted, not deep-merged). Returns no body.",
"description": "Replaces the organization's advanced outreach settings with the supplied object (upserted, not deep-merged). A field the object omits takes its default, not its previous value. Returns no body.",
"tags": [
"deliverability-ops"
],
@@ -28692,6 +28692,11 @@
"type": "boolean",
"description": "Add the sender to the suppression list when the classifier is at least 80% sure the reply asks to be removed."
},
"action_required_in_inbox": {
"type": "boolean",
"default": true,
"description": "Ask each automated notification whether it needs the recipient to act (a failed payment, a suspended or restricted account, a suspicious sign-in, a sending limit, a service about to expire) and keep the ones that do in the inbox, labelled Action required, instead of the Automated view."
},
"languages": {
"type": ["array", "null"],
"description": "The languages the workspace's mail is written in, for inbox tagging. Each is named to the classifier, and where Warmbly has its vocabulary, tagging also cuts that language's quoted history and recognises its away messages and bounce notices before asking. Nothing about a language is used until it is listed. Empty reads with the default set only.",
@@ -28727,6 +28732,11 @@
"maxLength": 40,
"description": "yes_no only: the label applied on yes (noul 0.60 or more); its action runs at 0.80 or more. Plain words: letters, digits, and single spaces or hyphens. A built-in label name, in any capitalization, is refused."
},
"automated": {
"type": "boolean",
"default": false,
"description": "Also ask it of automated notifications. A notification it matches gets its label and stays in the inbox instead of the Automated view; it never acts on one."
},
"action": {
"type": "object",
"description": "What a match may do. Only a confident human reply acts.",
+2 -1
View File
@@ -38,7 +38,8 @@ func (h *Handler) UpdateOutreachSettings(c *gin.Context) {
errx.JSON(c, errx.New(errx.BadRequest, "invalid user id"))
return
}
var req models.UpsertOutreachSettingsRequest
// An omitted field takes its default, so an older client cannot switch a default-on setting off.
req := models.UpsertOutreachSettingsRequest{Settings: models.DefaultAdvancedOutreachSettings()}
if err := c.ShouldBindJSON(&req); err != nil {
errx.JSON(c, errx.InvalidBody(err))
return
+2 -2
View File
@@ -185,8 +185,8 @@ func (s *service) notifyAboutMessage(userID uuid.UUID, orgID *uuid.UUID, uniboxE
}()
}
// uniboxThreadLink opens the conversation itself rather than the inbox.
func uniboxThreadLink(threadID string) string {
// UniboxThreadLink opens the conversation itself rather than the inbox.
func UniboxThreadLink(threadID string) string {
if threadID == "" {
return "/app/unibox"
}
+6 -2
View File
@@ -290,7 +290,11 @@ func (s *service) UpdateOrganizationSettings(ctx context.Context, organizationID
if err := settings.Validate(); err != nil {
return errx.NewWithIdentifier(errx.BadRequest, "invalid_setting", err.Error())
}
if err := inboxtag.ValidateQuestions(settings.InboxTagging.Questions); err != nil {
var saved []models.InboxTagQuestion
if current, err := s.repo.GetOutreachSettings(ctx, organizationID); err == nil && current != nil {
saved = current.InboxTagging.Questions
}
if err := inboxtag.ValidateQuestions(settings.InboxTagging.Questions, saved); err != nil {
return errx.NewWithIdentifier(errx.BadRequest, "invalid_setting", err.Error())
}
if err := s.repo.UpsertOutreachSettings(ctx, organizationID, updatedBy, settings); err != nil {
@@ -1567,7 +1571,7 @@ func (s *service) ProcessIncomingReply(ctx context.Context, emailAccountID uuid.
body = "Held until " + held.Format("2 Jan") + " · " + msg.Subject
}
}
s.notifyAboutMessage(uid, account.OrganizationID, msg.ID, cat, title, body, uniboxThreadLink(msg.ThreadID), map[string]any{
s.notifyAboutMessage(uid, account.OrganizationID, msg.ID, cat, title, body, UniboxThreadLink(msg.ThreadID), map[string]any{
"intent": string(intent),
"email_account_id": emailAccountID.String(),
"thread_id": msg.ThreadID,
@@ -0,0 +1,72 @@
package jobs
import (
"context"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/models"
)
type capturedOrgNotice struct {
orgID uuid.UUID
perm models.OrganizationPermission
message uuid.UUID
category models.NotificationCategory
title string
body string
link string
groupKey string
}
type captureOrgNotifier struct{ got chan capturedOrgNotice }
func (c *captureOrgNotifier) NotifyOrg(context.Context, uuid.UUID, models.OrganizationPermission, uuid.UUID, models.NotificationCategory, string, string, string, map[string]any, string) {
}
func (c *captureOrgNotifier) NotifyOrgAboutMessage(_ context.Context, orgID uuid.UUID, perm models.OrganizationPermission, message uuid.UUID, category models.NotificationCategory, title, body, link string, _ map[string]any, groupKey string) {
c.got <- capturedOrgNotice{orgID, perm, message, category, title, body, link, groupKey}
}
// The notice goes to members who both keep mailboxes running and can open the
// message, is tied to the message, and never carries the sender's subject.
func TestNotifyActionRequiredReachesMailboxManagers(t *testing.T) {
n := &captureOrgNotifier{got: make(chan capturedOrgNotice, 1)}
s := &JobsService{Notifier: n}
orgID := uuid.New()
msg := &models.EmailMessageStoreData{
ID: uuid.New(),
EmailID: uuid.New(),
ThreadID: "thread/1",
Subject: "Your account is suspended, verify at evil.example",
}
s.notifyActionRequired(orgID, "sales@acme.test", msg)
var got capturedOrgNotice
select {
case got = <-n.got:
case <-time.After(2 * time.Second):
t.Fatal("no notification raised")
}
if got.orgID != orgID || got.message != msg.ID || got.category != models.NotifInboxActionRequired {
t.Fatalf("notice = %+v", got)
}
if got.perm != models.PermManageEmails|models.PermAccessUnibox {
t.Fatalf("perm = %b, want manage mailboxes and use the inbox", got.perm)
}
if got.title != "Action required in sales@acme.test" || got.link != "/app/unibox/all/thread%2F1" || got.groupKey == "" {
t.Fatalf("notice = %+v", got)
}
if strings.Contains(got.title+got.body, "evil.example") {
t.Fatalf("the sender's subject reached the notification: %+v", got)
}
}
func TestNotifyActionRequiredWithoutNotifierIsQuiet(t *testing.T) {
s := &JobsService{}
s.notifyActionRequired(uuid.New(), "", &models.EmailMessageStoreData{ID: uuid.New()})
}
+1
View File
@@ -20,6 +20,7 @@ import (
// *notification.Service; local interface to avoid an import cycle.
type OrgNotifier interface {
NotifyOrg(ctx context.Context, orgID uuid.UUID, perm models.OrganizationPermission, exclude uuid.UUID, category models.NotificationCategory, title, body, link string, meta map[string]any, groupKey string)
NotifyOrgAboutMessage(ctx context.Context, orgID uuid.UUID, perm models.OrganizationPermission, uniboxEmailID uuid.UUID, category models.NotificationCategory, title, body, link string, meta map[string]any, groupKey string)
}
// OperatorNotifier is the instance-wide operator alert surface, declared here
+34 -16
View File
@@ -702,10 +702,11 @@ func containsSpamFlag(flags []string) bool {
// because they are facts and a question about a fact is a question that can be
// answered confidently and wrongly.
func (s *JobsService) tagInboundMessage(ctx context.Context, e *models.JobEventNewEmail) {
orgID, err := s.orgForMailbox(ctx, e.Message.EmailID)
if err != nil || orgID == uuid.Nil {
account := s.recipientAccount(ctx, e.Message.EmailID)
if account == nil || account.OrganizationID == nil || *account.OrganizationID == uuid.Nil {
return
}
orgID := *account.OrganizationID
// Our previous message in the thread, read from the database rather than
// asked. A reply is an answer, and the question it answers is not in it:
@@ -725,6 +726,9 @@ func (s *JobsService) tagInboundMessage(ctx context.Context, e *models.JobEventN
// Phases 2 and 3: what the verdict may do, per the workspace's switches.
// Live arrivals only; the backfill labels history and never acts on it.
s.actOnInboxTag(ctx, orgID, msg, d)
if d.ActionRequired {
s.notifyActionRequired(orgID, account.Email, e.Message)
}
// Tell the dashboard the message changed.
//
@@ -740,6 +744,34 @@ func (s *JobsService) tagInboundMessage(ctx context.Context, e *models.JobEventN
}
}
// notifyActionRequired tells the members who keep mailboxes running that one
// received mail needing action, since nobody may be reading that mailbox. The
// sender's subject stays out: this mail is phishing-shaped by selection.
func (s *JobsService) notifyActionRequired(orgID uuid.UUID, mailbox string, m *models.EmailMessageStoreData) {
if s.Notifier == nil {
return
}
title := "Action required in a mailbox"
if mailbox != "" {
title = "Action required in " + mailbox
}
notify := func(ctx context.Context) {
s.Notifier.NotifyOrgAboutMessage(ctx, orgID, models.PermManageEmails|models.PermAccessUnibox, m.ID,
models.NotifInboxActionRequired, title,
"An automated message in this mailbox says something needs someone to act. Open it in the inbox.",
advanced.UniboxThreadLink(m.ThreadID), map[string]any{
"email_account_id": m.EmailID.String(),
"thread_id": m.ThreadID,
}, "inbox_action_required:"+m.ID.String())
}
// Off the ingest path: the fan-out is a round trip per member.
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
notify(ctx)
}()
}
// actOnInboxTag executes the actions the tagging policy allows for one
// verdict and records them on the row, so the review page shows the action
// next to the answer that caused it. The policy decides in inboxtag; the
@@ -774,17 +806,3 @@ func (s *JobsService) actOnInboxTag(ctx context.Context, orgID uuid.UUID, msg in
}
log.Info().Str("message_id", msg.MessageID).Strs("actions", done).Msg("inbox tagging acted on a reply")
}
// orgForMailbox resolves the workspace that owns a mailbox. Tagging is scoped
// per workspace (labels, storage, idempotency), so a mailbox with no org is not
// taggable rather than taggable into nowhere.
func (s *JobsService) orgForMailbox(ctx context.Context, accountID uuid.UUID) (uuid.UUID, error) {
if s.EmailRepository == nil {
return uuid.Nil, nil
}
account, xerr := s.EmailRepository.GetByID(ctx, accountID)
if xerr != nil || account == nil || account.OrganizationID == nil {
return uuid.Nil, nil
}
return *account.OrganizationID, nil
}
@@ -0,0 +1,302 @@
package inboxtag
import (
"context"
"errors"
"slices"
"testing"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/repository"
)
// A no-reply notice the header layer decides offline.
func billingNotice() Message {
return Message{
OrganizationID: uuid.New(),
EmailAccountID: uuid.New(),
MessageID: "<billing@google.com>",
ThreadID: "t-billing",
Subject: "Payment declined for your Google Workspace subscription",
BodyText: "We could not charge your card. Update your payment details or your account will be suspended.",
FromAddr: "Google Workspace <payments-noreply@google.com>",
}
}
func noticeSettings(on bool, qs ...models.InboxTagQuestion) fakeSettings {
return fakeSettings{s: models.InboxTaggingSettings{ActionRequiredInInbox: on, Questions: qs}}
}
func invoiceQuestion(automated bool) models.InboxTagQuestion {
return models.InboxTagQuestion{
ID: "q9",
Type: models.InboxTagQuestionYesNo,
Question: "The message is an invoice.",
Label: "Invoice",
Automated: automated,
}
}
type failingAsker struct{ calls int }
func (f *failingAsker) Ask(context.Context, any, map[string]Question) (*Response, error) {
f.calls++
return nil, errors.New("typesafe unavailable")
}
func TestActionRequiredKeepsANotificationInTheInbox(t *testing.T) {
d := Decide(map[string]Answer{
"kind": {Choice: KindNotification, Confidence: 0.95},
SigActionRequired: {Noul: 0.9},
}, Facts{})
if !d.ActionRequired || d.Automated() {
t.Fatalf("action required = %v, automated = %v", d.ActionRequired, d.Automated())
}
if !slices.Equal(d.Labels, []string{"Notification", LabelActionRequired}) {
t.Fatalf("labels = %v", d.Labels)
}
if d.Priority != PriorityToday {
t.Fatalf("priority = %q (relevance %d), want it with the replies due today", d.Priority, d.Relevance)
}
}
func TestRoutineNotificationStaysAutomated(t *testing.T) {
d := Decide(map[string]Answer{SigActionRequired: {Noul: 0.3}}, Facts{DeterministicKind: KindNotification})
if d.ActionRequired || !d.Automated() || slices.Contains(d.Labels, LabelActionRequired) {
t.Fatalf("a receipt was kept in the inbox: %+v", d)
}
if d.SignalStrength[SigActionRequired] != 0.3 {
t.Fatalf("the answer was not recorded: %v", d.SignalStrength)
}
}
// Only a notification is asked: a bounce or an auto-reply is about our own
// sending, and a person's reply already reaches the inbox.
func TestActionRequiredReadsOnlyNotifications(t *testing.T) {
for _, kind := range []string{KindBounceHard, KindAutoReplyOOO, KindAutoReplyTicket, KindHumanReply, KindColdInbound} {
d := Decide(map[string]Answer{SigActionRequired: {Noul: 0.99}}, Facts{DeterministicKind: kind})
if d.ActionRequired || d.KeepInInbox || slices.Contains(d.Labels, LabelActionRequired) {
t.Errorf("%s: %+v", kind, d)
}
}
// An untrusted kind stays in the inbox without the label.
d := Decide(map[string]Answer{
"kind": {Choice: KindNotification, Confidence: 0.4},
SigActionRequired: {Noul: 0.99},
}, Facts{})
if d.ActionRequired || d.Automated() {
t.Fatalf("untrusted kind: %+v", d)
}
}
func TestOfflineNotificationIsAskedOnlyTheActionQuestion(t *testing.T) {
asker := &capturingAsker{resp: Response{Answers: map[string]Answer{SigActionRequired: {Noul: 0.92}}}}
repo := &fakeRepo{}
svc := newService(t, asker, repo)
svc.WireSettings(noticeSettings(true, invoiceQuestion(false)))
d, err := svc.Classify(context.Background(), billingNotice())
if err != nil {
t.Fatalf("classify: %v", err)
}
if len(asker.questions) != 1 {
t.Fatalf("asked %d questions of a notice, want only the action check: %v", len(asker.questions), asker.questions)
}
if _, ok := asker.questions[SigActionRequired]; !ok {
t.Fatal("the action check was not asked")
}
if d.Kind != KindNotification || d.KindSource != "header" || !d.ActionRequired {
t.Fatalf("decision = %+v", d)
}
if len(repo.saved) != 1 || repo.saved[0].Automated || !slices.Contains(repo.saved[0].Labels, LabelActionRequired) {
t.Fatalf("saved = %+v", repo.saved)
}
}
func TestOfflineNotificationWithTheCheckOffMakesNoCall(t *testing.T) {
asker := &countingAsker{}
repo := &fakeRepo{}
svc := newService(t, asker, repo)
svc.WireSettings(noticeSettings(false, invoiceQuestion(false)))
d, err := svc.Classify(context.Background(), billingNotice())
if err != nil {
t.Fatalf("classify: %v", err)
}
if asker.calls != 0 {
t.Fatalf("made %d calls with nothing to ask", asker.calls)
}
if !d.Automated() || len(repo.saved) != 1 || !repo.saved[0].Automated {
t.Fatalf("decision %+v, saved %+v", d, repo.saved)
}
}
// A failed check files the notice as it was filed before the check existed,
// unasked, so an outage does not fill every inbox with newsletters and the
// notification re-check asks it later.
func TestFailedActionCheckFilesTheNoticeUnasked(t *testing.T) {
asker := &failingAsker{}
repo := &fakeRepo{}
svc := newService(t, asker, repo)
d, err := svc.Classify(context.Background(), billingNotice())
if err != nil {
t.Fatalf("classify: %v", err)
}
if asker.calls != 1 || !d.Automated() || len(repo.saved) != 1 || !repo.saved[0].Automated {
t.Fatalf("calls %d, decision %+v, saved %+v", asker.calls, d, repo.saved)
}
if string(repo.saved[0].Answers) != "{}" {
t.Fatalf("answers = %s, want none so the re-check offers it again", repo.saved[0].Answers)
}
}
func TestWorkspaceQuestionAskedOfNotifications(t *testing.T) {
asker := &capturingAsker{resp: Response{Answers: map[string]Answer{
SigActionRequired: {Noul: 0.1},
"custom_q9": {Noul: 0.85},
}}}
repo := &fakeRepo{}
svc := newService(t, asker, repo)
svc.WireSettings(noticeSettings(true, invoiceQuestion(true), laterMaybe(models.InboxTagQuestionAction{})))
d, err := svc.Classify(context.Background(), billingNotice())
if err != nil {
t.Fatalf("classify: %v", err)
}
if _, ok := asker.questions["custom_q1"]; ok {
t.Fatal("a question about replies was asked of a notification")
}
if len(asker.questions) != 2 {
t.Fatalf("questions = %v", asker.questions)
}
if d.ActionRequired || !d.KeepInInbox || d.Automated() || !slices.Contains(d.Labels, "Invoice") {
t.Fatalf("decision = %+v", d)
}
if len(repo.saved) != 1 || repo.saved[0].Automated {
t.Fatalf("saved = %+v", repo.saved)
}
}
// Asked of notifications does not mean asked of bounces, and never acts.
func TestWorkspaceNotificationQuestionScope(t *testing.T) {
stop := invoiceQuestion(true)
stop.Action = models.InboxTagQuestionAction{Type: models.InboxTagActionStop}
custom := []models.InboxTagQuestion{stop}
answers := map[string]Answer{"custom_q9": {Noul: 0.95}}
if d := DecideWith(answers, Facts{DeterministicKind: KindBounceHard}, custom); len(d.Custom) != 0 || d.KeepInInbox {
t.Fatalf("bounce: %+v", d)
}
d := DecideWith(answers, Facts{DeterministicKind: KindNotification}, custom)
if len(d.Custom) != 1 || !d.KeepInInbox {
t.Fatalf("notification: %+v", d)
}
if p := PlanActions(d, allOn()); !p.Empty() {
t.Fatalf("a notification acted: %+v", p)
}
// And it still reads replies as before.
reply := map[string]Answer{"kind": {Choice: KindHumanReply, Confidence: 0.95}, "custom_q9": {Noul: 0.95}}
if d := DecideWith(reply, Facts{}, custom); len(d.Custom) != 1 || d.KeepInInbox {
t.Fatalf("reply: %+v", d)
}
}
func TestFullCallCarriesTheActionCheckOnlyWhenOn(t *testing.T) {
if _, ok := QuestionsFor(nil, true)[SigActionRequired]; !ok {
t.Fatal("on: the action check is missing from the full call")
}
if _, ok := QuestionsFor(nil, false)[SigActionRequired]; ok {
t.Fatal("off: the action check was still asked")
}
if len(NotificationQuestions([]models.InboxTagQuestion{invoiceQuestion(false)}, false)) != 0 {
t.Fatal("off with no notification questions still asks something")
}
}
func TestRecheckNotificationsReclassifiesStoredNotices(t *testing.T) {
asker := &capturingAsker{resp: Response{Answers: map[string]Answer{SigActionRequired: {Noul: 0.9}}}}
n := billingNotice()
repo := &fakeRepo{notices: []repository.BackfillCandidate{{
EmailAccountID: n.EmailAccountID,
MessageID: n.MessageID,
ThreadID: n.ThreadID,
Subject: n.Subject,
BodyText: n.BodyText,
FromAddr: n.FromAddr,
}}}
svc := newService(t, asker, repo)
p, err := svc.Backfill(context.Background(), uuid.New(), BackfillOptions{RecheckNotifications: true})
if err != nil {
t.Fatalf("recheck: %v", err)
}
if p.Classified != 1 || !slices.Equal(repo.reopened, []string{n.MessageID}) {
t.Fatalf("progress %+v, reopened %v", p, repo.reopened)
}
if len(repo.saved) != 1 || repo.saved[0].Automated {
t.Fatalf("saved = %+v", repo.saved)
}
}
// Nothing to read is skipped rather than reopened, or it would come back
// unasked and be offered on every run.
func TestRecheckNotificationsSkipsEmptyNotices(t *testing.T) {
asker := &countingAsker{}
repo := &fakeRepo{notices: []repository.BackfillCandidate{{MessageID: "<empty@notice>", FromAddr: "no-reply@vendor.test"}}}
svc := newService(t, asker, repo)
p, err := svc.Backfill(context.Background(), uuid.New(), BackfillOptions{RecheckNotifications: true})
if err != nil {
t.Fatalf("recheck: %v", err)
}
if p.Skipped != 1 || asker.calls != 0 || len(repo.reopened) != 0 {
t.Fatalf("progress %+v, calls %d, reopened %v", p, asker.calls, repo.reopened)
}
}
func TestRecheckNotificationsRefusesWhenNothingIsAsked(t *testing.T) {
svc := newService(t, &countingAsker{}, &fakeRepo{})
// A question asked of notifications alone does not write the check's answer.
svc.WireSettings(noticeSettings(false, invoiceQuestion(true)))
if _, err := svc.Backfill(context.Background(), uuid.New(), BackfillOptions{RecheckNotifications: true}); !errors.Is(err, ErrNothingToAsk) {
t.Fatalf("err = %v, want ErrNothingToAsk", err)
}
}
// Without the built-in check nothing would re-ask a notice filed unasked, so a
// failed call for the workspace's own questions releases it for a backfill.
func TestFailedNotificationQuestionReleasesWithTheCheckOff(t *testing.T) {
asker := &failingAsker{}
repo := &fakeRepo{}
svc := newService(t, asker, repo)
svc.WireSettings(noticeSettings(false, invoiceQuestion(true)))
m := billingNotice()
if _, err := svc.Classify(context.Background(), m); err == nil {
t.Fatal("a failed call was not reported")
}
if len(repo.saved) != 0 || repo.tagged[m.MessageID] {
t.Fatalf("saved %v, claim kept %v", repo.saved, repo.tagged[m.MessageID])
}
}
// A recheck whose call failed asked nothing, and says so.
func TestRecheckNotificationsCountsAFailedCheckAsFailed(t *testing.T) {
n := billingNotice()
repo := &fakeRepo{notices: []repository.BackfillCandidate{{
EmailAccountID: n.EmailAccountID, MessageID: n.MessageID, ThreadID: n.ThreadID,
Subject: n.Subject, BodyText: n.BodyText, FromAddr: n.FromAddr,
}}}
svc := newService(t, &failingAsker{}, repo)
p, err := svc.Backfill(context.Background(), uuid.New(), BackfillOptions{RecheckNotifications: true})
if err != nil {
t.Fatalf("recheck: %v", err)
}
if p.Failed != 1 || p.Classified != 0 {
t.Fatalf("progress = %+v, want the unasked recheck counted as failed", p)
}
}
+51 -28
View File
@@ -36,9 +36,36 @@ func customKey(id string) string { return customPrefix + id }
func optionKey(i int) string { return "option_" + strconv.Itoa(i+1) }
// QuestionsFor is the built-in set plus the workspace's own.
func QuestionsFor(custom []models.InboxTagQuestion) map[string]Question {
// QuestionsFor is the built-in set plus the workspace's own, and the
// action-required check when the workspace keeps such mail in the inbox.
func QuestionsFor(custom []models.InboxTagQuestion, actionRequired bool) map[string]Question {
q := Questions()
if actionRequired {
q[SigActionRequired] = ActionRequiredQuestion()
}
addCustom(q, custom)
return q
}
// NotificationQuestions is what is asked of a notification decided offline:
// the action-required check and the workspace questions asked of automated
// mail. Empty means nothing is worth a call.
func NotificationQuestions(custom []models.InboxTagQuestion, actionRequired bool) map[string]Question {
q := map[string]Question{}
if actionRequired {
q[SigActionRequired] = ActionRequiredQuestion()
}
var own []models.InboxTagQuestion
for _, c := range custom {
if c.Automated {
own = append(own, c)
}
}
addCustom(q, own)
return q
}
func addCustom(q map[string]Question, custom []models.InboxTagQuestion) {
for _, c := range custom {
switch c.Type {
case models.InboxTagQuestionYesNo:
@@ -52,36 +79,35 @@ func QuestionsFor(custom []models.InboxTagQuestion) map[string]Question {
q[customKey(c.ID)] = Question{Type: QuestionChoice, Instructions: c.Question, Criteria: criteria}
}
}
return q
}
// automatedKind is mail no person wrote. A workspace question is about what
// someone said, so it labels none of these.
func automatedKind(kind string) bool {
switch kind {
case KindBounceHard, KindBounceSoft, KindAutoReplyOOO, KindAutoReplyTicket, KindNotification:
return true
}
return false
}
// decideCustom reads the workspace questions' answers onto a decision.
// decideCustom reads the workspace questions' answers onto a decision. A
// question is about what someone said, so on mail no person wrote only one
// the workspace asked of notifications applies, and a match keeps it in the
// inbox.
func decideCustom(d *Decision, answers map[string]Answer, custom []models.InboxTagQuestion) {
if d.Kind == "" || automatedKind(d.Kind) {
if d.Kind == "" {
return
}
automated := IsAutomatedKind(d.Kind)
seen := map[string]bool{}
for _, l := range d.Labels {
seen[l] = true
}
add := func(m CustomMatch) {
d.Custom = append(d.Custom, m)
if automated {
d.KeepInInbox = true
}
if !seen[m.Label] {
seen[m.Label] = true
d.Labels = append(d.Labels, m.Label)
}
}
for _, c := range custom {
if automated && (!c.Automated || d.Kind != KindNotification) {
continue
}
a, ok := answers[customKey(c.ID)]
if !ok {
continue
@@ -140,22 +166,19 @@ func ReservedLabel(label string) bool {
return false
}
// ValidateQuestions refuses a workspace question whose label the built-in
// taxonomy owns. Shape is checked by the settings model.
func ValidateQuestions(qs []models.InboxTagQuestion) error {
check := func(label string) error {
if ReservedLabel(label) {
return fmt.Errorf("label %q is a built-in tagging label; pick another name", label)
// ValidateQuestions refuses a built-in label name, unless that question was already saved with it.
func ValidateQuestions(qs, saved []models.InboxTagQuestion) error {
kept := map[string]bool{}
for _, q := range saved {
for _, l := range CustomLabels([]models.InboxTagQuestion{q}) {
kept[q.ID+"/"+strings.ToLower(l)] = true
}
return nil
}
for _, q := range qs {
if err := check(q.Label); q.Label != "" && err != nil {
return err
}
for _, c := range q.Choices {
if err := check(c.Label); err != nil {
return err
labels := CustomLabels([]models.InboxTagQuestion{q})
for _, label := range labels {
if ReservedLabel(label) && !kept[q.ID+"/"+strings.ToLower(label)] {
return fmt.Errorf("label %q is a built-in tagging label; pick another name", label)
}
}
}
+22 -4
View File
@@ -42,7 +42,7 @@ func confidentReply() map[string]Answer {
}
func TestQuestionsForAddsWorkspaceQuestions(t *testing.T) {
q := QuestionsFor([]models.InboxTagQuestion{laterMaybe(models.InboxTagQuestionAction{}), roleQuestion()})
q := QuestionsFor([]models.InboxTagQuestion{laterMaybe(models.InboxTagQuestionAction{}), roleQuestion()}, false)
if len(q) != len(Questions())+2 {
t.Fatalf("got %d questions, want the built-in set plus two", len(q))
}
@@ -196,16 +196,16 @@ func TestValidateQuestionsRefusesBuiltInLabels(t *testing.T) {
for _, label := range []string{LabelNeedsReview, "needs review", "SALES PITCH", LabelGoneQuiet} {
q := laterMaybe(models.InboxTagQuestionAction{})
q.Label = label
if err := ValidateQuestions([]models.InboxTagQuestion{q}); err == nil {
if err := ValidateQuestions([]models.InboxTagQuestion{q}, nil); err == nil {
t.Errorf("%q accepted", label)
}
}
c := roleQuestion()
c.Choices[0].Label = "interested"
if err := ValidateQuestions([]models.InboxTagQuestion{c}); err == nil {
if err := ValidateQuestions([]models.InboxTagQuestion{c}, nil); err == nil {
t.Error("a built-in label accepted as a choice")
}
if err := ValidateQuestions([]models.InboxTagQuestion{laterMaybe(models.InboxTagQuestionAction{}), roleQuestion()}); err != nil {
if err := ValidateQuestions([]models.InboxTagQuestion{laterMaybe(models.InboxTagQuestionAction{}), roleQuestion()}, nil); err != nil {
t.Fatalf("valid questions refused: %v", err)
}
}
@@ -386,3 +386,21 @@ func TestRecheckKeepsLabelsWhenNothingWasStored(t *testing.T) {
t.Fatalf("labels removed with no verdict stored: %v", cats.removed)
}
}
// A label saved before the built-in taxonomy took its name keeps working; a
// new question cannot take it.
func TestValidateQuestionsKeepsASavedLabelThatBecameBuiltIn(t *testing.T) {
q := laterMaybe(models.InboxTagQuestionAction{})
q.Label = "Action required"
if err := ValidateQuestions([]models.InboxTagQuestion{q}, []models.InboxTagQuestion{q}); err != nil {
t.Fatalf("saved label refused: %v", err)
}
if err := ValidateQuestions([]models.InboxTagQuestion{q}, []models.InboxTagQuestion{laterMaybe(models.InboxTagQuestionAction{})}); err == nil {
t.Fatal("a new question took a built-in label")
}
moved := q
moved.ID = "q7"
if err := ValidateQuestions([]models.InboxTagQuestion{moved}, []models.InboxTagQuestion{q}); err == nil {
t.Fatal("a saved built-in label moved to a new question")
}
}
+20 -2
View File
@@ -68,13 +68,19 @@ type Decision struct {
// Custom are the workspace questions that fired. Their labels are already
// in Labels; they never move relevance.
Custom []CustomMatch
// ActionRequired is automated mail that needs the recipient to act.
ActionRequired bool
// KeepInInbox is automated mail a check matched, the built-in one or a
// workspace question asked of automated mail, so it stays in the inbox.
KeepInInbox bool
}
// Automated reports a trusted verdict that no person wrote this message. An
// untrusted kind is never automated, so a message the model was unsure about
// stays in the inbox.
// stays in the inbox, and neither is one that needs acting on.
func (d Decision) Automated() bool {
return IsAutomatedKind(d.Kind) && d.ReviewReason != "kind" && !d.Skipped()
return IsAutomatedKind(d.Kind) && !d.KeepInInbox && d.ReviewReason != "kind" && !d.Skipped()
}
// Skipped reports a decision that did nothing because the message was ours.
@@ -170,6 +176,15 @@ func DecideWith(answers map[string]Answer, facts Facts, custom []models.InboxTag
d.Signals = append(d.Signals, id)
}
}
// Asked of a notification only; any other kind already reaches a person.
if a, ok := answers[SigActionRequired]; ok && d.Kind == KindNotification {
d.SignalStrength[SigActionRequired] = a.Noul
if a.Noul >= Yes {
d.Signals = append(d.Signals, SigActionRequired)
d.ActionRequired = true
d.KeepInInbox = true
}
}
sort.Strings(d.Signals)
// ── Scores, normalised to 0..1 ─────────────────────────────────────────
@@ -251,6 +266,9 @@ func labelsFor(d Decision) []string {
}
add(d.Kind)
if d.ActionRequired {
add(SigActionRequired)
}
if d.Kind == KindHumanReply && d.ReviewReason != "intent" {
add(d.Intent)
}
+5 -1
View File
@@ -225,6 +225,7 @@ type fakeRepo struct {
saved []*repository.InboxTagResult
untagged []repository.BackfillCandidate
cold []repository.BackfillCandidate
notices []repository.BackfillCandidate
previous string
campaign string
inReplyTo []string
@@ -271,6 +272,9 @@ func (f *fakeRepo) PreviousOutbound(_ context.Context, _ uuid.UUID, _ string, in
func (f *fakeRepo) ListColdInboundInCampaignThreads(context.Context, uuid.UUID, time.Time, int) ([]repository.BackfillCandidate, error) {
return f.cold, nil
}
func (f *fakeRepo) ListUncheckedNotifications(context.Context, uuid.UUID, time.Time, int) ([]repository.BackfillCandidate, error) {
return f.notices, nil
}
func (f *fakeRepo) Reopen(_ context.Context, _ uuid.UUID, id, _ string) ([]string, error) {
delete(f.tagged, id)
f.reopened = append(f.reopened, id)
@@ -331,7 +335,7 @@ func TestOneCallPerEmailWithAllQuestions(t *testing.T) {
if asker.calls != 1 {
t.Fatalf("made %d calls, want exactly 1", asker.calls)
}
if want := len(Questions()); asker.questions != want {
if want := len(QuestionsFor(nil, true)); asker.questions != want {
t.Fatalf("sent %d questions, want all %d in the one call", asker.questions, want)
}
}
+1 -1
View File
@@ -174,7 +174,7 @@ func TestUnreadableIntentKeepsTheConfidentKind(t *testing.T) {
// the count: a label added here without an explanation there ships with no
// hover text, which is the state the feature started in.
func TestLabelCountMatchesTheDashboardMeanings(t *testing.T) {
const documented = 19 // keep in step with EVERY_AUTOMATIC_LABEL in tagMeanings.test.ts
const documented = 20 // keep in step with EVERY_AUTOMATIC_LABEL in tagMeanings.test.ts
if got := len(AllLabels()); got != documented {
t.Fatalf("AllLabels has %d labels but the dashboard explains %d.\n"+
"Add the new label to web/src/lib/unibox/tagMeanings.ts and to\n"+
+23
View File
@@ -168,6 +168,20 @@ var signalInstructions = map[string]string{
SigNeedsHumanJudgement: "Answering this message well requires a person to read it.",
}
// SigActionRequired is the one question asked of automated mail: whether it
// needs the recipient to act. A match keeps the conversation in the inbox.
const SigActionRequired = "action_required"
const actionRequiredInstruction = "The message tells the recipient about a problem with their own account, " +
"payment, domain or service that they must act on, such as a failed payment, a suspended or restricted " +
"account, a suspicious sign-in, a sending limit, or a service about to expire."
// ActionRequiredQuestion is that question, sent only when the workspace keeps
// such mail in the inbox.
func ActionRequiredQuestion() Question {
return Question{Type: QuestionNoul, Instructions: actionRequiredInstruction}
}
// Scores are ordered rubrics. Ten levels is the API maximum; eleven is a 400.
// The score value is used for threshold checks only, never as a magnitude to
// do arithmetic between levels with.
@@ -219,6 +233,9 @@ var Weights = map[string]float64{
IntentNotInterested: -40,
"auto_reply": -60, // either auto_reply_* kind
"bounce": -80, // either bounce_* kind
// Automated mail that needs acting on sorts with the replies due today.
SigActionRequired: 40,
}
// Priority buckets, applied to the clamped 0-100 relevance.
@@ -254,6 +271,10 @@ func bucket(relevance float64) string {
// the one label that means "the system declined to decide".
const LabelNeedsReview = "Needs review"
// LabelActionRequired marks automated mail that needs the recipient to act,
// which is why it stayed in the inbox.
const LabelActionRequired = "Action required"
// labelTitles is the label a person reads for each answer, in plain words a
// non-native speaker understands. An answer missing here is recorded but never
// becomes a label, because it would sit on most threads and filter nothing.
@@ -280,6 +301,8 @@ var labelTitles = map[string]string{
SigAsksForCall: "Meeting",
SigRequestsRemoval: "Unsubscribe",
SigLegalThreat: "Legal threat",
SigActionRequired: LabelActionRequired,
}
// LabelFor is the label an answer files under, or "" when it has none.
+72 -8
View File
@@ -102,22 +102,29 @@ func (s *Service) WireSettings(src SettingsSource) {
type workspaceSettings struct {
questions []models.InboxTagQuestion
languages []string
// actionRequired asks whether automated mail needs the recipient to act.
actionRequired bool
}
// workspace reads them. A failed read costs the workspace's additions for this
// message, never the classification.
// message, never the classification, and keeps the defaults.
func (s *Service) workspace(ctx context.Context, orgID uuid.UUID) workspaceSettings {
defaults := workspaceSettings{actionRequired: models.DefaultAdvancedOutreachSettings().InboxTagging.ActionRequiredInInbox}
if s.settings == nil || orgID == uuid.Nil {
return workspaceSettings{}
return defaults
}
cfg, err := s.settings.GetOutreachSettings(ctx, orgID)
if err != nil || cfg == nil {
if err != nil {
log.Warn().Err(err).Str("org_id", orgID.String()).Msg("inbox tagging: workspace settings not read; built-in set only")
}
return workspaceSettings{}
return defaults
}
return workspaceSettings{
questions: cfg.InboxTagging.Questions,
languages: cfg.InboxTagging.Languages,
actionRequired: cfg.InboxTagging.ActionRequiredInInbox,
}
return workspaceSettings{questions: cfg.InboxTagging.Questions, languages: cfg.InboxTagging.Languages}
}
func (s *Service) Enabled() bool {
@@ -182,7 +189,8 @@ func (s *Service) Classify(ctx context.Context, m Message) (Decision, error) {
var custom []models.InboxTagQuestion
var resp *Response
if facts.DeterministicKind == "" {
switch {
case facts.DeterministicKind == "":
if !HasContent(state) {
release()
return Decision{}, nil
@@ -191,11 +199,27 @@ func (s *Service) Classify(ctx context.Context, m Message) (Decision, error) {
state.Language = LanguageHint(ws.languages)
// 4. Send every question, the workspace's own included, in one request
// to avoid repeated state ingest.
resp, err = s.asker.Ask(ctx, state, QuestionsFor(custom))
resp, err = s.asker.Ask(ctx, state, QuestionsFor(custom, ws.actionRequired))
if err != nil {
release()
return Decision{}, err
}
case facts.DeterministicKind == KindNotification && HasContent(state):
// A notice decided offline is still asked whether it needs acting on.
if qs := NotificationQuestions(ws.questions, ws.actionRequired); len(qs) > 0 {
custom = ws.questions
state.Language = LanguageHint(ws.languages)
resp, err = s.asker.Ask(ctx, state, qs)
if err != nil && !ws.actionRequired {
release()
return Decision{}, err
}
if err != nil {
// Filed as before the check existed; --recheck-notifications asks it later.
log.Warn().Err(err).Str("message_id", m.MessageID).Msg("inbox tagging: action check failed; notification filed unasked")
resp = nil
}
}
}
answers := map[string]Answer{}
@@ -424,8 +448,16 @@ type BackfillOptions struct {
// that belong to a campaign, instead of untagged mail. Those verdicts were
// made without the campaign in the state.
RecheckColdInbound bool
// RecheckNotifications asks stored notifications whether they need acting
// on, instead of untagged mail. Those verdicts were made before the
// question existed, so a failed payment among them sits in Automated.
RecheckNotifications bool
}
// ErrNothingToAsk refuses a notification re-check for a workspace that asks
// automated mail nothing.
var ErrNothingToAsk = errors.New("this workspace does not ask notifications whether they need action: turn on \"Keep mail that needs action in the inbox\" under Settings > Sending first")
// Backfill classifies historical inbound mail that has never been tagged.
//
// It exists because a classifier that only sees new arrivals is useless on the
@@ -446,11 +478,24 @@ func (s *Service) Backfill(ctx context.Context, orgID uuid.UUID, opts BackfillOp
opts.Limit = 200
}
if opts.RecheckColdInbound && opts.RecheckNotifications {
return p, errors.New("re-check cold inbound mail and notifications in separate runs")
}
var candidates []repository.BackfillCandidate
var langs []string
var err error
if opts.RecheckColdInbound {
switch {
case opts.RecheckColdInbound:
candidates, err = s.repo.ListColdInboundInCampaignThreads(ctx, orgID, opts.Since, opts.Limit)
} else {
case opts.RecheckNotifications:
ws := s.workspace(ctx, orgID)
if !ws.actionRequired {
return p, ErrNothingToAsk
}
langs = ws.languages
candidates, err = s.repo.ListUncheckedNotifications(ctx, orgID, opts.Since, opts.Limit)
default:
candidates, err = s.repo.ListUntagged(ctx, orgID, opts.Since, opts.Limit)
}
if err != nil {
@@ -503,6 +548,22 @@ func (s *Service) Backfill(ctx context.Context, orgID uuid.UUID, opts BackfillOp
continue
}
}
if opts.RecheckNotifications {
// Nothing to read is never asked, so reopening it would loop forever.
if !HasContent(BuildState(c.Subject, c.BodyText, "", "", langs...)) {
p.Skipped++
if opts.OnProgress != nil {
opts.OnProgress(p, c.Subject)
}
continue
}
stale, err = s.repo.Reopen(ctx, orgID, c.MessageID, KindNotification)
if err != nil {
p.Failed++
log.Warn().Err(err).Str("message_id", c.MessageID).Msg("inbox tagging recheck: verdict not reopened")
continue
}
}
d, err := s.Classify(ctx, Message{
OrganizationID: orgID,
@@ -528,10 +589,13 @@ func (s *Service) Backfill(ctx context.Context, orgID uuid.UUID, opts BackfillOp
log.Warn().Err(rerr).Str("thread_id", c.ThreadID).Msg("inbox tagging recheck: stale labels kept")
}
}
_, asked := d.SignalStrength[SigActionRequired]
switch {
case err != nil:
p.Failed++
log.Warn().Err(err).Str("message_id", c.MessageID).Msg("inbox tagging backfill: message failed")
case opts.RecheckNotifications && stored && d.Kind == KindNotification && !asked:
p.Failed++
case d.Skipped() || d.KindSource == "":
p.Skipped++
default:
+2
View File
@@ -231,6 +231,8 @@ func digestTitle(category models.NotificationCategory, n int) string {
return fmt.Sprintf("%d placement tests finished", n)
case models.NotifPlacementAlert:
return fmt.Sprintf("%d placement alerts", n)
case models.NotifInboxActionRequired:
return fmt.Sprintf("%d messages need action", n)
default:
return fmt.Sprintf("%d new notifications", n)
}
+16 -1
View File
@@ -64,6 +64,8 @@ type Service interface {
// message with every recipient in To; Slack fires at most once. Each
// member's own preferences still gate their channels. Best-effort.
NotifyOrg(ctx context.Context, orgID uuid.UUID, perm models.OrganizationPermission, exclude uuid.UUID, category models.NotificationCategory, title, body, link string, meta map[string]any, groupKey string)
// NotifyOrgAboutMessage is NotifyOrg for one unibox message.
NotifyOrgAboutMessage(ctx context.Context, orgID uuid.UUID, perm models.OrganizationPermission, uniboxEmailID uuid.UUID, category models.NotificationCategory, title, body, link string, meta map[string]any, groupKey string)
// WireDelivery attaches the email + Slack + user/member-lookup
// dependencies (wired post-construction in both mains) and starts the
@@ -172,6 +174,19 @@ func (s *service) NotifyAboutMessage(ctx context.Context, userID uuid.UUID, orgI
// notification for each member. Slack posts to one org workspace, so it fires
// for the first member whose prefs allow it and stays suppressed for the rest.
func (s *service) NotifyOrg(ctx context.Context, orgID uuid.UUID, perm models.OrganizationPermission, exclude uuid.UUID, category models.NotificationCategory, title, body, link string, meta map[string]any, groupKey string) {
s.notifyMembers(ctx, orgID, perm, exclude, nil, category, title, body, link, meta, groupKey)
}
// NotifyOrgAboutMessage is NotifyOrg for one unibox message, so each member's
// notification leaves with the message and is read with it.
func (s *service) NotifyOrgAboutMessage(ctx context.Context, orgID uuid.UUID, perm models.OrganizationPermission, uniboxEmailID uuid.UUID, category models.NotificationCategory, title, body, link string, meta map[string]any, groupKey string) {
if uniboxEmailID == uuid.Nil {
return
}
s.notifyMembers(ctx, orgID, perm, uuid.Nil, &uniboxEmailID, category, title, body, link, meta, groupKey)
}
func (s *service) notifyMembers(ctx context.Context, orgID uuid.UUID, perm models.OrganizationPermission, exclude uuid.UUID, uniboxEmailID *uuid.UUID, category models.NotificationCategory, title, body, link string, meta map[string]any, groupKey string) {
if s == nil || s.members == nil || orgID == uuid.Nil {
return
}
@@ -188,7 +203,7 @@ func (s *service) NotifyOrg(ctx context.Context, orgID uuid.UUID, perm models.Or
if perm != 0 && !m.Permissions.HasPermission(perm) {
continue
}
fired := s.notifyOne(ctx, m.UserID, &org, nil, category, title, body, link, meta, groupKey, slackFired)
fired := s.notifyOne(ctx, m.UserID, &org, uniboxEmailID, category, title, body, link, meta, groupKey, slackFired)
slackFired = slackFired || fired
}
}
+10
View File
@@ -88,6 +88,10 @@ type InboxTaggingSettings struct {
// MailLanguageNames codes. Tagging reads each with its vocabulary and names
// it to the classifier; empty uses the default set only.
Languages []string `json:"languages"`
// ActionRequiredInInbox asks whether an automated notification needs the
// recipient to act (a failed payment, a suspended account) and keeps the
// ones that do in the inbox, labelled, instead of the Automated view.
ActionRequiredInInbox bool `json:"action_required_in_inbox"`
}
// InboxTagQuestion is one workspace-defined tagging question. A yes/no
@@ -101,6 +105,9 @@ type InboxTagQuestion struct {
Label string `json:"label,omitempty"`
Action InboxTagQuestionAction `json:"action"`
Choices []InboxTagChoice `json:"choices,omitempty"`
// Automated also asks it of automated notifications; a match labels the
// conversation and keeps it in the inbox. It never acts on one.
Automated bool `json:"automated,omitempty"`
}
// InboxTagChoice is one option of a choice question.
@@ -1070,6 +1077,9 @@ func DefaultAdvancedOutreachSettings() AdvancedOutreachSettings {
NotNowHoldDays: NotNowHoldDaysDefault,
StopOnDeclined: true,
TaskOnCallRequest: true,
// Reversible and the point of the Automated view: mail that
// keeps a mailbox alive is never filed away with the receipts.
ActionRequiredInInbox: true,
},
SendTimeOptimization: SendTimeOptimizationSettings{
// Off by default: turning it on delays sends to reach the
+24 -13
View File
@@ -34,6 +34,10 @@ const (
// NotifPlacementAlert fires when a campaign's scheduled placement test
// comes back below its alert threshold, so it emails by default.
NotifPlacementAlert NotificationCategory = "placement_alert"
// NotifInboxActionRequired fires when automatic tagging finds automated
// mail in a mailbox that needs someone to act, such as a failed payment or
// a suspended account, so it emails by default.
NotifInboxActionRequired NotificationCategory = "inbox_action_required"
)
// ChannelPrefs is the per-category delivery toggles: in-app feed, account
@@ -67,6 +71,8 @@ type NotificationPreferences struct {
// PlacementFinished and PlacementAlert are the inbox placement test pair.
PlacementFinished CategoryPref `json:"placement_finished"`
PlacementAlert CategoryPref `json:"placement_alert"`
// InboxActionRequired is mailbox mail that needs someone to act.
InboxActionRequired CategoryPref `json:"inbox_action_required"`
// EmailDigestMinutes is the email-channel bundling window: pending
// notification emails hold this long, then flush as one email. Bounded
@@ -94,19 +100,22 @@ func DefaultNotificationPreferences() NotificationPreferences {
// whoever can edit the DNS, so this one emails by default too.
domainAuth := CategoryPref{Enabled: true, Channels: ChannelPrefs{InApp: true, Push: true, Email: true}}
return NotificationPreferences{
InboundReply: off,
InboundOOO: off,
HealthBounce: on,
HealthComplaint: on,
WorkerDowntime: on,
SecuritySignIn: on,
BillingAlert: billing,
TeamActivity: on,
CampaignPaused: campaignPaused,
DomainAuth: domainAuth,
PlacementFinished: on,
PlacementAlert: domainAuth,
EmailDigestMinutes: 30,
InboundReply: off,
InboundOOO: off,
HealthBounce: on,
HealthComplaint: on,
WorkerDowntime: on,
SecuritySignIn: on,
BillingAlert: billing,
TeamActivity: on,
CampaignPaused: campaignPaused,
DomainAuth: domainAuth,
PlacementFinished: on,
PlacementAlert: domainAuth,
// A mailbox about to lose its subscription has to reach whoever can
// fix it even when nobody reads the inbox, so this emails too.
InboxActionRequired: domainAuth,
EmailDigestMinutes: 30,
}
}
@@ -137,6 +146,8 @@ func (p NotificationPreferences) CategoryPref(c NotificationCategory) CategoryPr
return p.PlacementFinished
case NotifPlacementAlert:
return p.PlacementAlert
case NotifInboxActionRequired:
return p.InboxActionRequired
default:
return CategoryPref{}
}
@@ -187,3 +187,40 @@ func TestLiveInboxTagRemoveAutoLabels(t *testing.T) {
t.Fatal("removed a label a person applied")
}
}
// Only notifications whose verdict never asked the action check are offered
// again; one that answered it, and any other kind, are left alone.
func TestLiveInboxTagUncheckedNotifications(t *testing.T) {
f := newInboxTagCampaignFixture(t)
ctx := context.Background()
now := time.Now().UTC()
for _, id := range []string{"<old@notice>", "<asked@notice>", "<bounce@notice>"} {
f.unibox(f.mailbox, "inbox", id, "t"+id, "Billing <no-reply@vendor.test>", nil, now.Add(-time.Hour))
}
f.verdict("<old@notice>", "t<old@notice>", "notification", []string{"Notification"})
f.verdict("<asked@notice>", "t<asked@notice>", "notification", []string{"Notification"})
f.exec(`UPDATE inbox_tag_results SET answers = '{"action_required": {"noul": 0.1}}' WHERE organization_id = $1 AND message_id = '<asked@notice>'`, f.org)
f.verdict("<bounce@notice>", "t<bounce@notice>", "bounce_hard", []string{"Bounced"})
got, err := NewInboxTagRepository(f.pool).ListUncheckedNotifications(ctx, f.org, now.Add(-24*time.Hour), 10)
if err != nil {
t.Fatalf("list: %v", err)
}
var ids []string
for _, c := range got {
ids = append(ids, c.MessageID)
}
if !slices.Equal(ids, []string{"<old@notice>"}) {
t.Fatalf("offered %v, want only the notice never asked", ids)
}
// Reopening it brings the message back to the inbox until it is judged again.
f.exec(`UPDATE unibox_emails SET automated = true WHERE message_id = '<old@notice>' AND email_id = $1`, f.mailbox)
if _, err := NewInboxTagRepository(f.pool).Reopen(ctx, f.org, "<old@notice>", "notification"); err != nil {
t.Fatalf("reopen: %v", err)
}
var automated bool
if err := f.pool.QueryRow(ctx, `SELECT automated FROM unibox_emails WHERE message_id = '<old@notice>' AND email_id = $1`, f.mailbox).Scan(&automated); err != nil || automated {
t.Fatalf("automated = %v (%v), want the reopened message back in the inbox", automated, err)
}
}
+29 -3
View File
@@ -60,6 +60,9 @@ type InboxTagRepository interface {
// verdicts made before the campaign behind a thread could be resolved.
ListColdInboundInCampaignThreads(ctx context.Context, orgID uuid.UUID, since time.Time, limit int) ([]BackfillCandidate, error)
Reopen(ctx context.Context, orgID uuid.UUID, messageID, kind string) ([]string, error)
// ListUncheckedNotifications backs the re-check of notifications stored
// before they were asked whether they need acting on.
ListUncheckedNotifications(ctx context.Context, orgID uuid.UUID, since time.Time, limit int) ([]BackfillCandidate, error)
// ThreadStates backs the follow-up sweep: who spoke last, when, and how far
// the thread ever got.
@@ -338,6 +341,18 @@ func (r *inboxTagRepository) ListColdInboundInCampaignThreads(ctx context.Contex
)`, orgID, since, limit)
}
// ListUncheckedNotifications returns inbound messages stored as notifications
// by a verdict that never asked whether they need the recipient to act.
func (r *inboxTagRepository) ListUncheckedNotifications(ctx context.Context, orgID uuid.UUID, since time.Time, limit int) ([]BackfillCandidate, error) {
return r.listCandidates(ctx, `
AND EXISTS (
SELECT 1 FROM inbox_tag_results r
WHERE r.organization_id = $1 AND r.message_id = ue.message_id
AND r.status = 'complete' AND r.kind = 'notification'
AND NOT (r.answers ? 'action_required')
)`, orgID, since, limit)
}
func (r *inboxTagRepository) listCandidates(ctx context.Context, filter string, orgID uuid.UUID, since time.Time, limit int) ([]BackfillCandidate, error) {
q := `
SELECT ue.email_id, ue.user_id, ue.message_id, ue.thread_id,
@@ -378,12 +393,23 @@ func (r *inboxTagRepository) listCandidates(ctx context.Context, filter string,
// Reopen drops one stored verdict of the given kind so the message can be
// classified again, and returns the labels it had written. Only a complete
// verdict of that kind is touched.
// The message returns to the inbox with it, so a failed re-classification leaves it visible.
func (r *inboxTagRepository) Reopen(ctx context.Context, orgID uuid.UUID, messageID, kind string) ([]string, error) {
var labels []string
err := r.db.QueryRow(ctx, `
DELETE FROM inbox_tag_results
WHERE organization_id = $1 AND message_id = $2 AND status = 'complete' AND kind = $3
RETURNING labels
WITH dropped AS (
DELETE FROM inbox_tag_results
WHERE organization_id = $1 AND message_id = $2 AND status = 'complete' AND kind = $3
RETURNING email_account_id, message_id, labels
), shown AS (
UPDATE unibox_emails ue
SET automated = false
FROM dropped
WHERE ue.email_id = dropped.email_account_id
AND ue.message_id = dropped.message_id
AND ue.automated
)
SELECT labels FROM dropped
`, orgID, messageID, kind).Scan(&labels)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
+17 -16
View File
@@ -30,22 +30,23 @@ func NewTagCategoryStore(db *pgxpool.Pool) *TagCategoryStore {
// tagColors gives each family a colour so the labels read as a set in the
// inbox rather than a pile of identical chips. Anything unlisted gets slate.
var tagColors = map[string]string{
"Bounced": "#b91c1c",
"Out of office": "#a16207",
"Auto-reply": "#a16207",
"Notification": "#64748b",
"Sales pitch": "#7c3aed",
"Interested": "#15803d",
"Meeting": "#15803d",
"Pricing": "#0d9488",
"Question": "#0284c7",
"Update": "#0284c7",
"Not now": "#a16207",
"Not interested": "#9f1239",
"Wrong person": "#7c3aed",
"Unsubscribe": "#b91c1c",
"Legal threat": "#b91c1c",
"Needs review": "#c2410c",
"Bounced": "#b91c1c",
"Out of office": "#a16207",
"Auto-reply": "#a16207",
"Notification": "#64748b",
"Action required": "#dc2626",
"Sales pitch": "#7c3aed",
"Interested": "#15803d",
"Meeting": "#15803d",
"Pricing": "#0d9488",
"Question": "#0284c7",
"Update": "#0284c7",
"Not now": "#a16207",
"Not interested": "#9f1239",
"Wrong person": "#7c3aed",
"Unsubscribe": "#b91c1c",
"Legal threat": "#b91c1c",
"Needs review": "#c2410c",
// Follow-up states, warm to cold as the silence lengthens.
"Needs reply": "#be123c",
"Follow up": "#c2410c",
@@ -26,6 +26,7 @@ const HEALTH: { key: NotificationCategoryKey; label: string; hint: string }[] =
{ key: "health_bounce", label: "Bounce detected", hint: "A campaign starts bouncing — notifies the campaign owner." },
{ key: "health_complaint", label: "Spam complaint", hint: "Any complaint event on one of your campaigns." },
{ key: "health_worker_downtime", label: "Worker downtime", hint: "A sender worker stops responding." },
{ key: "inbox_action_required", label: "Mail that needs action", hint: "A mailbox received automated mail that needs someone to act, like a failed payment, a suspended account or a suspicious sign-in. Goes to members who manage mailboxes and use the inbox." },
{ key: "health_domain_auth", label: "Domain authentication failing", hint: "A sending domain lost its SPF or DMARC record. Cold sending and warmup stop from it if it is not fixed." },
{ key: "campaign_paused", label: "Campaign auto-paused", hint: "A guardrail stopped a campaign because its bounce, complaint, or reply rate left the band." },
{ key: "placement_alert", label: "Placement monitor alert", hint: "A campaign's scheduled placement test found less of its mail in the inbox than its alert threshold." },
@@ -112,6 +113,7 @@ export default function NotificationsSettingsPage() {
"health_complaint",
"health_worker_downtime",
"health_domain_auth",
"inbox_action_required",
"campaign_paused",
"placement_alert",
"placement_finished",
@@ -5,6 +5,7 @@
import React from "react";
import { PencilIcon, PlusIcon, Trash2Icon, XIcon } from "lucide-react";
import { NumberInput, TextInput } from "@/components/ui/field";
import { Checkbox } from "@/components/ui/checkbox";
import { SelectMenu, type SelectOption } from "@/components/ui/select-menu";
import { useConfirm } from "@/hooks/context/confirm";
import { isAutomaticTag } from "@/lib/unibox/tagMeanings";
@@ -73,8 +74,9 @@ function actionSummary(a: InboxTagQuestionAction): string {
}
// problems mirrors the server's checks, so Save explains a refusal instead of
// the autosave failing after the fact.
function problems(q: InboxTagQuestion, taken: Set<string>): string[] {
// the autosave failing after the fact. kept holds the labels the question was
// saved with, which stay valid even if a built-in label took the name since.
function problems(q: InboxTagQuestion, taken: Set<string>, kept: Set<string>): string[] {
const out: string[] = [];
if (!q.question.trim()) out.push("Write the question.");
const seen = new Set<string>();
@@ -86,7 +88,7 @@ function problems(q: InboxTagQuestion, taken: Set<string>): string[] {
}
const key = name.toLowerCase();
if ([...name].length > INBOX_TAG_LABEL_MAX_LEN) out.push(`"${name}" is longer than ${INBOX_TAG_LABEL_MAX_LEN} characters.`);
if (isAutomaticTag(name)) out.push(`"${name}" is a built-in label. Pick another name.`);
if (isAutomaticTag(name) && !kept.has(key)) out.push(`"${name}" is a built-in label. Pick another name.`);
else if (taken.has(key) || seen.has(key)) out.push(`"${name}" is already used by another question or option.`);
seen.add(key);
};
@@ -107,13 +109,15 @@ function problems(q: InboxTagQuestion, taken: Set<string>): string[] {
// the fields the question type uses.
function finalize(q: InboxTagQuestion): InboxTagQuestion {
const question = q.question.trim().replace(/\s+/g, " ");
const automated = q.automated ? { automated: true } : {};
if (q.type === "yes_no") {
return { id: q.id, type: "yes_no", question, label: inboxTagLabelName(q.label ?? ""), action: q.action };
return { id: q.id, type: "yes_no", question, label: inboxTagLabelName(q.label ?? ""), action: q.action, ...automated };
}
return {
id: q.id,
type: "choice",
question,
...automated,
action: { type: "" },
choices: (q.choices ?? []).map((c) => ({
label: inboxTagLabelName(c.label),
@@ -199,6 +203,7 @@ export default function TaggingQuestions({
))}
<span className="text-[11px] text-slate-400">
{q.type === "choice" ? "pick one" : "yes or no"}
{q.automated && " · also asked of automated notifications"}
</span>
</div>
</div>
@@ -290,7 +295,8 @@ function QuestionForm({
const [q, setQ] = React.useState<InboxTagQuestion>(initial);
const [tried, setTried] = React.useState(false);
const dirty = JSON.stringify(q) !== JSON.stringify(initial);
const issues = problems(q, taken);
const kept = React.useMemo(() => new Set(labelsOf(initial).map((l) => l.toLowerCase())), [initial]);
const issues = problems(q, taken, kept);
const patch = (next: Partial<InboxTagQuestion>) => setQ((prev) => ({ ...prev, ...next }));
const patchChoice = (i: number, next: Partial<InboxTagChoice>) =>
@@ -422,6 +428,21 @@ function QuestionForm({
</div>
)}
<label className="flex items-start gap-2.5 cursor-pointer select-none">
<Checkbox
tone="slate"
checked={!!q.automated}
onChange={(e) => patch({ automated: e.target.checked || undefined })}
className="mt-0.5"
/>
<span className="text-[12px] leading-snug text-slate-700">
Also ask about automated notifications
<span className="block text-[11px] text-slate-500 mt-0.5">
Notifications leave the inbox for the Automated view. A notification this question matches gets its label and stays in the inbox instead. It never holds, stops or opens a task for one; those still apply to replies only.
</span>
</span>
</label>
{tried && issues.length > 0 && (
<ul className="text-[11.5px] text-red-600 space-y-0.5">
{issues.map((m) => (
+19
View File
@@ -426,6 +426,25 @@ function SendingSettings() {
)}
</Section>
<Section
eyebrow="Automated mail"
description="Automatic inbox tagging moves mail nobody wrote, like receipts, sign-in codes, newsletters and bounces, out of the inbox into the Automated view. Mail that keeps a mailbox running should not go with it."
>
{isLoading || !draft ? (
<div className="h-7 w-40 rounded bg-slate-100 animate-pulse" />
) : (
<Row
label="Keep mail that needs action in the inbox"
description="Each notification is also asked whether it needs someone to act: a failed payment, a suspended or restricted account, a suspicious sign-in, a sending limit, or a service about to expire. The ones that do stay in the inbox labelled Action required, and members who manage mailboxes are notified. A notification recognised by its sender alone costs one small classifier call for this."
>
<Toggle
on={tagging.action_required_in_inbox}
onChange={(on) => patchInboxTagging({ action_required_in_inbox: on })}
/>
</Row>
)}
</Section>
<Section
eyebrow="Tagging languages and questions"
description="Tune automatic inbox tagging to your own mail. Languages and questions only change how tagging reads a message; they never change a message's kind, intent or relevance on their own."
@@ -35,6 +35,7 @@ import {
MessageSquareReplyIcon,
BotIcon,
BanIcon,
TriangleAlertIcon,
} from "lucide-react";
import useUniboxOverview from "@/lib/api/hooks/app/unibox/useUniboxOverview";
import useMarkSeen from "@/lib/api/hooks/app/unibox/useMarkSeen";
@@ -101,6 +102,7 @@ const MAIL_FOLDERS: {
];
const VIEW_ICONS: Record<UniboxViewId, React.ReactNode> = {
action_required: <TriangleAlertIcon className={ICON} />,
hot: <FlameIcon className={ICON} />,
needs_reply: <MessageSquareReplyIcon className={ICON} />,
follow_up: <ClockIcon className={ICON} />,
@@ -9,6 +9,7 @@ import {
BellIcon,
ClockIcon,
CreditCardIcon,
InboxIcon,
KeyRoundIcon,
MailCheckIcon,
MailWarningIcon,
@@ -44,6 +45,7 @@ const CATEGORY_META: Record<string, { icon: LucideIcon; tone: string }> = {
health_domain_auth: { icon: ShieldOffIcon, tone: "bg-rose-50 text-rose-600" },
placement_finished: { icon: MailCheckIcon, tone: "bg-sky-50 text-sky-600" },
placement_alert: { icon: MailWarningIcon, tone: "bg-rose-50 text-rose-600" },
inbox_action_required: { icon: InboxIcon, tone: "bg-rose-50 text-rose-600" },
};
const FALLBACK_META = { icon: BellIcon, tone: "bg-slate-100 text-slate-500" };
@@ -33,6 +33,7 @@ export interface NotificationPreferences {
health_domain_auth: CategoryPref;
placement_finished: CategoryPref;
placement_alert: CategoryPref;
inbox_action_required: CategoryPref;
email_digest_minutes: number;
}
@@ -80,6 +81,9 @@ export function normalizeNotificationPreferences(
// Emails by default: a campaign landing in spam has to reach whoever
// can fix it even when nobody has the dashboard open.
placement_alert: p?.placement_alert ?? billing,
// Emails by default: a mailbox about to lose its subscription has to
// reach whoever can fix it even when nobody reads that inbox.
inbox_action_required: p?.inbox_action_required ?? billing,
email_digest_minutes: Math.min(Math.max(minutes, EMAIL_WINDOW_MIN_MINUTES), EMAIL_WINDOW_MAX_MINUTES),
};
}
@@ -61,6 +61,9 @@ export interface InboxTaggingSettings {
questions?: InboxTagQuestion[] | null;
// models.MailLanguageNames codes tagging reads mail in.
languages?: string[] | null;
// Ask automated notifications whether they need action, and keep the ones
// that do in the inbox instead of the Automated view.
action_required_in_inbox: boolean;
}
export type InboxTagActionType = "" | "hold" | "stop" | "task";
@@ -85,6 +88,9 @@ export interface InboxTagQuestion {
label?: string;
action: InboxTagQuestionAction;
choices?: InboxTagChoice[];
// Also asked of automated notifications; a match keeps the conversation in
// the inbox. Never acts on one.
automated?: boolean;
}
export const DEFAULT_INBOX_TAGGING: InboxTaggingSettings = {
@@ -95,6 +101,7 @@ export const DEFAULT_INBOX_TAGGING: InboxTaggingSettings = {
suppress_on_removal_request: false,
questions: [],
languages: [],
action_required_in_inbox: true,
};
// Bounds matching internal/models/advanced_outreach.go.
+1 -1
View File
@@ -9,7 +9,7 @@ import { tagMeaning, isAutomaticTag } from "./tagMeanings";
// Mirrors AllLabels() in internal/app/inboxtag/policy.go. Kept by hand, which
// is exactly why it is asserted rather than trusted.
const EVERY_AUTOMATIC_LABEL = [
"Auto-reply", "Bounced", "Follow up", "Gone quiet", "Interested",
"Action required", "Auto-reply", "Bounced", "Follow up", "Gone quiet", "Interested",
"Legal threat", "Meeting", "Needs reply", "Needs review", "Not interested",
"Not now", "Notification", "Out of office", "Pricing", "Question",
"Sales pitch", "Unsubscribe", "Update", "Wrong person",
+4 -1
View File
@@ -8,11 +8,14 @@
// labels a user created ever need their own explanations too.
const TAG_MEANINGS: Record<string, string> = {
// What the message is. Automated mail leaves the inbox for the Automated view.
// What the message is. Automated mail leaves the inbox for the Automated view,
// unless it needs your action.
bounced: "The email was not delivered: the address does not exist, or the server refused it for now.",
"out of office": "An automatic out-of-office or vacation reply, not a person.",
"auto-reply": "An automatic \"we got your message\" or ticket receipt, not a person.",
notification: "An automatic message from a service: security alerts, sign-in codes, receipts, newsletters.",
"action required":
"An automatic message saying something needs your action: a failed payment, a suspended or restricted account, a suspicious sign-in, a sending limit, or a service about to expire. Kept in the inbox so it is not missed.",
"sales pitch": "Someone trying to sell to you. Not a reply to your outreach.",
// What a reply wants. Only ever on a person's reply.
+8 -2
View File
@@ -11,7 +11,7 @@
import type { UniboxCategoryOverview } from "@/lib/api/models/app/unibox/UniboxOverview";
export type UniboxViewId = "hot" | "needs_reply" | "follow_up" | "declined" | "automated";
export type UniboxViewId = "action_required" | "hot" | "needs_reply" | "follow_up" | "declined" | "automated";
export interface UniboxView {
id: UniboxViewId;
@@ -25,6 +25,12 @@ export interface UniboxView {
}
export const UNIBOX_VIEWS: UniboxView[] = [
{
id: "action_required",
label: "Action required",
meaning: "Automated mail that needs someone to act: a failed payment, a suspended account, a suspicious sign-in, a sending limit, a service about to expire. Kept in the inbox so it is not missed.",
labels: ["action required"],
},
{
id: "hot",
label: "Hot leads",
@@ -52,7 +58,7 @@ export const UNIBOX_VIEWS: UniboxView[] = [
{
id: "automated",
label: "Automated",
meaning: "Security alerts, notifications, bounces and auto-replies. Kept out of the inbox because nobody wrote them.",
meaning: "Sign-in codes, receipts, newsletters, bounces and auto-replies. Kept out of the inbox because nobody wrote them. Anything that needs your action stays in the inbox instead.",
labels: ["bounced", "out of office", "auto-reply", "notification"],
automated: true,
},