Answer from the address a mail came in for, under its own name, and take a signature as HTML

Three things about who a mail is from, grouped as v0.3.5.

The sender name (#70). EmailAlias::$displayName was shown in the From
selector and published as the JMAP identity name, and nothing sent it:
MessageSendService built From with the account's name whatever the
address. There was also no field to set it in the browser.

  Account::senderNameFor()   the alias's name, else the account's; the
                             selector, the draft row and the From header
                             all read it
  Settings -> Aliases        a sender name field under each address
  EmailSubmission/set        writes fromName with the address it resolves

The address a reply goes out from (#41, the reply half). Every window
was pre-set from the account alone, so a reply to mail for an alias
opened on the primary address, and a draft saved from an alias reopened
on the primary and was stored that way by the next autosave.

  ReplyFromResolver          the first of From (own mail), To, Cc, then
                             Delivered-To / X-Original-To / Envelope-To
                             that the account may send as
  SenderResolver::tokenFor() opens a draft on the address it carries
  catch-all domains          an account setting, not an alias row: any
                             address at one may be answered from, is
                             left out of a reply-all, and signs with the
                             account signature. ComposeType admits such
                             a token on submit by the same rule. Not
                             offered for Gmail and Microsoft accounts.

Typing an arbitrary From into a new message is not in this.

Signatures as HTML (#32). Pasting source into the contenteditable gave
the source back, and a linked image never showed, the page's img-src
being 'self' data: blob:.

  Rendered | HTML            a textarea beside the editor. Rendered asks
                             the server (signature/preview) and shows
                             what a save would keep, with the elements
                             and attributes the sanitiser dropped named
                             underneath
  SignatureImporter          sanitises, and fetches https images through
                             ImageProxyFetcher into data: sources, 256 KB
                             each and 768 KB a signature; the rest stay
                             linked and are counted
  DataImageEmbedder          on send, a data: image becomes an inline
                             part and a cid: reference, since Gmail and
                             Outlook draw no data URI. It reads the
                             entity-encoded form the sanitiser stores
                             (= for the padding).

No migration: the catch-all domains live in Account::$settings and are
carried by a config backup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Paul LütznerandClaude Opus 5.5 committed 2026-10-13 12:39:40 +02:00
1 parent 780ed01de9
commit 76bf97c82e
42 files changed
+2990 -81

No files matched your search

+28
View File
@@ -6,6 +6,34 @@ so anything that changes the schema irreversibly is called out explicitly.
The published image tags: `latest` follows the most recent release below,
`main` follows the tip of the default branch, and `sha-…` pins one commit.
## Unreleased
**An address can send under its own name.** Settings → Aliases has a sender name field under each
address; left empty, the address sends under the account's name as before. The name was already
shown in the From selector for an alias that had one (a phone app could set it), but the mail
itself went out under the account's name regardless — the selector and the recipient now see the
same one, in the browser and from the apps. No
migration. [#70](https://github.com/karatektus/pl_mail/issues/70), reported by @Node815.
**A reply goes out from the address the mail was sent to.** Mail that came in for an alias is
answered from that alias, with that alias's signature, instead of every reply opening on the
primary address; answering your own sent mail keeps the address it was sent from. A domain whose
every address reaches the mailbox can be added under Settings → Aliases → Catch-all domains, and
mail to any address at it is then answered from that address without adding each one as an alias
(not offered for Gmail and Outlook accounts, which send only as addresses the provider knows).
Reopening a draft saved from an alias no longer puts it back on the primary address. Typing an
arbitrary From address into a new message is not part of this. No
migration. [#41](https://github.com/karatektus/pl_mail/issues/41), reported by @YoGo9.
**A signature can be pasted as HTML, and its images travel with the mail.** The signature editor
has two views, Rendered and HTML. HTML takes the source; Rendered shows it as it will be stored,
cleaned by the server, with a line underneath naming what was removed (usually a `<style>` block
and `class` attributes). An image a signature links to is fetched and kept in the signature — up
to 256 KB each — so it shows in the editor, which it could not before, and is sent as an attached
inline image instead of a link recipients have to allow. Existing signatures are unchanged until
they are next saved. No
migration. [#32](https://github.com/karatektus/pl_mail/issues/32), reported by @eatcodeplay.
## v0.3.4 — 2026-10-13
**A Gmail import no longer runs into Google's quota and stays there.** Google lowered the
@@ -1493,7 +1493,12 @@ export default class extends Controller {
_signatureFor(token) {
const map = this.hasSignaturesValue ? this.signaturesValue : {};
return map?.[token] ?? '';
// An address at a catch-all domain has no entry of its own — the
// server cannot list every local part — so it signs with the account's
// wildcard entry, `<accountId>|*`. See SignatureProvider::tokenMap().
const wildcard = `${String(token).split('|')[0]}|*`;
return map?.[token] ?? map?.[wildcard] ?? '';
}
_currentSignature() {
@@ -0,0 +1,167 @@
import { Controller } from "@hotwired/stimulus";
/**
* The signature editor's second view: the same signature, as HTML source.
*
* The editor is a contenteditable, which is right for a signature somebody
* types and useless for one they already have — out of a generator, a brand
* guide, another mail client. Pasting that source into a contenteditable gets
* you the source, angle brackets and all. So there is a textarea to paste it
* into, and a pair of buttons to move between the two.
*
* THE TWO DIRECTIONS ARE NOT THE SAME ACT
*
* Rendered → HTML is local: the textarea is filled with the editor's markup.
*
* HTML → Rendered asks the server. The source is posted to the preview route,
* which runs the import a save would run — the sanitiser, and fetching remote
* images into the signature — and what comes back is what is shown. The point
* is that the rendered view never flatters: a `<style>` block the server is
* going to drop is already gone from what the user is looking at, and the line
* under the editor says so by name. Rendering the pasted source in the browser
* instead would show a signature that the next save quietly made worse.
*
* The hidden field the form posts follows whichever view is up. In the source
* view it is the textarea's text, so pressing Save without rendering first
* saves what was typed — through the same import, on the server.
*
* | Targets | |
* |----------------|--------------------------------------------------------|
* | editor | the contenteditable (shared with compose--compose-toolbar) |
* | source | the textarea |
* | field | the hidden input the form posts |
* | formatting | the format buttons, which only act on the editor |
* | renderedButton | shows the rendered view |
* | sourceButton | shows the source view |
* | report | what the last render removed, embedded or left remote |
*
* Values:
* url — the preview route
* token — csrf_token() for that route
* i18n — the report's sentences, translated server-side
*/
export default class extends Controller {
static targets = ["editor", "source", "field", "formatting", "renderedButton", "sourceButton", "report"];
static values = {
url: String,
token: String,
i18n: { type: Object, default: {} },
};
showSource(event) {
event?.preventDefault();
if (false === this.sourceTarget.hidden) {
return;
}
this.sourceTarget.value = this.editorTarget.innerHTML.trim();
this.#show("source");
this.sourceTarget.focus();
}
async showRendered(event) {
event?.preventDefault();
if (true === this.sourceTarget.hidden || true === this._rendering) {
return;
}
this._rendering = true;
this.renderedButtonTarget.disabled = true;
try {
const imported = await this.#preview(this.sourceTarget.value);
// Already sanitised by the server — the same string a save would
// store and the page would print back into this element.
this.editorTarget.innerHTML = imported.html;
this.fieldTarget.value = imported.html;
this.#report(imported);
this.#show("rendered");
} catch {
// Stay in the source view: the text is the only copy of what was
// typed, and swapping it for a stale rendering would lose it.
this.#say(this.i18nValue.failed ?? "");
} finally {
this._rendering = false;
this.renderedButtonTarget.disabled = false;
}
}
/** The source view is what gets saved while it is the one showing. */
typed() {
this.fieldTarget.value = this.sourceTarget.value;
}
// ── Private ───────────────────────────────────────────────────────────
async #preview(html) {
const body = new FormData();
body.set("_token", this.tokenValue);
body.set("signature", html);
const response = await fetch(this.urlValue, {
method: "POST",
body,
headers: { Accept: "application/json" },
credentials: "same-origin",
});
if (false === response.ok) {
throw new Error(`preview answered ${response.status}`);
}
return response.json();
}
#show(view) {
const source = "source" === view;
this.sourceTarget.hidden = false === source;
this.editorTarget.hidden = source;
this.formattingTarget.classList.toggle("invisible", source);
this.sourceButtonTarget.setAttribute("aria-pressed", String(source));
this.renderedButtonTarget.setAttribute("aria-pressed", String(false === source));
if (true === source) {
this.fieldTarget.value = this.sourceTarget.value;
}
}
/**
* One line, built from what the import reported. Elements are written as
* tags and attributes as bare names, which is how they were spelled in the
* source the user is being told about.
*/
#report(imported) {
const removed = [
...(imported.removedElements ?? []).map((name) => `<${name}>`),
...(imported.removedAttributes ?? []),
];
const parts = [];
parts.push(
removed.length > 0
? (this.i18nValue.removed ?? "").replace("%items%", removed.join(", "))
: (this.i18nValue.clean ?? ""),
);
if ((imported.embeddedImages ?? 0) > 0) {
parts.push((this.i18nValue.embedded ?? "").replace("%count%", String(imported.embeddedImages)));
}
if ((imported.remoteImages ?? 0) > 0) {
parts.push((this.i18nValue.remote ?? "").replace("%count%", String(imported.remoteImages)));
}
this.#say(parts.filter((part) => "" !== part).join(" "));
}
#say(text) {
this.reportTarget.textContent = text;
this.reportTarget.hidden = "" === text;
}
}
+31 -1
View File
@@ -1,4 +1,4 @@
<!-- translated-from: features/accounts.md sha1:ca0e4734b8a3295ffaa110d32f4d65e64762172b -->
<!-- translated-from: features/accounts.md sha1:c4ffdb6325cb4ee4e9c2accbaa93cec3e4400496 -->
# Konten und Aliase
@@ -237,6 +237,36 @@ Schreibfenster nicht mehr angeboten.
Die **From**-Auswahl im Schreibfenster listet Konten und Aliase gemeinsam auf, sodass das Senden
unter einer Aliasadresse eine Wahl ist und nicht zwei.
Eine **Antwort** öffnet sich mit der Adresse, an die die Mail gerichtet war. Was für einen Alias
ankam, wird von diesem Alias beantwortet, ohne dass du ihn auswählst; eine Mail, die keine Adresse
des Kontos nennt — eine Liste, ein Bcc, von dem der Server keine Spur gelassen hat — wird von der
primären beantwortet. Antwortest du auf eine Mail, die du selbst geschickt hast, bleibt die
Adresse, von der du sie geschickt hast. Die Auswahl ist weiterhin da, um das zu ändern.
### Der Name, unter dem eine Adresse sendet
Unter jeder Adresse steht ein Feld für den **Absendernamen**: das, was die Empfängerin neben der
Adresse sieht. Bleibt es leer, sendet die Adresse unter dem Namen des Kontos, und genau den zeigt
das Feld ausgegraut. Eine Familienadresse in einem privaten Konto kann „Herr und Frau Schmidt“
heißen, während das Konto „Jan Schmidt“ bleibt. Die From-Auswahl zeigt denselben Namen, mit dem die
Mail hinausgeht.
### Catch-all-Domains
Wenn dein Anbieter **jede** Adresse einer Domain in dieses Postfach zustellt, trag die Domain unter
**Catch-all-Domains** ein. plMail behandelt dann jede Adresse dieser Domain beim Antworten als
deine: Eine Antwort auf eine Mail an `irgendwas@example.com` geht von `irgendwas@example.com` aus,
mit dem Namen und der Signatur des Kontos, und in deinem eigenen „Allen antworten“ stehst du nicht
selbst. Du musst vorher keinen Alias anlegen, und an eingehender Mail ändert sich nichts.
Drei Dinge tut es nicht. Es gilt für genau eine Domain — `example.com` deckt `mail.example.com`
nicht ab. Es gilt für Antworten und Weiterleitungen; eine neue Nachricht geht weiterhin von einer
Adresse aus der Liste oben aus. Und für Gmail- und Outlook-Konten wird es nicht angeboten, denn die
senden nur über Adressen, die der Anbieter selbst kennt, und schreiben jede andere um oder lehnen
sie ab. plMail prüft nicht, ob die Domain wirklich ein Catch-all ist oder ob dein Anbieter dich von
dort senden lässt: Das ist eine Sache zwischen dir und dem Anbieter, und ein falscher Eintrag zeigt
sich bei der ersten Antwort als abgelehnter oder umgeschriebener Absender.
Einen Vorbehalt nennt plMail auf der Seite selbst: **Outlook versendet unabhängig von deiner
Wahl hier immer über den primären Alias des Kontos.** Das ist Microsofts Verhalten, geändert
wird es in deinen Microsoft-Kontoeinstellungen und nicht hier.
+30 -8
View File
@@ -1,4 +1,4 @@
<!-- translated-from: features/mail.md sha1:d9a7adcb753fe43c9c0fd2cff88d98382f05cefb -->
<!-- translated-from: features/mail.md sha1:74ff626a6379dafc150e11f5b19e81b76abfc40d -->
# Mail
@@ -837,7 +837,9 @@ materialisiert.
Konversation heraus, öffnet sich der Editor auf einem breiten Bildschirm stattdessen am Fuß der
Konversation; darunter fällt er auf das angedockte Fenster zurück. So oder so bietet das
Fenster formatierten Text, Kontakt-Vervollständigung in den Adressfeldern und eine
**From**-Auswahl, die jedes aktive Konto und jede Absendeadresse darauf auflistet.
**From**-Auswahl, die jedes aktive Konto und jede Absendeadresse darauf auflistet. Eine Antwort
öffnet sich mit der Adresse, an die die Mail gerichtet war; siehe
[Konten und Aliase](accounts.md).
Die Vervollständigung bietet ausschließlich **deine eigenen** gesammelten Kontakte an. Auf einer
Installation mit mehreren Personen sind die Korrespondenten der einen für die andere unsichtbar, und
@@ -921,6 +923,27 @@ willst. Dieser Knopf ersetzt den Block an Ort und Stelle, statt einen zweiten an
dasselbe tut ein Wechsel des Kontos in der **From**-Auswahl: Der Signaturblock wird getauscht, ein
bereits getippter Absatz überlebt den Wechsel.
**Eine Signatur, die du schon als HTML hast** — aus einem Generator, einem Markenhandbuch, einem
anderen Mailprogramm — kommt über die **HTML**-Ansicht des Editors hinein. **Darstellung** und
**HTML** über dem Feld wechseln zwischen beiden: HTML ist die Signatur als Quelltext, zum Einfügen
oder Bearbeiten; Darstellung ist die Signatur so, wie sie gespeichert wird. Der Wechsel zur
Darstellung ist nicht der Browser, der zeichnet, was du eingefügt hast. Der Quelltext geht an den
Server, durch dieselbe Bereinigung, durch die jede Signatur gespeichert wird, und was zurückkommt,
ist das, was du siehst — die Darstellung kann also nicht besser aussehen als das, was Empfänger
bekommen. Eine Zeile unter dem Editor nennt alles, was entfernt wurde: typischerweise einen
`<style>`-Block und die `class`-Attribute, die darauf verwiesen, denn die meisten Mailprogramme
ignorieren beides. Schreib die Gestaltung an die Elemente selbst (`style="…"`), das bleibt
erhalten. Speichern aus beiden Ansichten legt dasselbe ab.
**Bilder in einer Signatur werden in der Signatur gespeichert.** Ein Bild, auf das per Adresse
verwiesen wird (`<img src="https://…">`), wird geholt, wenn du darstellst oder speicherst, und in
der Signatur selbst abgelegt. Es reist dann mit jeder Mail als eingebettetes Bild mit — die
Empfängerin sieht dein Logo, ohne externe Inhalte laden zu müssen, und es funktioniert auch dann
noch, wenn die Webadresse verschwindet. Ein Bild bleibt stattdessen ein bloßer Verweis, wenn es
sich nicht über https holen lässt, kein PNG, JPEG, GIF oder WebP ist oder größer als 256 KB ist
(768 KB für eine Signatur insgesamt); die Zeile unter dem Editor zählt diese, und im Editor
erscheinen sie nicht, weil er keine Bilder von anderswo lädt.
Schreibst du direkt in der Konversation, bekommst du eine **verkürzte Kopfzeile**: das
**An**-Feld selbst, tippbar, und dahinter — hinter dem Pfeil daneben — eingeklappt Absender, Cc,
Bcc und Betreff. Bei einer Antwort änderst du die Empfängerin selten, bei einer Weiterleitung
@@ -1111,12 +1134,11 @@ zurückzugeben, die bereits unterwegs ist, und das liest sich wie ein Abbruch, d
ist kein „aus dieser Ansicht entfernen“ — der Dialog ist eine Abkürzung zu genau derselben Operation,
über alle Konten hinweg, untergeordnete Labels eingeschlossen.
**Ein Bild in der Signatur kann kein eingebettetes Bild sein.** Ein Bild in einer *Signatur* wird
als gewöhnlicher Bildverweis gespeichert und nicht als `cid:`-Teil: Die Bereinigung, durch die
jede Signatur geschrieben wird, entfernt bewusst genau das Attribut, das daraus eines machen
würde — damit eine Signatur nichts einschmuggeln kann, das aussieht wie eines der eingebetteten
Bilder der Nachricht selbst. Eingebettete Bilder funktionieren im Nachrichtentext, dort, wo du sie
hinsetzt.
**Ein Bild in der Signatur muss klein sein, um mit der Mail zu reisen.** Ein Bild in einer
*Signatur* wird in ihr gespeichert und als eingebetteter Teil verschickt, bis 256 KB je Bild und
768 KB je Signatur; siehe den Abschnitt *Signatur* weiter oben. Alles Größere, ein SVG oder ein
Bild, das sich nicht holen ließ, bleibt ein Verweis auf seine Webadresse, den Empfänger
möglicherweise erst erlauben müssen.
**„Neu“ ist nicht „ungelesen“, und das Fähnchen zu verlieren heißt nicht, etwas gelesen zu
haben.** An einer Zeile im Posteingang vorbeizuscrollen räumt ihr „Neu“ ab, denn die Zeile wurde
+27
View File
@@ -209,6 +209,33 @@ Disabled addresses stay on the list and stop being offered in the compose window
The **From** selector in the compose window lists accounts and aliases together, so sending as an
alias is one choice rather than two.
A **reply** opens on the address the mail was sent to. Mail that came in for an alias is answered
from that alias, without you picking it; mail that names none of the account's addresses — a list, a
Bcc the server left no trace of — is answered from the primary one. Answering mail you sent yourself
keeps the address you sent it from. The selector is still there to change it.
### The name an address sends under
Under each address is a field for the **sender name**: what the recipient sees beside the address.
Left empty, the address sends under the account's name, which is what the field shows greyed out. A
family address on a personal account can be "Mr and Mrs Smith" while the account stays "John
Smith". The From selector shows the same name the mail goes out with.
### Catch-all domains
If your provider delivers **every** address at a domain to this mailbox, add the domain under
**Catch-all domains**. plMail then treats any address at that domain as yours for answering: a
reply to mail sent to `anything@example.com` goes out from `anything@example.com`, with the
account's name and signature, and you are left out of your own reply-all. No alias has to be added
first, and nothing changes for mail that arrives.
Three things it does not do. It is one domain exactly — `example.com` does not cover
`mail.example.com`. It applies to replies and forwards; a new message is still sent from an address
on the list above. And it is not offered for Gmail or Outlook accounts, which send only as addresses
the provider itself knows about and rewrite or refuse any other. plMail does not check that the
domain really is a catch-all, or that your provider lets you send from it: that is between you and
the provider, and a wrong entry shows up as a refused or rewritten sender on the first reply.
One caveat plMail states on the page itself: **Outlook sends as the account's primary alias
regardless of what you choose here.** That is Microsoft's behaviour, changed in your Microsoft
account settings, not here.
+24 -6
View File
@@ -763,7 +763,8 @@ attachment has never touched plMail's disk; it is materialised on first access.
**Compose** opens a window docked at the bottom right. Replying from inside a thread opens the
editor at the foot of the conversation instead, on a wide screen; below that it falls back to the
dock. Either way the window offers rich text, contact autocomplete on the address fields, and a
**From** selector listing every active account and every sending alias on it.
**From** selector listing every active account and every sending alias on it. A reply opens on the
address the mail was sent to; see [Accounts and aliases](accounts.md#sending-aliases).
Autocomplete only ever offers **your own** harvested contacts. On an install with more than one
person, nobody's correspondents are visible to anybody else, and an address belonging to another
@@ -839,6 +840,24 @@ leave without; **Insert signature** adds the block when you want it. That button
in place instead of adding a second one, and so does changing the **From** account: the signature
block is swapped and a paragraph you have already typed survives the change.
**A signature you already have as HTML** — from a generator, a brand guide, another mail client —
goes in through the **HTML** view of the editor. **Rendered** and **HTML** above the field switch
between the two: HTML is the signature as source, to paste or edit; Rendered is the signature as it
will be stored. Switching to Rendered is not the browser drawing what you pasted. The source goes to
the server, through the same clean-up every signature is saved through, and what comes back is what
you see — so the rendered view cannot look better than what recipients get. A line under the editor
names anything that was taken out: typically a `<style>` block and the `class` attributes that
pointed into it, since most mail clients ignore both. Put the styling on the elements themselves
(`style="…"`), which is kept. Saving from either view stores the same thing.
**Images in a signature are stored in the signature.** A picture referenced by address
(`<img src="https://…">`) is fetched when you render or save and kept inside the signature
itself. It then travels with every mail as an attached inline image — the recipient sees your logo
without being asked to load remote content, and it keeps working after the web address stops. An
image stays a plain reference instead when it cannot be fetched over https, is not a PNG, JPEG, GIF
or WebP, or is larger than 256 KB (768 KB for one signature in all); the line under the editor
counts those, and they do not show in the editor, which loads no images from elsewhere.
Writing inside a conversation gets a **reduced header**: the **To** field itself, typable, with
From, Cc, Bcc and the subject folded away behind the chevron beside it. Replying rarely means
retargeting — but forwarding always does, and the recipient is the one thing a forward is missing,
@@ -1013,11 +1032,10 @@ the search box says how far it has got, and until it says nothing at all, "meani
from this view" — the dialog is a shortcut to the same operation, across every account, nested labels
included.
**A signature image cannot be an inline image.** A picture placed in a *signature* is stored as an
ordinary image reference, not as a `cid:` part: the sanitiser every signature is written through
drops the marker attribute that would make it one, deliberately, so a signature cannot smuggle in
something that looks like one of the message's own inline pictures. Inline images work in the
message body, where you put them.
**A signature image has to be small to travel with the mail.** A picture in a *signature* is stored
inside it and sent as an inline part, up to 256 KB each and 768 KB per signature; see
[Signature](#signature). Anything larger, an SVG, or an image that could not be fetched stays a
reference to its web address, which recipients may have to allow before it shows.
**"New" is not "unread", and losing the badge is not reading anything.** Scrolling past a row in the
inbox retires its New badge, because the row was put in front of you. The conversation stays unread
+4 -4
View File
@@ -137,7 +137,7 @@ class ComposeController extends AbstractController
}
$form = $this->formFactory->create($message, $ctx, $this->currentUser());
$form->get('account')->setData($this->senders->token($account));
$form->get('account')->setData($this->senders->tokenFor($message, $account));
$this->addressFields->hydrate($form, $message, $this->getUser());
return $this->renderWindow($form, $message, $ctx);
@@ -252,7 +252,7 @@ class ComposeController extends AbstractController
private function composeWindow(ComposeContext $ctx, Message $draft, Account $account): Response
{
$form = $this->formFactory->create($draft, $ctx, $this->currentUser());
$form->get('account')->setData($this->senders->token($account));
$form->get('account')->setData($this->senders->tokenFor($draft, $account));
$this->addressFields->hydrate($form, $draft, $this->getUser());
return $this->renderWindow($form, $draft, $ctx);
@@ -280,7 +280,7 @@ class ComposeController extends AbstractController
$ctx = $ctx->withMode(ComposeContext::MODE_FORWARD);
$form = $this->formFactory->create($draft, $ctx, $this->currentUser());
$form->get('account')->setData($this->senders->token($account));
$form->get('account')->setData($this->senders->tokenFor($draft, $account));
$this->addressFields->hydrate($form, $draft, $this->getUser());
return $this->renderWindow($form, $draft, $ctx);
@@ -547,7 +547,7 @@ class ComposeController extends AbstractController
}
$form = $this->formFactory->create($message, $ctx, $this->currentUser());
$form->get('account')->setData($this->senders->token($message->account));
$form->get('account')->setData($this->senders->tokenFor($message, $message->account));
$this->addressFields->hydrate($form, $message, $this->getUser());
// Inline: pull the message back out of the thread and re-open the
+114
View File
@@ -133,6 +133,92 @@ final class AliasController extends AbstractController
return $this->streamResponse($request, $account, 'alias.deleted');
}
/**
* The name mail from this address goes out under.
*
* Empty clears it, and that is an answer rather than an error: the address
* goes back to sending under the account's name, which is what every alias
* does until somebody says otherwise (Account::senderNameFor()).
*
* Control characters are dropped on the way in. The name ends up in a
* header, and although the mailer encodes it, a line break stored here
* would also be shown in the From selector and published to JMAP clients,
* none of which expect one.
*/
#[Route('/{aliasId}/name', name: 'name', methods: ['POST'])]
public function setName(Request $request, Account $account, int $aliasId): Response
{
$this->assertCsrf($request, 'alias-name' . $aliasId);
$this->denyAccessUnlessGranted(OwnershipVoter::OWN, $account);
$target = $this->ownedAlias($account, $aliasId);
$name = (string) preg_replace('/[\x00-\x1F\x7F]+/u', ' ', (string) $request->request->get('name', ''));
$target->displayName = mb_substr(trim($name), 0, 255);
$this->em->flush();
return $this->streamResponse(
$request,
$account,
null === $target->displayName ? 'alias.name_cleared' : 'alias.name_set',
);
}
/**
* Declare a domain a catch-all: every address at it reaches this mailbox.
*
* What that buys is answering as the address a mail was sent to
* (ReplyFromResolver) without adding each one as an alias first. Refused
* for Gmail and Microsoft accounts, where it could not work: both send as
* the addresses the provider knows about and rewrite or reject any other
* From, so the setting would promise something the first reply would break.
*/
#[Route('/catch-all/add', name: 'catch_all_add', methods: ['POST'])]
public function addCatchAll(Request $request, Account $account): Response
{
$this->assertCsrf($request, 'alias-catch-all' . $account->id);
$this->denyAccessUnlessGranted(OwnershipVoter::OWN, $account);
if (true === $account->isGmail() || true === $account->isMicrosoft()) {
return $this->streamResponse($request, $account, 'alias.catch_all_unsupported');
}
$domain = $this->domainFrom((string) $request->request->get('domain', ''));
if (null === $domain) {
return $this->streamResponse($request, $account, 'alias.catch_all_invalid');
}
$domains = $account->catchAllDomains;
if (true === in_array($domain, $domains, true)) {
return $this->streamResponse($request, $account, 'alias.catch_all_duplicate');
}
$domains[] = $domain;
$account->catchAllDomains = $domains;
$this->em->flush();
return $this->streamResponse($request, $account, 'alias.catch_all_added');
}
#[Route('/catch-all/remove', name: 'catch_all_remove', methods: ['POST'])]
public function removeCatchAll(Request $request, Account $account): Response
{
$this->assertCsrf($request, 'alias-catch-all' . $account->id);
$this->denyAccessUnlessGranted(OwnershipVoter::OWN, $account);
$domain = strtolower(trim((string) $request->request->get('domain', '')));
$account->catchAllDomains = array_values(array_filter(
$account->catchAllDomains,
static fn (string $existing): bool => $existing !== $domain,
));
$this->em->flush();
return $this->streamResponse($request, $account, 'alias.catch_all_removed');
}
#[Route('/refresh', name: 'refresh', methods: ['POST'])]
public function refresh(Request $request, Account $account): Response
{
@@ -146,6 +232,34 @@ final class AliasController extends AbstractController
// ── Private ───────────────────────────────────────────────────────────────
/**
* A domain name out of whatever was typed, or null when it is not one.
*
* Forgiving about the spellings people reach for — `@example.com`,
* `*@example.com`, a pasted address — because all of them mean the same
* domain, and strict about the result: letters, digits and hyphens in
* dot-separated labels with a top-level one, which is what a domain mail
* is delivered to looks like. An internationalised name goes in as its
* punycode form, the way it appears in an address on the wire.
*/
private function domainFrom(string $typed): ?string
{
$domain = strtolower(trim($typed));
$at = strrpos($domain, '@');
if (false !== $at) {
$domain = substr($domain, $at + 1);
}
$domain = trim($domain, " .\t");
if (strlen($domain) > 253 || 1 !== preg_match('/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z][a-z0-9-]{0,61}[a-z0-9]$/', $domain)) {
return null;
}
return $domain;
}
private function streamResponse(Request $request, Account $account, string $toastMessage): Response
{
$manageableAccounts = $this->accountRepository->findForUserOrdered($this->getUser());
@@ -8,9 +8,11 @@ use App\Controller\ChecksCsrf;
use App\Entity\Mail\Account;
use App\Repository\Mail\AccountRepository;
use App\Security\Voter\OwnershipVoter;
use App\Service\Mail\SignatureImporter;
use App\Service\Mail\SignatureProvider;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
@@ -74,9 +76,35 @@ final class SignatureController extends AbstractController
private readonly EntityManagerInterface $em,
private readonly AccountRepository $accountRepository,
private readonly SignatureProvider $signatures,
private readonly SignatureImporter $importer,
) {
}
/**
* What a signature would be stored as, without storing it.
*
* This is the editor's "Rendered" view of HTML typed as source: the same
* import a save runs (SignatureImporter), returned instead of written, so
* what the user is shown before saving is what the save will keep — not
* the browser's own rendering of markup the server is about to cut down.
* The report beside the HTML names what was removed.
*
* Its own token, per account, sent the way a form would send it. Nothing
* is written here, but the import fetches the images the HTML names, and a
* request that makes the server fetch something is not one to accept from
* another site's page.
*/
#[Route('/preview', name: 'preview', methods: ['POST'])]
public function preview(Request $request, Account $account): JsonResponse
{
$this->assertCsrf($request, 'signature-preview' . $account->id);
$this->denyAccessUnlessGranted(OwnershipVoter::OWN, $account);
return new JsonResponse(
$this->importer->import((string) $request->request->get('signature', ''))->toArray(),
);
}
/**
* The account-wide signature, used by every address that has none of its own.
*/
@@ -88,7 +116,7 @@ final class SignatureController extends AbstractController
$account->setSetting(
Account::SETTING_SIGNATURE,
$this->signatures->sanitize((string) $request->request->get('signature', '')),
$this->importer->import((string) $request->request->get('signature', ''))->html,
);
$this->em->flush();
@@ -99,7 +127,7 @@ final class SignatureController extends AbstractController
* Set, seed or clear one address's own signature. See the class docblock
* for why all three share a route.
*/
#[Route('/{aliasId}', name: 'alias', methods: ['POST'])]
#[Route('/{aliasId}', name: 'alias', requirements: ['aliasId' => '\\d+'], methods: ['POST'])]
public function setForAlias(Request $request, Account $account, int $aliasId): Response
{
$this->assertCsrf($request, 'settings-signature-alias' . $aliasId);
@@ -132,7 +160,7 @@ final class SignatureController extends AbstractController
$account->setSetting(
$key,
$this->signatures->sanitize((string) $request->request->get('signature', '')),
$this->importer->import((string) $request->request->get('signature', ''))->html,
);
$this->em->flush();
+50
View File
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Domain\DTO\Mail;
/**
* A signature as it will be stored, and what it cost to get it there.
*
* The HTML is the cleaned one. The rest is the account of the cleaning, which
* exists so the settings page can say what happened to markup somebody pasted
* in good faith instead of quietly showing them less than they wrote.
*/
final readonly class ImportedSignature
{
/**
* @param string $html sanitised, with the images that could be fetched carried inside it
* @param list<string> $removedElements tag names the sanitiser dropped, e.g. `style`, `script`
* @param list<string> $removedAttributes attribute names it dropped, e.g. `class`, `onclick`
* @param int $embeddedImages remote images now stored in the signature itself
* @param int $remoteImages remote images that could not be, and still point at their URL
*/
public function __construct(
public string $html,
public array $removedElements = [],
public array $removedAttributes = [],
public int $embeddedImages = 0,
public int $remoteImages = 0,
) {
}
public function changedAnything(): bool
{
return [] !== $this->removedElements || [] !== $this->removedAttributes;
}
/**
* @return array{html: string, removedElements: list<string>, removedAttributes: list<string>, embeddedImages: int, remoteImages: int}
*/
public function toArray(): array
{
return [
'html' => $this->html,
'removedElements' => $this->removedElements,
'removedAttributes' => $this->removedAttributes,
'embeddedImages' => $this->embeddedImages,
'remoteImages' => $this->remoteImages,
];
}
}
+150
View File
@@ -563,6 +563,22 @@ class Account extends AccountModel
return 'compose.signature.alias.' . $aliasId;
}
/**
* The domains whose every address reaches this mailbox: a catch-all.
*
* A list of lowercased domain names. Any address at one of them counts as
* this account's own for answering: a reply to mail that came in for
* `anything@example.com` goes out from `anything@example.com`, without
* that address ever having been added as an alias.
*
* A setting and not an EmailAlias row holding `*@example.com`, because an
* alias row is an address everywhere it is read — it gets a signature
* panel, a read-receipt mode, a JMAP identity, a line in the backup — and
* a pattern is none of those things. Twenty callers would each have had to
* learn to skip it.
*/
public const string SETTING_CATCH_ALL_DOMAINS = 'mail.catch_all_domains';
/**
* A sync that worked, recorded the same way a calendar records one.
*
@@ -1031,4 +1047,138 @@ class Account extends AccountModel
return array_values(array_unique($fallback));
}
}
/**
* The catch-all domains of this account, lowercased. See
* SETTING_CATCH_ALL_DOMAINS.
*
* Filtered on the way out as well as on the way in: the bag is jsonb, and
* a hand-edited or restored value that is not a list of strings must read
* as "none" rather than reach a comparison.
*
* @var list<string>
*/
public array $catchAllDomains {
get {
$stored = $this->getSetting(self::SETTING_CATCH_ALL_DOMAINS, []);
if (false === is_array($stored)) {
return [];
}
$domains = [];
foreach ($stored as $domain) {
if (true === is_string($domain) && '' !== trim($domain)) {
$domains[] = strtolower(trim($domain));
}
}
return array_values(array_unique($domains));
}
set {
$domains = [];
foreach ($value as $domain) {
$domains[] = strtolower(trim($domain));
}
$domains = array_values(array_unique(array_filter($domains, static fn (string $domain): bool => '' !== $domain)));
if ([] === $domains) {
$this->unsetSetting(self::SETTING_CATCH_ALL_DOMAINS);
return;
}
$this->setSetting(self::SETTING_CATCH_ALL_DOMAINS, $domains);
}
}
/**
* Whether an address is at one of this account's catch-all domains.
*
* The domain is compared whole: `example.com` does not cover
* `mail.example.com`, because a catch-all is configured per domain at the
* provider and a subdomain is a different one.
*/
public function catchesAddress(?string $address): bool
{
$address = strtolower(trim((string) $address));
$at = strrpos($address, '@');
if (false === $at || 0 === $at) {
return false;
}
return in_array(substr($address, $at + 1), $this->catchAllDomains, true);
}
/**
* Whether this account may put an address in From: a sendable alias, the
* display address of an account with no alias rows yet, or anything at a
* catch-all domain.
*
* The one answer the From selector, a reply's choice of sender and the
* token that comes back from the browser all have to share.
*/
public function maySendAs(?string $address): bool
{
$address = strtolower(trim((string) $address));
if ('' === $address) {
return false;
}
$sendable = $this->sendableAliases;
foreach ($sendable as $alias) {
if ($alias->address === $address) {
return true;
}
}
if ([] === $sendable && strtolower((string) $this->displayAddress) === $address) {
return true;
}
return $this->catchesAddress($address);
}
/**
* Whether an address is this account's own, for leaving the user out of
* their own replies: ownedAddresses, plus anything a catch-all domain
* delivers here.
*/
public function ownsAddress(?string $address): bool
{
$address = strtolower(trim((string) $address));
if ('' === $address) {
return false;
}
return in_array($address, $this->ownedAddresses, true) || $this->catchesAddress($address);
}
/**
* The display name mail from one address goes out under.
*
* The alias's own name when it has one, the account's otherwise — the same
* rule the From selector labels its options by, so what the selector shows
* is what the recipient sees. An address with no alias row (a catch-all
* address, an account not yet seeded) has only the account's name to use.
*/
public function senderNameFor(?string $address): ?string
{
$address = strtolower(trim((string) $address));
foreach ($this->aliases as $alias) {
if ($alias->address === $address && null !== $alias->displayName) {
return $alias->displayName;
}
}
return $this->name;
}
}
+97 -9
View File
@@ -3,6 +3,7 @@
namespace App\Form;
use App\Domain\Enum\Mail\MessagePriority;
use App\Domain\Helper\AddressHelper;
use App\Entity\Mail\Account;
use App\Entity\Mail\Message;
use App\Repository\Mail\AccountRepository;
@@ -14,6 +15,8 @@ use Symfony\Component\Form\Extension\Core\Type\EnumType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\Security\Core\User\UserInterface;
@@ -33,20 +36,28 @@ class ComposeType extends AbstractType
/** @var UserInterface $user */
$user = $options['user'];
$draft = ($options['data'] ?? null) instanceof Message ? $options['data'] : null;
$choices = $this->fromChoices($user);
// A draft already going out from a catch-all address — a reply to mail
// that came in for one — needs that address among the options, or the
// window could not show what it is about to send as.
//
// isset(), because a message that has not been filed under an account
// yet — the one send() binds a first POST to — has no account to read:
// the property is typed and uninitialised, not null.
if (null !== $draft && true === isset($draft->account, $draft->fromAddress)) {
$choices += $this->catchAllChoice($user, $draft->account->id . '|' . strtolower($draft->fromAddress), $choices);
}
$builder
// Unmapped. Value is a "accountId|address" token: the option the
// user picks resolves BOTH the sending account (Drafts label +
// IMAP drafts mailbox, via applyAccount()) and the exact From
// address. One option per sendable alias; accounts with no aliases
// yet contribute a single option for their display address.
// Pre-set on render with ->setData($this->senderToken($account)).
->add('account', ChoiceType::class, [
'label' => false,
'mapped' => false,
'choices' => $this->fromChoices($user),
'choice_value' => static fn (?string $token): string => $token ?? '',
'attr' => ['class' => 'compose-from-select'],
])
// Pre-set on render with SenderResolver::tokenFor().
->add('account', ChoiceType::class, $this->fromField($choices))
// The `send` group is only applied by ComposeController::send(), so
// a draft may be saved with no recipient at all — which is the
@@ -140,6 +151,83 @@ class ComposeType extends AbstractType
'label' => false,
'required' => false,
]);
// The same admission on the way back in. A window that opened on a
// catch-all address posts a token no list of aliases contains, and the
// form that receives the POST is built afresh — for a first send there
// is no stored draft yet to read the address off. So the token itself
// is checked, against the same rule: an active account of this user,
// and an address at one of its catch-all domains. Anything else stays
// exactly as refused as it was.
$builder->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event) use ($user, $choices): void {
$data = $event->getData();
$token = true === is_array($data) ? ($data['account'] ?? null) : null;
if (false === is_string($token)) {
return;
}
$extra = $this->catchAllChoice($user, $token, $choices);
if ([] !== $extra) {
$event->getForm()->add('account', ChoiceType::class, $this->fromField($choices + $extra));
}
});
}
/**
* @param array<string,string> $choices
*
* @return array<string,mixed>
*/
private function fromField(array $choices): array
{
return [
'label' => false,
'mapped' => false,
'choices' => $choices,
'choice_value' => static fn (?string $token): string => $token ?? '',
'attr' => ['class' => 'compose-from-select'],
];
}
/**
* The one extra From option a catch-all address needs, or nothing.
*
* Nothing when the token is already an option, when it does not name an
* active account of this user, or when its address is not at one of that
* account's catch-all domains (Account::catchesAddress()). The token comes
* from the browser on a submit, so this is the authorisation for sending
* as an address no alias row names.
*
* @param array<string,string> $choices
*
* @return array<string,string> label => token, at most one entry
*/
private function catchAllChoice(UserInterface $user, string $token, array $choices): array
{
if (true === in_array($token, $choices, true) || false === str_contains($token, '|')) {
return [];
}
[$id, $address] = explode('|', $token, 2);
if (false === ctype_digit($id) || $address !== AddressHelper::email($address) || false === AddressHelper::isValidEmail($address)) {
return [];
}
$account = $this->accountRepository->find((int) $id);
if (
null === $account
|| $account->usr !== $user
|| false === (bool) $account->isActive
|| false === $account->catchesAddress($address)
) {
return [];
}
return [$this->fromLabel($address, $account->senderNameFor($address)) => $token];
}
/**
@@ -167,7 +255,7 @@ class ComposeType extends AbstractType
}
foreach ($sendable as $alias) {
$choices[$this->fromLabel($alias->address, $alias->displayName ?? $account->name)]
$choices[$this->fromLabel($alias->address, $account->senderNameFor($alias->address))]
= $account->id . '|' . $alias->address;
}
}
@@ -262,11 +262,9 @@ final class EmailSubmissionSetMethod implements JmapMethod
* composer, which already resolved an alias of its own — exactly as it
* was.
*
* Only fromAddress is written. fromName stays the account's name because
* MessageSendService builds the From display name from the account and
* never reads the column, so setting it here would change what the local
* copy shows and nothing that goes out — a difference between the two that
* is worse than the alias display name being unused.
* fromName follows the address: MessageSendService builds the From display
* name with Account::senderNameFor(), and the local copy carries the same
* one, so an alias with a name of its own shows it here and on the wire.
*/
private function applyIdentity(Account $account, Message $message, mixed $identityId): string
{
@@ -292,6 +290,7 @@ final class EmailSubmissionSetMethod implements JmapMethod
}
$message->fromAddress = $address;
$message->fromName = $account->senderNameFor($address);
return $address;
}
+4
View File
@@ -198,6 +198,10 @@ final readonly class ConfigBackupUsers
// allowlist silently stops carrying anything that ships after it.
Account::SETTING_SIGNATURE,
Account::SETTING_READ_RECEIPT_DEFAULT,
// A list of domain names, with nothing in it that belongs to one
// host: the mailbox is a catch-all for the same domains wherever
// plMail reads it from.
Account::SETTING_CATCH_ALL_DOMAINS,
];
/**
+12 -6
View File
@@ -15,6 +15,7 @@ use App\Repository\Mail\MailboxRepository;
use App\Service\Label\LabelResolver;
use App\Service\Label\ThreadLabelSynchronizer;
use App\Service\Mail\AttachmentResolver;
use App\Service\Mail\Mime\DataImageEmbedder;
use App\Service\Mail\MailChangeRecorder;
use App\Service\Mail\MailSenderRegistry;
use App\Domain\Interface\MailSenderInterface;
@@ -44,6 +45,7 @@ class MessageSendService
private readonly MessageThreader $threader,
private readonly ThreadLabelSynchronizer $threadLabels,
private readonly LoggerInterface $logger = new NullLogger(),
private readonly DataImageEmbedder $dataImages = new DataImageEmbedder(),
) {
}
@@ -278,11 +280,6 @@ class MessageSendService
private function buildEmail(Message $message, Account $account): Email
{
$fromName = $account->name;
if (null === $fromName) {
$fromName = '';
}
$subject = $message->subject;
if (null === $subject) {
$subject = '';
@@ -294,6 +291,13 @@ class MessageSendService
$fromAddress = $account->displayAddress ?? $account->email ?? '';
}
// After the address, because the name depends on it: an alias with a
// display name of its own sends under that, and only one without falls
// back to the account's. This read the account's name unconditionally,
// so the From selector offered "Mr and Mrs Smith <family@…>" and the
// mail went out as the account holder.
$fromName = $account->senderNameFor($fromAddress) ?? '';
$email = new Email()
->from(new Address($fromAddress, $fromName))
->subject($subject);
@@ -323,7 +327,9 @@ class MessageSendService
}
if ($message->bodyHtml) {
$email->html($message->bodyHtml);
// `data:` images become inline parts on the way out — most mail
// clients do not draw a data URI. See DataImageEmbedder.
$email->html($this->dataImages->embed($email, $message->bodyHtml));
}
if ($message->bodyText) {
+4 -1
View File
@@ -146,7 +146,10 @@ final readonly class DraftPersister
// account's own address one line later. Every mail sent from an alias
// went out as the primary address, and nothing reported it.
$message->fromAddress = $sender ?? $account->email;
$message->fromName = $account->name;
// The name that address sends under — an alias's own when it has one.
// Same lookup MessageSendService builds the From header with, so the
// local copy and the mail on the wire name the same sender.
$message->fromName = $account->senderNameFor($message->fromAddress);
$message->addFlag(MessageFlag::DRAFT);
$message->seenAt ??= new DateTimeImmutable();
@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace App\Service\Mail\Mime;
use Symfony\Component\Mime\Email;
/**
* Turns `data:` images in an outgoing body into attached inline parts.
*
* A `data:` URI is how an image lives inside a piece of HTML with nothing
* beside it, which is exactly what a stored signature needs to be — and it is
* also the one way of carrying an image that mail clients widely refuse to
* show. Gmail's web client drops the source, Outlook on Windows draws an empty
* box. A signature whose logo is perfect in the composer would arrive without
* it at most of the people it was sent to.
*
* So on the wire each one becomes what every client does render: a related
* part with a Content-ID, and a `cid:` reference where the data was. Only on
* the wire — the stored body keeps its `data:` source, because that is the
* form the app's own pages can draw.
*
* WHAT IS LEFT ALONE
*
* Anything that is not a base64 PNG, JPEG, GIF or WebP, and anything that does
* not decode. An SVG in particular stays as it is: no mail client renders an
* attached SVG inline either, so moving it would change nothing a recipient
* sees.
*
* The same image used twice is attached once. The part is named for a hash of
* its bytes, so two references to one logo share a part rather than doubling
* the size of every mail that carries them.
*/
final readonly class DataImageEmbedder
{
private const array EXTENSIONS = [
'image/png' => 'png',
'image/jpeg' => 'jpg',
'image/gif' => 'gif',
'image/webp' => 'webp',
];
/**
* Possessive on the payload (`++`): it can run to hundreds of kilobytes,
* and a pattern that could give characters back would exhaust the
* backtrack limit on exactly the images this exists for.
*
* `&`, `#` and `;` are in the payload's alphabet because the body has been
* through the sanitiser by the time it is sent, and the sanitiser writes
* the base64 padding as `&#61;` and a plus as `&#43;`. A pattern holding
* out for a literal `=` matched every image whose length happened to be a
* multiple of three and no other.
*/
private const string PATTERN = '~(<img\b[^>]*?\ssrc\s*=\s*)(["\'])data:(image/(?:png|jpeg|gif|webp));base64,([A-Za-z0-9+/=\s&#;]++)\2~i';
/**
* The body with its `data:` images pointing at parts now embedded in the
* email.
*/
public function embed(Email $email, string $html): string
{
if (false === stripos($html, 'data:image/')) {
return $html;
}
$embedded = [];
$result = preg_replace_callback(
self::PATTERN,
static function (array $match) use ($email, &$embedded): string {
$type = strtolower($match[3]);
$bytes = base64_decode(
(string) preg_replace('/\s+/', '', html_entity_decode($match[4], ENT_QUOTES | ENT_HTML5, 'UTF-8')),
true,
);
if (false === $bytes || '' === $bytes) {
return $match[0];
}
$name = sprintf('inline-%s.%s', substr(hash('sha256', $bytes), 0, 16), self::EXTENSIONS[$type]);
if (false === array_key_exists($name, $embedded)) {
$email->embed($bytes, $name, $type);
$embedded[$name] = true;
}
return $match[1] . $match[2] . 'cid:' . $name . $match[2];
},
$html,
);
// Null is PCRE giving up. The body goes out as it was: an image some
// clients will not show is better than a mail with no body.
return $result ?? $html;
}
}
+35 -33
View File
@@ -30,6 +30,7 @@ final class ReplyDraftBuilder
public function __construct(
private readonly SignatureProvider $signatures,
private readonly MailBodySanitizer $sanitizer,
private readonly ReplyFromResolver $replyFrom = new ReplyFromResolver(),
) {
}
@@ -41,15 +42,13 @@ final class ReplyDraftBuilder
{
$draft = $this->draft($original, $account, 'Re', 'reply');
$own = $account->ownedAddresses;
// Answering mail you sent yourself is continuing that conversation with
// the people you wrote to — every client does this, and without it
// Reply on a message in Sent addressed the reply to you.
if (true === $this->isOwn($original->fromAddress, $own) && [] !== $this->withoutOwn($original->toAddresses ?? [], $own)) {
$draft->toAddresses = $this->withoutOwn($original->toAddresses ?? [], $own);
if (true === $account->ownsAddress($original->fromAddress) && [] !== $this->withoutOwn($original->toAddresses ?? [], $account)) {
$draft->toAddresses = $this->withoutOwn($original->toAddresses ?? [], $account);
$draft->ccAddresses = true === $replyAll
? $this->withoutOwn($original->ccAddresses ?? [], $own)
? $this->withoutOwn($original->ccAddresses ?? [], $account)
: [];
$this->linkToOriginal($draft, $original);
@@ -95,23 +94,22 @@ final class ReplyDraftBuilder
]];
}
/** @param list<string> $own */
private function isOwn(?string $address, array $own): bool
{
return in_array(strtolower($address ?? ''), $own, true);
}
/**
* A list of addresses minus the account's own.
*
* Asked of the account rather than compared against a list, because "own"
* is not only a list: an address at a catch-all domain is the user's too,
* and no list of aliases contains it. See Account::ownsAddress().
*
* @param array<array<string,mixed>> $addresses
* @param list<string> $own
*
* @return list<array<string,mixed>>
*/
private function withoutOwn(array $addresses, array $own): array
private function withoutOwn(array $addresses, Account $account): array
{
return array_values(array_filter(
$addresses,
fn (array $address): bool => false === $this->isOwn($address['address'] ?? null, $own),
static fn (array $address): bool => false === $account->ownsAddress($address['address'] ?? null),
));
}
@@ -132,7 +130,10 @@ final class ReplyDraftBuilder
$already,
);
return $this->withoutOwn($addresses, $taken);
return array_values(array_filter(
$addresses,
static fn (array $address): bool => false === in_array(strtolower($address['address'] ?? ''), $taken, true),
));
}
/**
@@ -198,16 +199,10 @@ final class ReplyDraftBuilder
*/
private function everyoneElse(Message $original, Account $account): array
{
$own = $account->ownedAddresses;
$seen = [];
foreach (array_merge($original->toAddresses ?? [], $original->ccAddresses ?? []) as $address) {
if (false === in_array(strtolower($address['address'] ?? ''), $own, true)) {
$seen[] = $address;
}
}
return $seen;
return $this->withoutOwn(
array_merge($original->toAddresses ?? [], $original->ccAddresses ?? []),
$account,
);
}
private function draft(Message $original, ?Account $account, string $prefix, string $mode, bool $signed = true): Message
@@ -216,10 +211,17 @@ final class ReplyDraftBuilder
// paragraph that is the writing space, so the unsigned body already
// starts with somewhere to type — a second one would render as a
// blank line above the caret.
$lead = $signed ? $this->signature($account) : '';
// Chosen before the signature, because the signature follows it: an
// alias with a sign-off of its own signs the reply it sends.
$from = null !== $account ? $this->replyFrom->addressFor($original, $account) : null;
$lead = $signed ? $this->signature($account, $from) : '';
$draft = new Message();
$draft->account = $account;
// Null when the original names none of the account's addresses, which
// leaves the window on the primary one — SenderResolver::tokenFor()
// reads this back when it pre-selects From.
$draft->fromAddress = $from;
$draft->subject = $this->prefixSubject($prefix, $original->subject);
$draft->bodyHtml = $lead . $this->quote($original, $mode);
$draft->hasAttachments = false;
@@ -243,19 +245,19 @@ final class ReplyDraftBuilder
* the quote follows that. Exactly the arrangement every other mail client
* uses.
*
* The account's own display address rather than a chosen alias, because
* nothing has been chosen yet — the window opens on the token
* SenderResolver::token() builds, which is that address. Picking a
* different From afterwards swaps the block, which is what the
* `data-pl-signature` marker on it is for.
* For the address the reply goes out from — ReplyFromResolver's answer —
* and the account's display address when there is none, which is the
* option the window then opens on. Picking a different From afterwards
* swaps the block, which is what the `data-pl-signature` marker on it is
* for.
*/
private function signature(?Account $account): string
private function signature(?Account $account, ?string $from = null): string
{
if (null === $account) {
return '';
}
$block = $this->signatures->blockFor($account, $account->displayAddress);
$block = $this->signatures->blockFor($account, $from ?? $account->displayAddress);
return '' === $block ? '' : '<p><br></p>' . $block;
}
+79
View File
@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Service\Mail;
use App\Domain\Helper\RecipientHeaderHelper;
use App\Entity\Mail\Account;
use App\Entity\Mail\Message;
/**
* Which of the account's addresses answers a message: the one it was sent to.
*
* A mailbox with three addresses is three correspondents to the people writing
* to it. Answering mail that came in for `sales@` from `me@` tells the sender
* an address they were never given, and on a catch-all domain it is the whole
* reason the domain exists — the address somebody was handed is the one their
* answer has to come from.
*
* Until this existed every reply opened on the account's primary address,
* aliases included, and getting it right was a dropdown the user had to
* remember on every mail.
*
* WHERE THE ADDRESS IS LOOKED FOR, IN ORDER
*
* 1. From, when the original is the user's own mail: answering something you
* sent continues it as whoever you were when you sent it.
* 2. To, then Cc — what the sender typed.
* 3. Delivered-To, X-Original-To, Envelope-To — what the server delivered to,
* which is the only place the address appears when the user was in Bcc or
* on a list. Last, because a forwarding hop rewrites these to an address
* nobody wrote to.
*
* The first address the account may send as wins (Account::maySendAs()). That
* is the same rule the From selector is built from, so whatever comes out of
* here is an option the selector can show and the send will accept. Null means
* the message names none of them, and the caller stays on the primary address.
*/
final readonly class ReplyFromResolver
{
private const array DELIVERY_HEADERS = ['delivered-to', 'x-original-to', 'envelope-to'];
public function addressFor(Message $original, Account $account): ?string
{
foreach ($this->candidates($original) as $address) {
$address = strtolower(trim($address));
if (true === $account->maySendAs($address)) {
return $address;
}
}
return null;
}
/**
* @return list<string>
*/
private function candidates(Message $original): array
{
$candidates = [(string) $original->fromAddress];
foreach ([$original->toAddresses ?? [], $original->ccAddresses ?? []] as $list) {
foreach ($list as $entry) {
$candidates[] = (string) ($entry['address'] ?? '');
}
}
$headers = $original->headers ?? [];
foreach (self::DELIVERY_HEADERS as $field) {
foreach (RecipientHeaderHelper::addresses($headers, $field) as $entry) {
$candidates[] = $entry['address'];
}
}
return $candidates;
}
}
+27
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Service\Mail;
use App\Entity\Mail\Account;
use App\Entity\Mail\Message;
use App\Repository\Mail\AccountRepository;
use Symfony\Component\Security\Core\User\UserInterface;
@@ -35,6 +36,32 @@ final readonly class SenderResolver
return sprintf('%d|%s', $account->id, $account->displayAddress ?? $account->email ?? '');
}
/**
* The token a window editing this draft opens on: the address the draft
* already carries, when the account may still send as it.
*
* A draft has an opinion about its From in two cases — a reply, which
* ReplyDraftBuilder addresses from whichever of the account's addresses
* the original was sent to, and a draft saved earlier with an alias
* picked. Both used to reopen on the primary address, because every window
* was pre-set from the account alone: the alias the user had chosen was
* silently swapped back, and the next autosave stored the swap.
*
* Checked against Account::maySendAs() rather than trusted, so an alias
* disabled since the draft was written falls back to the primary address
* instead of offering an option the selector no longer has.
*/
public function tokenFor(Message $draft, Account $account): string
{
$address = strtolower(trim((string) $draft->fromAddress));
if ($draft->account === $account && true === $account->maySendAs($address)) {
return sprintf('%d|%s', $account->id, $address);
}
return $this->token($account);
}
/**
* The account a submitted token selects, or null when it selects nothing
* the user may send from.
+223
View File
@@ -0,0 +1,223 @@
<?php
declare(strict_types=1);
namespace App\Service\Mail;
use App\Domain\DTO\Mail\ImportedSignature;
use Dom\HTMLDocument;
/**
* A signature written as HTML, made into one plMail will store.
*
* Two things happen to it, and the caller is told about both.
*
* IT IS SANITISED, through the same allow-list as every other signature
* (SignatureProvider::sanitize()). For a signature typed into the editor that
* removes nothing anyone would notice. For one pasted as source out of a
* generator or another mail client it routinely does — a `<style>` block, the
* `class` attributes that pointed into it — and a signature that silently
* renders differently from what was pasted reads as a bug. So the difference
* is reported: which elements and which attributes were in what came in and
* are not in what goes out.
*
* ITS REMOTE IMAGES ARE BROUGHT INSIDE. An `<img src="https://…">` is fetched
* once and stored in the signature as a `data:` source. That is what makes the
* logo show at all in plMail's own pages, whose content policy loads images
* from nowhere else, and it means the recipient is sent the image rather than
* a request to somebody's web server — no "load remote content?" prompt, no
* broken logo the day the host goes away. MessageSendService turns the `data:`
* source into an attached inline part when the mail is built, since a data URI
* is not something mail clients display (see DataImageEmbedder).
*
* WHAT STAYS REMOTE
*
* An image that cannot be fetched, is not a PNG, JPEG, GIF or WebP, or is
* larger than MAX_IMAGE_BYTES keeps its URL and is counted. The cap is there
* because a signature is not paid for once: it sits in every draft, is handed
* to every compose window, and goes out with every mail.
*
* The fetch goes through ImageProxyFetcher and so under its rules — public
* addresses only, bounded size and time, redirects re-checked hop by hop.
*/
final readonly class SignatureImporter
{
/** One image. A logo is a few kilobytes; a photograph is not a signature. */
private const int MAX_IMAGE_BYTES = 256 * 1024;
/** Everything embedded in one signature. */
private const int MAX_TOTAL_BYTES = 768 * 1024;
private const array EMBEDDABLE = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
/**
* The scaffolding of a whole document. Pasting a full page as a signature
* loses these by definition and nobody misses them, so they are not
* reported as something that was taken away.
*/
private const array STRUCTURAL = ['html', 'head', 'body', 'title', 'meta'];
/**
* Attributes the sanitiser may rewrite or add but never meaningfully
* removes: `src` changes when an image is embedded, and `target` and `rel`
* are forced onto every link.
*/
private const array UNREPORTED_ATTRIBUTES = ['src', 'target', 'rel'];
public function __construct(
private SignatureProvider $signatures,
private ImageProxyFetcher $images,
) {
}
public function import(?string $html): ImportedSignature
{
$html = (string) $html;
$clean = $this->signatures->sanitize($html);
[$removedElements, $removedAttributes] = $this->removed($html, $clean);
[$clean, $embedded, $remote] = $this->embedImages($clean);
return new ImportedSignature($clean, $removedElements, $removedAttributes, $embedded, $remote);
}
/**
* Element and attribute names present before sanitising and absent — or
* fewer — after it.
*
* Counted rather than compared as text. The sanitiser re-serialises
* everything it keeps, so the two strings differ in quoting and entity
* spelling even when nothing was dropped, and a diff of them would cry
* wolf on every save.
*
* @return array{0: list<string>, 1: list<string>}
*/
private function removed(string $before, string $after): array
{
[$elementsBefore, $attributesBefore] = $this->census($before);
[$elementsAfter, $attributesAfter] = $this->census($after);
return [
$this->fewer($elementsBefore, $elementsAfter, self::STRUCTURAL),
$this->fewer($attributesBefore, $attributesAfter, self::UNREPORTED_ATTRIBUTES),
];
}
/**
* @return array{0: array<string,int>, 1: array<string,int>} element counts and attribute counts, by name
*/
private function census(string $html): array
{
$elements = [];
$attributes = [];
if ('' === trim($html)) {
return [$elements, $attributes];
}
// The HTML5 parser, the one a browser would use on the same string, so
// the count is of what the markup means rather than of how it is
// spelled.
$document = HTMLDocument::createFromString(
'<!DOCTYPE html><html><body>' . $html . '</body></html>',
LIBXML_NOERROR,
);
foreach ($document->getElementsByTagName('*') as $element) {
$name = strtolower($element->localName);
$elements[$name] = ($elements[$name] ?? 0) + 1;
foreach ($element->attributes as $attribute) {
$key = strtolower($attribute->name);
$attributes[$key] = ($attributes[$key] ?? 0) + 1;
}
}
return [$elements, $attributes];
}
/**
* @param array<string,int> $before
* @param array<string,int> $after
* @param list<string> $ignored
*
* @return list<string>
*/
private function fewer(array $before, array $after, array $ignored): array
{
$names = [];
foreach ($before as $name => $count) {
if (false === in_array($name, $ignored, true) && $count > ($after[$name] ?? 0)) {
$names[] = (string) $name;
}
}
sort($names);
return $names;
}
/**
* @return array{0: string, 1: int, 2: int} the HTML, images embedded, images left remote
*/
private function embedImages(string $html): array
{
$embedded = 0;
$remote = 0;
$total = 0;
// After the sanitiser, which is why the pattern can be this plain: it
// writes every attribute double-quoted and entity-encoded.
$result = preg_replace_callback(
'~(<img\b[^>]*?\ssrc=")(https?:[^"]+)(")~i',
function (array $match) use (&$embedded, &$remote, &$total): string {
$data = $this->dataUri(html_entity_decode($match[2], ENT_QUOTES | ENT_HTML5, 'UTF-8'), $total);
if (null === $data) {
++$remote;
return $match[0];
}
++$embedded;
return $match[1] . $data . $match[3];
},
$html,
);
return [$result ?? $html, $embedded, $remote];
}
private function dataUri(string $url, int &$total): ?string
{
$fetched = $this->images->fetch($url);
if (null === $fetched) {
return null;
}
$type = strtolower(trim(explode(';', $fetched['contentType'])[0]));
$size = (int) @filesize($fetched['path']);
if (
false === in_array($type, self::EMBEDDABLE, true)
|| $size <= 0
|| $size > self::MAX_IMAGE_BYTES
|| $total + $size > self::MAX_TOTAL_BYTES
) {
return null;
}
$bytes = @file_get_contents($fetched['path']);
if (false === $bytes || '' === $bytes) {
return null;
}
$total += $size;
return sprintf('data:%s;base64,%s', $type, base64_encode($bytes));
}
}
+9
View File
@@ -186,6 +186,15 @@ final readonly class SignatureProvider
$map[$account->id . '|' . $address] = $this->blockFor($account, $address);
}
// An address at a catch-all domain is a From option no row above
// names — there are as many of them as there are local parts. They
// all sign the same way, with the account's signature, so one
// wildcard entry answers for every one of them and the composer
// falls back to it for a token it does not find by name.
if ([] !== $account->catchAllDomains) {
$map[$account->id . '|*'] = $this->blockFor($account, null);
}
}
return $map;
+22 -2
View File
@@ -2,8 +2,8 @@
{% set isInactive = alias.status.value == 'inactive' %}
{% set isSystem = alias.source.value == 'system' %}
<li class="flex items-center gap-3 py-1">
<span class="w-2 h-2 rounded-full shrink-0 {{ isInactive ? 'bg-ink-faint' : 'bg-green-400' }}"
<li class="flex items-start gap-3 py-1">
<span class="mt-1.5 w-2 h-2 rounded-full shrink-0 {{ isInactive ? 'bg-ink-faint' : 'bg-green-400' }}"
aria-hidden="true"></span>
<div class="flex-1 min-w-0">
@@ -13,6 +13,26 @@
<span class="ml-1 text-[11px] text-ink-faint">({{ 'settings.aliases.system'|trans }})</span>
{% endif %}
</span>
{# The name this address sends under. Empty means the account's own,
which is why that is the placeholder: it is what the recipient sees
until something is typed here. Not offered on a disabled address —
it sends nothing, so it has no name to send under. #}
{% if not isInactive %}
<form method="post" action="{{ path('app_alias_name', { id: account.id, aliasId: alias.id }) }}"
data-turbo-stream class="mt-0.5 flex items-center gap-2">
<input type="hidden" name="_token" value="{{ csrf_token('alias-name' ~ alias.id) }}">
<input type="text" name="name" value="{{ alias.displayName }}" maxlength="255"
autocomplete="off" data-1p-ignore data-lpignore="true"
placeholder="{{ account.name ?: 'settings.aliases.name_placeholder'|trans }}"
aria-label="{{ 'settings.aliases.name_for'|trans({'%alias%': alias.address}) }}"
class="min-w-0 flex-1 max-w-xs h-7 rounded-md border border-line bg-surface px-2 text-xs text-ink
placeholder:text-ink-faint focus:border-accent focus:outline-none">
<button type="submit" class="shrink-0 text-[11px] text-ink-faint hover:text-accent transition-colors cursor-pointer">
{{ 'settings.aliases.name_save'|trans }}
</button>
</form>
{% endif %}
</div>
{% if isPrimary %}
+46
View File
@@ -50,6 +50,52 @@
{{ 'settings.aliases.add'|trans }}
</button>
{{ form_end(aliasForm) }}
{# Catch-all domains. Not drawn for Gmail and Microsoft, which
send only as addresses the provider knows — see
AliasController::addCatchAll(). #}
{% if not account.isGmail and not account.isMicrosoft %}
<div class="mt-4" data-catch-all="{{ account.id }}">
<p class="text-sm text-ink">{{ 'settings.aliases.catch_all.label'|trans }}</p>
<p class="text-xs text-ink-faint">{{ 'settings.aliases.catch_all.hint'|trans }}</p>
{% if account.catchAllDomains is not empty %}
<ul class="mt-2 flex flex-wrap gap-1.5">
{% for domain in account.catchAllDomains %}
<li class="inline-flex items-center gap-1.5 rounded-full bg-sunken pl-2.5 pr-1.5 py-0.5 text-xs text-ink">
<span>*@{{ domain }}</span>
<form method="post" action="{{ path('app_alias_catch_all_remove', { id: account.id }) }}"
data-turbo-stream class="inline-flex">
<input type="hidden" name="_token" value="{{ csrf_token('alias-catch-all' ~ account.id) }}">
<input type="hidden" name="domain" value="{{ domain }}">
<button type="submit"
class="text-ink-faint hover:text-red-500 transition-colors cursor-pointer"
aria-label="{{ 'settings.aliases.catch_all.remove'|trans({'%domain%': domain}) }}">
<i class="fa-solid fa-xmark text-[10px]" aria-hidden="true"></i>
</button>
</form>
</li>
{% endfor %}
</ul>
{% endif %}
<form method="post" action="{{ path('app_alias_catch_all_add', { id: account.id }) }}"
data-turbo-stream class="mt-2 flex items-end gap-2">
<input type="hidden" name="_token" value="{{ csrf_token('alias-catch-all' ~ account.id) }}">
<input type="text" name="domain" required autocomplete="off"
data-1p-ignore data-lpignore="true"
placeholder="{{ 'settings.aliases.catch_all.placeholder'|trans }}"
aria-label="{{ 'settings.aliases.catch_all.label'|trans }}"
class="min-w-0 flex-1 h-9 rounded-lg border border-line bg-surface px-3 text-sm text-ink
placeholder:text-ink-faint focus:border-accent focus:outline-none">
<button type="submit"
class="shrink-0 inline-flex items-center gap-2 px-3 h-9 rounded-lg text-sm font-medium border border-line text-ink hover:bg-hover transition-colors cursor-pointer">
<i class="fa-solid fa-plus text-xs" aria-hidden="true"></i>
{{ 'settings.aliases.catch_all.add'|trans }}
</button>
</form>
</div>
{% endif %}
</div>
{% endfor %}
+4
View File
@@ -70,6 +70,8 @@ Variables:
token: csrf_token('settings-signature' ~ account.id),
signature: account.getSetting('compose.signature')|default(''),
label: 'settings.signature.label'|trans,
preview: path('app_signature_preview', { id: account.id }),
previewToken: csrf_token('signature-preview' ~ account.id),
} only %}
{# ── Per-address overrides, folded away ───────────────────── #}
@@ -157,6 +159,8 @@ Variables:
token: csrf_token('settings-signature-alias' ~ alias.id),
signature: aliasSignature,
label: 'settings.signature.for_alias'|trans({'%alias%': alias.address}),
preview: path('app_signature_preview', { id: account.id }),
previewToken: csrf_token('signature-preview' ~ account.id),
} only %}
{% if aliasSignature is empty %}
+54 -2
View File
@@ -32,11 +32,29 @@ Variables:
label string accessible name for the editor
autofocus bool put the caret in the editor on load (the alias editor
revealed by "use a different signature" does)
preview string route that returns the signature as it would be stored
previewToken string CSRF token for that route
TWO VIEWS OF ONE SIGNATURE. "Rendered" is the contenteditable above; "HTML" is
the same signature as source, in a textarea, for pasting one written elsewhere.
Going from HTML back to Rendered is not a client-side swap: the source is sent
to `preview`, and what comes back — sanitised, images embedded — is what is
shown, with a line underneath naming anything that was removed. So the rendered
view is always what a save would keep. See settings--signature-source.
#}
{% set autofocus = autofocus|default(false) %}
<form method="post" action="{{ action }}" data-turbo-stream
data-controller="compose--compose-toolbar"
data-controller="compose--compose-toolbar settings--signature-source"
data-settings--signature-source-url-value="{{ preview }}"
data-settings--signature-source-token-value="{{ previewToken }}"
data-settings--signature-source-i18n-value="{{ {
removed: 'settings.signature.source.removed'|trans,
clean: 'settings.signature.source.clean'|trans,
embedded: 'settings.signature.source.embedded'|trans,
remote: 'settings.signature.source.remote'|trans,
failed: 'settings.signature.source.failed'|trans,
}|json_encode|e('html_attr') }}"
{# The four strings the toolbar controller writes from JavaScript. Not
optional decoration: without it the link popover's heading and its two
refusals fall back to their English defaults, which is the one thing
@@ -56,11 +74,15 @@ Variables:
{# What is actually submitted. The contenteditable below is mirrored into
it on every input by the toolbar controller's _syncHiddenInput(). #}
<input type="hidden" name="signature" value="{{ signature|e('html_attr') }}"
data-compose--compose-toolbar-target="hiddenInput">
data-compose--compose-toolbar-target="hiddenInput"
data-settings--signature-source-target="field">
<div class="rounded-lg border border-line bg-surface overflow-hidden">
<div class="flex items-center gap-0.5 border-b border-line bg-sunken px-1.5 py-1">
{% set sb = "shrink-0 px-1.5 py-0.5 rounded text-sm text-ink-muted hover:bg-hover transition-colors" %}
{# The format buttons act on the contenteditable, so they go with it
when the source view is up. #}
<div class="flex items-center gap-0.5" data-settings--signature-source-target="formatting">
<button type="button" data-compose--compose-toolbar-action
data-action="click->compose--compose-toolbar#bold"
title="{{ 'compose.bold'|trans }}" aria-label="{{ 'compose.bold'|trans }}"
@@ -82,10 +104,36 @@ Variables:
data-action="click->compose--compose-toolbar#insertLink"
title="{{ 'compose.insert_link'|trans }}" aria-label="{{ 'compose.insert_link'|trans }}"
class="{{ sb }}"><i class="fa-solid fa-link" aria-hidden="true"></i></button>
</div>
{# Rendered | HTML. Two buttons rather than a checkbox because the
two directions are not the same act: one asks the server what
the source turns into. aria-pressed carries which is showing. #}
{% set mode = "px-2 py-0.5 text-[11px] font-medium transition-colors cursor-pointer aria-pressed:bg-surface aria-pressed:text-ink aria-pressed:shadow-sm text-ink-faint hover:text-ink-muted" %}
<div class="ml-auto inline-flex items-center rounded-md border border-line bg-sunken p-0.5"
role="group" aria-label="{{ 'settings.signature.source.toggle'|trans }}">
<button type="button" aria-pressed="true"
data-settings--signature-source-target="renderedButton"
data-action="click->settings--signature-source#showRendered"
class="{{ mode }} rounded">{{ 'settings.signature.source.rendered'|trans }}</button>
<button type="button" aria-pressed="false"
data-settings--signature-source-target="sourceButton"
data-action="click->settings--signature-source#showSource"
class="{{ mode }} rounded">{{ 'settings.signature.source.html'|trans }}</button>
</div>
</div>
<textarea hidden rows="8" spellcheck="false" autocomplete="off" autocapitalize="off"
data-settings--signature-source-target="source"
data-action="input->settings--signature-source#typed"
aria-label="{{ 'settings.signature.source.label'|trans({'%label%': label}) }}"
placeholder="{{ 'settings.signature.source.placeholder'|trans }}"
class="block w-full resize-y border-0 bg-surface px-3 py-2 font-mono text-xs text-ink outline-none
placeholder:text-ink-faint"></textarea>
<div id="signature-editor-{{ id }}"
data-compose--compose-toolbar-target="editor"
data-settings--signature-source-target="editor"
contenteditable="true"
{{ autofocus ? 'autofocus' : '' }}
role="textbox"
@@ -108,6 +156,10 @@ Variables:
class: 'absolute left-0 top-10 w-80 max-w-[calc(100%-1rem)]',
}, with_context = false) }}
{# What the last render did to the source. Empty until there has been one. #}
<p hidden role="status" class="mt-1.5 text-xs text-ink-faint"
data-settings--signature-source-target="report"></p>
<div class="flex justify-end mt-1.5">
<button type="submit"
class="rounded-full bg-accent text-accent-ink text-xs font-semibold px-3 py-1.5
@@ -0,0 +1,318 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller\Mail;
use App\Domain\Enum\Account\EmailAliasSource;
use App\Domain\Enum\Account\EmailAliasStatus;
use App\Entity\Mail\Account;
use App\Entity\Mail\EmailAlias;
use App\Entity\Mail\Message;
use App\Entity\User\User;
use App\Repository\User\UserRepository;
use App\Tests\Support\Mail\OpensComposeWindow;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
/**
* The From a reply window opens on, and what a draft saved from it keeps.
*
* ReplyFromResolverTest pins which address is chosen. These pin that the
* choice survives the three places it used to be lost: the window pre-setting
* its selector from the account alone, the form refusing an address that is in
* no list of aliases, and the saved draft naming the account holder instead of
* the alias.
*/
final class ReplyFromAddressTest extends WebTestCase
{
use OpensComposeWindow;
private const string ADMIN_EMAIL = 'e2e-admin@plmail.test';
private EntityManagerInterface $em;
private Connection $connection;
protected function tearDown(): void
{
if (true === isset($this->connection) && true === $this->connection->isTransactionActive()) {
$this->connection->rollBack();
}
parent::tearDown();
}
public function testAReplyToMailForAnAliasOpensOnThatAlias(): void
{
$client = static::createClient();
$user = $this->boot($client);
$account = $this->account($user, 'me@joder.dev');
$this->alias($account, 'sales@joder.dev');
$original = $this->received($account, to: 'sales@joder.dev');
$crawler = $client->request('GET', '/compose/reply/' . $original->id, server: self::DOCK_FRAME);
self::assertResponseIsSuccessful();
self::assertSame($account->id . '|sales@joder.dev', $this->selectedFrom($crawler));
}
public function testAReplyToMailForThePrimaryAddressStaysOnIt(): void
{
$client = static::createClient();
$user = $this->boot($client);
$account = $this->account($user, 'me@joder.dev');
$this->alias($account, 'sales@joder.dev');
$original = $this->received($account, to: 'me@joder.dev');
$crawler = $client->request('GET', '/compose/reply/' . $original->id, server: self::DOCK_FRAME);
self::assertSame($account->id . '|me@joder.dev', $this->selectedFrom($crawler));
}
/** The alias's own signature, not the account's: the reply is signed as who sends it. */
public function testAReplyFromAnAliasCarriesThatAliasesSignature(): void
{
$client = static::createClient();
$user = $this->boot($client);
$account = $this->account($user, 'me@joder.dev');
$alias = $this->alias($account, 'sales@joder.dev');
$account->setSetting(Account::SETTING_SIGNATURE, '<p>John, privately</p>');
$account->setSetting(Account::signatureAliasSetting((int) $alias->id), '<p>The sales desk</p>');
$this->em->flush();
$original = $this->received($account, to: 'sales@joder.dev');
$client->request('GET', '/compose/reply/' . $original->id, server: self::DOCK_FRAME);
$body = (string) $client->getResponse()->getContent();
self::assertStringContainsString('The sales desk', $body);
self::assertStringNotContainsString('John, privately', $body);
}
public function testAReplyToMailForACatchAllAddressOffersAndSelectsThatAddress(): void
{
$client = static::createClient();
$user = $this->boot($client);
$account = $this->account($user, 'me@joder.dev');
$account->catchAllDomains = ['shop.test'];
$this->em->flush();
$original = $this->received($account, to: 'yo@shop.test');
$crawler = $client->request('GET', '/compose/reply/' . $original->id, server: self::DOCK_FRAME);
self::assertResponseIsSuccessful();
self::assertSame($account->id . '|yo@shop.test', $this->selectedFrom($crawler));
self::assertStringContainsString(
'yo@shop.test',
$crawler->filter('select[name="compose[account]"] option[selected]')->text(),
);
}
/**
* The address is in no list the form was built from, so the form has to
* admit it from the token — on a first save there is no stored draft yet.
*/
public function testADraftFromACatchAllAddressIsSavedAsThatAddress(): void
{
$client = static::createClient();
$user = $this->boot($client);
$account = $this->account($user, 'me@joder.dev');
$account->catchAllDomains = ['shop.test'];
$this->em->flush();
$this->save($client, $account->id . '|yo@shop.test');
$draft = $this->lastDraft($account);
self::assertSame('yo@shop.test', $draft->fromAddress);
self::assertSame('John Smith', $draft->fromName);
}
/** The same token for a domain nobody declared is a forged sender, and stays refused. */
public function testAnAddressAtADomainThatIsNotACatchAllIsNotAccepted(): void
{
$client = static::createClient();
$user = $this->boot($client);
$account = $this->account($user, 'me@joder.dev');
$account->catchAllDomains = ['shop.test'];
$this->em->flush();
$this->save($client, $account->id . '|ceo@bank.test');
$drafts = (int) $this->em->createQuery(
'SELECT COUNT(m.id) FROM ' . Message::class . ' m WHERE m.account = :account AND m.fromAddress = :from',
)->setParameter('account', $account->id)->setParameter('from', 'ceo@bank.test')->getSingleScalarResult();
self::assertSame(0, $drafts);
}
/** #70: the name in the From selector is the name the saved mail carries. */
public function testADraftFromAnAliasWithItsOwnNameCarriesThatName(): void
{
$client = static::createClient();
$user = $this->boot($client);
$account = $this->account($user, 'me@joder.dev');
$this->alias($account, 'family@joder.dev', 'Mr and Mrs Smith');
$crawler = $client->request('GET', '/compose/new', server: self::DOCK_FRAME);
self::assertStringContainsString(
'Mr and Mrs Smith <family@joder.dev>',
$crawler->filter('select[name="compose[account]"]')->text(),
);
$this->save($client, $account->id . '|family@joder.dev');
$draft = $this->lastDraft($account);
self::assertSame('family@joder.dev', $draft->fromAddress);
self::assertSame('Mr and Mrs Smith', $draft->fromName);
}
/** A draft saved from an alias used to reopen on the primary address. */
public function testReopeningADraftKeepsTheAliasItWasSavedFrom(): void
{
$client = static::createClient();
$user = $this->boot($client);
$account = $this->account($user, 'me@joder.dev');
$this->alias($account, 'sales@joder.dev');
$this->save($client, $account->id . '|sales@joder.dev');
$draft = $this->lastDraft($account);
$crawler = $client->request('GET', '/compose/edit/' . $draft->id, server: self::DOCK_FRAME);
self::assertResponseIsSuccessful();
self::assertSame($account->id . '|sales@joder.dev', $this->selectedFrom($crawler));
}
// ── helpers ──────────────────────────────────────────────────────────────
private function selectedFrom(\Symfony\Component\DomCrawler\Crawler $crawler): string
{
$selected = $crawler->filter('select[name="compose[account]"] option[selected]');
self::assertSame(1, $selected->count(), 'the From selector has one option selected');
return (string) $selected->attr('value');
}
private function save(KernelBrowser $client, string $fromToken): void
{
$crawler = $client->request('GET', '/compose/new', server: self::DOCK_FRAME);
$token = $crawler->filter('input[name="compose[_token]"]')->attr('value');
$client->request('POST', '/compose/draft', ['compose' => [
'_token' => $token,
'account' => $fromToken,
'toAddresses' => ['rike@example.test'],
'subject' => 'From an address of mine',
'bodyHtml' => '<p>Hello</p>',
]]);
}
private function lastDraft(Account $account): Message
{
$this->em->clear();
$message = $this->em->createQuery(
'SELECT m FROM ' . Message::class . ' m WHERE m.account = :account AND m.subject = :subject ORDER BY m.id DESC',
)->setParameter('account', $account->id)
->setParameter('subject', 'From an address of mine')
->setMaxResults(1)
->getOneOrNullResult();
self::assertNotNull($message, 'the POST saved a draft');
return $message;
}
private function received(Account $account, string $to): Message
{
$message = new Message();
$message->account = $account;
$message->subject = 'A question';
$message->fromName = 'Katharina';
$message->fromAddress = 'k@example.test';
$message->toAddresses = [['name' => '', 'address' => $to]];
$message->messageId = '<question-' . bin2hex(random_bytes(4)) . '@example.test>';
$message->bodyHtml = '<p>Do you ship to Bremen?</p>';
$message->hasAttachments = false;
$message->flags = [];
$this->em->persist($message);
$this->em->flush();
return $message;
}
private function boot(KernelBrowser $client): User
{
$container = static::getContainer();
$this->em = $container->get(EntityManagerInterface::class);
$this->connection = $container->get(Connection::class);
$user = $container->get(UserRepository::class)->findOneBy(['email' => self::ADMIN_EMAIL]);
if (null === $user) {
self::markTestSkipped('run `app:test:seed-user --admin` first');
}
$client->loginUser($user);
$client->disableReboot();
$this->connection->beginTransaction();
$this->em->createQuery(
'UPDATE ' . Account::class . ' a SET a.isActive = false WHERE a.usr = :usr',
)->setParameter('usr', $user)->execute();
$this->em->clear();
return $this->em->find(User::class, $user->id);
}
private function account(User $user, string $email): Account
{
$account = new Account();
$account->usr = $user;
$account->name = 'John Smith';
$account->username = $email;
$account->email = $email;
$account->authType = 'password';
$account->isActive = true;
$account->isPrimary = true;
$account->sortOrder = 0;
$account->imapHost = 'imap.example.test';
$this->em->persist($account);
$primary = new EmailAlias($account, $email, EmailAliasSource::Manual, EmailAliasStatus::Primary);
$account->addAlias($primary);
$this->em->persist($primary);
$this->em->flush();
return $account;
}
private function alias(Account $account, string $address, ?string $name = null): EmailAlias
{
$alias = new EmailAlias($account, $address, EmailAliasSource::Manual, EmailAliasStatus::Active, $name);
$account->addAlias($alias);
$this->em->persist($alias);
$this->em->flush();
return $alias;
}
}
@@ -0,0 +1,307 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller\Settings;
use App\Domain\Enum\Account\EmailAliasSource;
use App\Domain\Enum\Account\EmailAliasStatus;
use App\Entity\Mail\Account;
use App\Entity\Mail\EmailAlias;
use App\Entity\User\User;
use App\Repository\User\UserRepository;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\Attributes\DataProvider;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
/**
* Settings → Aliases: the name an address sends under, and catch-all domains.
*
* Both are driven through the page's own forms — the token is read off the
* rendered row — so a field that stops being drawn fails here as well as one
* that stops being stored.
*/
final class AliasSenderNameAndCatchAllTest extends WebTestCase
{
private const string ADMIN_EMAIL = 'e2e-admin@plmail.test';
private EntityManagerInterface $em;
private Connection $connection;
protected function tearDown(): void
{
if (true === isset($this->connection) && true === $this->connection->isTransactionActive()) {
$this->connection->rollBack();
}
parent::tearDown();
}
// ── sender name ──────────────────────────────────────────────────────────
public function testAnAliasCanBeGivenANameToSendUnder(): void
{
$client = static::createClient();
$user = $this->boot($client);
$account = $this->account($user, 'me@joder.dev');
$alias = $this->alias($account, 'family@joder.dev');
$url = sprintf('/account/%d/alias/%d/name', $account->id, $alias->id);
$client->request('POST', $url, [
'_token' => $this->token($client, $url),
'name' => " Mr and Mrs\r\nSmith ",
]);
self::assertResponseRedirects();
$account = $this->reload($account);
// The line break is gone: the name is shown in a selector and sent in
// a header, and neither has room for one.
self::assertSame('Mr and Mrs Smith', $account->senderNameFor('family@joder.dev'));
self::assertSame('John Smith', $account->senderNameFor('me@joder.dev'), 'the other address is untouched');
}
public function testClearingTheNameGoesBackToTheAccounts(): void
{
$client = static::createClient();
$user = $this->boot($client);
$account = $this->account($user, 'me@joder.dev');
$alias = $this->alias($account, 'family@joder.dev', 'Mr and Mrs Smith');
$url = sprintf('/account/%d/alias/%d/name', $account->id, $alias->id);
$client->request('POST', $url, ['_token' => $this->token($client, $url), 'name' => ' ']);
self::assertSame('John Smith', $this->reload($account)->senderNameFor('family@joder.dev'));
}
/** The placeholder is what the recipient sees until a name is typed. */
public function testTheNameFieldShowsTheStoredNameOverTheAccountsAsPlaceholder(): void
{
$client = static::createClient();
$user = $this->boot($client);
$account = $this->account($user, 'me@joder.dev');
$alias = $this->alias($account, 'family@joder.dev', 'Mr and Mrs Smith');
$crawler = $client->request('GET', '/settings?section=aliases');
$field = $crawler->filter(sprintf('form[action$="/alias/%d/name"] input[name="name"]', $alias->id));
self::assertSame(1, $field->count());
self::assertSame('Mr and Mrs Smith', $field->attr('value'));
self::assertSame('John Smith', $field->attr('placeholder'));
}
public function testANameCannotBeSetWithoutTheRowsToken(): void
{
$client = static::createClient();
$user = $this->boot($client);
$account = $this->account($user, 'me@joder.dev');
$alias = $this->alias($account, 'family@joder.dev');
$client->request('POST', sprintf('/account/%d/alias/%d/name', $account->id, $alias->id), [
'_token' => 'not-the-token',
'name' => 'Somebody Else',
]);
self::assertResponseStatusCodeSame(403);
self::assertSame('John Smith', $this->reload($account)->senderNameFor('family@joder.dev'));
}
// ── catch-all domains ────────────────────────────────────────────────────
#[DataProvider('domainSpellings')]
public function testADomainIsAcceptedHoweverItWasTyped(string $typed): void
{
$client = static::createClient();
$user = $this->boot($client);
$account = $this->account($user, 'me@joder.dev');
$url = sprintf('/account/%d/alias/catch-all/add', $account->id);
$client->request('POST', $url, ['_token' => $this->token($client, $url), 'domain' => $typed]);
self::assertSame(['shop.test'], $this->reload($account)->catchAllDomains);
}
/**
* @return iterable<string, array{string}>
*/
public static function domainSpellings(): iterable
{
yield 'bare' => ['shop.test'];
yield 'capitalised, with spaces' => [' Shop.TEST '];
yield 'with the at sign' => ['@shop.test'];
yield 'as a wildcard address' => ['*@shop.test'];
yield 'as one of its addresses' => ['yo@shop.test'];
}
#[DataProvider('notDomains')]
public function testWhatIsNotADomainIsRefused(string $typed): void
{
$client = static::createClient();
$user = $this->boot($client);
$account = $this->account($user, 'me@joder.dev');
$url = sprintf('/account/%d/alias/catch-all/add', $account->id);
$client->request('POST', $url, ['_token' => $this->token($client, $url), 'domain' => $typed]);
self::assertSame([], $this->reload($account)->catchAllDomains);
}
/**
* @return iterable<string, array{string}>
*/
public static function notDomains(): iterable
{
yield 'empty' => [''];
yield 'one label' => ['localhost'];
yield 'every domain' => ['*'];
yield 'a wildcard label' => ['*.test'];
yield 'a space inside' => ['shop .test'];
yield 'a leading hyphen' => ['-shop.test'];
yield 'a numeric top level' => ['192.168.1.1'];
}
public function testACatchAllDomainIsListedAndCanBeRemoved(): void
{
$client = static::createClient();
$user = $this->boot($client);
$account = $this->account($user, 'me@joder.dev');
$account->catchAllDomains = ['shop.test', 'other.test'];
$this->em->flush();
$crawler = $client->request('GET', '/settings?section=aliases');
self::assertStringContainsString('*@shop.test', $crawler->filter('[data-catch-all]')->text());
$url = sprintf('/account/%d/alias/catch-all/remove', $account->id);
$client->request('POST', $url, ['_token' => $this->token($client, $url), 'domain' => 'shop.test']);
self::assertSame(['other.test'], $this->reload($account)->catchAllDomains);
}
/**
* Gmail sends only as addresses Google knows about, so the section is not
* drawn there and a hand-made POST is refused: the setting would promise
* something the first reply would break.
*/
public function testAGmailAccountIsNotOfferedCatchAllDomainsAndCannotBeGivenOne(): void
{
$client = static::createClient();
$user = $this->boot($client);
$plain = $this->account($user, 'me@joder.dev');
$gmail = $this->account($user, 'me@gmail.test');
// Read while the account is still a password one and the form is
// drawn: the token is per account, and afterwards there is no form to
// read it from. That is the stale page a real refusal would come from.
$url = sprintf('/account/%d/alias/catch-all/add', $gmail->id);
$token = $this->token($client, $url);
$gmail = $this->reload($gmail);
$gmail->authType = 'oauth2';
$gmail->oauthProvider = 'google';
$this->em->flush();
self::assertTrue($gmail->isGmail(), 'the fixture is a Gmail account');
$crawler = $client->request('GET', '/settings?section=aliases');
self::assertSame(1, $crawler->filter(sprintf('[data-catch-all="%d"]', $plain->id))->count());
self::assertSame(0, $crawler->filter(sprintf('[data-catch-all="%d"]', $gmail->id))->count());
$client->request('POST', $url, ['_token' => $token, 'domain' => 'shop.test']);
self::assertSame([], $this->reload($gmail)->catchAllDomains);
}
// ── helpers ──────────────────────────────────────────────────────────────
private function token(KernelBrowser $client, string $formAction): string
{
$crawler = $client->request('GET', '/settings?section=aliases');
self::assertResponseIsSuccessful();
$form = $crawler->filter(sprintf('form[action="%s"]', $formAction));
self::assertGreaterThan(0, $form->count(), sprintf('the panel renders a form posting to %s', $formAction));
return (string) $form->filter('input[name="_token"]')->first()->attr('value');
}
private function reload(Account $account): Account
{
$this->em->clear();
return $this->em->find(Account::class, $account->id);
}
private function boot(KernelBrowser $client): User
{
$container = static::getContainer();
$this->em = $container->get(EntityManagerInterface::class);
$this->connection = $container->get(Connection::class);
$user = $container->get(UserRepository::class)->findOneBy(['email' => self::ADMIN_EMAIL]);
if (null === $user) {
self::markTestSkipped('run `app:test:seed-user --admin` first');
}
$client->loginUser($user);
$client->disableReboot();
$this->connection->beginTransaction();
$this->em->createQuery(
'UPDATE ' . Account::class . ' a SET a.isActive = false WHERE a.usr = :usr',
)->setParameter('usr', $user)->execute();
$this->em->clear();
return $this->em->find(User::class, $user->id);
}
private function account(User $user, string $email): Account
{
$account = new Account();
$account->usr = $user;
$account->name = 'John Smith';
$account->username = $email;
$account->email = $email;
$account->authType = 'password';
$account->isActive = true;
$account->isPrimary = true;
$account->sortOrder = 0;
$account->imapHost = 'imap.example.test';
$this->em->persist($account);
$primary = new EmailAlias($account, $email, EmailAliasSource::Manual, EmailAliasStatus::Primary);
$account->addAlias($primary);
$this->em->persist($primary);
$this->em->flush();
return $account;
}
private function alias(Account $account, string $address, ?string $name = null): EmailAlias
{
$alias = new EmailAlias($account, $address, EmailAliasSource::Manual, EmailAliasStatus::Active, $name);
$account->addAlias($alias);
$this->em->persist($alias);
$this->em->flush();
return $alias;
}
}
@@ -387,6 +387,56 @@ final class SignatureSectionTest extends WebTestCase
* id — see SignatureController — so one lookup answers for every state the
* row can be in, including the inheriting one that has no editor.
*/
// ── HTML source and its rendering ────────────────────────────────────────
/**
* The "Rendered" view asks the server what the source turns into, and the
* answer is what a save would keep — nothing is stored by asking.
*/
public function testThePreviewReturnsWhatASaveWouldKeepAndStoresNothing(): void
{
$client = static::createClient();
$user = $this->boot($client);
$account = $this->account($user, 'source@joder.dev');
$crawler = $client->request('GET', '/settings?section=signature');
$form = $crawler->filter(sprintf('form[action="/account/%d/signature"]', $account->id));
self::assertSame(1, $form->filter('textarea[data-settings--signature-source-target="source"]')->count());
self::assertSame(
sprintf('/account/%d/signature/preview', $account->id),
$form->attr('data-settings--signature-source-url-value'),
);
$client->request('POST', (string) $form->attr('data-settings--signature-source-url-value'), [
'_token' => (string) $form->attr('data-settings--signature-source-token-value'),
'signature' => '<style>.s{color:red}</style><p class="s">Ada</p><script>alert(1)</script>',
]);
self::assertResponseIsSuccessful();
$preview = json_decode((string) $client->getResponse()->getContent(), true, flags: JSON_THROW_ON_ERROR);
self::assertSame('<p>Ada</p>', $preview['html']);
self::assertSame(['script', 'style'], $preview['removedElements']);
self::assertSame(['class'], $preview['removedAttributes']);
self::assertNull($this->reload($account)->getSetting(Account::SETTING_SIGNATURE));
}
public function testThePreviewRefusesARequestWithoutItsToken(): void
{
$client = static::createClient();
$user = $this->boot($client);
$account = $this->account($user, 'source@joder.dev');
$client->request('POST', sprintf('/account/%d/signature/preview', $account->id), [
'_token' => 'not-the-token',
'signature' => '<p>Ada</p>',
]);
self::assertResponseStatusCodeSame(403);
}
private function aliasToken(KernelBrowser $client, int $aliasId): string
{
$crawler = $client->request('GET', '/settings?section=signature');
@@ -0,0 +1,157 @@
<?php
declare(strict_types=1);
namespace App\Tests\Entity\Mail;
use App\Domain\Enum\Account\EmailAliasSource;
use App\Domain\Enum\Account\EmailAliasStatus;
use App\Entity\Mail\Account;
use App\Entity\Mail\EmailAlias;
use PHPUnit\Framework\TestCase;
/**
* Which addresses an account sends as, and under what name.
*
* The From selector, a reply's choice of sender, and the check on the token
* the browser posts back all ask the account these questions. They are pinned
* here once because the three must give one answer: an address the selector
* offers and the send refuses is a mail that cannot go out, and the reverse is
* a mail sent as somebody the user never was.
*/
final class AccountSendingAddressesTest extends TestCase
{
// ── catch-all domains ────────────────────────────────────────────────────
public function testAnAccountHasNoCatchAllDomainsUntilOneIsAdded(): void
{
$account = $this->account();
self::assertSame([], $account->catchAllDomains);
self::assertFalse($account->catchesAddress('anything@example.test'));
}
public function testEveryAddressAtACatchAllDomainIsCaught(): void
{
$account = $this->account();
$account->catchAllDomains = ['Example.TEST '];
self::assertSame(['example.test'], $account->catchAllDomains);
self::assertTrue($account->catchesAddress('anything@example.test'));
self::assertTrue($account->catchesAddress(' Sales@EXAMPLE.test'));
}
/** A catch-all is set up per domain at the provider; a subdomain is another one. */
public function testACatchAllDomainCoversNeitherItsSubdomainsNorItsLookalikes(): void
{
$account = $this->account();
$account->catchAllDomains = ['example.test'];
self::assertFalse($account->catchesAddress('me@mail.example.test'));
self::assertFalse($account->catchesAddress('me@notexample.test'));
self::assertFalse($account->catchesAddress('me@example.test.evil.test'));
self::assertFalse($account->catchesAddress('@example.test'));
self::assertFalse($account->catchesAddress('example.test'));
self::assertFalse($account->catchesAddress(null));
}
public function testRemovingTheLastCatchAllDomainRemovesTheSetting(): void
{
$account = $this->account();
$account->catchAllDomains = ['example.test'];
$account->catchAllDomains = [];
self::assertNull($account->getSetting(Account::SETTING_CATCH_ALL_DOMAINS));
}
/** The bag is jsonb: a value that is not a list of strings reads as none. */
public function testAStoredValueThatIsNotAListOfDomainsReadsAsNone(): void
{
$account = $this->account();
$account->setSetting(Account::SETTING_CATCH_ALL_DOMAINS, 'example.test');
self::assertSame([], $account->catchAllDomains);
$account->setSetting(Account::SETTING_CATCH_ALL_DOMAINS, [null, 7, '', 'example.test']);
self::assertSame(['example.test'], $account->catchAllDomains);
}
// ── what may go in From ──────────────────────────────────────────────────
public function testAnAccountMaySendAsItsEnabledAliasesAndNotItsDisabledOnes(): void
{
$account = $this->account();
$this->alias($account, 'me@example.test', EmailAliasStatus::Primary);
$this->alias($account, 'sales@example.test', EmailAliasStatus::Active);
$this->alias($account, 'old@example.test', EmailAliasStatus::Inactive);
self::assertTrue($account->maySendAs('me@example.test'));
self::assertTrue($account->maySendAs('Sales@Example.test'));
self::assertFalse($account->maySendAs('old@example.test'));
self::assertFalse($account->maySendAs('stranger@example.test'));
self::assertFalse($account->maySendAs(''));
}
public function testAnAccountWithNoAliasRowsMaySendAsItsOwnAddress(): void
{
self::assertTrue($this->account()->maySendAs('me@example.test'));
}
public function testAnAccountMaySendAsAnyAddressAtACatchAllDomain(): void
{
$account = $this->account();
$this->alias($account, 'me@example.test', EmailAliasStatus::Primary);
$account->catchAllDomains = ['shop.test'];
self::assertTrue($account->maySendAs('orders@shop.test'));
self::assertFalse($account->maySendAs('orders@other.test'));
}
/** Leaving yourself out of a reply-all has to know a catch-all address is you. */
public function testAnAddressAtACatchAllDomainIsTheAccountsOwn(): void
{
$account = $this->account();
$this->alias($account, 'me@example.test', EmailAliasStatus::Primary);
$account->catchAllDomains = ['shop.test'];
self::assertTrue($account->ownsAddress('me@example.test'));
self::assertTrue($account->ownsAddress('orders@shop.test'));
self::assertFalse($account->ownsAddress('someone@elsewhere.test'));
}
// ── the name mail goes out under ─────────────────────────────────────────
public function testAnAliasWithANameOfItsOwnSendsUnderIt(): void
{
$account = $this->account();
$this->alias($account, 'me@example.test', EmailAliasStatus::Primary);
$this->alias($account, 'family@example.test', EmailAliasStatus::Active, 'Mr and Mrs Smith');
self::assertSame('Mr and Mrs Smith', $account->senderNameFor('family@example.test'));
self::assertSame('Mr and Mrs Smith', $account->senderNameFor('Family@Example.test'));
}
public function testAnAliasWithoutANameSendsUnderTheAccounts(): void
{
$account = $this->account();
$this->alias($account, 'me@example.test', EmailAliasStatus::Primary);
self::assertSame('John Smith', $account->senderNameFor('me@example.test'));
self::assertSame('John Smith', $account->senderNameFor('orders@shop.test'));
self::assertSame('John Smith', $account->senderNameFor(null));
}
private function account(): Account
{
$account = new Account();
$account->name = 'John Smith';
$account->email = 'me@example.test';
return $account;
}
private function alias(Account $account, string $address, EmailAliasStatus $status, ?string $name = null): void
{
$account->addAlias(new EmailAlias($account, $address, EmailAliasSource::Manual, $status, $name));
}
}
@@ -193,6 +193,7 @@ final class ConfigBackupRoundTripTest extends KernelTestCase
$this->check('account setting sync.backfill_target', null, $account->getSetting(Account::SETTING_BACKFILL_TARGET));
$this->check('account setting compose.signature', '<p>Viele Grüße, Anna</p>', $account->getSetting(Account::SETTING_SIGNATURE));
$this->check('account setting compose.read_receipt.default', 'ask', $account->getSetting(Account::SETTING_READ_RECEIPT_DEFAULT));
$this->check('account setting mail.catch_all_domains', ['shop.test'], $account->catchAllDomains);
$this->check(
'account setting compose.signature.alias.N',
'<p>Anna, privat</p>',
@@ -429,6 +430,7 @@ final class ConfigBackupRoundTripTest extends KernelTestCase
$account->setSetting(Account::SETTING_BACKFILL_TARGET, 0);
$account->setSetting(Account::SETTING_SIGNATURE, '<p>Viele Grüße, Anna</p>');
$account->setSetting(Account::SETTING_READ_RECEIPT_DEFAULT, 'ask');
$account->catchAllDomains = ['shop.test'];
$this->entityManager->persist($account);
@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service\Imap;
use App\Domain\Enum\Account\EmailAliasSource;
use App\Domain\Enum\Account\EmailAliasStatus;
use App\Entity\Mail\Account;
use App\Entity\Mail\EmailAlias;
use App\Entity\Mail\Message;
use App\Service\Imap\MessageSendService;
use ReflectionMethod;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Mime\Email;
/**
* Two things about the mail that actually leaves, read off the MIME it is
* built into: whose name is in From, and what became of a `data:` image.
*
* buildEmail() is private and is reached by reflection, deliberately. Both
* were bugs of the same shape — the app showing one thing and the wire
* carrying another — so the assertion has to be made on the wire, and going
* through send() would need a mail server to say the same thing more slowly.
* Nothing is persisted: the method only reads the two objects it is given.
*/
final class OutgoingSenderNameAndImagesTest extends KernelTestCase
{
/** A one-pixel PNG. */
private const string PNG = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==';
/**
* #70. The From selector labelled the option "Mr and Mrs Smith" and the
* mail went out under the account holder's name.
*/
public function testMailFromAnAliasWithItsOwnNameGoesOutUnderThatName(): void
{
$account = $this->account();
$message = $this->message($account, 'family@example.test');
$from = $this->build($message, $account)->getFrom()[0];
self::assertSame('family@example.test', $from->getAddress());
self::assertSame('Mr and Mrs Smith', $from->getName());
}
public function testMailFromAnAddressWithNoNameOfItsOwnGoesOutUnderTheAccounts(): void
{
$account = $this->account();
self::assertSame('John Smith', $this->build($this->message($account, 'me@example.test'), $account)->getFrom()[0]->getName());
self::assertSame('John Smith', $this->build($this->message($account, 'yo@shop.test'), $account)->getFrom()[0]->getName());
}
/** A signature's embedded logo: attached and referenced, not left as a data URI. */
public function testADataImageInTheBodyLeavesAsAnInlinePart(): void
{
$account = $this->account();
$message = $this->message($account, 'me@example.test');
$message->bodyHtml = '<p>Hello</p><div data-pl-signature><img src="data:image/png;base64,' . self::PNG . '" alt="Acme"></div>';
$wire = $this->build($message, $account)->toString();
self::assertStringContainsString('multipart/related', $wire);
self::assertStringContainsString('Content-Type: image/png', $wire);
self::assertStringNotContainsString('data:image/png', quoted_printable_decode($wire));
self::assertMatchesRegularExpression('~<img src="cid:[^"]+" alt="Acme">~', quoted_printable_decode($wire));
self::assertStringContainsString('data:image/png', (string) $message->bodyHtml, 'the stored body is not rewritten');
}
private function build(Message $message, Account $account): Email
{
$service = self::getContainer()->get(MessageSendService::class);
return new ReflectionMethod($service, 'buildEmail')->invoke($service, $message, $account);
}
private function message(Account $account, string $from): Message
{
$message = new Message();
$message->account = $account;
$message->fromAddress = $from;
$message->subject = 'Hello';
$message->bodyHtml = '<p>Hello</p>';
$message->toAddresses = [['name' => 'Rike', 'address' => 'rike@example.test']];
return $message;
}
private function account(): Account
{
$account = new Account();
$account->name = 'John Smith';
$account->email = 'me@example.test';
$account->addAlias(new EmailAlias($account, 'me@example.test', EmailAliasSource::Manual, EmailAliasStatus::Primary));
$account->addAlias(new EmailAlias($account, 'family@example.test', EmailAliasSource::Manual, EmailAliasStatus::Active, 'Mr and Mrs Smith'));
return $account;
}
}
@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service\Mail\Mime;
use App\Service\Mail\MailBodySanitizer;
use App\Service\Mail\Mime\DataImageEmbedder;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Mime\Email;
/**
* A `data:` image in a body leaves as an attached inline part.
*
* The assertions are made on the serialised mail wherever they can be, because
* that is what a recipient's client is handed: a part with a Content-ID, a
* body that points at it, and no data URI left for Gmail to discard.
*/
final class DataImageEmbedderTest extends TestCase
{
/** A one-pixel PNG. */
private const string PNG = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==';
private DataImageEmbedder $embedder;
protected function setUp(): void
{
$this->embedder = new DataImageEmbedder();
}
public function testADataImageBecomesAnInlinePartTheBodyPointsAt(): void
{
$email = $this->email();
$html = $this->embedder->embed($email, '<p>Paul</p><img src="data:image/png;base64,' . self::PNG . '" alt="logo" width="40">');
self::assertStringNotContainsString('data:image', $html);
self::assertMatchesRegularExpression('~<img src="cid:inline-[0-9a-f]{16}\.png" alt="logo" width="40">~', $html);
self::assertCount(1, $email->getAttachments());
$wire = $email->html($html)->toString();
self::assertStringContainsString('multipart/related', $wire);
self::assertStringContainsString('Content-Type: image/png', $wire);
self::assertStringContainsString('Content-Disposition: inline', $wire);
self::assertStringNotContainsString('data:image', $wire);
}
public function testTheSameImageUsedTwiceIsAttachedOnce(): void
{
$email = $this->email();
$image = '<img src="data:image/png;base64,' . self::PNG . '">';
$html = $this->embedder->embed($email, $image . '<p>and again</p>' . $image);
self::assertCount(1, $email->getAttachments());
self::assertSame(2, substr_count($html, 'cid:inline-'));
}
/** Base64 out of a generator is often wrapped; the image is the same one. */
public function testWrappedBase64AndSingleQuotesAreUnderstood(): void
{
$email = $this->email();
$html = $this->embedder->embed(
$email,
"<img alt='x' src='data:image/png;base64," . chunk_split(self::PNG, 20, "\n") . "'>",
);
self::assertStringContainsString("src='cid:inline-", $html);
self::assertSame(base64_decode(self::PNG), $email->getAttachments()[0]->getBody());
}
/**
* The form every stored body is actually in. The sanitiser entity-encodes
* attribute values, so the padding arrives as `&#61;` and a plus as
* `&#43;` — and an embedder that only knew raw base64 embedded nothing
* that had been saved.
*/
public function testAnImageThatHasBeenThroughTheSanitiserIsStillEmbedded(): void
{
$sanitizer = new MailBodySanitizer(self::createStub(UrlGeneratorInterface::class), new NullLogger());
// 0xFB 0xEF 0xBE encodes to "++++", and one byte more leaves padding.
$bytes = "\xFB\xEF\xBE\xFB";
$sanitized = $sanitizer->sanitizeComposedBody('<img src="data:image/png;base64,' . base64_encode($bytes) . '" alt="logo">');
self::assertStringContainsString('&#61;', $sanitized, 'the fixture is in the encoded form this is about');
$email = $this->email();
$html = $this->embedder->embed($email, $sanitized);
self::assertStringContainsString('src="cid:inline-', $html);
self::assertSame($bytes, $email->getAttachments()[0]->getBody());
}
public function testWhatIsNotAnEmbeddableImageIsLeftExactlyAsItWas(): void
{
$email = $this->email();
foreach ([
'<p>No images at all.</p>',
'<img src="https://example.test/logo.png">',
'<img src="cid:already@plmail">',
'<img src="data:image/svg+xml;base64,PHN2Zy8+">',
'<img src="data:image/png;base64,">',
'<p>data:image/png;base64,' . self::PNG . ' written out in a paragraph</p>',
] as $html) {
self::assertSame($html, $this->embedder->embed($email, $html));
}
self::assertCount(0, $email->getAttachments());
}
/** A real logo is far past PCRE's backtrack limit if the pattern can backtrack. */
public function testALargeImageIsEmbeddedRatherThanDroppedByThePattern(): void
{
$email = $this->email();
$bytes = random_bytes(400_000);
$html = $this->embedder->embed($email, '<img src="data:image/jpeg;base64,' . base64_encode($bytes) . '">');
self::assertMatchesRegularExpression('~^<img src="cid:inline-[0-9a-f]{16}\.jpg">$~', $html);
self::assertSame($bytes, $email->getAttachments()[0]->getBody());
}
private function email(): Email
{
return new Email()->from('me@example.test')->to('you@example.test')->subject('Hello');
}
}
@@ -4,7 +4,10 @@ declare(strict_types=1);
namespace App\Tests\Service\Mail;
use App\Domain\Enum\Account\EmailAliasSource;
use App\Domain\Enum\Account\EmailAliasStatus;
use App\Entity\Mail\Account;
use App\Entity\Mail\EmailAlias;
use App\Entity\Mail\Message;
use App\Entity\Mail\MessageThread;
use App\Entity\User\User;
@@ -404,6 +407,52 @@ final class ReplyDraftBuilderTest extends TestCase
);
}
// ── the address it answers from ──────────────────────────────────────────
/** ReplyFromResolverTest has the rules; this is that the draft carries the answer. */
public function testAReplyIsAddressedFromTheAliasTheOriginalWasSentTo(): void
{
$account = $this->account();
$account->addAlias(new EmailAlias($account, 'me@example.test', EmailAliasSource::Manual, EmailAliasStatus::Primary));
$sales = new EmailAlias($account, 'sales@example.test', EmailAliasSource::Manual, EmailAliasStatus::Active);
$account->addAlias($sales);
$original = $this->original();
$original->toAddresses = [['name' => '', 'address' => 'sales@example.test']];
self::assertSame('sales@example.test', $this->builder->reply($original, $account)->fromAddress);
}
public function testAReplyToMailThatNamesNoAddressOfOursLeavesFromUnset(): void
{
$original = $this->original();
$original->toAddresses = [['name' => '', 'address' => 'list@elsewhere.test']];
$original->ccAddresses = [];
self::assertNull($this->builder->reply($original, $this->account())->fromAddress);
}
/** yo@shop.test is in no list of aliases, and is still the user: not in their own Cc. */
public function testAReplyAllLeavesOutTheCatchAllAddressTheMailCameInFor(): void
{
$account = $this->account();
$account->catchAllDomains = ['shop.test'];
$original = $this->original();
$original->toAddresses = [
['name' => '', 'address' => 'yo@shop.test'],
['name' => 'Tanja', 'address' => 'tanja@example.test'],
];
$draft = $this->builder->reply($original, $account, replyAll: true);
self::assertSame('yo@shop.test', $draft->fromAddress);
self::assertSame(
['tanja@example.test', 'cc@example.test'],
array_column($draft->ccAddresses ?? [], 'address'),
);
}
private function signedAccount(string $signature): Account
{
$account = $this->account();
@@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service\Mail;
use App\Domain\Enum\Account\EmailAliasSource;
use App\Domain\Enum\Account\EmailAliasStatus;
use App\Entity\Mail\Account;
use App\Entity\Mail\EmailAlias;
use App\Entity\Mail\Message;
use App\Service\Mail\ReplyFromResolver;
use PHPUnit\Framework\TestCase;
/**
* A reply goes out from the address the mail was sent to.
*
* Every case is the same question — which of this account's addresses does the
* original name — asked of a different place in the message, in the order the
* resolver looks.
*/
final class ReplyFromResolverTest extends TestCase
{
private ReplyFromResolver $resolver;
protected function setUp(): void
{
$this->resolver = new ReplyFromResolver();
}
public function testMailSentToAnAliasIsAnsweredFromThatAlias(): void
{
$original = $this->original(to: ['Sales@Example.test']);
self::assertSame('sales@example.test', $this->resolver->addressFor($original, $this->account()));
}
public function testAnAliasInCcCounts(): void
{
$original = $this->original(to: ['someone@elsewhere.test'], cc: ['sales@example.test']);
self::assertSame('sales@example.test', $this->resolver->addressFor($original, $this->account()));
}
/** What the sender typed first is who they thought they were writing to. */
public function testToIsPreferredOverCc(): void
{
$original = $this->original(to: ['sales@example.test'], cc: ['me@example.test']);
self::assertSame('sales@example.test', $this->resolver->addressFor($original, $this->account()));
}
public function testMailToADisabledAliasIsNotAnsweredFromIt(): void
{
$original = $this->original(to: ['old@example.test']);
self::assertNull($this->resolver->addressFor($original, $this->account()));
}
public function testMailThatNamesNoneOfTheAccountsAddressesHasNoAnswer(): void
{
$original = $this->original(to: ['list@elsewhere.test']);
self::assertNull($this->resolver->addressFor($original, $this->account()));
}
/** Bcc and list mail: the address is only in what the server recorded. */
public function testTheDeliveryHeadersAreReadWhenToAndCcNameNobody(): void
{
$original = $this->original(to: ['list@elsewhere.test']);
$original->headers = ['Delivered-To' => 'sales@example.test'];
self::assertSame('sales@example.test', $this->resolver->addressFor($original, $this->account()));
// webklex spells header names lowercase with underscores.
$original->headers = ['x_original_to' => '<Sales@example.test>'];
self::assertSame('sales@example.test', $this->resolver->addressFor($original, $this->account()));
}
/** A forwarding hop rewrites Delivered-To; what the sender typed still wins. */
public function testTheVisibleRecipientsWinOverTheDeliveryHeaders(): void
{
$original = $this->original(to: ['sales@example.test']);
$original->headers = ['Delivered-To' => 'me@example.test'];
self::assertSame('sales@example.test', $this->resolver->addressFor($original, $this->account()));
}
public function testMailToACatchAllAddressIsAnsweredFromThatAddress(): void
{
$account = $this->account();
$account->catchAllDomains = ['shop.test'];
$original = $this->original(to: ['Yo@Shop.test']);
self::assertSame('yo@shop.test', $this->resolver->addressFor($original, $account));
}
/** Answering your own sent mail continues it as whoever you were then. */
public function testAReplyToYourOwnMailKeepsTheAddressItWasSentFrom(): void
{
$original = $this->original(to: ['customer@elsewhere.test']);
$original->fromAddress = 'sales@example.test';
self::assertSame('sales@example.test', $this->resolver->addressFor($original, $this->account()));
}
/**
* @param list<string> $to
* @param list<string> $cc
*/
private function original(array $to, array $cc = []): Message
{
$wrap = static fn (string $address): array => ['name' => '', 'address' => $address];
$message = new Message();
$message->fromAddress = 'k@elsewhere.test';
$message->toAddresses = array_map($wrap, $to);
$message->ccAddresses = array_map($wrap, $cc);
return $message;
}
private function account(): Account
{
$account = new Account();
$account->email = 'me@example.test';
$account->addAlias(new EmailAlias($account, 'me@example.test', EmailAliasSource::Manual, EmailAliasStatus::Primary));
$account->addAlias(new EmailAlias($account, 'sales@example.test', EmailAliasSource::Manual, EmailAliasStatus::Active));
$account->addAlias(new EmailAlias($account, 'old@example.test', EmailAliasSource::Manual, EmailAliasStatus::Inactive));
return $account;
}
}
+44
View File
@@ -4,7 +4,11 @@ declare(strict_types=1);
namespace App\Tests\Service\Mail;
use App\Domain\Enum\Account\EmailAliasSource;
use App\Domain\Enum\Account\EmailAliasStatus;
use App\Entity\Mail\Account;
use App\Entity\Mail\EmailAlias;
use App\Entity\Mail\Message;
use App\Entity\User\User;
use App\Repository\Mail\AccountRepository;
use App\Service\Mail\SenderResolver;
@@ -139,6 +143,46 @@ final class SenderResolverTest extends TestCase
self::assertStringEndsWith('|me@example.test', $this->resolver->token($account));
}
// ── the token a draft's window opens on ──────────────────────────────────
public function testADraftOpensOnTheAddressItAlreadyGoesOutFrom(): void
{
$account = $this->account();
$account->addAlias(new EmailAlias($account, 'me@example.test', EmailAliasSource::Manual, EmailAliasStatus::Primary));
$account->addAlias(new EmailAlias($account, 'sales@example.test', EmailAliasSource::Manual, EmailAliasStatus::Active));
// The fixture account was never persisted, so its id reads as 0.
self::assertSame('0|sales@example.test', $this->resolver->tokenFor($this->draft($account, 'Sales@Example.test'), $account));
}
public function testADraftFromACatchAllAddressOpensOnThatAddress(): void
{
$account = $this->account();
$account->catchAllDomains = ['shop.test'];
self::assertSame('0|yo@shop.test', $this->resolver->tokenFor($this->draft($account, 'yo@shop.test'), $account));
}
/** An alias disabled since the draft was written is not an option any more. */
public function testADraftFromAnAddressTheAccountNoLongerSendsAsOpensOnThePrimary(): void
{
$account = $this->account();
$account->addAlias(new EmailAlias($account, 'me@example.test', EmailAliasSource::Manual, EmailAliasStatus::Primary));
$account->addAlias(new EmailAlias($account, 'old@example.test', EmailAliasSource::Manual, EmailAliasStatus::Inactive));
self::assertSame($this->resolver->token($account), $this->resolver->tokenFor($this->draft($account, 'old@example.test'), $account));
self::assertSame($this->resolver->token($account), $this->resolver->tokenFor($this->draft($account, null), $account));
}
private function draft(Account $account, ?string $from): Message
{
$draft = new Message();
$draft->account = $account;
$draft->fromAddress = $from;
return $draft;
}
private function account(): Account
{
$account = new Account();
@@ -0,0 +1,180 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service\Mail;
use App\Service\Mail\ImageProxyFetcher;
use App\Service\Mail\MailBodySanitizer;
use App\Service\Mail\SignatureImporter;
use App\Service\Mail\SignatureProvider;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
/**
* A signature pasted as HTML: what is kept, what is reported, what is fetched.
*
* The real sanitiser and the real image fetcher, over a mock HTTP client. The
* report is a claim about what the sanitiser did, so a stubbed sanitiser would
* only test that the report agrees with the stub.
*/
final class SignatureImporterTest extends TestCase
{
/** A one-pixel PNG. */
private const string PNG = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==';
/** A literal public address: passes the fetcher's range check without a resolver. */
private const string HOST = 'https://93.184.216.34';
/** @var list<string> */
private array $requested = [];
// ── the report ───────────────────────────────────────────────────────────
public function testASignatureTheSanitiserLeavesAloneReportsNothingRemoved(): void
{
$imported = $this->importer()->import(
'<p style="color: #333">Paul <b>Joder</b><br><a href="https://example.test">example.test</a></p>',
);
self::assertSame([], $imported->removedElements);
self::assertSame([], $imported->removedAttributes);
self::assertFalse($imported->changedAnything());
self::assertStringContainsString('<b>Joder</b>', $imported->html);
}
public function testWhatTheSanitiserDropsIsNamed(): void
{
$imported = $this->importer()->import(
'<style>.sig { color: red }</style>'
. '<div class="sig" id="s" onclick="track()"><p>Paul</p><script>alert(1)</script></div>',
);
self::assertSame(['script', 'style'], $imported->removedElements);
// `id` is not in the list because the sanitiser keeps it: the report
// is of what happened, not of what one might expect to.
self::assertSame(['class', 'onclick'], $imported->removedAttributes);
self::assertTrue($imported->changedAnything());
self::assertStringNotContainsString('alert', $imported->html);
self::assertStringContainsString('<p>Paul</p>', $imported->html);
}
/** A whole page pasted as a signature loses its scaffolding; that is not news. */
public function testTheScaffoldingOfAFullDocumentIsNotReported(): void
{
$imported = $this->importer()->import(
'<!DOCTYPE html><html><head><meta charset="utf-8"><title>Sig</title></head><body><p>Paul</p></body></html>',
);
self::assertSame([], $imported->removedElements);
self::assertStringContainsString('<p>Paul</p>', $imported->html);
}
public function testAnEmptySignatureIsEmptyAndReportsNothing(): void
{
$imported = $this->importer()->import(" \n ");
self::assertSame('', $imported->html);
self::assertFalse($imported->changedAnything());
self::assertSame(0, $imported->embeddedImages + $imported->remoteImages);
}
// ── images ───────────────────────────────────────────────────────────────
public function testARemoteImageIsFetchedAndStoredInTheSignature(): void
{
$imported = $this->importer()->import(
'<p>Paul</p><img src="' . self::HOST . '/logo.png?v=1&amp;s=2" alt="Acme" width="80">',
);
self::assertSame([self::HOST . '/logo.png?v=1&s=2'], $this->requested, 'the URL is fetched decoded, once');
self::assertSame(1, $imported->embeddedImages);
self::assertSame(0, $imported->remoteImages);
self::assertStringContainsString('src="data:image/png;base64,' . self::PNG . '"', $imported->html);
self::assertStringContainsString('alt="Acme"', $imported->html);
self::assertStringNotContainsString('93.184.216.34', $imported->html);
self::assertSame([], $imported->removedAttributes, 'a rewritten src is not a removed one');
}
/** Stored once, a data image must come through the next save unchanged and unfetched. */
public function testAnAlreadyEmbeddedImageIsKeptAsItIs(): void
{
$html = '<img src="data:image/png;base64,' . self::PNG . '" alt="Acme">';
$imported = $this->importer()->import($html);
self::assertSame([], $this->requested);
// Compared decoded: the sanitiser writes the padding as `&#61;`.
self::assertStringContainsString('data:image/png;base64,' . self::PNG, html_entity_decode($imported->html));
self::assertSame(0, $imported->embeddedImages);
self::assertSame(0, $imported->remoteImages);
}
public function testAnImageThatCannotBeFetchedStaysRemoteAndIsCounted(): void
{
$imported = $this->importer(status: 404)->import('<img src="' . self::HOST . '/gone.png">');
self::assertSame(0, $imported->embeddedImages);
self::assertSame(1, $imported->remoteImages);
self::assertStringContainsString('src="' . self::HOST . '/gone.png"', $imported->html);
}
/** The fetcher refuses plain http and private addresses; neither is asked for. */
public function testAnImageTheFetcherRefusesStaysRemoteWithoutARequest(): void
{
$imported = $this->importer()->import(
'<img src="http://93.184.216.34/logo.png"><img src="https://192.168.1.10/logo.png">',
);
self::assertSame([], $this->requested);
self::assertSame(2, $imported->remoteImages);
}
public function testAnImageTooLargeForASignatureStaysRemote(): void
{
$imported = $this->importer(body: random_bytes(300 * 1024))->import('<img src="' . self::HOST . '/photo.png">');
self::assertSame(0, $imported->embeddedImages);
self::assertSame(1, $imported->remoteImages);
self::assertStringNotContainsString('data:image', $imported->html);
}
/** No mail client shows an attached SVG, so embedding one would only hide the failure. */
public function testAnSvgStaysRemote(): void
{
$imported = $this->importer(body: '<svg xmlns="http://www.w3.org/2000/svg"/>', type: 'image/svg+xml')
->import('<img src="' . self::HOST . '/logo.svg">');
self::assertSame(0, $imported->embeddedImages);
self::assertSame(1, $imported->remoteImages);
}
private function importer(?string $body = null, string $type = 'image/png', int $status = 200): SignatureImporter
{
$body ??= (string) base64_decode(self::PNG);
$client = new MockHttpClient(function (string $method, string $url) use ($body, $type, $status): MockResponse {
$this->requested[] = $url;
return new MockResponse($body, [
'http_code' => $status,
'response_headers' => ['content-type' => $type],
]);
});
$sanitizer = new MailBodySanitizer(self::createStub(UrlGeneratorInterface::class), new NullLogger());
return new SignatureImporter(
new SignatureProvider($sanitizer),
new ImageProxyFetcher(
$client,
new NullLogger(),
// A directory of its own: the fetcher caches by URL, and a
// cached answer from another case would be this case's result.
sys_get_temp_dir() . '/plmail-signature-import-test-' . bin2hex(random_bytes(4)),
),
);
}
}
+56
View File
@@ -133,6 +133,62 @@ test.describe("signature settings", () => {
}
});
/**
* A signature that already exists as HTML: pasted as source, rendered by
* the server, and saved as what was shown.
*
* In a browser because the claim is about what is on screen. The rendered
* view has to be the sanitised signature and not the browser's own reading
* of the source — the `<style>` rule below would turn the text red if the
* source were simply dropped into the page — and the line under the editor
* has to name what was taken out.
*/
test("HTML pasted as source is rendered as it will be saved", async ({
page,
}) => {
await page.goto("/settings?section=signature");
const block = accountBlock(page);
const editor = block.locator(EDITOR).first();
const source = block.getByRole("textbox", { name: /as HTML/ }).first();
const stamp = `Ada ${Date.now()}`;
await expect(source).toBeHidden();
await block.getByRole("button", { name: "HTML", exact: true }).first().click();
await expect(source).toBeVisible();
await expect(editor).toBeHidden();
await source.fill(
`<style>.sig { color: rgb(255, 0, 0) }</style><p class="sig"><b>${stamp}</b></p><script>window.pwned = true</script>`,
);
await block.getByRole("button", { name: "Rendered", exact: true }).first().click();
await expect(editor).toBeVisible();
await expect(source).toBeHidden();
await expect(editor.locator("b")).toHaveText(stamp);
await expect(editor.locator("style, script")).toHaveCount(0);
await expect(editor.locator("p")).not.toHaveCSS("color", "rgb(255, 0, 0)");
await expect(block.getByRole("status").first()).toContainText("<script>, <style>, class");
expect(await page.evaluate(() => (window as any).pwned)).toBeUndefined();
// Back in the source view, the source is the cleaned one.
await block.getByRole("button", { name: "HTML", exact: true }).first().click();
await expect(source).toHaveValue(`<p><b>${stamp}</b></p>`);
// Saving from the source view stores what was typed, cleaned the same way.
await source.fill(`<p><i>${stamp}</i></p><script>window.pwned = true</script>`);
await block.getByRole("button", { name: "Save signature" }).first().click();
await expect(page.getByText("Saved.")).toBeVisible();
await page.goto("/settings?section=signature");
await expect(accountBlock(page).locator(EDITOR).first().locator("i")).toHaveText(stamp);
await expect(accountBlock(page).locator(EDITOR).first().locator("script")).toHaveCount(0);
});
/**
* Overriding reveals a usable editor on the FIRST render — no toggle
* round trip — and reverting takes the row back to one line.
+27
View File
@@ -1152,9 +1152,16 @@ admin:
hint: Der Build, den diese Installation ausführt — die Version und der Commit, aus dem sie gebaut wurde.
alias:
added: 'Adresse hinzugefügt.'
catch_all_added: 'Catch-all-Domain hinzugefügt.'
catch_all_duplicate: 'Diese Domain ist in diesem Konto bereits als Catch-all eingetragen.'
catch_all_invalid: 'Das sieht nicht nach einer Domain aus. Gib sie so ein: example.com.'
catch_all_removed: 'Catch-all-Domain entfernt.'
catch_all_unsupported: 'Gmail und Outlook senden nur über Adressen, die der Anbieter kennt. Eine Catch-all-Domain lässt sich in diesem Konto deshalb nicht nutzen.'
deleted: 'Adresse entfernt.'
duplicate: 'Diese Adresse ist bereits in diesem Konto vorhanden.'
invalid: 'Das sieht nicht nach einer gültigen E-Mail-Adresse aus.'
name_cleared: 'Absendername entfernt. Diese Adresse sendet wieder unter dem Namen des Kontos.'
name_set: 'Absendername gespeichert.'
primary_set: 'Hauptadresse aktualisiert.'
refreshed: 'Vom Anbieter aktualisiert.'
status_blocked: 'Die Hauptadresse kann nicht deaktiviert werden — lege zuerst eine andere als Hauptadresse fest.'
@@ -2810,6 +2817,12 @@ settings:
aliases:
add: 'Hinzufügen'
add_placeholder: 'Weitere Adresse hinzufügen…'
catch_all:
add: 'Als Catch-all eintragen'
hint: 'Wenn jede Adresse einer Domain in diesem Postfach ankommt, trage die Domain hier ein. Eine Antwort geht dann von der Adresse aus, an die die Mail gerichtet war, ohne dass du jede einzeln oben hinzufügst.'
label: 'Catch-all-Domains'
placeholder: 'example.com'
remove: '%domain% entfernen'
delete: 'Entfernen'
disable: 'Deaktivieren'
empty: 'Noch keine Adressen.'
@@ -2818,6 +2831,9 @@ settings:
make_primary: 'Als Hauptadresse festlegen'
ms_sender_hint: 'Outlook versendet unabhängig von der hier gewählten Adresse immer über den primären Alias deines Kontos — ändere ihn in deinen'
ms_sender_hint_link: 'Microsoft-Kontoeinstellungen'
name_for: 'Absendername für %alias%'
name_placeholder: 'Absendername'
name_save: 'Namen speichern'
primary: 'Primär'
refresh: 'Vom Anbieter aktualisieren'
subheading: 'Adressen, unter denen dieses Konto sendet und empfängt.'
@@ -3922,6 +3938,17 @@ settings:
reverted: 'Wieder die Signatur des Kontos.'
save: 'Signatur speichern'
saved: 'Gespeichert.'
source:
clean: 'Es wurde nichts entfernt.'
embedded: 'In der Signatur gespeicherte Bilder: %count%.'
failed: 'Die Signatur konnte nicht dargestellt werden. Dein HTML ist unverändert. Versuch es noch einmal.'
html: 'HTML'
label: '%label% als HTML'
placeholder: 'Füge das HTML deiner Signatur ein oder schreib es hier…'
remote: 'Bilder, die nicht gespeichert werden konnten und verlinkt bleiben: %count%. Sie werden hier nicht angezeigt, und manche Empfänger müssen sie erst erlauben.'
removed: 'Entfernt, weil Mailprogramme es nicht anzeigen würden oder es nicht sicher zu versenden ist: %items%.'
rendered: 'Darstellung'
toggle: 'Ansicht der Signatur'
subheading: 'Was am Ende der Nachrichten steht, die du schreibst.'
sorting:
again:
+27
View File
@@ -1138,9 +1138,16 @@ admin:
hint: The build this installation is running — the release, and the commit it was built from.
alias:
added: 'Address added.'
catch_all_added: 'Catch-all domain added.'
catch_all_duplicate: 'That domain is already a catch-all on this account.'
catch_all_invalid: "That doesn't look like a domain. Enter it like example.com."
catch_all_removed: 'Catch-all domain removed.'
catch_all_unsupported: 'Gmail and Outlook only send as addresses the provider knows, so a catch-all domain cannot be used on this account.'
deleted: 'Address removed.'
duplicate: 'That address is already on this account.'
invalid: "That doesn't look like a valid email address."
name_cleared: 'Sender name removed. This address sends under the account name again.'
name_set: 'Sender name saved.'
primary_set: 'Primary address updated.'
refreshed: 'Refreshed from provider.'
status_blocked: "The primary address can't be disabled — make another primary first."
@@ -2821,6 +2828,12 @@ settings:
aliases:
add: 'Add'
add_placeholder: 'Add another address…'
catch_all:
add: 'Set as catch-all'
hint: 'If every address at a domain reaches this mailbox, add the domain here. A reply then goes out from the address the mail was sent to, without adding each one above.'
label: 'Catch-all domains'
placeholder: 'example.com'
remove: 'Remove %domain%'
delete: 'Remove'
disable: 'Disable'
empty: 'No addresses yet.'
@@ -2829,6 +2842,9 @@ settings:
make_primary: 'Make primary'
ms_sender_hint: 'Outlook sends as your account’s primary alias regardless of the address chosen here — change it in your'
ms_sender_hint_link: 'Microsoft account settings'
name_for: 'Sender name for %alias%'
name_placeholder: 'Sender name'
name_save: 'Save name'
primary: 'Primary'
refresh: 'Refresh from provider'
subheading: 'Addresses this account sends and receives as.'
@@ -3903,6 +3919,17 @@ settings:
reverted: 'Back to the account signature.'
save: 'Save signature'
saved: 'Saved.'
source:
clean: 'Nothing was removed.'
embedded: 'Images stored in the signature: %count%.'
failed: 'The signature could not be rendered. Your HTML is unchanged. Try again.'
html: 'HTML'
label: '%label% as HTML'
placeholder: 'Paste or write the HTML of your signature…'
remote: 'Images that could not be stored and stay linked: %count%. They do not show here and some recipients have to allow them first.'
removed: 'Removed, because mail clients would not show it or it is not safe to send: %items%.'
rendered: 'Rendered'
toggle: 'Signature view'
subheading: 'What is added to the bottom of the messages you write.'
sorting:
again:
+27
View File
@@ -1059,9 +1059,16 @@ admin:
hint: The build this ship be sailin' — the release, an' the commit she were built from.
alias:
added: 'Address added, arr!'
catch_all_added: 'Catch-all harbour added, arr!'
catch_all_duplicate: 'That harbour already catches all for this crew member.'
catch_all_invalid: 'That be no harbour name. Write it like example.com.'
catch_all_removed: 'Catch-all harbour struck from the charts.'
catch_all_unsupported: 'Gmail and Outlook only sail under addresses their harbourmaster knows, so a catch-all harbour be no use to this crew member.'
deleted: 'Address struck from the roster.'
duplicate: 'That address already sails with this crew member.'
invalid: 'That don''t look like a proper parrot address.'
name_cleared: 'Sender name struck. This address sails under the crew member''s name again.'
name_set: 'Sender name stowed.'
primary_set: 'Flagship address changed.'
refreshed: 'Fetched anew from the harbourmaster.'
status_blocked: 'The flagship address can''t be struck — name another flagship first.'
@@ -2666,6 +2673,12 @@ settings:
aliases:
add: 'Add'
add_placeholder: 'Add another address…'
catch_all:
add: 'Make it catch all'
hint: 'If every address at a harbour lands in this hold, add the harbour here. A reply then sails from the address the letter was sent to, without adding each one above.'
label: 'Catch-all harbours'
placeholder: 'example.com'
remove: 'Strike %domain%'
delete: 'Strike it'
disable: 'Furl it'
empty: 'No addresses yet.'
@@ -2674,6 +2687,9 @@ settings:
make_primary: 'Make it the flagship'
ms_sender_hint: 'Outlook always sails under yer crew member''s flagship alias, whatever ye pick here — change it in yer'
ms_sender_hint_link: 'Microsoft account settings'
name_for: 'Sender name for %alias%'
name_placeholder: 'Sender name'
name_save: 'Stow the name'
primary: 'Flagship'
refresh: 'Fetch from the harbourmaster'
subheading: 'Addresses this crew member sends and receives under.'
@@ -3705,6 +3721,17 @@ settings:
reverted: 'Back to the ship''s own mark.'
save: 'Set down yer mark'
saved: 'Set down.'
source:
clean: 'Nothing was thrown overboard.'
embedded: 'Pictures stowed in the signature: %count%.'
failed: 'The signature could not be drawn. Yer HTML be untouched. Try again.'
html: 'HTML'
label: '%label% as HTML'
placeholder: 'Paste or scrawl the HTML of yer signature…'
remote: 'Pictures that could not be stowed and stay linked: %count%. They do not show here, and some recipients must allow them first.'
removed: 'Thrown overboard, for letter readers would not show it or it be not safe to send: %items%.'
rendered: 'Drawn'
toggle: 'Signature view'
subheading: 'What be carved at the foot o'' the scrolls ye write.'
sorting:
again: