mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 01:54:10 +00:00
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:
1 parent
ec938bca99
commit
4656f8874e
32 files changed
+1574
-10
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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']];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user