Answer the inbox from the panel, with templates

A reply starts from a template filled in the language the person wrote in,
is sent by the application when it has a mailer that delivers or handed to
the replier's mail program otherwise, and is recorded on the submission.
The same is available through the API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 10:45:47 +02:00
parent 697d333ad4
commit 76d022053b
27 changed files with 1096 additions and 0 deletions

View File

@@ -7,13 +7,19 @@ namespace App\Filament\Resources\Applications\Pages;
use App\Filament\Resources\Applications\ApplicationResource;
use App\Filament\Resources\Applications\Tables\ApplicationsTable;
use App\Models\Application;
use App\Models\ApplicationReply;
use App\Models\ReplyTemplate;
use App\Support\Inbox\Replier;
use Filament\Actions\Action;
use Filament\Actions\DeleteAction;
use Filament\Actions\ForceDeleteAction;
use Filament\Actions\RestoreAction;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ViewRecord;
use Illuminate\Support\Facades\Auth;
class ViewApplication extends ViewRecord
{
@@ -24,10 +30,67 @@ class ViewApplication extends ViewRecord
return [
// Answering is the point of the record, so the reply is the first
// button rather than something to copy out of the page.
// Answering is the point of the record, so the reply is the first
// button: a template to start from, edited here, and sent — by
// this application when it can send mail, or handed to the
// person's own mail program with the text already written.
Action::make('reply')
->label(__('panel.applications.actions.reply'))
->icon('heroicon-o-envelope')
->color('primary')
->authorize('update')
->modalHeading(fn (Application $record) => __('panel.replies.heading', ['name' => $record->name]))
->modalDescription(fn () => Replier::canSendMail() ? __('panel.replies.sends') : __('panel.replies.hands_over'))
->modalSubmitActionLabel(fn () => Replier::canSendMail() ? __('panel.replies.send') : __('panel.replies.open_mail'))
->schema([
Select::make('template_id')
->label(__('panel.replies.fields.template'))
->options(fn (Application $record) => ReplyTemplate::query()->for($record->kind)->pluck('name', 'id')->all())
->placeholder(__('panel.replies.no_template'))
->live()
->afterStateUpdated(function (?string $state, callable $set, Application $record): void {
$template = $state ? ReplyTemplate::find($state) : null;
if ($template !== null) {
$text = $template->renderFor($record, Auth::user());
$set('subject', $text['subject']);
$set('body', $text['body']);
}
}),
TextInput::make('subject')
->label(__('panel.replies.fields.subject'))
->default(fn (Application $record) => __('panel.applications.reply_subject', ['subject' => $record->subject()], $record->locale))
->required()
->maxLength(255),
Textarea::make('body')
->label(__('panel.replies.fields.body'))
->helperText(fn (Application $record) => __('panel.replies.in_language', [
'language' => config("locales.supported.{$record->locale}.native", $record->locale),
]))
->required()
->rows(12),
])
->action(function (Application $record, array $data, $livewire): void {
$reply = Replier::reply($record, $data['subject'], $data['body'], Auth::user());
if ($reply->channel === ApplicationReply::MAILTO) {
$livewire->js('window.location.href = '.json_encode(Replier::mailto($record, $data['subject'], $data['body'])));
}
Notification::make()
->title($reply->channel === ApplicationReply::MAIL ? __('panel.replies.sent') : __('panel.replies.recorded'))
->success()
->send();
}),
// The way it always worked: an empty message in your own mail
// program, for an answer that fits no template.
Action::make('reply_elsewhere')
->label(__('panel.replies.elsewhere'))
->icon('heroicon-o-arrow-top-right-on-square')
->color('gray')
->url(fn (Application $record) => 'mailto:'.$record->email.'?subject='
.rawurlencode(__('panel.applications.reply_subject', [
'subject' => $record->subject(),