This commit is contained in:
baodev0
2026-10-05 09:30:24 +07:00
parent a1aeb88061
commit 1544629a1f
53 changed files with 1292 additions and 963 deletions
Vendored
BIN
View File
Binary file not shown.
File diff suppressed because one or more lines are too long
@@ -2,12 +2,17 @@
namespace App\Http\Controllers\Api\Admin\EmailTemplate;
use App\Support\EmailRecipients;
use App\Support\MailGlobals;
use App\Support\MailPlaceholders;
use App\Exceptions\InvalidTemplateVariableException;
use App\Http\Controllers\Controller;
use App\Mail\GenericTemplateMail;
use App\Models\Setting;
use App\Models\Template;
use App\Services\Email\EmailTemplateRenderer;
use App\Services\Email\TemplateGuard;
use App\Support\EmailScenarioRegistry;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -17,6 +22,13 @@ use Illuminate\Validation\Rule;
class EmailTemplateController extends Controller
{
private const VARIABLE_RULES = [
'variables' => 'sometimes|array',
'variables.*.key' => ['required', 'string', 'max:100', 'regex:/^[a-zA-Z_][a-zA-Z0-9_]*$/'],
'variables.*.label' => 'nullable|string|max:255',
'variables.*.example' => 'nullable|string|max:5000',
];
private ?array $scenarioLabelOverridesCache = null;
public function index(Request $request): JsonResponse
@@ -40,32 +52,39 @@ class EmailTemplateController extends Controller
return response()->json(['error' => false, 'data' => $this->format($template)]);
}
/**
* Danh sách scenario để render dropdown động ở frontend — gồm cả scenario
* đã khai báo sẵn (Template::SCENARIOS) lẫn scenario tự tạo (đã dùng
* trong bảng template nhưng chưa có label cứng trong code).
*/
public function scenarios(): JsonResponse
{
$used = Template::whereNotNull('scenario')->distinct()->pluck('scenario')->all();
$labels = $this->scenarioLabelOverrides();
$templates = Template::query()->get(['key', 'scenario', 'status']);
$labels = $this->scenarioLabelOverrides();
$known = collect(Template::SCENARIOS)->map(fn ($label, $key) => [
'key' => $key,
'label' => $labels[$key] ?? $label,
'has_template' => in_array($key, $used, true),
]);
$registered = collect(EmailScenarioRegistry::SCENARIOS)->map(function ($definition, $key) use ($templates, $labels) {
$matches = $templates->filter(fn ($t) => $t->key === $key || $t->scenario === $key);
$custom = collect($used)
->reject(fn ($key) => array_key_exists($key, Template::SCENARIOS))
->map(fn ($key) => ['key' => $key, 'label' => $labels[$key] ?? $key, 'has_template' => true]);
return [
...EmailScenarioRegistry::describe($key),
'key' => $key,
'label' => $labels[$key] ?? $definition['label'],
'has_template' => $matches->isNotEmpty(),
'is_active' => $matches->contains(fn ($t) => (int) $t->status === 1),
];
});
return response()->json(['error' => false, 'data' => $known->concat($custom)->values()]);
$custom = $templates->pluck('scenario')->filter()->unique()
->reject(fn ($key) => EmailScenarioRegistry::find($key) !== null)
->map(fn ($key) => [
...EmailScenarioRegistry::describe($key),
'key' => $key,
'label' => $labels[$key] ?? $key,
'has_template' => true,
'is_active' => true,
]);
return response()->json(['error' => false, 'data' => $registered->values()->concat($custom->values())]);
}
/**
* Đặt/đổi tên hiển thị (label) cho một scenario — cho cả scenario tự tạo
* lẫn scenario khai báo sẵn trong Template::SCENARIOS. Lưu trong Setting
* lẫn scenario khai báo sẵn trong EmailScenarioRegistry. Lưu trong Setting
* (key=scenario_labels) vì scenario là khái niệm chung, không thuộc riêng
* một Template row nào.
*/
@@ -96,7 +115,7 @@ class EmailTemplateController extends Controller
: [];
}
public function store(Request $request): JsonResponse
public function store(Request $request, TemplateGuard $guard): JsonResponse
{
$data = $request->validate([
'name' => 'nullable|string|max:255',
@@ -117,9 +136,10 @@ class EmailTemplateController extends Controller
'mail_config.cc.*' => 'email',
'mail_config.bcc' => 'nullable|array',
'mail_config.bcc.*' => 'email',
...self::VARIABLE_RULES,
]);
$template = DB::transaction(fn() => Template::create([
$template = new Template([
'type_id' => 0,
'name' => $data['name'] ?? null,
'scenario' => $data['scenario'] ?? null,
@@ -131,7 +151,10 @@ class EmailTemplateController extends Controller
'builder_data' => $data['builder_data'] ?? null,
'status' => $data['status'] ?? 1,
'metadata' => isset($data['mail_config']) ? ['mail' => $data['mail_config']] : null,
]));
]);
$this->syncVariables($template, $data['variables'] ?? []);
$guard->assertCanSave($template);
DB::transaction(fn () => $template->save());
return response()->json([
'error' => false, 'message' => 'Tạo template thành công.',
@@ -139,7 +162,7 @@ class EmailTemplateController extends Controller
], 201);
}
public function update(Request $request, int $id): JsonResponse
public function update(Request $request, int $id, TemplateGuard $guard): JsonResponse
{
$template = Template::findOrFail($id);
@@ -162,17 +185,21 @@ class EmailTemplateController extends Controller
'mail_config.cc.*' => 'email',
'mail_config.bcc' => 'nullable|array',
'mail_config.bcc.*' => 'email',
...self::VARIABLE_RULES,
]);
// metadata.variables do developer khai báo theo code — admin chỉ được sửa metadata.mail.
if (array_key_exists('mail_config', $data)) {
$metadata = $template->metadata ?? [];
$metadata['mail'] = $data['mail_config'] ?? [];
$data['metadata'] = $metadata;
}
unset($data['mail_config']);
$variables = $data['variables'] ?? [];
unset($data['mail_config'], $data['variables']);
DB::transaction(fn() => $template->update($data));
$template->fill($data);
$this->syncVariables($template, $variables);
$guard->assertCanSave($template);
DB::transaction(fn () => $template->save());
return response()->json([
'error' => false, 'message' => 'Cập nhật template thành công.',
@@ -180,9 +207,11 @@ class EmailTemplateController extends Controller
]);
}
public function destroy(int $id): JsonResponse
public function destroy(int $id, TemplateGuard $guard): JsonResponse
{
DB::transaction(fn() => Template::findOrFail($id)->delete());
$template = Template::findOrFail($id);
$guard->assertCanDelete($template);
DB::transaction(fn () => $template->delete());
return response()->json(['error' => false, 'message' => 'Đã xoá template.']);
}
@@ -232,9 +261,9 @@ class EmailTemplateController extends Controller
$overrides = $request->validate([
'subject' => 'nullable|string|max:255',
'message' => 'nullable|string',
...self::VARIABLE_RULES,
]);
// Cho phép preview nội dung đang sửa dở (chưa lưu) bằng cách override tạm, không đụng DB.
$draft = clone $template;
if (array_key_exists('subject', $overrides)) {
$draft->subject = $overrides['subject'];
@@ -242,9 +271,11 @@ class EmailTemplateController extends Controller
if (array_key_exists('message', $overrides)) {
$draft->message = $overrides['message'];
}
$draft->syncVariables($overrides['variables'] ?? []);
$exampleData = collect($template->variables())
$exampleData = collect($draft->variables())
->mapWithKeys(fn ($def, $key) => [$key => $def['example'] ?? "[{$key}]"])
->merge(MailGlobals::values())
->all();
try {
@@ -272,23 +303,24 @@ class EmailTemplateController extends Controller
'bcc' => 'nullable|string',
]);
$to = $this->parseRecipients($data['to']);
$to = EmailRecipients::parse($data['to']);
if (empty($to)) {
return response()->json(['error' => true, 'message' => 'Vui lòng nhập email người nhận hợp lệ.'], 422);
}
$exampleData = collect($template->variables())
->mapWithKeys(fn ($def, $key) => [$key => $def['example'] ?? "[{$key}]"])
->merge(MailGlobals::values())
->all();
try {
$rendered = $renderer->render($template, $exampleData);
$mailer = Mail::to($to);
if ($cc = $this->parseRecipients($data['cc'] ?? null)) {
if ($cc = EmailRecipients::parse($data['cc'] ?? null)) {
$mailer->cc($cc);
}
if ($bcc = $this->parseRecipients($data['bcc'] ?? null)) {
if ($bcc = EmailRecipients::parse($data['bcc'] ?? null)) {
$mailer->bcc($bcc);
}
$mailer->send(new GenericTemplateMail(
@@ -311,21 +343,38 @@ class EmailTemplateController extends Controller
return response()->json(['error' => false, 'message' => 'Đã gửi email mẫu.']);
}
private function parseRecipients(?string $value): array
{
if (!$value) {
return [];
}
// ── Helpers ───────────────────────────────────────────────────────────────
return collect(preg_split('/[,;]+/', $value) ?: [])
->map(fn ($email) => trim((string) $email))
->filter(fn ($email) => $email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL))
->unique()
->values()
->all();
private function syncVariables(Template $template, array $overrides): void
{
if ($template->format !== 'json') {
$template->syncVariables($overrides);
}
}
// ── Helpers ───────────────────────────────────────────────────────────────
private function placeholders(Template $t): array
{
$declared = $t->variables();
return array_map(fn (string $key) => [
'key' => $key,
'label' => $declared[$key]['label'] ?? $key,
'example' => $declared[$key]['example'] ?? null,
'recipient' => array_key_exists($key, MailPlaceholders::RECIPIENT_FIELDS) || array_key_exists($key, MailGlobals::LABELS),
], MailPlaceholders::extract($t->subject, $t->message));
}
private function scenarioLabel(Template $t): ?string
{
$scenario = EmailScenarioRegistry::find($t->key, $t->scenario) ?? $t->scenario;
if (!$scenario) {
return null;
}
return $this->scenarioLabelOverrides()[$scenario]
?? EmailScenarioRegistry::SCENARIOS[$scenario]['label']
?? $scenario;
}
private function format(Template $t): array
{
@@ -335,9 +384,7 @@ class EmailTemplateController extends Controller
'name' => $t->name,
'scenario' => $t->scenario,
'key' => $t->key,
'scenario_label' => $t->scenario
? ($this->scenarioLabelOverrides()[$t->scenario] ?? Template::SCENARIOS[$t->scenario] ?? $t->scenario)
: null,
'scenario_label' => $this->scenarioLabel($t),
'format' => $t->format ?? 'html',
'invoice_template_id' => $t->invoice_template_id,
'invoice_template_name' => $t->invoiceTemplate?->name,
@@ -347,6 +394,9 @@ class EmailTemplateController extends Controller
'status' => $t->status,
'variables' => array_values($t->variables()),
'mail_config' => $t->mailConfig(),
'placeholders' => $this->placeholders($t),
...collect(EmailScenarioRegistry::describe($t->key, $t->scenario))->only(['registered', 'category', 'group', 'permissions', 'required_variables'])->all(),
'global_variables' => collect(MailGlobals::LABELS)->map(fn ($label, $key) => ['key' => $key, 'label' => $label])->values(),
'created_at' => $t->created_at,
'updated_at' => $t->updated_at,
];
@@ -10,9 +10,12 @@ use App\Models\Customer;
use App\Models\Mail;
use App\Models\MailCampaign;
use App\Models\Template;
use App\Services\Email\MailCampaignRenderer;
use App\Support\MailPlaceholders;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
final class MailCampaignController extends Controller
{
@@ -21,7 +24,7 @@ final class MailCampaignController extends Controller
$limit = min((int) $request->get('per_page', 20), 200);
$paginator = MailCampaign::query()
->with('template')
->with(['template', 'creator'])
->orderByDesc('created_at')
->paginate($limit);
@@ -39,7 +42,7 @@ final class MailCampaignController extends Controller
public function show(string $uuid): JsonResponse
{
$campaign = MailCampaign::with('template')->where('uuid', $uuid)->first();
$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);
@@ -48,12 +51,13 @@ final class MailCampaignController extends Controller
$logs = Mail::with('customer')
->where('campaign_id', $campaign->id)
->orderByDesc('created_at')
->get()
->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,
]);
@@ -62,35 +66,25 @@ final class MailCampaignController extends Controller
'error' => false,
'data' => [
...$this->mapCampaign($campaign),
'logs' => $logs,
'cc' => $campaign->cc,
'bcc' => $campaign->bcc,
'variables' => $campaign->variables ?? [],
'logs' => $logs,
],
]);
}
public function preview(Request $request): JsonResponse
public function preview(Request $request, MailCampaignRenderer $renderer): JsonResponse
{
$data = $request->validate([
'template_id' => 'nullable|integer|exists:template,id',
'subject' => 'required_without:template_id|nullable|string|max:255',
'message' => 'required_without:template_id|nullable|string',
]);
$subject = trim((string) ($data['subject'] ?? ''));
$message = trim((string) ($data['message'] ?? ''));
if (!empty($data['template_id'])) {
$template = Template::find($data['template_id']);
$subject = trim((string) ($template->subject ?? $subject));
$message = trim((string) ($template->message ?? $message));
}
$campaign = $this->draftCampaign($request->validate($this->contentRules()));
return response()->json([
'error' => false,
'data' => ['html' => $this->renderPreview($subject, $message)],
'data' => ['html' => $renderer->render($campaign, $renderer->sampleValues($campaign))['html']],
]);
}
public function previewCampaign(string $uuid): JsonResponse
public function previewCampaign(string $uuid, MailCampaignRenderer $renderer): JsonResponse
{
$campaign = MailCampaign::where('uuid', $uuid)->first();
@@ -100,101 +94,178 @@ final class MailCampaignController extends Controller
return response()->json([
'error' => false,
'data' => ['html' => $this->renderPreview($campaign->subject, $campaign->message)],
'data' => ['html' => $renderer->render($campaign, $renderer->sampleValues($campaign))['html']],
]);
}
private function renderPreview(string $subject, string $message): string
public function showLog(string $uuid, int $id): JsonResponse
{
return Template::renderWithLayout($subject, $message);
$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 store(Request $request): JsonResponse
{
$data = $request->validate([
...$this->contentRules(),
'recipient_type' => 'required|in:all,selected',
'uuids' => 'required_if:recipient_type,selected|array|min:1',
'uuids' => 'nullable|array',
'uuids.*' => 'string|exists:customers,uuid',
'template_id' => 'nullable|integer|exists:template,id',
'subject' => 'required_without:template_id|nullable|string|max:255',
'message' => 'required_without:template_id|nullable|string',
'emails' => 'nullable|array|max:1000',
'emails.*' => 'email|max:255',
'name' => 'nullable|string|max:255',
'cc' => 'nullable|string|max:1000',
'bcc' => 'nullable|string|max:1000',
]);
$template = null;
$subject = trim((string) ($data['subject'] ?? ''));
$message = trim((string) ($data['message'] ?? ''));
$campaign = $this->draftCampaign($data);
if (!empty($data['template_id'])) {
$template = Template::query()
->whereKey($data['template_id'])
->where('status', 1)
->first();
$recipients = $data['recipient_type'] === 'all'
? $this->allCustomerRecipients()
: $this->selectedRecipients($data['uuids'] ?? [], $data['emails'] ?? []);
if (!$template) {
return response()->json([
'error' => true,
'message' => 'Template không tồn tại hoặc đang bị tắt.',
], 422);
}
$subject = trim((string) $template->subject);
$message = trim((string) $template->message);
}
if ($subject === '' || $message === '') {
if ($recipients === []) {
return response()->json([
'error' => true,
'message' => 'Subject và content không được để trống.',
], 422);
}
$customers = $data['recipient_type'] === 'all'
? Customer::query()->whereNotNull('email')->get(['id', 'email'])
: Customer::query()->whereIn('uuid', $data['uuids'])->get(['id', 'email']);
if ($customers->isEmpty()) {
return response()->json([
'error' => true,
'message' => 'Không tìm thấy khách hàng nào để gửi.',
'message' => 'Không có người nhận hợp lệ để gửi.',
], 422);
}
$admin = Auth::guard('admin')->user();
$campaign = MailCampaign::create([
'name' => trim((string) ($data['name'] ?? '')) ?: $subject,
'template_id' => $template->id ?? null,
'subject' => $subject,
'message' => $message,
$campaign->fill([
'name' => trim((string) ($data['name'] ?? '')) ?: $campaign->subject,
'cc' => $data['cc'] ?? null,
'bcc' => $data['bcc'] ?? null,
'recipient_type' => $data['recipient_type'],
'total_recipients' => $customers->count(),
'total_recipients' => count($recipients),
'sent_count' => 0,
'failed_count' => 0,
'status' => 'queued',
'created_by' => $admin->id ?? null,
]);
])->save();
foreach ($customers as $customer) {
SendMailCampaignRecipient::dispatch(
$campaign->id,
$customer->id,
$subject,
$message,
$data['cc'] ?? null,
$data['bcc'] ?? null,
);
foreach ($recipients as $email => $customerId) {
SendMailCampaignRecipient::dispatch($campaign->id, $customerId, $email);
}
return response()->json([
'error' => false,
'message' => 'Chiến dịch đã được tạo và đang gửi.',
'message' => "Đã đưa {$campaign->total_recipients} email vào hàng đợi gửi.",
'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 = 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<string, int>
*/
private function allCustomerRecipients(): array
{
return Customer::query()
->whereNotNull('email')
->where('email', '!=', '')
->pluck('id', 'email')
->all();
}
/**
* @param array<int, string> $uuids
* @param array<int, string> $emails
* @return array<string, int|null>
*/
private function selectedRecipients(array $uuids, array $emails): array
{
$emails = array_values(array_unique(array_map(fn ($email) => mb_strtolower(trim($email)), $emails)));
$customers = Customer::query()
->where(fn ($q) => $q->whereIn('uuid', $uuids)->orWhereIn('email', $emails))
->whereNotNull('email')
->get(['id', 'email', 'uuid']);
$recipients = [];
foreach ($customers as $customer) {
if (in_array($customer->uuid, $uuids, true) || in_array(mb_strtolower($customer->email), $emails, true)) {
$recipients[mb_strtolower($customer->email)] = $customer->id;
}
}
foreach ($emails as $email) {
$recipients[$email] ??= null;
}
return array_filter($recipients, fn ($id, $email) => filter_var($email, FILTER_VALIDATE_EMAIL), ARRAY_FILTER_USE_BOTH);
}
private function mapCampaign(MailCampaign $campaign): array
{
return [
@@ -208,6 +279,8 @@ final class MailCampaignController extends Controller
'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,
];
+74 -72
View File
@@ -2,14 +2,21 @@
namespace App\Jobs\Mail;
use App\Mail\Customer\ManualCustomerEmail;
use App\Mail\GenericTemplateMail;
use App\Models\Customer;
use App\Models\Mail as MailLog;
use App\Models\MailCampaign;
use App\Models\Notification;
use App\Services\Email\MailCampaignRenderer;
use App\Support\EmailRecipients;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
class SendMailCampaignRecipient implements ShouldQueue
@@ -18,87 +25,82 @@ class SendMailCampaignRecipient implements ShouldQueue
public function __construct(
private readonly int $campaignId,
private readonly int $customerId,
private readonly string $subject,
private readonly string $message,
private readonly ?string $cc = null,
private readonly ?string $bcc = null,
private readonly ?int $customerId,
private readonly string $email,
) {}
public function handle(): void
{
$campaign = MailCampaign::find($this->campaignId);
$customer = Customer::find($this->customerId);
if (!$campaign || !$customer || !$customer->email) {
$this->markSent($campaign, failed: true);
return;
}
try {
$mailer = Mail::to($customer->email);
if ($cc = $this->parseRecipients($this->cc)) {
$mailer->cc($cc);
}
if ($bcc = $this->parseRecipients($this->bcc)) {
$mailer->bcc($bcc);
}
$mailer->send(new ManualCustomerEmail($this->subject, $this->message));
\App\Models\Mail::create([
'campaign_id' => $campaign->id,
'template_id' => $campaign->template_id ?? 0,
'subject' => $this->subject,
'to' => $customer->email,
'customer_id' => $customer->id,
'message' => $this->message,
'status' => 'sent',
]);
$this->markSent($campaign, failed: false);
} catch (\Throwable) {
\App\Models\Mail::create([
'campaign_id' => $campaign->id,
'template_id' => $campaign->template_id ?? 0,
'subject' => $this->subject,
'to' => $customer->email,
'customer_id' => $customer->id,
'message' => $this->message,
'status' => 'failed',
]);
$this->markSent($campaign, failed: true);
}
}
private function parseRecipients(?string $value): array
{
if (!$value) {
return [];
}
return collect(preg_split('/[,;]+/', $value) ?: [])
->map(fn ($email) => trim((string) $email))
->filter(fn ($email) => $email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL))
->unique()
->values()
->all();
}
private function markSent(?MailCampaign $campaign, bool $failed): void
public function handle(MailCampaignRenderer $renderer): void
{
$campaign = MailCampaign::with('template')->find($this->campaignId);
if (!$campaign) {
return;
}
$campaign->increment($failed ? 'failed_count' : 'sent_count');
$campaign->refresh();
$customer = $this->customerId ? Customer::find($this->customerId) : null;
$rendered = $renderer->render($campaign, $renderer->recipientValues($campaign, $customer, $this->email));
$cc = EmailRecipients::parse($campaign->cc);
if ($campaign->sent_count + $campaign->failed_count >= $campaign->total_recipients) {
$campaign->update([
'status' => $campaign->failed_count > 0 && $campaign->sent_count === 0 ? 'failed' : 'completed',
$failed = false;
try {
$mailer = Mail::to($this->email);
if ($cc) {
$mailer->cc($cc);
}
if ($bcc = EmailRecipients::parse($campaign->bcc)) {
$mailer->bcc($bcc);
}
$mailer->send(new GenericTemplateMail(
$rendered['subject'],
$rendered['html'],
Arr::only($campaign->template?->mailConfig() ?? [], ['from_email', 'from_name', 'reply_to', 'reply_to_name']),
));
} catch (\Throwable $e) {
$failed = true;
Log::warning('Gửi email chiến dịch thất bại', [
'campaign_id' => $campaign->id,
'to' => $this->email,
'error' => $e->getMessage(),
]);
}
MailLog::create([
'campaign_id' => $campaign->id,
'template_id' => $campaign->template_id ?? 0,
'subject' => $rendered['subject'],
'to' => $this->email,
'cc' => implode(', ', $cc),
'customer_id' => $customer?->id,
'message' => $rendered['html'],
'status' => $failed ? 'failed' : 'sent',
]);
$this->markDone($campaign, $failed);
}
private function markDone(MailCampaign $campaign, bool $failed): void
{
$campaign->increment($failed ? 'failed_count' : 'sent_count');
$finished = MailCampaign::whereKey($campaign->id)
->whereIn('status', ['queued', 'sending'])
->whereRaw('sent_count + failed_count >= total_recipients')
->update([
'status' => DB::raw("CASE WHEN sent_count = 0 THEN 'failed' ELSE 'completed' END"),
'completed_at' => now(),
]);
if ($finished && $campaign->created_by) {
$campaign->refresh();
Notification::create([
'recipient_type' => 'admin',
'recipient_id' => $campaign->created_by,
'type' => 'mail_campaign_completed',
'title' => $campaign->status === 'failed' ? 'Gửi email thất bại' : 'Đã gửi email thành công',
'body' => "{$campaign->name}: {$campaign->sent_count}/{$campaign->total_recipients} email đã gửi"
. ($campaign->failed_count ? ", {$campaign->failed_count} lỗi." : '.'),
'link' => "/mail-campaigns/{$campaign->uuid}",
'data' => ['campaign_uuid' => $campaign->uuid],
]);
}
}
}
+9 -21
View File
@@ -4,39 +4,27 @@ declare(strict_types=1);
namespace App\Mail\Admin;
use App\Mail\Concerns\RequiresDbTemplate;
use App\Mail\TemplateMailable;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class AdminOrderNotification extends Mailable
class AdminOrderNotification extends TemplateMailable
{
use Queueable, SerializesModels, RequiresDbTemplate;
use Queueable, SerializesModels;
public function __construct(public readonly array $data) {}
public function envelope(): Envelope
{
return new Envelope(
subject: $this->renderFromTemplate('admin_order_notification', $this->vars())['subject'],
);
}
public function content(): Content
{
return new Content(
htmlString: $this->renderFromTemplate('admin_order_notification', $this->vars())['html'],
);
}
public function attachments(): array
{
return [];
}
private function vars(): array
protected function scenario(): string
{
return 'admin_order_notification';
}
protected function variables(): array
{
$vmFields = ['ip' => '🌐 IP Address', 'username' => '👤 Username', 'password' => '🔑 Password'];
$vmRows = '';
+6 -18
View File
@@ -2,37 +2,25 @@
namespace App\Mail\Admin;
use App\Mail\Concerns\RequiresDbTemplate;
use App\Mail\TemplateMailable;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class DomainOrderNewNotification extends Mailable implements ShouldQueue
class DomainOrderNewNotification extends TemplateMailable implements ShouldQueue
{
use Queueable, SerializesModels, RequiresDbTemplate;
use Queueable, SerializesModels;
public function __construct(
protected array $data,
) {}
public function envelope(): Envelope
protected function scenario(): string
{
return new Envelope(
subject: $this->renderFromTemplate('admin_domain_order_new', $this->vars())['subject'],
);
return 'admin_domain_order_new';
}
public function content(): Content
{
return new Content(
htmlString: $this->renderFromTemplate('admin_domain_order_new', $this->vars())['html'],
);
}
private function vars(): array
protected function variables(): array
{
return [
'company_name' => $this->data['company_name'] ?? config('app.name'),
@@ -4,39 +4,27 @@ declare(strict_types=1);
namespace App\Mail\Admin;
use App\Mail\Concerns\RequiresDbTemplate;
use App\Mail\TemplateMailable;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class HostingAccountCreatedAdminNotification extends Mailable
class HostingAccountCreatedAdminNotification extends TemplateMailable
{
use Queueable, SerializesModels, RequiresDbTemplate;
use Queueable, SerializesModels;
public function __construct(public readonly array $data) {}
public function envelope(): Envelope
{
return new Envelope(
subject: $this->renderFromTemplate('admin_hosting_account_created', $this->vars())['subject'],
);
}
public function content(): Content
{
return new Content(
htmlString: $this->renderFromTemplate('admin_hosting_account_created', $this->vars())['html'],
);
}
public function attachments(): array
{
return [];
}
private function vars(): array
protected function scenario(): string
{
return 'admin_hosting_account_created';
}
protected function variables(): array
{
$row = fn (string $label, ?string $value) => $value
? '<div class="cred-row"><span class="cred-label">' . $label . '</span><span class="cred-value">' . e($value) . '</span></div>'
@@ -2,39 +2,27 @@
namespace App\Mail\Admin;
use App\Mail\Concerns\RequiresDbTemplate;
use App\Mail\TemplateMailable;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class ManualDepositPendingNotification extends Mailable
class ManualDepositPendingNotification extends TemplateMailable
{
use Queueable, SerializesModels, RequiresDbTemplate;
use Queueable, SerializesModels;
public function __construct(public array $data) {}
public function envelope(): Envelope
{
return new Envelope(
subject: $this->renderFromTemplate('admin_manual_deposit_pending', $this->vars())['subject'],
);
}
public function content(): Content
{
return new Content(
htmlString: $this->renderFromTemplate('admin_manual_deposit_pending', $this->vars())['html'],
);
}
public function attachments(): array
{
return [];
}
private function vars(): array
protected function scenario(): string
{
return 'admin_manual_deposit_pending';
}
protected function variables(): array
{
return [
'transaction_code' => $this->data['transaction_code'] ?? 'N/A',
+6 -18
View File
@@ -2,38 +2,26 @@
namespace App\Mail\Admin;
use App\Mail\Concerns\RequiresDbTemplate;
use App\Mail\TemplateMailable;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class TicketAdminNotification extends Mailable implements ShouldQueue
class TicketAdminNotification extends TemplateMailable implements ShouldQueue
{
use Queueable, SerializesModels, RequiresDbTemplate;
use Queueable, SerializesModels;
public function __construct(
public readonly mixed $ticket,
public readonly mixed $customer,
) {}
public function envelope(): Envelope
protected function scenario(): string
{
return new Envelope(
subject: $this->renderFromTemplate('admin_ticket_notification', $this->vars())['subject'],
);
return 'admin_ticket_notification';
}
public function content(): Content
{
return new Content(
htmlString: $this->renderFromTemplate('admin_ticket_notification', $this->vars())['html'],
);
}
private function vars(): array
protected function variables(): array
{
$description = strip_tags($this->ticket->description ?? '');
+9 -21
View File
@@ -2,39 +2,27 @@
namespace App\Mail\Admin;
use App\Mail\Concerns\RequiresDbTemplate;
use App\Mail\TemplateMailable;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class TrialRegistrationRequest extends Mailable
class TrialRegistrationRequest extends TemplateMailable
{
use Queueable, SerializesModels, RequiresDbTemplate;
use Queueable, SerializesModels;
public function __construct(public array $data) {}
public function envelope(): Envelope
{
return new Envelope(
subject: $this->renderFromTemplate('admin_trial_registration_request', $this->vars())['subject'],
);
}
public function content(): Content
{
return new Content(
htmlString: $this->renderFromTemplate('admin_trial_registration_request', $this->vars())['html'],
);
}
public function attachments(): array
{
return [];
}
private function vars(): array
protected function scenario(): string
{
return 'admin_trial_registration_request';
}
protected function variables(): array
{
return [
'customer_name' => $this->data['customer_name'] ?? 'N/A',
@@ -0,0 +1,25 @@
<?php
namespace App\Mail\Concerns;
use Illuminate\Mail\Mailables\Address;
use Illuminate\Mail\Mailables\Envelope;
trait BuildsTemplateEnvelope
{
protected function templateEnvelope(string $subject, array $mailConfig): Envelope
{
return new Envelope(
subject: $subject,
from: $this->templateAddress($mailConfig['from_email'] ?? null, $mailConfig['from_name'] ?? null),
replyTo: array_filter([$this->templateAddress($mailConfig['reply_to'] ?? null, $mailConfig['reply_to_name'] ?? null)]),
cc: $mailConfig['cc'] ?? [],
bcc: $mailConfig['bcc'] ?? [],
);
}
private function templateAddress(?string $email, ?string $name): ?Address
{
return $email ? new Address($email, $name) : null;
}
}
-21
View File
@@ -1,21 +0,0 @@
<?php
namespace App\Mail\Concerns;
use App\Services\Email\EmailTemplateService;
/**
* Render subject+html từ Template DB (EmailTemplateService) theo scenario.
* Không còn fallback về Blade view — nếu template thiếu/tắt hoặc chứa
* variable không hợp lệ, exception sẽ propagate rõ ràng thay vì gửi email rỗng.
*/
trait RequiresDbTemplate
{
private ?array $dbRendered = null;
/** @return array{subject: string, html: string} */
protected function renderFromTemplate(string $scenario, array $vars): array
{
return $this->dbRendered ??= app(EmailTemplateService::class)->preview($scenario, $vars);
}
}
+8 -21
View File
@@ -2,40 +2,27 @@
namespace App\Mail;
use App\Mail\Concerns\RequiresDbTemplate;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class CreateHosting extends Mailable
class CreateHosting extends TemplateMailable
{
use Queueable, SerializesModels, RequiresDbTemplate;
use Queueable, SerializesModels;
/** @param int $template Không còn dùng — scenario cố định 'hosting_information'. Giữ tham số để không phá vỡ caller hiện tại. */
public function __construct($template = 0, public $data = []) {}
public function envelope(): Envelope
{
return new Envelope(
subject: $this->renderFromTemplate('hosting_information', $this->vars())['subject'],
);
}
public function content(): Content
{
return new Content(
htmlString: $this->renderFromTemplate('hosting_information', $this->vars())['html'],
);
}
public function attachments(): array
{
return [];
}
private function vars(): array
protected function scenario(): string
{
return 'hosting_information';
}
protected function variables(): array
{
return [
'company_name' => $this->data['company_name'] ?? '',
+6 -18
View File
@@ -2,16 +2,13 @@
namespace App\Mail\Customer;
use App\Mail\Concerns\RequiresDbTemplate;
use App\Mail\TemplateMailable;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class DepositSuccess extends Mailable
class DepositSuccess extends TemplateMailable
{
use Queueable, SerializesModels, RequiresDbTemplate;
use Queueable, SerializesModels;
public array $data;
@@ -20,21 +17,12 @@ class DepositSuccess extends Mailable
$this->data = $data;
}
public function envelope(): Envelope
protected function scenario(): string
{
return new Envelope(
subject: $this->renderFromTemplate('customer_deposit_success', $this->vars())['subject'],
);
return 'customer_deposit_success';
}
public function content(): Content
{
return new Content(
htmlString: $this->renderFromTemplate('customer_deposit_success', $this->vars())['html'],
);
}
private function vars(): array
protected function variables(): array
{
return [
'customer_name' => $this->data['customer_name'] ?? 'Khách hàng',
@@ -2,37 +2,25 @@
namespace App\Mail\Customer;
use App\Mail\Concerns\RequiresDbTemplate;
use App\Mail\TemplateMailable;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class DomainOrderReceivedNotification extends Mailable implements ShouldQueue
class DomainOrderReceivedNotification extends TemplateMailable implements ShouldQueue
{
use Queueable, SerializesModels, RequiresDbTemplate;
use Queueable, SerializesModels;
public function __construct(
protected array $data,
) {}
public function envelope(): Envelope
protected function scenario(): string
{
return new Envelope(
subject: $this->renderFromTemplate('domain_order_received', $this->vars())['subject'],
);
return 'domain_order_received';
}
public function content(): Content
{
return new Content(
htmlString: $this->renderFromTemplate('domain_order_received', $this->vars())['html'],
);
}
private function vars(): array
protected function variables(): array
{
return [
'customer_name' => $this->data['customer_name'] ?? 'Quý khách',
+8 -40
View File
@@ -2,59 +2,30 @@
namespace App\Mail\Customer;
use App\Mail\Concerns\RequiresDbTemplate;
use App\Models\Setting;
use App\Mail\TemplateMailable;
use App\Support\MailGlobals;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class HostingAccountCreated extends Mailable
class HostingAccountCreated extends TemplateMailable
{
use Queueable, SerializesModels, RequiresDbTemplate;
use Queueable, SerializesModels;
public function __construct(public array $data) {}
public function envelope(): Envelope
{
return new Envelope(
subject: $this->renderFromTemplate('hosting_account_created', $this->vars())['subject'],
);
}
public function content(): Content
{
return new Content(
htmlString: $this->renderFromTemplate('hosting_account_created', $this->vars())['html'],
);
}
public function attachments(): array
{
return [];
}
private function payload(): array
protected function scenario(): string
{
try {
$setting = Setting::where('key', 'setting')->first();
$settingValue = $setting ? json_decode((string) $setting->value, true) : [];
} catch (\Throwable) {
$settingValue = [];
}
return array_merge([
'company_name' => $settingValue['company'] ?? config('app.name', 'The Gioi So'),
'support_email' => $settingValue['email'] ?? config('mail.support_email', 'support@thegioiso.com'),
'support_phone' => $settingValue['hotline'] ?? ($settingValue['phone'] ?? config('mail.support_phone', '1900.xxxx')),
'login_url' => rtrim(config('app.frontend_url', ''), '/') . '/dang-nhap',
], $this->data);
return 'hosting_account_created';
}
private function vars(): array
protected function variables(): array
{
$data = $this->payload();
$data = [...MailGlobals::values(), ...$this->data];
$row = fn (string $label, ?string $value) => $value
? "<tr><td style=\"padding: 7px 11px; border-right: 1px solid #e8e8e8;\">{$label}</td><td style=\"padding: 7px 11px; border-right: 1px solid #e8e8e8;\"><strong>{$value}</strong></td></tr>"
@@ -82,9 +53,6 @@ class HostingAccountCreated extends Mailable
? '<p style="text-align: justify; color: #b45309; font-size: 13px;">Lưu ý: Vì lý do bảo mật, mật khẩu FTP chỉ được gửi một lần duy nhất qua email và không được lưu trữ trên hệ thống. Vui lòng lưu lại ở nơi an toàn.</p>'
: '',
'plesk_block' => $pleskBlock,
'login_url' => $data['login_url'] ?? rtrim(config('app.frontend_url', ''), '/') . '/dang-nhap',
'support_email' => $data['support_email'] ?? config('mail.support_email', 'support@thegioiso.com'),
'support_phone' => $data['support_phone'] ?? config('mail.support_phone', '1900.xxxx'),
];
}
}
+9 -42
View File
@@ -2,64 +2,33 @@
namespace App\Mail\Customer;
use App\Mail\Concerns\RequiresDbTemplate;
use App\Models\Setting;
use App\Mail\TemplateMailable;
use App\Support\MailGlobals;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class ManualDepositStatus extends Mailable
class ManualDepositStatus extends TemplateMailable
{
use Queueable, SerializesModels, RequiresDbTemplate;
use Queueable, SerializesModels;
public function __construct(public array $data) {}
public function envelope(): Envelope
{
return new Envelope(
subject: $this->renderFromTemplate('manual_deposit_status', $this->vars())['subject'],
);
}
public function content(): Content
{
return new Content(
htmlString: $this->renderFromTemplate('manual_deposit_status', $this->vars())['html'],
);
}
public function attachments(): array
{
return [];
}
private function payload(): array
protected function scenario(): string
{
try {
$setting = Setting::where('key', 'setting')->first();
$settingValue = $setting ? json_decode((string) $setting->value, true) : [];
} catch (\Throwable) {
$settingValue = [];
}
return array_merge([
'company_name' => $settingValue['company'] ?? config('app.name', 'The Gioi So'),
'company_logo' => $settingValue['logo'] ?? '',
'support_email' => $settingValue['email'] ?? config('mail.support_email', 'support@thegioiso.com'),
'support_phone' => $settingValue['hotline'] ?? ($settingValue['phone'] ?? config('mail.support_phone', '1900.xxxx')),
'portal_url' => rtrim(env('FRONTEND_URL', config('app.url')), '/') . '/nap-tien',
], $this->data);
return 'manual_deposit_status';
}
private function vars(): array
protected function variables(): array
{
$data = $this->payload();
$data = [...MailGlobals::values(), ...$this->data];
$amount = (float) ($data['amount'] ?? 0);
return [
'company_name' => $data['company_name'] ?? config('app.name', 'The Gioi So'),
'customer_name' => $data['customer_name'] ?? 'Quý khách',
'transaction_code' => $data['transaction_code'] ?? 'N/A',
'amount_formatted' => number_format($amount, 0, ',', '.'),
@@ -71,9 +40,7 @@ class ManualDepositStatus extends Mailable
'processed_date_row' => !empty($data['processed_date'])
? '<tr><td style="padding:12px 16px;background-color:#fafafa;color:#78909c;width:42%">Thời gian xử lý</td><td style="padding:12px 16px;color:#263238">' . $data['processed_date'] . '</td></tr>'
: '',
'portal_url' => $data['portal_url'] ?? rtrim(env('FRONTEND_URL', config('app.url')), '/') . '/nap-tien',
'support_email' => $data['support_email'] ?? config('mail.support_email', 'support@thegioiso.com'),
'support_phone' => $data['support_phone'] ?? config('mail.support_phone', '1900.xxxx'),
'portal_url' => $data['portal_url'] ?? MailGlobals::frontendUrl() . '/nap-tien',
];
}
}
+8 -40
View File
@@ -2,68 +2,36 @@
namespace App\Mail\Customer;
use App\Mail\Concerns\RequiresDbTemplate;
use App\Models\Setting;
use App\Mail\TemplateMailable;
use App\Support\MailGlobals;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class PasswordResetByAdmin extends Mailable
class PasswordResetByAdmin extends TemplateMailable
{
use Queueable, SerializesModels, RequiresDbTemplate;
use Queueable, SerializesModels;
public function __construct(public array $data) {}
public function envelope(): Envelope
{
return new Envelope(
subject: $this->renderFromTemplate('password_reset_admin', $this->vars())['subject'],
);
}
public function content(): Content
{
return new Content(
htmlString: $this->renderFromTemplate('password_reset_admin', $this->vars())['html'],
);
}
public function attachments(): array
{
return [];
}
private function payload(): array
protected function scenario(): string
{
try {
$setting = Setting::where('key', 'setting')->first();
$settingValue = $setting ? json_decode((string) $setting->value, true) : [];
} catch (\Throwable) {
$settingValue = [];
}
return array_merge([
'company_name' => $settingValue['company'] ?? config('app.name', 'The Gioi So'),
'support_email' => $settingValue['email'] ?? config('mail.support_email', 'support@thegioiso.com'),
'support_phone' => $settingValue['hotline'] ?? ($settingValue['phone'] ?? config('mail.support_phone', '1900.xxxx')),
'login_url' => rtrim(config('app.frontend_url', ''), '/') . '/dang-nhap',
], $this->data);
return 'password_reset_admin';
}
private function vars(): array
protected function variables(): array
{
$data = $this->payload();
$data = [...MailGlobals::values(), ...$this->data];
return [
'app_name' => $data['company_name'] ?? config('app.name', 'The Gioi So'),
'full_name' => $data['fullname'] ?? 'Quý khách',
'email' => $data['email'] ?? '',
'password' => $data['password'] ?? '',
'login_url' => $data['login_url'] ?? rtrim(config('app.frontend_url', ''), '/') . '/dang-nhap',
'support_email' => $data['support_email'] ?? config('mail.support_email', 'support@thegioiso.com'),
'support_phone' => $data['support_phone'] ?? config('mail.support_phone', '1900.xxxx'),
];
}
}
+6 -18
View File
@@ -2,34 +2,22 @@
namespace App\Mail\Customer;
use App\Mail\Concerns\RequiresDbTemplate;
use App\Mail\TemplateMailable;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class PasswordResetLink extends Mailable
class PasswordResetLink extends TemplateMailable
{
use Queueable, SerializesModels, RequiresDbTemplate;
use Queueable, SerializesModels;
public function __construct(public array $data) {}
public function envelope(): Envelope
protected function scenario(): string
{
return new Envelope(
subject: $this->renderFromTemplate('customer_password_reset_link', $this->vars())['subject'],
);
return 'customer_password_reset_link';
}
public function content(): Content
{
return new Content(
htmlString: $this->renderFromTemplate('customer_password_reset_link', $this->vars())['html'],
);
}
private function vars(): array
protected function variables(): array
{
return [
'full_name' => $this->data['fullname'] ?? 'Quý khách',
+8 -40
View File
@@ -2,17 +2,14 @@
namespace App\Mail\Customer;
use App\Mail\Concerns\RequiresDbTemplate;
use App\Models\Setting;
use App\Mail\TemplateMailable;
use App\Support\MailGlobals;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class SnapshotActionMail extends Mailable
class SnapshotActionMail extends TemplateMailable
{
use Queueable, SerializesModels, RequiresDbTemplate;
use Queueable, SerializesModels;
private const LABELS = [
'create' => ['icon' => '📸', 'title' => 'Tạo Snapshot thành công', 'desc' => 'Snapshot máy chủ ảo của bạn đã được tạo thành công', 'status' => 'Tạo thành công ✓'],
@@ -22,45 +19,19 @@ class SnapshotActionMail extends Mailable
public function __construct(public array $data) {}
public function envelope(): Envelope
{
return new Envelope(
subject: $this->renderFromTemplate('snapshot_action', $this->vars())['subject'],
);
}
public function content(): Content
{
return new Content(
htmlString: $this->renderFromTemplate('snapshot_action', $this->vars())['html'],
);
}
public function attachments(): array
{
return [];
}
private function payload(): array
protected function scenario(): string
{
try {
$setting = Setting::where('key', 'setting')->first();
$settingValue = $setting ? json_decode((string) $setting->value, true) : [];
} catch (\Throwable) {
$settingValue = [];
}
return array_merge([
'company_name' => $settingValue['company'] ?? config('app.name', 'The Gioi So'),
'support_email' => $settingValue['email'] ?? config('mail.support_email', 'support@thegioiso.com'),
'support_phone' => $settingValue['hotline'] ?? ($settingValue['phone'] ?? config('mail.support_phone', '1900.xxxx')),
'login_url' => rtrim(config('app.frontend_url', ''), '/') . '/dang-nhap',
], $this->data);
return 'snapshot_action';
}
private function vars(): array
protected function variables(): array
{
$data = $this->payload();
$data = [...MailGlobals::values(), ...$this->data];
$label = self::LABELS[$data['action'] ?? 'create'] ?? self::LABELS['create'];
return [
@@ -73,9 +44,6 @@ class SnapshotActionMail extends Mailable
'action_title' => $label['title'],
'action_desc' => $label['desc'],
'action_status' => $label['status'],
'login_url' => $data['login_url'] ?? rtrim(config('app.frontend_url', ''), '/') . '/dang-nhap',
'support_email' => $data['support_email'] ?? config('mail.support_email', 'support@thegioiso.com'),
'support_phone' => $data['support_phone'] ?? config('mail.support_phone', '1900.xxxx'),
];
}
}
+11 -22
View File
@@ -2,16 +2,14 @@
namespace App\Mail\Customer;
use App\Mail\Concerns\RequiresDbTemplate;
use App\Mail\TemplateMailable;
use App\Support\MailGlobals;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class VMCreationFailed extends Mailable
class VMCreationFailed extends TemplateMailable
{
use Queueable, SerializesModels, RequiresDbTemplate;
use Queueable, SerializesModels;
public array $data;
@@ -20,33 +18,24 @@ class VMCreationFailed extends Mailable
$this->data = $data;
}
public function envelope(): Envelope
{
return new Envelope(
subject: $this->renderFromTemplate('vm_creation_failed', $this->vars())['subject'],
);
}
public function content(): Content
{
return new Content(
htmlString: $this->renderFromTemplate('vm_creation_failed', $this->vars())['html'],
);
}
public function attachments(): array
{
return [];
}
private function vars(): array
protected function scenario(): string
{
return 'vm_creation_failed';
}
protected function variables(): array
{
return [
'app_name' => config('app.name', 'Thế Giới Số'),
'full_name' => $this->data['customer_name'] ?? 'Khách hàng',
'order_num' => $this->data['order_num'] ?? 'N/A',
'product_name' => $this->data['product_name'] ?? 'Sản phẩm',
'invoice_url' => rtrim(env('FRONTEND_URL', config('app.url')), '/') . '/hoa-don/' . ($this->data['order_id'] ?? ''),
'invoice_url' => MailGlobals::frontendUrl() . '/hoa-don/' . ($this->data['order_id'] ?? ''),
'support_email' => config('mail.support_email', 'support@example.com'),
'support_phone' => config('mail.support_phone', '1900-xxxx'),
];
+8 -38
View File
@@ -2,65 +2,35 @@
namespace App\Mail\Customer;
use App\Mail\Concerns\RequiresDbTemplate;
use App\Models\Setting;
use App\Mail\TemplateMailable;
use App\Support\MailGlobals;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class VerifyAccount extends Mailable
class VerifyAccount extends TemplateMailable
{
use Queueable, SerializesModels, RequiresDbTemplate;
use Queueable, SerializesModels;
public function __construct(public array $data) {}
public function envelope(): Envelope
{
return new Envelope(
subject: $this->renderFromTemplate('verify_account_welcome', $this->vars())['subject'],
);
}
public function content(): Content
{
return new Content(
htmlString: $this->renderFromTemplate('verify_account_welcome', $this->vars())['html'],
);
}
public function attachments(): array
{
return [];
}
private function payload(): array
protected function scenario(): string
{
try {
$setting = Setting::where('key', 'setting')->first();
$settingValue = $setting ? json_decode((string) $setting->value, true) : [];
} catch (\Throwable) {
$settingValue = [];
}
return array_merge([
'company_name' => $settingValue['company'] ?? config('app.name', 'The Gioi So'),
'support_email' => $settingValue['email'] ?? config('mail.support_email', 'support@thegioiso.com'),
'support_phone' => $settingValue['hotline'] ?? ($settingValue['phone'] ?? config('mail.support_phone', '1900.xxxx')),
], $this->data);
return 'verify_account_welcome';
}
private function vars(): array
protected function variables(): array
{
$data = $this->payload();
$data = [...MailGlobals::values(), ...$this->data];
return [
'app_name' => $data['company_name'] ?? config('app.name', 'The Gioi So'),
'full_name' => $data['fullname'] ?? 'Quý khách',
'verify_code' => $data['verify_code'] ?? '',
'support_email' => $data['support_email'] ?? config('mail.support_email', 'support@thegioiso.com'),
'support_phone' => $data['support_phone'] ?? config('mail.support_phone', '1900.xxxx'),
];
}
}
+8 -39
View File
@@ -2,22 +2,19 @@
namespace App\Mail\Customer;
use App\Mail\Concerns\RequiresDbTemplate;
use App\Models\Setting;
use App\Mail\TemplateMailable;
use App\Support\MailGlobals;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Attachment;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
/**
* Email thông tin truy cập VPS sau khi khởi tạo — render từ Template DB
* (scenario=vm_credentials), thay cho blade tĩnh send-info-vps trước đây.
*/
class VmCredentials extends Mailable
class VmCredentials extends TemplateMailable
{
use Queueable, SerializesModels, RequiresDbTemplate;
use Queueable, SerializesModels;
public function __construct(
public array $data,
@@ -25,20 +22,6 @@ class VmCredentials extends Mailable
public readonly ?string $invoicePdfFileName = null,
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: $this->renderFromTemplate('vm_credentials', $this->vars())['subject'],
);
}
public function content(): Content
{
return new Content(
htmlString: $this->renderFromTemplate('vm_credentials', $this->vars())['html'],
);
}
public function attachments(): array
{
if ($this->invoicePdf !== null && $this->invoicePdfFileName !== null) {
@@ -51,25 +34,14 @@ class VmCredentials extends Mailable
return [];
}
private function payload(): array
protected function scenario(): string
{
try {
$setting = Setting::where('key', 'setting')->first();
$settingValue = $setting ? json_decode((string) $setting->value, true) : [];
} catch (\Throwable) {
$settingValue = [];
}
return array_merge([
'support_email' => $settingValue['email'] ?? config('mail.support_email', 'support@thegioiso.com'),
'support_phone' => $settingValue['hotline'] ?? ($settingValue['phone'] ?? config('mail.support_phone', '1900.xxxx')),
'login_url' => rtrim(env('FRONTEND_URL', config('app.url')), '/') . '/dang-nhap',
], $this->data);
return 'vm_credentials';
}
private function vars(): array
protected function variables(): array
{
$data = $this->payload();
$data = [...MailGlobals::values(), ...$this->data];
$row = fn (string $label, ?string $value) => $value
? "<tr bgcolor=\"#FFF1E6\"><td style=\"padding: 7px 11px; border-right: 1px solid #e8e8e8; width: 200px;\">{$label}</td><td style=\"padding: 7px 11px; border-right: 1px solid #e8e8e8;\"><strong>{$value}</strong></td></tr>"
@@ -87,9 +59,6 @@ class VmCredentials extends Mailable
'service_password' => $data['service_password'] ?? '',
'service_port_row' => $row('Cổng kết nối:', $data['service_port'] ?? null),
'service_due_exp_row' => $row('Hạn sử dụng:', $data['service_due_exp'] ?? null),
'login_url' => $data['login_url'] ?? rtrim(env('FRONTEND_URL', config('app.url')), '/') . '/dang-nhap',
'support_email' => $data['support_email'] ?? config('mail.support_email', 'support@thegioiso.com'),
'support_phone' => $data['support_phone'] ?? config('mail.support_phone', '1900.xxxx'),
];
}
}
+8 -40
View File
@@ -2,59 +2,30 @@
namespace App\Mail\Customer;
use App\Mail\Concerns\RequiresDbTemplate;
use App\Models\Setting;
use App\Mail\TemplateMailable;
use App\Support\MailGlobals;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class VmPasswordChanged extends Mailable
class VmPasswordChanged extends TemplateMailable
{
use Queueable, SerializesModels, RequiresDbTemplate;
use Queueable, SerializesModels;
public function __construct(public array $data) {}
public function envelope(): Envelope
{
return new Envelope(
subject: $this->renderFromTemplate('vm_password_changed', $this->vars())['subject'],
);
}
public function content(): Content
{
return new Content(
htmlString: $this->renderFromTemplate('vm_password_changed', $this->vars())['html'],
);
}
public function attachments(): array
{
return [];
}
private function payload(): array
protected function scenario(): string
{
try {
$setting = Setting::where('key', 'setting')->first();
$settingValue = $setting ? json_decode((string) $setting->value, true) : [];
} catch (\Throwable) {
$settingValue = [];
}
return array_merge([
'company_name' => $settingValue['company'] ?? config('app.name', 'The Gioi So'),
'support_email' => $settingValue['email'] ?? config('mail.support_email', 'support@thegioiso.com'),
'support_phone' => $settingValue['hotline'] ?? ($settingValue['phone'] ?? config('mail.support_phone', '1900.xxxx')),
'login_url' => rtrim(config('app.frontend_url', ''), '/') . '/dang-nhap',
], $this->data);
return 'vm_password_changed';
}
private function vars(): array
protected function variables(): array
{
$data = $this->payload();
$data = [...MailGlobals::values(), ...$this->data];
$newPassword = $data['newPassword'] ?? '';
return [
@@ -64,9 +35,6 @@ class VmPasswordChanged extends Mailable
'new_password_row' => $newPassword
? '<div>Mật khẩu mới: <strong style="font-family: \'Menlo\',\'Consolas\',monospace; color: #111827;">' . $newPassword . '</strong></div>'
: '',
'login_url' => $data['login_url'] ?? rtrim(config('app.frontend_url', ''), '/') . '/dang-nhap',
'support_email' => $data['support_email'] ?? config('mail.support_email', 'support@thegioiso.com'),
'support_phone' => $data['support_phone'] ?? config('mail.support_phone', '1900.xxxx'),
];
}
}
+11 -22
View File
@@ -2,16 +2,14 @@
namespace App\Mail\Customer;
use App\Mail\Concerns\RequiresDbTemplate;
use App\Mail\TemplateMailable;
use App\Support\MailGlobals;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class VmRebuildFailed extends Mailable
class VmRebuildFailed extends TemplateMailable
{
use Queueable, SerializesModels, RequiresDbTemplate;
use Queueable, SerializesModels;
public array $data;
@@ -20,33 +18,24 @@ class VmRebuildFailed extends Mailable
$this->data = $data;
}
public function envelope(): Envelope
{
return new Envelope(
subject: $this->renderFromTemplate('vm_rebuild_failed', $this->vars())['subject'],
);
}
public function content(): Content
{
return new Content(
htmlString: $this->renderFromTemplate('vm_rebuild_failed', $this->vars())['html'],
);
}
public function attachments(): array
{
return [];
}
private function vars(): array
protected function scenario(): string
{
return 'vm_rebuild_failed';
}
protected function variables(): array
{
return [
'app_name' => config('app.name', 'Thế Giới Số'),
'full_name' => $this->data['customer_name'] ?? 'Khách hàng',
'vm_name' => $this->data['vm_name'] ?? 'N/A',
'ip' => $this->data['ip'] ?? 'N/A',
'service_url' => rtrim((string) env('FRONTEND_URL', config('app.url')), '/') . '/dich-vu',
'service_url' => MailGlobals::frontendUrl() . '/dich-vu',
'support_email' => config('mail.support_email', 'support@example.com'),
'support_phone' => config('mail.support_phone', '1900-xxxx'),
];
+11 -43
View File
@@ -2,66 +2,34 @@
namespace App\Mail\Customer;
use App\Mail\Concerns\RequiresDbTemplate;
use App\Models\Setting;
use App\Mail\TemplateMailable;
use App\Support\MailGlobals;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class Welcome extends Mailable
class Welcome extends TemplateMailable
{
use Queueable, SerializesModels, RequiresDbTemplate;
use Queueable, SerializesModels;
public function __construct(public array $data) {}
public function envelope(): Envelope
{
return new Envelope(
subject: $this->renderFromTemplate('customer_welcome', $this->vars())['subject'],
);
}
public function content(): Content
{
return new Content(
htmlString: $this->renderFromTemplate('customer_welcome', $this->vars())['html'],
);
}
public function attachments(): array
{
return [];
}
private function vars(): array
protected function scenario(): string
{
$data = $this->payload();
return 'customer_welcome';
}
protected function variables(): array
{
$data = [...MailGlobals::values(), ...$this->data];
return [
'app_name' => $data['company_name'] ?? config('app.name', 'The Gioi So'),
'full_name' => $data['fullname'] ?? 'Quý khách',
'login_url' => $data['login_url'] ?? rtrim(env('FRONTEND_URL', config('app.url')), '/') . '/dang-nhap',
'support_email' => $data['support_email'] ?? config('mail.support_email', 'support@thegioiso.com'),
'support_phone' => $data['support_phone'] ?? config('mail.support_phone', '1900.xxxx'),
];
}
private function payload(): array
{
try {
$setting = Setting::where('key', 'setting')->first();
$settingValue = $setting ? json_decode((string) $setting->value, true) : [];
} catch (\Throwable) {
$settingValue = [];
}
return array_merge([
'company_name' => $settingValue['company'] ?? config('app.name', 'The Gioi So'),
'support_email' => $settingValue['email'] ?? config('mail.support_email', 'support@thegioiso.com'),
'support_phone' => $settingValue['hotline'] ?? ($settingValue['phone'] ?? config('mail.support_phone', '1900.xxxx')),
'login_url' => rtrim(env('FRONTEND_URL', config('app.url')), '/') . '/dang-nhap',
], $this->data);
}
}
+8 -21
View File
@@ -2,40 +2,27 @@
namespace App\Mail;
use App\Mail\Concerns\RequiresDbTemplate;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class ExpiryService extends Mailable
class ExpiryService extends TemplateMailable
{
use Queueable, SerializesModels, RequiresDbTemplate;
use Queueable, SerializesModels;
/** @param int $template Không còn dùng — scenario cố định 'service_expiry'. Giữ tham số để không phá vỡ caller hiện tại. */
public function __construct($template = 0, public $data = []) {}
public function envelope(): Envelope
{
return new Envelope(
subject: $this->renderFromTemplate('service_expiry', $this->vars())['subject'],
);
}
public function content(): Content
{
return new Content(
htmlString: $this->renderFromTemplate('service_expiry', $this->vars())['html'],
);
}
public function attachments(): array
{
return [];
}
private function vars(): array
protected function scenario(): string
{
return 'service_expiry';
}
protected function variables(): array
{
return [
'company_name' => $this->data['company_name'] ?? '',
+3 -14
View File
@@ -2,9 +2,9 @@
namespace App\Mail;
use App\Mail\Concerns\BuildsTemplateEnvelope;
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;
@@ -16,7 +16,7 @@ use Illuminate\Queue\SerializesModels;
*/
class GenericTemplateMail extends Mailable
{
use Queueable, SerializesModels;
use Queueable, SerializesModels, BuildsTemplateEnvelope;
public function __construct(
public readonly string $renderedSubject,
@@ -26,22 +26,11 @@ class GenericTemplateMail extends Mailable
public function envelope(): Envelope
{
return new Envelope(
subject: $this->renderedSubject,
from: $this->address($this->mailConfig['from_email'] ?? null, $this->mailConfig['from_name'] ?? null),
replyTo: array_filter([$this->address($this->mailConfig['reply_to'] ?? null, $this->mailConfig['reply_to_name'] ?? null)]),
cc: $this->mailConfig['cc'] ?? [],
bcc: $this->mailConfig['bcc'] ?? [],
);
return $this->templateEnvelope($this->renderedSubject, $this->mailConfig);
}
public function content(): Content
{
return new Content(htmlString: $this->renderedHtml);
}
private function address(?string $email, ?string $name): ?Address
{
return $email ? new Address($email, $name) : null;
}
}
+7 -22
View File
@@ -3,15 +3,11 @@
namespace App\Mail;
use App\Enums\Invoice\InvoiceStatus;
use App\Mail\Concerns\RequiresDbTemplate;
use App\Models\Invoice;
use App\Services\InvoicePaymentService;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Attachment;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
/**
* KHÔNG implement ShouldQueue — gọi từ trong queued job, gửi đồng bộ.
@@ -20,10 +16,8 @@ use Illuminate\Mail\Mailables\Envelope;
* đi qua bảng email_notification_templates riêng và rơi về blade khi không có template —
* hai hệ template song song cho cùng một email, admin sửa một nơi thì nơi kia vẫn nguyên.
*/
class InvoiceNotificationMail extends Mailable
class InvoiceNotificationMail extends TemplateMailable
{
use RequiresDbTemplate;
public function __construct(
public readonly Invoice $invoice,
public readonly Collection $invoiceAttachments,
@@ -36,20 +30,6 @@ class InvoiceNotificationMail extends Mailable
public readonly ?string $paymentUrl = null,
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: $this->renderFromTemplate('invoice_notification', $this->vars())['subject'],
);
}
public function content(): Content
{
return new Content(
htmlString: $this->renderFromTemplate('invoice_notification', $this->vars())['html'],
);
}
public function attachments(): array
{
if ($this->invoicePdf !== null && $this->invoicePdfFileName !== null) {
@@ -62,7 +42,12 @@ class InvoiceNotificationMail extends Mailable
return [];
}
private function vars(): array
protected function scenario(): string
{
return 'invoice_notification';
}
protected function variables(): array
{
$isRenewal = $this->type === 'renewal';
+5 -18
View File
@@ -2,39 +2,26 @@
namespace App\Mail;
use App\Mail\Concerns\RequiresDbTemplate;
use App\Models\Customer;
use App\Models\Service;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class ServiceRenewalReminderMail extends Mailable
class ServiceRenewalReminderMail extends TemplateMailable
{
use Queueable, SerializesModels, RequiresDbTemplate;
use Queueable, SerializesModels;
public function __construct(
public readonly Customer $customer,
public readonly Service $service,
) {}
public function envelope(): Envelope
protected function scenario(): string
{
return new Envelope(
subject: $this->renderFromTemplate('service_renewal_reminder', $this->vars())['subject'],
);
return 'service_renewal_reminder';
}
public function content(): Content
{
return new Content(
htmlString: $this->renderFromTemplate('service_renewal_reminder', $this->vars())['html'],
);
}
private function vars(): array
protected function variables(): array
{
$serverName = $this->service->server_name ?? '';
$price = $this->service->orderItem?->price
+52
View File
@@ -0,0 +1,52 @@
<?php
namespace App\Mail;
use App\Mail\Concerns\BuildsTemplateEnvelope;
use App\Models\Template;
use App\Services\Email\EmailTemplateRenderer;
use App\Services\Email\EmailTemplateService;
use App\Support\MailGlobals;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
abstract class TemplateMailable extends Mailable
{
use BuildsTemplateEnvelope;
private ?array $rendered = null;
abstract protected function scenario(): string;
/**
* @return array<string, mixed>
*/
abstract protected function variables(): array;
public function envelope(): Envelope
{
return $this->templateEnvelope($this->rendered()['subject'], $this->template()->mailConfig());
}
public function content(): Content
{
return new Content(htmlString: $this->rendered()['html']);
}
/**
* @return array{subject: string, html: string}
*/
private function rendered(): array
{
return $this->rendered ??= app(EmailTemplateRenderer::class)->render(
$this->template(),
[...MailGlobals::values(), ...$this->variables()],
);
}
private function template(): Template
{
return app(EmailTemplateService::class)->loadTemplate($this->scenario());
}
}
+8 -21
View File
@@ -2,43 +2,30 @@
namespace App\Mail;
use App\Mail\Concerns\RequiresDbTemplate;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class TicketCreated extends Mailable implements ShouldQueue
class TicketCreated extends TemplateMailable implements ShouldQueue
{
use Queueable, SerializesModels, RequiresDbTemplate;
use Queueable, SerializesModels;
public function __construct(
public readonly mixed $ticket,
public readonly mixed $customer,
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: $this->renderFromTemplate('ticket_created', $this->vars())['subject'],
);
}
public function content(): Content
{
return new Content(
htmlString: $this->renderFromTemplate('ticket_created', $this->vars())['html'],
);
}
public function attachments(): array
{
return [];
}
private function vars(): array
protected function scenario(): string
{
return 'ticket_created';
}
protected function variables(): array
{
$room = $this->ticket->room ?? null;
+8 -21
View File
@@ -2,18 +2,14 @@
namespace App\Mail;
use App\Mail\Concerns\RequiresDbTemplate;
use App\Models\Customer;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class VmSuspendedMail extends Mailable implements ShouldQueue
class VmSuspendedMail extends TemplateMailable implements ShouldQueue
{
use Queueable, SerializesModels, RequiresDbTemplate;
use Queueable, SerializesModels;
public function __construct(
public readonly Customer $customer,
@@ -21,26 +17,17 @@ class VmSuspendedMail extends Mailable implements ShouldQueue
public readonly string $vmwareId,
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: $this->renderFromTemplate('vm_suspended', $this->vars())['subject'],
);
}
public function content(): Content
{
return new Content(
htmlString: $this->renderFromTemplate('vm_suspended', $this->vars())['html'],
);
}
public function attachments(): array
{
return [];
}
private function vars(): array
protected function scenario(): string
{
return 'vm_suspended';
}
protected function variables(): array
{
return [
'customer_name' => $this->customer->fullname,
+7
View File
@@ -23,6 +23,13 @@ class Invoice extends Model
'auto_renew' => 'boolean',
];
protected static function booted(): void
{
static::creating(function (Invoice $invoice) {
$invoice->date ??= now()->toDateString();
});
}
public function customer()
{
return $this->belongsTo(Customer::class);
+5
View File
@@ -34,6 +34,11 @@ class Mail extends Model
);
}
public function campaign()
{
return $this->belongsTo(MailCampaign::class, 'campaign_id');
}
public function customer()
{
return $this->belongsTo(Customer::class, 'customer_id');
+7
View File
@@ -18,6 +18,7 @@ class MailCampaign extends Model
'template_id',
'subject',
'message',
'variables',
'cc',
'bcc',
'recipient_type',
@@ -30,6 +31,7 @@ class MailCampaign extends Model
];
protected $casts = [
'variables' => 'array',
'total_recipients' => 'integer',
'sent_count' => 'integer',
'failed_count' => 'integer',
@@ -52,4 +54,9 @@ class MailCampaign extends Model
{
return $this->belongsTo(Template::class, 'template_id');
}
public function creator()
{
return $this->belongsTo(User::class, 'created_by');
}
}
+32 -42
View File
@@ -3,6 +3,8 @@
namespace App\Models;
use App\Support\EmailTemplateShell;
use App\Support\MailGlobals;
use App\Support\MailPlaceholders;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Cache;
@@ -57,54 +59,42 @@ class Template extends Model
return collect($this->metadata['variables'] ?? [])->keyBy('key')->all();
}
/**
* @param array<int, array{key: string, label?: string|null, example?: string|null}> $overrides
*/
public function syncVariables(array $overrides = []): void
{
$declared = $this->variables();
$used = MailPlaceholders::extract($this->subject, $this->message);
$overrides = collect($overrides)->keyBy('key');
$variables = collect(array_unique([...array_keys($declared), ...$used]))
->reject(fn (string $key) => ($declared[$key]['custom'] ?? false) && !in_array($key, $used, true))
->map(function (string $key) use ($declared, $overrides) {
$variable = $declared[$key] ?? ['key' => $key, 'label' => MailGlobals::LABELS[$key] ?? $key, 'type' => 'string', 'custom' => true];
$override = $overrides->get($key, []);
if (filled($override['label'] ?? null)) {
$variable['label'] = trim($override['label']);
}
if (array_key_exists('example', $override)) {
$variable['example'] = (string) $override['example'];
}
return $variable;
})
->values()
->all();
$this->metadata = [...($this->metadata ?? []), 'variables' => $variables];
}
/** Mail sender config khai báo trong metadata.mail (from_email, from_name, reply_to, reply_to_name, cc, bcc). */
public function mailConfig(): array
{
return $this->metadata['mail'] ?? [];
}
public const SCENARIOS = [
'layout' => 'Bố cục Header/Footer (mặc định)',
'vm_credentials' => 'Khởi tạo dịch vụ',
'renewal_reminder' => 'Nhắc gia hạn',
'news_notification' => 'Thông báo tin tức',
'invoice_pdf' => 'Hoá đơn điện tử',
'vm_suspended' => 'VPS bị tạm ngưng',
'domain_order_received' => 'Đơn đăng ký tên miền đã ghi nhận',
'hosting_account_created' => 'Hosting đã khởi tạo',
'manual_deposit_status' => 'Trạng thái nạp tiền thủ công',
'order_payment_success' => 'Thanh toán đơn hàng thành công',
'password_reset_admin' => 'Admin cấp lại mật khẩu',
'service_renewal_reminder'=> 'Nhắc gia hạn dịch vụ',
'snapshot_action' => 'Thao tác Snapshot VPS',
'ticket_created' => 'Ticket hỗ trợ mới',
'verify_account_welcome' => 'Mã xác thực tài khoản',
'vm_creation_failed' => 'Khởi tạo VPS thất bại',
'vm_password_changed' => 'Đổi mật khẩu VPS',
'vm_rebuild_failed' => 'Rebuild VPS thất bại',
'domain_order_approved' => 'Tên miền được duyệt',
'domain_order_rejected' => 'Tên miền bị từ chối',
'order_created' => 'Xác nhận đơn hàng',
'admin_order_notification' => '[Admin] Đơn hàng mới',
'admin_domain_order_new' => '[Admin] Đơn đăng ký tên miền mới',
'admin_hosting_account_created' => '[Admin] Hosting account mới',
'admin_manual_deposit_pending' => '[Admin] Yêu cầu nạp tiền chờ duyệt',
'admin_ticket_notification' => '[Admin] Ticket hỗ trợ mới',
'admin_trial_registration_request' => '[Admin] Yêu cầu dùng thử VPS',
'customer_deposit_success' => 'Nạp tiền thành công',
'customer_welcome' => 'Chào mừng khách hàng mới',
'vm_port_change' => 'Đổi port Remote VPS',
'vm_password_reset_success' => 'Đặt lại mật khẩu VPS thành công',
'customer_signup_verify' => 'Xác thực đăng ký tài khoản',
'hosting_information' => 'Thông tin truy cập Hosting',
'customer_password_reset' => 'Khôi phục mật khẩu khách hàng',
'customer_password_reset_link' => 'Link đặt lại mật khẩu khách hàng',
'service_expiry' => 'Dịch vụ sắp hết hạn',
'customer_payment' => 'Thông báo thanh toán khách hàng',
'invoice_payment_reminder' => 'Nhắc thanh toán hoá đơn',
'invoice_notification' => 'Thông báo hoá đơn / gia hạn cước',
];
public function invoiceTemplate()
{
return $this->belongsTo(self::class, 'invoice_template_id');
+4 -17
View File
@@ -2,6 +2,7 @@
namespace App\Services\Customer;
use App\Support\EmailRecipients;
use App\Mail\Customer\ManualCustomerEmail;
use App\Mail\Customer\PasswordResetByAdmin;
use App\Models\Customer;
@@ -93,9 +94,9 @@ class CustomerMailService
abort_if($subject === '' || $message === '', 422, 'Subject và content không được để trống.');
$to = $this->parseEmailRecipients($data['to'] ?? null) ?: [$customer->email];
$cc = $this->parseEmailRecipients($data['cc'] ?? null);
$bcc = $this->parseEmailRecipients($data['bcc'] ?? null);
$to = EmailRecipients::parse($data['to'] ?? null) ?: [$customer->email];
$cc = EmailRecipients::parse($data['cc'] ?? null);
$bcc = EmailRecipients::parse($data['bcc'] ?? null);
abort_if($to === [], 422, 'Không có địa chỉ email nhận hợp lệ.');
@@ -129,18 +130,4 @@ class CustomerMailService
'customer_id' => $customer->id,
]);
}
private function parseEmailRecipients(?string $value): array
{
if (!$value) {
return [];
}
return collect(preg_split('/[,;]+/', $value) ?: [])
->map(fn($email) => trim((string) $email))
->filter(fn($email) => $email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL))
->unique()
->values()
->all();
}
}
+6 -9
View File
@@ -4,6 +4,7 @@ namespace App\Services\Email;
use App\Exceptions\InvalidTemplateVariableException;
use App\Models\Template;
use App\Support\MailPlaceholders;
/**
* Render Template subject/message: chỉ thay thế placeholder dạng {{key}} đã khai báo
@@ -11,7 +12,7 @@ use App\Models\Template;
*/
class EmailTemplateRenderer
{
private const PLACEHOLDER_PATTERN = '/\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/';
private const PLACEHOLDER_PATTERN = MailPlaceholders::PATTERN;
/**
* @return array{subject: string, html: string}
@@ -31,7 +32,7 @@ class EmailTemplateRenderer
private function assertKnownVariables(string $scenario, string $content, array $variables): void
{
$used = $this->extractPlaceholders($content);
$used = MailPlaceholders::extract($content);
$invalid = array_values(array_diff($used, array_keys($variables)));
if ($invalid !== []) {
@@ -39,17 +40,13 @@ class EmailTemplateRenderer
}
}
private function extractPlaceholders(string $content): array
{
preg_match_all(self::PLACEHOLDER_PATTERN, $content, $matches);
return array_values(array_unique($matches[1]));
}
private function replace(string $content, array $variables, array $data): string
{
return preg_replace_callback(self::PLACEHOLDER_PATTERN, function (array $match) use ($variables, $data) {
$key = $match[1];
if (!isset($variables[$key]) && !array_key_exists($key, $data)) {
return $match[0];
}
$definition = $variables[$key] ?? [];
$value = $data[$key] ?? $definition['example'] ?? '';
@@ -0,0 +1,53 @@
<?php
namespace App\Services\Email;
use App\Models\Customer;
use App\Models\MailCampaign;
use App\Models\Template;
use App\Support\MailGlobals;
use App\Support\MailPlaceholders;
final class MailCampaignRenderer
{
/**
* @param array<string, string> $values
* @return array{subject: string, html: string}
*/
public function render(MailCampaign $campaign, array $values): array
{
$subject = MailPlaceholders::fill((string) $campaign->subject, $values, escapeHtml: false);
$content = MailPlaceholders::fill((string) $campaign->message, $values);
return [
'subject' => $subject,
'html' => $campaign->template_id ? $content : Template::renderWithLayout($subject, $content),
];
}
/**
* @return array<string, string>
*/
public function recipientValues(MailCampaign $campaign, ?Customer $customer, string $email): array
{
return [...MailGlobals::values(), ...MailPlaceholders::recipientValues($customer, $email), ...$this->adminValues($campaign)];
}
/**
* @return array<string, string>
*/
public function sampleValues(MailCampaign $campaign): array
{
$samples = array_map(fn (string $key) => "[{$key}]", array_combine(
array_keys(MailPlaceholders::RECIPIENT_FIELDS),
array_keys(MailPlaceholders::RECIPIENT_FIELDS),
));
return [...MailGlobals::values(), ...$samples, ...$this->adminValues($campaign)];
}
private function adminValues(MailCampaign $campaign): array
{
return array_filter($campaign->variables ?? [], fn ($value) => $value !== null && $value !== '');
}
}
+65
View File
@@ -0,0 +1,65 @@
<?php
namespace App\Services\Email;
use App\Models\Template;
use App\Support\EmailScenarioRegistry;
use Illuminate\Http\Exceptions\HttpResponseException;
final class TemplateGuard
{
public function assertCanSave(Template $template): void
{
if ($template->exists) {
$this->assertKeepsRegisteredIdentity($template);
}
$definition = EmailScenarioRegistry::describe($template->key, $template->scenario);
if (!$definition['registered']) {
return;
}
$duplicate = Template::query()
->whereKeyNot($template->id)
->where(fn ($q) => $q->where('key', $definition['scenario'])->orWhere('scenario', $definition['scenario']))
->exists();
if ($duplicate) {
$this->reject("Scenario \"{$definition['scenario']}\" đã có template, không tạo thêm được.");
}
if (!$definition['permissions']['disable'] && (int) $template->status !== 1) {
$this->reject('Template hệ thống này không được tắt: code đang gửi email theo template này.');
}
$missing = EmailScenarioRegistry::missingVariables($definition['required_variables'], $template->subject, $template->message);
if ($missing !== []) {
$this->reject(
'Template thiếu biến bắt buộc: ' . implode(', ', array_map(fn ($key) => '{{$' . $key . '}}', $missing)),
['missing_variables' => $missing],
);
}
}
public function assertCanDelete(Template $template): void
{
$definition = EmailScenarioRegistry::describe($template->key, $template->scenario);
if (!$definition['permissions']['delete']) {
$this->reject('Template hệ thống không được xoá: code đang gửi email theo template này.');
}
}
private function assertKeepsRegisteredIdentity(Template $template): void
{
$before = EmailScenarioRegistry::describe($template->getOriginal('key'), $template->getOriginal('scenario'));
if ($before['registered'] && !$before['permissions']['change_key'] && $template->isDirty(['key', 'scenario'])) {
$this->reject("Không được đổi key/scenario của template hệ thống \"{$before['scenario']}\".");
}
}
private function reject(string $message, array $extra = []): never
{
throw new HttpResponseException(response()->json(['error' => true, 'message' => $message, ...$extra], 422));
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace App\Support;
final class EmailRecipients
{
/**
* @return array<int, string>
*/
public static function parse(?string $value): array
{
if (!$value) {
return [];
}
return collect(preg_split('/[,;]+/', $value) ?: [])
->map(fn ($email) => trim((string) $email))
->filter(fn ($email) => $email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL))
->unique()
->values()
->all();
}
}
+214
View File
@@ -0,0 +1,214 @@
<?php
namespace App\Support;
final class EmailScenarioRegistry
{
public const LAYOUT = 'LAYOUT';
public const TRANSACTIONAL = 'TRANSACTIONAL';
public const BUSINESS = 'BUSINESS';
public const INTERNAL = 'INTERNAL';
public const MARKETING = 'MARKETING';
public const CATEGORIES = [
self::LAYOUT => 'Bố cục chung',
self::TRANSACTIONAL => 'Transactional',
self::BUSINESS => 'Business',
self::INTERNAL => 'Internal',
self::MARKETING => 'Marketing',
];
private const SYSTEM_PERMISSIONS = [
'edit_content' => true,
'delete' => false,
'change_key' => false,
'disable' => false,
];
private const CUSTOM_PERMISSIONS = [
'edit_content' => true,
'delete' => true,
'change_key' => true,
'disable' => true,
];
private const LAYOUT_SLOT_TOKEN = '{{$content}}';
public const SCENARIOS = [
'layout' => [
'label' => 'Bố cục Header/Footer (mặc định)', 'category' => self::LAYOUT, 'group' => 'LAYOUT',
'required_variables' => ['content'],
],
'verify_account_welcome' => [
'label' => 'Mã xác thực tài khoản', 'category' => self::TRANSACTIONAL, 'group' => 'AUTH',
'required_variables' => ['verify_code'],
],
'customer_password_reset_link' => [
'label' => 'Link đặt lại mật khẩu khách hàng', 'category' => self::TRANSACTIONAL, 'group' => 'AUTH',
'required_variables' => ['reset_url'],
],
'password_reset_admin' => [
'label' => 'Admin cấp lại mật khẩu', 'category' => self::TRANSACTIONAL, 'group' => 'SECURITY',
'required_variables' => ['password'],
],
'customer_welcome' => [
'label' => 'Chào mừng khách hàng mới', 'category' => self::TRANSACTIONAL, 'group' => 'ACCOUNT',
'required_variables' => [],
],
'vm_credentials' => [
'label' => 'Khởi tạo VPS (thông tin truy cập)', 'category' => self::BUSINESS, 'group' => 'SERVICE',
'required_variables' => ['service_host', 'service_username', 'service_password'],
],
'hosting_information' => [
'label' => 'Thông tin truy cập Hosting', 'category' => self::BUSINESS, 'group' => 'SERVICE',
'required_variables' => ['service_host', 'service_username', 'service_password'],
],
'hosting_account_created' => [
'label' => 'Hosting đã khởi tạo', 'category' => self::BUSINESS, 'group' => 'SERVICE',
'required_variables' => ['domain_name'],
],
'vm_suspended' => [
'label' => 'VPS bị tạm ngưng', 'category' => self::BUSINESS, 'group' => 'SERVICE',
'required_variables' => ['vm_name'],
],
'service_expiry' => [
'label' => 'Dịch vụ sắp hết hạn', 'category' => self::BUSINESS, 'group' => 'SERVICE',
'required_variables' => ['product_name', 'service_due_exp'],
],
'service_renewal_reminder' => [
'label' => 'Nhắc gia hạn dịch vụ', 'category' => self::BUSINESS, 'group' => 'SERVICE',
'required_variables' => ['product_name', 'expires_at'],
],
'vm_creation_failed' => [
'label' => 'Khởi tạo VPS thất bại', 'category' => self::BUSINESS, 'group' => 'SERVICE',
'required_variables' => ['order_num'],
],
'vm_rebuild_failed' => [
'label' => 'Rebuild VPS thất bại', 'category' => self::BUSINESS, 'group' => 'SERVICE',
'required_variables' => ['vm_name'],
],
'vm_password_changed' => [
'label' => 'Đổi mật khẩu VPS', 'category' => self::BUSINESS, 'group' => 'SERVICE',
'required_variables' => ['vm_name', 'new_password_row'],
],
'snapshot_action' => [
'label' => 'Thao tác Snapshot VPS', 'category' => self::BUSINESS, 'group' => 'SERVICE',
'required_variables' => ['vm_name', 'snapshot_name'],
],
'invoice_notification' => [
'label' => 'Thông báo hoá đơn / gia hạn cước', 'category' => self::BUSINESS, 'group' => 'BILLING',
'required_variables' => ['invoice_number', 'total'],
],
'invoice_payment_reminder' => [
'label' => 'Nhắc thanh toán hoá đơn', 'category' => self::BUSINESS, 'group' => 'BILLING',
'required_variables' => ['invoice_number', 'outstanding'],
],
'customer_deposit_success' => [
'label' => 'Nạp tiền thành công', 'category' => self::BUSINESS, 'group' => 'BILLING',
'required_variables' => ['transaction_code', 'amount_formatted'],
],
'manual_deposit_status' => [
'label' => 'Trạng thái nạp tiền thủ công', 'category' => self::BUSINESS, 'group' => 'BILLING',
'required_variables' => ['transaction_code', 'status_label'],
],
'order_created' => [
'label' => 'Xác nhận đơn hàng', 'category' => self::BUSINESS, 'group' => 'ORDER',
'required_variables' => ['order_num'], 'permissions' => ['disable' => true],
],
'domain_order_received' => [
'label' => 'Đơn đăng ký tên miền đã ghi nhận', 'category' => self::BUSINESS, 'group' => 'ORDER',
'required_variables' => ['domain_name', 'order_num'],
],
'domain_order_approved' => [
'label' => 'Tên miền được duyệt', 'category' => self::BUSINESS, 'group' => 'ORDER',
'required_variables' => ['domain_name'], 'permissions' => ['disable' => true],
],
'domain_order_rejected' => [
'label' => 'Tên miền bị từ chối', 'category' => self::BUSINESS, 'group' => 'ORDER',
'required_variables' => ['domain_name', 'reason'], 'permissions' => ['disable' => true],
],
'ticket_created' => [
'label' => 'Ticket hỗ trợ mới', 'category' => self::BUSINESS, 'group' => 'SUPPORT',
'required_variables' => ['ticket_num'],
],
'admin_order_notification' => [
'label' => '[Admin] Đơn hàng mới', 'category' => self::INTERNAL, 'group' => 'ORDER',
'required_variables' => ['order_num'],
],
'admin_domain_order_new' => [
'label' => '[Admin] Đơn đăng ký tên miền mới', 'category' => self::INTERNAL, 'group' => 'ORDER',
'required_variables' => ['domain_name', 'order_num'],
],
'admin_hosting_account_created' => [
'label' => '[Admin] Hosting account mới', 'category' => self::INTERNAL, 'group' => 'SERVICE',
'required_variables' => ['domain_name'],
],
'admin_trial_registration_request' => [
'label' => '[Admin] Yêu cầu dùng thử VPS', 'category' => self::INTERNAL, 'group' => 'SERVICE',
'required_variables' => ['customer_email'],
],
'admin_manual_deposit_pending' => [
'label' => '[Admin] Yêu cầu nạp tiền chờ duyệt', 'category' => self::INTERNAL, 'group' => 'BILLING',
'required_variables' => ['transaction_code', 'amount_formatted'],
],
'admin_ticket_notification' => [
'label' => '[Admin] Ticket hỗ trợ mới', 'category' => self::INTERNAL, 'group' => 'SUPPORT',
'required_variables' => ['ticket_num'],
],
];
public static function find(?string ...$identifiers): ?string
{
foreach ($identifiers as $identifier) {
if ($identifier !== null && isset(self::SCENARIOS[$identifier])) {
return $identifier;
}
}
return null;
}
/**
* @return array{registered: bool, scenario: string|null, label: string|null, category: string, group: string|null, permissions: array<string, bool>, required_variables: array<int, string>}
*/
public static function describe(?string ...$identifiers): array
{
$scenario = self::find(...$identifiers);
$definition = $scenario ? self::SCENARIOS[$scenario] : null;
return [
'registered' => $definition !== null,
'scenario' => $scenario,
'label' => $definition['label'] ?? null,
'category' => $definition['category'] ?? self::MARKETING,
'group' => $definition['group'] ?? null,
'permissions' => $definition
? [...self::SYSTEM_PERMISSIONS, ...($definition['permissions'] ?? [])]
: self::CUSTOM_PERMISSIONS,
'required_variables' => $definition['required_variables'] ?? [],
];
}
/**
* @param array<int, string> $required
* @return array<int, string>
*/
public static function missingVariables(array $required, ?string $subject, ?string $message): array
{
$content = $subject . "\n" . $message;
$used = MailPlaceholders::extract($content);
return array_values(array_filter(
$required,
fn (string $key) => $key === 'content'
? !str_contains((string) $message, self::LAYOUT_SLOT_TOKEN)
: !in_array($key, $used, true),
));
}
}
+52
View File
@@ -0,0 +1,52 @@
<?php
namespace App\Support;
use App\Models\Setting;
final class MailGlobals
{
public const LABELS = [
'company_name' => 'Tên công ty',
'app_name' => 'Tên hệ thống',
'company_logo' => 'Logo công ty (URL)',
'support_email' => 'Email hỗ trợ',
'support_phone' => 'Hotline hỗ trợ',
'frontend_url' => 'Địa chỉ portal khách hàng',
'login_url' => 'Link đăng nhập',
'current_year' => 'Năm hiện tại',
'today' => 'Ngày hiện tại',
];
public static function frontendUrl(): string
{
return rtrim((string) (config('app.frontend_url') ?: config('app.url')), '/');
}
/**
* @return array<string, string>
*/
public static function values(): array
{
try {
$setting = json_decode((string) Setting::where('key', 'setting')->value('value'), true) ?: [];
} catch (\Throwable) {
$setting = [];
}
$companyName = $setting['company'] ?? config('app.name', 'The Gioi So');
$frontendUrl = self::frontendUrl();
return [
'company_name' => $companyName,
'app_name' => $companyName,
'company_logo' => $setting['logo'] ?? '',
'support_email' => $setting['email'] ?? config('mail.support_email', 'support@thegioiso.com'),
'support_phone' => $setting['hotline'] ?? $setting['phone'] ?? config('mail.support_phone', '1900.xxxx'),
'frontend_url' => $frontendUrl,
'login_url' => $frontendUrl . '/dang-nhap',
'current_year' => now()->format('Y'),
'today' => now()->format('d/m/Y'),
];
}
}
+64
View File
@@ -0,0 +1,64 @@
<?php
namespace App\Support;
use App\Models\Customer;
final class MailPlaceholders
{
public const PATTERN = '/\{\{\s*\$?([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/';
private const LAYOUT_SLOT = 'content';
public const RECIPIENT_FIELDS = [
'client_name' => 'name',
'customer_name' => 'name',
'full_name' => 'name',
'fullname' => 'name',
'name' => 'name',
'customer_email' => 'email',
'email' => 'email',
'customer_phone' => 'phone',
'phone' => 'phone',
];
/**
* @return array<int, string>
*/
public static function extract(?string ...$contents): array
{
preg_match_all(self::PATTERN, implode("\n", array_map('strval', $contents)), $matches);
return array_values(array_diff(array_unique($matches[1]), [self::LAYOUT_SLOT]));
}
/**
* @param array<string, string|null> $values
*/
public static function fill(string $content, array $values, bool $escapeHtml = true): string
{
return preg_replace_callback(self::PATTERN, function (array $match) use ($values, $escapeHtml) {
if ($match[1] === self::LAYOUT_SLOT) {
return $match[0];
}
$value = (string) ($values[$match[1]] ?? '');
return $escapeHtml ? nl2br(htmlspecialchars($value, ENT_QUOTES, 'UTF-8')) : $value;
}, $content) ?? $content;
}
/**
* @return array<string, string>
*/
public static function recipientValues(?Customer $customer, string $email): array
{
$fields = [
'name' => $customer?->fullname ?: $email,
'email' => $email,
'phone' => (string) ($customer?->phone ?? ''),
];
return array_map(fn (string $field) => $fields[$field], self::RECIPIENT_FIELDS);
}
}
@@ -0,0 +1,19 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
DB::table('invoices')
->whereNull('date')
->whereNotNull('created_at')
->update(['date' => DB::raw('DATE(created_at)')]);
}
public function down(): void
{
}
};
@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('mail_campaigns', function (Blueprint $table) {
$table->json('variables')->nullable()->after('message');
});
}
public function down(): void
{
Schema::table('mail_campaigns', function (Blueprint $table) {
$table->dropColumn('variables');
});
}
};
@@ -0,0 +1,21 @@
<?php
use App\Models\Template;
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
public function up(): void
{
Template::query()
->where(fn ($q) => $q->whereNull('format')->orWhere('format', 'html'))
->each(function (Template $template) {
$template->syncVariables();
$template->save();
});
}
public function down(): void
{
}
};
+1
View File
@@ -139,6 +139,7 @@ Route::prefix('mail-campaigns')
Route::get('/', 'index')->middleware('perm:mail-campaign.view');
Route::post('preview', 'preview')->middleware('perm:mail-campaign.create');
Route::get('{uuid}/preview', 'previewCampaign')->middleware('perm:mail-campaign.view');
Route::get('{uuid}/logs/{id}', 'showLog')->whereNumber('id')->middleware('perm:mail-campaign.view');
Route::get('{uuid}', 'show')->middleware('perm:mail-campaign.view');
Route::post('/', 'store')->middleware('perm:mail-campaign.create');
});
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace Tests\Unit;
use App\Support\EmailScenarioRegistry;
use PHPUnit\Framework\TestCase;
class EmailScenarioRegistryTest extends TestCase
{
public function test_registered_scenario_is_locked_and_custom_template_is_marketing(): void
{
$system = EmailScenarioRegistry::describe(null, 'customer_password_reset_link');
$this->assertTrue($system['registered']);
$this->assertSame('TRANSACTIONAL', $system['category']);
$this->assertSame(['edit_content' => true, 'delete' => false, 'change_key' => false, 'disable' => false], $system['permissions']);
$this->assertTrue(EmailScenarioRegistry::describe('order_created')['permissions']['disable']);
$custom = EmailScenarioRegistry::describe('promo_thang_10', 'promo');
$this->assertFalse($custom['registered']);
$this->assertSame('MARKETING', $custom['category']);
$this->assertTrue($custom['permissions']['delete']);
}
public function test_missing_variables_accepts_both_syntaxes_and_layout_slot(): void
{
$this->assertSame([], EmailScenarioRegistry::missingVariables(['reset_url'], 'x', '<a href="{{ $reset_url }}">'));
$this->assertSame([], EmailScenarioRegistry::missingVariables(['reset_url'], 'Link {{reset_url}}', ''));
$this->assertSame(['reset_url'], EmailScenarioRegistry::missingVariables(['reset_url'], 'x', '<p>Đặt lại mật khẩu</p>'));
$this->assertSame([], EmailScenarioRegistry::missingVariables(['content'], null, '<table>{{$content}}</table>'));
$this->assertSame(['content'], EmailScenarioRegistry::missingVariables(['content'], null, '<table></table>'));
}
}
+24
View File
@@ -111,4 +111,28 @@ class EmailTemplateRendererTest extends TestCase
$this->assertStringNotContainsString('49', $result['html']);
$this->assertStringContainsString('&lt;?php', $result['html']);
}
public function test_dollar_placeholders_are_declared_on_sync_and_rendered_with_default(): void
{
$template = $this->makeTemplate(
'Ưu đãi {{$promo_code}}',
'<p>{{ $promo_code }} cho {{full_name}}</p>{{$content}}',
[
['key' => 'full_name', 'label' => 'Họ tên', 'type' => 'string'],
['key' => 'old_custom', 'label' => 'Cũ', 'type' => 'string', 'custom' => true],
],
);
$template->syncVariables([['key' => 'promo_code', 'label' => 'Mã KM', 'example' => 'TGS10']]);
$variables = $template->variables();
$this->assertSame(['full_name', 'promo_code'], array_keys($variables));
$this->assertSame('Mã KM', $variables['promo_code']['label']);
$this->assertTrue($variables['promo_code']['custom']);
$result = (new EmailTemplateRenderer())->render($template, ['full_name' => 'An']);
$this->assertSame('Ưu đãi TGS10', $result['subject']);
$this->assertSame('<p>TGS10 cho An</p>{{$content}}', $result['html']);
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace Tests\Unit;
use App\Models\Customer;
use App\Support\MailPlaceholders;
use PHPUnit\Framework\TestCase;
class MailPlaceholdersTest extends TestCase
{
public function test_extracts_both_placeholder_styles_without_layout_slot(): void
{
$this->assertSame(
['client_name', 'promo_code', 'due_date'],
MailPlaceholders::extract('Xin chào {{ $client_name }}', '<p>{{promo_code}} {{$due_date}} {{$content}} {{client_name}}</p>'),
);
}
public function test_fill_escapes_html_and_keeps_layout_slot(): void
{
$html = MailPlaceholders::fill('<b>{{$name}}</b>{{$content}}{{missing}}', ['name' => "<script>\nx"]);
$this->assertSame("<b>&lt;script&gt;<br />\nx</b>{{\$content}}", $html);
$this->assertSame('Hi <A&B>', MailPlaceholders::fill('Hi {{ name }}', ['name' => '<A&B>'], escapeHtml: false));
}
public function test_recipient_values_fall_back_to_email_for_external_recipient(): void
{
$external = MailPlaceholders::recipientValues(null, 'a@b.vn');
$this->assertSame('a@b.vn', $external['client_name']);
$this->assertSame('', $external['phone']);
$customer = new Customer(['fullname' => 'Nguyễn An', 'phone' => '0909']);
$values = MailPlaceholders::recipientValues($customer, 'an@x.vn');
$this->assertSame('Nguyễn An', $values['customer_name']);
$this->assertSame('an@x.vn', $values['customer_email']);
$this->assertSame('0909', $values['customer_phone']);
}
}