redo message actions

This commit is contained in:
Paul Lützner committed 2026-07-09 16:28:13 +02:00
1 parent 1ff18f0c82
commit ae9bb8b757
13 files changed
+429 -43

No files matched your search

+48 -12
View File
@@ -1,7 +1,21 @@
import { Controller } from "@hotwired/stimulus";
/**
* Handles per-row status actions in the message list.
*
* Values:
* id — entity ID (thread or message)
* type — "thread" (default) | "message"
*
* Routes used:
* thread → /thread/{id}/status/{action}
* message → /message/{id}/status/{action}
*/
export default class extends Controller {
static values = { id: Number };
static values = {
id: Number,
type: { type: String, default: "thread" },
};
stop(event) {
event.stopPropagation();
@@ -10,9 +24,6 @@ export default class extends Controller {
toggleSelect(event) {
event.stopPropagation();
// Notify the toolbar controller so it can sync the master checkbox
// and show/hide the action bar. We bubble up through the DOM until
// we find the element that hosts list-toolbar, then dispatch there.
const toolbar = document.querySelector("[data-controller~='list-toolbar']");
if (toolbar) {
@@ -23,13 +34,15 @@ export default class extends Controller {
}
async toggleStar(event) {
const { starUrl } = event.params;
event.stopPropagation();
await this.#post(this.#url("star"));
await this.#post(starUrl);
}
async archive(event) {
const { archiveUrl } = event.params;
event.stopPropagation();
await this.#post(this.#url("archive"));
await this.#post(archiveUrl);
}
async delete(event) {
@@ -39,20 +52,40 @@ export default class extends Controller {
async snooze(event, until = null) {
event.stopPropagation();
await this.#post(this.#url("snooze"), { until });
// Snooze is always thread-level.
const url = this.typeValue === "thread"
? this.#url("snooze")
: `/thread/${this.idValue}/status/snooze`;
await this.#post(url, { until });
}
async markRead(event) {
event.stopPropagation();
const { read } = event.params;
await this.#post(this.#url("read"), { read });
}
// ---------------------------------------------------------------- private
async trash(event) {
event.stopPropagation();
// Separate from delete: moves to trash rather than permanently removing.
// For thread rows this calls the delete route (which internally decides
// trash vs. permanent based on current mailbox).
// For message rows it calls the dedicated trash route.
const url = this.typeValue === "message"
? this.#url("trash")
: this.#url("delete");
await this.#post(url);
}
// ── Private ───────────────────────────────────────────────────────────
#url(action) {
return `/thread/${this.idValue}/status/${action}`;
const base = this.typeValue === "message" ? "message" : "thread";
return `/${base}/${this.idValue}/status/${action}`;
}
async #post(url, body = {}) {
@@ -66,11 +99,14 @@ export default class extends Controller {
});
if (!response.ok) {
console.error(`Thread status update failed: ${url}`, response.status);
console.error(`[message-row] status update failed: ${url}`, response.status);
return;
}
const html = await response.text();
Turbo.renderStreamMessage(html);
if (html.trim() !== "") {
Turbo.renderStreamMessage(html);
}
}
}
+1 -3
View File
@@ -23,6 +23,4 @@ framework:
Symfony\Component\Notifier\Message\SmsMessage: async
App\Message\SyncMailboxMessage: async
App\Message\SendMessageMessage: async
# Route your messages to the transports
# 'App\Message\YourMessage': async
App\Message\ApplyImapFlagsMessage: async
+97 -23
View File
@@ -2,55 +2,85 @@
namespace App\Controller;
use App\Domain\Enum\MailboxSpecialUse;
use App\Domain\Enum\MessageFlag;
use App\Entity\Message;
use App\Entity\MessageThread;
use App\Entity\User;
use App\Message\ApplyImapFlagsMessage;
use App\Repository\MailboxRepository;
use App\Repository\MessageRepository;
use App\Repository\MessageThreadRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use function Sodium\add;
#[Route('/thread/{thread}/status', name: 'app_thread_status_')]
#[Route('/status/{type}/{id}', name: 'app_status_')]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class ThreadStatusController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly MessageRepository $messageRepository,
) {}
private readonly EntityManagerInterface $em,
private readonly MessageRepository $messageRepository,
private readonly MessageThreadRepository $threadRepository,
private readonly MessageBusInterface $bus,
private readonly MailboxRepository $mailboxRepository,
)
{
}
#[Route('/star', name: 'star', methods: ['POST'])]
public function star(MessageThread $thread): Response
public function star(string $type, int $id): Response
{
$this->assertOwnership($thread);
$messages = $this->resolveMessages($type, $id);
$thread->setStarredAt(
$thread->getStarredAt() === null ? new DateTimeImmutable() : null
);
$message = $messages[0];
$action = 'flag';
if ($message->getStarredAt() === null) {
$message
->addFlag(MessageFlag::FLAGGED)
->setStarredAt(new DateTimeImmutable());
$message->getThread()->setStarredAt(new DateTimeImmutable());
} else {
$action = 'unflag';
$message->removeFlag(MessageFlag::FLAGGED)
->setStarredAt(null);
$message->getThread()->setStarredAt(null);
}
$this->em->flush();
$this->dispatchImapAction($messages, $action);
return $this->renderTurboStream('thread/status/_star.stream.html.twig', [
'thread' => $thread,
$type => 'message' === $type ? $message : $message->getThread(),
]);
}
#[Route('/archive', name: 'archive', methods: ['POST'])]
public function archive(MessageThread $thread): Response
public function archive(string $type, int $id): Response
{
$this->assertOwnership($thread);
$messages = $this->resolveMessages($type, $id);
$thread->setArchivedAt(
$thread->isArchived() ? null : new DateTimeImmutable()
);
$archiveMailbox = $this->mailboxRepository->findArchiveMailboxForAccount($messages[0]->getMailbox()->getAccount());
if(null === $archiveMailbox){
return $this->renderTurboStream('_toasts/generic.html.twig', ['type' => 'error', 'message' => 'toast.error.no_archive_mailbox']);
}
// $thread->setArchivedAt(
// $thread->isArchived() ? null : new DateTimeImmutable()
// );
$this->em->flush();
//move to archive
return $this->renderTurboStream('thread/status/_archive.stream.html.twig', [
'thread' => $thread,
$type => 'message' === $type ? $messages[0] : $messages[0]->getThread(),
]);
}
@@ -115,20 +145,63 @@ class ThreadStatusController extends AbstractController
$this->em->flush();
return $this->renderTurboStream('thread/status/_read.stream.html.twig', [
'thread' => $thread,
'thread' => $thread,
'markAsRead' => $markAsRead,
]);
}
// ---------------------------------------------------------------- helpers
private function assertOwnership(MessageThread $thread): void
/**
* @return iterable<Message>
*/
private function resolveMessages(string $type, int $id): iterable
{
/** @var User $user */
$user = $this->getUser();
$messages = [];
if ($thread->getAccount()->getUsr() !== $user) {
throw $this->createAccessDeniedException('This thread does not belong to you.');
if ('message' === $type) {
$messages = [$this->messageRepository->find($id)];
}
if ('thread' === $type) {
$messages = $this->threadRepository->find($id)->getMessages();
}
$this->assertOwnership($messages);
return $messages;
}
/**
* @param iterable<Message> $messages
*/
private function dispatchImapAction(iterable $messages, string $action): void
{
$ids = [];
foreach ($messages as $message) {
if(null === $message->getImapUid()){
continue;
}
$ids[] = $message->getId();
}
if (count($ids) === 0) {
return;
}
$this->bus->dispatch(new ApplyImapFlagsMessage($ids, $action));
}
/**
* @param iterable<Message> $messages
*/
private function assertOwnership(iterable $messages): void
{
foreach ($messages as $message) {
if ($message->getMailbox()->getAccount()->getUsr() !== $this->getUser()) {
throw $this->createAccessDeniedException();
}
}
}
@@ -138,4 +211,5 @@ class ThreadStatusController extends AbstractController
headers: ['Content-Type' => 'text/vnd.turbo-stream.html'],
));
}
}
+1
View File
@@ -9,4 +9,5 @@ enum MailboxSpecialUse: string
case TRASH = '\\Trash';
case DRAFTS = '\\Drafts';
case JUNK = '\\Junk';
case ARCHIVE = '\\Archive';
}
+32
View File
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Message;
/**
* Dispatched after a status change (star, read, archive, delete, move) so that
* the change is mirrored on the IMAP server asynchronously.
*
* action values:
* 'flag' — set \Flagged (star)
* 'unflag' — clear \Flagged (unstar)
* 'seen' — set \Seen (mark read)
* 'unseen' — clear \Seen (mark unread)
* 'archive' — move message to the account's \Archive mailbox
* 'trash' — move message to the account's \Trash mailbox
* 'delete' — permanently delete (used when message is already in Trash)
*
* @param int[] $messageIds App Message entity IDs to act on
* @param string $action One of the values listed above
*/
readonly class ApplyImapFlagsMessage
{
/**
* @param int[] $messageIds
*/
public function __construct(
public array $messageIds,
public string $action,
) {}
}
@@ -0,0 +1,229 @@
<?php
declare(strict_types=1);
namespace App\MessageHandler;
use App\Domain\Helper\ImapConnectionFactory;
use App\Entity\Account;
use App\Entity\Mailbox;
use App\Entity\Message;
use App\Message\ApplyImapFlagsMessage;
use App\Repository\MailboxRepository;
use App\Repository\MessageRepository;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Webklex\PHPIMAP\Client;
#[AsMessageHandler]
final class ApplyImapFlagsHandler
{
public function __construct(
private readonly MessageRepository $messageRepository,
private readonly MailboxRepository $mailboxRepository,
private readonly LoggerInterface $logger,
) {}
public function __invoke(ApplyImapFlagsMessage $message): void
{
$messages = $this->messageRepository->findBy(['id' => $message->messageIds]);
if (count($messages) === 0) {
$this->logger->warning('ApplyImapFlagsHandler: no messages found', [
'ids' => $message->messageIds,
'action' => $message->action,
]);
return;
}
// Group messages by account so we open one IMAP connection per account,
// then sub-group by mailbox within each account.
/** @var array<int, array<int, Message[]>> $byAccount accountId → mailboxId → Message[] */
$byAccount = [];
foreach ($messages as $msg) {
$accountId = $msg->getMailbox()->getAccount()->getId();
$mailboxId = $msg->getMailbox()->getId();
$byAccount[$accountId][$mailboxId][] = $msg;
}
foreach ($byAccount as $accountId => $byMailbox) {
// All messages in the group share the same account object.
$firstMessage = array_values($byMailbox)[0][0];
$account = $firstMessage->getMailbox()->getAccount();
try {
$client = ImapConnectionFactory::connect($account);
$this->processAccount($client, $account, $byMailbox, $message->action);
$client->disconnect();
} catch (\Throwable $e) {
$this->logger->error('ApplyImapFlagsHandler: IMAP error', [
'accountId' => $accountId,
'action' => $message->action,
'error' => $e->getMessage(),
]);
}
}
}
/**
* @param array<int, Message[]> $byMailbox mailboxId → Message[]
*/
private function processAccount(
Client $client,
Account $account,
array $byMailbox,
string $action,
): void {
// Resolve destination mailbox once per account for move actions.
$destinationPath = null;
if ($action === 'archive' || $action === 'trash') {
$destinationPath = $this->resolveDestinationPath($client, $account, $action);
if ($destinationPath === null) {
$this->logger->warning('ApplyImapFlagsHandler: destination mailbox not found', [
'accountId' => $account->getId(),
'action' => $action,
]);
return;
}
}
foreach ($byMailbox as $mailboxId => $messages) {
$mailbox = $this->mailboxRepository->find($mailboxId);
if ($mailbox === null) {
continue;
}
try {
$this->processMailbox($client, $mailbox, $messages, $action, $destinationPath);
} catch (\Throwable $e) {
$this->logger->error('ApplyImapFlagsHandler: mailbox error', [
'mailboxId' => $mailboxId,
'action' => $action,
'error' => $e->getMessage(),
]);
}
}
}
/**
* @param Message[] $messages
*/
private function processMailbox(
Client $client,
Mailbox $mailbox,
array $messages,
string $action,
?string $destinationPath,
): void {
$folder = $client->getFolder($mailbox->getName());
if ($folder === null) {
$this->logger->warning('ApplyImapFlagsHandler: folder not found', [
'mailbox' => $mailbox->getName(),
]);
return;
}
foreach ($messages as $msg) {
// Messages synced from IMAP have an imapUid; locally-composed
// messages that were never appended to IMAP do not.
if ($msg->getImapUid() === null) {
continue;
}
try {
$this->applyToMessage($folder, $msg->getImapUid(), $action, $destinationPath);
} catch (\Throwable $e) {
$this->logger->error('ApplyImapFlagsHandler: per-message error', [
'messageId' => $msg->getId(),
'uid' => $msg->getImapUid(),
'action' => $action,
'error' => $e->getMessage(),
]);
}
}
}
private function applyToMessage(
\Webklex\PHPIMAP\Folder $folder,
int $uid,
string $action,
?string $destinationPath,
): void {
// Fetch the live message from IMAP by UID so we get the Webklex
// Message object with its write methods.
$imapMessage = $folder->messages()
->whereUid($uid)
->get()
->first();
if ($imapMessage === null) {
$this->logger->warning('ApplyImapFlagsHandler: UID not found on server', [
'uid' => $uid,
'folder' => $folder->path,
]);
return;
}
match ($action) {
'flag' => $imapMessage->setFlag('Flagged'),
'unflag' => $imapMessage->unsetFlag('Flagged'),
'seen' => $imapMessage->setFlag('Seen'),
'unseen' => $imapMessage->unsetFlag('Seen'),
'archive' => $imapMessage->move($destinationPath),
'trash' => $imapMessage->move($destinationPath),
'delete' => $imapMessage->delete(expunge: true),
default => $this->logger->warning('ApplyImapFlagsHandler: unknown action', ['action' => $action]),
};
}
/**
* Resolve the IMAP folder path for the destination mailbox.
* For archive: prefers \Archive, falls back to \Sent (unusual but safe).
* For trash: uses \Trash.
*/
private function resolveDestinationPath(Client $client, Account $account, string $action): ?string
{
$specialUse = ($action === 'archive') ? '\\Archive' : '\\Trash';
// Look up in our DB first — fastest path.
$mailbox = $this->mailboxRepository->findOneBy([
'account' => $account,
'specialUse' => $specialUse,
]);
if ($mailbox !== null) {
return $mailbox->getFullPath();
}
// Fall back to scanning server folders by name.
$nameMap = [
'\\Trash' => ['Trash', 'Deleted', 'Deleted Items', 'Deleted Messages'],
'\\Archive' => ['Archive', 'Archives'],
];
$candidates = $nameMap[$specialUse] ?? [];
foreach ($candidates as $candidate) {
try {
$folder = $client->getFolder($candidate);
if ($folder !== null) {
return $folder->path;
}
} catch (\Throwable) {
// Not found — try next.
}
}
return null;
}
}
+4 -1
View File
@@ -33,7 +33,10 @@ class MailboxRepository extends ServiceEntityRepository
return $indexed;
}
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]);
@@ -2,6 +2,7 @@
{% set isThread = item.messages is defined %}
{% if isThread %}
{% set type = 'thread' %}
{% set unread = item.unreadCount > 0 %}
{% set subject = item.subject ?: ('thread_row.no_subject'|trans) %}
{% set starred = item.starredAt is not null %}
@@ -15,6 +16,7 @@
{% set rowId = item.id %}
{% else %}
{% set type = 'message' %}
{% set unread = item.seenAt is null %}
{% set subject = item.subject ?: ('thread_row.no_subject'|trans) %}
{% set starred = item.starredAt is not null %}
@@ -68,6 +70,7 @@
<button
type="button"
data-action="click->message-row#toggleStar"
data-message-row-star-url-param="{{ path('app_status_star', {'type': type, 'id': rowId}) }}"
class="z-10 pointer-events-auto cursor-pointer flex-shrink-0 text-gray-300 dark:text-gray-600 hover:text-amber-400 transition-colors {{ starred ? '!text-amber-400' : '' }}"
aria-label="{{ starred ? 'thread_row.unstar'|trans : 'thread_row.star'|trans }}"
>
@@ -123,6 +126,7 @@
<div class="absolute inset-0 flex items-center justify-end invisible group-hover:visible pointer-events-auto gap-1">
<button type="button" data-action="click->message-row#archive"
data-message-row-archive-url-param="{{ path('app_status_archive', {'type': type, 'id': rowId}) }}"
aria-label="{{ 'thread_row.actions.archive'|trans }}"
title="{{ 'thread_row.actions.archive'|trans }}"
class="z-10 p-1.5 cursor-pointer rounded-lg text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors">
+1 -1
View File
@@ -21,8 +21,8 @@
{# ── flex row for content only ── #}
<div class="flex items-center gap-3 px-5 py-3.5 text-base font-medium">
{{ message }}
<span data-toast-target="countdown"></span>s
{% if extra.undo_url is defined %}
<span data-toast-target="countdown"></span>s
<button
data-controller="undo-send"
data-undo-send-url-value="{{ extra.undo_url }}"
+7
View File
@@ -0,0 +1,7 @@
<turbo-stream action="append" target="toast-region">
<template>
{% include '_partials/_toast.html.twig' with {
message: message|trans
} %}
</template>
</turbo-stream>
@@ -1,2 +1,2 @@
<turbo-stream action="remove" target="thread_{{ thread.id }}">
<turbo-stream action="remove" target="thread_{{ message is defined ? message.id : thread.id }}">
</turbo-stream>
@@ -1,5 +1,5 @@
<turbo-stream action="replace" target="thread_{{ thread.id }}">
<turbo-stream action="replace" target="thread_{{ message is defined ? message.id : thread.id }}">
<template>
{% include '_partials/_thread_row.html.twig' with { thread: thread } %}
{% include '_partials/_thread_row.html.twig' %}
</template>
</turbo-stream>
+2
View File
@@ -120,6 +120,8 @@ form:
cancel: Cancel
toast:
error:
no_archive_mailbox: There is no archive mailbox.
dismiss: Dismiss
mailbox: