From 1544629a1f460201c4f04691a166fbfd65568c7c Mon Sep 17 00:00:00 2001 From: baodev0 Date: Mon, 5 Oct 2026 09:30:24 +0700 Subject: [PATCH] update --- .DS_Store | Bin 10244 -> 10244 bytes .phpunit.cache/test-results | 2 +- .../EmailTemplate/EmailTemplateController.php | 142 +++++++---- .../MailCampaign/MailCampaignController.php | 227 ++++++++++++------ app/Jobs/Mail/SendMailCampaignRecipient.php | 146 +++++------ app/Mail/Admin/AdminOrderNotification.php | 30 +-- app/Mail/Admin/DomainOrderNewNotification.php | 24 +- ...HostingAccountCreatedAdminNotification.php | 30 +-- .../ManualDepositPendingNotification.php | 30 +-- app/Mail/Admin/TicketAdminNotification.php | 24 +- app/Mail/Admin/TrialRegistrationRequest.php | 30 +-- app/Mail/Concerns/BuildsTemplateEnvelope.php | 25 ++ app/Mail/Concerns/RequiresDbTemplate.php | 21 -- app/Mail/CreateHosting.php | 29 +-- app/Mail/Customer/DepositSuccess.php | 24 +- .../DomainOrderReceivedNotification.php | 24 +- app/Mail/Customer/HostingAccountCreated.php | 48 +--- app/Mail/Customer/ManualDepositStatus.php | 51 +--- app/Mail/Customer/PasswordResetByAdmin.php | 48 +--- app/Mail/Customer/PasswordResetLink.php | 24 +- app/Mail/Customer/SnapshotActionMail.php | 48 +--- app/Mail/Customer/VMCreationFailed.php | 33 +-- app/Mail/Customer/VerifyAccount.php | 46 +--- app/Mail/Customer/VmCredentials.php | 47 +--- app/Mail/Customer/VmPasswordChanged.php | 48 +--- app/Mail/Customer/VmRebuildFailed.php | 33 +-- app/Mail/Customer/Welcome.php | 54 +---- app/Mail/ExpiryService.php | 29 +-- app/Mail/GenericTemplateMail.php | 17 +- app/Mail/InvoiceNotificationMail.php | 29 +-- app/Mail/ServiceRenewalReminderMail.php | 23 +- app/Mail/TemplateMailable.php | 52 ++++ app/Mail/TicketCreated.php | 29 +-- app/Mail/VmSuspendedMail.php | 29 +-- app/Models/Invoice.php | 7 + app/Models/Mail.php | 5 + app/Models/MailCampaign.php | 7 + app/Models/Template.php | 74 +++--- app/Services/Customer/CustomerMailService.php | 21 +- app/Services/Email/EmailTemplateRenderer.php | 15 +- app/Services/Email/MailCampaignRenderer.php | 53 ++++ app/Services/Email/TemplateGuard.php | 65 +++++ app/Support/EmailRecipients.php | 23 ++ app/Support/EmailScenarioRegistry.php | 214 +++++++++++++++++ app/Support/MailGlobals.php | 52 ++++ app/Support/MailPlaceholders.php | 64 +++++ ...26_10_01_000002_backfill_invoices_date.php | 19 ++ ..._add_variables_to_mail_campaigns_table.php | 22 ++ ...2_sync_template_variables_from_content.php | 21 ++ routes/api/admin/system.php | 1 + tests/Unit/EmailScenarioRegistryTest.php | 33 +++ tests/Unit/EmailTemplateRendererTest.php | 24 ++ tests/Unit/MailPlaceholdersTest.php | 39 +++ 53 files changed, 1292 insertions(+), 963 deletions(-) create mode 100644 app/Mail/Concerns/BuildsTemplateEnvelope.php delete mode 100644 app/Mail/Concerns/RequiresDbTemplate.php create mode 100644 app/Mail/TemplateMailable.php create mode 100644 app/Services/Email/MailCampaignRenderer.php create mode 100644 app/Services/Email/TemplateGuard.php create mode 100644 app/Support/EmailRecipients.php create mode 100644 app/Support/EmailScenarioRegistry.php create mode 100644 app/Support/MailGlobals.php create mode 100644 app/Support/MailPlaceholders.php create mode 100644 database/migrations/2026_10_01_000002_backfill_invoices_date.php create mode 100644 database/migrations/2026_10_02_000001_add_variables_to_mail_campaigns_table.php create mode 100644 database/migrations/2026_10_02_000002_sync_template_variables_from_content.php create mode 100644 tests/Unit/EmailScenarioRegistryTest.php create mode 100644 tests/Unit/MailPlaceholdersTest.php diff --git a/.DS_Store b/.DS_Store index a6ef1b5a76c0514656963975cee6b1968ca82c2b..ec7be8e0954bc71d19ec41f702300bcb86a12a38 100644 GIT binary patch delta 272 zcmZn(XbIThDj1h)CB?wNz`~%%kj{|FP?DSP;*yk;p9B=+SlcOZ;e`1KM^yO~yz&JZ zhQZ1CxdlKy44l~)CNCCLWHSCT`Iw*_yK#5@p-ZMGCJPD)PyQ#!gTz;43SgLgMo2** zCmo@ifgw;4BsjT6sEjdT@^2wYrltQTiwOranI}xn7FJ>AnI$%PkFdn#Rl@O1yaJQC zMOHISnLPP{h}h(FA~B2?HfxLiU}jvoSw&KUk;%+%vYk{m(;bG%tEJ{LrRY!gl1^aU Ouz9s~JttV@O(pVtpDZXOJo%p>4-#LINr7SV86gFM zoOFb41_r+!Ai>EkLS>8rlYa|IGEK>yEG8VxWOiY4wy+8_L&=WGdxRw>uM&=DVmL6F zTVyp;`^3o)M8qba6NzDL*sLx3gPGB0vx=kyBU9;*$#zoNOpg>Mua=t26dX3$OF99l NWVLiXCs^f8CICf2SD^p^ diff --git a/.phpunit.cache/test-results b/.phpunit.cache/test-results index fa8cdc2..c06a223 100644 --- a/.phpunit.cache/test-results +++ b/.phpunit.cache/test-results @@ -1 +1 @@ -{"version":2,"defects":{"Tests\\Feature\\Api\\Client\\ClientAuthSecurityTest::test_reused_rotated_refresh_token_revokes_all_tokens_of_customer":7,"Tests\\Feature\\Console\\ReencryptSecretsTest::test_rotating_keys_re_encrypts_rsa_and_app_key_columns":7,"Tests\\Feature\\Console\\ReencryptSecretsTest::test_dry_run_changes_nothing_and_undecryptable_rows_fail_the_command":7,"Tests\\Feature\\Api\\Admin\\Customer\\CustomerMassAssignmentTest::test_store_ignores_fields_not_declared_in_validation_rules":7,"Tests\\Feature\\Api\\Admin\\Customer\\CustomerMassAssignmentTest::test_update_ignores_fields_not_declared_in_validation_rules":7,"Tests\\Feature\\Api\\Admin\\Customer\\ResetPasswordTest::test_reset_password_sends_email_and_does_not_leak_password_in_response":7,"Tests\\Feature\\Api\\Admin\\LoginThrottleTest::test_admin_login_is_throttled_after_5_attempts_per_minute":7,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"settings index\"":7,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"banks index\"":7,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"domain-pricing index\"":7,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"email-templates index\"":7,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"tickets index\"":7,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"activity-logs index\"":7,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"email-logs index\"":7,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"post-categories index\"":7,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"posts index\"":7,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_force_delete_post_requires_post_destroy_permission":7,"Tests\\Feature\\Api\\Admin\\Employee\\EmployeeRoleGuardTest::test_non_full_administrator_cannot_edit_a_full_administrator":7,"Tests\\Feature\\Api\\Admin\\Employee\\EmployeeRoleGuardTest::test_non_full_administrator_cannot_delete_a_full_administrator":7,"Tests\\Feature\\Api\\Admin\\Employee\\EmployeeRoleGuardTest::test_cannot_change_own_role":7,"Tests\\Feature\\Api\\Admin\\Employee\\EmployeeRoleGuardTest::test_non_full_administrator_cannot_assign_administrator_role":7,"Tests\\Feature\\Api\\Service\\VmChangeIpTest::test_change_ip_to_free_pool_ip_dispatches_job_with_pool_gateway":7,"Tests\\Feature\\Api\\Admin\\Employee\\TmpDbgTest::test_cannot_change_own_role":5},"times":{"Tests\\Unit\\BillingPricingTest::test_cycle_to_months_covers_every_enum_case":0.003,"Tests\\Unit\\BillingPricingTest::test_cycle_to_months_throws_on_unknown_value":0,"Tests\\Unit\\BillingPricingTest::test_cycle_to_months_throws_on_null":0,"Tests\\Unit\\BillingPricingTest::test_domain_price_formula with data set \"years=1, no setup fee\"":0.005,"Tests\\Unit\\BillingPricingTest::test_domain_price_formula with data set \"years=1, with setup fee\"":0,"Tests\\Unit\\BillingPricingTest::test_domain_price_formula with data set \"years=3, no setup fee\"":0,"Tests\\Unit\\BillingPricingTest::test_domain_price_formula with data set \"years=3, with setup fee\"":0,"Tests\\Unit\\EmailTemplateRendererTest::test_it_replaces_known_placeholders_in_subject_and_html":0.013,"Tests\\Unit\\EmailTemplateRendererTest::test_it_falls_back_to_example_value_when_data_missing":0,"Tests\\Unit\\EmailTemplateRendererTest::test_it_html_escapes_string_type_by_default":0.001,"Tests\\Unit\\EmailTemplateRendererTest::test_it_rejects_invalid_url_type":0,"Tests\\Unit\\EmailTemplateRendererTest::test_it_throws_for_undeclared_variable":0.001,"Tests\\Unit\\EmailTemplateRendererTest::test_it_never_executes_placeholder_content_as_code":0,"Tests\\Unit\\ExampleTest::test_that_true_is_true":0,"Tests\\Unit\\Models\\ReinstallLogPlainPasswordTest::test_plain_password_is_stored_encrypted_and_reads_back_transparently":0.004,"Tests\\Unit\\Models\\ReinstallLogPlainPasswordTest::test_backfill_migration_is_idempotent":0.001,"Tests\\Unit\\ServiceFillableTest::test_service_ignores_fields_not_declared_in_fillable":0.044,"Tests\\Unit\\Services\\VCenter\\PowerShellServiceSecretLoggingTest::test_change_ip_vm_linux_never_logs_the_guest_password":0.004,"Tests\\Unit\\Services\\VCenter\\PowerShellServiceSecretLoggingTest::test_change_ip_vm_windows_never_logs_the_guest_password":0.001,"Tests\\Unit\\Support\\VCenterHttpClientFactoryTest::test_verify_true_and_no_ca_bundle_uses_default_verification":0.002,"Tests\\Unit\\Support\\VCenterHttpClientFactoryTest::test_verify_true_with_ca_bundle_pins_the_bundle_path":0,"Tests\\Unit\\Support\\VCenterHttpClientFactoryTest::test_verify_false_disables_verification":0,"Tests\\Feature\\Api\\Admin\\Customer\\CustomerMassAssignmentTest::test_store_ignores_fields_not_declared_in_validation_rules":0.042,"Tests\\Feature\\Api\\Admin\\Customer\\CustomerMassAssignmentTest::test_update_ignores_fields_not_declared_in_validation_rules":0.01,"Tests\\Feature\\Api\\Admin\\Customer\\ResetPasswordTest::test_reset_password_sends_email_and_does_not_leak_password_in_response":0.019,"Tests\\Feature\\Api\\Admin\\Employee\\EmployeeRoleGuardTest::test_non_full_administrator_cannot_edit_a_full_administrator":0.012,"Tests\\Feature\\Api\\Admin\\Employee\\EmployeeRoleGuardTest::test_non_full_administrator_cannot_delete_a_full_administrator":0.01,"Tests\\Feature\\Api\\Admin\\Employee\\EmployeeRoleGuardTest::test_cannot_change_own_role":0.008,"Tests\\Feature\\Api\\Admin\\Employee\\EmployeeRoleGuardTest::test_non_full_administrator_cannot_assign_administrator_role":0.011,"Tests\\Feature\\Api\\Admin\\Employee\\EmployeeRoleGuardTest::test_full_administrator_can_assign_administrator_role":0.014,"Tests\\Feature\\Api\\Admin\\InvoiceTestRouteRemovedTest::test_unauthenticated_invoice_test_routes_no_longer_exist":0.003,"Tests\\Feature\\Api\\Admin\\LoginThrottleTest::test_admin_login_is_throttled_after_5_attempts_per_minute":0.008,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"settings index\"":0.08,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"banks index\"":0.011,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"domain-pricing index\"":0.011,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"email-templates index\"":0.014,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"tickets index\"":0.015,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"activity-logs index\"":0.014,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"email-logs index\"":0.016,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"post-categories index\"":0.017,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"posts index\"":0.016,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"media index\"":0.117,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"dashboard stats\"":0.019,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"queue jobs recent\"":0.031,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_force_delete_post_requires_post_destroy_permission":0.016,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_dashboard_requires_dashboard_view_permission":0.025,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_full_administrator_bypasses_all_permission_gates":0.051,"Tests\\Feature\\Api\\Client\\ClientAuthSecurityTest::test_access_token_older_than_skew_window_is_rejected":0.003,"Tests\\Feature\\Api\\Client\\ClientAuthSecurityTest::test_verify_email_code_is_invalidated_after_too_many_wrong_attempts":0.015,"Tests\\Feature\\Api\\Client\\ClientAuthSecurityTest::test_forgot_password_sends_link_without_changing_password_and_reset_revokes_sessions":0.448,"Tests\\Feature\\Api\\Client\\ClientAuthSecurityTest::test_cccd_images_are_private_and_served_only_via_signed_url":0.019,"Tests\\Feature\\Api\\Client\\ClientAuthSecurityTest::test_access_token_is_revoked_when_password_changes":0.02,"Tests\\Feature\\Api\\Client\\ClientAuthSecurityTest::test_throttle_uses_client_ip_from_encrypted_payload_not_next_server_ip":0.072,"Tests\\Feature\\Api\\Client\\ClientAuthSecurityTest::test_logout_revokes_refresh_token_so_it_cannot_be_used_again":0.009,"Tests\\Feature\\Api\\Client\\ClientAuthSecurityTest::test_reused_rotated_refresh_token_revokes_all_tokens_of_customer":0.01,"Tests\\Feature\\Api\\Client\\ClientAuthSecurityTest::test_legacy_cccd_path_is_not_exposed":0.007,"Tests\\Feature\\Api\\Client\\SecurityHardeningTest::test_register_rejects_short_password":0.004,"Tests\\Feature\\Api\\Client\\SecurityHardeningTest::test_register_accepts_password_with_at_least_ten_characters":0.01,"Tests\\Feature\\Api\\Client\\SecurityHardeningTest::test_cccd_upload_rejects_svg":0.011,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_admin_can_list_domain_orders":0.014,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_non_admin_cannot_access_domain_orders":0.002,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_confirm_registration_creates_dns_domain_and_attaches_customer":0.015,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_cannot_confirm_already_confirmed_order":0.012,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_cannot_create_duplicate_dns_domain":0.012,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_update_kyc_url_sets_pending_kyc_status":0.013,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_activate_transitions_from_pending_kyc_to_active":0.018,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_cannot_activate_pending_order_directly":0.012,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_reject_and_refund_restores_customer_balance":0.023,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_reject_and_refund_is_idempotent":0.025,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_cannot_reject_non_pending_order":0.011,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_refund_uses_invoice_amount_not_current_pricing":0.022,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_transaction_rollback_on_error":0.009,"Tests\\Feature\\Api\\DomainOrder\\ClientDomainOrderTest::test_authenticated_customer_can_place_domain_order":0.016,"Tests\\Feature\\Api\\DomainOrder\\ClientDomainOrderTest::test_insufficient_balance_returns_422":0.006,"Tests\\Feature\\Api\\DomainOrder\\ClientDomainOrderTest::test_duplicate_pending_domain_is_rejected":0.016,"Tests\\Feature\\Api\\DomainOrder\\ClientDomainOrderTest::test_unauthenticated_cannot_place_order":0.002,"Tests\\Feature\\Api\\DomainOrder\\ClientDomainOrderTest::test_invalid_product_type_rejected":0.005,"Tests\\Feature\\Api\\DomainOrder\\ClientDomainOrderTest::test_domain_normalized_to_lowercase":0.015,"Tests\\Feature\\Api\\DomainOrder\\ClientDomainOrderTest::test_unknown_tld_returns_422":0.005,"Tests\\Feature\\Api\\DomainOrder\\ClientDomainOrderTest::test_years_defaults_to_1_when_not_provided":0.015,"Tests\\Feature\\Api\\Service\\OrderErrorMessageTest::test_pay_order_hides_unknown_exception_message":0.01,"Tests\\Feature\\Api\\Service\\OrderErrorMessageTest::test_pay_order_keeps_business_message_for_invalid_argument":0.01,"Tests\\Feature\\Api\\Service\\RenewServiceTest::test_renew_annual_service_for_exactly_one_cycle_charges_the_annual_price":0.012,"Tests\\Feature\\Api\\Service\\RenewServiceTest::test_renew_annual_service_with_months_not_matching_the_cycle_is_rejected":0.007,"Tests\\Feature\\Api\\Service\\RenewServiceTest::test_renew_charges_the_recurring_addon_price_on_top_of_the_service_price":0.018,"Tests\\Feature\\Api\\Service\\RenewServiceTest::test_renew_uses_current_catalog_price_not_the_original_order_price":0.011,"Tests\\Feature\\Api\\Service\\RenewServiceTest::test_renew_fails_when_product_pricing_is_no_longer_active":0.008,"Tests\\Feature\\Api\\Service\\RenewServiceTest::test_renew_reuses_original_order_instead_of_creating_a_new_one":0.012,"Tests\\Feature\\Api\\Service\\RenewServiceTest::test_renewal_invoice_has_a_billing_period_and_is_not_duplicated_for_the_same_period":0.016,"Tests\\Feature\\Api\\Service\\RenewServiceTest::test_renew_while_existing_invoice_is_overdue_returns_the_same_invoice":0.02,"Tests\\Feature\\Api\\Service\\ServiceOwnershipTest::test_client_cannot_fetch_another_customers_service_by_uuid":0.013,"Tests\\Feature\\Api\\Service\\ServiceOwnershipTest::test_client_can_fetch_own_service_by_uuid":0.012,"Tests\\Feature\\Api\\Service\\StoreOrderAddonTest::test_addon_belonging_to_another_product_type_is_rejected":0.007,"Tests\\Feature\\Api\\Service\\StoreOrderAddonTest::test_inactive_addon_is_rejected":0.007,"Tests\\Feature\\Api\\Service\\StoreOrderAddonTest::test_qty_above_max_is_rejected":0.006,"Tests\\Feature\\Api\\Service\\StoreOrderAddonTest::test_valid_addon_within_bounds_is_accepted":0.016,"Tests\\Feature\\Api\\Service\\VmChangeIpTest::test_change_ip_outside_pool_returns_422":0.026,"Tests\\Feature\\Api\\Service\\VmChangeIpTest::test_change_ip_already_in_use_returns_422":0.021,"Tests\\Feature\\Api\\Service\\VmChangeIpTest::test_change_ip_to_free_pool_ip_dispatches_job_with_pool_gateway":0.017,"Tests\\Feature\\Api\\Service\\VmChangeIpTest::test_power_on_for_suspended_service_returns_403":0.023,"Tests\\Feature\\Api\\Service\\VmOwnershipTest::test_customer_cannot_read_power_state_of_another_customers_vm_by_numeric_id":0.007,"Tests\\Feature\\Api\\Service\\VmUpgradeAddonsTest::test_upgrade_request_creates_unpaid_invoice_without_touching_vcenter":0.011,"Tests\\Feature\\Api\\Service\\VmUpgradeAddonsTest::test_qty_over_max_is_rejected":0.005,"Tests\\Feature\\Api\\Service\\VmUpgradeAddonsTest::test_paying_the_invoice_dispatches_upgrade_job_and_updates_addons_meta":0.017,"Tests\\Feature\\Api\\Transaction\\ManualDepositProofTest::test_confirm_rejects_proof_path_belonging_to_another_customer":0.011,"Tests\\Feature\\Api\\Transaction\\ManualDepositProofTest::test_confirm_rejects_traversal_and_reused_proof_paths":0.012,"Tests\\Feature\\Api\\Transaction\\ManualDepositProofTest::test_confirm_without_proof_is_rejected":0.006,"Tests\\Feature\\Api\\Transaction\\ManualDepositProofTest::test_upload_then_confirm_binds_proof_to_owner":0.014,"Tests\\Feature\\Console\\InvoiceReminderTest::test_no_reminder_is_sent_before_the_first_offset":0.006,"Tests\\Feature\\Console\\InvoiceReminderTest::test_reminder_step_advances_once_per_offset_reached":0.008,"Tests\\Feature\\Console\\InvoiceReminderTest::test_an_overdue_invoice_reaches_the_later_offsets":0.009,"Tests\\Feature\\Console\\InvoiceReminderTest::test_a_paid_invoice_is_never_reminded":0.006,"Tests\\Feature\\Console\\InvoiceReminderTest::test_manual_reminder_sends_even_when_the_offset_was_already_used":0.005,"Tests\\Feature\\Console\\PostWhmcsSyncSafetyTest::test_reminder_chain_does_not_reopen_for_long_overdue_legacy_invoices":0.009,"Tests\\Feature\\Console\\PostWhmcsSyncSafetyTest::test_recently_overdue_invoice_is_still_reminded":0.006,"Tests\\Feature\\Console\\PostWhmcsSyncSafetyTest::test_manual_reminder_still_works_on_an_old_invoice":0.004,"Tests\\Feature\\Console\\PostWhmcsSyncSafetyTest::test_legacy_pending_order_does_not_block_new_orders":0.003,"Tests\\Feature\\Console\\PostWhmcsSyncSafetyTest::test_local_pending_order_still_blocks_new_orders":0.005,"Tests\\Feature\\Console\\PostWhmcsSyncSafetyTest::test_expiry_does_not_duplicate_an_invoice_already_synced_on_the_order":0.011,"Tests\\Feature\\Console\\ServiceExpiryTest::test_expiry_command_creates_renewal_invoice_on_the_original_order":0.015,"Tests\\Feature\\Console\\ServiceExpiryTest::test_running_expiry_command_twice_does_not_duplicate_the_renewal_invoice":0.018,"Tests\\Feature\\Console\\ServiceExpiryTest::test_cancelled_service_does_not_get_a_renewal_invoice":0.026,"Tests\\Feature\\Console\\ServiceExpiryTest::test_expiry_command_does_not_duplicate_invoice_after_it_became_overdue":0.018,"Tests\\Feature\\Console\\ServiceExpiryTest::test_overdue_renewal_invoice_does_not_reset_an_active_order":0.017,"Tests\\Feature\\Console\\ServiceExpiryTest::test_renewal_invoice_uses_the_current_price_list":0.018,"Tests\\Feature\\Console\\ServiceExpiryTest::test_renewal_falls_back_to_the_original_price_when_no_current_price_exists":0.014,"Tests\\Feature\\EmailTemplateServiceTest::test_send_dispatches_job_to_queue":0.001,"Tests\\Feature\\EmailTemplateServiceTest::test_job_renders_and_sends_mail_with_template_sender_config":0.002,"Tests\\Feature\\EmailTemplateServiceTest::test_job_falls_back_to_system_default_sender_when_template_has_no_mail_config":0.001,"Tests\\Feature\\EmailTemplateServiceTest::test_preview_throws_for_undeclared_variable":0.001,"Tests\\Feature\\EmailTemplateServiceTest::test_load_template_is_cached_by_scenario":0.002,"Tests\\Feature\\Payment\\AdminTransactionApproveTest::test_approving_the_same_manual_deposit_twice_credits_the_customer_only_once":0.011,"Tests\\Feature\\Payment\\AdminTransactionApproveTest::test_approving_credits_the_received_amount_not_the_declared_one":0.012,"Tests\\Feature\\Payment\\AdminTransactionApproveTest::test_approving_without_received_amount_credits_the_declared_amount":0.011,"Tests\\Feature\\Payment\\InvoicePaymentServiceTest::test_paying_an_already_paid_invoice_does_not_double_deduct_balance":0.019,"Tests\\Feature\\Payment\\InvoicePaymentServiceTest::test_a_cancelled_invoice_cannot_be_paid_and_is_never_counted_as_paid":0.007,"Tests\\Feature\\Payment\\InvoicePaymentServiceTest::test_admin_cannot_mark_a_cancelled_invoice_as_paid":0.008,"Tests\\Feature\\Payment\\InvoicePaymentServiceTest::test_partial_payment_records_amount_paid_and_keeps_invoice_unpaid":0.014,"Tests\\Feature\\Payment\\InvoicePaymentServiceTest::test_paying_the_remainder_settles_the_invoice":0.023,"Tests\\Feature\\Payment\\InvoicePaymentServiceTest::test_overpaying_only_charges_the_outstanding_amount":0.014,"Tests\\Feature\\Payment\\InvoicePaymentServiceTest::test_refund_returns_balance_and_records_a_negative_transaction":0.019,"Tests\\Feature\\Api\\Client\\ClientAuthSecurityTest::test_concurrent_refresh_within_grace_window_does_not_revoke_sessions":0.013,"Tests\\Unit\\Support\\TlsHttpClientFactoryTest::test_verify_true_and_no_ca_bundle_uses_default_verification":0.002,"Tests\\Unit\\Support\\TlsHttpClientFactoryTest::test_verify_true_with_ca_bundle_pins_the_bundle_path":0,"Tests\\Unit\\Support\\TlsHttpClientFactoryTest::test_verify_false_disables_verification":0,"Tests\\Unit\\Support\\TlsHttpClientFactoryTest::test_plesk_reads_its_own_config":0,"Tests\\Unit\\Support\\TlsHttpClientFactoryTest::test_curl_options_follow_vcenter_config":0,"Tests\\Feature\\Console\\ReencryptSecretsTest::test_rotating_keys_re_encrypts_rsa_and_app_key_columns":0.123,"Tests\\Feature\\Console\\ReencryptSecretsTest::test_dry_run_changes_nothing_and_undecryptable_rows_fail_the_command":0.059,"Tests\\Unit\\Support\\SshHostKeyVerifierTest::test_missing_known_hosts_is_rejected_while_tls_verification_is_on":0.01,"Tests\\Unit\\Support\\SshHostKeyVerifierTest::test_missing_known_hosts_is_allowed_when_tls_verification_is_off":0,"Tests\\Unit\\Support\\SshHostKeyVerifierTest::test_host_key_must_match_known_hosts_entry":0.002,"Tests\\Feature\\ZzInvoiceBladeCompareTest::test_split_invoice_template_renders_same_html":0.04,"Tests\\Feature\\Api\\Admin\\Employee\\TmpDbgTest::test_cannot_change_own_role":0.133}} \ No newline at end of file +{"version":2,"defects":{"Tests\\Feature\\Api\\Client\\ClientAuthSecurityTest::test_reused_rotated_refresh_token_revokes_all_tokens_of_customer":7,"Tests\\Feature\\Console\\ReencryptSecretsTest::test_rotating_keys_re_encrypts_rsa_and_app_key_columns":7,"Tests\\Feature\\Console\\ReencryptSecretsTest::test_dry_run_changes_nothing_and_undecryptable_rows_fail_the_command":7,"Tests\\Feature\\Api\\Admin\\Customer\\CustomerMassAssignmentTest::test_store_ignores_fields_not_declared_in_validation_rules":7,"Tests\\Feature\\Api\\Admin\\Customer\\CustomerMassAssignmentTest::test_update_ignores_fields_not_declared_in_validation_rules":7,"Tests\\Feature\\Api\\Admin\\Customer\\ResetPasswordTest::test_reset_password_sends_email_and_does_not_leak_password_in_response":7,"Tests\\Feature\\Api\\Admin\\LoginThrottleTest::test_admin_login_is_throttled_after_5_attempts_per_minute":7,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"settings index\"":7,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"banks index\"":7,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"domain-pricing index\"":7,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"email-templates index\"":7,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"tickets index\"":7,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"activity-logs index\"":7,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"email-logs index\"":7,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"post-categories index\"":7,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"posts index\"":7,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_force_delete_post_requires_post_destroy_permission":7,"Tests\\Feature\\Api\\Admin\\Employee\\EmployeeRoleGuardTest::test_non_full_administrator_cannot_edit_a_full_administrator":7,"Tests\\Feature\\Api\\Admin\\Employee\\EmployeeRoleGuardTest::test_non_full_administrator_cannot_delete_a_full_administrator":7,"Tests\\Feature\\Api\\Admin\\Employee\\EmployeeRoleGuardTest::test_cannot_change_own_role":7,"Tests\\Feature\\Api\\Admin\\Employee\\EmployeeRoleGuardTest::test_non_full_administrator_cannot_assign_administrator_role":7,"Tests\\Feature\\Api\\Service\\VmChangeIpTest::test_change_ip_to_free_pool_ip_dispatches_job_with_pool_gateway":7,"Tests\\Feature\\Api\\Admin\\Employee\\TmpDbgTest::test_cannot_change_own_role":5},"times":{"Tests\\Unit\\BillingPricingTest::test_cycle_to_months_covers_every_enum_case":0.003,"Tests\\Unit\\BillingPricingTest::test_cycle_to_months_throws_on_unknown_value":0,"Tests\\Unit\\BillingPricingTest::test_cycle_to_months_throws_on_null":0,"Tests\\Unit\\BillingPricingTest::test_domain_price_formula with data set \"years=1, no setup fee\"":0.004,"Tests\\Unit\\BillingPricingTest::test_domain_price_formula with data set \"years=1, with setup fee\"":0,"Tests\\Unit\\BillingPricingTest::test_domain_price_formula with data set \"years=3, no setup fee\"":0,"Tests\\Unit\\BillingPricingTest::test_domain_price_formula with data set \"years=3, with setup fee\"":0,"Tests\\Unit\\EmailTemplateRendererTest::test_it_replaces_known_placeholders_in_subject_and_html":0.011,"Tests\\Unit\\EmailTemplateRendererTest::test_it_falls_back_to_example_value_when_data_missing":0,"Tests\\Unit\\EmailTemplateRendererTest::test_it_html_escapes_string_type_by_default":0.001,"Tests\\Unit\\EmailTemplateRendererTest::test_it_rejects_invalid_url_type":0,"Tests\\Unit\\EmailTemplateRendererTest::test_it_throws_for_undeclared_variable":0.001,"Tests\\Unit\\EmailTemplateRendererTest::test_it_never_executes_placeholder_content_as_code":0,"Tests\\Unit\\ExampleTest::test_that_true_is_true":0,"Tests\\Unit\\Models\\ReinstallLogPlainPasswordTest::test_plain_password_is_stored_encrypted_and_reads_back_transparently":0.004,"Tests\\Unit\\Models\\ReinstallLogPlainPasswordTest::test_backfill_migration_is_idempotent":0.001,"Tests\\Unit\\ServiceFillableTest::test_service_ignores_fields_not_declared_in_fillable":0.04,"Tests\\Unit\\Services\\VCenter\\PowerShellServiceSecretLoggingTest::test_change_ip_vm_linux_never_logs_the_guest_password":0.013,"Tests\\Unit\\Services\\VCenter\\PowerShellServiceSecretLoggingTest::test_change_ip_vm_windows_never_logs_the_guest_password":0.001,"Tests\\Unit\\Support\\VCenterHttpClientFactoryTest::test_verify_true_and_no_ca_bundle_uses_default_verification":0.002,"Tests\\Unit\\Support\\VCenterHttpClientFactoryTest::test_verify_true_with_ca_bundle_pins_the_bundle_path":0,"Tests\\Unit\\Support\\VCenterHttpClientFactoryTest::test_verify_false_disables_verification":0,"Tests\\Feature\\Api\\Admin\\Customer\\CustomerMassAssignmentTest::test_store_ignores_fields_not_declared_in_validation_rules":0.042,"Tests\\Feature\\Api\\Admin\\Customer\\CustomerMassAssignmentTest::test_update_ignores_fields_not_declared_in_validation_rules":0.01,"Tests\\Feature\\Api\\Admin\\Customer\\ResetPasswordTest::test_reset_password_sends_email_and_does_not_leak_password_in_response":0.019,"Tests\\Feature\\Api\\Admin\\Employee\\EmployeeRoleGuardTest::test_non_full_administrator_cannot_edit_a_full_administrator":0.012,"Tests\\Feature\\Api\\Admin\\Employee\\EmployeeRoleGuardTest::test_non_full_administrator_cannot_delete_a_full_administrator":0.01,"Tests\\Feature\\Api\\Admin\\Employee\\EmployeeRoleGuardTest::test_cannot_change_own_role":0.008,"Tests\\Feature\\Api\\Admin\\Employee\\EmployeeRoleGuardTest::test_non_full_administrator_cannot_assign_administrator_role":0.011,"Tests\\Feature\\Api\\Admin\\Employee\\EmployeeRoleGuardTest::test_full_administrator_can_assign_administrator_role":0.014,"Tests\\Feature\\Api\\Admin\\InvoiceTestRouteRemovedTest::test_unauthenticated_invoice_test_routes_no_longer_exist":0.003,"Tests\\Feature\\Api\\Admin\\LoginThrottleTest::test_admin_login_is_throttled_after_5_attempts_per_minute":0.008,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"settings index\"":0.08,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"banks index\"":0.011,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"domain-pricing index\"":0.011,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"email-templates index\"":0.014,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"tickets index\"":0.015,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"activity-logs index\"":0.014,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"email-logs index\"":0.016,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"post-categories index\"":0.017,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"posts index\"":0.016,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"media index\"":0.117,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"dashboard stats\"":0.019,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"queue jobs recent\"":0.031,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_force_delete_post_requires_post_destroy_permission":0.016,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_dashboard_requires_dashboard_view_permission":0.025,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_full_administrator_bypasses_all_permission_gates":0.051,"Tests\\Feature\\Api\\Client\\ClientAuthSecurityTest::test_access_token_older_than_skew_window_is_rejected":0.003,"Tests\\Feature\\Api\\Client\\ClientAuthSecurityTest::test_verify_email_code_is_invalidated_after_too_many_wrong_attempts":0.015,"Tests\\Feature\\Api\\Client\\ClientAuthSecurityTest::test_forgot_password_sends_link_without_changing_password_and_reset_revokes_sessions":0.448,"Tests\\Feature\\Api\\Client\\ClientAuthSecurityTest::test_cccd_images_are_private_and_served_only_via_signed_url":0.019,"Tests\\Feature\\Api\\Client\\ClientAuthSecurityTest::test_access_token_is_revoked_when_password_changes":0.02,"Tests\\Feature\\Api\\Client\\ClientAuthSecurityTest::test_throttle_uses_client_ip_from_encrypted_payload_not_next_server_ip":0.072,"Tests\\Feature\\Api\\Client\\ClientAuthSecurityTest::test_logout_revokes_refresh_token_so_it_cannot_be_used_again":0.009,"Tests\\Feature\\Api\\Client\\ClientAuthSecurityTest::test_reused_rotated_refresh_token_revokes_all_tokens_of_customer":0.01,"Tests\\Feature\\Api\\Client\\ClientAuthSecurityTest::test_legacy_cccd_path_is_not_exposed":0.007,"Tests\\Feature\\Api\\Client\\SecurityHardeningTest::test_register_rejects_short_password":0.004,"Tests\\Feature\\Api\\Client\\SecurityHardeningTest::test_register_accepts_password_with_at_least_ten_characters":0.01,"Tests\\Feature\\Api\\Client\\SecurityHardeningTest::test_cccd_upload_rejects_svg":0.011,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_admin_can_list_domain_orders":0.014,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_non_admin_cannot_access_domain_orders":0.002,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_confirm_registration_creates_dns_domain_and_attaches_customer":0.015,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_cannot_confirm_already_confirmed_order":0.012,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_cannot_create_duplicate_dns_domain":0.012,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_update_kyc_url_sets_pending_kyc_status":0.013,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_activate_transitions_from_pending_kyc_to_active":0.018,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_cannot_activate_pending_order_directly":0.012,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_reject_and_refund_restores_customer_balance":0.023,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_reject_and_refund_is_idempotent":0.025,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_cannot_reject_non_pending_order":0.011,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_refund_uses_invoice_amount_not_current_pricing":0.022,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_transaction_rollback_on_error":0.009,"Tests\\Feature\\Api\\DomainOrder\\ClientDomainOrderTest::test_authenticated_customer_can_place_domain_order":0.016,"Tests\\Feature\\Api\\DomainOrder\\ClientDomainOrderTest::test_insufficient_balance_returns_422":0.006,"Tests\\Feature\\Api\\DomainOrder\\ClientDomainOrderTest::test_duplicate_pending_domain_is_rejected":0.016,"Tests\\Feature\\Api\\DomainOrder\\ClientDomainOrderTest::test_unauthenticated_cannot_place_order":0.002,"Tests\\Feature\\Api\\DomainOrder\\ClientDomainOrderTest::test_invalid_product_type_rejected":0.005,"Tests\\Feature\\Api\\DomainOrder\\ClientDomainOrderTest::test_domain_normalized_to_lowercase":0.015,"Tests\\Feature\\Api\\DomainOrder\\ClientDomainOrderTest::test_unknown_tld_returns_422":0.005,"Tests\\Feature\\Api\\DomainOrder\\ClientDomainOrderTest::test_years_defaults_to_1_when_not_provided":0.015,"Tests\\Feature\\Api\\Service\\OrderErrorMessageTest::test_pay_order_hides_unknown_exception_message":0.01,"Tests\\Feature\\Api\\Service\\OrderErrorMessageTest::test_pay_order_keeps_business_message_for_invalid_argument":0.01,"Tests\\Feature\\Api\\Service\\RenewServiceTest::test_renew_annual_service_for_exactly_one_cycle_charges_the_annual_price":0.012,"Tests\\Feature\\Api\\Service\\RenewServiceTest::test_renew_annual_service_with_months_not_matching_the_cycle_is_rejected":0.007,"Tests\\Feature\\Api\\Service\\RenewServiceTest::test_renew_charges_the_recurring_addon_price_on_top_of_the_service_price":0.018,"Tests\\Feature\\Api\\Service\\RenewServiceTest::test_renew_uses_current_catalog_price_not_the_original_order_price":0.011,"Tests\\Feature\\Api\\Service\\RenewServiceTest::test_renew_fails_when_product_pricing_is_no_longer_active":0.008,"Tests\\Feature\\Api\\Service\\RenewServiceTest::test_renew_reuses_original_order_instead_of_creating_a_new_one":0.012,"Tests\\Feature\\Api\\Service\\RenewServiceTest::test_renewal_invoice_has_a_billing_period_and_is_not_duplicated_for_the_same_period":0.016,"Tests\\Feature\\Api\\Service\\RenewServiceTest::test_renew_while_existing_invoice_is_overdue_returns_the_same_invoice":0.02,"Tests\\Feature\\Api\\Service\\ServiceOwnershipTest::test_client_cannot_fetch_another_customers_service_by_uuid":0.013,"Tests\\Feature\\Api\\Service\\ServiceOwnershipTest::test_client_can_fetch_own_service_by_uuid":0.012,"Tests\\Feature\\Api\\Service\\StoreOrderAddonTest::test_addon_belonging_to_another_product_type_is_rejected":0.007,"Tests\\Feature\\Api\\Service\\StoreOrderAddonTest::test_inactive_addon_is_rejected":0.007,"Tests\\Feature\\Api\\Service\\StoreOrderAddonTest::test_qty_above_max_is_rejected":0.006,"Tests\\Feature\\Api\\Service\\StoreOrderAddonTest::test_valid_addon_within_bounds_is_accepted":0.016,"Tests\\Feature\\Api\\Service\\VmChangeIpTest::test_change_ip_outside_pool_returns_422":0.026,"Tests\\Feature\\Api\\Service\\VmChangeIpTest::test_change_ip_already_in_use_returns_422":0.021,"Tests\\Feature\\Api\\Service\\VmChangeIpTest::test_change_ip_to_free_pool_ip_dispatches_job_with_pool_gateway":0.017,"Tests\\Feature\\Api\\Service\\VmChangeIpTest::test_power_on_for_suspended_service_returns_403":0.023,"Tests\\Feature\\Api\\Service\\VmOwnershipTest::test_customer_cannot_read_power_state_of_another_customers_vm_by_numeric_id":0.007,"Tests\\Feature\\Api\\Service\\VmUpgradeAddonsTest::test_upgrade_request_creates_unpaid_invoice_without_touching_vcenter":0.011,"Tests\\Feature\\Api\\Service\\VmUpgradeAddonsTest::test_qty_over_max_is_rejected":0.005,"Tests\\Feature\\Api\\Service\\VmUpgradeAddonsTest::test_paying_the_invoice_dispatches_upgrade_job_and_updates_addons_meta":0.017,"Tests\\Feature\\Api\\Transaction\\ManualDepositProofTest::test_confirm_rejects_proof_path_belonging_to_another_customer":0.011,"Tests\\Feature\\Api\\Transaction\\ManualDepositProofTest::test_confirm_rejects_traversal_and_reused_proof_paths":0.012,"Tests\\Feature\\Api\\Transaction\\ManualDepositProofTest::test_confirm_without_proof_is_rejected":0.006,"Tests\\Feature\\Api\\Transaction\\ManualDepositProofTest::test_upload_then_confirm_binds_proof_to_owner":0.014,"Tests\\Feature\\Console\\InvoiceReminderTest::test_no_reminder_is_sent_before_the_first_offset":0.006,"Tests\\Feature\\Console\\InvoiceReminderTest::test_reminder_step_advances_once_per_offset_reached":0.008,"Tests\\Feature\\Console\\InvoiceReminderTest::test_an_overdue_invoice_reaches_the_later_offsets":0.009,"Tests\\Feature\\Console\\InvoiceReminderTest::test_a_paid_invoice_is_never_reminded":0.006,"Tests\\Feature\\Console\\InvoiceReminderTest::test_manual_reminder_sends_even_when_the_offset_was_already_used":0.005,"Tests\\Feature\\Console\\PostWhmcsSyncSafetyTest::test_reminder_chain_does_not_reopen_for_long_overdue_legacy_invoices":0.009,"Tests\\Feature\\Console\\PostWhmcsSyncSafetyTest::test_recently_overdue_invoice_is_still_reminded":0.006,"Tests\\Feature\\Console\\PostWhmcsSyncSafetyTest::test_manual_reminder_still_works_on_an_old_invoice":0.004,"Tests\\Feature\\Console\\PostWhmcsSyncSafetyTest::test_legacy_pending_order_does_not_block_new_orders":0.003,"Tests\\Feature\\Console\\PostWhmcsSyncSafetyTest::test_local_pending_order_still_blocks_new_orders":0.005,"Tests\\Feature\\Console\\PostWhmcsSyncSafetyTest::test_expiry_does_not_duplicate_an_invoice_already_synced_on_the_order":0.011,"Tests\\Feature\\Console\\ServiceExpiryTest::test_expiry_command_creates_renewal_invoice_on_the_original_order":0.015,"Tests\\Feature\\Console\\ServiceExpiryTest::test_running_expiry_command_twice_does_not_duplicate_the_renewal_invoice":0.018,"Tests\\Feature\\Console\\ServiceExpiryTest::test_cancelled_service_does_not_get_a_renewal_invoice":0.026,"Tests\\Feature\\Console\\ServiceExpiryTest::test_expiry_command_does_not_duplicate_invoice_after_it_became_overdue":0.018,"Tests\\Feature\\Console\\ServiceExpiryTest::test_overdue_renewal_invoice_does_not_reset_an_active_order":0.017,"Tests\\Feature\\Console\\ServiceExpiryTest::test_renewal_invoice_uses_the_current_price_list":0.018,"Tests\\Feature\\Console\\ServiceExpiryTest::test_renewal_falls_back_to_the_original_price_when_no_current_price_exists":0.014,"Tests\\Feature\\EmailTemplateServiceTest::test_send_dispatches_job_to_queue":0.001,"Tests\\Feature\\EmailTemplateServiceTest::test_job_renders_and_sends_mail_with_template_sender_config":0.002,"Tests\\Feature\\EmailTemplateServiceTest::test_job_falls_back_to_system_default_sender_when_template_has_no_mail_config":0.001,"Tests\\Feature\\EmailTemplateServiceTest::test_preview_throws_for_undeclared_variable":0.001,"Tests\\Feature\\EmailTemplateServiceTest::test_load_template_is_cached_by_scenario":0.002,"Tests\\Feature\\Payment\\AdminTransactionApproveTest::test_approving_the_same_manual_deposit_twice_credits_the_customer_only_once":0.011,"Tests\\Feature\\Payment\\AdminTransactionApproveTest::test_approving_credits_the_received_amount_not_the_declared_one":0.012,"Tests\\Feature\\Payment\\AdminTransactionApproveTest::test_approving_without_received_amount_credits_the_declared_amount":0.011,"Tests\\Feature\\Payment\\InvoicePaymentServiceTest::test_paying_an_already_paid_invoice_does_not_double_deduct_balance":0.019,"Tests\\Feature\\Payment\\InvoicePaymentServiceTest::test_a_cancelled_invoice_cannot_be_paid_and_is_never_counted_as_paid":0.007,"Tests\\Feature\\Payment\\InvoicePaymentServiceTest::test_admin_cannot_mark_a_cancelled_invoice_as_paid":0.008,"Tests\\Feature\\Payment\\InvoicePaymentServiceTest::test_partial_payment_records_amount_paid_and_keeps_invoice_unpaid":0.014,"Tests\\Feature\\Payment\\InvoicePaymentServiceTest::test_paying_the_remainder_settles_the_invoice":0.023,"Tests\\Feature\\Payment\\InvoicePaymentServiceTest::test_overpaying_only_charges_the_outstanding_amount":0.014,"Tests\\Feature\\Payment\\InvoicePaymentServiceTest::test_refund_returns_balance_and_records_a_negative_transaction":0.019,"Tests\\Feature\\Api\\Client\\ClientAuthSecurityTest::test_concurrent_refresh_within_grace_window_does_not_revoke_sessions":0.013,"Tests\\Unit\\Support\\TlsHttpClientFactoryTest::test_verify_true_and_no_ca_bundle_uses_default_verification":0.002,"Tests\\Unit\\Support\\TlsHttpClientFactoryTest::test_verify_true_with_ca_bundle_pins_the_bundle_path":0,"Tests\\Unit\\Support\\TlsHttpClientFactoryTest::test_verify_false_disables_verification":0,"Tests\\Unit\\Support\\TlsHttpClientFactoryTest::test_plesk_reads_its_own_config":0,"Tests\\Unit\\Support\\TlsHttpClientFactoryTest::test_curl_options_follow_vcenter_config":0,"Tests\\Feature\\Console\\ReencryptSecretsTest::test_rotating_keys_re_encrypts_rsa_and_app_key_columns":0.123,"Tests\\Feature\\Console\\ReencryptSecretsTest::test_dry_run_changes_nothing_and_undecryptable_rows_fail_the_command":0.059,"Tests\\Unit\\Support\\SshHostKeyVerifierTest::test_missing_known_hosts_is_rejected_while_tls_verification_is_on":0.009,"Tests\\Unit\\Support\\SshHostKeyVerifierTest::test_missing_known_hosts_is_allowed_when_tls_verification_is_off":0,"Tests\\Unit\\Support\\SshHostKeyVerifierTest::test_host_key_must_match_known_hosts_entry":0.003,"Tests\\Feature\\ZzInvoiceBladeCompareTest::test_split_invoice_template_renders_same_html":0.04,"Tests\\Feature\\Api\\Admin\\Employee\\TmpDbgTest::test_cannot_change_own_role":0.133,"Tests\\Unit\\MailPlaceholdersTest::test_extracts_both_placeholder_styles_without_layout_slot":0,"Tests\\Unit\\MailPlaceholdersTest::test_fill_escapes_html_and_keeps_layout_slot":0,"Tests\\Unit\\MailPlaceholdersTest::test_recipient_values_fall_back_to_email_for_external_recipient":0,"Tests\\Unit\\EmailTemplateRendererTest::test_dollar_placeholders_are_declared_on_sync_and_rendered_with_default":0,"Tests\\Unit\\EmailScenarioRegistryTest::test_registered_scenario_is_locked_and_custom_template_is_marketing":0,"Tests\\Unit\\EmailScenarioRegistryTest::test_missing_variables_accepts_both_syntaxes_and_layout_slot":0}} \ No newline at end of file diff --git a/app/Http/Controllers/Api/Admin/EmailTemplate/EmailTemplateController.php b/app/Http/Controllers/Api/Admin/EmailTemplate/EmailTemplateController.php index 0f6c4e5..c12ac9a 100644 --- a/app/Http/Controllers/Api/Admin/EmailTemplate/EmailTemplateController.php +++ b/app/Http/Controllers/Api/Admin/EmailTemplate/EmailTemplateController.php @@ -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, ]; diff --git a/app/Http/Controllers/Api/Admin/MailCampaign/MailCampaignController.php b/app/Http/Controllers/Api/Admin/MailCampaign/MailCampaignController.php index 6af483e..d4353c1 100644 --- a/app/Http/Controllers/Api/Admin/MailCampaign/MailCampaignController.php +++ b/app/Http/Controllers/Api/Admin/MailCampaign/MailCampaignController.php @@ -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 + */ + private function allCustomerRecipients(): array + { + return Customer::query() + ->whereNotNull('email') + ->where('email', '!=', '') + ->pluck('id', 'email') + ->all(); + } + + /** + * @param array $uuids + * @param array $emails + * @return array + */ + 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, ]; diff --git a/app/Jobs/Mail/SendMailCampaignRecipient.php b/app/Jobs/Mail/SendMailCampaignRecipient.php index d4a9441..fa1b222 100644 --- a/app/Jobs/Mail/SendMailCampaignRecipient.php +++ b/app/Jobs/Mail/SendMailCampaignRecipient.php @@ -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], + ]); } } } diff --git a/app/Mail/Admin/AdminOrderNotification.php b/app/Mail/Admin/AdminOrderNotification.php index a7996bc..5f10222 100644 --- a/app/Mail/Admin/AdminOrderNotification.php +++ b/app/Mail/Admin/AdminOrderNotification.php @@ -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 = ''; diff --git a/app/Mail/Admin/DomainOrderNewNotification.php b/app/Mail/Admin/DomainOrderNewNotification.php index 80ca470..ee15b06 100644 --- a/app/Mail/Admin/DomainOrderNewNotification.php +++ b/app/Mail/Admin/DomainOrderNewNotification.php @@ -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'), diff --git a/app/Mail/Admin/HostingAccountCreatedAdminNotification.php b/app/Mail/Admin/HostingAccountCreatedAdminNotification.php index 3d1f71b..12bb528 100644 --- a/app/Mail/Admin/HostingAccountCreatedAdminNotification.php +++ b/app/Mail/Admin/HostingAccountCreatedAdminNotification.php @@ -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 ? '
' . $label . '' . e($value) . '
' diff --git a/app/Mail/Admin/ManualDepositPendingNotification.php b/app/Mail/Admin/ManualDepositPendingNotification.php index 737e051..359f49d 100644 --- a/app/Mail/Admin/ManualDepositPendingNotification.php +++ b/app/Mail/Admin/ManualDepositPendingNotification.php @@ -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', diff --git a/app/Mail/Admin/TicketAdminNotification.php b/app/Mail/Admin/TicketAdminNotification.php index c2e9323..16344ed 100644 --- a/app/Mail/Admin/TicketAdminNotification.php +++ b/app/Mail/Admin/TicketAdminNotification.php @@ -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 ?? ''); diff --git a/app/Mail/Admin/TrialRegistrationRequest.php b/app/Mail/Admin/TrialRegistrationRequest.php index e610f3d..6c6c628 100644 --- a/app/Mail/Admin/TrialRegistrationRequest.php +++ b/app/Mail/Admin/TrialRegistrationRequest.php @@ -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', diff --git a/app/Mail/Concerns/BuildsTemplateEnvelope.php b/app/Mail/Concerns/BuildsTemplateEnvelope.php new file mode 100644 index 0000000..235e4af --- /dev/null +++ b/app/Mail/Concerns/BuildsTemplateEnvelope.php @@ -0,0 +1,25 @@ +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; + } +} diff --git a/app/Mail/Concerns/RequiresDbTemplate.php b/app/Mail/Concerns/RequiresDbTemplate.php deleted file mode 100644 index 3b12d1d..0000000 --- a/app/Mail/Concerns/RequiresDbTemplate.php +++ /dev/null @@ -1,21 +0,0 @@ -dbRendered ??= app(EmailTemplateService::class)->preview($scenario, $vars); - } -} diff --git a/app/Mail/CreateHosting.php b/app/Mail/CreateHosting.php index 272e77c..c7b23cb 100644 --- a/app/Mail/CreateHosting.php +++ b/app/Mail/CreateHosting.php @@ -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'] ?? '', diff --git a/app/Mail/Customer/DepositSuccess.php b/app/Mail/Customer/DepositSuccess.php index b54d4a7..11237eb 100644 --- a/app/Mail/Customer/DepositSuccess.php +++ b/app/Mail/Customer/DepositSuccess.php @@ -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', diff --git a/app/Mail/Customer/DomainOrderReceivedNotification.php b/app/Mail/Customer/DomainOrderReceivedNotification.php index 49619fd..559bb53 100644 --- a/app/Mail/Customer/DomainOrderReceivedNotification.php +++ b/app/Mail/Customer/DomainOrderReceivedNotification.php @@ -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', diff --git a/app/Mail/Customer/HostingAccountCreated.php b/app/Mail/Customer/HostingAccountCreated.php index 0d62284..4276025 100644 --- a/app/Mail/Customer/HostingAccountCreated.php +++ b/app/Mail/Customer/HostingAccountCreated.php @@ -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 ? "{$label}{$value}" @@ -82,9 +53,6 @@ class HostingAccountCreated extends Mailable ? '

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.

' : '', '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'), ]; } } diff --git a/app/Mail/Customer/ManualDepositStatus.php b/app/Mail/Customer/ManualDepositStatus.php index 2d7d8be..f0f29e6 100644 --- a/app/Mail/Customer/ManualDepositStatus.php +++ b/app/Mail/Customer/ManualDepositStatus.php @@ -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']) ? 'Thời gian xử lý' . $data['processed_date'] . '' : '', - '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', ]; } } diff --git a/app/Mail/Customer/PasswordResetByAdmin.php b/app/Mail/Customer/PasswordResetByAdmin.php index d46b22a..1d4a9f6 100644 --- a/app/Mail/Customer/PasswordResetByAdmin.php +++ b/app/Mail/Customer/PasswordResetByAdmin.php @@ -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'), ]; } } diff --git a/app/Mail/Customer/PasswordResetLink.php b/app/Mail/Customer/PasswordResetLink.php index dd39cf9..af17716 100644 --- a/app/Mail/Customer/PasswordResetLink.php +++ b/app/Mail/Customer/PasswordResetLink.php @@ -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', diff --git a/app/Mail/Customer/SnapshotActionMail.php b/app/Mail/Customer/SnapshotActionMail.php index 037d54c..23617d4 100644 --- a/app/Mail/Customer/SnapshotActionMail.php +++ b/app/Mail/Customer/SnapshotActionMail.php @@ -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'), ]; } } diff --git a/app/Mail/Customer/VMCreationFailed.php b/app/Mail/Customer/VMCreationFailed.php index 7f95389..16d8e80 100644 --- a/app/Mail/Customer/VMCreationFailed.php +++ b/app/Mail/Customer/VMCreationFailed.php @@ -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'), ]; diff --git a/app/Mail/Customer/VerifyAccount.php b/app/Mail/Customer/VerifyAccount.php index 1cf9fc7..09ec703 100644 --- a/app/Mail/Customer/VerifyAccount.php +++ b/app/Mail/Customer/VerifyAccount.php @@ -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'), ]; } } diff --git a/app/Mail/Customer/VmCredentials.php b/app/Mail/Customer/VmCredentials.php index 6b64b61..ce2cef4 100644 --- a/app/Mail/Customer/VmCredentials.php +++ b/app/Mail/Customer/VmCredentials.php @@ -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 ? "{$label}{$value}" @@ -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'), ]; } } diff --git a/app/Mail/Customer/VmPasswordChanged.php b/app/Mail/Customer/VmPasswordChanged.php index 2ed847b..e3eeb3c 100644 --- a/app/Mail/Customer/VmPasswordChanged.php +++ b/app/Mail/Customer/VmPasswordChanged.php @@ -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 ? '
Mật khẩu mới: ' . $newPassword . '
' : '', - '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'), ]; } } diff --git a/app/Mail/Customer/VmRebuildFailed.php b/app/Mail/Customer/VmRebuildFailed.php index 0303e56..97c7be6 100644 --- a/app/Mail/Customer/VmRebuildFailed.php +++ b/app/Mail/Customer/VmRebuildFailed.php @@ -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'), ]; diff --git a/app/Mail/Customer/Welcome.php b/app/Mail/Customer/Welcome.php index f787fb5..ca69844 100644 --- a/app/Mail/Customer/Welcome.php +++ b/app/Mail/Customer/Welcome.php @@ -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); - } } diff --git a/app/Mail/ExpiryService.php b/app/Mail/ExpiryService.php index 1dd8e39..fa954e1 100644 --- a/app/Mail/ExpiryService.php +++ b/app/Mail/ExpiryService.php @@ -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'] ?? '', diff --git a/app/Mail/GenericTemplateMail.php b/app/Mail/GenericTemplateMail.php index e8b3de0..d459874 100644 --- a/app/Mail/GenericTemplateMail.php +++ b/app/Mail/GenericTemplateMail.php @@ -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; - } } diff --git a/app/Mail/InvoiceNotificationMail.php b/app/Mail/InvoiceNotificationMail.php index 1aee16f..6a63738 100644 --- a/app/Mail/InvoiceNotificationMail.php +++ b/app/Mail/InvoiceNotificationMail.php @@ -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'; diff --git a/app/Mail/ServiceRenewalReminderMail.php b/app/Mail/ServiceRenewalReminderMail.php index c38c7e7..2554187 100644 --- a/app/Mail/ServiceRenewalReminderMail.php +++ b/app/Mail/ServiceRenewalReminderMail.php @@ -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 diff --git a/app/Mail/TemplateMailable.php b/app/Mail/TemplateMailable.php new file mode 100644 index 0000000..74d7e0e --- /dev/null +++ b/app/Mail/TemplateMailable.php @@ -0,0 +1,52 @@ + + */ + 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()); + } +} diff --git a/app/Mail/TicketCreated.php b/app/Mail/TicketCreated.php index 97a0520..b687092 100644 --- a/app/Mail/TicketCreated.php +++ b/app/Mail/TicketCreated.php @@ -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; diff --git a/app/Mail/VmSuspendedMail.php b/app/Mail/VmSuspendedMail.php index 5e51b72..dc21d17 100644 --- a/app/Mail/VmSuspendedMail.php +++ b/app/Mail/VmSuspendedMail.php @@ -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, diff --git a/app/Models/Invoice.php b/app/Models/Invoice.php index e147ce9..cdc54af 100644 --- a/app/Models/Invoice.php +++ b/app/Models/Invoice.php @@ -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); diff --git a/app/Models/Mail.php b/app/Models/Mail.php index f9682cd..6b90d69 100644 --- a/app/Models/Mail.php +++ b/app/Models/Mail.php @@ -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'); diff --git a/app/Models/MailCampaign.php b/app/Models/MailCampaign.php index a41e0ad..2bd32fb 100644 --- a/app/Models/MailCampaign.php +++ b/app/Models/MailCampaign.php @@ -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'); + } } diff --git a/app/Models/Template.php b/app/Models/Template.php index 9152189..446ecd6 100644 --- a/app/Models/Template.php +++ b/app/Models/Template.php @@ -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 $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'); diff --git a/app/Services/Customer/CustomerMailService.php b/app/Services/Customer/CustomerMailService.php index d40bc07..d4046ec 100644 --- a/app/Services/Customer/CustomerMailService.php +++ b/app/Services/Customer/CustomerMailService.php @@ -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(); - } } diff --git a/app/Services/Email/EmailTemplateRenderer.php b/app/Services/Email/EmailTemplateRenderer.php index 3aef5da..caae97a 100644 --- a/app/Services/Email/EmailTemplateRenderer.php +++ b/app/Services/Email/EmailTemplateRenderer.php @@ -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'] ?? ''; diff --git a/app/Services/Email/MailCampaignRenderer.php b/app/Services/Email/MailCampaignRenderer.php new file mode 100644 index 0000000..cefe906 --- /dev/null +++ b/app/Services/Email/MailCampaignRenderer.php @@ -0,0 +1,53 @@ + $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 + */ + public function recipientValues(MailCampaign $campaign, ?Customer $customer, string $email): array + { + return [...MailGlobals::values(), ...MailPlaceholders::recipientValues($customer, $email), ...$this->adminValues($campaign)]; + } + + /** + * @return array + */ + 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 !== ''); + } +} diff --git a/app/Services/Email/TemplateGuard.php b/app/Services/Email/TemplateGuard.php new file mode 100644 index 0000000..d2523c5 --- /dev/null +++ b/app/Services/Email/TemplateGuard.php @@ -0,0 +1,65 @@ +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)); + } +} diff --git a/app/Support/EmailRecipients.php b/app/Support/EmailRecipients.php new file mode 100644 index 0000000..ccde598 --- /dev/null +++ b/app/Support/EmailRecipients.php @@ -0,0 +1,23 @@ + + */ + 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(); + } +} diff --git a/app/Support/EmailScenarioRegistry.php b/app/Support/EmailScenarioRegistry.php new file mode 100644 index 0000000..8da0367 --- /dev/null +++ b/app/Support/EmailScenarioRegistry.php @@ -0,0 +1,214 @@ + '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, required_variables: array} + */ + 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 $required + * @return array + */ + 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), + )); + } +} diff --git a/app/Support/MailGlobals.php b/app/Support/MailGlobals.php new file mode 100644 index 0000000..f3eab75 --- /dev/null +++ b/app/Support/MailGlobals.php @@ -0,0 +1,52 @@ + '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 + */ + 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'), + ]; + } +} diff --git a/app/Support/MailPlaceholders.php b/app/Support/MailPlaceholders.php new file mode 100644 index 0000000..4699130 --- /dev/null +++ b/app/Support/MailPlaceholders.php @@ -0,0 +1,64 @@ + 'name', + 'customer_name' => 'name', + 'full_name' => 'name', + 'fullname' => 'name', + 'name' => 'name', + 'customer_email' => 'email', + 'email' => 'email', + 'customer_phone' => 'phone', + 'phone' => 'phone', + ]; + + /** + * @return array + */ + 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 $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 + */ + 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); + } +} diff --git a/database/migrations/2026_10_01_000002_backfill_invoices_date.php b/database/migrations/2026_10_01_000002_backfill_invoices_date.php new file mode 100644 index 0000000..fa3a7c2 --- /dev/null +++ b/database/migrations/2026_10_01_000002_backfill_invoices_date.php @@ -0,0 +1,19 @@ +whereNull('date') + ->whereNotNull('created_at') + ->update(['date' => DB::raw('DATE(created_at)')]); + } + + public function down(): void + { + } +}; diff --git a/database/migrations/2026_10_02_000001_add_variables_to_mail_campaigns_table.php b/database/migrations/2026_10_02_000001_add_variables_to_mail_campaigns_table.php new file mode 100644 index 0000000..36e6de4 --- /dev/null +++ b/database/migrations/2026_10_02_000001_add_variables_to_mail_campaigns_table.php @@ -0,0 +1,22 @@ +json('variables')->nullable()->after('message'); + }); + } + + public function down(): void + { + Schema::table('mail_campaigns', function (Blueprint $table) { + $table->dropColumn('variables'); + }); + } +}; diff --git a/database/migrations/2026_10_02_000002_sync_template_variables_from_content.php b/database/migrations/2026_10_02_000002_sync_template_variables_from_content.php new file mode 100644 index 0000000..217a829 --- /dev/null +++ b/database/migrations/2026_10_02_000002_sync_template_variables_from_content.php @@ -0,0 +1,21 @@ +where(fn ($q) => $q->whereNull('format')->orWhere('format', 'html')) + ->each(function (Template $template) { + $template->syncVariables(); + $template->save(); + }); + } + + public function down(): void + { + } +}; diff --git a/routes/api/admin/system.php b/routes/api/admin/system.php index 91b6e2f..05106f5 100644 --- a/routes/api/admin/system.php +++ b/routes/api/admin/system.php @@ -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'); }); diff --git a/tests/Unit/EmailScenarioRegistryTest.php b/tests/Unit/EmailScenarioRegistryTest.php new file mode 100644 index 0000000..17075fc --- /dev/null +++ b/tests/Unit/EmailScenarioRegistryTest.php @@ -0,0 +1,33 @@ +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', '')); + $this->assertSame([], EmailScenarioRegistry::missingVariables(['reset_url'], 'Link {{reset_url}}', '')); + $this->assertSame(['reset_url'], EmailScenarioRegistry::missingVariables(['reset_url'], 'x', '

Đặt lại mật khẩu

')); + $this->assertSame([], EmailScenarioRegistry::missingVariables(['content'], null, '{{$content}}
')); + $this->assertSame(['content'], EmailScenarioRegistry::missingVariables(['content'], null, '
')); + } +} diff --git a/tests/Unit/EmailTemplateRendererTest.php b/tests/Unit/EmailTemplateRendererTest.php index 7a7eb40..dfb1b9c 100644 --- a/tests/Unit/EmailTemplateRendererTest.php +++ b/tests/Unit/EmailTemplateRendererTest.php @@ -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}}', + '

{{ $promo_code }} cho {{full_name}}

{{$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('

TGS10 cho An

{{$content}}', $result['html']); + } } diff --git a/tests/Unit/MailPlaceholdersTest.php b/tests/Unit/MailPlaceholdersTest.php new file mode 100644 index 0000000..e64e683 --- /dev/null +++ b/tests/Unit/MailPlaceholdersTest.php @@ -0,0 +1,39 @@ +assertSame( + ['client_name', 'promo_code', 'due_date'], + MailPlaceholders::extract('Xin chào {{ $client_name }}', '

{{promo_code}} {{$due_date}} {{$content}} {{client_name}}

'), + ); + } + + public function test_fill_escapes_html_and_keeps_layout_slot(): void + { + $html = MailPlaceholders::fill('{{$name}}{{$content}}{{missing}}', ['name' => "