This commit is contained in:
baodev0
2026-10-08 11:25:28 +07:00
parent 1544629a1f
commit 8651dde6b1
45 changed files with 1543 additions and 357 deletions
@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Api\Admin\Customer\Concerns;
use App\Models\Service;
use App\Support\AutoEmailGate;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -34,6 +35,7 @@ trait ManagesCustomerServices
'requirements' => 'nullable|string',
'feedback' => 'nullable|string',
'price' => 'nullable|numeric|min:0',
'virtual_machine_id' => 'nullable|integer|exists:virtual_machines,id',
'os_id' => 'nullable|integer|exists:os,id',
'vm_cpu' => 'nullable|integer|min:1',
'vm_ram_mb' => 'nullable|integer|min:1',
@@ -74,6 +76,7 @@ trait ManagesCustomerServices
public function notifyServiceRenewal(string $uuid, int $serviceId): JsonResponse
{
AutoEmailGate::markManual();
$this->serviceProvisioning->notifyRenewal($uuid, $serviceId);
return response()->json(['error' => false, 'message' => 'Đã gửi thông báo gia hạn đến khách hàng.']);
@@ -238,6 +238,8 @@ class EmailTemplateController extends Controller
'news_template_id' => 'nullable|integer|exists:template,id',
'invoice_template_id' => 'nullable|integer|exists:template,id',
'mail_enabled' => 'sometimes|boolean',
'auto_disabled' => 'sometimes|array',
'auto_disabled.*' => ['string', Rule::in(array_keys(EmailScenarioRegistry::SCENARIOS))],
]);
$setting = Setting::firstOrNew(['key' => 'email_settings']);
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api\Admin\Invoice\Concerns;
use App\Mail\InvoiceNotificationMail;
use App\Models\Customer;
use App\Services\InvoiceReminderService;
use App\Support\AutoEmailGate;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
@@ -36,6 +37,7 @@ trait SendsInvoiceNotifications
}
try {
AutoEmailGate::markManual();
$reminders->remind($invoice->load('customer'), force: true);
} catch (\Throwable $e) {
return response()->json([
@@ -79,6 +81,7 @@ trait SendsInvoiceNotifications
);
$paymentUrl = url('/client/invoices/' . $invoice->invoice_number . '/payment');
try {
AutoEmailGate::markManual();
$invoicePdf = $this->invoicePdfService->generate($invoice);
$invoicePdfFileName = $this->invoicePdfService->generateFileName($invoice);
@@ -44,6 +44,30 @@ class ServiceController extends Controller
return response()->json(['error' => false, 'data' => $service]);
}
public function bulkAction(Request $request): JsonResponse
{
$data = $request->validate([
'ids' => 'required|array|min:1',
'ids.*' => 'integer|distinct|exists:services,id',
'action' => 'required|in:activate,suspend,cancel,detach-customer',
]);
$updates = match ($data['action']) {
'activate' => ['status' => 'active', 'suspended_at' => null, 'cancelled_at' => null],
'suspend' => ['status' => 'suspended', 'suspended_at' => now()],
'cancel' => ['status' => 'cancelled', 'cancelled_at' => now()],
'detach-customer' => ['customer_id' => null],
};
$updated = Service::whereIn('id', $data['ids'])->update($updates);
return response()->json([
'error' => false,
'message' => "Đã cập nhật {$updated} dịch vụ.",
'data' => ['updated' => $updated],
]);
}
public function detachCustomer(int $id): JsonResponse
{
$service = Service::findOrFail($id);
@@ -129,7 +129,7 @@ class ServiceController extends Controller
{
$customer = $request->user();
$service = Service::with(['orderItem.order.vmWare.vCenter'])
$service = Service::with(['vmware.vCenter', 'orderItem.order.vmWare.vCenter'])
->where('uuid', $uuid)
->where('customer_id', $customer->id)
->first();
@@ -138,7 +138,7 @@ class ServiceController extends Controller
return response()->json(['error' => true, 'message' => 'Không tìm thấy dịch vụ'], 404);
}
$vm = $service->orderItem?->order?->vmWare?->first();
$vm = $service->resolveVmware();
if (!$vm) {
return response()->json(['error' => true, 'message' => 'Dịch vụ chưa được cấp phát VM'], 404);
@@ -31,7 +31,7 @@ trait ManagesVmNetwork
return $service;
}
$vm = $service->orderItem?->order?->vmWare?->first();
$vm = $service->resolveVmware();
if (!$vm) {
return response()->json(['success' => false, 'message' => 'Không tìm thấy VM cho dịch vụ này.'], 404);
}
@@ -77,7 +77,7 @@ trait ManagesVmNetwork
return $service;
}
$vm = $service->orderItem?->order?->vmWare?->first();
$vm = $service->resolveVmware();
if (!$vm) {
return response()->json(['success' => false, 'message' => 'Không tìm thấy VM cho dịch vụ này.'], 404);
@@ -219,8 +219,9 @@ trait ManagesVmNetwork
{
$customer = $request->user();
// VM chain: Service → orderItem → order → vmWare (same as ServiceClientDetailResource)
$service = Service::with([
'vmware.vCenter',
'vmware.ipAddress.host.datacenter.vCenter',
'orderItem.order.vmWare.vCenter',
'orderItem.order.vmWare.ipAddress.host.datacenter.vCenter',
'orderItem.os.osGroup',
+3 -1
View File
@@ -65,7 +65,9 @@ class ExpiryMail implements ShouldQueue
try {
$email = new ExpiryService($this->template_id,$data);
Mail::to($product->Customer->email)->send($email);
if (Mail::to($product->Customer->email)->send($email) === null) {
return;
}
\App\Models\Mail::create([
'template_id' => $this->template_id,
'subject' => $email->envelope()->subject,
+2 -2
View File
@@ -49,11 +49,11 @@ class SendMailCampaignRecipient implements ShouldQueue
if ($bcc = EmailRecipients::parse($campaign->bcc)) {
$mailer->bcc($bcc);
}
$mailer->send(new GenericTemplateMail(
$failed = $mailer->send(new GenericTemplateMail(
$rendered['subject'],
$rendered['html'],
Arr::only($campaign->template?->mailConfig() ?? [], ['from_email', 'from_name', 'reply_to', 'reply_to_name']),
));
)) === null;
} catch (\Throwable $e) {
$failed = true;
Log::warning('Gửi email chiến dịch thất bại', [
+1
View File
@@ -37,6 +37,7 @@ class SendTemplateEmailJob implements ShouldQueue
$rendered['subject'],
$rendered['html'],
$template->mailConfig(),
$this->scenario,
));
}
}
+10
View File
@@ -3,6 +3,7 @@
namespace App\Listeners\Mail;
use App\Models\Setting;
use App\Support\AutoEmailGate;
use Illuminate\Mail\Events\MessageSending;
use Illuminate\Support\Facades\Log;
@@ -26,6 +27,15 @@ class CheckMailEnabled
return false;
}
$scenario = $event->data[AutoEmailGate::SCENARIO_DATA_KEY] ?? null;
if (AutoEmailGate::blocks($scenario)) {
Log::info('Mail sending blocked: automatic sending is off for this scenario.', [
'scenario' => $scenario,
'subject' => $event->message->getSubject(),
]);
return false;
}
return true;
}
}
+5 -4
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Mail\Admin;
use App\Mail\TemplateMailable;
use App\Support\EmailTemplateShell;
use Illuminate\Bus\Queueable;
use Illuminate\Queue\SerializesModels;
@@ -26,11 +27,11 @@ class AdminOrderNotification extends TemplateMailable
protected function variables(): array
{
$vmFields = ['ip' => '🌐 IP Address', 'username' => '👤 Username', 'password' => '🔑 Password'];
$vmFields = ['ip' => 'IP Address', 'username' => 'Username', 'password' => 'Password'];
$vmRows = '';
foreach ($vmFields as $field => $label) {
if (!empty($this->data[$field])) {
$vmRows .= '<div class="vm-row"><span class="vm-label">' . $label . '</span><span class="vm-value">' . e($this->data[$field]) . '</span></div>';
$vmRows .= EmailTemplateShell::row($label, e($this->data[$field]));
}
}
@@ -40,14 +41,14 @@ class AdminOrderNotification extends TemplateMailable
'customer_name' => $this->data['customer_name'] ?? 'N/A',
'customer_email' => $this->data['customer_email'] ?? 'N/A',
'client_ip_row' => !empty($this->data['client_ip'])
? '<tr><td>IP đặt hàng</td><td>' . e($this->data['client_ip']) . '</td></tr>'
? EmailTemplateShell::row('IP đặt hàng', e($this->data['client_ip']))
: '',
'product_name' => $this->data['product_name'] ?? 'N/A',
'product_type' => $this->data['product_type'] ?? 'N/A',
'billing_cycle' => $this->data['billing_cycle'] ?? 'N/A',
'total_formatted' => number_format((float) ($this->data['total'] ?? 0), 0, ',', '.') . ' VNĐ',
'vm_block' => $vmRows
? '<div class="section-label">Thông tin dịch vụ Cloud VPS</div><div class="vm-box">' . $vmRows . '</div>'
? EmailTemplateShell::heading('Thông tin dịch vụ Cloud VPS') . EmailTemplateShell::infoTable([], $vmRows)
: '',
'company_name' => $this->data['company_name'] ?? 'Portal Admin',
];
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Mail\Admin;
use App\Mail\TemplateMailable;
use App\Support\EmailTemplateShell;
use Illuminate\Bus\Queueable;
use Illuminate\Queue\SerializesModels;
@@ -27,7 +28,7 @@ class HostingAccountCreatedAdminNotification extends TemplateMailable
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>'
? EmailTemplateShell::row($label, e($value))
: '';
return [
@@ -41,10 +42,10 @@ class HostingAccountCreatedAdminNotification extends TemplateMailable
'plesk_client_id' => $this->data['plesk_client_id'] ?? 'N/A',
'plesk_subscription_id' => $this->data['plesk_subscription_id'] ?? 'N/A',
'ftp_username' => $this->data['ftp_username'] ?? 'N/A',
'ftp_password_row' => $row('🔑 FTP Password', $this->data['ftp_password'] ?? null),
'ftp_password_row' => $row('FTP Password', $this->data['ftp_password'] ?? null),
'plesk_panel_url' => $this->data['plesk_panel_url'] ?? 'N/A',
'plesk_login' => $this->data['plesk_login'] ?? 'N/A',
'plesk_password_row' => $row('🔑 Password', $this->data['plesk_password'] ?? null),
'plesk_password_row' => $row('Plesk Password', $this->data['plesk_password'] ?? null),
'company_name' => $this->data['company_name'] ?? 'Portal Admin',
];
}
@@ -3,6 +3,7 @@
namespace App\Mail\Admin;
use App\Mail\TemplateMailable;
use App\Support\EmailTemplateShell;
use Illuminate\Bus\Queueable;
use Illuminate\Queue\SerializesModels;
@@ -30,14 +31,14 @@ class ManualDepositPendingNotification extends TemplateMailable
'customer_name' => $this->data['customer_name'] ?? 'N/A',
'customer_email' => $this->data['customer_email'] ?? 'N/A',
'customer_phone_row' => !empty($this->data['customer_phone'])
? '<tr><td>Số điện thoại</td><td>' . e($this->data['customer_phone']) . '</td></tr>'
? EmailTemplateShell::row('Số điện thoại', e($this->data['customer_phone']))
: '',
'client_ip_row' => !empty($this->data['client_ip'])
? '<tr><td>IP gửi yêu cầu</td><td>' . e($this->data['client_ip']) . '</td></tr>'
? EmailTemplateShell::row('IP gửi yêu cầu', e($this->data['client_ip']))
: '',
'amount_formatted' => number_format((float) ($this->data['amount'] ?? 0), 0, ',', '.') . ' VNĐ',
'proof_row' => !empty($this->data['proof_image_url'])
? '<tr><td>Chứng từ</td><td><a href="' . e($this->data['proof_image_url']) . '">Xem chứng từ chuyển khoản</a></td></tr>'
? EmailTemplateShell::row('Chứng từ', '<a style="color: #f16723;" href="' . e($this->data['proof_image_url']) . '" target="_blank" rel="noopener">Xem chứng từ chuyển khoản</a>')
: '',
'company_name' => $this->data['company_name'] ?? 'Portal Admin',
];
+2 -1
View File
@@ -3,6 +3,7 @@
namespace App\Mail\Admin;
use App\Mail\TemplateMailable;
use App\Support\EmailTemplateShell;
use Illuminate\Bus\Queueable;
use Illuminate\Queue\SerializesModels;
@@ -33,7 +34,7 @@ class TrialRegistrationRequest extends TemplateMailable
'ssd' => $this->data['ssd'] ?? '-',
'os' => $this->data['os'] ?? '-',
'other_row' => !empty($this->data['other'])
? '<tr><td>Yêu cầu khác</td><td>' . e($this->data['other']) . '</td></tr>'
? EmailTemplateShell::row('Yêu cầu khác', e($this->data['other']))
: '',
'company_name' => $this->data['company_name'] ?? 'Portal Admin',
'created_at' => $this->data['created_at'] ?? now()->format('d/m/Y H:i:s'),
+11 -10
View File
@@ -3,6 +3,7 @@
namespace App\Mail\Customer;
use App\Mail\TemplateMailable;
use App\Support\EmailTemplateShell;
use App\Support\MailGlobals;
use Illuminate\Bus\Queueable;
use Illuminate\Queue\SerializesModels;
@@ -28,17 +29,17 @@ class HostingAccountCreated extends TemplateMailable
$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>"
? EmailTemplateShell::row($label, e($value))
: '';
$pleskBlock = '';
if (!empty($data['plesk_login'])) {
$pleskBlock = '<p style="color: #f16723;"><strong>TÀI KHOẢN ĐĂNG NHẬP PLESK PANEL</strong></p>'
. '<table cellpadding="0" cellspacing="0" border="0" style="width: 100%; table-layout: fixed; border: 1px solid #e8e8e8; border-right: 0;"><tbody>'
. '<tr><td style="padding: 7px 11px; border-right: 1px solid #e8e8e8; width: 200px;">Địa chỉ Panel:</td><td style="padding: 7px 11px; border-right: 1px solid #e8e8e8;"><a style="color: #f16723; text-decoration: none;" href="' . ($data['plesk_panel_url'] ?? '') . '" target="_blank" rel="noopener">' . ($data['plesk_panel_url'] ?? '') . '</a></td></tr>'
. '<tr bgcolor="#FFF1E6"><td style="padding: 7px 11px; border-right: 1px solid #e8e8e8;">Tên đăng nhập:</td><td style="padding: 7px 11px; border-right: 1px solid #e8e8e8;"><strong>' . ($data['plesk_login'] ?? '') . '</strong></td></tr>'
. (!empty($data['plesk_password']) ? '<tr><td style="padding: 7px 11px; border-right: 1px solid #e8e8e8;">Mật khẩu:</td><td style="padding: 7px 11px; border-right: 1px solid #e8e8e8;"><strong>' . $data['plesk_password'] . '</strong></td></tr>' : '')
. '</tbody></table>';
$pleskBlock = EmailTemplateShell::paragraph('<strong style="color: #f16723;">TÀI KHOẢN ĐĂNG NHẬP PLESK PANEL</strong>')
. EmailTemplateShell::infoTable(array_filter([
'Địa chỉ Panel' => '<a style="color: #f16723; text-decoration: none;" href="' . e($data['plesk_panel_url'] ?? '') . '" target="_blank" rel="noopener">' . e($data['plesk_panel_url'] ?? '') . '</a>',
'Tên đăng nhập' => e($data['plesk_login']),
'Mật khẩu' => !empty($data['plesk_password']) ? e($data['plesk_password']) : null,
]));
}
return [
@@ -46,9 +47,9 @@ class HostingAccountCreated extends TemplateMailable
'full_name' => $data['customer_name'] ?? 'Quý khách',
'domain_name' => $data['domain_name'] ?? '',
'hosting_plan' => $data['hosting_plan'] ?? '',
'server_host_row' => $row('Máy chủ / FTP Host:', $data['server_host'] ?? null),
'ftp_username_row' => $row('Tài khoản FTP:', $data['ftp_username'] ?? null),
'ftp_password_row' => $row('Mật khẩu FTP:', $data['ftp_password'] ?? null),
'server_host_row' => $row('Máy chủ / FTP Host', $data['server_host'] ?? null),
'ftp_username_row' => $row('Tài khoản FTP', $data['ftp_username'] ?? null),
'ftp_password_row' => $row('Mật khẩu FTP', $data['ftp_password'] ?? null),
'ftp_password_notice' => !empty($data['ftp_password'])
? '<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>'
: '',
+2 -1
View File
@@ -3,6 +3,7 @@
namespace App\Mail\Customer;
use App\Mail\TemplateMailable;
use App\Support\EmailTemplateShell;
use App\Support\MailGlobals;
use Illuminate\Bus\Queueable;
use Illuminate\Queue\SerializesModels;
@@ -38,7 +39,7 @@ class ManualDepositStatus extends TemplateMailable
'status_message' => $data['status_message'] ?? ($data['message'] ?? ''),
'deposit_date' => $data['deposit_date'] ?? now()->format('d/m/Y H:i:s'),
'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>'
? EmailTemplateShell::row('Thời gian xử lý', e($data['processed_date']))
: '',
'portal_url' => $data['portal_url'] ?? MailGlobals::frontendUrl() . '/nap-tien',
];
+7 -6
View File
@@ -3,6 +3,7 @@
namespace App\Mail\Customer;
use App\Mail\TemplateMailable;
use App\Support\EmailTemplateShell;
use App\Support\MailGlobals;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailables\Attachment;
@@ -44,21 +45,21 @@ class VmCredentials extends TemplateMailable
$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>"
? EmailTemplateShell::row($label, e($value))
: '';
return [
'app_name' => $data['company_name'] ?? config('app.name', 'The Gioi So'),
'full_name' => $data['client_name'] ?? 'Quý khách',
'product_name' => $data['product_name'] ?? 'Cloud VPS',
'product_billing_row' => $row('Chu kỳ thanh toán:', $data['product_billing'] ?? null),
'product_price_row' => $row('Giá dịch vụ:', !empty($data['product_price']) ? $data['product_price'] . ' VNĐ' : null),
'os_name_row' => $row('Hệ điều hành:', $data['os_name'] ?? null),
'product_billing_row' => $row('Chu kỳ thanh toán', $data['product_billing'] ?? null),
'product_price_row' => $row('Giá dịch vụ', !empty($data['product_price']) ? $data['product_price'] . ' VNĐ' : null),
'os_name_row' => $row('Hệ điều hành', $data['os_name'] ?? null),
'service_host' => $data['service_host'] ?? '',
'service_username' => $data['service_username'] ?? '',
'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),
'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),
];
}
}
+2 -1
View File
@@ -3,6 +3,7 @@
namespace App\Mail\Customer;
use App\Mail\TemplateMailable;
use App\Support\EmailTemplateShell;
use App\Support\MailGlobals;
use Illuminate\Bus\Queueable;
use Illuminate\Queue\SerializesModels;
@@ -33,7 +34,7 @@ class VmPasswordChanged extends TemplateMailable
'full_name' => $data['fullname'] ?? 'Quý khách',
'vm_name' => $data['vm_name'] ?? '',
'new_password_row' => $newPassword
? '<div>Mật khẩu mới: <strong style="font-family: \'Menlo\',\'Consolas\',monospace; color: #111827;">' . $newPassword . '</strong></div>'
? EmailTemplateShell::row('Mật khẩu mới', '<span style="font-family: Menlo,Consolas,monospace; color: #111827;">' . e($newPassword) . '</span>')
: '',
];
}
+7
View File
@@ -3,6 +3,7 @@
namespace App\Mail;
use App\Mail\Concerns\BuildsTemplateEnvelope;
use App\Support\AutoEmailGate;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
@@ -22,6 +23,7 @@ class GenericTemplateMail extends Mailable
public readonly string $renderedSubject,
public readonly string $renderedHtml,
public readonly array $mailConfig = [],
public readonly ?string $scenario = null,
) {}
public function envelope(): Envelope
@@ -33,4 +35,9 @@ class GenericTemplateMail extends Mailable
{
return new Content(htmlString: $this->renderedHtml);
}
public function buildViewData()
{
return [...parent::buildViewData(), AutoEmailGate::SCENARIO_DATA_KEY => $this->scenario];
}
}
+3 -4
View File
@@ -5,6 +5,7 @@ namespace App\Mail;
use App\Enums\Invoice\InvoiceStatus;
use App\Models\Invoice;
use App\Services\InvoicePaymentService;
use App\Support\EmailTemplateShell;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Mail\Mailables\Attachment;
@@ -113,10 +114,8 @@ class InvoiceNotificationMail extends TemplateMailable
return '';
}
return '<tr><td style="padding: 7px 11px; border-right: 1px solid #e8e8e8;">Đã thanh toán:</td><td style="padding: 7px 11px;"><strong style="color: #059669;">'
. number_format($paid, 0, ',', '.') . ' VNĐ</strong></td></tr>'
. '<tr bgcolor="#FFF1E6"><td style="padding: 7px 11px; border-right: 1px solid #e8e8e8;">Còn thiếu:</td><td style="padding: 7px 11px;"><strong style="color: #dc2626;">'
. number_format($outstanding, 0, ',', '.') . ' VNĐ</strong></td></tr>';
return EmailTemplateShell::row('Đã thanh toán', '<span style="color: #059669;">' . number_format($paid, 0, ',', '.') . ' VNĐ</span>')
. EmailTemplateShell::row('Còn thiếu', '<span style="color: #dc2626;">' . number_format($outstanding, 0, ',', '.') . ' VNĐ</span>');
}
private function payButton(): string
+2 -1
View File
@@ -4,6 +4,7 @@ namespace App\Mail;
use App\Models\Customer;
use App\Models\Service;
use App\Support\EmailTemplateShell;
use Illuminate\Bus\Queueable;
use Illuminate\Queue\SerializesModels;
@@ -36,7 +37,7 @@ class ServiceRenewalReminderMail extends TemplateMailable
? \Carbon\Carbon::parse($this->service->expires_at)->format('d/m/Y')
: 'N/A',
'price_row' => $price
? '<tr><td style="padding: 7px 11px; border-right: 1px solid #e8e8e8;">Số tiền gia hạn:</td><td style="padding: 7px 11px; border-right: 1px solid #e8e8e8;"><strong>' . $price . ' VNĐ</strong></td></tr>'
? EmailTemplateShell::row('Số tiền gia hạn', $price . ' VNĐ')
: '',
'login_url' => rtrim(config('app.frontend_url', ''), '/') . '/dang-nhap',
'support_email' => 'support@tgs.com.vn',
+6
View File
@@ -6,6 +6,7 @@ use App\Mail\Concerns\BuildsTemplateEnvelope;
use App\Models\Template;
use App\Services\Email\EmailTemplateRenderer;
use App\Services\Email\EmailTemplateService;
use App\Support\AutoEmailGate;
use App\Support\MailGlobals;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
@@ -34,6 +35,11 @@ abstract class TemplateMailable extends Mailable
return new Content(htmlString: $this->rendered()['html']);
}
public function buildViewData()
{
return [...parent::buildViewData(), AutoEmailGate::SCENARIO_DATA_KEY => $this->scenario()];
}
/**
* @return array{subject: string, html: string}
*/
+2 -1
View File
@@ -2,6 +2,7 @@
namespace App\Mail;
use App\Support\EmailTemplateShell;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\SerializesModels;
@@ -34,7 +35,7 @@ class TicketCreated extends TemplateMailable implements ShouldQueue
'ticket_num' => $this->ticket->ticket_num,
'subject' => $this->ticket->subject,
'room_row' => $room
? '<tr><td style="font-family: Arial, Helvetica, sans-serif; font-size: 13px; color: #666666; padding: 4px 0;" valign="top">Phòng ban:</td><td style="font-family: Arial, Helvetica, sans-serif; font-size: 13px; color: #000000; padding: 4px 0;" valign="top">' . $room . '</td></tr>'
? EmailTemplateShell::row('Phòng ban', e($room))
: '',
'ticket_url' => rtrim(config('app.frontend_url', ''), '/') . '/tickets/' . $this->ticket->ticket_num,
'app_name' => config('app.name', 'Thế Giới Số'),
+5
View File
@@ -109,6 +109,11 @@ class Service extends Model
return $this->belongsTo(ModuleVmware::class, 'vm_id');
}
public function resolveVmware(): ?ModuleVmware
{
return $this->vmware ?? $this->orderItem?->order?->vmWare?->first();
}
/** The Plesk hosting account associated with this service, if type = hosting. */
public function hostingAccount()
{
@@ -79,12 +79,16 @@ class CustomerServiceProvisioningService
$service = Service::where('customer_id', $customer->id)->where('id', $serviceId)->first();
abort_if(!$service, 404, 'Không tìm thấy dịch vụ');
return DB::transaction(function () use ($service, $data) {
$vmFields = ['os_id', 'vm_cpu', 'vm_ram_mb', 'vm_disk_gb', 'vm_ip', 'vm_dns', 'vm_user', 'vm_password', 'vm_port'];
return DB::transaction(function () use ($customer, $service, $data) {
$vmFields = ['virtual_machine_id', 'os_id', 'vm_cpu', 'vm_ram_mb', 'vm_disk_gb', 'vm_ip', 'vm_dns', 'vm_user', 'vm_password', 'vm_port'];
$service->fill(collect($data)->except(['price', ...$vmFields])->all());
$service->save();
if (!empty($data['virtual_machine_id'])) {
$this->assignVirtualMachine($service, $customer, (int) $data['virtual_machine_id']);
}
if (array_key_exists('price', $data) && $data['price'] !== null && $service->orderItem) {
$service->orderItem->update(['price' => $data['price']]);
}
@@ -117,6 +121,49 @@ class CustomerServiceProvisioningService
});
}
private function assignVirtualMachine(Service $service, Customer $customer, int $virtualMachineId): void
{
$vm = VirtualMachines::with('datacenter')->findOrFail($virtualMachineId);
$current = $service->vmware;
if ($current && ($current->virtual_machine_id === $vm->id || $current->vmware_id === $vm->vm_id)) {
return;
}
$takenBy = DB::table('module_vmware as mv')
->join('services as s', 's.vm_id', '=', 'mv.id')
->where(fn($q) => $q->where('mv.virtual_machine_id', $vm->id)->orWhere('mv.vmware_id', $vm->vm_id))
->where('s.id', '!=', $service->id)
->value('s.id');
abort_if($takenBy, 422, "VM {$vm->name} đang gán cho dịch vụ #{$takenBy}.");
if ($current) {
$current->update([
'virtual_machine_id' => $vm->id,
'vmware_id' => $vm->vm_id,
'name' => $vm->name,
'vcenter_id' => $vm->datacenter->vcenter_id ?? null,
'cpu' => $vm->cpu,
'ram_mb' => $vm->ram_mb,
'ip' => $vm->ip,
]);
return;
}
$price = (int) ($service->orderItem->price ?? 0);
$vmId = $this->createModuleVmwareIfNeeded(
['virtual_machine_id' => $vm->id, 'server_name' => $service->server_name],
$customer,
$service->orderItem?->order,
$price,
$service->expires_at ?? now(),
);
ModuleVmware::where('id', $vmId)->update(['service_id' => $service->id]);
$service->update(['vm_id' => $vmId]);
}
/**
* Gửi email nhắc gia hạn dịch vụ cho khách hàng.
*/
@@ -285,7 +332,7 @@ class CustomerServiceProvisioningService
});
}
private function createModuleVmwareIfNeeded(array $data, Customer $customer, Order $order, int $basePrice, string|Carbon $expiresAt): ?int
private function createModuleVmwareIfNeeded(array $data, Customer $customer, ?Order $order, int $basePrice, string|Carbon $expiresAt): ?int
{
if (empty($data['virtual_machine_id'])) {
return null;
@@ -298,7 +345,7 @@ class CustomerServiceProvisioningService
['virtual_machine_id' => $vm->id],
[
'customer_id' => $customer->id,
'order_id' => $order->id,
'order_id' => $order?->id,
'name' => $vm->name,
'vmware_id' => $vm->vm_id,
'vcenter_id' => $vcenterId,
+7 -2
View File
@@ -5,6 +5,8 @@ namespace App\Services;
use App\Models\Customer;
use App\Models\Invoice;
use App\Services\Email\EmailTemplateService;
use App\Support\AutoEmailGate;
use App\Support\EmailTemplateShell;
use Carbon\Carbon;
/**
@@ -79,6 +81,10 @@ class InvoiceReminderService
return false;
}
if (! $force && AutoEmailGate::blocks(self::SCENARIO)) {
return false;
}
$customer = $invoice->customer;
if (! $customer?->email) {
return false;
@@ -108,8 +114,7 @@ class InvoiceReminderService
? 'Hoá đơn đã quá hạn ' . $daysLate . ' ngày.'
: ($daysLate === 0 ? 'Hôm nay là hạn thanh toán của hoá đơn này.' : 'Hoá đơn sẽ đến hạn sau ' . abs($daysLate) . ' ngày.'),
'paid_row' => (int) ($invoice->amount_paid ?? 0) > 0
? '<tr><td style="padding: 7px 11px; border-right: 1px solid #e8e8e8;">Đã thanh toán:</td><td style="padding: 7px 11px;"><strong>'
. number_format((int) $invoice->amount_paid, 0, ',', '.') . ' VNĐ</strong></td></tr>'
? EmailTemplateShell::row('Đã thanh toán', number_format((int) $invoice->amount_paid, 0, ',', '.') . ' VNĐ')
: '',
'login_url' => rtrim((string) config('app.frontend_url', ''), '/') . '/dang-nhap',
'support_email' => 'support@tgs.com.vn',
+37
View File
@@ -0,0 +1,37 @@
<?php
namespace App\Support;
use App\Models\Setting;
use Illuminate\Support\Facades\Context;
final class AutoEmailGate
{
public const SCENARIO_DATA_KEY = '__mail_scenario';
private const MANUAL_CONTEXT_KEY = 'mail.manual_send';
public static function markManual(): void
{
Context::addHidden(self::MANUAL_CONTEXT_KEY, true);
}
public static function blocks(?string $scenario): bool
{
if ($scenario === null || Context::hasHidden(self::MANUAL_CONTEXT_KEY)) {
return false;
}
return in_array($scenario, self::disabledScenarios(), true);
}
/**
* @return array<int, string>
*/
public static function disabledScenarios(): array
{
$value = Setting::where('key', 'email_settings')->value('value');
return (array) (json_decode($value ?? '', true)['auto_disabled'] ?? []);
}
}
+125 -42
View File
@@ -2,75 +2,158 @@
namespace App\Support;
/**
* Khung 700px dùng chung cho template email trong tbl.template: header (banner) + footer
* (menu dịch vụ + thông tin công ty). Mỗi scenario chỉ cần viết phần <tr class="NoiDungKM">.
*
* Thông tin công ty hardcode ở footer vì EmailTemplateRenderer chỉ thay placeholder dạng
* {{key}} đã khai báo trong metadata.variables — mọi scenario đều phải khai lại nếu muốn
* footer động, không đáng.
*/
class EmailTemplateShell
{
/** Bọc phần nội dung vào khung chuẩn. */
private const BRAND = '#f16723';
private const SERVICE_LINKS = [
'Premium Hosting' => 'https://tgs.com.vn/premium-hosting/',
'Cloud Server' => 'https://tgs.com.vn/cloud-server/',
'Thuê Server' => 'https://tgs.com.vn/may-chu-rieng',
'Thiết kế website' => 'https://tgs.com.vn/thiet-ke-website/',
];
public static function wrap(string $bodyHtml): string
{
return self::header() . $bodyHtml . self::footer();
}
/**
* Mở khối NoiDungKM với style chữ chuẩn, để scenario chỉ viết nội dung bên trong.
*/
public static function page(string $innerHtml): string
{
return self::wrap(self::body($innerHtml));
}
public static function body(string $innerHtml): string
{
return '<tr class="NoiDungKM"><td style="width: 700px; padding: 32px 48px; background-color: #ffffff; font-family: Montserrat,-apple-system,sans-serif; font-size: 15px; line-height: 24px; color: #626262;" valign="top">'
return '<tr class="NoiDungKM"><td style="box-sizing: border-box; width: 700px; padding: 32px 48px; background-color: #ffffff; font-family: Montserrat,-apple-system,sans-serif; font-size: 15px; line-height: 24px; color: #626262;" valign="top" bgcolor="#ffffff" width="700">'
. $innerHtml
. '</td></tr>';
}
/** Khối "Lưu ý: email tự động" dùng ở cuối hầu hết scenario. */
public static function title(string $html): string
{
return '<p style="box-sizing: border-box; margin: 0 0 6px 0; font-size: 21px; font-weight: bold; text-align: center; color: ' . self::BRAND . ';">' . $html . '</p>';
}
public static function subtitle(string $html): string
{
return '<p style="box-sizing: border-box; margin: 0 0 8px 0; font-size: 14px; text-align: center; color: #6b7280;">' . $html . '</p>';
}
public static function highlight(string $html): string
{
return '<table style="box-sizing: border-box; margin: 24px auto;" cellspacing="0" cellpadding="0"><tbody><tr>'
. '<td style="box-sizing: border-box; background-color: #fff7f2; border: 2px dashed ' . self::BRAND . '; border-radius: 8px; padding: 14px 36px;" align="center" bgcolor="#fff7f2">'
. '<span style="box-sizing: border-box; font-family: \'Courier New\', monospace; font-size: 30px; font-weight: 700; letter-spacing: 6px; color: ' . self::BRAND . ';">' . $html . '</span>'
. '</td></tr></tbody></table>';
}
public static function heading(string $html): string
{
return '<p style="box-sizing: border-box; margin: 0 0 8px 0; font-weight: bold; color: #374151;">' . $html . '</p>';
}
public static function greeting(string $nameHtml): string
{
return '<p style="box-sizing: border-box; margin: 24px 0 16px 0;">Kính chào <strong style="box-sizing: border-box; color: ' . self::BRAND . ';">' . $nameHtml . '</strong>,</p>';
}
public static function paragraph(string $html): string
{
return '<p style="box-sizing: border-box; margin: 0 0 20px 0; text-align: justify;">' . $html . '</p>';
}
public static function button(string $url, string $label): string
{
return '<table style="box-sizing: border-box; margin: 24px auto;" cellspacing="0" cellpadding="0"><tbody><tr>'
. '<td style="box-sizing: border-box; background-color: ' . self::BRAND . '; padding: 12px 24px; border-radius: 4px;" align="center" bgcolor="' . self::BRAND . '">'
. '<a style="box-sizing: border-box; text-decoration: none; font-size: 14px; font-weight: 600; color: #ffffff;" href="' . $url . '" target="_blank" rel="noopener">' . $label . '</a>'
. '</td></tr></tbody></table>';
}
public static function note(string $html, string $label = 'Lưu ý:'): string
{
return '<table style="box-sizing: border-box; width: 100%; border-collapse: collapse; margin: 0 0 20px 0;" role="presentation" width="100%" cellspacing="0" cellpadding="0"><tbody><tr>'
. '<td style="box-sizing: border-box; padding: 14px 16px; border-left: 3px solid ' . self::BRAND . '; background-color: #f8fafc; font-size: 13px; line-height: 20px; color: #626262;" bgcolor="#f8fafc">'
. '<strong style="box-sizing: border-box; color: #374151;">' . $label . '</strong> ' . $html
. '</td></tr></tbody></table>';
}
public static function autoNotice(): string
{
return <<<'HTML'
<table style="width: 100%; border-collapse: collapse;" role="presentation" cellspacing="0" cellpadding="0"><tbody><tr>
<td style="padding: 14px 16px; border-left: 3px solid #f16723; background-color: #f8fafc; font-size: 13px; line-height: 20px; color: #626262;"><strong style="color: #374151;">Lưu ý:</strong> Đây là email được gửi tự động. Vui lòng không trả lời email này.</td>
</tr></tbody></table>
HTML;
return self::note('Đây là email được gửi tự động. Vui lòng không trả lời email này.');
}
public static function supportLine(): string
{
return self::paragraph('Mọi thắc mắc xin liên hệ <strong>support@tgs.com.vn</strong> hoặc <strong>(028) 7309. 7379</strong>.');
}
/**
* @param array<string, string> $rows label => value HTML (đã escape)
*/
public static function infoTable(array $rows, string $extraRowsHtml = ''): string
{
$html = '';
foreach ($rows as $label => $value) {
$html .= self::row($label, $value);
}
return '<table style="box-sizing: border-box; width: 100%; border-collapse: collapse; table-layout: fixed; margin: 0 0 20px 0; border: 1px solid #e8e8e8;" role="presentation" width="100%" cellspacing="0" cellpadding="0"><tbody>'
. $html . $extraRowsHtml
. '</tbody></table>';
}
public static function row(string $label, string $valueHtml): string
{
return '<tr><td style="box-sizing: border-box; width: 38%; padding: 8px 12px; border-bottom: 1px solid #e8e8e8; background-color: #fafafa; color: #626262;" bgcolor="#fafafa">' . $label . '</td>'
. '<td style="box-sizing: border-box; padding: 8px 12px; border-bottom: 1px solid #e8e8e8; color: #374151;"><strong>' . $valueHtml . '</strong></td></tr>';
}
public static function header(): string
{
return <<<'HTML'
<table style="border-collapse: collapse; width: 100%; background-color: #efefef;" border="0" cellspacing="0" cellpadding="0"><tbody><tr><td style="padding: 20px;" align="center" valign="top"><table style="padding-top: 20px; padding-bottom: 20px; border-collapse: collapse; background-color: #ffffff; width: 700px;" border="0" cellspacing="0" cellpadding="0"><tbody>
<tr class="Header"><td style="width: 700px;" valign="top"><table align="center" width="700" bgcolor="#ffffff" style="border-collapse: collapse; width: 700px; max-width: 100%; text-align: center; background-color: #ffffff; border-top: 5px solid #f16723;" border="0" cellspacing="0" cellpadding="0"><tbody><tr><td style="padding: 0;"><a href="https://tgs.com.vn" style="display: block;" target="_blank" rel="noopener"><img src="https://res.cloudinary.com/izpast9i/image/upload/v1786499308/email_header.png" alt="Thư gửi từ CÔNG TY CỔ PHẦN GIẢI PHÁP CÔNG NGHỆ THẾ GIỚI SỐ" style="width: 100%; display: block; border: none;" width="700"></a></td></tr></tbody></table></td></tr>
<table style="box-sizing: border-box; border-collapse: collapse; width: 100%; background-color: #efefef;" border="0" width="100%" cellspacing="0" cellpadding="0" bgcolor="#efefef"><tbody><tr><td style="box-sizing: border-box; padding: 20px;" align="center" valign="top"><table style="box-sizing: border-box; padding-top: 20px; padding-bottom: 20px; border-collapse: collapse; background-color: #ffffff; width: 700px;" border="0" width="700" cellspacing="0" cellpadding="0" bgcolor="#ffffff"><tbody>
<tr class="Header"><td style="box-sizing: border-box; width: 700px;" valign="top" width="700"><table style="box-sizing: border-box; border-collapse: collapse; width: 700px; max-width: 100%; text-align: center; background-color: #ffffff; border-top: 5px solid #f16723;" border="0" width="700" cellspacing="0" cellpadding="0" align="center" bgcolor="#ffffff"><tbody><tr><td style="box-sizing: border-box; padding: 0;"><a style="box-sizing: border-box; display: block;" href="https://tgs.com.vn" target="_blank" rel="noopener"><img style="box-sizing: border-box; width: 100%; display: block; border: none;" src="https://res.cloudinary.com/izpast9i/image/upload/v1786499308/email_header.png" alt="Thư gửi từ CÔNG TY CỔ PHẦN GIẢI PHÁP CÔNG NGHỆ THẾ GIỚI SỐ" width="700"></a></td></tr></tbody></table></td></tr>
HTML;
}
public static function footer(): string
{
return <<<'HTML'
<tr class="Footer"><td align="center" valign="top"><table border="0" cellpadding="0" cellspacing="0" width="700" align="center" background="https://res.cloudinary.com/izpast9i/image/upload/v1786500298/email_footer.png" bgcolor="#F16723" style="border-collapse: collapse; width: 700px; background-image: url(&quot;https://res.cloudinary.com/izpast9i/image/upload/v1786500298/email_footer.png&quot;); background-size: cover; background-position: center; background-repeat: no-repeat;"><tbody>
<tr><td style="padding: 40px 30px 20px 30px; text-align: center;"><table border="0" cellpadding="0" cellspacing="0" width="100%" style="border-collapse: separate; border-spacing: 8px 0; width: 100%;"><tbody><tr>
<td width="25%" style="border: 1px solid rgba(255,255,255,0.35); border-radius: 8px; padding: 6px 4px; font-size: 12px; line-height: 16px; color: #ffffff; text-align: center; background-color: rgba(255,255,255,0.18);"><a href="https://tgs.com.vn/premium-hosting/" target="_blank" rel="noopener" style="color: #ffffff; text-decoration: none;">Premium Hosting</a></td>
<td width="25%" style="border: 1px solid rgba(255,255,255,0.35); border-radius: 8px; padding: 6px 4px; font-size: 12px; line-height: 16px; color: #ffffff; text-align: center; background-color: rgba(255,255,255,0.18);"><a href="https://tgs.com.vn/cloud-server/" target="_blank" rel="noopener" style="color: #ffffff; text-decoration: none;">Cloud Server</a></td>
<td width="25%" style="border: 1px solid rgba(255,255,255,0.35); border-radius: 8px; padding: 6px 4px; font-size: 12px; line-height: 16px; color: #ffffff; text-align: center; background-color: rgba(255,255,255,0.18);"><a href="https://cloudemail.vn/" target="_blank" rel="noopener" style="color: #ffffff; text-decoration: none;">Email doanh nghiệp</a></td>
<td width="25%" style="border: 1px solid rgba(255,255,255,0.35); border-radius: 8px; padding: 6px 4px; font-size: 12px; line-height: 16px; color: #ffffff; text-align: center; background-color: rgba(255,255,255,0.18);"><a href="https://tgs.com.vn/thiet-ke-website/" target="_blank" rel="noopener" style="color: #ffffff; text-decoration: none;">Thiết kế website</a></td>
</tr></tbody></table></td></tr>
<tr><td style="padding: 6px 30px 0 30px; text-align: center;"><strong style="font-size: 14px; line-height: 22px; color: #ffffff;">CÔNG TY CỔ PHẦN GIẢI PHÁP CÔNG NGHỆ THẾ GIỚI SỐ</strong></td></tr>
<tr><td style="padding: 6px 30px 0 30px; text-align: center;"><span style="font-size: 12px; line-height: 18px; color: #ffffff;">Địa chỉ: 128/38 Tân Hương, Phường Phú Thọ Hòa, Thành Phố Hồ Chí Minh</span></td></tr>
<tr><td style="padding: 6px 30px 0 30px; text-align: center;"><span style="font-size: 12px; line-height: 18px; color: #ffffff;">Tel: (028) 7309. 7379 &nbsp;-&nbsp; MST: 0318443116</span></td></tr>
<tr><td style="padding: 6px 30px 0 30px; text-align: center;"><span style="font-size: 12px; line-height: 18px; color: #ffffff;">Email: <a href="mailto:support@tgs.com.vn" style="color: #ffffff; text-decoration: none;">support@tgs.com.vn</a></span></td></tr>
<tr><td style="padding: 6px 30px 0 30px; text-align: center;"><span style="font-size: 12px; line-height: 18px; color: #ffffff;"><a href="https://www.tgs.com.vn" style="color: #ffffff; text-decoration: underline;">www.TGS.com.vn</a> &nbsp;&amp;&nbsp; <a href="https://www.thegioiso.cloud" style="color: #ffffff; text-decoration: underline;">www.thegioiso.CLOUD</a> &nbsp;&amp;&nbsp; <a href="https://www.thegioiserver.com" style="color: #ffffff; text-decoration: underline;">www.thegioiSERVER.com</a> &nbsp;&amp;&nbsp; <a href="https://www.cloudemail.vn" style="color: #ffffff; text-decoration: underline;">www.CloudEmail.vn</a></span></td></tr>
<tr><td style="padding: 6px 30px 16px 30px; text-align: center;"><span style="font-size: 12px; line-height: 20px; color: #ffffff; font-weight: bold;">Thế Giới Số - Premium HOSTING &bull; CLOUD &bull; SERVER &amp; WEBSITE | 15+ năm Phụng Sự</span></td></tr>
</tbody></table></td></tr>
</tbody></table></td></tr></tbody></table>
HTML;
return '<tr class="Footer"><td style="box-sizing: border-box;" align="center" valign="top">'
. '<table style="box-sizing: border-box; border-collapse: collapse; width: 700px; font-family: Montserrat,-apple-system,sans-serif; background-image: url(\'https://res.cloudinary.com/izpast9i/image/upload/v1786500298/email_footer.png\'); background-size: cover; background-position: center; background-repeat: no-repeat;" border="0" width="700" cellspacing="0" cellpadding="0" align="center" bgcolor="#F16723"><tbody>'
. self::serviceBanner()
. self::companyInfo()
. '</tbody></table></td></tr>'
. '</tbody></table></td></tr></tbody></table>';
}
private static function serviceBanner(): string
{
$cells = '';
foreach (self::SERVICE_LINKS as $label => $url) {
$cells .= '<td style="box-sizing: border-box; border: 1px solid rgba(255,255,255,0.35); border-radius: 8px; padding: 6px 4px; font-size: 12px; line-height: 16px; color: #ffffff; text-align: center; background-color: rgba(255,255,255,0.18);" align="center" width="25%">'
. '<a style="box-sizing: border-box; color: #ffffff; text-decoration: none;" href="' . $url . '" target="_blank" rel="noopener">' . $label . '</a></td>';
}
return '<tr><td style="box-sizing: border-box; padding: 40px 30px 20px 30px; text-align: center;" align="center">'
. '<table style="box-sizing: border-box; border-collapse: separate; border-spacing: 8px 0; width: 100%;" border="0" width="100%" cellspacing="0" cellpadding="0"><tbody><tr>'
. $cells
. '</tr></tbody></table></td></tr>';
}
private static function companyInfo(): string
{
$line = fn (string $html, string $padding = '6px 30px 0 30px') => '<tr><td style="box-sizing: border-box; padding: ' . $padding . '; text-align: center;" align="center">' . $html . '</td></tr>';
$text = 'box-sizing: border-box; font-size: 12px; line-height: 18px; color: #ffffff;';
return $line('<strong style="box-sizing: border-box; font-size: 14px; line-height: 22px; color: #ffffff;">CÔNG TY CỔ PHẦN GIẢI PHÁP CÔNG NGHỆ THẾ GIỚI SỐ</strong>')
. $line('<span style="' . $text . '">Địa chỉ: 128/38 Tân Hương, Phường Phú Thọ Hòa, Thành Phố Hồ Chí Minh</span>')
. $line('<span style="' . $text . '">Tel: (028) 7309. 7379 &nbsp;-&nbsp; MST: 0318443116</span>')
. $line('<span style="' . $text . '">Email: <a style="box-sizing: border-box; color: #ffffff; text-decoration: none;" href="mailto:support@tgs.com.vn">support@tgs.com.vn</a></span>')
. $line('<span style="box-sizing: border-box; font-size: 12px; line-height: 20px; color: #ffffff; font-weight: bold;">🥇 Thế Giới Số 🥇 - Premium HOSTING &bull; CLOUD &bull; SERVER &amp; WEBSITE | 15+ năm Phụng Sự🚀</span>', '6px 30px 16px 30px');
}
/**
* Bọc một đoạn HTML thành email hoàn chỉnh (doctype + head). Thay cho blade
* emails.customer.template-envelope — để không còn mail nào đi qua Blade.
*/
public static function document(?string $title, string $bodyHtml): string
{
$safeTitle = htmlspecialchars((string) $title, ENT_QUOTES, 'UTF-8');