mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 01:54:10 +00:00
start reply/forward function
This commit is contained in:
1 parent
3265d98f90
commit
325f794fea
4 files changed
+311
-40
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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} <{$fromAddr}>" : $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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user