mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 10:04:18 +00:00
Merge fix/compose-safety
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
commit
17c21df702
18 files changed
+1481
-106
No files matched your search
@@ -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() {
|
||||
|
||||
@@ -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()),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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, [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user