'hero','label'=>'Hero Classic (이벤트/마케팅)'], ['key'=>'newsletter','label'=>'Newsletter Card (여러 항목)'], ['key'=>'minimal','label'=>'Minimal Transactional (알림/인증)'], ['key'=>'clean','label'=>'Clean Transactional'], ['key'=>'dark','label'=>'Dark Transactional'], ]; } public function getStatusLabels(): array { return [ 'scheduled'=>'예약대기', 'queued'=>'대기', 'sending'=>'발송중', 'sent'=>'완료', 'partial'=>'부분실패', 'failed'=>'실패', 'canceled'=>'취소', ]; } public function getItemStatusLabels(): array { return [ 'queued'=>'대기', 'sent'=>'성공', 'failed'=>'실패', 'canceled'=>'취소', 'skipped'=>'스킵', ]; } public function getModeLabels(): array { return [ 'one'=>'단건', 'many'=>'여러건', 'csv'=>'CSV 업로드', ]; } public function getActiveTemplates(): array { return $this->tplRepo->activeAll(); } public function listTemplates(array $filters, int $perPage=20) { return $this->tplRepo->list($filters, $perPage); } public function getTemplate(int $id): ?object { return $this->tplRepo->find($id); } public function createTemplate(int $adminId, array $data): array { $code = (string)$data['code']; if ($this->tplRepo->existsCode($code)) { return ['ok'=>false,'message'=>'이미 존재하는 code 입니다.']; } $id = $this->tplRepo->create([ 'code' => $code, 'title' => $data['title'], 'description' => $data['description'] ?? '', 'skin_key' => $data['skin_key'], 'subject_tpl' => $data['subject'], 'body_tpl' => $data['body'], 'hero_image_url'=> $data['hero_image_url'] ?? '', 'cta_label' => $data['cta_label'] ?? '', 'cta_url' => $data['cta_url'] ?? '', 'is_active' => (int)(!empty($data['is_active']) ? 1 : 0), 'created_by' => $adminId, 'updated_by' => $adminId, ]); return ['ok'=>true,'id'=>$id]; } public function updateTemplate(int $adminId, int $id, array $data): array { $affected = $this->tplRepo->update($id, [ 'title'=>$data['title'], 'description'=>$data['description'] ?? '', 'skin_key'=>$data['skin_key'], 'subject_tpl'=>$data['subject'], 'body_tpl'=>$data['body'], 'hero_image_url'=>$data['hero_image_url'] ?? '', 'cta_label'=>$data['cta_label'] ?? '', 'cta_url'=>$data['cta_url'] ?? '', 'is_active'=> (int)(($data['is_active'] ?? 0) ? 1 : 0), 'updated_by'=>$adminId, ]); return ['ok'=>$affected>=0]; } public function listBatches(array $filters, int $perPage=20) { $page = $this->repo->listBatches($filters, $perPage); if (is_object($page) && method_exists($page, 'appends')) { $clean = array_filter($filters, fn($v) => $v !== null && $v !== ''); $page->appends($clean); } return $page; } public function getBatchDetail(int $batchId, array $filters, int $perPage=50): array { $batch = $this->repo->findBatch($batchId); if (!$batch) return ['ok'=>false]; $items = $this->repo->listItems($batchId, $filters, $perPage); if (is_object($items) && method_exists($items, 'appends')) { $clean = array_filter($filters, fn($v) => $v !== null && $v !== ''); $items->appends($clean); } return ['ok'=>true,'batch'=>$batch,'items'=>$items]; } public function cancelBatch(int $adminId, int $batchId): array { $b = $this->repo->findBatch($batchId); if (!$b) return ['ok'=>false,'message'=>'배치를 찾을 수 없습니다.']; if (in_array((string)$b->status, ['sent','failed','partial','canceled'], true)) { return ['ok'=>false,'message'=>'이미 종료된 배치입니다.']; } $this->repo->cancelBatch($batchId); return ['ok'=>true,'message'=>'취소 처리했습니다.']; } public function retryFailed(int $adminId, int $batchId): array { $b = $this->repo->findBatch($batchId); if (!$b) return ['ok'=>false,'message'=>'배치를 찾을 수 없습니다.']; $n = $this->repo->resetFailedToQueued($batchId); $this->repo->updateBatch($batchId, ['status'=>'queued','last_error'=>'']); \App\Jobs\Admin\Mail\DispatchMailBatchJob::dispatch($batchId)->onQueue('mail'); return ['ok'=>true,'message'=>"실패 {$n}건을 재시도 대기열로 전환했습니다."]; } public function createBatch(int $adminId, array $data, Request $request, array $ctx): array { $mode = (string)$data['send_mode']; // ✅ (선택) 템플릿을 서버에서 강제 적용하고 싶으면 template_id가 넘어왔을 때 덮어쓰기 // Blade에서 tplSelect에 name="template_id" 꼭 넣어야 들어옴 $tplId = (int)($data['template_id'] ?? 0); if ($tplId > 0) { $tpl = $this->tplRepo->find($tplId); if (!$tpl) return ['ok'=>false,'message'=>'템플릿을 찾을 수 없습니다.']; $data['skin_key'] = (string)($tpl->skin_key ?? $data['skin_key']); $data['subject'] = (string)($tpl->subject_tpl ?? $data['subject']); $data['body'] = (string)($tpl->body_tpl ?? $data['body']); $data['hero_image_url'] = (string)($tpl->hero_image_url ?? ($data['hero_image_url'] ?? '')); $data['cta_label'] = (string)($tpl->cta_label ?? ($data['cta_label'] ?? '')); $data['cta_url'] = (string)($tpl->cta_url ?? ($data['cta_url'] ?? '')); } // 1) 수신자 수집 $recipients = $this->collectRecipients($mode, $data, $request); if (!$recipients['ok']) return $recipients; $list = $recipients['list']; // [ ['email'=>..., 'name'=>..., 'tokens'=>[...]], ... ] $total = $recipients['total']; $valid = count($list); if ($valid < 1) { return ['ok'=>false,'message'=>'유효한 수신자가 없습니다.']; } // 2) 배치 생성 $scheduledAt = !empty($data['scheduled_at']) ? (string)$data['scheduled_at'].':00' : null; $status = $scheduledAt ? 'scheduled' : 'queued'; $ipBin = null; try { $ipBin = @inet_pton($ctx['ip'] ?? '') ?: null; } catch (\Throwable $e) {} $batchId = $this->repo->createBatch([ 'admin_user_id'=>$adminId, 'admin_name'=>$ctx['admin_name'] ?? '', 'created_ip'=>$ipBin, 'created_ua'=>mb_substr((string)($ctx['ua'] ?? ''),0,255), 'send_mode'=>$mode, 'from_email'=>$data['from_email'], 'from_name'=>$data['from_name'] ?? '', 'reply_to'=>$data['reply_to'] ?? '', 'skin_key'=>$data['skin_key'], 'subject_raw'=>$data['subject'], 'body_raw'=>$data['body'], 'hero_image_url'=>$data['hero_image_url'] ?? '', 'cta_label'=>$data['cta_label'] ?? '', 'cta_url'=>$data['cta_url'] ?? '', 'scheduled_at'=>$scheduledAt, 'status'=>$status, 'total_count'=>$total, 'valid_count'=>$valid, 'sent_count'=>0, 'failed_count'=>0, 'canceled_count'=>0, 'last_error'=>'', ]); // 3) 아이템 insert (토큰 치환) $rows = []; $seq = 1; foreach ($list as $r) { $tokens = $r['tokens'] ?? []; // ✅ strtr로 치환(키-값 배열) $subjectFinal = $this->applyTokens((string)$data['subject'], $tokens); $bodyFinal = $this->applyTokens((string)$data['body'], $tokens); $rows[] = [ 'batch_id'=>$batchId, 'seq'=>$seq++, 'to_email'=>$r['email'], 'to_name'=>$r['name'] ?? '', 'token_json'=>json_encode($tokens, JSON_UNESCAPED_UNICODE), 'subject_final'=>$subjectFinal, 'body_final'=>$bodyFinal, 'status'=>'queued', 'attempts'=>0, 'last_error'=>'', ]; } foreach (array_chunk($rows, 500) as $chunk) { $this->repo->bulkInsertItems($batchId, $chunk); } if (!$scheduledAt) { \App\Jobs\Admin\Mail\DispatchMailBatchJob::dispatch($batchId)->onQueue('mail'); } return [ 'ok'=>true, 'batch_id'=>$batchId, 'total'=>$total, 'valid'=>$valid, 'scheduled'=>(bool)$scheduledAt, ]; } private function collectRecipients(string $mode, array $data, Request $request): array { $raw = []; if ($mode === 'one') { if (empty($data['to_email'])) return ['ok'=>false,'message'=>'수신자 이메일을 입력하세요.']; $raw[] = ['email'=>$data['to_email'], 'name'=>$data['to_name'] ?? '', 'tokens'=>[]]; } elseif ($mode === 'many') { $text = (string)($data['to_emails_text'] ?? ''); $raw = array_merge($raw, $this->parseEmailsText($text)); } elseif ($mode === 'csv') { $file = $request->file('to_emails_csv'); if (!$file) return ['ok'=>false,'message'=>'CSV 파일을 업로드하세요.']; $raw = array_merge($raw, $this->parseEmailsCsv($file->getRealPath())); } $total = count($raw); $seen = []; $list = []; foreach ($raw as $r) { $email = mb_strtolower(trim((string)($r['email'] ?? ''))); if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) continue; if (isset($seen[$email])) continue; $seen[$email] = true; $list[] = [ 'email'=>$email, 'name'=>trim((string)($r['name'] ?? '')), 'tokens'=>is_array($r['tokens'] ?? null) ? $r['tokens'] : [], ]; } return ['ok'=>true,'total'=>$total,'list'=>$list]; } private function parseEmailsText(string $text): array { // 형식: // email,token1,token2,... // ✅ 1열=email, 2열={_text_02_}, 3열={_text_03_} ... $lines = preg_split('/\r\n|\r|\n/', $text) ?: []; $out = []; foreach ($lines as $line) { $line = trim((string)$line); if ($line === '') continue; // ✅ 빈열 유지(열 밀림 방지) => filter 하지 않음 $cols = array_map('trim', explode(',', $line)); $email = $cols[0] ?? ''; $name = $cols[1] ?? ''; // 로그용으로만(원하면 비워도 됨) $tokens = $this->colsToTokens($cols); $out[] = ['email'=>$email,'name'=>$name,'tokens'=>$tokens]; } return $out; } private function parseEmailsCsv(string $path): array { $out = []; $fh = fopen($path, 'r'); if (!$fh) return $out; while (($row = fgetcsv($fh)) !== false) { if (!$row) continue; // fgetcsv는 빈값도 배열에 포함됨(열 밀림 방지에 유리) $cols = array_map(fn($v)=>trim((string)$v), $row); $email = $cols[0] ?? ''; $name = $cols[1] ?? ''; // 로그용 $tokens = $this->colsToTokens($cols); $out[] = ['email'=>$email,'name'=>$name,'tokens'=>$tokens]; } fclose($fh); return $out; } private function colsToTokens(array $cols): array { $tokens = []; if (!empty($cols[1])) $tokens['{_name_}'] = $cols[1]; for ($i = 1; $i < count($cols) && $i <= 8; $i++) { // 최대 {_text_09_} 까지 $tokenNo = str_pad((string)($i + 1), 2, '0', STR_PAD_LEFT); // 02..09 $tokens['{_text_'.$tokenNo.'_}'] = (string)($cols[$i] ?? ''); } return $tokens; } private function applyTokens(string $text, array $tokens): string { if ($text === '' || empty($tokens)) return $text; return strtr($text, $tokens); } public function dispatchDueScheduledBatches(int $limit=20): int { $rows = $this->repo->pickDueScheduledBatches($limit); $n = 0; foreach ($rows as $b) { $this->repo->setBatchQueued((int)$b->id); \App\Jobs\Admin\Mail\DispatchMailBatchJob::dispatch((int)$b->id)->onQueue('mail'); $n++; } return $n; } public function sendItem(object $batch, object $item): array { if (in_array((string)$batch->status, ['canceled'], true)) { $this->repo->updateItem((int)$item->id, [ 'status'=>'skipped','last_error'=>'batch_canceled' ]); return ['ok'=>false,'skipped'=>true]; } $view = match ((string)$batch->skin_key) { 'newsletter' => 'mail.skins.newsletter', 'minimal' => 'mail.skins.minimal', 'clean' => 'mail.skins.clean', 'dark' => 'mail.skins.dark', default => 'mail.skins.hero', }; $subject = (string)($item->subject_final ?? ''); $bodyText = (string)($item->body_final ?? ''); // 핵심: preview와 동일한 변수명으로 통일 $payload = [ 'brand' => config('app.name'), 'subtitle' => '공지/이벤트/안내 메일', // 스킨에서 쓰면 보이고, 안 쓰면 무시됨 'logoUrl' => config('services.brand.logo_url', ''), 'subject' => $subject, 'body_html' => nl2br(e($bodyText)), 'body_text' => $bodyText, 'heroUrl' => (string)($batch->hero_image_url ?? ''), 'siteUrl' => config('app.url'), 'year' => date('Y'), 'sns' => [ ['label'=>'Instagram', 'url'=>config('services.brand.sns.instagram','')], ['label'=>'YouTube', 'url'=>config('services.brand.sns.youtube','')], ['label'=>'Kakao', 'url'=>config('services.brand.sns.kakao','')], ], ]; try { $this->mail->sendTemplate( $item->to_email, $subject, $view, $payload, queue: false ); $this->repo->updateItem((int)$item->id, [ 'status'=>'sent', 'attempts'=> (int)$item->attempts + 1, 'last_error'=>'', 'sent_at'=> now()->format('Y-m-d H:i:s'), ]); return ['ok'=>true]; } catch (\Throwable $e) { $msg = mb_substr($e->getMessage(), 0, 500); $this->repo->updateItem((int)$item->id, [ 'status'=>'failed', 'attempts'=> (int)$item->attempts + 1, 'last_error'=>$msg, ]); Log::warning('[admin-mail] send failed', [ 'batch_id'=>$batch->id, 'item_id'=>$item->id, 'to'=>$item->to_email, 'err'=>$msg, ]); return ['ok'=>false,'error'=>$msg]; } } public function refreshBatchProgress(int $batchId): void { $b = $this->repo->findBatch($batchId); if (!$b) return; $cnt = $this->repo->countItemsByStatus($batchId); $valid = (int)$b->valid_count; $sent = (int)($cnt['sent'] ?? 0); $failed= (int)($cnt['failed'] ?? 0); $queued= (int)($cnt['queued'] ?? 0); $status = (string)$b->status; if ($status !== 'canceled') { if ($queued > 0) $status = 'sending'; else { if ($failed === 0 && $sent >= $valid) $status = 'sent'; elseif ($sent > 0 && $failed > 0) $status = 'partial'; else $status = 'failed'; } } $this->repo->updateBatchCounts($batchId, $cnt, $status); } }