feat(mail-campaigns): sender info + resolve typed emails against customers

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
baodev0
2026-10-22 21:18:53 +07:00
co-authored by Claude Opus 5.5
parent 54c14c41d8
commit e1efc88fd2
10 changed files with 277 additions and 18 deletions
@@ -244,8 +244,12 @@ trait ManagesCustomerServices
public function listAvailableVms(Request $request): JsonResponse
{
$vms = $this->serviceProvisioning->listAvailableVms(trim((string) $request->get('q', '')));
$result = $this->serviceProvisioning->listAvailableVms(
trim((string) $request->get('q', '')),
max(1, (int) $request->get('page', 1)),
max(1, min((int) $request->get('per_page', 200), 200)),
);
return response()->json(['error' => false, 'data' => $vms]);
return response()->json(['error' => false] + $result);
}
}
@@ -6,28 +6,64 @@ namespace App\Http\Controllers\Api\Admin\MailCampaign;
use App\Http\Controllers\Api\Admin\MailCampaign\Concerns\HandlesCampaignBulkActions;
use App\Http\Controllers\Controller;
use App\Http\Resources\Api\Admin\CustomerResource;
use App\Jobs\Mail\SendMailCampaignRecipient;
use App\Models\Customer;
use App\Models\Mail;
use App\Models\MailCampaign;
use App\Models\Template;
use App\Services\Email\MailCampaignRenderer;
use App\Support\AutoEmailGate;
use App\Support\MailPlaceholders;
use App\Support\MailSettings;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
final class MailCampaignController extends Controller
{
use HandlesCampaignBulkActions;
private const MAX_EMAILS = 1000;
private const EMAIL_RULE = 'required|string|email:rfc|max:255';
private const EMAIL_MESSAGES = [
'emails.required' => 'Nhập ít nhất một email.',
'emails.max' => 'Tối đa ' . self::MAX_EMAILS . ' email mỗi lần.',
'emails.*.required' => 'Email không được để trống.',
'emails.*.email' => 'Email ":input" không hợp lệ.',
'emails.*.max' => 'Email ":input" dài quá 255 ký tự.',
];
public function index(Request $request): JsonResponse
{
$limit = min((int) $request->get('per_page', 20), 200);
$filters = $request->validate([
'search' => 'nullable|string|max:255',
'status' => 'nullable|array',
'status.*' => 'string|in:queued,sending,completed,failed,' . MailCampaign::STATUS_CANCELLED,
'source' => 'nullable|string|in:template,manual',
'date_from' => 'nullable|date',
'date_to' => 'nullable|date',
]);
$search = trim((string) ($filters['search'] ?? ''));
$paginator = MailCampaign::query()
->with(['template', 'creator'])
->when($search !== '', fn ($q) => $q->where(fn ($q) => $q
->where('name', 'like', "%{$search}%")
->orWhere('subject', 'like', "%{$search}%")
->orWhereHas('template', fn ($t) => $t->where('name', 'like', "%{$search}%"))))
->when($filters['status'] ?? null, fn ($q, $status) => $q->whereIn('status', $status))
->when(($filters['source'] ?? null) === 'template', fn ($q) => $q->whereNotNull('template_id'))
->when(($filters['source'] ?? null) === 'manual', fn ($q) => $q->whereNull('template_id'))
->when($filters['date_from'] ?? null, fn ($q, $from) => $q->whereDate('created_at', '>=', $from))
->when($filters['date_to'] ?? null, fn ($q, $to) => $q->whereDate('created_at', '<=', $to))
->orderByDesc('created_at')
->paginate($limit);
@@ -107,6 +143,19 @@ final class MailCampaignController extends Controller
]);
}
public function sender(): JsonResponse
{
return response()->json([
'error' => false,
'data' => [
'from_address' => config('mail.from.address'),
'from_name' => config('mail.from.name'),
'smtp_configured' => MailSettings::isConfigured(),
'mail_enabled' => AutoEmailGate::mailEnabled(),
],
]);
}
public function preview(Request $request, MailCampaignRenderer $renderer): JsonResponse
{
$campaign = $this->draftCampaign($request->validate($this->contentRules()));
@@ -156,6 +205,26 @@ final class MailCampaignController extends Controller
]);
}
public function resolveEmails(Request $request): JsonResponse
{
$data = $request->validate([
'emails' => 'required|array|min:1|max:' . self::MAX_EMAILS,
'emails.*' => self::EMAIL_RULE,
], self::EMAIL_MESSAGES);
$emails = $this->normalizeEmails($data['emails']);
$customers = $this->customersByEmail($emails);
$registered = $customers->map(fn (Customer $customer) => mb_strtolower(trim($customer->email)))->all();
return response()->json([
'error' => false,
'data' => [
'customers' => CustomerResource::collection($customers->values()),
'unregistered' => array_values(array_diff($emails, $registered)),
],
]);
}
public function store(Request $request): JsonResponse
{
$data = $request->validate([
@@ -163,11 +232,14 @@ final class MailCampaignController extends Controller
'recipient_type' => 'required|in:all,selected',
'uuids' => 'nullable|array',
'uuids.*' => 'string|exists:customers,uuid',
'emails' => 'nullable|array|max:1000',
'emails.*' => 'email|max:255',
'emails' => 'nullable|array|max:' . self::MAX_EMAILS,
'emails.*' => self::EMAIL_RULE,
'name' => 'nullable|string|max:255',
'cc' => 'nullable|string|max:1000',
'bcc' => 'nullable|string|max:1000',
], [
...self::EMAIL_MESSAGES,
'uuids.*.exists' => 'Khách hàng đã chọn không còn tồn tại.',
]);
$campaign = $this->draftCampaign($data);
@@ -293,17 +365,18 @@ final class MailCampaignController extends Controller
*/
private function selectedRecipients(array $uuids, array $emails, bool $marketingOnly): array
{
$emails = array_values(array_unique(array_map(fn ($email) => mb_strtolower(trim($email)), $emails)));
$emails = $this->normalizeEmails($emails);
$customers = Customer::query()
->where(fn ($q) => $q->whereIn('uuid', $uuids)->orWhereIn('email', $emails))
->whereIn('uuid', $uuids)
->whereNotNull('email')
->get(['id', 'email', 'uuid', 'email_marketing']);
->get(['id', 'email', 'uuid', 'email_marketing'])
->merge($this->customersByEmail($emails));
$recipients = [];
$optedOutEmails = [];
foreach ($customers as $customer) {
$email = mb_strtolower($customer->email);
$email = mb_strtolower(trim($customer->email));
if (!in_array($customer->uuid, $uuids, true) && !in_array($email, $emails, true)) {
continue;
}
@@ -325,6 +398,32 @@ final class MailCampaignController extends Controller
];
}
/**
* @param array<int, string> $emails
* @return array<int, string>
*/
private function normalizeEmails(array $emails): array
{
return array_values(array_unique(array_map(fn ($email) => mb_strtolower(trim((string) $email)), $emails)));
}
/**
* @param array<int, string> $emails
* @return Collection<int, Customer>
*/
private function customersByEmail(array $emails): Collection
{
if ($emails === []) {
return new Collection();
}
return Customer::query()
->whereIn(DB::raw('LOWER(TRIM(email))'), $emails)
->get()
->unique(fn (Customer $customer) => mb_strtolower(trim($customer->email)))
->toBase();
}
private function mapCampaign(MailCampaign $campaign): array
{
return [
@@ -79,6 +79,8 @@ class ProductController extends Controller
'product_type' => 'nullable|string',
'category' => 'nullable|string|max:255',
'thumbnail' => 'nullable|string|max:2048',
'short_description' => 'nullable|string',
'description' => 'nullable|string',
'is_active' => 'boolean',
'setup_fee' => 'nullable|numeric|min:0',
'cpu' => 'nullable|numeric|min:0',
@@ -114,6 +116,8 @@ class ProductController extends Controller
'service_plan_id' => $validated['service_plan_id'] ?? null,
'product_type' => $productType,
'thumbnail' => ($validated['thumbnail'] ?? null) ?: null,
'short_description' => $validated['short_description'] ?? null,
'description' => $validated['description'] ?? null,
'is_active' => $validated['is_active'] ?? true,
'setup_fee' => $validated['setup_fee'] ?? 0,
'cpu' => $validated['cpu'] ?? null,
@@ -148,6 +152,8 @@ class ProductController extends Controller
'service_plan_id' => 'nullable|integer|exists:service_plans,id',
'product_type' => 'nullable|string',
'thumbnail' => 'nullable|string|max:2048',
'short_description' => 'nullable|string',
'description' => 'nullable|string',
'is_active' => 'boolean',
'setup_fee' => 'nullable|numeric|min:0',
'cpu' => 'nullable|numeric|min:0',