Merge fix/compose-safety

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Paul LütznerandClaude Fable 5 committed 2026-08-11 18:39:11 +02:00
commit 17c21df702
18 files changed
+1481 -106

No files matched your search

+290 -18
View File
@@ -2,11 +2,25 @@
import { Controller } from '@hotwired/stimulus'
export default class extends Controller {
static targets = ['toField', 'ccField', 'bccField', 'subject', 'body', 'saveStatus', 'toCollection', 'collapsible', 'minimizeIcon', 'expandIcon', 'ccBtn', 'bccBtn', 'title', 'accountSelect', 'fromBtn', 'fromLabel', 'fromChevron', 'fromDropdown', 'fromRow', 'fields', 'fieldsChevron', 'fileInput', 'attachments', 'scroller', 'formatBar', 'formatToggle', 'sendBtn'];
static targets = ['toField', 'ccField', 'bccField', 'subject', 'body', 'saveStatus', 'toCollection', 'collapsible', 'minimizeIcon', 'expandIcon', 'ccBtn', 'bccBtn', 'title', 'accountSelect', 'fromBtn', 'fromLabel', 'fromChevron', 'fromDropdown', 'fromRow', 'fields', 'fieldsChevron', 'fileInput', 'attachments', 'scroller', 'formatBar', 'formatToggle', 'sendBtn', 'errors'];
/** Below this the dock window is the whole screen — matches Tailwind's md. */
static MOBILE_QUERY = '(max-width: 767px)';
/**
* An address the window will accept as a chip.
*
* Deliberately the same shape as the `createFilter` ContactAutocompleteField
* hands Tom Select, because they are two enforcements of one rule and a
* window that chipped what the field would refuse — or the other way round
* — is worse than either rule alone.
*/
static ADDRESS = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
static values = {
// Every user-facing string the controller writes, translated
// server-side. Defaults are English so a window rendered without the
// attribute still says something, but the template always supplies it.
i18n: { type: Object, default: {} },
draftUrl: String,
sendUrl: String,
autosaveDelay: { type: Number, default: 2000 },
@@ -36,9 +50,12 @@ export default class extends Controller {
this._boundAutosave = this._scheduleAutosave.bind(this);
this._boundHandleTab = this._handleTab.bind(this);
this._boundHandleEnter = this._handleEnter.bind(this);
// Bubbles up from the Tom Select textboxes, so it runs after Tom
// Select's own key handler has had its say — see _handleTab().
this.element.addEventListener('keydown', this._boundHandleTab);
this.element.addEventListener('keydown', this._boundHandleEnter);
// The integration picker lives in the modal frame, which is outside
// this element entirely, so it reports back on window rather than
@@ -74,6 +91,13 @@ export default class extends Controller {
this._focusCursorAtTop();
}
// Tom Select is built by the autocomplete bundle, which may connect
// after this controller does — hence both the immediate pass and the
// event.
this._boundNameAddressFields = this._nameAddressFields.bind(this);
this.element.addEventListener('autocomplete:connect', this._boundNameAddressFields);
requestAnimationFrame(this._boundNameAddressFields);
// Inline: the thread's reply buttons step aside while we're open.
// Cached — by the time disconnect() runs the card is already detached
// and closest() would find nothing.
@@ -81,9 +105,33 @@ export default class extends Controller {
this._zone?.classList.add('composing');
}
/**
* Give each recipient combobox the name its label already carries.
*
* Tom Select replaces the <select> with a text input of its own making,
* and an accessible name does not follow across that swap on its own — so
* the To field reported as a bare "combobox" with nothing to say what it
* was for. The <label for> in the template is the source of truth; this
* copies it onto whatever Tom Select actually built.
*/
_nameAddressFields() {
for (const row of this._addressRows()) {
const label = row.querySelector('label');
const input = row.querySelector('.ts-control input');
if (null === label || null === input) {
continue;
}
input.setAttribute('aria-label', label.textContent.trim());
}
}
disconnect() {
clearTimeout(this.#autosaveTimer);
this.element.removeEventListener('keydown', this._boundHandleTab);
this.element.removeEventListener('keydown', this._boundHandleEnter);
this.element.removeEventListener('autocomplete:connect', this._boundNameAddressFields);
window.removeEventListener('plmail:integration-attached', this._boundIntegrationAttached);
const form = this.element.querySelector('form');
form.removeEventListener('input', this._boundAutosave);
@@ -322,6 +370,124 @@ export default class extends Controller {
}
}
/**
* Enter must never be a send.
*
* This is the accidental-send bug, and the mechanism is plain implicit form
* submission. A single-line <input> inside a <form> submits that form on
* Enter unless something calls preventDefault, and this form's action is
* the send URL — so any Enter the address field did not consume sent the
* message, with whatever was (or was not) typed.
*
* Tom Select consumes the key only when it has a use for it: an active
* suggestion to commit, or a createItem() that succeeds. Type something
* with no `@` — it matches no contact, and createFilter refuses to make a
* chip of it — and neither applies, so the keystroke fell through to the
* browser and the mail went out to nobody with no subject and no body.
* That is why every existing test missed it: they all type real addresses,
* which Tom Select always swallows.
*
* So: Enter is claimed here unconditionally (bar the explicit Ctrl/Cmd
* shortcut), and *then* asked what it should have meant. In an address
* field that is "commit a chip", and a value that cannot become one is
* reported rather than silently dropped.
*
* Runs on the container so Tom Select's own handler goes first;
* defaultPrevented is how we know it already dealt with the key.
*/
_handleEnter(event) {
if ('Enter' !== event.key || true === event.isComposing) {
return;
}
// The one deliberate keyboard send, Gmail's shortcut. Explicit, so it
// is not the footgun the bare key was.
if (true === event.ctrlKey || true === event.metaKey) {
event.preventDefault();
this._requestSend();
return;
}
// Tom Select committed a chip, or opened its menu — either way it has
// already stopped this reaching the form.
if (true === event.defaultPrevented) {
return;
}
const target = event.target;
// The body is a contenteditable, where Enter is a newline and there is
// no implicit submission to guard against.
if (true === target?.isContentEditable) {
return;
}
if (false === (target instanceof HTMLInputElement)) {
return;
}
// Nothing below this line may fall through to the browser.
event.preventDefault();
// An address row, specifically — not merely a Tom Select. The typeface
// and size pickers are Tom Selects too now, and a filter string typed
// into one of those is not an address anybody failed to enter.
const row = this._addressRows().find((candidate) => candidate.contains(target));
if (undefined === row) {
// Subject, and anything else single-line: Enter moves on rather
// than sending. Gmail does the same.
this._focusBody();
return;
}
this._commitTypedAddress(target.closest('.ts-wrapper'), target);
}
/**
* Turn what is typed in an address field into a chip, or say why not.
*
* Tom Select is asked to do it rather than being worked around: createItem
* applies the same createFilter the field is configured with, so the answer
* here and the answer to a blur or a Tab are the same answer.
*/
_commitTypedAddress(wrapper, input) {
const typed = input.value.trim();
if ('' === typed || null === wrapper) {
return;
}
const select = this._tomSelectFor(wrapper);
if (false === this.constructor.ADDRESS.test(typed)) {
this._reportError(this._t('invalidAddress', '"%s" is not a valid email address').replace('%s', typed));
return;
}
this._clearError();
if (null === select) {
return;
}
select.createItem(typed, false);
select.setTextboxValue('');
select.focus();
}
/** The Tom Select instance behind a rendered `.ts-wrapper`. */
_tomSelectFor(wrapper) {
// The bundle hangs the instance off the original <select>, which Tom
// Select leaves in the DOM (hidden) next to the wrapper it built.
const select = wrapper.parentElement?.querySelector('select');
return select?.tomselect ?? null;
}
/** The To/Cc/Bcc rows, in tab order. */
_addressRows() {
return [
@@ -395,7 +561,7 @@ export default class extends Controller {
toggle.type = 'button';
toggle.contentEditable = 'false';
toggle.dataset.quoteToggle = '1';
toggle.textContent = '··· (show quoted text)';
toggle.textContent = `··· (${this._t('quoteShow', 'show quoted text')})`;
toggle.style.cssText = [
'display: inline-block',
'margin-bottom: 0.4em',
@@ -421,8 +587,8 @@ export default class extends Controller {
const isHidden = node.style.display === 'none';
node.style.display = isHidden ? '' : 'none';
toggle.textContent = isHidden
? '··· (hide quoted text)'
: '··· (show quoted text)';
? `··· (${this._t('quoteHide', 'hide quoted text')})`
: `··· (${this._t('quoteShow', 'show quoted text')})`;
});
node.parentNode.insertBefore(wrapper, node);
@@ -847,7 +1013,7 @@ export default class extends Controller {
const url = this.hasDraftUrlValue ? this.draftUrlValue : form.action;
const status = this.hasSaveStatusTarget ? this.saveStatusTarget : null;
this._setStatus('Saving…', 'text-ink-faint');
this._setStatus(this._t('saving', 'Saving…'), 'text-ink-faint');
try {
const response = await fetch(url, {
@@ -875,13 +1041,13 @@ export default class extends Controller {
oldForm.action = this.sendUrlValue;
}
this._setStatus('Draft saved', 'text-success');
this._setStatus(this._t('saved', 'Draft saved'), 'text-success');
}
} else {
throw new Error('Server error');
}
} catch (_) {
this._setStatus('Failed to save', 'text-danger');
this._setStatus(this._t('saveFailed', 'Could not save the draft'), 'text-danger');
}
}
@@ -943,7 +1109,101 @@ export default class extends Controller {
status.classList.add('text-ink-faint');
status.textContent = missing <= 0
? ''
: `${missing} more character${1 === missing ? '' : 's'} to save`;
: this._pendingText(missing);
}
/**
* The countdown line, in the right plural form.
*
* Two keys rather than one plural string: the catalogue's pluralisation is
* Symfony's, evaluated server-side, and there is no count to evaluate it
* against until the user is typing.
*/
_pendingText(missing) {
return 1 === missing
? this._t('pendingOne', 'one more character to save')
: this._t('pendingMany', '%d more characters to save').replace('%d', String(missing));
}
/** A translated string, falling back to English if the value is absent. */
_t(key, fallback) {
return this.i18nValue?.[key] ?? fallback;
}
/** Show a refusal next to the address rows. */
_reportError(text) {
if (false === this.hasErrorsTarget) {
this._setStatus(text, 'text-danger');
return;
}
this.errorsTarget.classList.remove('hidden');
this.errorsTarget.querySelector('p').textContent = text;
}
_clearError() {
if (true === this.hasErrorsTarget) {
this.errorsTarget.classList.add('hidden');
this.errorsTarget.querySelector('p').textContent = '';
}
}
/** Move the caret into the body — where Enter on a header row leads. */
_focusBody() {
if (true === this.hasBodyTarget) {
this.bodyTarget.focus();
}
}
/**
* The committed recipients across To, Cc and Bcc.
*
* Counted per address row rather than by sweeping the window for
* `.ts-control .item`: the typeface and size pickers are Tom Selects too
* now, and each renders its *selected option* as an `.item`. Swept, an
* empty To read as two recipients and the send guard waved it through.
*/
_recipientCount() {
return this._addressRows()
.reduce((total, row) => total + row.querySelectorAll('.ts-control .item').length, 0);
}
/** Ask the form to submit, going through every guard in _handleSubmit. */
_requestSend() {
this.element.querySelector('form')?.requestSubmit();
}
/**
* The last gate before a message leaves.
*
* Everything here is a question the user would rather be asked than have
* answered for them — and the first one is not a question at all, because
* a send with no recipient cannot be what anyone meant. The server refuses
* the same three things (ComposeType's `send` group, and
* ComposeController::send); this is only the half that can still be
* friendly about it.
*/
_guardSend() {
if (0 === this._recipientCount()) {
this._reportError(this._t('recipientRequired', 'Add at least one recipient before sending.'));
return false;
}
this._clearError();
const subject = this.hasSubjectTarget ? this.subjectTarget.value.trim() : '';
if ('' === subject && false === window.confirm(this._t('confirmNoSubject', 'This message has no subject. Send it anyway?'))) {
return false;
}
if (0 === this._typedLength() && false === window.confirm(this._t('confirmNoBody', 'This message has no text. Send it anyway?'))) {
return false;
}
return true;
}
_handleSubmit(event) {
@@ -951,6 +1211,13 @@ export default class extends Controller {
event.preventDefault();
return;
}
if (false === this._guardSend()) {
event.preventDefault();
return;
}
clearTimeout(this.#autosaveTimer);
this._submitting = true;
@@ -965,7 +1232,7 @@ export default class extends Controller {
// The icon button has no text to replace — keep its markup.
if ('' !== button.textContent.trim()) {
button.dataset.sendLabel ??= button.textContent;
button.textContent = 'Sending…';
button.textContent = this._t('sending', 'Sending…');
}
});
@@ -1011,7 +1278,7 @@ export default class extends Controller {
const id = this._messageId();
if (null === id) {
this._setStatus('Could not attach', 'text-danger');
this._setStatus(this._t('attachFailed', 'Could not attach'), 'text-danger');
return;
}
@@ -1050,7 +1317,7 @@ export default class extends Controller {
this._insertLinks(links);
}
this._setStatus('Draft saved', 'text-success');
this._setStatus(this._t('saved', 'Draft saved'), 'text-success');
// The links went into the body directly, so the form has not fired an
// input event — without this the draft would keep the pre-link body
@@ -1104,7 +1371,12 @@ export default class extends Controller {
if (undefined !== tooBig) {
const limit = Math.round(this.maxAttachmentBytesValue / (1024 * 1024));
this._setStatus(`${tooBig.name} is over ${limit} MB`, 'text-danger');
this._setStatus(
this._t('tooLarge', '%s is over %d MB')
.replace('%s', tooBig.name)
.replace('%d', String(limit)),
'text-danger',
);
return;
}
@@ -1114,7 +1386,7 @@ export default class extends Controller {
const id = this._messageId();
if (null === id) {
this._setStatus('Could not attach', 'text-danger');
this._setStatus(this._t('attachFailed', 'Could not attach'), 'text-danger');
return;
}
@@ -1122,7 +1394,7 @@ export default class extends Controller {
const body = new FormData();
files.forEach((file) => body.append('files[]', file));
this._setStatus('Uploading…', 'text-ink-faint');
this._setStatus(this._t('uploading', 'Uploading…'), 'text-ink-faint');
await this._renderAttachments(
fetch(`/compose/attachments/${id}`, {
@@ -1130,7 +1402,7 @@ export default class extends Controller {
body,
headers: { 'X-Requested-With': 'XMLHttpRequest' },
}),
'Could not attach',
this._t('attachFailed', 'Could not attach'),
);
}
@@ -1146,7 +1418,7 @@ export default class extends Controller {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
}),
'Could not remove attachment',
this._t('attachRemoveFailed', 'Could not remove attachment'),
);
}
@@ -1172,7 +1444,7 @@ export default class extends Controller {
this.attachmentsTarget.innerHTML = await response.text();
}
this._setStatus('Draft saved', 'text-success');
this._setStatus(this._t('saved', 'Draft saved'), 'text-success');
}
/**
@@ -1209,7 +1481,7 @@ export default class extends Controller {
_updateTitle(value) {
if (this.hasTitleTarget) {
this.titleTarget.textContent = value.trim() || 'New Message';
this.titleTarget.textContent = value.trim() || this._t('newMessage', 'New Message');
}
}
@@ -150,6 +150,49 @@ export default class extends Controller {
select.dropdown.style.fontSize = fontSize
if (this.bareValue) select.wrapper.classList.add("ts-bare")
this.#carryAccessibleName(select)
}
/**
* Carry the native select's accessible name onto the widget that replaces
* it.
*
* Tom Select builds its own text input and leaves the <select> hidden
* behind it. A name declared on the select — `aria-label`, or a <label>
* bound by id — therefore names an element the user can no longer reach,
* and the control they *can* reach reports as an unnamed combobox. The
* compose toolbar's typeface and size pickers were two of those.
*
* Only ever fills a gap: a widget Tom Select already named (it does wire
* `aria-labelledby` up from some label arrangements) is left alone.
*/
#carryAccessibleName(select) {
const input = select.control_input
if (!input || input.hasAttribute("aria-label") || input.hasAttribute("aria-labelledby")) return
const label = this.element.getAttribute("aria-label")
if (label) {
input.setAttribute("aria-label", label)
return
}
const labelledBy = this.element.getAttribute("aria-labelledby")
if (labelledBy) {
input.setAttribute("aria-labelledby", labelledBy)
return
}
const bound = this.element.id
? document.querySelector(`label[for="${CSS.escape(this.element.id)}"]`)
: null
if (bound?.textContent.trim()) input.setAttribute("aria-label", bound.textContent.trim())
}
#optionCount() {
+108 -13
View File
@@ -3,6 +3,7 @@
namespace App\Controller\Mail;
use App\Domain\Enum\Integration\Capability;
use App\Domain\Helper\AddressHelper;
use App\Entity\Mail\Account;
use App\Entity\Mail\Message;
use App\Entity\Mail\MessagePart;
@@ -21,6 +22,7 @@ use App\Service\Mail\ReplyDraftBuilder;
use App\Service\Mail\SenderResolver;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\Request;
@@ -28,6 +30,7 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Messenger\Stamp\DelayStamp;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* Label-based compose: the From selector is an Account (unmapped form field),
@@ -73,6 +76,7 @@ class ComposeController extends AbstractController
private readonly SenderResolver $senders,
private readonly MessageEraser $eraser,
private readonly LabelChangePropagator $labelChanges,
private readonly TranslatorInterface $translator,
)
{
}
@@ -224,6 +228,29 @@ class ComposeController extends AbstractController
return $this->sendResponse($message, $ctx);
}
// Belt and braces behind the Count constraint in ComposeType.
//
// That rule proves the *selection* is not empty; this proves the
// addresses about to go on the wire are addresses. They reach here
// from Contact rows, which are only ever created from a validated
// address — but a row can also arrive from a sync, an import or a
// restore, and none of those are this form. Refusing here means
// there is no path through the controller that dispatches a send
// to something that is not an address.
$unsendable = $this->firstUnsendableAddress($message);
if (null !== $unsendable) {
$form->get('toAddresses')->addError(new FormError(
$this->translator->trans(
'compose.recipient_invalid',
['{{ address }}' => $unsendable],
'validators',
),
));
return $this->renderWindow($form, $message, $ctx);
}
$token = $form->get('account')->getData();
$account = $this->senders->accountFor($token, $this->getUser())
?? $message->account
@@ -278,9 +305,23 @@ class ComposeController extends AbstractController
]);
}
return $this->render('compose/_undo_toast.html.twig', [
'form' => $form,
// Every variable the window needs, and as a turbo-stream.
//
// This used to be a bare render() of form+message, which meant the
// template reached _window.html.twig without `ctx` or
// `pickerIntegrations` — and the window reads both. Under
// strict_variables that is a 500; without it the integration picker
// silently vanished and the frame/url params defaulted to the dock's.
//
// Either way the undo POST never returned a window, so the toast
// faded and the draft was filed with no way back to it — while the
// cancel above had already gone through. Cancelling a send has to
// *reopen* what was being written, which is the whole point of it.
return $this->turboStream('compose/_undo_toast.html.twig', [
'form' => $form,
'message' => $message,
'ctx' => $ctx,
'pickerIntegrations' => $this->pickerIntegrations(),
]);
}
@@ -435,6 +476,35 @@ class ComposeController extends AbstractController
// ── Private helpers ───────────────────────────────────────────────────────
/**
* The first recipient on the message that is not a usable address, or null
* when every one of them is.
*
* Cc and Bcc are checked as well as To: a malformed address anywhere in the
* envelope fails the send at the SMTP layer, and failing it here says which
* one rather than leaving a bounce to explain it.
*/
private function firstUnsendableAddress(Message $message): ?string
{
$groups = [
$message->toAddresses ?? [],
$message->ccAddresses ?? [],
$message->bccAddresses ?? [],
];
foreach ($groups as $addresses) {
foreach ($addresses as $entry) {
$address = (string) ($entry['address'] ?? '');
if (false === AddressHelper::isValidEmail($address)) {
return '' === $address ? '—' : $address;
}
}
}
return null;
}
/**
* The thread whose list row the discard should take with it, or null to
* leave every row standing.
@@ -597,16 +667,28 @@ class ComposeController extends AbstractController
'form' => $form,
'message' => $message,
'ctx' => $ctx,
// Services this user can pull files out of. Download rather than
// Browse is the test that matters: a service you can list but not
// fetch from would open a picker that cannot attach anything.
'pickerIntegrations' => $this->integrationRepository->findSupportingForUser(
$this->getUser(),
Capability::Download,
),
'pickerIntegrations' => $this->pickerIntegrations(),
]);
}
/**
* Services this user can pull files out of. Download rather than Browse is
* the test that matters: a service you can list but not fetch from would
* open a picker that cannot attach anything.
*
* Its own method because the window is rendered from two places — here and
* the undo toast — and the one that forgot it was a 500.
*
* @return list<\App\Entity\Integration\Integration>
*/
private function pickerIntegrations(): array
{
return $this->integrationRepository->findSupportingForUser(
$this->getUser(),
Capability::Download,
);
}
private function turboStream(string $template, array $params): Response
{
return $this->render($template, $params, new Response(
@@ -630,10 +712,23 @@ class ComposeController extends AbstractController
return $account;
}
return $this->accountRepository->findOneBy([
'usr' => $this->getUser(),
'isActive' => true,
]);
// Ordered, because this is the answer to "which account is primary" on
// every install where nothing carries the flag — an account created by
// anything other than AccountCreator::create() (a seed, an import, a
// restore) never gets it. Unordered, findOneBy returned whichever row
// the database felt like, so the From default could differ between two
// loads of the same window.
//
// sortOrder is the right tiebreak rather than an arbitrary one: it IS
// the user's own arrangement, and isPrimary is derived from position 0
// of exactly this ordering (AccountCreator::resequence()).
return $this->accountRepository->findOneBy(
[
'usr' => $this->getUser(),
'isActive' => true,
],
['sortOrder' => 'ASC'],
);
}
/**
@@ -265,7 +265,9 @@ final class AccountController extends AbstractController
return $this->render('account/_mutation.stream.html.twig', [
'toastMessage' => $toastMessage,
'manageableAccounts' => $this->accountRepository->findForUserOrderedByName($this->getUser()),
// Same ordering as the page this replaces and as reorder()'s own
// stream — three renders of one list that used to disagree.
'manageableAccounts' => $this->accountRepository->findForUserOrdered($this->getUser()),
]);
}
}
+10 -1
View File
@@ -71,7 +71,16 @@ final class SettingsController extends AbstractController
$section = 'accounts';
}
$manageableAccounts = $this->accountRepository->findForUserOrderedByName($this->getUser());
// The user's own arrangement, not the alphabet.
//
// This list is drag-reorderable, and AccountController::reorder()
// re-renders it with findForUserOrdered() — so dragging a row produced
// a list in sortOrder that snapped back to alphabetical on the next
// load. The order is also what "primary" means (position 0, see
// AccountCreator::resequence()), which made the first row and the
// primary account two different rows on any install where the two
// orderings disagree.
$manageableAccounts = $this->accountRepository->findForUserOrdered($this->getUser());
return $this->render('settings/index.html.twig', [
'section' => $section,
+20 -1
View File
@@ -15,6 +15,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Validator\Constraints\Count;
class ComposeType extends AbstractType
{
@@ -44,8 +45,26 @@ class ComposeType extends AbstractType
'attr' => ['class' => 'compose-from-select'],
])
// The `send` group is only applied by ComposeController::send(), so
// a draft may be saved with no recipient at all — which is the
// point of a draft — while an actual send may not.
//
// This is the server's half of the accidental-send guard, and it
// has to be the server's: the window can be bypassed, and an
// address that never resolved to a Contact arrives here as an
// empty selection rather than as a bad string (see
// ContactAutocompleteField, which drops what it cannot validate).
// "No valid recipient" and "no recipient" are therefore the same
// failure by the time the form is bound, and one rule covers both.
->add('toAddresses', ContactAutocompleteField::class, [
'mapped' => false,
'mapped' => false,
'constraints' => [
new Count(
min: 1,
minMessage: 'compose.recipient_required',
groups: ['send'],
),
],
])
->add('ccAddresses', ContactAutocompleteField::class, [
+1 -1
View File
@@ -2,7 +2,7 @@
<template>
{% include '_partials/_toast.html.twig' with {
type: 'success',
message: 'Sending…',
message: 'compose.sending'|trans,
extra: {
undo_url: path('app_compose_mail_undo', { id: message.id }),
hide_after: 8000
+32 -3
View File
@@ -1,11 +1,40 @@
{#
compose/_undo_toast.html.twig
─────────────────────────────────────────────────────────────────────────────
Cancelling a send from the dock: say so, and put the window back exactly as it
was — recipients, subject, body and attachments included.
`replace` rather than `append`, and `only` rather than an inherited context:
• The window's own root element IS <turbo-frame id="compose_dock">, so
appending it *into* that frame nested a frame inside itself and left the
previous (emptied) one wrapping it. Replacing swaps the frame for the new
one, which is what _inline_undo.stream.html.twig does for its frame.
• Without `only`, the include inherited whatever the caller happened to have
and silently did without the rest. `pickerIntegrations` was the one it did
without, which under strict_variables made the whole undo a 500 — and the
cancel had already been flushed by then, so the send was called off while
the window it belonged to never came back.
Variables: form, message, ctx, pickerIntegrations
#}
<turbo-stream action="append" target="toast-region">
<template>
{% include '_partials/_toast.html.twig' with { type: 'info', message: 'Send cancelled.' } %}
{% include '_partials/_toast.html.twig' with {
type: 'info',
message: 'toast.send_cancelled'|trans,
} only %}
</template>
</turbo-stream>
<turbo-stream action="append" target="compose_dock">
<turbo-stream action="replace" target="{{ ctx.frame }}">
<template>
{% include 'compose/_window.html.twig' with { form: form, message: message } %}
{% include 'compose/_window.html.twig' with {
form: form,
message: message,
ctx: ctx,
pickerIntegrations: pickerIntegrations,
} only %}
</template>
</turbo-stream>
+162 -64
View File
@@ -31,6 +31,29 @@ Variables: form, message, ctx (inline, frame, thread, urlParams)
<turbo-frame id="{{ frame }}">
<div data-controller="compose--compose compose--compose-toolbar"
{# Every word the controller puts on screen, translated here rather
than spelled in JavaScript — same arrangement as ui--select's
i18n value. A string baked into the controller is a string that
stays English in a German window. #}
data-compose--compose-i18n-value="{{ {
saving: 'compose.status.saving'|trans,
saved: 'compose.status.saved'|trans,
saveFailed: 'compose.status.save_failed'|trans,
uploading: 'compose.status.uploading'|trans,
sending: 'compose.sending'|trans,
newMessage: 'compose.new_message'|trans,
attachFailed: 'compose.attach_failed'|trans,
attachRemoveFailed: 'compose.attach_remove_failed'|trans,
pendingOne: 'compose.status.pending_one'|trans,
pendingMany: 'compose.status.pending_many'|trans({ '%count%': '%d' }),
quoteShow: 'compose.quote.show'|trans,
quoteHide: 'compose.quote.hide'|trans,
invalidAddress: 'compose.invalid_address'|trans({ '%address%': '%s' }),
recipientRequired: 'compose.recipient_required'|trans,
confirmNoSubject: 'compose.confirm.no_subject'|trans,
confirmNoBody: 'compose.confirm.no_body'|trans,
tooLarge: 'compose.attach_too_large'|trans({ '%name%': '%s', '%limit%': '%d' }),
}|json_encode|e('html_attr') }}"
data-compose--compose-inline-value="{{ inline ? 'true' : 'false' }}"
data-compose--compose-min-chars-value="{{ min_chars|default(5) }}"
data-compose--compose-max-attachment-bytes-value="{{ constant('App\\Controller\\Mail\\ComposeController::MAX_ATTACHMENT_BYTES') }}"
@@ -73,6 +96,7 @@ Variables: form, message, ctx (inline, frame, thread, urlParams)
is the way out — same gesture as the Gmail app, and the same for
a reply, which is fullscreen below md too. #}
<button type="button" title="{{ 'compose.save_and_close'|trans }}"
aria-label="{{ 'compose.save_and_close'|trans }}"
data-action="compose--compose#saveDraft compose--compose#close"
class="md:hidden {{ hc }}">
<i class="fa-solid fa-arrow-left text-sm"></i>
@@ -101,7 +125,7 @@ Variables: form, message, ctx (inline, frame, thread, urlParams)
{% else %}
<span class="text-sm font-semibold text-ink truncate min-w-0 flex-1 md:flex-none md:max-w-[260px] cursor-pointer"
data-compose--compose-target="title"
data-action="click->compose--compose#toggleMinimize">New Message</span>
data-action="click->compose--compose#toggleMinimize">{{ 'compose.new_message'|trans }}</span>
{% endif %}
<div class="flex items-center gap-1 shrink-0">
<span data-compose--compose-target="saveStatus"
@@ -110,33 +134,37 @@ Variables: form, message, ctx (inline, frame, thread, urlParams)
{# Send is the one action that must never be scrolled away, so
below md it lives up here rather than in the bottom bar,
which is a horizontal scroller. #}
<button type="submit" title="Send"
<button type="submit" title="{{ 'compose.send'|trans }}"
aria-label="{{ 'compose.send'|trans }}"
data-compose--compose-target="sendBtn"
class="md:hidden {{ hc }} text-accent hover:text-accent">
<i class="fa-solid fa-paper-plane text-sm"></i>
<i class="fa-solid fa-paper-plane text-sm" aria-hidden="true"></i>
</button>
{% if inline %}
{# Popping out lands in the dock, which below md is the
same fullscreen window we are already in. #}
<button type="button" title="{{ 'compose.pop_out'|trans }}"
aria-label="{{ 'compose.pop_out'|trans }}"
data-action="click->compose--compose#popOut"
class="hidden md:flex {{ hc }}">
<i class="fa-solid fa-up-right-from-square text-xs"></i>
<i class="fa-solid fa-up-right-from-square text-xs" aria-hidden="true"></i>
</button>
{% else %}
{# Minimize and expand are dock concepts: below md the
window already is the screen. #}
<button type="button" title="Minimize"
<button type="button" title="{{ 'compose.minimize'|trans }}"
aria-label="{{ 'compose.minimize'|trans }}"
data-action="click->compose--compose#toggleMinimize"
class="hidden md:flex {{ hc }}">
<i data-compose--compose-target="minimizeIcon" class="fa-solid fa-minus text-xs"></i>
<i data-compose--compose-target="minimizeIcon" class="fa-solid fa-minus text-xs" aria-hidden="true"></i>
</button>
<button type="button" title="Expand"
<button type="button" title="{{ 'compose.expand'|trans }}"
aria-label="{{ 'compose.expand'|trans }}"
data-action="click->compose--compose#toggleExpand"
class="hidden md:flex {{ hc }}">
<i data-compose--compose-target="expandIcon"
class="fa-solid fa-up-right-and-down-left-from-center text-[10px]"></i>
class="fa-solid fa-up-right-and-down-left-from-center text-[10px]" aria-hidden="true"></i>
</button>
{% endif %}
@@ -191,13 +219,16 @@ Variables: form, message, ctx (inline, frame, thread, urlParams)
<div class="relative shrink-0">
<div class="flex items-center gap-3 px-4 pt-3 pb-1"
data-compose--compose-target="fromRow">
<span class="text-xs font-medium text-ink-faint w-10 shrink-0 text-right">From</span>
<span id="{{ form.vars.id }}_from_label"
class="text-xs font-medium text-ink-faint w-10 shrink-0 text-right">{{ 'compose.from'|trans }}</span>
<button type="button"
data-action="click->compose--compose#toggleFromDropdown"
data-compose--compose-target="fromBtn"
aria-haspopup="listbox"
aria-labelledby="{{ form.vars.id }}_from_label {{ form.vars.id }}_from_value"
class="flex-1 flex items-center justify-between gap-2 text-sm
text-ink-soft text-left">
<span data-compose--compose-target="fromLabel" class="truncate">
<span data-compose--compose-target="fromLabel" id="{{ form.vars.id }}_from_value" class="truncate">
{% for choice in form.account.vars.choices %}
{% if form.account.vars.value == choice.value %}{{ choice.label }}{% endif %}
{% endfor %}
@@ -233,9 +264,18 @@ Variables: form, message, ctx (inline, frame, thread, urlParams)
</div>
{# ── To ───────────────────────────────────────────────── #}
{# A real <label for>, not a <span>: it is what gives the recipient
combobox an accessible name. Tom Select hides the <select> and
builds its own text input, and copies the name across only when
there is a label bound to the select's id — so the label is
load-bearing rather than decoration. The controller mirrors it
onto the generated input as well, for the same reason a belt
goes with braces. #}
<div data-compose--compose-target="toField"
class="flex items-center gap-3 px-4 py-1.5 shrink-0 min-h-[36px]">
<span class="text-xs font-medium text-ink-faint w-10 shrink-0 text-right">To</span>
<label for="{{ form.toAddresses.vars.id }}"
id="{{ form.toAddresses.vars.id }}_label"
class="text-xs font-medium text-ink-faint w-10 shrink-0 text-right">{{ 'compose.to'|trans }}</label>
<div class="flex-1 min-w-0">
{{ form_widget(form.toAddresses) }}
</div>
@@ -243,22 +283,43 @@ Variables: form, message, ctx (inline, frame, thread, urlParams)
<button type="button"
data-compose--compose-target="ccBtn"
data-action="click->compose--compose#showCc"
{# title, not aria-label: the button already has an
accessible name — the word "Cc" on its face — and
an aria-label would replace it rather than add to
it, leaving the visible label and the spoken one
saying different things (WCAG 2.5.3). #}
title="{{ 'compose.add_cc'|trans }}"
class="text-xs text-ink-faint hover:text-accent transition-colors">
Cc
{{ 'compose.cc'|trans }}
</button>
<button type="button"
data-compose--compose-target="bccBtn"
data-action="click->compose--compose#showBcc"
title="{{ 'compose.add_bcc'|trans }}"
class="text-xs text-ink-faint hover:text-accent transition-colors">
Bcc
{{ 'compose.bcc'|trans }}
</button>
</div>
</div>
{# Why a send was refused. Rendered from the form rather than from a
toast so it sits with the field it is about, and so a refusal
that came from the server reads the same as one the window
caught itself. #}
<div data-compose--compose-target="errors"
class="px-4 {{ form.toAddresses.vars.errors|length > 0 ? 'py-1' : 'hidden' }}"
role="alert" aria-live="polite">
<p class="text-xs text-danger">
{%- for error in form.toAddresses.vars.errors %}{{ error.message }}{% endfor -%}
</p>
</div>
{# ── Cc ───────────────────────────────────────────────── #}
<div data-compose--compose-target="ccField"
class="flex hidden items-center gap-3 px-4 py-1.5 shrink-0 min-h-[36px]">
<span class="text-xs font-medium text-ink-faint w-10 shrink-0 text-right">Cc</span>
<label for="{{ form.ccAddresses.vars.id }}"
id="{{ form.ccAddresses.vars.id }}_label"
class="text-xs font-medium text-ink-faint w-10 shrink-0 text-right">{{ 'compose.cc'|trans }}</label>
<div class="flex-1 min-w-0">
{{ form_widget(form.ccAddresses) }}
</div>
@@ -267,7 +328,9 @@ Variables: form, message, ctx (inline, frame, thread, urlParams)
{# ── Bcc ──────────────────────────────────────────────── #}
<div data-compose--compose-target="bccField"
class="flex hidden items-center gap-3 px-4 py-1.5 shrink-0 min-h-[36px]">
<span class="text-xs font-medium text-ink-faint w-10 shrink-0 text-right">Bcc</span>
<label for="{{ form.bccAddresses.vars.id }}"
id="{{ form.bccAddresses.vars.id }}_label"
class="text-xs font-medium text-ink-faint w-10 shrink-0 text-right">{{ 'compose.bcc'|trans }}</label>
<div class="flex-1 min-w-0">
{{ form_widget(form.bccAddresses) }}
</div>
@@ -278,13 +341,14 @@ Variables: form, message, ctx (inline, frame, thread, urlParams)
{# ── Subject ──────────────────────────────────────────── #}
<div class="flex items-center gap-3 px-4 py-2 shrink-0">
<span class="text-xs font-medium text-ink-faint w-10 shrink-0 text-right">Subj</span>
<label for="{{ form.subject.vars.id }}"
class="text-xs font-medium text-ink-faint w-10 shrink-0 text-right">{{ 'compose.subject_label'|trans }}</label>
<input type="text"
id="{{ form.subject.vars.id }}"
name="{{ form.subject.vars.full_name }}"
data-compose--compose-target="subject"
value="{{ form.subject.vars.value ?: '' }}"
placeholder="Subject"
placeholder="{{ 'compose.subject_placeholder'|trans }}"
class="flex-1 bg-transparent outline-none text-sm
text-ink
placeholder:text-ink-faint"/>
@@ -298,7 +362,10 @@ Variables: form, message, ctx (inline, frame, thread, urlParams)
data-compose--compose-toolbar-target="editor"
data-compose--compose-target="body"
contenteditable="true"
data-placeholder="Write your message…"
role="textbox"
aria-multiline="true"
aria-label="{{ 'compose.body_placeholder'|trans }}"
data-placeholder="{{ 'compose.body_placeholder'|trans }}"
class="w-full min-h-[170px] bg-transparent outline-none
text-sm leading-relaxed
text-ink
@@ -367,59 +434,66 @@ Variables: form, message, ctx (inline, frame, thread, urlParams)
<button type="button" data-compose--compose-toolbar-action data-compose--compose-toolbar-target="boldBtn"
data-action="click->compose--compose-toolbar#bold"
title="{{ 'compose.bold'|trans }}" aria-label="{{ 'compose.bold'|trans }}"
class="{{ tb }} font-bold text-ink-soft">B</button>
<button type="button" data-compose--compose-toolbar-action data-compose--compose-toolbar-target="italicBtn"
data-action="click->compose--compose-toolbar#italic"
title="{{ 'compose.italic'|trans }}" aria-label="{{ 'compose.italic'|trans }}"
class="{{ tb }} italic text-ink-soft">I</button>
<button type="button" data-compose--compose-toolbar-action data-compose--compose-toolbar-target="underlineBtn"
data-action="click->compose--compose-toolbar#underline"
title="{{ 'compose.underline'|trans }}" aria-label="{{ 'compose.underline'|trans }}"
class="{{ tb }} underline text-ink-soft">U</button>
{# The colour input covers the whole control instead of
being a 0×0 box parked beside it. Sized to nothing, with
pointer-events off and tabindex="-1", it was reachable by
neither pointer nor keyboard and never entered the
accessibility tree at all — the "A" was a picture of a
button. Stretched over the label it keeps exactly the
same look, and is now the real, focusable control it
always appeared to be. #}
<label class="shrink-0 px-1.5 py-0.5 rounded text-sm flex flex-col items-center leading-none gap-0.5
transition-colors text-ink-soft
hover:bg-hover cursor-pointer relative">
<span class="font-semibold">A</span>
hover:bg-hover cursor-pointer relative
focus-within:ring-2 focus-within:ring-accent">
<span class="font-semibold" aria-hidden="true">A</span>
<span data-compose--compose-toolbar-target="colorSwatch"
class="block w-3.5 h-[3px] bg-accent rounded-sm"></span>
class="block w-3.5 h-[3px] bg-accent rounded-sm" aria-hidden="true"></span>
<input type="color" data-action="input->compose--compose-toolbar#changeColor"
class="absolute opacity-0 w-0 h-0 pointer-events-none" tabindex="-1">
title="{{ 'compose.text_color'|trans }}"
aria-label="{{ 'compose.text_color'|trans }}"
class="absolute inset-0 w-full h-full opacity-0 cursor-pointer">
</label>
<div class="w-px h-4 bg-line mx-1 shrink-0"></div>
<button type="button" data-compose--compose-toolbar-action data-action="click->compose--compose-toolbar#alignLeft"
class="{{ tb }}" title="Align left">
<i class="fa-solid fa-align-left"></i></button>
<button type="button" data-compose--compose-toolbar-action data-action="click->compose--compose-toolbar#alignCenter"
class="{{ tb }}" title="Align center">
<i class="fa-solid fa-align-center"></i></button>
<button type="button" data-compose--compose-toolbar-action data-action="click->compose--compose-toolbar#alignRight"
class="{{ tb }}" title="Align right">
<i class="fa-solid fa-align-right"></i></button>
{% set toolbarButtons = [
{ action: 'alignLeft', icon: 'fa-align-left', label: 'compose.align.left' },
{ action: 'alignCenter', icon: 'fa-align-center', label: 'compose.align.center' },
{ action: 'alignRight', icon: 'fa-align-right', label: 'compose.align.right' },
{ divider: true },
{ action: 'orderedList', icon: 'fa-list-ol', label: 'compose.list.ordered' },
{ action: 'unorderedList', icon: 'fa-list-ul', label: 'compose.list.unordered' },
{ action: 'outdent', icon: 'fa-outdent', label: 'compose.outdent' },
{ action: 'indent', icon: 'fa-indent', label: 'compose.indent' },
{ divider: true },
{ action: 'undo', icon: 'fa-rotate-left', label: 'compose.undo' },
{ action: 'redo', icon: 'fa-rotate-right', label: 'compose.redo' },
] %}
<div class="w-px h-4 bg-line mx-1 shrink-0"></div>
<button type="button" data-compose--compose-toolbar-action data-action="click->compose--compose-toolbar#orderedList"
class="{{ tb }}" title="Numbered list">
<i class="fa-solid fa-list-ol"></i></button>
<button type="button" data-compose--compose-toolbar-action data-action="click->compose--compose-toolbar#unorderedList"
class="{{ tb }}" title="Bullet list">
<i class="fa-solid fa-list-ul"></i></button>
<button type="button" data-compose--compose-toolbar-action data-action="click->compose--compose-toolbar#outdent"
class="{{ tb }}" title="Outdent">
<i class="fa-solid fa-outdent"></i></button>
<button type="button" data-compose--compose-toolbar-action data-action="click->compose--compose-toolbar#indent"
class="{{ tb }}" title="Indent">
<i class="fa-solid fa-indent"></i></button>
<div class="w-px h-4 bg-line mx-1 shrink-0"></div>
<button type="button" data-compose--compose-toolbar-action data-action="click->compose--compose-toolbar#undo"
class="{{ tb }}" title="Undo">
<i class="fa-solid fa-rotate-left"></i></button>
<button type="button" data-compose--compose-toolbar-action data-action="click->compose--compose-toolbar#redo"
class="{{ tb }}" title="Redo">
<i class="fa-solid fa-rotate-right"></i></button>
{% for item in toolbarButtons %}
{% if item.divider|default(false) %}
<div class="w-px h-4 bg-line mx-1 shrink-0"></div>
{% else %}
<button type="button" data-compose--compose-toolbar-action
data-action="click->compose--compose-toolbar#{{ item.action }}"
class="{{ tb }}"
title="{{ item.label|trans }}"
aria-label="{{ item.label|trans }}">
<i class="fa-solid {{ item.icon }}" aria-hidden="true"></i></button>
{% endif %}
{% endfor %}
</div>
{# Action bar #}
@@ -430,11 +504,13 @@ Variables: form, message, ctx (inline, frame, thread, urlParams)
<button type="submit"
data-compose--compose-target="sendBtn"
class="text-white text-sm font-semibold px-4 py-1.5 hover:bg-white/10 transition-colors">
Send
{{ 'compose.send'|trans }}
</button>
<button type="button"
title="{{ 'compose.send_options'|trans }}"
aria-label="{{ 'compose.send_options'|trans }}"
class="text-white border-l border-white/30 px-2.5 py-1.5 hover:bg-white/10 transition-colors text-xs">
<i class="fa-solid fa-chevron-down"></i>
<i class="fa-solid fa-chevron-down" aria-hidden="true"></i>
</button>
</div>
@@ -445,23 +521,44 @@ Variables: form, message, ctx (inline, frame, thread, urlParams)
<button type="button"
data-action="click->compose--compose#toggleFormatBar"
data-compose--compose-target="formatToggle"
{# "Aa" on its face is the accessible name; the
title enlarges on it without replacing it. #}
title="{{ 'compose.formatting'|trans }}"
class="{{ ic }} text-sm font-bold tracking-tighter">Aa</button>
<button type="button"
data-action="click->compose--compose#openFilePicker"
title="{{ 'compose.attach'|trans }}"
class="{{ ic }}"><i class="fa-solid fa-paperclip"></i></button>
aria-label="{{ 'compose.attach'|trans }}"
class="{{ ic }}"><i class="fa-solid fa-paperclip" aria-hidden="true"></i></button>
{# No name: never submitted with the form, only read by the
controller and POSTed to the attachment endpoint. #}
<input type="file" multiple class="hidden"
data-compose--compose-target="fileInput"
data-action="change->compose--compose#uploadFiles">
<button type="button" data-compose--compose-toolbar-action data-action="click->compose--compose-toolbar#insertLink" class="{{ ic }}"><i class="fa-solid fa-link"></i></button>
<button type="button" class="{{ ic }}"><i class="fa-regular fa-face-smile"></i></button>
<button type="button" class="{{ ic }}"><i class="fa-regular fa-image"></i></button>
<button type="button" class="{{ ic }}"><i class="fa-solid fa-lock"></i></button>
<button type="button" class="{{ ic }}"><i class="fa-solid fa-pen-nib"></i></button>
<button type="button" class="{{ ic }}"><i class="fa-solid fa-ellipsis-vertical"></i></button>
<button type="button" data-compose--compose-toolbar-action
data-action="click->compose--compose-toolbar#insertLink"
title="{{ 'compose.insert_link'|trans }}"
aria-label="{{ 'compose.insert_link'|trans }}"
class="{{ ic }}"><i class="fa-solid fa-link" aria-hidden="true"></i></button>
{# Named even though they do nothing yet. A control with no
accessible name is unreachable by a screen reader
whether or not it is wired up, and these are the six the
audit found: an unlabelled row of icons is what the
compose toolbar read as. #}
{% set placeholderTools = [
{ icon: 'fa-regular fa-face-smile', label: 'compose.insert_emoji' },
{ icon: 'fa-regular fa-image', label: 'compose.image' },
{ icon: 'fa-solid fa-lock', label: 'compose.encrypt' },
{ icon: 'fa-solid fa-pen-nib', label: 'compose.signature' },
{ icon: 'fa-solid fa-ellipsis-vertical', label: 'compose.more_options' },
] %}
{% for tool in placeholderTools %}
<button type="button"
title="{{ tool.label|trans }}"
aria-label="{{ tool.label|trans }}"
class="{{ ic }}"><i class="{{ tool.icon }}" aria-hidden="true"></i></button>
{% endfor %}
</div>
{# Attach from a connected service. Sized to what the user
@@ -505,8 +602,9 @@ Variables: form, message, ctx (inline, frame, thread, urlParams)
<button type="button"
data-action="compose--compose#discard"
title="{{ 'compose.discard'|trans }}"
aria-label="{{ 'compose.discard'|trans }}"
class="{{ ic }} hover:text-danger">
<i class="fa-regular fa-trash-can"></i>
<i class="fa-regular fa-trash-can" aria-hidden="true"></i>
</button>
</div>
</div>
+10 -1
View File
@@ -28,7 +28,16 @@
<div class="min-w-0 flex-1">
<p class="flex items-center gap-2 min-w-0">
<span class="truncate text-sm font-medium text-ink">{{ account.displayAddress }}</span>
{% if loop.first %}
{# Read the flag, not the position. This badge used to
be `loop.first`, which made it a statement about
whatever order the list happened to arrive in — and
the settings page sorts alphabetically while
`isPrimary` is derived from the user's own
arrangement (AccountCreator::resequence). The two
disagreed whenever the alphabet did, so the account
badged PRIMARY here and the account compose actually
sent from were different accounts. #}
{% if account.isPrimary %}
<span class="shrink-0 inline-flex items-center rounded-full bg-blue-500/10 px-2 py-0.5 text-[11px] font-medium text-accent">
{{ 'settings.accounts.primary'|trans }}
</span>
+240
View File
@@ -0,0 +1,240 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller\Mail;
use App\Entity\Mail\Account;
use App\Entity\User\User;
use App\Repository\User\UserRepository;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
/**
* The two halves of compose the browser cannot be trusted with.
*
* Which account a fresh window sends *from*, and whether the server will accept
* a send addressed to nothing. Both came out of one external bug report, and
* both are server-side questions: the From default is chosen before any script
* runs, and "the UI would not let me do that" is not a reason the API may
* assume it was not done.
*/
final class ComposeSafetyTest 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();
}
/**
* The From selector must open on the account marked primary — whatever the
* alphabet thinks.
*
* The reported case exactly: two accounts whose alphabetical order is the
* reverse of the user's own arrangement. `isPrimary` is derived from
* position 0 of that arrangement (AccountCreator::resequence()), so the
* account badged PRIMARY in settings is the second one alphabetically —
* and the settings page was listing, and badging, alphabetically.
*/
public function testAFreshWindowSendsFromThePrimaryAccount(): void
{
$client = static::createClient();
$user = $this->boot($client);
// "aaa@" sorts first; the primary is deliberately the other one.
$this->account($user, 'aaa@joder.dev', sortOrder: 1, primary: false);
$primary = $this->account($user, 'pmd@joder.dev', sortOrder: 0, primary: true);
$this->em->flush();
$crawler = $client->request('GET', '/compose/new');
self::assertResponseIsSuccessful();
$selected = $crawler->filter('select.sr-only option[selected]');
self::assertCount(1, $selected, 'exactly one From option is preselected');
self::assertSame(
$primary->id . '|pmd@joder.dev',
$selected->attr('value'),
'the primary account, not the alphabetically first one',
);
}
/**
* With nothing flagged primary the answer still has to be stable.
*
* Any account that was not created through AccountCreator::create() — a
* seed, an import, a config restore — carries isPrimary = false, so this is
* not a corner case. The fallback used to be an unordered findOneBy, which
* meant the From default could differ between two loads of the same window.
*/
public function testWithNoPrimaryFlagTheFirstArrangedAccountIsUsed(): void
{
$client = static::createClient();
$user = $this->boot($client);
$this->account($user, 'zzz@joder.dev', sortOrder: 0, primary: false);
$this->account($user, 'aaa@joder.dev', sortOrder: 1, primary: false);
$this->em->flush();
$addresses = [];
for ($i = 0; $i < 2; $i++) {
$crawler = $client->request('GET', '/compose/new');
self::assertResponseIsSuccessful();
$addresses[] = $crawler->filter('select.sr-only option[selected]')->attr('value');
}
self::assertSame($addresses[0], $addresses[1], 'the same window twice gives the same From');
self::assertStringEndsWith(
'|zzz@joder.dev',
(string) $addresses[0],
'sortOrder 0 — the head of the user\'s own arrangement',
);
}
/**
* A send with no usable recipient is refused, and says why.
*
* The address in the report — `keine-gueltige-adresse`, no `@` — never
* becomes a Contact (ContactAutocompleteField drops what it cannot
* validate), so by the time the form is bound it is indistinguishable from
* an empty To. One rule covers both, and it lives in the `send` validation
* group so a *draft* may still be saved with no recipient.
*/
public function testTheServerRefusesASendWithNoValidRecipient(): void
{
$client = static::createClient();
$user = $this->boot($client);
$account = $this->account($user, 'sender@joder.dev', sortOrder: 0, primary: true);
$this->em->flush();
$crawler = $client->request('GET', '/compose/new');
self::assertResponseIsSuccessful();
$token = $crawler->filter('input[name="compose[_token]"]')->attr('value');
$client->request('POST', '/compose/send', [
'compose' => [
'_token' => $token,
'account' => $account->id . '|sender@joder.dev',
'toAddresses' => ['keine-gueltige-adresse'],
'subject' => 'No valid recipient',
'bodyHtml' => '<p>Body</p>',
],
]);
// 422, not 500 and not 200: the window comes back carrying its errors,
// which is also the status Turbo needs to re-render a rejected form.
self::assertResponseStatusCodeSame(422);
$body = (string) $client->getResponse()->getContent();
self::assertStringNotContainsString(
'toast-region',
$body,
'no send toast — nothing was dispatched',
);
self::assertStringContainsString(
'recipient',
strtolower($body),
'and the window says what was wrong with it',
);
}
/** A draft, by contrast, may have no recipient at all. */
public function testADraftMayStillBeSavedWithNoRecipient(): void
{
$client = static::createClient();
$user = $this->boot($client);
$account = $this->account($user, 'drafter@joder.dev', sortOrder: 0, primary: true);
$this->em->flush();
$crawler = $client->request('GET', '/compose/new');
$token = $crawler->filter('input[name="compose[_token]"]')->attr('value');
$client->request('POST', '/compose/draft', [
'compose' => [
'_token' => $token,
'account' => $account->id . '|drafter@joder.dev',
'subject' => 'Just thinking out loud',
'bodyHtml' => '<p>Not addressed to anyone yet.</p>',
],
]);
self::assertResponseIsSuccessful();
}
// ── fixture ───────────────────────────────────────────────────────────
private function boot(object $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);
// The fixtures below live in a transaction that is rolled back in
// tearDown, so they exist only on this connection. A rebooted kernel
// builds a new one and cannot see them — which is why a test making
// two requests found no accounts on the second.
$client->disableReboot();
$this->connection->beginTransaction();
// Every account this user already has would otherwise compete with the
// two the test is about — the seeded mailbox included.
$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, int $sortOrder, bool $primary): Account
{
$account = new Account();
$account->usr = $user;
$account->name = $email;
$account->username = $email;
$account->email = $email;
$account->authType = 'password';
$account->isActive = true;
$account->isPrimary = $primary;
$account->sortOrder = $sortOrder;
$account->imapHost = 'imap.example.test';
$this->em->persist($account);
return $account;
}
}
+390
View File
@@ -0,0 +1,390 @@
import { test, expect, type Page } from "./support/test";
import { seed } from "./support/config";
/**
* The compose window's accidental-send guards, and the window's own manners.
*
* All of this comes from one external bug report against a live install, and
* the first item is the reason the rest were found: typing a string with no
* `@` into To and pressing Enter *sent the message* — no subject, no body, a
* garbage recipient — leaving only the 10s undo window to take it back, and
* that window did not work either.
*
* The mechanism was implicit form submission. Tom Select calls preventDefault
* on Enter only when it has something to do with the keystroke: an active
* suggestion to commit, or a `createItem()` that succeeds. A string matching no
* contact AND failing `createFilter` (no `@`) satisfies neither, so the keydown
* reached the <form> — whose action is the send URL.
*
* That is why compose-recipients.spec.ts never caught it: every address it
* types is valid, so `createItem()` always succeeds and always swallows the
* key. The invalid address is the whole test.
*/
const DOCK = "#compose_dock";
const GARBAGE = "keine-gueltige-adresse";
const VALID = "safety-valid@example.test";
test.beforeAll(() => {
seed("seed-mail", "clear-drafts");
});
async function openCompose(page: Page): Promise<void> {
await page.goto("/mail/inbox");
await page.getByRole("link", { name: "Compose" }).first().click();
await expect(page.locator(`${DOCK} .ts-control`).first()).toBeVisible();
}
function toInput(page: Page) {
return page.locator(`${DOCK} .ts-control input`).first();
}
function chips(page: Page) {
return page.locator(`${DOCK} .ts-control`).first().locator(".item");
}
/**
* Records every send POST the page makes. Asserting "nothing was sent" needs a
* witness that survives the assertion — waitForResponse would only report a
* send that happened inside its own timeout.
*/
function watchSends(page: Page): string[] {
const sends: string[] = [];
page.on("request", (request) => {
if (request.method() === "POST" && /\/compose\/send/.test(request.url())) {
sends.push(request.url());
}
});
return sends;
}
async function fillMessage(page: Page, address: string): Promise<void> {
await toInput(page).fill(address);
await toInput(page).press("Enter");
await page.locator(`${DOCK} [data-compose--compose-target="subject"]`).fill("Safety subject");
await page
.locator(`${DOCK} [data-compose--compose-toolbar-target="editor"]`)
.fill("Safety body text, long enough to save.");
}
test.describe("compose safety", () => {
// ── K-01 ──────────────────────────────────────────────────────────
test("Enter on an invalid address does not send the message", async ({ page }) => {
const sends = watchSends(page);
await openCompose(page);
await toInput(page).fill(GARBAGE);
await toInput(page).press("Enter");
// Give a send that was going to happen the time to happen.
await page.waitForTimeout(1_000);
expect(sends).toHaveLength(0);
await expect(page.locator(`${DOCK} .ts-control`).first()).toBeVisible();
await expect(
page.locator(`${DOCK} [data-compose--compose-toolbar-target="editor"]`),
).toBeVisible();
});
test("Enter on a valid address commits a chip and does not send", async ({ page }) => {
const sends = watchSends(page);
await openCompose(page);
await toInput(page).fill(VALID);
await toInput(page).press("Enter");
await expect(chips(page)).toHaveCount(1);
await expect(chips(page).first()).toContainText(VALID);
await page.waitForTimeout(500);
expect(sends).toHaveLength(0);
});
/** Ctrl+Enter stays a send — the one deliberate keyboard shortcut. */
test("Ctrl+Enter sends", async ({ page }) => {
const sends = watchSends(page);
await openCompose(page);
await fillMessage(page, VALID);
await page.locator(`${DOCK} [data-compose--compose-target="subject"]`).press("Control+Enter");
await expect.poll(() => sends.length, { timeout: 10_000 }).toBe(1);
});
// ── K-02 ──────────────────────────────────────────────────────────
test("an invalid address is refused rather than chipped, and says so", async ({ page }) => {
await openCompose(page);
await toInput(page).fill(GARBAGE);
await toInput(page).press("Enter");
await expect(chips(page)).toHaveCount(0);
await expect(page.locator(`${DOCK} [data-compose--compose-target="errors"]`)).toContainText(
GARBAGE,
);
});
test("sending with no recipient is refused", async ({ page }) => {
const sends = watchSends(page);
await openCompose(page);
await page.locator(`${DOCK} [data-compose--compose-target="subject"]`).fill("No recipient");
await page
.locator(`${DOCK} [data-compose--compose-toolbar-target="editor"]`)
.fill("Body with no addressee at all.");
await page.locator(DOCK).getByRole("button", { name: "Send", exact: true }).click();
await page.waitForTimeout(1_000);
expect(sends).toHaveLength(0);
await expect(page.locator(`${DOCK} [data-compose--compose-target="errors"]`)).toBeVisible();
});
/**
* The server's own refusal, reached past the window entirely.
*
* "The API must refuse, not just the UI" is the point: the guards above all
* live in a controller the user can simply not run.
*/
test("the server refuses a send with no valid recipient", async ({ page }) => {
await openCompose(page);
const status = await page.evaluate(async (garbage) => {
const form = document.querySelector("#compose_dock form") as HTMLFormElement;
const body = new FormData(form);
body.set("compose[toAddresses][]", garbage);
const response = await fetch("/compose/send", {
method: "POST",
body,
headers: { "X-Requested-With": "XMLHttpRequest" },
});
return { ok: response.ok, text: (await response.text()).slice(0, 4000) };
}, GARBAGE);
// Not a send toast — the window comes back carrying the refusal.
expect(status.text).not.toContain("toast-region");
expect(status.text).toMatch(/recipient|Empfänger/i);
});
test("an empty subject asks before sending", async ({ page }) => {
const sends = watchSends(page);
await openCompose(page);
await toInput(page).fill(VALID);
await toInput(page).press("Enter");
await page
.locator(`${DOCK} [data-compose--compose-toolbar-target="editor"]`)
.fill("Body but no subject line.");
// Refuse the confirm: nothing may go out.
page.once("dialog", (dialog) => dialog.dismiss());
await page.locator(DOCK).getByRole("button", { name: "Send", exact: true }).click();
await page.waitForTimeout(1_000);
expect(sends).toHaveLength(0);
});
/**
* Enter on a toolbar control is neither a send nor a failed address.
*
* The typeface and size pickers are Tom Selects too, so "is this a Tom
* Select?" is not the same question as "is this an address field?" — the
* Enter handler keys off the address rows, not off `.ts-wrapper`.
*
* (These two pickers hold three and four options, below ui--select's
* search-box threshold, so they render no text input and the handler
* declines them a step earlier still. The assertion is on the behaviour
* either way: Enter here does nothing at all.)
*/
test("Enter on a formatting control neither sends nor reports an address", async ({ page }) => {
const sends = watchSends(page);
await openCompose(page);
const typeface = page.locator(`${DOCK} [data-compose--compose-toolbar-target="fontFamily"]`);
await expect(typeface).toBeAttached();
// The widget Tom Select built in its place, which is what has focus.
await page
.locator(`${DOCK} [data-compose--compose-target="formatBar"] .ts-control`)
.first()
.press("Enter");
await expect(page.locator(`${DOCK} [data-compose--compose-target="errors"]`)).toBeHidden();
await page.waitForTimeout(500);
expect(sends).toHaveLength(0);
});
// ── K-03 / K-04 ───────────────────────────────────────────────────
/**
* Undo has to put the window back, with everything that was typed in it.
*
* It used to 500: the undo response rendered the compose window without
* `pickerIntegrations`, and the cancel flag was already flushed by then —
* so the send was called off, the toast faded, and the draft was filed with
* no way back to it.
*/
test("undo reopens the window with the recipient intact", async ({ page }) => {
await openCompose(page);
await fillMessage(page, VALID);
await page.locator(DOCK).getByRole("button", { name: "Send", exact: true }).click();
const undo = page.locator("[data-controller='compose--undo-send']");
await expect(undo).toBeVisible({ timeout: 10_000 });
await undo.click();
// The window is back…
await expect(page.locator(`${DOCK} .compose-window`)).toBeVisible({ timeout: 10_000 });
// …and so is everything that was in it.
await expect(chips(page)).toHaveCount(1);
await expect(chips(page).first()).toContainText(VALID);
await expect(page.locator(`${DOCK} [data-compose--compose-target="subject"]`)).toHaveValue(
"Safety subject",
);
await expect(
page.locator(`${DOCK} [data-compose--compose-toolbar-target="editor"]`),
).toContainText("Safety body text");
});
// ── A-03 / A-05 ───────────────────────────────────────────────────
test("every compose control has an accessible name", async ({ page }) => {
await openCompose(page);
const dock = page.locator(DOCK);
for (const name of [
"To",
"Send",
"Minimize",
"Expand",
"Attach files",
"Insert emoji",
"Insert image",
"Encrypt message",
"Insert signature",
"More options",
"Text colour",
"Typeface",
"Text size",
]) {
await expect(
dock.getByRole("button", { name, exact: true })
.or(dock.getByRole("combobox", { name, exact: true }))
.or(dock.getByRole("textbox", { name, exact: true }))
.first(),
`"${name}" should be reachable by its accessible name`,
).toBeAttached();
}
// A-05: the text-colour control was not in the accessibility tree at
// all — a <label> wrapping a 0×0, pointer-events:none, tabindex="-1"
// input. It has to be focusable.
const colour = dock.getByRole("textbox", { name: "Text colour" });
await colour.focus();
await expect(colour).toBeFocused();
// No control may be left nameless.
const unnamed = await dock.evaluate((root) =>
Array.from(root.querySelectorAll("button"))
.filter((b) => {
const el = b as HTMLElement;
if (el.offsetParent === null) return false;
const name =
el.getAttribute("aria-label") ??
el.getAttribute("title") ??
el.textContent?.trim() ??
"";
return name === "";
})
.map((b) => b.outerHTML.slice(0, 120)),
);
expect(unnamed).toEqual([]);
});
});
// ── I-01 / I-03: the window speaks the UI's language ──────────────────
/**
* Switch the signed-in user's language through the real settings form.
*
* The active language's button is `disabled`, so asking for the one already in
* force is a no-op rather than a failed click — which matters in the cleanup
* hook, where the test may have failed before it ever changed anything.
*/
async function useLocale(page: Page, locale: string): Promise<void> {
await page.goto("/settings?section=general");
const button = page
.locator(`form[action*='locale']:has(input[value='${locale}']) button`)
.first();
if (await button.isDisabled()) {
return;
}
await button.click();
await expect(page).toHaveURL(/section=general/);
}
test.describe("compose localization", () => {
// Restore English for whatever runs next on this worker's user.
test.afterEach(async ({ page }) => {
await useLocale(page, "en");
});
test("the compose window is German in a German UI", async ({ page }) => {
await useLocale(page, "de");
await page.goto("/mail/inbox");
await page.getByRole("link", { name: /Schreiben|Compose/ }).first().click();
const dock = page.locator(DOCK);
await expect(dock.locator(".ts-control").first()).toBeVisible();
const text = await dock.innerText();
// The words the report named, in German.
for (const german of ["Neue Nachricht", "Von", "An", "Betr", "Senden"]) {
expect(text).toContain(german);
}
// And none of the English it used to show beside "Serifenlos".
for (const english of ["New Message", "Subj", "Write your message"]) {
expect(text).not.toContain(english);
}
// The dock chrome and the formatting toolbar too (I-03).
for (const name of [
"Minimieren",
"Fenster vergrössern",
"Linksbündig ausrichten",
"Nummerierte Liste",
"Rückgängig",
"Einzug vergrössern",
]) {
await expect(
dock.getByRole("button", { name, exact: true }),
`the toolbar should offer "${name}"`,
).toBeAttached();
}
});
});
+55 -1
View File
@@ -1072,21 +1072,74 @@ compose:
large: Gross
normal: Normal
small: Klein
align:
center: Zentriert ausrichten
left: Linksbündig ausrichten
right: Rechtsbündig ausrichten
confirm:
no_body: Diese Nachricht hat keinen Text. Trotzdem senden?
no_subject: Diese Nachricht hat keinen Betreff. Trotzdem senden?
list:
ordered: Nummerierte Liste
unordered: Aufzählung
quote:
hide: zitierten Text ausblenden
show: zitierten Text anzeigen
status:
pending_many: "noch %count% Zeichen bis zum Speichern"
pending_one: noch ein Zeichen bis zum Speichern
save_failed: Entwurf konnte nicht gespeichert werden
saved: Entwurf gespeichert
saving: "Wird gespeichert…"
uploading: "Wird hochgeladen…"
add_bcc: Bcc-Empfänger hinzufügen
add_cc: Cc-Empfänger hinzufügen
attach: Dateien anhängen
attachment_remove: Anhang entfernen
attach_failed: Anhängen fehlgeschlagen
attach_remove_failed: Anhang konnte nicht entfernt werden
attach_too_large: "%name% ist grösser als %limit% MB"
bcc: Bcc
bold: Fett
cc: Cc
click_to_cancel: zum Abbrechen klicken
continue_editing: Weiter bearbeiten
discard: Entwurf löschen
empty_draft: (leerer Entwurf)
encrypt: Nachricht verschlüsseln
expand: Fenster vergrössern
formatting: Formatierung
forward: Weiterleiten
from: Von
image: Bild einfügen
indent: Einzug vergrössern
insert_emoji: Emoji einfügen
insert_link: Link einfügen
invalid_address: "„%address%“ ist keine gültige E-Mail-Adresse"
italic: Kursiv
minimize: Minimieren
more_options: Weitere Optionen
new_message: Neue Nachricht
no_recipients: Keine Empfänger
outdent: Einzug verkleinern
pop_out: In eigenem Fenster öffnen
recipient_required: Füge mindestens einen Empfänger hinzu, bevor du sendest.
redo: Wiederholen
reply: Antworten
reply_all: Allen antworten
reply_placeholder: "An %name% antworten"
save_and_close: Entwurf speichern und schließen
send: Senden
send_options: Sendeoptionen
sending: "Wird gesendet…"
signature: Signatur einfügen
subject_label: Betr
subject_placeholder: Betreff
text_color: Textfarbe
to: An
underline: Unterstrichen
undo: Rückgängig
body_placeholder: "Schreibe deine Nachricht…"
attachment_remove: Anhang entfernen
drafts:
empty: Keine Entwürfe.
@@ -2168,6 +2221,7 @@ thread_row:
toast:
dismiss: Schließen
send_cancelled: Senden abgebrochen — zurück zu deinem Entwurf.
undo: Rückgängig
error:
no_archive_mailbox: Es gibt kein Archiv-Postfach.
+55 -1
View File
@@ -1045,21 +1045,74 @@ compose:
large: Large
normal: Normal
small: Small
align:
center: Align center
left: Align left
right: Align right
confirm:
no_body: This message has no text. Send it anyway?
no_subject: This message has no subject. Send it anyway?
list:
ordered: Numbered list
unordered: Bullet list
quote:
hide: hide quoted text
show: show quoted text
status:
pending_many: "%count% more characters to save"
pending_one: one more character to save
save_failed: Could not save the draft
saved: Draft saved
saving: "Saving…"
uploading: "Uploading…"
add_bcc: Add Bcc recipients
add_cc: Add Cc recipients
attach: Attach files
attachment_remove: Remove attachment
attach_failed: Could not attach
attach_remove_failed: Could not remove attachment
attach_too_large: "%name% is over %limit% MB"
bcc: Bcc
bold: Bold
cc: Cc
click_to_cancel: click to cancel
continue_editing: Continue editing
discard: Delete draft
empty_draft: (empty draft)
encrypt: Encrypt message
expand: Expand
formatting: Formatting options
forward: Forward
from: From
image: Insert image
indent: Indent
insert_emoji: Insert emoji
insert_link: Insert link
invalid_address: "“%address%” is not a valid email address"
italic: Italic
minimize: Minimize
more_options: More options
new_message: New Message
no_recipients: No recipients
outdent: Outdent
pop_out: Open in compose window
recipient_required: Add at least one recipient before sending.
redo: Redo
reply: Reply
reply_all: Reply all
reply_placeholder: "Reply to %name%"
save_and_close: Save draft and close
send: Send
send_options: Send options
sending: "Sending…"
signature: Insert signature
subject_label: Subj
subject_placeholder: Subject
text_color: Text colour
to: To
underline: Underline
undo: Undo
body_placeholder: "Write your message…"
attachment_remove: Remove attachment
drafts:
empty: No drafts.
@@ -2128,6 +2181,7 @@ thread_row:
toast:
dismiss: Dismiss
send_cancelled: Send cancelled — back to your draft.
undo: Undo
error:
no_archive_mailbox: There is no archive mailbox.
+56 -1
View File
@@ -790,21 +790,74 @@ compose:
large: Grand
normal: Fair
small: Wee
align:
center: Amidships
left: Hard to port
right: Hard to starboard
confirm:
no_body: This scroll be blank. Cast it off regardless?
no_subject: This scroll bears no heading. Cast it off regardless?
list:
ordered: Numbered manifest
unordered: Bulleted manifest
quote:
hide: stow the quoted words
show: unfurl the quoted words
status:
pending_many: "%count% more marks afore stowin'"
pending_one: one more mark afore stowin'
save_failed: Could not stow the scroll
saved: Scroll stowed
saving: "Stowin'…"
uploading: "Haulin' aboard…"
add_bcc: Add shadow crew
add_cc: Add copied crew
attach: Lash on some cargo
attachment_remove: Heave the cargo overboard
attach_failed: Could not lash it on
attach_remove_failed: Could not heave the cargo overboard
attach_too_large: "%name% be heavier than %limit% MB"
bcc: Bcc
bold: Stout
cc: Cc
click_to_cancel: click to belay it
continue_editing: Keep scribblin'
discard: Burn this scroll
empty_draft: (blank scroll)
encrypt: Seal it in cipher
expand: Throw open the hatches
formatting: Fancy lettering
forward: Pass it along
from: From
image: Paste in a likeness
indent: Shove it inboard
insert_emoji: Daub on a face
insert_link: Splice in a link
invalid_address: "“%address%” be no proper address"
italic: Aslant
minimize: Batten down
more_options: More riggin'
new_message: A Fresh Scroll
no_recipients: Nobody to send it to
outdent: Shove it outboard
pop_out: Open in its own cabin
recipient_required: Name at least one soul afore ye cast off.
redo: Do it over again
reply: Answer
reply_all: Answer the whole crew
reply_placeholder: "Answer %name%"
save_and_close: Stow the scroll and close
send: Cast off
send_options: Castin'-off orders
sending: "Castin' off…"
signature: Make yer mark
subject_label: Head
subject_placeholder: Headin'
text_color: Ink colour
to: To
underline: Underscored
undo: Belay that
body_placeholder: "Scrawl yer message…"
attachment_remove: Heave the cargo overboard
drafts:
empty: No scrolls in the works.
@@ -1805,6 +1858,8 @@ thread_row:
toast:
dismiss: Away with ye
send_cancelled: Castin' off belayed — back to yer scroll.
undo: Belay that
error:
no_archive_mailbox: There be no hold on this ship.
+2
View File
@@ -9,5 +9,7 @@ caldav.credential_required: Gib ein App-Passwort ein oder setz den Haken, um das
caldav.reuse_account_required: Wähle, welches E-Mail-Konto sein Passwort hergeben soll — oder nimm den Haken weg und tippe eines ein.
caldav.username_required: Gib den Benutzernamen ein, unter dem dich dein CalDAV-Server kennt.
calendar.name_required: Gib dem Kalender einen Namen — daran erkennst du ihn zwischen den anderen.
compose.recipient_invalid: "„{{ address }}“ ist keine gültige E-Mail-Adresse."
compose.recipient_required: Füge mindestens einen Empfänger hinzu, bevor du sendest.
setup.install.password_mismatch: Die beiden Passwörter stimmen nicht überein.
setup.install.password_too_short: Mindestens {{ limit }} Zeichen. Für dieses Konto gibt es keine Passwort-Zurücksetzung.
+2
View File
@@ -10,5 +10,7 @@ caldav.credential_required: Enter an app password, or tick the box to use a mail
caldav.reuse_account_required: Choose which mail account's password to use, or untick the box and type one.
caldav.username_required: Enter the username your CalDAV server knows you by.
calendar.name_required: Give the calendar a name — it is what tells it apart from the others.
compose.recipient_invalid: "“{{ address }}” is not a valid email address."
compose.recipient_required: Add at least one recipient before sending.
setup.install.password_mismatch: The two passwords don't match.
setup.install.password_too_short: Use at least {{ limit }} characters. This account has no password reset.
+2
View File
@@ -8,5 +8,7 @@ caldav.credential_required: Give a ship's key, or tick the box to use the word s
caldav.reuse_account_required: Say which parrot account lends its word — or take the tick off an' type one.
caldav.username_required: Give the name yer CalDAV port knows ye by.
calendar.name_required: Name the almanac, else ye'll not tell it from the rest.
compose.recipient_invalid: "“{{ address }}” be no proper address, matey."
compose.recipient_required: Name at least one soul afore ye cast off.
setup.install.password_mismatch: Them two secret words don't match, matey.
setup.install.password_too_short: Use {{ limit }} marks at least. There be no gettin' this one back.