Compose: allow a send addressed to Bcc only (#200)

A send with nobody in To was refused with "Add at least one recipient before sending", even with addresses in Cc or Bcc. The window's guard counted To, Cc and Bcc; the server's Count(min: 1) rule in the send group counted To only.

The rule is now a Callback on the To field that counts all three, read off the bound form since the fields are unmapped. A send with no recipient at all is still refused; a draft may still be saved with none.

Closes #201.

Co-Authored-By: jo2904 <josephdegaullier@gmx.fr>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-17 00:30:15 +02:00
1 parent cbe14ea5ae
commit fe800ebbf9
3 files changed
+80 -4

No files matched your search

+5
View File
@@ -8,6 +8,11 @@ The published image tags: `latest` follows the most recent release below,
## Unreleased
**A mail can go to Bcc recipients only.** Sending with nobody in To was refused with "Add at least
one recipient before sending", even with addresses in Cc or Bcc: the window counted all three, but
the server only counted To. It now counts all three as well. A mail with no recipient at all is
still refused, and a draft may still be saved with none. No schema change.
**Outlook's `<![endif]-->` no longer shows up in a message.** Mail written for Outlook hides
parts of itself behind conditional comments, and one form of them ends in a marker that is not
inside a comment. A mail that also had a comment containing `--` anywhere in it was read by an older
+39 -4
View File
@@ -21,7 +21,9 @@ 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;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Validator\Constraints\Callback;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
class ComposeType extends AbstractType
{
@@ -70,12 +72,17 @@ class ComposeType extends AbstractType
// 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.
//
// Counted across To, Cc and Bcc, as the window's own guard does: a
// mail sent only to Bcc is a normal thing to want (a list kept
// private, a copy to oneself), and SMTP, Gmail and Graph all take
// it. The rule sits on To so the refusal shows where the user looks
// first.
->add('toAddresses', ContactAutocompleteField::class, [
'mapped' => false,
'constraints' => [
new Count(
min: 1,
minMessage: 'compose.recipient_required',
new Callback(
callback: self::requireARecipient(...),
groups: ['send'],
),
],
@@ -270,6 +277,34 @@ class ComposeType extends AbstractType
return '' !== $name ? sprintf('%s <%s>', $name, $address) : $address;
}
/**
* Refuse a send with nobody in To, Cc or Bcc.
*
* Read off the bound form rather than off the Message: the three fields are
* unmapped, and the controller copies them onto the entity only after
* validation has run.
*/
private static function requireARecipient(mixed $to, ExecutionContextInterface $context): void
{
$field = $context->getObject();
if (false === $field instanceof FormInterface || null === $field->getParent()) {
return;
}
$form = $field->getParent();
foreach (['toAddresses', 'ccAddresses', 'bccAddresses'] as $name) {
$selected = $form->get($name)->getData();
if (true === is_countable($selected) && count($selected) > 0) {
return;
}
}
$context->buildViolation('compose.recipient_required')->addViolation();
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
@@ -165,6 +165,42 @@ final class ComposeSafetyTest extends WebTestCase
);
}
/**
* Bcc alone is a recipient.
*
* The rule used to count To only, so a mail meant for Bcc recipients and
* nobody else came back with "Add at least one recipient", although the
* window itself had let it through.
*/
public function testASendToBccAloneIsAccepted(): 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', server: self::DOCK_FRAME);
$token = $crawler->filter('input[name="compose[_token]"]')->attr('value');
$client->request('POST', '/compose/send', [
'compose' => [
'_token' => $token,
'account' => $account->id . '|sender@joder.dev',
'bccAddresses' => ['hidden@example.test'],
'subject' => 'Bcc only',
'bodyHtml' => '<p>Body</p>',
],
]);
self::assertResponseIsSuccessful();
self::assertStringNotContainsString(
'add at least one recipient',
strtolower((string) $client->getResponse()->getContent()),
);
}
/** A draft, by contrast, may have no recipient at all. */
public function testADraftMayStillBeSavedWithNoRecipient(): void
{