Files
pl_mail/assets/controllers/rules/rule_builder_controller.js
maniandClaude Sonnet 5.5 74e5e28984 feat(rules): add a "File in category" filter action
A filter can now file the mail it matches under one of the reader's own
categories. RuleCategoryFiler decides whether it may: only a message whose
sender passes authentication (a forged From line must not sit in a tab named
after the real sender), only into a category the rule's owner owns, and, on a
Gmail label, only when the reader asked plMail to overrule the provider. A
refusal leaves the message to the ordinary rules. When two filters match, the
one that runs last decides.

The editor offers the action only to somebody who has a category (or whose
rule already carries one), so nobody else's editor changes. Ownership voting,
the plain-English restatement and the three catalogues learn the new action.

RuleActionExecutor takes the filer as a constructor argument, so the one test
that builds it by hand passes a real one.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 14:32:13 +02:00

795 lines
31 KiB
JavaScript

import { Controller } from "@hotwired/stimulus"
/**
* Boolean condition-tree editor for mail rules.
*
* The tree is rendered as nested, indented groups rather than a canvas or a
* two-pane arrangement. That is a deliberate mobile decision: vertical flow
* needs no horizontal room, so a phone gets the *same* editor with tighter
* indentation and a wrapped control row, not a cut-down one. Depth is shown by
* a left rail rather than whitespace alone, which survives the narrower indent.
*
* The tree lives in JS and is serialised into a hidden input on submit, because
* its shape is recursive and its depth is chosen by the author — something a
* server-rendered form tree models badly. FilterAstValidator re-checks whatever
* arrives; nothing here is trusted.
*
* Two feedback affordances hang off the same debounce: a plain-English sentence
* and a live count of matching mail. The count is what makes a filter something
* you can see rather than something you write and hope about.
*
* The one thing this editor does not draw itself is the folder a
* "save attachments to" action targets. That is the destination picker the
* reading pane already uses, opened in the shared modal and answered with a
* `integration--destination-picker:chosen` event — a rule that saves to
* Nextcloud and a hand-saved attachment browse the same folders, because there
* is one chooser rather than a second, thinner one built here.
*/
const OPERATORS = [
{ value: "AND", labelKey: "all" },
{ value: "OR", labelKey: "any" },
{ value: "NOT", labelKey: "none" },
]
/** Field kind decides which input is rendered and how the value is coerced. */
const FIELDS = {
from: "text", fromAddress: "text", fromDomain: "text",
to: "text", cc: "text", bcc: "text",
subject: "text", body: "text", text: "text",
filename: "text", listId: "text",
hasLabel: "label", notLabel: "label",
minSize: "number", maxSize: "number",
before: "date", after: "date",
hasAttachment: "bool",
hasKeyword: "keyword", notKeyword: "keyword",
}
const KEYWORDS = ["$seen", "$flagged", "$draft", "$answered"]
export default class extends Controller {
static targets = ["tree", "conditions", "actions", "actionList", "summary", "count", "form"]
static values = {
conditions: Object,
actions: Array,
labels: Array,
categories: Array,
integrations: Array,
previewUrl: String,
csrf: String,
/** Open a fresh editor with one condition row to fill in. */
seedCondition: Boolean,
i18n: Object,
}
connect() {
this.tree = this._seed(this.conditionsValue)
this.actions = Array.isArray(this.actionsValue) ? [...this.actionsValue] : []
// Folder id → the readable name the picker gave it. Only the id is
// stored on the rule, so a saved rule reopened later starts with an
// empty map and shows ids until something is picked again — see
// _folderControls() for why the id is what is stored.
this._folderLabels = new Map()
this.render()
this.renderActions()
this.schedulePreview()
}
disconnect() {
clearTimeout(this._previewTimer)
}
// ── Rendering ───────────────────────────────────────────────────────────
render() {
this.treeTarget.replaceChildren(this._group(this.tree, [], 0))
this.serialise()
}
/**
* A group: operator selector, its children, and the two add buttons.
* `path` addresses this node from the root so handlers can mutate in place
* without needing node identity.
*/
_group(node, path, depth) {
const wrap = document.createElement("div")
wrap.className =
depth === 0
? "rounded-lg border border-line bg-sunken/40"
: "rounded-lg border border-line bg-sunken/40 mt-2"
const head = document.createElement("div")
head.className = "flex flex-wrap items-center gap-2 px-3 py-2 border-b border-line"
const seg = document.createElement("div")
seg.className = "inline-flex rounded-lg border border-field overflow-hidden"
for (const op of OPERATORS) {
const b = document.createElement("button")
b.type = "button"
b.textContent = this._t(op.labelKey)
b.className =
"px-2.5 h-7 text-xs font-medium transition-colors cursor-pointer " +
(node.operator === op.value
? "bg-accent text-accent-ink"
: "text-ink-soft hover:bg-hover")
b.addEventListener("click", () => {
this._at(path).operator = op.value
this.render()
this.schedulePreview()
})
seg.append(b)
}
head.append(seg)
const hint = document.createElement("span")
hint.className = "text-xs text-ink-faint"
hint.textContent = this._t("of_the_following")
head.append(hint)
const spacer = document.createElement("div")
spacer.className = "flex-1"
head.append(spacer)
head.append(this._smallButton("fa-plus", this._t("add_condition"), () => {
this._at(path).conditions.push({ field: "subject", value: "" })
this.render()
this.schedulePreview()
}))
// Depth is capped well below the validator's limit: past three levels a
// nested filter is easier to express as two rules.
if (depth < 3) {
head.append(this._smallButton("fa-code-branch", this._t("add_group"), () => {
this._at(path).conditions.push({ operator: "AND", conditions: [{ field: "subject", value: "" }] })
this.render()
this.schedulePreview()
}))
}
if (depth > 0) {
head.append(this._smallButton("fa-xmark", this._t("remove_group"), () => {
this._removeAt(path)
this.render()
this.schedulePreview()
}, true))
}
wrap.append(head)
const body = document.createElement("div")
// The left rail is what carries depth once the indent shrinks on narrow
// screens; pl-2 on mobile, pl-4 from sm up.
body.className = "px-2 sm:px-3 py-2 space-y-2"
node.conditions.forEach((child, i) => {
const childPath = [...path, i]
const railed = document.createElement("div")
railed.className = depth > 0 ? "border-l-2 border-line/70 pl-2 sm:pl-3" : ""
railed.append(
child.operator ? this._group(child, childPath, depth + 1) : this._condition(child, childPath),
)
body.append(railed)
})
// Removing the last condition is a decision, not an accident, so the
// empty state says what the rule now does rather than sitting blank.
if (node.conditions.length === 0) {
const empty = document.createElement("p")
empty.className = "px-1 py-1 text-xs text-ink-faint"
empty.textContent = this._t(depth === 0 ? "no_conditions" : "empty_group")
body.append(empty)
}
wrap.append(body)
return wrap
}
_condition(node, path) {
const row = document.createElement("div")
row.className = "flex flex-wrap items-center gap-2"
const field = this._selectElement("w-auto min-w-[8.5rem]")
for (const name of Object.keys(FIELDS)) {
const o = document.createElement("option")
o.value = name
o.textContent = this._t(`field.${name}`)
o.selected = node.field === name
field.append(o)
}
field.addEventListener("change", () => {
const target = this._at(path)
target.field = field.value
target.value = this._defaultFor(field.value)
this.render()
this.schedulePreview()
})
row.append(field)
row.append(this._valueInput(node, path))
row.append(this._smallButton("fa-xmark", this._t("remove_condition"), () => {
this._removeAt(path)
this.render()
this.schedulePreview()
}, true))
return row
}
_valueInput(node, path) {
const kind = FIELDS[node.field] ?? "text"
const commit = (value) => {
this._at(path).value = value
this.serialise()
this.schedulePreview()
}
if (kind === "bool" || kind === "keyword" || kind === "label") {
const select = this._selectElement("flex-1 min-w-[9rem]")
const options =
kind === "bool"
? [{ v: true, t: this._t("yes") }, { v: false, t: this._t("no") }]
: kind === "keyword"
? KEYWORDS.map((k) => ({ v: k, t: this._t(`keyword.${k.slice(1)}`) }))
: this.labelsValue.map((l) => ({ v: l.id, t: l.name }))
for (const opt of options) {
const o = document.createElement("option")
o.value = String(opt.v)
o.textContent = opt.t
o.selected = String(node.value) === String(opt.v)
select.append(o)
}
select.addEventListener("change", () => {
commit(kind === "bool" ? select.value === "true" : kind === "label" ? Number(select.value) : select.value)
})
return select
}
const input = document.createElement("input")
input.className = this._inputClass() + " flex-1 min-w-[9rem]"
// Names the condition's value field, so it can be found without asking
// for "the text input in this row". The row also contains the search
// box Tom Select injects into any select with more than a handful of
// options — an unnamed, unlabelled <input type="text"> belonging to the
// widget, not to the rule — and a positional query over both picks
// whichever the widget happened to render first.
input.dataset.ruleValue = ""
input.type = kind === "number" ? "number" : kind === "date" ? "date" : "text"
input.value = node.value ?? ""
input.placeholder = this._t(`placeholder.${kind}`)
input.addEventListener("input", () => {
commit(kind === "number" ? Number(input.value) : input.value)
})
return input
}
// ── Actions ─────────────────────────────────────────────────────────────
renderActions() {
this.actionListTarget.replaceChildren()
this.actions.forEach((action, i) => {
const row = document.createElement("div")
row.className = "flex flex-wrap items-center gap-2"
const select = this._selectElement("w-auto min-w-[10rem]")
for (const type of this.i18nValue.actionTypes) {
const o = document.createElement("option")
o.value = type
o.textContent = this._t(`action.${type}`)
o.selected = action.type === type
select.append(o)
}
select.addEventListener("change", () => {
this.actions[i] = { type: select.value }
if (select.value === "applyLabel" || select.value === "removeLabel") {
this.actions[i].labelId = this._assignableLabels()[0]?.id
}
if (select.value === "saveToIntegration") {
this.actions[i].integrationId = this.integrationsValue[0]?.id
}
if (select.value === "fileInCategory") {
this.actions[i].categoryId = this.categoriesValue[0]?.id
}
this.renderActions()
this.serialise()
this.schedulePreview()
})
row.append(select)
if (action.type === "applyLabel" || action.type === "removeLabel") {
const label = this._selectElement("flex-1 min-w-[9rem]")
// System labels are excluded here but not from conditions:
// "has label Inbox" is a sensible test, while "apply label
// Inbox" is not — that is what the archive action means.
for (const l of this._assignableLabels()) {
const o = document.createElement("option")
o.value = String(l.id)
o.textContent = l.name
o.selected = Number(action.labelId) === l.id
label.append(o)
}
label.addEventListener("change", () => {
this.actions[i].labelId = Number(label.value)
this.serialise()
this.schedulePreview()
})
row.append(label)
}
if (action.type === "fileInCategory") {
if (0 === this.categoriesValue.length) {
// Every category was deleted since this rule was written.
// Said plainly rather than rendering an empty select that
// would store no target and file nothing at run time.
const warn = document.createElement("span")
warn.className = "text-xs text-danger"
warn.textContent = this._t("no_categories")
row.append(warn)
} else {
const target = this._selectElement("flex-1 min-w-[9rem]")
for (const category of this.categoriesValue) {
const o = document.createElement("option")
o.value = String(category.id)
o.textContent = category.name
o.selected = Number(action.categoryId) === category.id
target.append(o)
}
target.addEventListener("change", () => {
this.actions[i].categoryId = Number(target.value)
this.serialise()
this.schedulePreview()
})
row.append(target)
}
}
if (action.type === "saveToIntegration") {
if (0 === this.integrationsValue.length) {
// Nothing to save to. Said plainly here rather than
// rendering an empty select that would silently store no
// target and do nothing at run time.
const warn = document.createElement("span")
warn.className = "text-xs text-danger"
warn.textContent = this._t("no_integrations")
row.append(warn)
} else {
const target = this._selectElement("w-auto min-w-[9rem]")
for (const integration of this.integrationsValue) {
const o = document.createElement("option")
o.value = String(integration.id)
o.textContent = integration.name
o.selected = Number(action.integrationId) === integration.id
target.append(o)
}
target.addEventListener("change", () => {
this.actions[i].integrationId = Number(target.value)
// The folder belongs to the connection it was browsed
// in. Left behind on a switch it would still look
// chosen, still be stored, and be handed to a service
// that has never heard of it — the upload would land in
// the new connection's default and the editor would go
// on claiming otherwise.
delete this.actions[i].folder
this.renderActions()
this.serialise()
this.schedulePreview()
})
row.append(target)
row.append(...this._folderControls(action, i))
}
}
row.append(this._smallButton("fa-xmark", this._t("remove_action"), () => {
this.actions.splice(i, 1)
this.renderActions()
this.serialise()
this.schedulePreview()
}, true))
this.actionListTarget.append(row)
})
}
addAction() {
const assignable = this._assignableLabels()
// With no user labels to apply, "apply label" would be an action that
// cannot be completed — offer something that works instead.
this.actions.push(
assignable.length > 0
? { type: "applyLabel", labelId: assignable[0].id }
: { type: "markRead" },
)
this.renderActions()
this.serialise()
this.schedulePreview()
}
/**
* A folder came back from the destination picker.
*
* Wired as a `@document` action on the form — see
* settings/filters/_editor.html.twig — because the picker renders into the
* body-level modal frame and cannot reach this one through the DOM.
*
* Which row it belongs to is remembered rather than carried in the event:
* there is exactly one dialog and it is modal, so the row that opened it is
* the row that is still waiting when the answer arrives, and the picker
* would otherwise have to be told about an editor it knows nothing about.
*/
folderChosen(event) {
const index = this._folderPickFor
const action = this.actions[index]
if (undefined === action || action.type !== "saveToIntegration") {
return
}
const { folder = "", label = "" } = event.detail ?? {}
if ("" === folder) {
// The root of a file store and the "Library" row of a photo library
// both answer with an empty id, and an empty folder is not stored —
// MailRuleController drops it, and the upload handler then falls
// back to the connection's own default. So choosing the top level
// IS how a rule is put back on the default, and the readout says
// so rather than showing a blank.
delete action.folder
} else {
action.folder = folder
// Only an id is stored, so this is the one chance to learn what it
// is called. Kept by id rather than by row: rows are rebuilt on
// every render and renumbered when one is removed, while the id is
// what the label describes.
if ("" !== label) {
this._folderLabels.set(folder, label)
}
}
this.renderActions()
this.serialise()
this.schedulePreview()
}
/**
* The chosen folder, and the button that opens the picker to change it.
*
* Returned as a list rather than appended here so renderActions() keeps
* deciding the row's order in one place.
*/
_folderControls(action, index) {
const integration = this._integration(action.integrationId)
if (undefined === integration) {
return []
}
const chosen = typeof action.folder === "string" ? action.folder : ""
const readout = document.createElement("span")
readout.className = "inline-flex min-w-0 max-w-[16rem] items-center gap-1.5 text-xs text-ink-muted"
readout.innerHTML = '<i class="fa-solid fa-folder text-[10px] text-amber-500" aria-hidden="true"></i>'
const name = document.createElement("span")
name.className = "truncate"
// The stored id is the fallback, not a second stored name. A remembered
// label can go stale — a folder renamed in the service would leave the
// editor naming somewhere that no longer exists — while the id is
// exactly what the upload will be given. For a service whose ids are
// paths, "Documents/Invoices", it also reads perfectly well on its own.
name.textContent = "" === chosen
? this._t("folder.default")
: (this._folderLabels.get(chosen) ?? chosen)
name.title = name.textContent
readout.append(name)
const browse = document.createElement("button")
browse.type = "button"
browse.className =
"shrink-0 inline-flex items-center gap-1.5 h-8 px-2.5 rounded-lg border border-field bg-field " +
"text-xs font-medium text-ink-soft hover:bg-hover transition-colors cursor-pointer"
browse.innerHTML = '<i class="fa-solid fa-folder-open text-[10px]" aria-hidden="true"></i>'
browse.append(document.createTextNode(this._t("folder.choose")))
// The one dialog shell, opened the way every other trigger in the app
// opens it: mount ui--modal on the button and let it point the frame at
// the picker. setAttribute rather than dataset, for the reason
// _selectElement() gives — the double dash does not survive dataset's
// camel-casing.
browse.setAttribute("data-controller", "ui--modal")
browse.setAttribute("data-ui--modal-src-value", this._browseUrl(integration, chosen))
browse.setAttribute("data-ui--modal-title-value", integration.name)
browse.setAttribute("data-ui--modal-size-value", integration.mediaLibrary === true ? "full" : "wide")
browse.setAttribute("data-action", "click->ui--modal#open")
browse.addEventListener("click", () => {
this._folderPickFor = index
})
return [readout, browse]
}
// ── Feedback ────────────────────────────────────────────────────────────
schedulePreview() {
clearTimeout(this._previewTimer)
this.countTarget.dataset.state = "pending"
this._previewTimer = setTimeout(() => this.preview(), 450)
}
/**
* Asks the server both questions at once: how many messages this catches,
* and how it reads in words. The sentence is built server-side so there is
* only one describer and it is translated — see FilterDescriber.
*/
async preview() {
const ast = this._toAst(this.tree)
// Numbered so only the newest answer is drawn. A count over a broad
// rule is slower than one over the narrower rule typed a moment later,
// and without this the slow one landed last and the readout described
// a rule that was no longer on screen.
const seq = (this._previewSeq = (this._previewSeq ?? 0) + 1)
const stale = () => seq !== this._previewSeq
try {
const response = await fetch(this.previewUrlValue, {
method: "POST",
headers: { "Content-Type": "application/json", "X-CSRF-Token": this.csrfValue },
// The account goes with it: a rule scoped to one account only
// ever acts on that account, and a count over all of them
// would be most wrong exactly where it matters most — a rule
// with no conditions at all.
body: JSON.stringify({
conditions: ast,
actions: this.actions,
account: this.element.querySelector('[name="account"]')?.value ?? "",
}),
})
const data = await response.json()
if (stale()) return
if (data.ok !== true) {
this.countTarget.dataset.state = "error"
this.countTarget.textContent = data.error ?? this._t("count.error")
return
}
this.summaryTarget.textContent = data.description ?? ""
this.countTarget.dataset.state = "ok"
this.countTarget.textContent = this._plural(
this._t(data.capped ? "count.capped" : "count.exact"),
data.count,
).replace("%count%", String(data.count))
} catch {
if (stale()) return
// A failed probe is only a missing hint — never block the save.
this.countTarget.dataset.state = "error"
this.countTarget.textContent = this._t("count.error")
}
}
// ── Serialisation ───────────────────────────────────────────────────────
serialise() {
this.conditionsTarget.value = JSON.stringify(this._toAst(this.tree))
this.actionsTarget.value = JSON.stringify(this.actions)
}
/** Editor shape → the stored/compiled AST shape. */
_toAst(node) {
if (node.operator) {
// An operator node with no conditions is invalid; the empty tree
// is how "no conditions" is stored, and the compiler reads it as
// every message.
if (node.conditions.length === 0) {
return {}
}
return {
operator: node.operator,
conditions: node.conditions.map((c) => this._toAst(c)),
}
}
return { [node.field]: node.value }
}
/** Stored AST → editor shape, which carries field/value separately. */
_seed(ast) {
// An empty tree is a rule with no conditions: it acts on everything in
// its scope. A brand-new editor gets one row to fill in instead, which
// is what the vast majority of rules want — see seedConditionValue.
if (!ast || Object.keys(ast).length === 0) {
return {
operator: "AND",
conditions: this.seedConditionValue ? [{ field: "subject", value: "" }] : [],
}
}
const fromAst = (n) => {
if (n && typeof n === "object" && n.operator) {
return { operator: n.operator, conditions: (n.conditions ?? []).map(fromAst) }
}
const [field, value] = Object.entries(n ?? {})[0] ?? ["subject", ""]
return { field, value }
}
const seeded = fromAst(ast)
// The root is always a group, so the operator control has somewhere to
// live even for a brand-new single-condition rule.
return seeded.operator ? seeded : { operator: "AND", conditions: [seeded] }
}
// ── Helpers ─────────────────────────────────────────────────────────────
/** Labels a rule may apply or remove — user labels only. */
_assignableLabels() {
return this.labelsValue.filter((l) => l.system !== true)
}
/** The connection an action names, or undefined once it is disconnected. */
_integration(id) {
return this.integrationsValue.find((entry) => entry.id === Number(id))
}
/**
* Where the picker should open for this connection.
*
* Reopened at the folder already chosen, so changing a rule's destination
* starts where it currently points rather than at the top of the account —
* the same courtesy a manual save gets from save.last_destination.
*
* Built through URL so the folder is encoded properly: ids are paths for
* some services, and a "Mail/Invoices & co" appended by hand would split
* the query string.
*/
_browseUrl(integration, folder) {
const url = new URL(integration.browseUrl, window.location.origin)
if ("" !== folder) {
url.searchParams.set("folder", folder)
}
return url.pathname + url.search
}
_at(path) {
return path.reduce((node, i) => node.conditions[i], this.tree)
}
_removeAt(path) {
const parentPath = path.slice(0, -1)
const parent = this._at(parentPath)
parent.conditions.splice(path[path.length - 1], 1)
if (parent.conditions.length > 0) {
return
}
// A nested group with nothing left in it is a tree the validator
// rejects and a box on screen that means nothing, so it goes too.
// The root is different: empty there is the rule that acts on
// everything, which is exactly what removing the last condition is
// for.
if (parentPath.length > 0) {
this._removeAt(parentPath)
}
}
_defaultFor(field) {
const kind = FIELDS[field]
if (kind === "bool") return true
if (kind === "keyword") return KEYWORDS[0]
if (kind === "label") return this.labelsValue[0]?.id
if (kind === "number") return 0
return ""
}
_smallButton(icon, title, onClick, danger = false) {
const b = document.createElement("button")
b.type = "button"
b.title = title
b.setAttribute("aria-label", title)
b.className =
"shrink-0 w-7 h-7 flex items-center justify-center rounded-lg text-xs transition-colors cursor-pointer " +
(danger
? "text-ink-faint hover:text-danger hover:bg-danger-soft"
: "text-ink-faint hover:text-ink hover:bg-hover")
b.innerHTML = `<i class="fa-solid ${icon}" aria-hidden="true"></i>`
b.addEventListener("click", onClick)
return b
}
_inputClass() {
return "h-8 rounded-lg border border-field bg-field px-2 text-xs text-ink focus:outline-none focus:ring-2 focus:ring-accent focus:border-transparent"
}
/**
* A <select> that ui--select will pick up.
*
* Every other select in the app is written in Twig and gets these
* attributes from the _select.html.twig macro. This editor builds its rows
* imperatively, so it has to stamp them itself — Stimulus watches the DOM,
* so an element created here connects as soon as it is appended.
*
* `select-field` rather than _inputClass(): it is the same box, but it is
* also the class the widget's stylesheet knows to undo once it has copied
* the list onto its wrapper. Left as _inputClass(), the wrapper would draw
* a second border around the control's own.
*/
_selectElement(extraClass = "") {
const select = document.createElement("select")
select.className = `select-field h-8 text-xs ${extraClass}`.trim()
// setAttribute, not dataset: the double dash in the identifier does not
// survive the round trip through dataset's camel-casing.
select.setAttribute("data-controller", "ui--select")
select.setAttribute(
"data-ui--select-i18n-value",
JSON.stringify({ noResults: this._t("selectNoResults") }),
)
return select
}
_t(key) {
return this.i18nValue[key] ?? key
}
/**
* Picks the singular or plural half of a "one|many" string.
*
* The count changes as the rule is typed and the number is substituted
* here rather than fetched, so the translation has to arrive carrying both
* shapes — hence the pipe, which is Symfony's own separator. A string
* without one is returned untouched, so a translation that has not been
* split yet still reads.
*/
_plural(message, count) {
const forms = message.split("|")
return forms.length < 2 ? message : (count === 1 ? forms[0] : forms[1])
}
}