update
This commit is contained in:
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,
|
||||
];
|
||||
|
||||
@@ -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],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 ?? '');
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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'] ?? '',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
];
|
||||
|
||||
@@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'] ?? '',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 !== '');
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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>'));
|
||||
}
|
||||
}
|
||||
@@ -111,4 +111,28 @@ class EmailTemplateRendererTest extends TestCase
|
||||
$this->assertStringNotContainsString('49', $result['html']);
|
||||
$this->assertStringContainsString('<?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']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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><script><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']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user