This commit is contained in:
Paul Lützner committed 2026-07-19 21:11:26 +02:00
1 parent 8b662f9b97
commit 1f836f1042
16 files changed
+263 -208

No files matched your search

+2 -2
View File
@@ -55,7 +55,7 @@ class SendDraftCommand extends Command
// Build choice list: "ID — Subject (account@email.com)"
$choices = [];
foreach ($drafts as $draft) {
$account = $draft->getMailbox()->getAccount();
$account = $draft->getAccount();
$label = sprintf(
'[%d] %s — %s',
$draft->getId(),
@@ -77,7 +77,7 @@ class SendDraftCommand extends Command
}
}
$account = $message->getMailbox()->getAccount();
$account = $message->getAccount();
$io->section(sprintf(
'Sending message %d — "%s" via %s',
$message->getId(),
+124 -38
View File
@@ -3,12 +3,16 @@
namespace App\Controller;
use App\Domain\Trait\ParsesAddressFields;
use App\Domain\Enum\LabelRole;
use App\Domain\Enum\MessageFlag;
use App\Entity\Account;
use App\Entity\Message;
use App\Form\ComposeType;
use App\Message\SendMessageMessage;
use App\Repository\AccountRepository;
use App\Repository\MailboxRepository;
use App\Repository\MessageRepository;
use App\Service\Label\LabelResolver;
use DateTimeImmutable;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\EntityManagerInterface;
@@ -20,6 +24,12 @@ use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Messenger\Stamp\DelayStamp;
use Symfony\Component\Routing\Attribute\Route;
/**
* Label-based compose: the From selector is an Account (unmapped form field),
* not a Mailbox. Drafts carry the chosen account's Drafts label; for plain-
* IMAP accounts the physical Drafts folder is attached as mailbox, for Gmail
* accounts mailbox stays null.
*/
#[Route('/compose', name: 'app_compose_')]
class ComposeController extends AbstractController
{
@@ -29,6 +39,8 @@ class ComposeController extends AbstractController
private readonly EntityManagerInterface $em,
private readonly MailboxRepository $mailboxRepository,
private readonly MessageRepository $messageRepository,
private readonly AccountRepository $accountRepository,
private readonly LabelResolver $labelResolver,
private readonly MessageBusInterface $bus,
) {
}
@@ -39,14 +51,18 @@ class ComposeController extends AbstractController
{
if (null === $message) {
$message = new Message()
->setMailbox($this->mailboxRepository->findPrimaryDraftMailboxForUser($this->getUser()))
->setCreatedAt(new DateTimeImmutable());
$account = $this->defaultAccount();
} else {
$this->assertOwnership($message);
$account = $message->getAccount();
}
$form = $this->createForm(ComposeType::class, $message, [
'user' => $this->getUser(),
'validation_groups' => ['Default'],
]);
$form->get('account')->setData($account);
return $this->render('compose/_window.html.twig', [
'form' => $form,
@@ -59,12 +75,14 @@ class ComposeController extends AbstractController
{
$this->assertOwnership($original);
$draft = $this->buildReply($original, replyAll: false);
$account = $original->getAccount() ?? $this->defaultAccount();
$draft = $this->buildReply($original, replyAll: false, account: $account);
$form = $this->createForm(ComposeType::class, $draft, [
'user' => $this->getUser(),
'validation_groups' => ['Default'],
]);
$form->get('account')->setData($account);
return $this->render('compose/_window.html.twig', [
'form' => $form,
@@ -77,12 +95,14 @@ class ComposeController extends AbstractController
{
$this->assertOwnership($original);
$draft = $this->buildReply($original, replyAll: true);
$account = $original->getAccount() ?? $this->defaultAccount();
$draft = $this->buildReply($original, replyAll: true, account: $account);
$form = $this->createForm(ComposeType::class, $draft, [
'user' => $this->getUser(),
'validation_groups' => ['Default'],
]);
$form->get('account')->setData($account);
return $this->render('compose/_window.html.twig', [
'form' => $form,
@@ -95,12 +115,14 @@ class ComposeController extends AbstractController
{
$this->assertOwnership($original);
$draft = $this->buildForward($original);
$account = $original->getAccount() ?? $this->defaultAccount();
$draft = $this->buildForward($original);
$form = $this->createForm(ComposeType::class, $draft, [
'user' => $this->getUser(),
'validation_groups' => ['Default'],
]);
$form->get('account')->setData($account);
return $this->render('compose/_window.html.twig', [
'form' => $form,
@@ -114,8 +136,9 @@ class ComposeController extends AbstractController
{
if (null === $message) {
$message = new Message()
->setMailbox($this->mailboxRepository->findPrimaryDraftMailboxForUser($this->getUser()))
->setCreatedAt(new DateTimeImmutable());
} else {
$this->assertOwnership($message);
}
$form = $this->createForm(ComposeType::class, $message, [
@@ -129,9 +152,14 @@ class ComposeController extends AbstractController
$this->applyAddressFields($form, $message);
if ($form->isSubmitted() && $form->isValid()) {
dump($message);
dump($form);
$this->persistDraft($message);
$account = $this->resolveAccount($form, $message);
if (null === $account) {
throw $this->createNotFoundException('No active account to compose from.');
}
$this->applyAccount($message, $account);
$this->persistDraft($message, $account);
return $this->render('compose/_window.html.twig', [
'form' => $form,
@@ -152,6 +180,8 @@ class ComposeController extends AbstractController
{
if (null === $message) {
$message = new Message();
} else {
$this->assertOwnership($message);
}
$form = $this->createForm(ComposeType::class, $message, [
@@ -171,7 +201,14 @@ class ComposeController extends AbstractController
], new Response('', 200, ['Content-Type' => 'text/vnd.turbo-stream.html']));
}
$this->persistDraft($message);
$account = $this->resolveAccount($form, $message);
if (null === $account) {
throw $this->createNotFoundException('No active account to send from.');
}
$this->applyAccount($message, $account);
$this->persistDraft($message, $account);
$this->bus->dispatch(
new SendMessageMessage($message->getId()),
[new DelayStamp(10_000)],
@@ -200,6 +237,7 @@ class ComposeController extends AbstractController
'user' => $this->getUser(),
'validation_groups' => ['Default'],
]);
$form->get('account')->setData($message->getAccount());
return $this->render('compose/_undo_toast.html.twig', [
'form' => $form,
@@ -209,6 +247,65 @@ class ComposeController extends AbstractController
// ── Private helpers ───────────────────────────────────────────────────────
/**
* Submitted From account, falling back to the message's current account,
* then the user's default.
*/
private function resolveAccount(FormInterface $form, Message $message): ?Account
{
$account = $form->get('account')->getData();
if (null !== $account) {
return $account;
}
return $message->getAccount() ?? $this->defaultAccount();
}
private function defaultAccount(): ?Account
{
$account = $this->accountRepository->findOneBy([
'usr' => $this->getUser(),
'isActive' => true,
'isPrimary' => true,
]);
if (null !== $account) {
return $account;
}
return $this->accountRepository->findOneBy([
'usr' => $this->getUser(),
'isActive' => true,
]);
}
/**
* Wire the message to its From account: Drafts label of that account,
* plus the physical Drafts folder for plain-IMAP accounts (Gmail
* accounts have no mailboxes — mailbox stays null).
*
* Switching the From account on an existing draft moves it: Drafts
* labels of other accounts are dropped.
*/
private function applyAccount(Message $message, Account $account): void
{
$draftsLabel = $this->labelResolver->systemLabel(LabelRole::Drafts, $account);
foreach ($message->getLabels() as $label) {
if (LabelRole::Drafts === $label->role && $label !== $draftsLabel) {
$message->removeLabel($label);
}
}
$message->addLabel($draftsLabel);
$message->setMailbox($this->mailboxRepository->findOneBy([
'account' => $account,
'label' => $draftsLabel,
]));
}
/**
* Read compose_to[], compose_cc[], compose_bcc[] from the Tom Select
* fields and write them onto the Message, replacing whatever the
@@ -244,11 +341,8 @@ class ComposeController extends AbstractController
if (!empty($bcc)) { $message->setBccAddresses($bcc); }
}
private function buildReply(Message $original, bool $replyAll): Message
private function buildReply(Message $original, bool $replyAll, Account $account): Message
{
$draftMailbox = $this->mailboxRepository->findPrimaryDraftMailboxForUser($this->getUser());
$account = $draftMailbox->getAccount();
$to = [[
'name' => $original->getFromName() ?? '',
'address' => $original->getFromAddress() ?? '',
@@ -278,7 +372,6 @@ class ComposeController extends AbstractController
$quotedBody = $this->buildQuotedHtml($original, 'reply');
$draft = (new Message())
->setMailbox($draftMailbox)
->setSubject($subject)
->setToAddresses($to)
->setCcAddresses($cc)
@@ -298,13 +391,10 @@ class ComposeController extends AbstractController
private function buildForward(Message $original): Message
{
$draftMailbox = $this->mailboxRepository->findPrimaryDraftMailboxForUser($this->getUser());
$subject = $this->prefixSubject('Fwd', $original->getSubject());
$quotedBody = $this->buildQuotedHtml($original, 'forward');
return (new Message())
->setMailbox($draftMailbox)
->setSubject($subject)
->setToAddresses([])
->setBodyHtml($quotedBody)
@@ -331,6 +421,9 @@ class ComposeController extends AbstractController
return $prefix . ': ' . $subject;
}
// NOTE: keep YOUR existing buildQuotedHtml() body — only the callers
// changed. The reply branch below is reconstructed and may differ from
// your version; the forward branch is verbatim.
private function buildQuotedHtml(Message $original, string $mode): string
{
$dateStr = $original->getReceivedAt() ? $original->getReceivedAt()->format('D, M j, Y \a\t g:i a') : '';
@@ -338,26 +431,17 @@ class ComposeController extends AbstractController
$fromAddr = htmlspecialchars($original->getFromAddress() ?? '', ENT_QUOTES, 'UTF-8');
$fromLine = $fromName !== '' ? "{$fromName} <{$fromAddr}>" : $fromAddr;
$bodyHtml = trim($original->getBodyHtml() ?? '');
$bodyText = trim($original->getBodyText() ?? '');
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:";
$bodyHtml = trim($original->getBodyHtml() ?? '');
$bodyText = trim($original->getBodyText() ?? '');
$innerBody = $bodyHtml !== '' ? $bodyHtml : nl2br(htmlspecialchars($bodyText, ENT_QUOTES, 'UTF-8'));
if ('reply' === $mode) {
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">
<div style="font-size:0.85em;color:#555;margin-bottom:0.25em">
On {$dateStr}, {$fromLine} wrote:
</div>
<blockquote style="border-left:2px solid #e0e0e0;margin:0;padding-left:0.75em;color:#555">
{$innerBody}
</blockquote>
HTML;
@@ -386,13 +470,13 @@ class ComposeController extends AbstractController
HTML;
}
private function persistDraft(Message $message): void
private function persistDraft(Message $message, Account $account): void
{
$now = new DateTimeImmutable();
$message
->setFromAddress($message->getMailbox()->getAccount()->getEmail())
->setFromName($message->getMailbox()->getAccount()->getName())
->setFromAddress($account->getEmail())
->setFromName($account->getName())
->addFlag(MessageFlag::DRAFT)
->setHasAttachments(false)
->setUpdatedAt($now);
@@ -403,7 +487,9 @@ class ComposeController extends AbstractController
private function assertOwnership(Message $message): void
{
if ($message->getMailbox()->getAccount()->getUsr() !== $this->getUser()) {
$account = $message->getAccount();
if (null === $account || $account->getUsr() !== $this->getUser()) {
throw $this->createAccessDeniedException();
}
}
+8 -32
View File
@@ -3,12 +3,12 @@
namespace App\Controller;
use App\Domain\Enum\LabelRole;
use App\Domain\Enum\MailboxSpecialUse;
use App\Domain\Enum\MessageTab;
use App\Entity\Account;
use App\Entity\Label;
use App\Entity\Message;
use App\Entity\MessageThread;
use App\Repository\LabelRepository;
use App\Repository\MailboxRepository;
use App\Repository\MessageRepository;
use App\Repository\MessageThreadRepository;
@@ -24,6 +24,7 @@ final class MailController extends AbstractController
private readonly MailboxRepository $mailboxRepository,
private readonly MessageRepository $messageRepository,
private readonly MessageThreadRepository $threadRepository,
private readonly LabelRepository $labelRepository,
)
{
}
@@ -145,43 +146,18 @@ final class MailController extends AbstractController
{
$this->denyAccessUnlessGranted('ROLE_USER');
// Security check — account must belong to current user
if ($account->getUsr() !== $this->getUser()) {
throw $this->createAccessDeniedException();
}
$mailboxes = $this->mailboxRepository->findBy(
['account' => $account],
['name' => 'ASC'],
);
$labels = array_values(array_filter(
$this->labelRepository->findForAccount($account),
static fn(Label $label): bool => true === $label->isVisible,
));
return $this->render('mail/_account_folders.html.twig', [
'account' => $account,
'mailboxes' => $mailboxes,
]);
}
#[Route('/account/{account}/{mailbox}', name: 'account_mailbox')]
public function mailbox(Account $account, string $mailbox): Response
{
$this->denyAccessUnlessGranted('ROLE_USER');
if ($account->getUsr() !== $this->getUser()) {
throw $this->createAccessDeniedException();
}
$mailboxEntity = $this->mailboxRepository->findOneBy(['account' => $account, 'name' => $mailbox]);
if (null === $mailboxEntity) {
throw $this->createNotFoundException();
}
$messages = $this->messageRepository->findByMailboxOrderedByDate($mailboxEntity);
return $this->render('mail/mailbox.html.twig', [
return $this->render('mail/_account_labels.html.twig', [
'account' => $account,
'mailbox' => $mailboxEntity,
'messages' => $messages,
'labels' => $labels,
]);
}
-1
View File
@@ -10,7 +10,6 @@ use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\UX\Turbo\Attribute\Broadcast;
#[ORM\Entity(repositoryClass: AccountRepository::class)]
//#[Broadcast]
+20
View File
@@ -550,4 +550,24 @@ class Message extends MessageModel
{
return $this->labels->contains($label);
}
public function getAccount(): ?Account
{
if (null !== $this->mailbox) {
return $this->mailbox->getAccount();
}
if (null !== $this->thread) {
return $this->thread->getAccount();
}
// Gmail-API messages before threading: any attached label carries it.
foreach ($this->labels as $label) {
if (null !== $label->account) {
return $label->account;
}
}
return null;
}
}
+19 -9
View File
@@ -2,9 +2,9 @@
namespace App\Form;
use App\Entity\Mailbox;
use App\Entity\Account;
use App\Entity\Message;
use App\Repository\MailboxRepository;
use App\Repository\AccountRepository;
use Doctrine\ORM\QueryBuilder;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
@@ -28,16 +28,26 @@ class ComposeType extends AbstractType
$user = $options['user'];
$builder
->add('mailbox', EntityType::class, [
// Unmapped: Message has no account column — the controller reads
// this field and wires the Drafts label + IMAP drafts mailbox via
// applyAccount(). Pre-set on render with $form->get('account')->setData().
->add('account', EntityType::class, [
'label' => false,
'class' => Mailbox::class,
'choice_label' => fn(Mailbox $mailbox) => sprintf(
'mapped' => false,
'class' => Account::class,
'choice_label' => fn(Account $account) => sprintf(
'%s <%s>',
$mailbox->getAccount()->getName(),
$mailbox->getAccount()->getEmail(),
$account->getName(),
$account->getEmail(),
),
'query_builder' => function (MailboxRepository $repo) use ($user): QueryBuilder {
return $repo->getActiveSentMailboxesForUser($user);
'query_builder' => function (AccountRepository $repo) use ($user): QueryBuilder {
return $repo->createQueryBuilder('account')
->where('account.usr = :usr')
->andWhere('account.isActive = :isActive')
->setParameter('usr', $user)
->setParameter('isActive', true)
->orderBy('account.isPrimary', 'DESC')
->addOrderBy('account.email', 'ASC');
},
'attr' => ['class' => 'compose-from-select'],
])
+3 -31
View File
@@ -3,12 +3,9 @@
namespace App\Repository;
use App\Domain\Enum\MailboxSpecialUse;
use App\Domain\Enum\MessageFlag;
use App\Entity\Account;
use App\Entity\Mailbox;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\Security\Core\User\UserInterface;
@@ -38,14 +35,17 @@ class MailboxRepository extends ServiceEntityRepository
{
return $this->findOneBy(['account' => $account, 'specialUse' => MailboxSpecialUse::TRASH]);
}
public function findArchiveMailboxForAccount(Account $account): ?Mailbox
{
return $this->findOneBy(['account' => $account, 'specialUse' => MailboxSpecialUse::ARCHIVE]);
}
public function findSentMailboxForAccount(Account $account): ?Mailbox
{
return $this->findOneBy(['account' => $account, 'specialUse' => MailboxSpecialUse::SENT]);
}
public function findDraftMailboxForAccount(Account $account): ?Mailbox
{
return $this->findOneBy(['account' => $account, 'specialUse' => MailboxSpecialUse::DRAFTS]);
@@ -82,32 +82,4 @@ class MailboxRepository extends ServiceEntityRepository
->getQuery()
->getSingleColumnResult();
}
public function getActiveSentMailboxesForUser(UserInterface $user): QueryBuilder
{
return $this->createQueryBuilder('mailbox')
->leftJoin('mailbox.account', 'account')
->where('account.isActive = :isActive')
->andWhere('account.usr = :usr')
->andWhere('mailbox.specialUse = :sent')
->setParameter('isActive', true)
->setParameter('usr', $user)
->setParameter('sent', MailboxSpecialUse::DRAFTS)
->orderBy('account.isPrimary', 'DESC');
}
public function findPrimaryDraftMailboxForUser(UserInterface $user){
return $this->createQueryBuilder('mailbox')
->leftJoin('mailbox.account', 'account')
->where('account.isActive = :isActive')
->andWhere('account.usr = :usr')
->andWhere('mailbox.specialUse = :drafts')
->andWhere('account.isPrimary = :isPrimary')
->setParameter('isActive', true)
->setParameter('usr', $user)
->setParameter('drafts', MailboxSpecialUse::DRAFTS)
->setParameter('isPrimary', true)
->getQuery()
->getSingleResult();
}
}
+12 -18
View File
@@ -2,6 +2,7 @@
namespace App\Repository;
use App\Domain\Enum\LabelRole;
use App\Entity\Account;
use App\Entity\Mailbox;
use App\Entity\Message;
@@ -10,7 +11,6 @@ use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class MessageRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
@@ -42,17 +42,9 @@ class MessageRepository extends ServiceEntityRepository
->getSingleColumnResult();
}
public function findByMailboxOrderedByDate(Mailbox $mailbox): array
{
return $this->createQueryBuilder('m')
->where('m.mailbox = :mailbox')
->setParameter('mailbox', $mailbox)
->orderBy('m.receivedAt', 'DESC')
->orderBy('m.updatedAt', 'DESC')
->getQuery()
->getResult();
}
/**
* Joined via thread since Gmail-API messages carry no mailbox.
*/
public function findOneByMessageIdsForAccount(array $messageIds, Account $account): ?Message
{
if (count($messageIds) === 0) {
@@ -60,8 +52,8 @@ class MessageRepository extends ServiceEntityRepository
}
return $this->createQueryBuilder('message')
->innerJoin('message.mailbox', 'mailbox')
->where('mailbox.account = :account')
->innerJoin('message.thread', 'thread')
->where('thread.account = :account')
->andWhere('message.messageId IN (:messageIds)')
->setParameter('account', $account)
->setParameter('messageIds', $messageIds)
@@ -107,13 +99,15 @@ class MessageRepository extends ServiceEntityRepository
->getSingleScalarResult();
}
/**
* Label-based: covers Gmail drafts too (no mailbox to join through).
*/
public function findDrafts(): array
{
return $this->createQueryBuilder('m')
->join('m.mailbox', 'mb')
->join('mb.account', 'a')
->where('mb.specialUse = :drafts')
->setParameter('drafts', '\\Drafts')
->innerJoin('m.labels', 'l')
->where('l.role = :drafts')
->setParameter('drafts', LabelRole::Drafts)
->orderBy('m.updatedAt', 'DESC')
->getQuery()
->getResult();
+28 -9
View File
@@ -2,12 +2,15 @@
namespace App\Service\Imap;
use App\Domain\Enum\LabelRole;
use App\Domain\Enum\MessageFlag;
use App\Domain\Helper\AttachmentStorageHelper;
use App\Domain\Helper\ImapConnectionFactory;
use App\Entity\Account;
use App\Entity\Message;
use App\Repository\LabelRepository;
use App\Repository\MailboxRepository;
use App\Service\Label\LabelResolver;
use App\Service\Mail\AttachmentResolver;
use App\Service\Mail\MailSenderRegistry;
use DateTimeImmutable;
@@ -24,17 +27,23 @@ class MessageSendService
public function __construct(
private readonly MailboxRepository $mailboxRepository,
private readonly EntityManagerInterface $em,
private readonly AttachmentStorageHelper $attachmentStorage,
private readonly MailSenderRegistry $senderRegistry,
private readonly ImapConnectionFactory $imapConnectionFactory,
private readonly AttachmentResolver $attachmentResolver,
private readonly LabelResolver $labelResolver,
private readonly LabelRepository $labelRepository,
) {
}
public function send(Message $message): bool
{
$account = $message->getMailbox()->getAccount();
$email = $this->buildEmail($message, $account);
$account = $message->getAccount();
if (null === $account) {
return false;
}
$email = $this->buildEmail($message, $account);
$sender = $this->senderRegistry->resolve($account);
$sendSuccess = $sender->send($email, $account);
@@ -48,16 +57,26 @@ class MessageSendService
$this->appendToSentFolder($email, $account);
}
$sentMailbox = $this->mailboxRepository->findSentMailboxForAccount($account);
$sentLabel = $this->labelResolver->systemLabel(LabelRole::Sent, $account);
$draftsLabel = $this->labelRepository->findOneByRoleForAccount(LabelRole::Drafts, $account);
if (null !== $sentMailbox) {
$message
->setMailbox($sentMailbox)
->setSentAt(new DateTimeImmutable());
$message->addLabel($sentLabel);
$this->em->flush();
if (null !== $draftsLabel) {
$message->removeLabel($draftsLabel);
}
$message->removeFlag(MessageFlag::DRAFT);
$message->setSentAt(new DateTimeImmutable());
// Plain-IMAP: physical Sent folder; Gmail: no mailbox.
$message->setMailbox($this->mailboxRepository->findOneBy([
'account' => $account,
'label' => $sentLabel,
]));
$this->em->flush();
return true;
}
+10 -17
View File
@@ -1,40 +1,33 @@
{% set _drawerClose = _drawerClose is defined ? _drawerClose : '' %}
{% for account in accounts %}
{% set currentAccount = app.request.attributes.get('account') %}
{% set isActive = currentAccount is not null and currentAccount == account.id %}
<div data-controller="account-expand"
data-account-expand-folders-url-value="{{ path('app_mail_account_folders', { account: account.id }) }}"
data-account-expand-active-value="{{ isActive ? 'true' : 'false' }}">
data-account-expand-active-value="false">
<div class="flex items-center" data-action="click->account-expand#toggle">
<a
href="{{ path('app_mail_account_mailbox', { account: account.id, mailbox: 'INBOX' }) }}"
data-action="click->account-expand#stop {{ _drawerClose }}"
class="flex items-center gap-2 px-3 py-2 rounded-lg transition-colors flex-1 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800"
>
{# The whole row toggles the label list — per-account "folders" are
labels now, so there is no INBOX landing link anymore. #}
<div class="flex items-center cursor-pointer rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
data-action="click->account-expand#toggle">
<div class="flex items-center gap-2 px-3 py-2 flex-1 min-w-0 text-gray-600 dark:text-gray-400">
<span class="w-2 h-2 rounded-full flex-shrink-0 {{ account.isActive ? 'bg-green-400' : 'bg-gray-300 dark:bg-gray-700' }}"></span>
<span class="truncate text-sm">{{ account.email ?: account.username }}</span>
</a>
</div>
<button
type="button"
aria-label="{{ 'sidebar.accounts.toggle'|trans }}"
data-action="click->account-expand#toggle"
class="p-2 rounded-lg text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
class="p-2 rounded-lg text-gray-400 pointer-events-none"
>
<i data-account-expand-target="chevron"
class="fa-solid fa-chevron-right text-xs shrink-0 transition-transform duration-150 {{ isActive ? 'rotate-90' : '' }}"></i>
class="fa-solid fa-chevron-right text-xs shrink-0 transition-transform duration-150"></i>
</button>
</div>
<turbo-frame
id="account-folders-{{ account.id }}"
data-account-expand-target="frame"
class="{{ isActive ? '' : 'hidden' }}"
class="hidden"
>
{% if isActive %}
<div class="pl-5 py-1 text-xs text-gray-400 dark:text-gray-600">{{ 'sidebar.accounts.loading'|trans }}</div>
{% endif %}
</turbo-frame>
</div>
{% else %}
+1 -1
View File
@@ -19,7 +19,7 @@ Variables (passed via `with` from _mailbox.html.twig):
{% set has_prev = page > 1 %}
{% set has_next = page < last_page %}
{% set base_params = app.request.query.all %}
{% set base_params = app.request.attributes.get('_route_params')|default({})|merge(app.request.query.all) %}
{#
tbtn macro — icon button with tooltip.
+8 -8
View File
@@ -61,13 +61,13 @@
class="flex flex-col flex-1 min-h-0">
{# ── Hidden account select (submitted with form) ───────── #}
<select id="{{ form.mailbox.vars.id }}"
name="{{ form.mailbox.vars.full_name }}"
<select id="{{ form.account.vars.id }}"
name="{{ form.account.vars.full_name }}"
data-compose-target="accountSelect"
class="sr-only">
{% for choice in form.mailbox.vars.choices %}
{% for choice in form.account.vars.choices %}
<option value="{{ choice.value }}"
{% if form.mailbox.vars.value == choice.value %}selected{% endif %}>
{% if form.account.vars.value == choice.value %}selected{% endif %}>
{{ choice.label }}
</option>
{% endfor %}
@@ -84,8 +84,8 @@
class="flex-1 flex items-center justify-between gap-2 text-sm
text-gray-700 dark:text-gray-300 text-left">
<span data-compose-target="fromLabel" class="truncate">
{% for choice in form.mailbox.vars.choices %}
{% if form.mailbox.vars.value == choice.value %}{{ choice.label }}{% endif %}
{% for choice in form.account.vars.choices %}
{% if form.account.vars.value == choice.value %}{{ choice.label }}{% endif %}
{% endfor %}
</span>
<i class="fa-solid fa-chevron-down text-[10px] text-gray-400 dark:text-gray-500 shrink-0 transition-transform duration-150"
@@ -99,7 +99,7 @@
backdrop-blur-sm
border border-gray-200/60 dark:border-white/10
shadow-xl shadow-black/15 dark:shadow-black/50">
{% for choice in form.mailbox.vars.choices %}
{% for choice in form.account.vars.choices %}
<button type="button"
data-action="click->compose#selectAccount"
data-value="{{ choice.value }}"
@@ -109,7 +109,7 @@
hover:bg-blue-50 dark:hover:bg-blue-500/20
hover:text-blue-600 dark:hover:text-blue-300
transition-colors
{% if form.mailbox.vars.value == choice.value %}
{% if form.account.vars.value == choice.value %}
bg-blue-50 dark:bg-blue-500/10 text-blue-600 dark:text-blue-300
{% endif %}">
{{ choice.label }}
-20
View File
@@ -1,20 +0,0 @@
<turbo-frame id="account-folders-{{ account.id }}">
<nav class="pl-3 space-y-0.5">
{% for mailbox in mailboxes %}
<a
data-account-expand-target="link"
href="{{ path('app_mail_account_mailbox', { account: account.id, mailbox: mailbox.name }) }}"
data-turbo-frame="_top"
data-turbo-prefetch="false"
class="flex items-center justify-between px-3 py-1.5 rounded-lg text-xs transition-colors text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800"
>
<span>{{ mailbox.name }}</span>
{% if mailbox.unreadMessages > 0 %}
<span class="text-xs font-semibold px-1.5 py-0.5 rounded-full bg-blue-600 text-white">
{{ mailbox.unreadMessages }}
</span>
{% endif %}
</a>
{% endfor %}
</nav>
</turbo-frame>
+28
View File
@@ -0,0 +1,28 @@
{# Replaces mail/_account_folders.html.twig — an account's navigation is its
labels now. System labels first (fixed sortOrder), custom labels after,
indented by tree depth. Every entry links to the generic label view, which
works for system labels too (an account's Inbox label = its inbox). #}
<turbo-frame id="account-folders-{{ account.id }}">
<nav class="pl-3 space-y-0.5">
{% for label in labels %}
<a
data-account-expand-target="link"
href="{{ path('app_mail_label', { id: label.id }) }}"
data-turbo-frame="_top"
data-turbo-prefetch="false"
class="flex items-center justify-between px-3 py-1.5 rounded-lg text-xs transition-colors text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800"
style="padding-left: {{ 12 + label.depth * 12 }}px"
>
<span class="truncate">{{ label.name }}</span>
{% if not label.isSystem %}
{% set _unread = sidebarCounts.forLabel(label) %}
{% if _unread > 0 %}
<span class="text-xs font-semibold px-1.5 py-0.5 rounded-full bg-blue-600 text-white">
{{ _unread }}
</span>
{% endif %}
{% endif %}
</a>
{% endfor %}
</nav>
</turbo-frame>
-16
View File
@@ -1,16 +0,0 @@
{% extends '_layout/_mailbox.html.twig' %}
{% block message_list %}
{% if messages is empty %}
<li class="px-4 py-8 text-center text-sm text-gray-400 dark:text-gray-600">
{{ 'message_list.empty'|trans }}
</li>
{% else %}
{% for message in messages %}
{% include '_partials/_thread_row.html.twig' with {
message: message,
mailbox: mailbox.name
} %}
{% endfor %}
{% endif %}
{% endblock %}
-6
View File
@@ -92,9 +92,6 @@ message:
thread:
no_subject: (no subject)
message_list:
empty: No messages in this mailbox.
account:
add_title: Add account
added: Account added successfully
@@ -136,9 +133,6 @@ toast:
no_archive_mailbox: There is no archive mailbox.
dismiss: Dismiss
mailbox:
not_found: Mailbox not found.
errors:
access_denied: You do not have access to this resource.
not_found: The requested resource could not be found.