start reply/forward function

This commit is contained in:
Paul Lützner committed 2026-07-13 13:35:56 +02:00
1 parent 3265d98f90
commit 325f794fea
4 files changed
+311 -40

No files matched your search

+2 -2
View File
@@ -1508,8 +1508,8 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* }
* @psalm-type MercureConfig = array{
* hubs?: array<string, array{ // Default: []
* url?: scalar|Param|null, // URL of the hub's publish endpoint
* public_url?: scalar|Param|null, // URL of the hub's public endpoint // Default: null
* url?: scalar|Param|null, // URL of the hub's publish endpoint // Default: null
* public_url?: scalar|Param|null, // URL of the hub's public endpoint
* jwt?: string|array{ // JSON Web Token configuration.
* value?: scalar|Param|null, // JSON Web Token to use to publish to this hub.
* provider?: scalar|Param|null, // The ID of a service to call to provide the JSON Web Token.
+231 -24
View File
@@ -3,16 +3,13 @@
namespace App\Controller;
use App\Domain\Enum\MessageFlag;
use App\Entity\Account;
use App\Entity\Mailbox;
use App\Entity\Message;
use App\Form\ComposeType;
use App\Message\SendMessageMessage;
use App\Repository\MailboxRepository;
use App\Repository\MessageRepository;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use RuntimeException;
use Symfony\Component\Form\FormInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -26,9 +23,9 @@ class ComposeController extends AbstractController
public function __construct(
private readonly EntityManagerInterface $em,
private readonly MailboxRepository $mailboxRepository,
private readonly MessageRepository $messageRepository,
private readonly MessageBusInterface $bus,
)
{
) {
}
#[Route('/new', name: 'new', methods: ['GET'])]
@@ -47,11 +44,65 @@ class ComposeController extends AbstractController
]);
return $this->render('compose/_window.html.twig', [
'form' => $form,
'form' => $form,
'message' => $message,
]);
}
#[Route('/reply/{id}', name: 'reply', methods: ['GET'])]
public function reply(Message $original): Response
{
$this->assertOwnership($original);
$draft = $this->buildReply($original, replyAll: false);
$form = $this->createForm(ComposeType::class, $draft, [
'user' => $this->getUser(),
'validation_groups' => ['Default'],
]);
return $this->render('compose/_window.html.twig', [
'form' => $form,
'message' => $draft,
]);
}
#[Route('/reply-all/{id}', name: 'reply_all', methods: ['GET'])]
public function replyAll(Message $original): Response
{
$this->assertOwnership($original);
$draft = $this->buildReply($original, replyAll: true);
$form = $this->createForm(ComposeType::class, $draft, [
'user' => $this->getUser(),
'validation_groups' => ['Default'],
]);
return $this->render('compose/_window.html.twig', [
'form' => $form,
'message' => $draft,
]);
}
#[Route('/forward/{id}', name: 'forward', methods: ['GET'])]
public function forwardMessage(Message $original): Response
{
$this->assertOwnership($original);
$draft = $this->buildForward($original);
$form = $this->createForm(ComposeType::class, $draft, [
'user' => $this->getUser(),
'validation_groups' => ['Default'],
]);
return $this->render('compose/_window.html.twig', [
'form' => $form,
'message' => $draft,
]);
}
#[Route('/draft', name: 'form_new', methods: ['POST'])]
#[Route('/draft/{id}', name: 'form_edit', methods: ['POST'])]
public function draft(Request $request, ?Message $message = null): Response
@@ -64,7 +115,7 @@ class ComposeController extends AbstractController
$form = $this->createForm(ComposeType::class, $message, [
'user' => $this->getUser(),
'validation_groups' => ['Default'], // no 'send' group — To is optional
'validation_groups' => ['Default'],
]);
$form->handleRequest($request);
@@ -73,19 +124,18 @@ class ComposeController extends AbstractController
$this->persistDraft($message);
return $this->render('compose/_window.html.twig', [
'form' => $form,
'form' => $form,
'message' => $message,
'saved' => true,
'saved' => true,
]);
}
return $this->render('compose/_window.html.twig', [
'form' => $form,
'form' => $form,
'message' => $message,
]);
}
#[Route('/send', name: 'mail_send', methods: ['POST'])]
#[Route('/send/{id}', name: 'mail_send_draft', methods: ['POST'])]
public function send(Request $request, ?Message $message): Response
@@ -108,23 +158,20 @@ class ComposeController extends AbstractController
], new Response('', 200, ['Content-Type' => 'text/vnd.turbo-stream.html']));
}
$this->persistDraft($message); // saves to DB first
$this->persistDraft($message);
$sfMessageResult = $this->bus->dispatch(
$this->bus->dispatch(
new SendMessageMessage($message->getId()),
[new DelayStamp(10_000)] // 10 seconds
[new DelayStamp(10_000)],
);
dump($sfMessageResult);
return $this->render('compose/_send_toast.html.twig', [
'message' => $message,
], new Response('', 200, ['Content-Type' => 'text/vnd.turbo-stream.html']));
}
// validation failed — re-render compose window with errors
return $this->render('compose/_window.html.twig', [
'form' => $form,
'form' => $form,
'message' => $message,
]);
}
@@ -132,10 +179,7 @@ class ComposeController extends AbstractController
#[Route('/undo/{id}', name: 'mail_undo', methods: ['POST'])]
public function undo(Message $message): Response
{
// Security check — must own the message
if ($message->getMailbox()->getAccount()->getUsr() !== $this->getUser()) {
throw $this->createAccessDeniedException();
}
$this->assertOwnership($message);
$message->setCancelled(true);
$this->em->flush();
@@ -146,11 +190,167 @@ class ComposeController extends AbstractController
]);
return $this->render('compose/_undo_toast.html.twig', [
'form' => $form,
'form' => $form,
'message' => $message,
]);
}
// ── Private helpers ───────────────────────────────────────────────────────
private function buildReply(Message $original, bool $replyAll): Message
{
$draftMailbox = $this->mailboxRepository->findPrimaryDraftMailboxForUser($this->getUser());
$account = $draftMailbox->getAccount();
// "To" is always the original sender.
$to = [[
'name' => $original->getFromName() ?? '',
'address' => $original->getFromAddress() ?? '',
]];
// Reply-all: add original To/Cc, minus our own address.
$cc = [];
if (true === $replyAll) {
$ownAddress = strtolower($account->getEmail() ?? '');
$candidates = array_merge(
$original->getToAddresses() ?? [],
$original->getCcAddresses() ?? [],
);
foreach ($candidates as $addr) {
if (strtolower($addr['address'] ?? '') !== $ownAddress) {
$cc[] = $addr;
}
}
}
$subject = $this->prefixSubject('Re', $original->getSubject());
// References chain: append original message-id to existing References.
$references = array_merge(
$original->getReferences() ?? [],
array_filter([$original->getMessageId()]),
);
$quotedBody = $this->buildQuotedHtml($original, 'reply');
$draft = (new Message())
->setMailbox($draftMailbox)
->setSubject($subject)
->setToAddresses($to)
->setCcAddresses($cc)
->setBodyHtml($quotedBody)
->setInReplyTo(array_filter([$original->getMessageId()]))
->setReferences(array_values(array_unique($references)))
->setHasAttachments(false)
->setCreatedAt(new DateTimeImmutable())
->setUpdatedAt(new DateTimeImmutable());
if (null !== $original->getThread()) {
$draft->setThread($original->getThread());
}
return $draft;
}
private function buildForward(Message $original): Message
{
$draftMailbox = $this->mailboxRepository->findPrimaryDraftMailboxForUser($this->getUser());
$subject = $this->prefixSubject('Fwd', $original->getSubject());
$quotedBody = $this->buildQuotedHtml($original, 'forward');
$draft = (new Message())
->setMailbox($draftMailbox)
->setSubject($subject)
->setToAddresses([])
->setBodyHtml($quotedBody)
->setHasAttachments(false)
->setCreatedAt(new DateTimeImmutable())
->setUpdatedAt(new DateTimeImmutable());
return $draft;
}
private function prefixSubject(string $prefix, ?string $subject): string
{
$subject = trim($subject ?? '');
if ($subject === '') {
return $prefix . ': ';
}
// Avoid stacking: don't add "Re:" if it already starts with Re:/Fwd:
$pattern = '/^(re|fwd?)\s*:\s*/i';
if (preg_match($pattern, $subject)) {
if (strtolower($prefix) === 're') {
return $subject;
}
}
return $prefix . ': ' . $subject;
}
/**
* Build the quoted-message HTML block inserted below the cursor.
* The compose editor will place the cursor BEFORE this block.
*/
private function buildQuotedHtml(Message $original, string $mode): string
{
$dateStr = $original->getReceivedAt() ? $original->getReceivedAt()->format('D, M j, Y \a\t g:i a') : '';
$fromName = htmlspecialchars($original->getFromName() ?? '', ENT_QUOTES, 'UTF-8');
$fromAddr = htmlspecialchars($original->getFromAddress() ?? '', ENT_QUOTES, 'UTF-8');
$fromLine = $fromName !== '' ? "{$fromName} &lt;{$fromAddr}&gt;" : $fromAddr;
$bodyHtml = trim($original->getBodyHtml() ?? '');
$bodyText = trim($original->getBodyText() ?? '');
// Prefer HTML; fall back to text wrapped in <pre>.
if ($bodyHtml !== '') {
$innerBody = $bodyHtml;
} elseif ($bodyText !== '') {
$innerBody = '<pre style="white-space:pre-wrap;font-family:inherit;margin:0">'
. htmlspecialchars($bodyText, ENT_QUOTES, 'UTF-8')
. '</pre>';
} else {
$innerBody = '';
}
if ($mode === 'reply') {
$attribution = "On {$dateStr}, {$fromLine} wrote:";
return <<<HTML
<p><br></p>
<p style="margin:0;font-size:0.85em;color:#555">{$attribution}</p>
<blockquote style="margin:0 0 0 0.5em;padding:0 0 0 1em;border-left:3px solid #c7c7c7;color:#444">
{$innerBody}
</blockquote>
HTML;
}
// Forward: include a header block with original metadata.
$subjectLine = htmlspecialchars($original->getSubject() ?? '', ENT_QUOTES, 'UTF-8');
$toLine = implode(', ', array_map(
static fn(array $a) => htmlspecialchars(
($a['name'] ? $a['name'] . ' <' . $a['address'] . '>' : $a['address']),
ENT_QUOTES,
'UTF-8',
),
$original->getToAddresses() ?? [],
));
return <<<HTML
<p><br></p>
<div style="border-top:1px solid #e0e0e0;padding-top:0.75em;margin-top:0.5em;font-size:0.85em;color:#555">
<p style="margin:0 0 0.25em"><strong>---------- Forwarded message ----------</strong></p>
<p style="margin:0 0 0.1em"><strong>From:</strong> {$fromLine}</p>
<p style="margin:0 0 0.1em"><strong>Date:</strong> {$dateStr}</p>
<p style="margin:0 0 0.1em"><strong>Subject:</strong> {$subjectLine}</p>
<p style="margin:0 0 0.75em"><strong>To:</strong> {$toLine}</p>
<div>{$innerBody}</div>
</div>
HTML;
}
private function persistDraft(Message $message): void
{
$now = new DateTimeImmutable();
@@ -165,4 +365,11 @@ class ComposeController extends AbstractController
$this->em->persist($message);
$this->em->flush();
}
private function assertOwnership(Message $message): void
{
if ($message->getMailbox()->getAccount()->getUsr() !== $this->getUser()) {
throw $this->createAccessDeniedException();
}
}
}
+1 -1
View File
@@ -76,7 +76,7 @@
>
<i class="fa-{{ starred ? 'solid' : 'regular' }} fa-star text-[18px]"></i>
</button>
{# Content — switches between mobile (2-row) and desktop (1-row) #}
<div class="flex-1 min-w-0 flex flex-col md:flex-row md:items-center md:gap-4">
+77 -13
View File
@@ -10,7 +10,7 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
</svg>
</button>
<span class="text-sm text-gray-400 dark:text-gray-600 truncate">{{ thread.subject ?: '(no subject)' }}</span>
<span class="text-sm text-gray-400 dark:text-gray-600 truncate">{{ thread.subject ?: ('thread.no_subject'|trans) }}</span>
</div>
<div class="flex-1 overflow-y-auto"
@@ -21,7 +21,7 @@
>
<div class="max-w-3xl mx-auto px-6 py-6">
<h1 class="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-8">
{{ thread.subject ?: '(no subject)' }}
{{ thread.subject ?: ('thread.no_subject'|trans) }}
</h1>
<div class="space-y-6">
@@ -41,7 +41,7 @@
{# Avatar #}
<div class="w-10 h-10 rounded-full bg-gradient-to-br from-blue-500 to-indigo-600 flex-shrink-0 flex items-center justify-center text-white text-sm font-medium shadow-sm">
{{ message.fromName|slice(0,1) ?: message.fromAddress|slice(0,1)|upper }}
{{ message.fromName|default(message.fromAddress)|slice(0,1)|upper }}
</div>
{# Bubble #}
@@ -98,12 +98,6 @@
>
<div class="text-sm text-gray-700 dark:text-gray-300 mb-4">
{% if message.bodyHtml %}
{#
Shadow DOM host: the JS controller attaches a shadow root here
and injects the HTML into it. This gives full style isolation
without any iframe sizing problems.
data-html carries the escaped HTML; the controller decodes it.
#}
<div
data-thread-message-target="shadowHost"
data-html="{{ message.bodyHtml|e('html_attr') }}"
@@ -116,17 +110,66 @@
{% if message.hasAttachments %}
<div class="mt-3 p-3 border border-gray-100 dark:border-gray-800 rounded-lg bg-gray-50/50 dark:bg-gray-800/50">
<p class="text-xs font-medium text-gray-400 mb-2">Attachments</p>
<p class="text-xs font-medium text-gray-400 mb-2">{{ 'message.attachments'|trans }}</p>
<div class="flex gap-2">
{# TODO: iterate message.parts filtered by disposition = attachment #}
</div>
</div>
{% endif %}
<div class="flex gap-4 mt-4 pt-2 border-t border-gray-100 dark:border-gray-800/50">
<button class="text-xs font-medium text-blue-600 dark:text-blue-400 hover:underline">Reply</button>
<button class="text-xs font-medium text-gray-500 hover:underline">Forward</button>
{# ── Reply / Reply-all / Forward actions ──────────────── #}
<div class="flex items-center gap-1 mt-4 pt-3 border-t border-gray-100 dark:border-gray-800/50">
{# Reply #}
<a
href="{{ path('app_compose_reply', { id: message.id }) }}"
data-turbo-frame="compose_dock"
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium
text-gray-600 dark:text-gray-300
bg-gray-100 dark:bg-white/5
border border-gray-200/60 dark:border-white/10
hover:bg-gray-200 dark:hover:bg-white/10
transition-colors"
>
<i class="fa-solid fa-reply text-[11px]" aria-hidden="true"></i>
{{ 'compose.reply'|trans }}
</a>
{# Reply-all (only if thread has multiple participants) #}
{% set hasMultipleParticipants = (message.toAddresses|length + (message.ccAddresses is not empty ? message.ccAddresses|length : 0)) > 1 %}
{% if hasMultipleParticipants %}
<a
href="{{ path('app_compose_reply_all', { id: message.id }) }}"
data-turbo-frame="compose_dock"
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium
text-gray-600 dark:text-gray-300
bg-gray-100 dark:bg-white/5
border border-gray-200/60 dark:border-white/10
hover:bg-gray-200 dark:hover:bg-white/10
transition-colors"
>
<i class="fa-solid fa-reply-all text-[11px]" aria-hidden="true"></i>
{{ 'compose.reply_all'|trans }}
</a>
{% endif %}
{# Forward #}
<a
href="{{ path('app_compose_forward', { id: message.id }) }}"
data-turbo-frame="compose_dock"
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium
text-gray-600 dark:text-gray-300
bg-gray-100 dark:bg-white/5
border border-gray-200/60 dark:border-white/10
hover:bg-gray-200 dark:hover:bg-white/10
transition-colors"
>
<i class="fa-solid fa-share text-[11px]" aria-hidden="true"></i>
{{ 'compose.forward'|trans }}
</a>
</div>
</div>
</div>{# /bubble #}
@@ -134,5 +177,26 @@
</div>{# /relative #}
{% endfor %}
</div>
{# ── Quick reply bar at the bottom of the thread ─────────────────────── #}
<div class="mt-8 pt-4 border-t border-gray-100 dark:border-gray-800">
{% set lastMessage = thread.messages|last %}
{% if lastMessage is not null %}
<a
href="{{ path('app_compose_reply', { id: lastMessage.id }) }}"
data-turbo-frame="compose_dock"
class="flex items-center gap-3 w-full px-4 py-3 rounded-xl
border border-gray-200/60 dark:border-white/10
bg-white/60 dark:bg-white/[0.03]
hover:bg-gray-50 dark:hover:bg-white/[0.06]
text-sm text-gray-400 dark:text-gray-600
transition-colors cursor-text"
>
<i class="fa-solid fa-reply text-xs text-gray-300 dark:text-gray-700" aria-hidden="true"></i>
{{ 'compose.reply_placeholder'|trans({ '%name%': (lastMessage.fromName ?: lastMessage.fromAddress) }) }}
</a>
{% endif %}
</div>
</div>
</div>