554 lines
19 KiB
PHP
554 lines
19 KiB
PHP
<?php
|
|
namespace App\Services\Admin\Mail;
|
|
|
|
use App\Repositories\Admin\Mail\AdminMailRepository;
|
|
use App\Repositories\Admin\Mail\AdminMailTemplateRepository;
|
|
use App\Services\MailService;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Log;
|
|
use App\Services\Admin\AdminAuditService;
|
|
|
|
final class AdminMailService
|
|
{
|
|
public function __construct(
|
|
private readonly AdminMailRepository $repo,
|
|
private readonly AdminMailTemplateRepository $tplRepo,
|
|
private readonly MailService $mail,
|
|
private readonly AdminAuditService $audit,
|
|
) {}
|
|
|
|
public function getSkinOptions(): array
|
|
{
|
|
return [
|
|
['key'=>'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 입니다.'];
|
|
}
|
|
|
|
$payload = [
|
|
'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,
|
|
];
|
|
|
|
$id = $this->tplRepo->create($payload);
|
|
|
|
// 성공시에만 감사로그 (기존 흐름 방해 X)
|
|
if ((int)$id > 0) {
|
|
$req = request();
|
|
$this->audit->log(
|
|
actorAdminId: $adminId,
|
|
action: 'admin.mail_template.create',
|
|
targetType: 'mail_template',
|
|
targetId: (int)$id,
|
|
before: null,
|
|
after: array_merge(['id' => (int)$id], $payload),
|
|
ip: (string)($req?->ip() ?? ''),
|
|
ua: (string)($req?->userAgent() ?? ''),
|
|
);
|
|
}
|
|
|
|
return ['ok'=>true,'id'=>$id];
|
|
}
|
|
|
|
|
|
public function updateTemplate(int $adminId, int $id, array $data): array
|
|
{
|
|
// before 스냅샷: repo에 get/find가 있으면 사용, 없으면 null로 진행(흐름 방해 X)
|
|
$before = null;
|
|
try {
|
|
if (method_exists($this->tplRepo, 'find')) {
|
|
$before = $this->tplRepo->find($id);
|
|
} elseif (method_exists($this->tplRepo, 'get')) {
|
|
$before = $this->tplRepo->get($id);
|
|
}
|
|
} catch (\Throwable $ignored) {}
|
|
|
|
$payload = [
|
|
'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,
|
|
];
|
|
|
|
$affected = $this->tplRepo->update($id, $payload);
|
|
|
|
$ok = ($affected >= 0);
|
|
|
|
// 성공시에만 감사로그
|
|
if ($ok) {
|
|
$req = request();
|
|
|
|
$beforeAudit = $before ? (array)$before : ['id' => $id];
|
|
$afterAudit = array_merge($beforeAudit, $payload);
|
|
|
|
$this->audit->log(
|
|
actorAdminId: $adminId,
|
|
action: 'admin.mail_template.update',
|
|
targetType: 'mail_template',
|
|
targetId: (int)$id,
|
|
before: $beforeAudit,
|
|
after: $afterAudit,
|
|
ip: (string)($req?->ip() ?? ''),
|
|
ua: (string)($req?->userAgent() ?? ''),
|
|
);
|
|
}
|
|
|
|
return ['ok'=>$ok];
|
|
}
|
|
|
|
|
|
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);
|
|
}
|
|
}
|