480 lines
18 KiB
PHP
480 lines
18 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Http\Controllers\Api\Admin\MailCampaign;
|
||
|
||
use App\Http\Controllers\Api\Admin\MailCampaign\Concerns\HandlesCampaignBulkActions;
|
||
use App\Http\Controllers\Controller;
|
||
use App\Jobs\Mail\SendMailCampaignRecipient;
|
||
use App\Models\Customer;
|
||
use App\Models\Mail;
|
||
use App\Models\MailCampaign;
|
||
use App\Models\Template;
|
||
use App\Services\Email\MailCampaignRenderer;
|
||
use App\Support\AutoEmailGate;
|
||
use App\Support\MailAttachments;
|
||
use App\Support\MailPlaceholders;
|
||
use App\Support\MailSettings;
|
||
use Illuminate\Http\JsonResponse;
|
||
use Illuminate\Http\Request;
|
||
use Illuminate\Support\Collection;
|
||
use Illuminate\Support\Facades\Auth;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Illuminate\Support\Facades\Storage;
|
||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||
use Illuminate\Validation\ValidationException;
|
||
|
||
final class MailCampaignController extends Controller
|
||
{
|
||
use HandlesCampaignBulkActions;
|
||
|
||
private const MAX_EMAILS = 1000;
|
||
|
||
private const EMAIL_RULE = 'required|string|email:rfc|max:255';
|
||
|
||
private const EMAIL_MESSAGES = [
|
||
'emails.required' => 'Nhập ít nhất một email.',
|
||
'emails.max' => 'Tối đa ' . self::MAX_EMAILS . ' email mỗi lần.',
|
||
'emails.*.required' => 'Email không được để trống.',
|
||
'emails.*.email' => 'Email ":input" không hợp lệ.',
|
||
'emails.*.max' => 'Email ":input" dài quá 255 ký tự.',
|
||
];
|
||
|
||
public function index(Request $request): JsonResponse
|
||
{
|
||
$limit = min((int) $request->get('per_page', 20), 200);
|
||
|
||
$filters = $request->validate([
|
||
'search' => 'nullable|string|max:255',
|
||
'status' => 'nullable|array',
|
||
'status.*' => 'string|in:queued,sending,completed,failed,' . MailCampaign::STATUS_CANCELLED,
|
||
'source' => 'nullable|string|in:template,manual',
|
||
'date_from' => 'nullable|date',
|
||
'date_to' => 'nullable|date',
|
||
]);
|
||
$search = trim((string) ($filters['search'] ?? ''));
|
||
|
||
$paginator = MailCampaign::query()
|
||
->with(['template', 'creator'])
|
||
->when($search !== '', fn ($q) => $q->where(fn ($q) => $q
|
||
->where('name', 'like', "%{$search}%")
|
||
->orWhere('subject', 'like', "%{$search}%")
|
||
->orWhereHas('template', fn ($t) => $t->where('name', 'like', "%{$search}%"))))
|
||
->when($filters['status'] ?? null, fn ($q, $status) => $q->whereIn('status', $status))
|
||
->when(($filters['source'] ?? null) === 'template', fn ($q) => $q->whereNotNull('template_id'))
|
||
->when(($filters['source'] ?? null) === 'manual', fn ($q) => $q->whereNull('template_id'))
|
||
->when($filters['date_from'] ?? null, fn ($q, $from) => $q->whereDate('created_at', '>=', $from))
|
||
->when($filters['date_to'] ?? null, fn ($q, $to) => $q->whereDate('created_at', '<=', $to))
|
||
->orderByDesc('created_at')
|
||
->paginate($limit);
|
||
|
||
return response()->json([
|
||
'error' => false,
|
||
'data' => [
|
||
'items' => $paginator->getCollection()->map($this->mapCampaign(...))->all(),
|
||
'total' => $paginator->total(),
|
||
'per_page' => $paginator->perPage(),
|
||
'current_page' => $paginator->currentPage(),
|
||
'last_page' => $paginator->lastPage(),
|
||
],
|
||
]);
|
||
}
|
||
|
||
public function show(string $uuid): JsonResponse
|
||
{
|
||
$campaign = MailCampaign::with(['template', 'creator'])->where('uuid', $uuid)->first();
|
||
|
||
if (!$campaign) {
|
||
return response()->json(['error' => true, 'message' => 'Không tìm thấy chiến dịch'], 404);
|
||
}
|
||
|
||
$logs = Mail::with('customer')
|
||
->where('campaign_id', $campaign->id)
|
||
->orderByDesc('created_at')
|
||
->get(['id', 'to', 'customer_id', 'subject', 'status', 'created_at'])
|
||
->map(fn (Mail $log) => [
|
||
'id' => $log->id,
|
||
'to_email' => $log->to,
|
||
'to_name' => $log->customer->fullname ?? null,
|
||
'customer_id' => $log->customer_id,
|
||
'subject' => $log->subject,
|
||
'status' => $log->status,
|
||
'created_at' => $log->created_at,
|
||
]);
|
||
|
||
return response()->json([
|
||
'error' => false,
|
||
'data' => [
|
||
...$this->mapCampaign($campaign),
|
||
'cc' => $campaign->cc,
|
||
'bcc' => $campaign->bcc,
|
||
'attachments' => collect($campaign->attachments)->pluck('name')->all(),
|
||
'variables' => $campaign->variables ?? [],
|
||
'logs' => $logs,
|
||
],
|
||
]);
|
||
}
|
||
|
||
public function variableSuggestions(Request $request): JsonResponse
|
||
{
|
||
$data = $request->validate([
|
||
'template_id' => 'required|integer|exists:template,id',
|
||
]);
|
||
|
||
$suggestions = MailCampaign::query()
|
||
->where('template_id', $data['template_id'])
|
||
->whereNotNull('variables')
|
||
->where('sent_count', '>', 0)
|
||
->orderByDesc('created_at')
|
||
->limit(20)
|
||
->get(['uuid', 'name', 'variables', 'created_at'])
|
||
->filter(fn (MailCampaign $campaign) => !empty($campaign->variables))
|
||
->unique(fn (MailCampaign $campaign) => json_encode($campaign->variables))
|
||
->take(8)
|
||
->map(fn (MailCampaign $campaign) => [
|
||
'uuid' => $campaign->uuid,
|
||
'name' => $campaign->name,
|
||
'variables' => $campaign->variables,
|
||
'created_at' => $campaign->created_at,
|
||
])
|
||
->values();
|
||
|
||
return response()->json([
|
||
'error' => false,
|
||
'data' => $suggestions,
|
||
]);
|
||
}
|
||
|
||
public function sender(): JsonResponse
|
||
{
|
||
return response()->json([
|
||
'error' => false,
|
||
'data' => [
|
||
'from_address' => config('mail.from.address'),
|
||
'from_name' => config('mail.from.name'),
|
||
'smtp_configured' => MailSettings::isConfigured(),
|
||
'mail_enabled' => AutoEmailGate::mailEnabled(),
|
||
],
|
||
]);
|
||
}
|
||
|
||
public function preview(Request $request, MailCampaignRenderer $renderer): JsonResponse
|
||
{
|
||
$campaign = $this->draftCampaign($request->validate($this->contentRules()));
|
||
|
||
return response()->json([
|
||
'error' => false,
|
||
'data' => ['html' => $renderer->render($campaign, $renderer->sampleValues($campaign))['html']],
|
||
]);
|
||
}
|
||
|
||
public function previewCampaign(string $uuid, MailCampaignRenderer $renderer): JsonResponse
|
||
{
|
||
$campaign = MailCampaign::where('uuid', $uuid)->first();
|
||
|
||
if (!$campaign) {
|
||
return response()->json(['error' => true, 'message' => 'Không tìm thấy chiến dịch'], 404);
|
||
}
|
||
|
||
return response()->json([
|
||
'error' => false,
|
||
'data' => ['html' => $renderer->render($campaign, $renderer->sampleValues($campaign))['html']],
|
||
]);
|
||
}
|
||
|
||
public function showLog(string $uuid, int $id): JsonResponse
|
||
{
|
||
$log = Mail::query()
|
||
->whereKey($id)
|
||
->whereHas('campaign', fn ($q) => $q->where('uuid', $uuid))
|
||
->first();
|
||
|
||
if (!$log) {
|
||
return response()->json(['error' => true, 'message' => 'Không tìm thấy email'], 404);
|
||
}
|
||
|
||
return response()->json([
|
||
'error' => false,
|
||
'data' => [
|
||
'id' => $log->id,
|
||
'to_email' => $log->to,
|
||
'cc' => $log->cc,
|
||
'subject' => $log->subject,
|
||
'html' => $log->message,
|
||
'status' => $log->status,
|
||
'created_at' => $log->created_at,
|
||
],
|
||
]);
|
||
}
|
||
|
||
public function downloadAttachment(string $uuid, int $index): StreamedResponse|JsonResponse
|
||
{
|
||
$file = MailCampaign::where('uuid', $uuid)->first()?->attachments[$index] ?? null;
|
||
|
||
if (!$file || !Storage::disk($file['disk'])->exists($file['path'])) {
|
||
return response()->json(['error' => true, 'message' => 'Không tìm thấy tệp đính kèm'], 404);
|
||
}
|
||
|
||
return Storage::disk($file['disk'])->download($file['path'], $file['name']);
|
||
}
|
||
|
||
public function resolveEmails(Request $request): JsonResponse
|
||
{
|
||
$data = $request->validate([
|
||
'emails' => 'required|array|min:1|max:' . self::MAX_EMAILS,
|
||
'emails.*' => self::EMAIL_RULE,
|
||
], self::EMAIL_MESSAGES);
|
||
|
||
$emails = $this->normalizeEmails($data['emails']);
|
||
$customers = $this->customersByEmail($emails);
|
||
$registered = $customers->map(fn (Customer $customer) => mb_strtolower(trim($customer->email)))->all();
|
||
|
||
return response()->json([
|
||
'error' => false,
|
||
'data' => [
|
||
'customers' => $customers->values()->map(fn (Customer $customer) => [
|
||
'uuid' => $customer->uuid,
|
||
'fullname' => $customer->fullname,
|
||
'email' => $customer->email,
|
||
]),
|
||
'unregistered' => array_values(array_diff($emails, $registered)),
|
||
],
|
||
]);
|
||
}
|
||
|
||
public function store(Request $request): JsonResponse
|
||
{
|
||
$data = $request->validate([
|
||
...$this->contentRules(),
|
||
'recipient_type' => 'required|in:all,selected',
|
||
'uuids' => 'nullable|array',
|
||
'uuids.*' => 'string|exists:customers,uuid',
|
||
'emails' => 'nullable|array|max:' . self::MAX_EMAILS,
|
||
'emails.*' => self::EMAIL_RULE,
|
||
'name' => 'nullable|string|max:255',
|
||
'cc' => 'nullable|string|max:1000',
|
||
'bcc' => 'nullable|string|max:1000',
|
||
...MailAttachments::rules(),
|
||
], [
|
||
...self::EMAIL_MESSAGES,
|
||
'uuids.*.exists' => 'Khách hàng đã chọn không còn tồn tại.',
|
||
]);
|
||
|
||
$campaign = $this->draftCampaign($data);
|
||
|
||
$marketingOnly = $campaign->isMarketing();
|
||
$recipients = $data['recipient_type'] === 'all'
|
||
? $this->allCustomerRecipients($marketingOnly)
|
||
: $this->selectedRecipients($data['uuids'] ?? [], $data['emails'] ?? [], $marketingOnly);
|
||
$optedOut = $recipients['opted_out'];
|
||
$recipients = $recipients['recipients'];
|
||
|
||
if ($recipients === []) {
|
||
return response()->json([
|
||
'error' => true,
|
||
'message' => $optedOut > 0
|
||
? "Không có người nhận hợp lệ: {$optedOut} khách hàng đã tắt nhận email marketing."
|
||
: 'Không có người nhận hợp lệ để gửi.',
|
||
], 422);
|
||
}
|
||
|
||
$admin = Auth::guard('admin')->user();
|
||
|
||
$campaign->fill([
|
||
'name' => trim((string) ($data['name'] ?? '')) ?: $campaign->subject,
|
||
'cc' => $data['cc'] ?? null,
|
||
'bcc' => $data['bcc'] ?? null,
|
||
'recipient_type' => $data['recipient_type'],
|
||
'total_recipients' => count($recipients),
|
||
'sent_count' => 0,
|
||
'failed_count' => 0,
|
||
'status' => 'queued',
|
||
'created_by' => $admin->id ?? null,
|
||
])->save();
|
||
|
||
if (!empty($data['attachments'])) {
|
||
$campaign->update([
|
||
'attachments' => MailAttachments::store($data['attachments'], "mail-campaigns/{$campaign->uuid}"),
|
||
]);
|
||
}
|
||
|
||
foreach ($recipients as $email => $customerId) {
|
||
SendMailCampaignRecipient::dispatch($campaign->id, $customerId, $email);
|
||
}
|
||
|
||
return response()->json([
|
||
'error' => false,
|
||
'message' => "Đã đưa {$campaign->total_recipients} email vào hàng đợi gửi."
|
||
. ($optedOut > 0 ? " Bỏ qua {$optedOut} khách hàng đã tắt nhận email marketing." : ''),
|
||
'data' => $this->mapCampaign($campaign),
|
||
]);
|
||
}
|
||
|
||
private function contentRules(): array
|
||
{
|
||
return [
|
||
'template_id' => 'nullable|integer|exists:template,id',
|
||
'subject' => 'required_without:template_id|nullable|string|max:255',
|
||
'message' => 'required_without:template_id|nullable|string',
|
||
'variables' => 'nullable|array',
|
||
'variables.*' => 'nullable|string|max:5000',
|
||
];
|
||
}
|
||
|
||
private function draftCampaign(array $data): MailCampaign
|
||
{
|
||
$subject = trim((string) ($data['subject'] ?? ''));
|
||
$message = trim((string) ($data['message'] ?? ''));
|
||
$template = null;
|
||
|
||
if (!empty($data['template_id'])) {
|
||
$template = Template::query()->whereKey($data['template_id'])->where('status', 1)->first();
|
||
if (!$template || $template->format === 'json') {
|
||
throw ValidationException::withMessages(['template_id' => 'Template không tồn tại, đang bị tắt hoặc không phải email HTML.']);
|
||
}
|
||
|
||
$subject = $subject !== '' ? $subject : trim((string) $template->subject);
|
||
$message = $message !== '' ? $message : trim((string) $template->message);
|
||
}
|
||
|
||
if ($subject === '' || $message === '') {
|
||
throw ValidationException::withMessages(['subject' => 'Subject và content không được để trống.']);
|
||
}
|
||
|
||
$input = $data['variables'] ?? [];
|
||
$declared = $template?->variables() ?? [];
|
||
$variables = [];
|
||
foreach (MailPlaceholders::extract($subject, $message) as $key) {
|
||
$value = trim((string) ($input[$key] ?? ''));
|
||
if ($value === '' && !array_key_exists($key, MailPlaceholders::RECIPIENT_FIELDS)) {
|
||
$value = (string) ($declared[$key]['example'] ?? '');
|
||
}
|
||
if ($value !== '') {
|
||
$variables[$key] = $value;
|
||
}
|
||
}
|
||
|
||
return new MailCampaign([
|
||
'template_id' => $template?->id,
|
||
'subject' => $subject,
|
||
'message' => $message,
|
||
'variables' => $variables ?: null,
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* @return array{recipients: array<string, int>, opted_out: int}
|
||
*/
|
||
private function allCustomerRecipients(bool $marketingOnly): array
|
||
{
|
||
$customers = Customer::query()
|
||
->whereNotNull('email')
|
||
->where('email', '!=', '')
|
||
->get(['id', 'email', 'email_marketing']);
|
||
|
||
[$accepted, $optedOut] = $customers->partition(
|
||
fn (Customer $customer) => !$marketingOnly || $customer->acceptsMarketingEmail()
|
||
);
|
||
|
||
return [
|
||
'recipients' => $accepted->pluck('id', 'email')->all(),
|
||
'opted_out' => $optedOut->count(),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* @param array<int, string> $uuids
|
||
* @param array<int, string> $emails
|
||
* @return array{recipients: array<string, int|null>, opted_out: int}
|
||
*/
|
||
private function selectedRecipients(array $uuids, array $emails, bool $marketingOnly): array
|
||
{
|
||
$emails = $this->normalizeEmails($emails);
|
||
|
||
$customers = Customer::query()
|
||
->whereIn('uuid', $uuids)
|
||
->whereNotNull('email')
|
||
->get(['id', 'email', 'uuid', 'email_marketing'])
|
||
->merge($this->customersByEmail($emails));
|
||
|
||
$recipients = [];
|
||
$optedOutEmails = [];
|
||
foreach ($customers as $customer) {
|
||
$email = mb_strtolower(trim($customer->email));
|
||
if (!in_array($customer->uuid, $uuids, true) && !in_array($email, $emails, true)) {
|
||
continue;
|
||
}
|
||
if ($marketingOnly && !$customer->acceptsMarketingEmail()) {
|
||
$optedOutEmails[$email] = true;
|
||
continue;
|
||
}
|
||
$recipients[$email] = $customer->id;
|
||
}
|
||
foreach ($emails as $email) {
|
||
if (!isset($optedOutEmails[$email])) {
|
||
$recipients[$email] ??= null;
|
||
}
|
||
}
|
||
|
||
return [
|
||
'recipients' => array_filter($recipients, fn ($id, $email) => filter_var($email, FILTER_VALIDATE_EMAIL), ARRAY_FILTER_USE_BOTH),
|
||
'opted_out' => count($optedOutEmails),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* @param array<int, string> $emails
|
||
* @return array<int, string>
|
||
*/
|
||
private function normalizeEmails(array $emails): array
|
||
{
|
||
return array_values(array_unique(array_map(fn ($email) => mb_strtolower(trim((string) $email)), $emails)));
|
||
}
|
||
|
||
/**
|
||
* @param array<int, string> $emails
|
||
* @return Collection<int, Customer>
|
||
*/
|
||
private function customersByEmail(array $emails): Collection
|
||
{
|
||
if ($emails === []) {
|
||
return new Collection();
|
||
}
|
||
|
||
return Customer::query()
|
||
->whereIn(DB::raw('LOWER(TRIM(email))'), $emails)
|
||
->get()
|
||
->unique(fn (Customer $customer) => mb_strtolower(trim($customer->email)))
|
||
->toBase();
|
||
}
|
||
|
||
private function displaySubject(MailCampaign $campaign): string
|
||
{
|
||
$values = array_filter($campaign->variables ?? [], fn ($value) => $value !== null && $value !== '');
|
||
$subject = MailPlaceholders::fill((string) $campaign->subject, $values, escapeHtml: false);
|
||
|
||
return trim(preg_replace('/[\s|\-–,:]+$/u', '', trim($subject)) ?? $subject);
|
||
}
|
||
|
||
private function mapCampaign(MailCampaign $campaign): array
|
||
{
|
||
return [
|
||
'id' => $campaign->id,
|
||
'uuid' => $campaign->uuid,
|
||
'name' => $campaign->name,
|
||
'subject' => $this->displaySubject($campaign),
|
||
'template_id' => $campaign->template_id,
|
||
'template_name' => $campaign->template->name ?? null,
|
||
'total_recipients' => $campaign->total_recipients,
|
||
'sent_count' => $campaign->sent_count,
|
||
'failed_count' => $campaign->failed_count,
|
||
'status' => $campaign->status,
|
||
'recipient_type' => $campaign->recipient_type,
|
||
'created_by_name' => $campaign->creator->fullname ?? $campaign->creator->username ?? null,
|
||
'completed_at' => $campaign->completed_at,
|
||
'created_at' => $campaign->created_at,
|
||
];
|
||
}
|
||
}
|