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(),

View File

@@ -5,7 +5,9 @@ declare(strict_types=1);
namespace App\Filament\Resources\Applications\Schemas;
use App\Models\Application;
use App\Models\ApplicationReply;
use Filament\Infolists\Components\KeyValueEntry;
use Filament\Infolists\Components\RepeatableEntry;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
@@ -69,6 +71,36 @@ class ApplicationInfolist
->visible(fn (Application $record) => filled($record->links)),
]),
// What was already said, so the next person to open this does not
// say it again.
Section::make(__('panel.replies.history'))
->visible(fn (Application $record) => $record->replies()->exists())
->schema([
RepeatableEntry::make('replies')
->hiddenLabel()
->schema([
TextEntry::make('subject')
->hiddenLabel()
->weight('bold'),
TextEntry::make('created_at')
->hiddenLabel()
->state(fn (ApplicationReply $record) => __('panel.replies.by', [
'name' => $record->user_name ?? '—',
'when' => $record->created_at?->isoFormat('LLL'),
]).' · '.__('panel.replies.channels.'.$record->channel))
->color('gray'),
TextEntry::make('body')
->hiddenLabel()
->prose()
->formatStateUsing(fn (string $state) => nl2br(e($state)))
->html()
->columnSpanFull(),
])
->columns(2),
]),
Section::make(__('panel.applications.sections.internal'))
->description(__('panel.applications.sections.internal_hint'))
->columns(4)

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\ReplyTemplates\Pages;
use App\Filament\Resources\ReplyTemplates\ReplyTemplateResource;
use Filament\Resources\Pages\CreateRecord;
class CreateReplyTemplate extends CreateRecord
{
protected static string $resource = ReplyTemplateResource::class;
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\ReplyTemplates\Pages;
use App\Filament\Resources\ReplyTemplates\ReplyTemplateResource;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
class EditReplyTemplate extends EditRecord
{
protected static string $resource = ReplyTemplateResource::class;
protected function getHeaderActions(): array
{
return [
DeleteAction::make(),
];
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\ReplyTemplates\Pages;
use App\Filament\Resources\ReplyTemplates\ReplyTemplateResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListReplyTemplates extends ListRecords
{
protected static string $resource = ReplyTemplateResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\ReplyTemplates;
use App\Enums\ApplicationKind;
use App\Filament\Resources\ReplyTemplates\Pages\CreateReplyTemplate;
use App\Filament\Resources\ReplyTemplates\Pages\EditReplyTemplate;
use App\Filament\Resources\ReplyTemplates\Pages\ListReplyTemplates;
use App\Filament\Support\TranslatableTabs;
use App\Models\ReplyTemplate;
use BackedEnum;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use UnitEnum;
/**
* The answers the inbox gives often enough to write once.
*/
class ReplyTemplateResource extends Resource
{
protected static ?string $model = ReplyTemplate::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedChatBubbleBottomCenterText;
protected static ?int $navigationSort = 5;
protected static ?string $recordTitleAttribute = 'name';
public static function getModelLabel(): string
{
return __('panel.replies.templates.label');
}
public static function getPluralModelLabel(): string
{
return __('panel.replies.templates.plural');
}
public static function getNavigationGroup(): string|UnitEnum|null
{
return __('panel.groups.collective');
}
public static function form(Schema $schema): Schema
{
return $schema->components([
Section::make()
->columns(2)
->schema([
TextInput::make('name')
->label(__('panel.replies.templates.fields.name'))
->helperText(__('panel.replies.templates.fields.name_hint'))
->required()
->maxLength(255),
Select::make('kind')
->label(__('panel.replies.templates.fields.kind'))
->options(ApplicationKind::options())
->placeholder(__('panel.replies.templates.any_kind')),
TextInput::make('position')
->label(__('panel.replies.templates.fields.position'))
->numeric()
->minValue(0)
->default(0),
]),
Section::make()
->description(__('panel.replies.templates.placeholders', ['list' => implode(', ', ReplyTemplate::PLACEHOLDERS)]))
->schema([
TranslatableTabs::make(fn (string $locale, bool $isDefault) => [
TextInput::make("subject.{$locale}")
->label(__('panel.replies.fields.subject'))
->required($isDefault)
->maxLength(255),
Textarea::make("body.{$locale}")
->label(__('panel.replies.fields.body'))
->required($isDefault)
->rows(10),
]),
]),
]);
}
public static function table(Table $table): Table
{
return $table
->defaultSort('position')
->columns([
TextColumn::make('name')
->label(__('panel.replies.templates.fields.name'))
->searchable()
->weight('bold'),
TextColumn::make('kind')
->label(__('panel.replies.templates.fields.kind'))
->badge()
->formatStateUsing(fn (?string $state) => $state ? ApplicationKind::from($state)->getLabel() : __('panel.replies.templates.any_kind'))
->placeholder(__('panel.replies.templates.any_kind')),
TextColumn::make('subject')
->label(__('panel.replies.fields.subject'))
->limit(60),
])
->recordActions([
EditAction::make(),
DeleteAction::make(),
]);
}
public static function getPages(): array
{
return [
'index' => ListReplyTemplates::route('/'),
'create' => CreateReplyTemplate::route('/create'),
'edit' => EditReplyTemplate::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\Application;
use App\Models\ApplicationReply;
use App\Models\ReplyTemplate;
use App\Support\Inbox\Replier;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
/**
* Answering the inbox through the API: what was already said, the templates
* to start from, and sending an answer — the panel's "Reply".
*/
class ApplicationReplyController extends Controller
{
public function index(Application $application): JsonResponse
{
$this->authorize('view', $application);
return response()->json([
'data' => $application->replies()->get()->map(fn (ApplicationReply $reply) => $this->present($reply))->values(),
]);
}
/**
* `subject` and `body`, or a `template_id` to fill them from — in the
* language the person wrote in. Sent when this installation can send
* mail; otherwise recorded, with a `mailto` link to send it by hand.
*/
public function store(Request $request, Application $application): JsonResponse
{
$this->authorize('update', $application);
$data = $request->validate([
'template_id' => ['sometimes', 'nullable', 'integer', 'exists:reply_templates,id'],
'subject' => ['required_without:template_id', 'nullable', 'string', 'max:255'],
'body' => ['required_without:template_id', 'nullable', 'string', 'max:20000'],
]);
$text = ['subject' => $data['subject'] ?? null, 'body' => $data['body'] ?? null];
if (filled($data['template_id'] ?? null)) {
$rendered = ReplyTemplate::query()->findOrFail($data['template_id'])->renderFor($application, $request->user());
$text = ['subject' => $text['subject'] ?: $rendered['subject'], 'body' => $text['body'] ?: $rendered['body']];
}
if (blank($text['subject']) || blank($text['body'])) {
throw ValidationException::withMessages(['template_id' => [__('panel.replies.empty_template')]]);
}
$reply = Replier::reply($application, $text['subject'], $text['body'], $request->user());
return response()->json([
'data' => [
...$this->present($reply),
'mailto' => $reply->channel === ApplicationReply::MAILTO
? Replier::mailto($application, $text['subject'], $text['body'])
: null,
],
], 201);
}
public function templates(Request $request): JsonResponse
{
$this->authorize('viewAny', ReplyTemplate::class);
return response()->json([
'data' => ReplyTemplate::query()->orderBy('position')->orderBy('name')->get()->map(fn (ReplyTemplate $template) => [
'id' => $template->id,
'name' => $template->name,
'kind' => $template->kind,
'subject' => (object) $template->getTranslations('subject'),
'body' => (object) $template->getTranslations('body'),
])->values(),
]);
}
/**
* @return array<string, mixed>
*/
protected function present(ApplicationReply $reply): array
{
return [
'id' => $reply->id,
'subject' => $reply->subject,
'body' => $reply->body,
'channel' => $reply->channel,
'author' => ['id' => $reply->user_id, 'name' => $reply->user_name],
'created_at' => $reply->created_at?->toIso8601String(),
];
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use App\Models\ApplicationReply;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Address;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
/**
* The answer, as plain text — what a person would have typed, not a
* newsletter.
*/
class ApplicationReplyMail extends Mailable
{
use Queueable, SerializesModels;
public function __construct(public ApplicationReply $reply) {}
public function envelope(): Envelope
{
$replyTo = config('mail.reply_to.address');
return new Envelope(
subject: $this->reply->subject,
replyTo: $replyTo ? [new Address((string) $replyTo, (string) config('mail.reply_to.name'))] : [],
);
}
public function content(): Content
{
return new Content(text: 'mail.application-reply');
}
}

View File

@@ -12,6 +12,7 @@ use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\Config;
@@ -59,6 +60,14 @@ class Application extends Model
/**
* @return BelongsTo<User, $this>
*/
/**
* @return HasMany<ApplicationReply, $this>
*/
public function replies(): HasMany
{
return $this->hasMany(ApplicationReply::class)->latest('id');
}
public function handler(): BelongsTo
{
return $this->belongsTo(User::class, 'handled_by');

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* An answer given to a submission, kept so the next person to open it knows
* what was already said.
*/
#[Fillable(['user_id', 'user_name', 'subject', 'body', 'channel'])]
class ApplicationReply extends Model
{
public const UPDATED_AT = null;
public const MAIL = 'mail';
public const MAILTO = 'mailto';
/**
* @return BelongsTo<Application, $this>
*/
public function application(): BelongsTo
{
return $this->belongsTo(Application::class);
}
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Models;
use App\Enums\ApplicationKind;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Spatie\Translatable\HasTranslations;
/**
* An answer the inbox gives often enough to write once: "thanks, we'll be in
* touch", "this call is filled", "here is how we work".
*
* Written in every language, because the answer goes out in the one the
* person wrote in. `{name}`, `{subject}` and `{sender}` are filled in.
*/
#[Fillable(['name', 'kind', 'subject', 'body', 'position'])]
class ReplyTemplate extends Model
{
use HasFactory, HasTranslations;
public const PLACEHOLDERS = ['{name}', '{subject}', '{sender}'];
/**
* @var array<int, string>
*/
public array $translatable = ['subject', 'body'];
protected function casts(): array
{
return ['position' => 'integer'];
}
/**
* @param Builder<$this> $query
* @return Builder<$this>
*/
public function scopeFor(Builder $query, ApplicationKind $kind): Builder
{
return $query
->where(fn (Builder $q) => $q->whereNull('kind')->orWhere('kind', $kind->value))
->orderBy('position')
->orderBy('name');
}
/**
* Subject and body for one submission, in its language.
*
* @return array{subject: string, body: string}
*/
public function renderFor(Application $application, ?User $sender = null): array
{
$values = [$application->name, $application->subject(), $sender?->name ?? (string) config('app.name')];
return [
'subject' => str_replace(self::PLACEHOLDERS, $values, (string) $this->getTranslation('subject', $application->locale)),
'body' => str_replace(self::PLACEHOLDERS, $values, (string) $this->getTranslation('body', $application->locale)),
];
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Policies;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
/**
* Templates belong to the inbox: whoever answers it may write them.
*/
class ReplyTemplatePolicy
{
public function viewAny(User $user): bool
{
return $user->can('applications.view_any');
}
public function view(User $user, Model $model): bool
{
return $user->can('applications.view_any');
}
public function create(User $user): bool
{
return $user->can('applications.update');
}
public function update(User $user, Model $model): bool
{
return $user->can('applications.update');
}
public function delete(User $user, Model $model): bool
{
return $user->can('applications.update');
}
public function deleteAny(User $user): bool
{
return $user->can('applications.update');
}
}

View File

@@ -14,6 +14,7 @@ use App\Models\MissingUrl;
use App\Models\Page;
use App\Models\Project;
use App\Models\Redirect;
use App\Models\ReplyTemplate;
use App\Models\Repository;
use App\Models\Service;
use App\Models\Talk;
@@ -31,6 +32,7 @@ use App\Policies\MissingUrlPolicy;
use App\Policies\PagePolicy;
use App\Policies\ProjectPolicy;
use App\Policies\RedirectPolicy;
use App\Policies\ReplyTemplatePolicy;
use App\Policies\RepositoryPolicy;
use App\Policies\RolePolicy;
use App\Policies\ServicePolicy;
@@ -66,6 +68,7 @@ class AuthServiceProvider extends ServiceProvider
Gate::policy(User::class, UserPolicy::class);
Gate::policy(Role::class, RolePolicy::class);
Gate::policy(Webhook::class, WebhookPolicy::class);
Gate::policy(ReplyTemplate::class, ReplyTemplatePolicy::class);
// The one place a permission is granted — spatie's own gate check is
// switched off (config/permission.php) so nothing answers before this.

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Support\Inbox;
use App\Enums\ApplicationStatus;
use App\Mail\ApplicationReplyMail;
use App\Models\Application;
use App\Models\ApplicationReply;
use App\Models\User;
use Illuminate\Support\Facades\Mail;
/**
* Answering a submission: sending it when this application can send mail,
* handing it to the person's own mail program when it cannot, and keeping a
* record either way.
*
* "Can send" is a mailer that actually delivers. `log` and `array` accept a
* message and drop it, so with either of them a reply "sent from here" would
* be one nobody received.
*/
final class Replier
{
public static function canSendMail(): bool
{
return ! in_array(config('mail.default'), ['log', 'array', null], true);
}
public static function reply(Application $application, string $subject, string $body, User $by): ApplicationReply
{
$send = self::canSendMail();
$reply = $application->replies()->create([
'user_id' => $by->id,
'user_name' => $by->name,
'subject' => $subject,
'body' => $body,
'channel' => $send ? ApplicationReply::MAIL : ApplicationReply::MAILTO,
]);
if ($send) {
Mail::to($application->email, $application->name)->send(new ApplicationReplyMail($reply));
}
// Answered is at least contacted; a decision already taken stays.
if ($application->status === ApplicationStatus::New) {
$application->markAs(ApplicationStatus::Contacted, $by);
}
return $reply;
}
/**
* The same answer, for the person's own mail program to send.
*/
public static function mailto(Application $application, string $subject, string $body): string
{
return 'mailto:'.$application->email.'?subject='.rawurlencode($subject).'&body='.rawurlencode($body);
}
}