mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 01:54:10 +00:00
update
This commit is contained in:
1 parent
8b662f9b97
commit
1f836f1042
16 files changed
+263
-208
No files matched your search
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user