Add a spam button that can also filter the sender or their domain

A button beside Delete — on a list row, in an open conversation and
above a selection. On one conversation it opens a menu: move it to
Spam, or move it and create a filter for the sender, or for the
sender's whole domain. The domain filter asks once more first, and for
a mail provider's domain it sits behind "More options". The filter is
an ordinary rule under Settings → Filters, a second press on the same
sender makes no second one, and Undo takes back the move and a filter
the same click created.

The filters match on two new conditions, fromAddress and fromDomain,
which compare the whole address or the whole domain. `from` is a
substring over address and display name, so a filter for a@x.de built
on it also caught ba@x.de. Both are in the filter editor and in JMAP
Email/query.

Refs #31

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Paul LütznerandClaude Opus 5.5 committed 2026-10-09 14:20:26 +02:00
1 parent ec938bca99
commit 4656f8874e
32 files changed
+1574 -10

No files matched your search

+16
View File
@@ -8,6 +8,22 @@ The published image tags: `latest` follows the most recent release below,
## Unreleased
**Mail can be marked as spam with a button.** It sits beside Delete: among a row's buttons, in an
open conversation's toolbar and above a selection. On one conversation it opens a short menu — move
it to Spam, or move it and also create a filter that sends everything from this sender, or from the
sender's whole domain, to Spam from now on. The domain filter asks once more before it acts, and for
a mail provider's domain such as gmail.com it is behind **More options** first. The filter is an
ordinary one under Settings → Filters. Undo takes back the move and a filter the same click created.
Until now the only ways to say "this is spam" were Move to and dragging onto the Spam row. See
[Mail → Mark as spam](docs/features/mail.md#mark-as-spam).
**Filters can match a sender exactly.** Two new conditions sit beside "From contains": **From is
exactly**, which matches the sender's whole address, and **Sender's domain is**, which matches
everything after the `@`. "From contains" looks for the text anywhere in the address or the display
name, so a filter for `a@x.de` also caught `ba@x.de`, and anybody could match it by putting that
address in their display name. The spam button's filters use the new conditions. Existing filters
are unchanged. JMAP clients get the same two as `fromAddress` and `fromDomain` in `Email/query`.
**A public demo can see how much it is used.** In demo mode the admin area has a new section, Demo
visitors: how many demo sessions were started in the last hour, 24 hours, 7 days and 30 days, how
many different visitors started them, the total so far and how many have a mailbox right now. No
@@ -145,6 +145,17 @@ export default class extends Controller {
await this._bulkPost("trash");
}
/**
* The toolbar's spam button: the selection goes to Spam, and that is all.
*
* The same move "Move to → Spam" makes, without opening the picker. No
* filter is offered here, unlike the button on one conversation — a
* selection has no single sender to offer one for.
*/
async spamSelected() {
await this._bulkPost("move-to", { role: "spam" });
}
async markReadSelected() {
await this._bulkPost("read", { read: true });
}
@@ -0,0 +1,220 @@
import LabelMenuController from "./label_menu_controller.js";
import { jsonCsrfHeaders } from "../../csrf.js";
import { announceWrite } from "../../mail_writes.js";
import { requestFailed } from "../../request_errors.js";
/**
* The spam button's menu: move one conversation to Spam, and optionally make
* the filter that sends its sender, or its sender's whole domain, there too.
*
* Extends the label menu for the reason the move menu does: everything hard
* about that controller is the PANEL — the top layer, fitting inside whatever
* clips, dismissing on scroll — and none of it is about labels.
*
* What is its own is where the panel's contents come from. They depend on who
* the conversation is from, so they are fetched when the button is pressed
* rather than rendered into every row of a list. Fetched on every open, not
* once: it is one small request, and it puts the menu back to its first state
* — a confirmation somebody backed out of is not still showing next time.
*
* Values:
* url — GET, the panel's contents for this conversation
* reportUrl — POST, the action; body `{ filter, scope, value }` where
* filter is `none` | `sender` | `domain`
* closesPane — true in the reading pane, whose conversation has just left
* align — `start` hangs the panel from the button's left edge, `end`
* (the inherited behaviour) from its right
*
* Targets:
* trigger — the button; carries aria-expanded, which a list row also reads
* to keep its hover actions on screen while the menu is open
*
* and, inside the fetched panel, all optional:
* more — "More options", shown in place of the domain row for a mail
* provider's domain
* domain — the "whole domain" row, which asks before it acts
* confirm — the question it asks
*/
export default class extends LabelMenuController {
static targets = ["panel", "trigger", "more", "domain", "confirm"];
static values = {
url: String,
reportUrl: String,
closesPane: { type: Boolean, default: false },
align: { type: String, default: "end" },
};
async toggle(event) {
// Now, not after the fetch: by then the click has already reached the
// row this button sits in, and the row has opened its conversation.
event.stopPropagation();
if (false === this.panelTarget.classList.contains("hidden")) {
this._close();
return;
}
if (true === this._loading) {
return;
}
this._loading = true;
let response;
try {
response = await fetch(this.urlValue, { headers: { "X-Requested-With": "XMLHttpRequest" } });
} catch {
requestFailed(null);
return;
} finally {
this._loading = false;
}
if (requestFailed(response)) {
return;
}
this.panelTarget.innerHTML = await response.text();
super.toggle(event);
this.triggerTarget.setAttribute("aria-expanded", "true");
}
/** See the panel in _spam_menu.html.twig: a click in it is not a click on the row. */
stop(event) {
event.stopPropagation();
}
/** A mail provider's domain: the domain row takes the place of the button that revealed it. */
moreOptions(event) {
event.stopPropagation();
this.moreTarget.hidden = true;
this.domainTarget.hidden = false;
this._place();
}
/** First click on "whole domain": ask. The filter is made by the button in the question. */
askDomain(event) {
event.stopPropagation();
this.domainTarget.hidden = true;
this.confirmTarget.hidden = false;
this._place();
}
cancelDomain(event) {
event.stopPropagation();
this.confirmTarget.hidden = true;
this.domainTarget.hidden = false;
this._place();
}
async report(event) {
event.stopPropagation();
const filter = event.currentTarget.dataset.spamFilter ?? "none";
const view = this.#view();
this._close();
let response;
try {
response = await fetch(this.reportUrlValue, {
method: "POST",
headers: jsonCsrfHeaders(),
body: JSON.stringify({ filter, scope: view.scope, value: view.value }),
});
} catch {
requestFailed(null);
return;
}
if (requestFailed(response)) {
return;
}
Turbo.renderStreamMessage(await response.text());
announceWrite();
if (true === this.closesPaneValue) {
this.#closePane();
}
}
// ── Private ───────────────────────────────────────────────────────────
/**
* The inherited placement hangs the panel from the button's right edge,
* which suits a button at the right of its row. The reading pane's button
* is at the pane's left edge, where that put the menu over the sidebar.
*
* Only the top-layer case is corrected here; in place, the stylesheet's
* own `left-0` already says it.
*/
_place() {
super._place();
const panel = this.panelTarget;
if ("start" !== this.alignValue || false === panel.matches(":popover-open")) {
return;
}
const margin = 8;
const rect = this.triggerTarget.getBoundingClientRect();
const width = panel.getBoundingClientRect().width;
const left = Math.min(
Math.max(margin, rect.left),
Math.max(margin, window.innerWidth - width - margin),
);
panel.style.left = `${Math.round(left)}px`;
}
_close() {
super._close();
if (this.hasTriggerTarget) {
this.triggerTarget.setAttribute("aria-expanded", "false");
}
}
/** The label menu ticks what the selection carries. This menu has no ticks. */
_syncFromSelection() {
}
/** Which list is on screen, in the list toolbar's own words. See mail--move-menu. */
#view() {
const toolbar = document.querySelector("[data-controller~='mail--list-toolbar']");
return {
scope: toolbar?.getAttribute("data-mail--list-toolbar-view-scope-value") ?? "",
value: toolbar?.getAttribute("data-mail--list-toolbar-view-value-value") ?? "",
};
}
/** mail-pane lives on <body> (see _layout/app.html.twig). */
#closePane() {
const element = document.querySelector('[data-controller~="mail--mail-pane"]');
if (!element) {
return;
}
this.application
.getControllerForElementAndIdentifier(element, "mail--mail-pane")
?.close();
}
}
@@ -34,7 +34,8 @@ const OPERATORS = [
/** Field kind decides which input is rendered and how the value is coerced. */
const FIELDS = {
from: "text", to: "text", cc: "text", bcc: "text",
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",
+2
View File
@@ -805,6 +805,8 @@ returns too many emails and the client cannot tell.
| `hasAttachment` | Boolean. |
| `text` | **Real full-text search** — Postgres `tsvector` + `websearch_to_tsquery('english')`. Stemmed, ranked, not a substring scan. |
| `body` / `subject` / `from` | `ILIKE` substring. `from` covers both address and display name. |
| `fromAddress` | **plMail extension.** The sender's whole address, case-insensitive. Not a substring, and not the display name. |
| `fromDomain` | **plMail extension.** The part of the sender's address after the `@`, whole and case-insensitive; given without the `@`. A subdomain does not match. |
| `to` / `cc` / `bcc` | Substring over the serialised JSON address array (matches name or address). |
| `filename` | Substring over attachment filenames. Inline parts have null filenames and never match. |
| `listId` | Substring over the canonicalised `list-id` header. |
+3 -1
View File
@@ -1,4 +1,4 @@
<!-- translated-from: CLIENT_DEVELOPMENT.md sha1:1b3b2730a7c1034180f1e06fecb9d454194af4f6 -->
<!-- translated-from: CLIENT_DEVELOPMENT.md sha1:6b43f264a89c985a980e7be7d7a41a889d379bbe -->
# Einen Client für plMail bauen
Alles, was eine Entwicklerin (oder ein Agent) braucht, um einen *neuen* plMail-Client zu schreiben
@@ -885,6 +885,8 @@ merken.
| `hasAttachment` | Boolescher Wert. |
| `text` | **Echte Volltextsuche** — Postgres `tsvector` + `websearch_to_tsquery('english')`. Gestemmt, gewichtet, kein Teilstring-Scan. |
| `body` / `subject` / `from` | `ILIKE` auf Teilzeichenketten. `from` deckt Adresse und Anzeigename ab. |
| `fromAddress` | **plMail-Erweiterung.** Die ganze Adresse des Absenders, ohne Beachtung der Groß- und Kleinschreibung. Keine Teilzeichenkette, und nicht der Anzeigename. |
| `fromDomain` | **plMail-Erweiterung.** Der Teil der Absenderadresse hinter dem `@`, vollständig und ohne Beachtung der Groß- und Kleinschreibung; ohne das `@` angegeben. Eine Subdomain trifft nicht. |
| `to` / `cc` / `bcc` | Teilzeichenkette über dem serialisierten JSON-Adressarray (trifft Name oder Adresse). |
| `filename` | Teilzeichenkette über Dateinamen von Anhängen. Inline-Parts haben leere Dateinamen und treffen nie. |
| `listId` | Teilzeichenkette über den kanonisierten `list-id`-Header. |
+3 -1
View File
@@ -1,4 +1,4 @@
<!-- translated-from: features/filters.md sha1:5e4d8cbf54aca3e66fde1708f1c3a05af4fd6fee -->
<!-- translated-from: features/filters.md sha1:f344fbe7df852e520da9a62793d6cf5febeb3cc9 -->
# Filter
@@ -28,6 +28,8 @@ verschachtelt eine weitere Gruppe darin.
| Bedingung | Was sie prüft |
|---|---|
| **Von enthält** | Absenderadresse oder Anzeigename |
| **Von ist genau** | Die ganze Adresse des Absenders, und sonst nichts — kein Teil einer Adresse, und nicht der Anzeigename |
| **Absender-Domain ist** | Alles hinter dem `@` der Absenderadresse, vollständig. Ohne das `@` eingeben. Eine Subdomain ist eine andere Domain |
| **An enthält**, **Cc enthält**, **Bcc enthält** | Die jeweilige Adressliste |
| **Betreff enthält** | Die Betreffzeile |
| **Text enthält** | Den Nachrichtentext |
+37 -1
View File
@@ -1,4 +1,4 @@
<!-- translated-from: features/mail.md sha1:4d527f6e86ae3478d94e7a628b559a798f22d239 -->
<!-- translated-from: features/mail.md sha1:57def7ce9b8e9c88cee6508cfaac1ce4c2fe75f2 -->
# Mail
@@ -567,6 +567,42 @@ und als sonst nichts — wo die Nachricht danach landet, ist Sache des Anbieters
**Aktualisieren** in der Werkzeugleiste reiht für jedes deiner aktiven Konten eine
Synchronisierung ein und dreht sich, bis diese Aufträge abgearbeitet sind.
### Als Spam markieren
Der Spam-Knopf steht neben **Löschen**: unter den Knöpfen einer Zeile, in der Werkzeugleiste
einer geöffneten Konversation und in der Werkzeugleiste über einer Auswahl. Bei einer einzelnen
Konversation öffnet er ein kurzes Menü:
| Auswahl | Was sie tut |
|---|---|
| **In den Spam verschieben** | Verschiebt diese Konversation in den Spam-Ordner des Kontos, und sonst nichts |
| **Diesen Absender immer in den Spam** | Dasselbe, und legt einen Filter für die Adresse des Absenders an |
| **Diese ganze Domain immer in den Spam** | Dasselbe, und legt einen Filter für alles hinter dem `@` an |
Die ersten beiden wirken sofort. Der Domain-Filter fragt noch einmal nach, bevor er etwas tut,
weil er auch Adressen trifft, die dir nie geschrieben haben. Gehört die Domain einem
Mail-Anbieter — gmail.com, gmx.de, outlook.com und ähnliche —, steht diese Auswahl erst im Menü,
wenn du **Weitere Optionen** drückst, und fragt dann trotzdem nach: Ein Filter auf so eine Domain
schickt einen guten Teil jedes Adressbuchs in den Spam.
Der Absender ist, wer außer dir zuletzt in der Konversation geschrieben hat; deine eigenen
Antworten werden übersprungen. Eine Konversation, die nur deine eigene Mail enthält, bietet das
Verschieben an und keinen Filter.
Der Filter ist ein gewöhnlicher. Er steht unter **Einstellungen → Filter**, benannt nach dem,
worauf er passt, und lässt sich dort bearbeiten, abschalten oder löschen wie jeder andere — siehe
[Filter](filters.md). Er gilt für Mail, die ab jetzt ankommt, in jedem Konto; den Knopf noch
einmal beim selben Absender zu drücken, legt keinen zweiten an. **Rückgängig** in der Meldung
holt die Konversation zurück und nimmt einen Filter wieder weg, den derselbe Klick angelegt hat.
Über einer Auswahl verschiebt der Knopf alles Ausgewählte in den Spam und bietet keinen Filter
an: Eine Auswahl hat nicht den einen Absender. Für Mail, die schon im Spam oder im Papierkorb
liegt, wird der Knopf nicht gezeigt.
Mail in den Spam zu verschieben sagt plMail nichts über die nächste Nachricht. plMail hat keinen
eigenen Spamfilter, der lernt; ob dein Anbieter das Verschieben als Signal wertet, entscheidet
der Anbieter. Ein Filter ist der Weg, es hier dauerhaft zu machen.
## Zurückstellen
Zurückstellen ist Archivieren mit Wecker. Die Konversation verlässt den Posteingang sofort —
+2
View File
@@ -23,6 +23,8 @@ group; **Add group** nests another group inside it.
| Condition | What it tests |
|---|---|
| **From contains** | Sender address or display name |
| **From is exactly** | The sender's whole address, and nothing else — not part of an address, and not the display name |
| **Sender's domain is** | Everything after the `@` of the sender's address, whole. Enter it without the `@`. A subdomain is a different domain |
| **To contains**, **Cc contains**, **Bcc contains** | The corresponding address list |
| **Subject contains** | The subject line |
| **Body contains** | The message body |
+33
View File
@@ -516,6 +516,39 @@ and nothing else — where the message lands afterwards is the provider's busine
**Refresh** in the toolbar queues a sync for every active account you own and spins until those
jobs have drained.
### Mark as spam
The spam button sits beside **Delete**: among a row's buttons, in the toolbar of an open
conversation, and in the toolbar above a selection. On one conversation it opens a short menu:
| Choice | What it does |
|---|---|
| **Move to Spam** | Moves this conversation to the account's spam folder, and nothing else |
| **Always move this sender to Spam** | The same, and creates a filter for the sender's address |
| **Always move this whole domain to Spam** | The same, and creates a filter for everything after the `@` |
The first two act at once. The domain filter asks once more before it does anything, because it
catches addresses that have never written to you. Where the domain belongs to a mail provider —
gmail.com, gmx.de, outlook.com and the like — that choice is not on the menu until you press
**More options**, and then still asks: a filter on such a domain sends a good part of anybody's
address book to spam.
The sender is whoever else wrote most recently in the conversation; your own replies are skipped.
A conversation that holds only your own mail offers the move and no filter.
The filter is an ordinary one. It appears under **Settings → Filters**, named after what it
matches, and can be edited, switched off or deleted there like any other — see
[Filters](filters.md). It applies to mail that arrives from now on, in every account; pressing the
button again on the same sender does not create a second one. **Undo** in the toast puts the
conversation back and takes away a filter the same click created.
Above a selection the button moves everything selected to Spam and offers no filter: a selection
has no one sender. The button is not shown for mail that is already in Spam or in the bin.
Moving mail to spam tells plMail nothing about the next message. plMail has no spam filter of its
own that learns; whether your provider treats the move as a signal is up to the provider. A filter
is how you make it stick here.
## Snooze
Snoozing is archiving with a timer. The conversation leaves the Inbox now — at the provider too,
+161
View File
@@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
namespace App\Controller\Mail;
use App\Controller\ChecksCsrf;
use App\Controller\RendersTurboStreams;
use App\Entity\Mail\MessageThread;
use App\Entity\User\User;
use App\Repository\Mail\MessageThreadRepository;
use App\Security\Voter\OwnershipVoter;
use App\Service\Mail\MoveToService;
use App\Service\Mail\SpamSenderResolver;
use App\Service\Mail\StatusUndoService;
use App\Service\Rule\SpamFilterCreator;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* The spam button: move one conversation to Spam, and optionally make the
* filter that does the same to whatever its sender writes next.
*
* Two routes, because the button is a menu. The first renders what the menu
* offers for this conversation — which depends on who it is from, and that is
* worked out here rather than rendered into every row of a list of fifty. The
* second does it.
*
* The move itself is "Move to → Spam" and goes through the same service, so
* what leaves which list, what the provider is told and what an Undo puts back
* are all exactly what they are from the picker. What this adds is the filter.
*
* One conversation at a time. A selection has no single sender to offer a
* filter for; the list toolbar's own button moves it and offers nothing else.
*/
#[Route('/status/thread/{id}', name: 'app_status_spam_', requirements: ['id' => '\d+'])]
#[IsGranted('ROLE_USER')]
final class SpamController extends AbstractController
{
use ChecksCsrf;
use RendersTurboStreams;
private const string NO_FILTER = 'none';
public function __construct(
private readonly MessageThreadRepository $threads,
private readonly SpamSenderResolver $senders,
private readonly SpamFilterCreator $filters,
private readonly MoveToService $moveTo,
private readonly StatusUndoService $undo,
private readonly TranslatorInterface $translator,
) {
}
/** The menu's contents for this conversation. A fragment, loaded when the button is pressed. */
#[Route('/spam-menu', name: 'menu', methods: ['GET'])]
public function menu(int $id): Response
{
/** @var User $user */
$user = $this->getUser();
return $this->render('mail/_spam_menu_panel.html.twig', [
'sender' => $this->senders->of($this->thread($id), $user),
]);
}
#[Route('/spam', name: 'report', methods: ['POST'])]
public function report(Request $request, int $id): Response
{
$this->assertCsrf($request, 'ajax');
/** @var User $user */
$user = $this->getUser();
$thread = $this->thread($id);
$body = json_decode($request->getContent(), true);
$body = is_array($body) ? $body : [];
$filter = (string) ($body['filter'] ?? self::NO_FILTER);
// Refused before anything moves: a request that asked for a filter and
// got only the move would be half obeyed, with a toast that could not
// say which half.
if (self::NO_FILTER !== $filter && false === in_array($filter, SpamFilterCreator::SCOPES, true)) {
throw new BadRequestHttpException('Unknown filter.');
}
$sender = self::NO_FILTER === $filter ? null : $this->senders->of($thread, $user);
if (self::NO_FILTER !== $filter && null === $sender) {
throw new BadRequestHttpException('This conversation has no sender to filter.');
}
$target = $this->moveTo->systemTarget($user, 'spam');
if (null === $target) {
throw $this->createAccessDeniedException('No spam label.');
}
$scope = (string) ($body['scope'] ?? '');
$value = (string) ($body['value'] ?? '');
$move = $this->moveTo->plan($user, $target, $scope, $value);
// Pressed on mail that is already in Spam, from the Spam list. The
// button is not drawn there; the honest answer to a caller that posts
// anyway is that nothing happened.
if (true === $move->isNoop) {
return $this->renderTurboStream('thread/status/_bulk.stream.html.twig', [
'count' => 0,
'threads' => [],
'leaves' => false,
]);
}
$undoToken = $this->undo->remember($thread->messages);
$this->moveTo->move([$thread], $move);
$message = null;
if (null !== $sender) {
[$rule, $created] = $this->filters->create($user, $sender, $filter);
// Only a rule this press made goes back with the Undo.
if (true === $created) {
$this->undo->alsoRemove($undoToken, $rule);
}
$message = $this->translator->trans('toast.spam_filtered', [
'%sender%' => $this->filters->pattern($sender, $filter),
]);
}
return $this->renderTurboStream('thread/status/_moved_to.stream.html.twig', [
'count' => 1,
'threads' => [$thread],
'label' => $target,
'staying' => true === $this->moveTo->staysInView($thread, $user, $scope, $value) ? [$thread] : [],
'undoToken' => $undoToken,
'message' => $message,
]);
}
private function thread(int $id): MessageThread
{
$thread = $this->threads->find($id);
if (null === $thread) {
throw $this->createNotFoundException();
}
$this->denyAccessUnlessGranted(OwnershipVoter::OWN, $thread);
return $thread;
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Domain\DTO\Mail;
/**
* Who a conversation is from, for the purposes of calling it spam.
*
* Both halves are lower-cased and already checked: the address is a real
* address, and the domain is the part of it after the last `@`.
*/
final readonly class SpamSender
{
public function __construct(
public string $address,
public string $domain,
/**
* Whether the domain is a mail provider's — gmail.com, gmx.de — and so
* shared by people who have nothing to do with each other. A filter on
* such a domain bins a good part of anybody's address book, which is
* why the menu puts one more click in front of it.
*/
public bool $sharedDomain,
) {
}
/** The domain as it is shown to a person: `@example.com`. */
public function domainPattern(): string
{
return '@' . $this->domain;
}
}
+13
View File
@@ -140,6 +140,19 @@ final class FilterAstValidator
return;
}
if (true === in_array($property, FilterVocabulary::SENDER_CONDITIONS, true)) {
if (false === FilterVocabulary::isSenderValue($property, $value)) {
throw new InvalidFilterException(sprintf(
'fromDomain' === $property
? '"%s" needs a domain, without the @.'
: '"%s" needs a whole address.',
$property,
));
}
return;
}
if (true === in_array($property, FilterVocabulary::INT_CONDITIONS, true)) {
if (false === is_int($value)) {
throw new InvalidFilterException(sprintf('"%s" must be a whole number.', $property));
+47
View File
@@ -40,6 +40,27 @@ final class FilterVocabulary
'listId',
];
/**
* Conditions over the sender that match the whole of something, where
* `from` matches any part of it.
*
* fromAddress the sender's address is exactly this one
* fromDomain the part of the sender's address after the `@` is exactly
* this — no `@`, and a subdomain is a different domain
*
* `from` is a substring over address and display name, which is the right
* tool for "anything mentioning Acme" and the wrong one for "this sender":
* `a@x.de` also matches `ba@x.de`, and a display name can be made to
* contain any address at all. A rule that files a sender under Spam needs
* to mean that sender and nobody whose address merely ends the same way.
*
* Case does not matter for either; nothing else is loosened.
*/
public const array SENDER_CONDITIONS = [
'fromAddress',
'fromDomain',
];
/** Conditions taking an integer. */
public const array INT_CONDITIONS = [
'minSize',
@@ -81,6 +102,7 @@ final class FilterVocabulary
{
return array_merge(
self::TEXT_CONDITIONS,
self::SENDER_CONDITIONS,
self::INT_CONDITIONS,
self::DATE_CONDITIONS,
self::BOOL_CONDITIONS,
@@ -88,6 +110,31 @@ final class FilterVocabulary
);
}
/**
* Whether a value is one a sender condition can match on.
*
* One definition for the validator, which refuses to store anything else,
* and the compiler, which JMAP reaches without going through the
* validator. Deliberately loose about what an address looks like — the
* column holds whatever the mail said — and strict only about the shape
* the comparison depends on: an address has something either side of an
* `@`, a domain has no `@` in it, and neither has whitespace.
*/
public static function isSenderValue(string $condition, mixed $value): bool
{
if (false === is_string($value) || '' === $value || 1 === preg_match('/\s/u', $value)) {
return false;
}
$at = strrpos($value, '@');
if ('fromDomain' === $condition) {
return false === $at;
}
return false !== $at && $at > 0 && $at < strlen($value) - 1;
}
public static function supports(string $condition): bool
{
return in_array($condition, self::all(), true);
+44
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Jmap\Query;
use App\Domain\Filter\FilterVocabulary;
use App\Domain\Enum\Mail\MessageCategory;
use App\Domain\Enum\Mail\MessageFlag;
use App\Jmap\Protocol\Exception\MethodException;
@@ -137,6 +138,10 @@ final class EmailFilterCompiler
'body' => $this->like(['m.body_text'], $value, $parameters),
'subject' => $this->like(['m.subject'], $value, $parameters),
'from' => $this->like(['m.from_address', 'm.from_name'], $value, $parameters),
// plMail extensions: the sender exactly, where `from` is any part
// of address or display name. See FilterVocabulary::SENDER_CONDITIONS.
'fromAddress' => $this->fromAddress($value, $parameters),
'fromDomain' => $this->fromDomain($value, $parameters),
'to' => $this->jsonAddressLike('m.to_addresses', $value, $parameters),
'cc' => $this->jsonAddressLike('m.cc_addresses', $value, $parameters),
'bcc' => $this->jsonAddressLike('m.bcc_addresses', $value, $parameters),
@@ -446,6 +451,45 @@ final class EmailFilterCompiler
return '('.implode(' OR ', $parts).')';
}
/**
* The sender's address, whole and case-insensitively.
*
* Equality on the address column alone — not the display name, which the
* sender chooses and which `from` matches for exactly that reason.
*
* @param array<string,mixed> $parameters
*/
private function fromAddress(mixed $value, array &$parameters): string
{
if (false === FilterVocabulary::isSenderValue('fromAddress', $value)) {
throw new MethodException('invalidArguments', '"fromAddress" must be a whole email address.');
}
$name = $this->bind(mb_strtolower((string) $value), $parameters);
return sprintf('LOWER(m.from_address) = :%s', $name);
}
/**
* Everything after the `@` of the sender's address, whole.
*
* A suffix match anchored on the `@`, which is what makes it the domain
* and not the end of one: `example.com` matches `a@example.com` and
* neither `a@notexample.com` nor `a@mail.example.com`.
*
* @param array<string,mixed> $parameters
*/
private function fromDomain(mixed $value, array &$parameters): string
{
if (false === FilterVocabulary::isSenderValue('fromDomain', $value)) {
throw new MethodException('invalidArguments', '"fromDomain" must be a domain, without the @.');
}
$name = $this->bind('%@'.$this->escapeLike(mb_strtolower((string) $value)), $parameters);
return sprintf('LOWER(m.from_address) LIKE :%s', $name);
}
/**
* to/cc/bcc are json arrays of {name, address}; matching is a substring
* scan over the serialised array, which covers both fields at once.
+104
View File
@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace App\Service\Mail;
use App\Domain\DTO\Mail\SpamSender;
use App\Entity\Mail\Message;
use App\Entity\Mail\MessageThread;
use App\Entity\User\User;
use App\Repository\Mail\AccountRepository;
/**
* Works out who a conversation is from when somebody calls it spam.
*
* A conversation can have several senders, and one of them is usually the
* person looking at it. The answer is the sender of the newest message that
* somebody else wrote: the last thing to arrive is what the button was pressed
* about, and a filter on one's own address would file one's own Sent copies.
*
* Null when nobody else wrote anything — a conversation made only of the
* user's own mail, or one whose sender has no usable address. The menu then
* offers the move and no filter, because there is nobody to filter.
*
* Read on the server each time rather than sent by the browser. The filter
* this feeds is a standing rule over future mail, and what it matches on must
* not be whatever a request said it was.
*/
final readonly class SpamSenderResolver
{
public function __construct(
private MailPresetProvider $presets,
private AccountRepository $accounts,
) {
}
public function of(MessageThread $thread, User $user): ?SpamSender
{
$own = $this->ownAddresses($user);
$newest = null;
foreach ($thread->messages as $message) {
$address = $this->addressOf($message);
if (null === $address || true === in_array($address, $own, true)) {
continue;
}
if (null === $newest || $this->arrivedAt($message) > $this->arrivedAt($newest)) {
$newest = $message;
}
}
if (null === $newest) {
return null;
}
$address = (string) $this->addressOf($newest);
$domain = substr($address, (int) strrpos($address, '@') + 1);
return new SpamSender($address, $domain, null !== $this->presets->findByDomain($domain));
}
/** The From address, lower-cased, or null when it is not an address. */
private function addressOf(Message $message): ?string
{
$address = mb_strtolower(trim((string) $message->fromAddress));
if (false === filter_var($address, FILTER_VALIDATE_EMAIL)) {
return null;
}
return $address;
}
private function arrivedAt(Message $message): int
{
return ($message->receivedAt ?? $message->sentAt)?->getTimestamp() ?? 0;
}
/**
* Every address the user sends as: each account's own, and its aliases.
*
* Asked of the repository rather than read off `$user->accounts`: this is
* the list that keeps a filter off the user's own address, and it should
* not depend on whether that collection happens to be loaded and current.
*
* @return list<string>
*/
private function ownAddresses(User $user): array
{
$own = [];
foreach ($this->accounts->findBy(['usr' => $user]) as $account) {
$own[] = mb_strtolower(trim((string) $account->email));
foreach ($account->aliases as $alias) {
$own[] = mb_strtolower(trim($alias->address));
}
}
return $own;
}
}
+50
View File
@@ -8,9 +8,11 @@ use App\Domain\Enum\Mail\LabelRole;
use App\Entity\Label\Label;
use App\Entity\Mail\Account;
use App\Entity\Mail\Message;
use App\Entity\Rule\MailRule;
use App\Entity\User\User;
use App\Repository\Label\LabelRepository;
use App\Repository\Mail\MessageRepository;
use App\Repository\Rule\MailRuleRepository;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\RequestStack;
@@ -77,6 +79,7 @@ final readonly class StatusUndoService
private MessageRepository $messages,
private LabelRepository $labels,
private ThreadStatusUpdater $status,
private MailRuleRepository $rules,
) {
}
@@ -119,6 +122,31 @@ final readonly class StatusUndoService
return $token;
}
/**
* Make an Undo of this action take a filter back with it.
*
* For the spam button, whose one click can both move a conversation and
* create the rule that does the same to the sender's next mail. Undoing
* half of that would leave a filter the person has just said they did not
* mean. Called only for a rule the action itself created.
*
* A token that is no longer kept is left alone: there is nothing to attach
* to, and the rule is still listed under Settings → Filters.
*/
public function alsoRemove(string $token, MailRule $rule): void
{
$session = $this->requestStack->getSession();
$kept = (array) $session->get(self::SESSION_KEY, []);
if (false === isset($kept[$token]) || null === $rule->id) {
return;
}
$kept[$token]['rule'] = $rule->id;
$session->set(self::SESSION_KEY, $kept);
}
/**
* Put back what remember() wrote down.
*
@@ -144,6 +172,8 @@ final readonly class StatusUndoService
unset($kept[$token]);
$session->set(self::SESSION_KEY, $kept);
$this->removeRule($snapshot['rule'] ?? null, $user);
$messages = [];
foreach ($this->messages->findBy(['id' => array_keys($snapshot['messages'])]) as $message) {
@@ -174,6 +204,26 @@ final readonly class StatusUndoService
// ── Private ───────────────────────────────────────────────────────────────
/**
* The filter alsoRemove() attached, if there was one and it is still the
* user's. Gone already — deleted by hand in the seconds between — is fine.
*/
private function removeRule(mixed $id, User $user): void
{
if (false === is_int($id)) {
return;
}
$rule = $this->rules->find($id);
if (null === $rule || $rule->usr?->id !== $user->id) {
return;
}
$this->em->remove($rule);
$this->em->flush();
}
/**
* Move every message that is not where it was back to where it was.
*
+104
View File
@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace App\Service\Rule;
use App\Domain\DTO\Mail\SpamSender;
use App\Domain\Filter\FilterAstValidator;
use App\Entity\Rule\MailRule;
use App\Entity\User\User;
use App\Repository\Rule\MailRuleRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* Makes the filter the spam button offers: everything from this sender, or
* from this sender's whole domain, goes to Spam from now on.
*
* An ordinary rule, the same one the editor under Settings → Filters would
* have saved — one sender condition and the "mark as spam" action — so it is
* listed there, and can be edited, switched off or deleted like any other.
* Nothing about it is special once it exists.
*
* It matches on `fromAddress` or `fromDomain`, never on `from`. `from` is a
* substring over address and display name: a filter for `a@x.de` built on it
* would also file `ba@x.de`, and anybody could land in it by putting the
* address in their display name. The two sender conditions compare the whole
* address, or the whole domain, and nothing else.
*/
final readonly class SpamFilterCreator
{
public const string SENDER = 'sender';
public const string DOMAIN = 'domain';
/** What the button's menu may ask for. */
public const array SCOPES = [self::SENDER, self::DOMAIN];
public function __construct(
private MailRuleRepository $rules,
private FilterAstValidator $validator,
private EntityManagerInterface $em,
private TranslatorInterface $translator,
) {
}
/** Who a filter of this scope is about, as the toast and the rule's name say it. */
public function pattern(SpamSender $sender, string $scope): string
{
return self::DOMAIN === $scope ? $sender->domainPattern() : $sender->address;
}
/**
* The one condition such a filter has.
*
* @return array<string, string>
*/
private function condition(SpamSender $sender, string $scope): array
{
return self::DOMAIN === $scope
? ['fromDomain' => $sender->domain]
: ['fromAddress' => $sender->address];
}
/**
* The rule, and whether this call made it.
*
* A second press on mail from the same sender finds the first one's rule
* and makes nothing: two identical filters do nothing one does not, and
* the list under Settings → Filters would grow a row per press. The flag
* is what an Undo reads — it takes back a rule this press made, and leaves
* alone one that was already there.
*
* @return array{0: MailRule, 1: bool}
*/
public function create(User $user, SpamSender $sender, string $scope): array
{
$pattern = $this->pattern($sender, $scope);
$conditions = ['operator' => 'AND', 'conditions' => [$this->condition($sender, $scope)]];
$actions = [['type' => RuleActionExecutor::MARK_SPAM]];
foreach ($this->rules->findForUserOrdered($user) as $existing) {
if ($existing->conditions == $conditions && $existing->actions == $actions && null === $existing->account) {
return [$existing, false];
}
}
// Built here from two checked strings, so this cannot fail today. It
// is still the gate every stored condition goes through, because the
// tree feeds a SQL compiler.
$this->validator->validate($conditions);
$rule = new MailRule();
$rule->usr = $user;
$rule->name = $this->translator->trans('settings.filters.spam_rule_name', ['%sender%' => $pattern]);
$rule->conditions = $conditions;
$rule->actions = $actions;
$rule->sortOrder = $this->rules->nextSortOrder($user);
$this->em->persist($rule);
$this->em->flush();
return [$rule, true];
}
}
@@ -222,6 +222,11 @@ The button itself carries `group` and `relative`.
>
{{ m.tbtn('click->mail--list-toolbar#archiveSelected', 'fa-solid fa-box-archive', 'toolbar.actions.archive'|trans) }}
{{ m.tbtn('click->mail--list-toolbar#deleteSelected', 'fa-solid fa-trash', 'toolbar.actions.delete'|trans, 'hover:text-danger') }}
{# Not where the selection is already thrown away. In the bin the move
would pull mail back out of it and into Spam; in Spam it is nothing. #}
{% if list_scope|default('') not in ['spam', 'trash'] %}
{{ m.tbtn('click->mail--list-toolbar#spamSelected', 'fa-solid fa-circle-exclamation', 'toolbar.actions.spam'|trans, 'hover:text-danger') }}
{% endif %}
{{ m.tbtn('click->mail--list-toolbar#markReadSelected', 'fa-solid fa-envelope-open', 'toolbar.actions.mark_read'|trans) }}
{{ m.tbtn('click->mail--list-toolbar#markUnreadSelected', 'fa-solid fa-envelope', 'toolbar.actions.mark_unread'|trans) }}
{# A menu, not a direct action: this used to hardcode "tomorrow 8am"
+3 -3
View File
@@ -663,9 +663,9 @@ controller instead — the scroll position and which trees are open.
{% if _showSpam %}
{% set _href = path('app_mail_spam') %}
<div class="nav-item flex items-center justify-between gap-2 pl-9 pr-3 rounded-lg transition-colors {{ sidebar_row_state(_href) }}"
{# "This is junk", said by dropping. The only way to say it in
the interface: there is no mark-as-spam button anywhere, and
the row is here because the folder is. #}
{# "This is junk", said by dropping — the same move the spam
button and Move to make, for a pointer that is already
holding the mail. #}
{{ macros.drop_target(sidebarCounts.roleLabelId(enum('App\\Domain\\Enum\\Mail\\LabelRole').Spam), null, true) }}>
{# The tier rides on the LINK, not on the pill around it. The pill is
what you see — .nav-item draws it — but the row's height comes from
+65
View File
@@ -0,0 +1,65 @@
{#
_spam_menu.html.twig
─────────────────────────────────────────────────────────────────────────────
The spam button, for ONE conversation: in the reading pane's toolbar and among
a list row's hover actions. Pressing it opens a small menu — move this to Spam,
or move it and also filter the sender, or the sender's whole domain.
The panel is empty here and filled when the button is pressed
(SpamController::menu). What it offers depends on who the conversation is from,
and working that out for every row of a list, to render fifty menus nobody
opens, is the wrong trade.
The list toolbar does not use this. A selection has no one sender, so its
button moves the selection and offers nothing more (mail--list-toolbar).
Variables:
threadId int the conversation
variant string `toolbar` (reading pane) or `row` (list hover actions):
which button it is drawn as, and whether the pane showing
the conversation closes afterwards
#}
{% set variant = variant|default('toolbar') %}
<div data-controller="mail--spam-menu"
data-mail--spam-menu-url-value="{{ path('app_status_spam_menu', { id: threadId }) }}"
data-mail--spam-menu-report-url-value="{{ path('app_status_spam_report', { id: threadId }) }}"
data-mail--spam-menu-closes-pane-value="{{ variant == 'toolbar' ? 'true' : 'false' }}"
{# The toolbar's button is at the pane's left edge, a row's at its right:
the menu opens towards the side that has the room. #}
data-mail--spam-menu-align-value="{{ variant == 'toolbar' ? 'start' : 'end' }}"
class="relative {{ variant == 'row' ? 'z-10' : '' }}">
<button type="button"
data-action="click->mail--spam-menu#toggle"
aria-label="{{ 'toolbar.actions.spam'|trans }}"
title="{{ 'toolbar.actions.spam'|trans }}"
aria-haspopup="menu"
aria-expanded="false"
data-mail--spam-menu-target="trigger"
data-testid="spam-button"
{% if variant == 'row' %}
class="p-1.5 cursor-pointer rounded-lg text-ink-faint hover:text-danger hover:bg-danger-soft transition-colors"
{% else %}
class="flex items-center justify-center w-8 h-8 rounded-full
text-ink-muted hover:text-danger hover:bg-hover
transition-colors duration-100 cursor-pointer"
{% endif %}>
<i class="fa-solid fa-circle-exclamation {{ variant == 'row' ? 'w-4 h-4' : 'text-[15px]' }}" aria-hidden="true"></i>
</button>
{# A click anywhere in the panel stays in the panel. In a list it is a
descendant of the row, and a row opens its conversation when clicked.
`visible` for the same reason: a row's actions are `invisible` until the
row is hovered, visibility is inherited, and an open menu must not
depend on where the pointer happens to be. #}
<div data-mail--spam-menu-target="panel"
data-action="click->mail--spam-menu#stop"
role="menu"
aria-label="{{ 'toolbar.actions.spam'|trans }}"
class="hidden visible absolute {{ variant == 'toolbar' ? 'left-0' : 'right-0' }} top-full mt-1 z-30 w-80 max-h-96 overflow-y-auto rounded-pane
bg-pane-soft backdrop-blur-sm
border border-line
shadow-pop py-1 text-left"></div>
</div>
+13 -2
View File
@@ -652,7 +652,7 @@ be a worse regression than the one this feature fixes.
them. Stacked there is no hover, so it simply sits there. #}
<span class="absolute inset-0 flex items-center justify-end gap-1.5
text-xs text-ink-faint whitespace-nowrap
@xl:group-hover:invisible">
@xl:group-hover:invisible @xl:group-has-[[aria-expanded=true]]:invisible">
{# The visibility classes go on a wrapper, never on the <i>
itself: Font Awesome's own .fa-solid rule sets display,
and it lands after Tailwind's utilities, so `hidden` on
@@ -689,7 +689,11 @@ be a worse regression than the one this feature fixes.
{% endif %}
</span>
<div class="hidden @xl:flex absolute inset-0 items-center justify-end invisible group-hover:visible pointer-events-auto gap-1">
{# Shown on hover — and for as long as the spam menu hanging off
one of these buttons is open. That menu is a descendant, so
without the second rule a pointer that strays off the row on
its way to the menu hides the menu it was heading for. #}
<div class="hidden @xl:flex absolute inset-0 items-center justify-end invisible group-hover:visible has-[[aria-expanded=true]]:visible pointer-events-auto gap-1">
{% if false == draft and placement == 'normal' %}
<button type="button" data-action="click->mail--message-row#archive"
data-mail--message-row-archive-url-param="{{ path('app_status_archive', {'type': type, 'id': rowId}) }}"
@@ -714,6 +718,13 @@ be a worse regression than the one this feature fixes.
</button>
{% endif %}
{# The spam button, with its menu. Threads only: the menu's
routes take a conversation, and a row that is a single
message (a search hit, a draft) has Move to for this. #}
{% if false == draft and type == 'thread' and placement not in ['trash', 'spam'] %}
{% include '_partials/_spam_menu.html.twig' with { threadId: rowId, variant: 'row' } only %}
{% endif %}
{# Anywhere that is not the inbox: the way back.
The bin, spam AND the archive all had none — an archived
conversation could be reached from no action in the whole
+104
View File
@@ -0,0 +1,104 @@
{#
mail/_spam_menu_panel.html.twig — what the spam button's menu offers for one
conversation. Rendered by SpamController::menu() into the empty panel of
_partials/_spam_menu.html.twig.
Variables:
sender App\Domain\DTO\Mail\SpamSender|null — null when nobody but the user
wrote in this conversation, and then only the move is offered
The three choices are not equally dangerous and do not cost the same:
- moving, and filtering one sender, act on the first click;
- filtering a whole domain asks once more, in place, because it catches
addresses that have never written;
- and where the domain is a mail provider's (gmail.com, gmx.de), that choice
is not on the menu at all until "More options" is pressed — it is very
rarely what somebody means there, and a row that is always in view gets
clicked.
Every click is handled by mail--spam-menu, which owns the panel this lands in.
#}
{% set entry_class = 'w-full flex items-start gap-2.5 px-3 py-2 text-left
hover:bg-hover transition-colors cursor-pointer' %}
{% set heading_class = 'px-3 py-1 text-[10px] font-semibold uppercase tracking-wide text-ink-faint' %}
<p class="{{ heading_class }}">{{ 'spam_menu.heading'|trans }}</p>
<button type="button" role="menuitem"
data-action="click->mail--spam-menu#report"
data-spam-filter="none"
data-testid="spam-move"
class="{{ entry_class }}">
<i class="fa-solid fa-circle-exclamation w-3.5 mt-0.5 text-ink-faint" aria-hidden="true"></i>
<span class="min-w-0">
<span class="block text-sm text-ink">{{ 'spam_menu.move'|trans }}</span>
<span class="block text-xs text-ink-muted">{{ 'spam_menu.move_hint'|trans }}</span>
</span>
</button>
{% if sender %}
<div class="border-t border-line mt-1 pt-1">
<p class="{{ heading_class }}">{{ 'spam_menu.filter_heading'|trans }}</p>
<button type="button" role="menuitem"
data-action="click->mail--spam-menu#report"
data-spam-filter="sender"
data-testid="spam-filter-sender"
class="{{ entry_class }}">
<i class="fa-solid fa-user-slash w-3.5 mt-0.5 text-ink-faint" aria-hidden="true"></i>
<span class="min-w-0">
<span class="block text-sm text-ink">{{ 'spam_menu.sender'|trans }}</span>
<span class="block text-xs text-ink-muted font-mono break-all">{{ sender.address }}</span>
</span>
</button>
{% if sender.sharedDomain %}
<button type="button" role="menuitem"
data-mail--spam-menu-target="more"
data-action="click->mail--spam-menu#moreOptions"
data-testid="spam-more"
class="{{ entry_class }}">
<i class="fa-solid fa-ellipsis w-3.5 mt-0.5 text-ink-faint" aria-hidden="true"></i>
<span class="block text-sm text-ink-soft">{{ 'spam_menu.more'|trans }}</span>
</button>
{% endif %}
<button type="button" role="menuitem"
data-mail--spam-menu-target="domain"
data-action="click->mail--spam-menu#askDomain"
data-testid="spam-filter-domain"
{{ sender.sharedDomain ? 'hidden' : '' }}
class="{{ entry_class }}">
<i class="fa-solid fa-globe w-3.5 mt-0.5 text-ink-faint" aria-hidden="true"></i>
<span class="min-w-0">
<span class="block text-sm text-ink">{{ 'spam_menu.domain'|trans }}</span>
<span class="block text-xs text-ink-muted font-mono break-all">{{ sender.domainPattern }}</span>
</span>
</button>
{# The second click. In place of the row that asked for it, so the
pointer is already where the answer is. #}
<div data-mail--spam-menu-target="confirm" hidden
class="mx-2 my-1 rounded-lg bg-danger-soft px-3 py-2.5 space-y-2">
<p class="text-xs leading-relaxed text-danger">
{{ 'spam_menu.confirm_body'|trans({ '%domain%': sender.domain }) }}
{% if sender.sharedDomain %}{{ 'spam_menu.confirm_shared'|trans }}{% endif %}
</p>
<div class="flex flex-wrap gap-2">
<button type="button"
data-action="click->mail--spam-menu#report"
data-spam-filter="domain"
data-testid="spam-filter-domain-confirm"
class="inline-flex items-center h-7 px-2.5 rounded-lg text-xs font-medium text-danger border border-danger/40 hover:bg-danger-soft transition-colors cursor-pointer">
{{ 'spam_menu.confirm_yes'|trans }}
</button>
<button type="button"
data-action="click->mail--spam-menu#cancelDomain"
class="inline-flex items-center h-7 px-2.5 rounded-lg text-xs font-medium btn-quiet hover:bg-hover transition-colors cursor-pointer">
{{ 'spam_menu.confirm_cancel'|trans }}
</button>
</div>
</div>
</div>
{% endif %}
+8
View File
@@ -55,6 +55,14 @@ self-contained: no {% extends %}, no block dependencies.
{{ tbtn('click->mail--message-actions#trash', 'fa-solid fa-trash', 'toolbar.actions.delete'|trans) }}
{% endif %}
{# Beside Delete, because it is the same thought with a different
destination. Offered from the archive too — spam that was archived
by mistake is still spam — and not where the mail is already thrown
away. A controller of its own inside this one: see _spam_menu. #}
{% if placement not in ['trash', 'spam'] %}
{% include '_partials/_spam_menu.html.twig' with { threadId: thread.id, variant: 'toolbar' } only %}
{% endif %}
{{ tbtn('click->mail--message-actions#markUnread', 'fa-regular fa-envelope', 'toolbar.actions.mark_unread'|trans) }}
</div>
@@ -81,6 +81,8 @@ Variables:
'placeholder.number': 'settings.filters.placeholder.number'|trans,
'placeholder.date': 'settings.filters.placeholder.date'|trans,
'field.from': 'settings.filters.field.from'|trans,
'field.fromAddress': 'settings.filters.field.fromAddress'|trans,
'field.fromDomain': 'settings.filters.field.fromDomain'|trans,
'field.to': 'settings.filters.field.to'|trans,
'field.cc': 'settings.filters.field.cc'|trans,
'field.bcc': 'settings.filters.field.bcc'|trans,
@@ -16,6 +16,8 @@ Variables:
staying the ones still in the list they were moved from
label the target, for the toast
undoToken from StatusUndoService::remember()
message optional, already translated: what the toast says instead of
"Moved to …" — the spam button's, which may also have made a filter
#}
<!-- plmail-bulk: {{ count }} -->
@@ -33,6 +35,6 @@ Variables:
{% endfor %}
{% include 'thread/status/_undo_toast.stream.html.twig' with {
message: 'toast.moved'|trans({ '%folder%': label|label_name }),
message: message|default('toast.moved'|trans({ '%folder%': label|label_name })),
undoToken: undoToken,
} only %}
+410
View File
@@ -0,0 +1,410 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller\Mail;
use App\Domain\Enum\Mail\LabelRole;
use App\Entity\Mail\Message;
use App\Entity\Mail\MessageThread;
use App\Entity\Rule\MailRule;
use App\Service\Rule\RuleActionExecutor;
use App\Tests\Support\Mail\SeedsMarkerFixtures;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
/**
* The spam button: what its menu offers for a conversation, and what each
* choice does.
*
* The move is "Move to → Spam" and BulkMoveToTest pins that. What is pinned
* here is the part this button adds — who the sender is taken to be, the
* filter, and that an Undo takes the filter back with the move.
*/
final class SpamButtonTest extends WebTestCase
{
use SeedsMarkerFixtures;
private KernelBrowser $client;
private Connection $connection;
protected function setUp(): void
{
$this->client = static::createClient();
// Fixtures live in a transaction this test rolls back, and a rebooted
// kernel takes the connection holding it with them.
$this->client->disableReboot();
$container = static::getContainer();
$this->em = $container->get(EntityManagerInterface::class);
$this->connection = $container->get(Connection::class);
$this->connection->beginTransaction();
$this->user = $this->seedUser();
$this->account = $this->seedAccount();
$this->inbox = $this->seedLabel('Inbox', LabelRole::Inbox);
$this->client->loginUser($this->user);
}
protected function tearDown(): void
{
if (true === $this->connection->isTransactionActive()) {
$this->connection->rollBack();
}
parent::tearDown();
}
// ── the menu ─────────────────────────────────────────────────────────────
public function testTheMenuOffersTheMoveAndBothFilters(): void
{
$thread = $this->threadFrom('News@Shop-Deals.example');
$crawler = $this->client->request('GET', sprintf('/status/thread/%d/spam-menu', $thread->id));
self::assertResponseIsSuccessful();
self::assertCount(1, $crawler->filter('[data-spam-filter="none"]'));
self::assertStringContainsString('news@shop-deals.example', $crawler->filter('[data-spam-filter="sender"]')->text());
// The domain row is on the menu, asks first, and the button that acts
// is inside the question.
$domain = $crawler->filter('[data-testid="spam-filter-domain"]');
self::assertStringContainsString('@shop-deals.example', $domain->text());
self::assertNull($domain->attr('hidden'));
self::assertNull($domain->attr('data-spam-filter'), 'the first click on the domain row must not be the action');
self::assertNotNull($crawler->filter('[data-mail--spam-menu-target="confirm"]')->attr('hidden'));
self::assertCount(1, $crawler->filter('[data-mail--spam-menu-target="confirm"] [data-spam-filter="domain"]'));
self::assertCount(0, $crawler->filter('[data-testid="spam-more"]'));
}
/**
* gmail.com is not somebody's domain, it is everybody's. The domain row is
* there but out of sight, behind one more button.
*/
public function testAMailProvidersDomainIsBehindMoreOptions(): void
{
$thread = $this->threadFrom('someone@gmail.com');
$crawler = $this->client->request('GET', sprintf('/status/thread/%d/spam-menu', $thread->id));
self::assertResponseIsSuccessful();
self::assertCount(1, $crawler->filter('[data-testid="spam-more"]'));
self::assertNotNull($crawler->filter('[data-testid="spam-filter-domain"]')->attr('hidden'));
self::assertCount(1, $crawler->filter('[data-spam-filter="sender"]'));
}
/**
* The newest message somebody else wrote, not the newest message: the
* user's own reply is the last thing in the conversation and is nobody to
* filter.
*/
public function testTheSenderIsWhoeverElseWroteLast(): void
{
$thread = $this->threadFrom('first@old.example', '-3 days');
$this->addMessage($thread, 'second@new.example', '-2 days');
$this->addMessage($thread, (string) $this->account->email, '-1 day');
$crawler = $this->client->request('GET', sprintf('/status/thread/%d/spam-menu', $thread->id));
self::assertStringContainsString('second@new.example', $crawler->filter('[data-spam-filter="sender"]')->text());
}
public function testAConversationOfOnesOwnMailOffersOnlyTheMove(): void
{
$thread = $this->threadFrom((string) $this->account->email);
$crawler = $this->client->request('GET', sprintf('/status/thread/%d/spam-menu', $thread->id));
self::assertResponseIsSuccessful();
self::assertCount(1, $crawler->filter('[data-spam-filter="none"]'));
self::assertCount(0, $crawler->filter('[data-spam-filter="sender"]'));
self::assertCount(0, $crawler->filter('[data-spam-filter="domain"]'));
}
// ── the action ───────────────────────────────────────────────────────────
public function testMovingAloneMakesNoFilter(): void
{
$thread = $this->threadFrom('news@shop-deals.example');
$this->report($thread, ['filter' => 'none', 'scope' => 'inbox']);
self::assertSame(200, $this->client->getResponse()->getStatusCode());
$body = (string) $this->client->getResponse()->getContent();
self::assertStringContainsString(sprintf('action="remove" target="thread_%d"', $thread->id), $body);
self::assertMatchesRegularExpression('#/status/undo/[a-f0-9]{32}#', $body);
self::assertSame(['Spam'], $this->labelNames($thread));
self::assertSame([], $this->rules());
}
public function testFilteringTheSenderMakesAnOrdinaryRule(): void
{
$thread = $this->threadFrom('news@shop-deals.example');
$this->report($thread, ['filter' => 'sender', 'scope' => 'inbox']);
self::assertSame(200, $this->client->getResponse()->getStatusCode());
self::assertStringContainsString('news@shop-deals.example', (string) $this->client->getResponse()->getContent());
self::assertSame(['Spam'], $this->labelNames($thread));
$rules = $this->rules();
self::assertCount(1, $rules);
// The whole address, not `from`: a substring would also file
// xnews@shop-deals.example, and anybody whose display name held this.
self::assertSame(['operator' => 'AND', 'conditions' => [['fromAddress' => 'news@shop-deals.example']]], $rules[0]->conditions);
self::assertSame([['type' => RuleActionExecutor::MARK_SPAM]], $rules[0]->actions);
self::assertTrue($rules[0]->isEnabled);
self::assertNull($rules[0]->account, 'spam from this sender is spam in every mailbox');
self::assertStringContainsString('news@shop-deals.example', (string) $rules[0]->name);
}
public function testFilteringTheDomainMatchesOnTheDomain(): void
{
$thread = $this->threadFrom('news@shop-deals.example');
$this->report($thread, ['filter' => 'domain', 'scope' => 'inbox']);
self::assertSame(200, $this->client->getResponse()->getStatusCode());
$rules = $this->rules();
self::assertCount(1, $rules);
self::assertSame(['operator' => 'AND', 'conditions' => [['fromDomain' => 'shop-deals.example']]], $rules[0]->conditions);
self::assertStringContainsString('@shop-deals.example', (string) $rules[0]->name);
}
/**
* What the filter matches on is read off the conversation. A body that
* names an address of its own is not an instruction this route has.
*/
public function testAClientCannotNameWhoIsFiltered(): void
{
$thread = $this->threadFrom('news@shop-deals.example');
$this->report($thread, [
'filter' => 'sender',
'scope' => 'inbox',
'sender' => 'boss@work.example',
'address' => 'boss@work.example',
'from' => 'boss@work.example',
]);
self::assertSame('news@shop-deals.example', $this->rules()[0]->conditions['conditions'][0]['fromAddress']);
}
public function testASecondPressOnTheSameSenderMakesNoSecondRule(): void
{
$first = $this->threadFrom('news@shop-deals.example');
$second = $this->threadFrom('news@shop-deals.example');
$this->report($first, ['filter' => 'sender', 'scope' => 'inbox']);
$this->report($second, ['filter' => 'sender', 'scope' => 'inbox']);
self::assertSame(200, $this->client->getResponse()->getStatusCode());
self::assertCount(1, $this->rules());
}
public function testUndoTakesTheFilterBackWithTheMove(): void
{
$thread = $this->threadFrom('news@shop-deals.example');
$this->report($thread, ['filter' => 'sender', 'scope' => 'inbox']);
self::assertSame(1, preg_match('#/status/undo/[a-f0-9]{32}#', (string) $this->client->getResponse()->getContent(), $found));
self::assertCount(1, $this->rules());
$this->postTo($found[0], []);
self::assertSame(200, $this->client->getResponse()->getStatusCode());
self::assertSame(['Inbox'], $this->labelNames($thread));
self::assertSame([], $this->rules());
}
/**
* A rule that was there before the press is not this press's to take
* away: undoing the second report leaves the first one's filter standing.
*/
public function testUndoLeavesAFilterItDidNotMake(): void
{
$first = $this->threadFrom('news@shop-deals.example');
$second = $this->threadFrom('news@shop-deals.example');
$this->report($first, ['filter' => 'sender', 'scope' => 'inbox']);
$this->report($second, ['filter' => 'sender', 'scope' => 'inbox']);
self::assertSame(1, preg_match('#/status/undo/[a-f0-9]{32}#', (string) $this->client->getResponse()->getContent(), $found));
$this->postTo($found[0], []);
self::assertSame(['Inbox'], $this->labelNames($second));
self::assertCount(1, $this->rules());
}
public function testAnUnknownFilterIsRefusedBeforeAnythingMoves(): void
{
$thread = $this->threadFrom('news@shop-deals.example');
$this->report($thread, ['filter' => 'everyone', 'scope' => 'inbox']);
self::assertSame(400, $this->client->getResponse()->getStatusCode());
self::assertSame(['Inbox'], $this->labelNames($thread));
}
public function testAFilterWithNobodyToFilterIsRefusedBeforeAnythingMoves(): void
{
$thread = $this->threadFrom((string) $this->account->email);
$this->report($thread, ['filter' => 'sender', 'scope' => 'inbox']);
self::assertSame(400, $this->client->getResponse()->getStatusCode());
self::assertSame(['Inbox'], $this->labelNames($thread));
self::assertSame([], $this->rules());
}
public function testSomebodyElsesConversationIsRefused(): void
{
$thread = $this->threadFrom('news@shop-deals.example');
$stranger = $this->seedUser();
$this->client->loginUser($stranger);
$this->client->request('GET', sprintf('/status/thread/%d/spam-menu', $thread->id));
self::assertSame(403, $this->client->getResponse()->getStatusCode());
$this->report($thread, ['filter' => 'sender', 'scope' => 'inbox']);
self::assertSame(403, $this->client->getResponse()->getStatusCode());
self::assertSame(['Inbox'], $this->labelNames($thread));
}
// ── where the button is ──────────────────────────────────────────────────
public function testTheButtonIsInTheRowTheToolbarAndTheReadingPane(): void
{
$thread = $this->threadFrom('news@shop-deals.example');
$list = $this->client->request('GET', '/mail/inbox');
self::assertResponseIsSuccessful();
self::assertCount(1, $list->filter(sprintf('#thread_%d [data-controller="mail--spam-menu"]', $thread->id)));
self::assertCount(1, $list->filter('[data-action="click->mail--list-toolbar#spamSelected"]'));
$pane = $this->client->request('GET', sprintf('/mail/thread/%d', $thread->id));
self::assertResponseIsSuccessful();
self::assertGreaterThan(0, $pane->filter(sprintf(
'[data-mail--spam-menu-report-url-value="/status/thread/%d/spam"][data-mail--spam-menu-closes-pane-value="true"]',
$thread->id,
))->count());
}
// ── fixture ──────────────────────────────────────────────────────────────
/** A conversation in the Inbox whose one message is from $address. */
private function threadFrom(string $address, string $when = 'now'): MessageThread
{
$thread = $this->thread('From ' . $address, lastMessageAt: $when);
foreach ($thread->messages as $message) {
$message->fromAddress = $address;
$message->addLabel($this->inbox);
}
$this->em->flush();
return $thread;
}
private function addMessage(MessageThread $thread, string $from, string $when): void
{
$message = new Message();
$message->account = $this->account;
$message->thread = $thread;
$message->subject = (string) $thread->subject;
$message->fromAddress = $from;
$message->receivedAt = new DateTimeImmutable($when);
$message->sentAt = $message->receivedAt;
$message->seenAt = $message->receivedAt;
$message->flags = [];
$message->hasAttachments = false;
$message->addLabel($this->inbox);
$thread->addMessage($message);
$this->em->persist($message);
$this->em->flush();
}
/** @return list<MailRule> */
private function rules(): array
{
$this->em->clear();
return $this->em->getRepository(MailRule::class)->findBy(['usr' => $this->user->id], ['id' => 'ASC']);
}
/**
* Read back from the database rather than off the instance held here.
*
* @return list<string>
*/
private function labelNames(MessageThread $thread): array
{
$this->em->clear();
$fresh = $this->em->find(MessageThread::class, $thread->id);
self::assertNotNull($fresh);
$names = [];
foreach ($fresh->messages as $message) {
foreach ($message->labels as $label) {
$names[] = (string) $label->name;
}
}
sort($names);
return array_values(array_unique($names));
}
/** @param array<string, mixed> $body */
private function report(MessageThread $thread, array $body): void
{
$this->postTo(sprintf('/status/thread/%d/spam', $thread->id), $body);
}
/** @param array<string, mixed> $body */
private function postTo(string $url, array $body): void
{
// The `ajax` token, read the way the real caller reads it — from the
// layout's meta tag. See BulkMoveGuardTest::post().
$token = (string) $this->client->request('GET', '/mail/inbox')
->filter('meta[name="csrf-token"]')
->attr('content');
$this->client->request(
'POST',
$url,
[],
[],
['CONTENT_TYPE' => 'application/json', 'HTTP_X_CSRF_TOKEN' => $token],
(string) json_encode($body),
);
}
}
@@ -77,6 +77,18 @@ final class EmailFilterCompilerTest extends KernelTestCase
yield 'subject no match' => [['subject' => 'nothing matches this'], []];
yield 'from address' => [['from' => 'billing@'], [0]];
yield 'from display name' => [['from' => 'Acme'], [0]];
// The sender, whole. `from` above matched 'billing@' and 'Acme'; these
// match neither a part of an address nor a display name.
yield 'fromAddress' => [['fromAddress' => 'billing@acme.test'], [0]];
yield 'fromAddress ignores case' => [['fromAddress' => 'Billing@ACME.test'], [0]];
yield 'fromAddress is not a suffix' => [['fromAddress' => 'illing@acme.test'], []];
yield 'fromAddress is not a prefix' => [['fromAddress' => 'billing@acme.tes'], []];
yield 'fromAddress is not the name' => [['fromAddress' => 'acme@billing'], []];
yield 'fromDomain' => [['fromDomain' => 'example.test'], [1, 2]];
yield 'fromDomain ignores case' => [['fromDomain' => 'ACME.test'], [0]];
yield 'fromDomain is the whole one' => [['fromDomain' => 'cme.test'], []];
yield 'fromDomain is not a parent' => [['fromDomain' => 'test'], []];
yield 'fromDomain escapes wildcards' => [['fromDomain' => 'ex_mple.test'], []];
yield 'to' => [['to' => 'ops@example.test'], [0]];
yield 'cc' => [['cc' => 'cc@example.test'], [1]];
yield 'body' => [['body' => 'wire transfer'], [0]];
@@ -42,6 +42,8 @@ final class FilterAstValidatorTest extends TestCase
// real stemming, which no PHP-side twin could have reproduced.
yield 'full text' => [['text' => 'invoice']];
yield 'list id' => [['listId' => 'acme.test']];
yield 'exact sender' => [['fromAddress' => 'billing@acme.test']];
yield 'sender domain' => [['fromDomain' => 'acme.test']];
yield 'operator tree' => [[
'operator' => 'AND',
'conditions' => [
@@ -76,6 +78,14 @@ final class FilterAstValidatorTest extends TestCase
yield 'empty group' => [['operator' => 'AND', 'conditions' => []]];
yield 'stray key on group' => [['operator' => 'AND', 'conditions' => [['subject' => 'x']], 'subject' => 'y']];
yield 'blank text' => [['subject' => ' ']];
// The sender conditions compare whole values, so a value that is not
// one would be stored and then match nothing, silently.
yield 'sender without an @' => [['fromAddress' => 'acme.test']];
yield 'sender with no local part' => [['fromAddress' => '@acme.test']];
yield 'sender with a space' => [['fromAddress' => 'billing @acme.test']];
yield 'domain with an @' => [['fromDomain' => '@acme.test']];
yield 'domain given an address' => [['fromDomain' => 'billing@acme.test']];
yield 'blank domain' => [['fromDomain' => '']];
yield 'text given a number' => [['subject' => 42]];
yield 'size given a string' => [['minSize' => '100']];
yield 'bool given a string' => [['hasAttachment' => 'yes']];
+18
View File
@@ -3019,6 +3019,8 @@ settings:
cc: Cc enthält
filename: Anhang heißt
from: Von enthält
fromAddress: 'Von ist genau'
fromDomain: 'Absender-Domain ist'
hasAttachment: Hat einen Anhang
hasKeyword: Ist
hasLabel: Hat Label
@@ -3083,6 +3085,7 @@ settings:
running: 'Wird angewendet… bisher %count%'
run_confirm: '"%name%" auf vorhandene Mail anwenden? Das kann viele Nachrichten auf einmal verschieben und neu beschriften.'
saved: Filter gespeichert.
spam_rule_name: 'Spam: %sender%'
stop: Nach diesem Filter aufhören
stop_help: Für Nachrichten, auf die dieser Filter zutrifft, werden spätere Filter übersprungen.
subheading: Mail automatisch beim Eintreffen sortieren.
@@ -3932,6 +3935,7 @@ toast:
send_too_late: Zu spät — diese Nachricht wurde bereits gesendet.
send_failed: 'Diese Nachricht konnte nicht gesendet werden: %subject%'
sent: Nachricht gesendet.
spam_filtered: 'In den Spam verschoben. Künftige Mail von %sender% landet auch dort.'
undo: Rückgängig
undo_expired: Das lässt sich nicht mehr rückgängig machen.
undone: Rückgängig gemacht.
@@ -3951,6 +3955,7 @@ toolbar:
mark_unread: Als ungelesen markieren
move_to: Verschieben nach
snooze: Zurückstellen
spam: 'Als Spam markieren'
filter:
show_all: Alle anzeigen
unread_only: Nur ungelesene
@@ -3971,6 +3976,19 @@ spam:
empty: Keine Spam-Nachrichten.
heading: Spam
spam_menu:
confirm_body: 'Mail von jeder Adresse bei %domain% landet dann im Spam, auch von Leuten, die dir noch nie geschrieben haben.'
confirm_cancel: 'Abbrechen'
confirm_shared: 'Das ist ein Mail-Anbieter, den viele nutzen.'
confirm_yes: 'Ganze Domain filtern'
domain: 'Diese ganze Domain immer in den Spam'
filter_heading: 'Und künftige Mail filtern'
heading: 'Als Spam markieren'
more: 'Weitere Optionen'
move: 'In den Spam verschieben'
move_hint: 'Nur diese Unterhaltung.'
sender: 'Diesen Absender immer in den Spam'
trash:
empty: Keine gelöschten Nachrichten.
heading: Papierkorb
+18
View File
@@ -3029,6 +3029,8 @@ settings:
cc: Cc contains
filename: Attachment named
from: From contains
fromAddress: 'From is exactly'
fromDomain: 'Sender''s domain is'
hasAttachment: Has an attachment
hasKeyword: Is
hasLabel: Has label
@@ -3092,6 +3094,7 @@ settings:
running: 'Applying… %count% so far'
run_confirm: 'Apply "%name%" to mail you already have? This can move and relabel a lot of messages at once.'
saved: Filter saved.
spam_rule_name: 'Spam: %sender%'
stop: Stop after this filter
stop_help: Later filters are skipped for any message this one matches.
subheading: Sort mail automatically as it arrives.
@@ -3933,6 +3936,7 @@ toast:
send_too_late: Too late — that message had already been sent.
send_failed: 'That message could not be sent: %subject%'
sent: Message sent.
spam_filtered: 'Moved to Spam. Future mail from %sender% goes there too.'
undo: Undo
# The Undo was pressed twice, or after newer actions pushed it out. Said
# plainly: the button promised to put something back and has not.
@@ -3957,6 +3961,7 @@ toolbar:
mark_unread: Mark as unread
move_to: Move to
snooze: Snooze
spam: 'Mark as spam'
filter:
show_all: Show all
unread_only: Unread only
@@ -3977,6 +3982,19 @@ spam:
empty: No junk mail.
heading: Spam
spam_menu:
confirm_body: 'Mail from every address at %domain% will go to Spam, including people who have never written to you.'
confirm_cancel: 'Cancel'
confirm_shared: 'This is a mail provider that many people use.'
confirm_yes: 'Filter the whole domain'
domain: 'Always move this whole domain to Spam'
filter_heading: 'And filter future mail'
heading: 'Mark as spam'
more: 'More options'
move: 'Move to Spam'
move_hint: 'Only this conversation.'
sender: 'Always move this sender to Spam'
trash:
empty: No deleted messages.
heading: Trash
+18
View File
@@ -2871,6 +2871,8 @@ settings:
cc: Cc carries
filename: Cargo named
from: From carries
fromAddress: 'From be exactly'
fromDomain: 'Sender''s home port be'
hasAttachment: Carries cargo
hasKeyword: Be
hasLabel: Flies flag
@@ -2930,6 +2932,7 @@ settings:
running: 'Workin''… %count% so far'
run_confirm: 'Run "%name%" over the yarns already aboard? It can shift and re-flag a great many at once.'
saved: Sieve saved.
spam_rule_name: 'Bilge: %sender%'
stop: Stop after this sieve
stop_help: Later sieves be skipped for any yarn this one catches.
subheading: Sort yarns automatic-like as they come aboard.
@@ -3719,6 +3722,7 @@ toast:
send_too_late: Too late, matey — that message has already sailed.
send_failed: 'That message could not be hoisted: %subject%'
sent: Message away!
spam_filtered: 'Pumped into th'' bilge. Whatever %sender% sends next goes th'' same way.'
undo: Belay that
undo_expired: Too late to belay that, matey.
undone: Belayed.
@@ -3738,6 +3742,7 @@ toolbar:
mark_unread: Mark as unread
move_to: Haul off to
snooze: Let it drift
spam: 'Call it bilge'
filter:
show_all: Show every last one
unread_only: Unread only
@@ -3758,6 +3763,19 @@ spam:
empty: No bilge aboard.
heading: Bilge
spam_menu:
confirm_body: 'Letters from every hand at %domain% will go to th'' bilge, even them as never wrote ye.'
confirm_cancel: 'Belay that'
confirm_shared: 'This be a port half th'' seven seas sail from.'
confirm_yes: 'Bilge th'' whole port'
domain: 'Always send this whole port to th'' bilge'
filter_heading: 'An'' sort what comes after'
heading: 'Call it bilge'
more: 'More choices'
move: 'Pump into th'' bilge'
move_hint: 'Only this parley.'
sender: 'Always send this hand to th'' bilge'
trash:
empty: Naught fed to the fishes.
heading: Davy Jones' locker