giftcon_dev/app/Http/Controllers/Admin/Mail/AdminMailController.php

286 lines
10 KiB
PHP

<?php
namespace App\Http\Controllers\Admin\Mail;
use App\Http\Controllers\Controller;
use App\Services\Admin\Mail\AdminMailService;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Validator;
final class AdminMailController extends Controller
{
public function __construct(
private readonly AdminMailService $service
) {}
public function create()
{
$templates = $this->service->getActiveTemplates(); // service가 repo 호출
$skins = $this->service->getSkinOptions();
return view('admin.mail.send', [
'templates' => $templates,
'skins' => $skins,
]);
}
public function store(Request $request)
{
// ✅ 템플릿 선택값(옵션): Blade에서 <select name="template_id"> 로 보내면 여기서 받음
$hasTemplate = $request->filled('template_id');
$rules = [
'send_mode' => ['required','in:one,many,csv,db'],
// ✅ schedule_type 추가 + 예약이면 scheduled_at 필수
'schedule_type' => ['required','in:now,schedule'],
'scheduled_at' => ['nullable','date_format:Y-m-d H:i','required_if:schedule_type,schedule'],
'from_email' => ['required','email','max:190'],
'from_name' => ['nullable','string','max:120'],
'reply_to' => ['nullable','email','max:190'],
// ✅ 템플릿 선택이면(스킨 select disabled 등) skin_key가 누락될 수 있으니 nullable 허용 + 서버에서 기본값 세팅
'skin_key' => $hasTemplate
? ['nullable','in:hero,newsletter,minimal,clean,dark']
: ['required','in:hero,newsletter,minimal,clean,dark'],
// 템플릿 선택용(옵션)
'template_id' => ['nullable','integer','min:1'],
// ✅ subject/body는 무조건 들어가야 함(템플릿이면 적용 버튼으로 채워지게)
'subject' => ['required','string','max:190'],
'body' => ['required','string','max:20000'],
'hero_image_url' => ['nullable','string','max:500'],
'cta_label' => ['nullable','string','max:80'],
'cta_url' => ['nullable','string','max:500'],
// one
'to_email' => ['nullable','email','max:190'],
'to_name' => ['nullable','string','max:120'],
// many(text)
'to_emails_text' => ['nullable','string','max:500000'],
// csv upload
'to_emails_csv' => ['nullable','file','mimes:csv,txt','max:5120'],
// db mode(최소 v1)
'db_q' => ['nullable','string','max:120'],
'db_limit' => ['nullable','integer','min:1','max:20000'],
];
$v = Validator::make($request->all(), $rules);
// ✅ 모드별 추가 검증(필수값 체크)
$v->after(function ($validator) use ($request) {
$mode = (string)$request->input('send_mode');
if ($mode === 'one') {
$em = trim((string)$request->input('to_email'));
if ($em === '') {
$validator->errors()->add('to_email', '단건 발송은 수신자 이메일이 필요합니다.');
}
}
if ($mode === 'many') {
$raw = (string)$request->input('to_emails_text');
if (trim($raw) === '') {
$validator->errors()->add('to_emails_text', '여러건 발송은 수신자 목록을 입력하세요.');
}
}
if ($mode === 'csv') {
if (!$request->file('to_emails_csv')) {
$validator->errors()->add('to_emails_csv', 'CSV 발송은 CSV 파일 업로드가 필요합니다.');
}
}
});
$data = $v->validate();
// ✅ skin_key가 폼에서 누락되면(템플릿 선택 시 disabled 등) 기본값 채움
// (템플릿이면 서비스가 템플릿의 skin_key로 덮어쓰도록 하는 게 정석)
$data['skin_key'] = (string)($data['skin_key'] ?? '');
if ($data['skin_key'] === '') {
$data['skin_key'] = 'clean';
}
// ✅ 컨트롤러에서 “정상 규칙”으로 파싱 결과를 만들어 request에 심어둠
// → 서비스에서 이 parsed_rows를 우선 사용하면 토큰 밀림(덮어쓰기) 문제를 확실히 잡을 수 있음.
if ($data['send_mode'] === 'many') {
$rows = $this->parseManyText((string)($data['to_emails_text'] ?? ''));
if (count($rows) < 1) {
return back()->with('toast', [
'type'=>'danger','title'=>'실패','message'=>'여러건 수신자 파싱 결과가 없습니다. (이메일 형식/중복/입력 형식 확인)'
])->withInput();
}
// request에 전달(서비스에서 우선 사용)
$request->request->set('parsed_rows', $rows);
}
if ($data['send_mode'] === 'csv') {
/** @var UploadedFile|null $file */
$file = $request->file('to_emails_csv');
$rows = $file ? $this->parseCsvFile($file) : [];
if (count($rows) < 1) {
return back()->with('toast', [
'type'=>'danger','title'=>'실패','message'=>'CSV 파싱 결과가 없습니다. (1열 이메일 형식/중복/파일 내용 확인)'
])->withInput();
}
$request->request->set('parsed_rows', $rows);
}
$adminId = (int)auth('admin')->id();
$ctx = [
'admin_name' => (string)(auth('admin')->user()->name ?? ''),
'ip' => (string)$request->ip(),
'ua' => (string)$request->userAgent(),
];
$res = $this->service->createBatch($adminId, $data, $request, $ctx);
if (!$res['ok']) {
return back()->with('toast', [
'type'=>'danger','title'=>'실패','message'=>$res['message'] ?? '처리에 실패했습니다.'
])->withInput();
}
return redirect()->route('admin.mail.logs.show', ['batchId'=>$res['batch_id']])
->with('toast', [
'type'=>'success','title'=>'접수 완료',
'message'=>"{$res['total']}건 / 유효 {$res['valid']}건 등록. " . ($res['scheduled'] ? '예약 발송입니다.' : '발송을 시작합니다.')
]);
}
public function preview(Request $request)
{
$data = $request->validate([
'skin_key' => ['required','string','max:40'],
'subject' => ['nullable','string','max:200'],
'body' => ['nullable','string','max:20000'],
'heroUrl' => ['nullable','string','max:500'],
]);
// ✅ 미리보기 샘플도 실제 규칙과 동일하게
// {_text_02_}=이름, {_text_03_}=금액, {_text_04_}=상품유형
$sample = [
'{_text_02_}' => '홍길동',
'{_text_03_}' => '10000',
'{_text_04_}' => '쿠폰',
];
$subject = strtr((string)($data['subject'] ?? ''), $sample);
$body = strtr((string)($data['body'] ?? ''), $sample);
return response()->view('mail.preview', [
'skin_key' => $data['skin_key'],
'brand' => 'PIN FOR YOU',
'subtitle' => '공지/이벤트/안내 메일',
'logoUrl' => config('services.brand.logo_url', ''),
'heroUrl' => (string)($data['heroUrl'] ?? ''),
'subject' => $subject,
'body_html' => nl2br(e($body)),
'siteUrl' => config('app.url'),
'year' => date('Y'),
'sns' => [
['label'=>'Instagram','url'=>'https://example.com'],
['label'=>'YouTube','url'=>'https://example.com'],
['label'=>'Kakao','url'=>'https://example.com'],
],
]);
}
// =========================
// Token helpers (컨트롤러 공통)
// =========================
private function buildVarsFromTokens(array $tokens): array
{
$vars = [];
$tokens = array_values($tokens);
foreach ($tokens as $i => $v) {
$key = sprintf('{_text_%02d_}', $i + 2); // ✅ 무조건 02부터
$vars[$key] = trim((string)$v);
}
return $vars;
}
private function applyVars(string $tpl, array $vars): string
{
return strtr($tpl, $vars);
}
private function bodyToHtml(string $text): string
{
// ✅ 줄바꿈 유지 + XSS 방지
return nl2br(e($text));
}
// =========================
// Parsers
// =========================
private function parseManyText(string $raw): array
{
$lines = preg_split("/\r\n|\r|\n/", $raw) ?: [];
$rows = [];
$seen = [];
foreach ($lines as $idx => $line) {
$line = trim((string)$line);
if ($line === '') continue;
// ✅ 빈열 유지(열 밀림 방지)
$cols = array_map('trim', explode(',', $line));
$email = strtolower(trim($cols[0] ?? ''));
if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) continue;
if (isset($seen[$email])) continue;
$seen[$email] = true;
$tokens = array_slice($cols, 1); // 2열부터
$vars = $this->buildVarsFromTokens($tokens);
$rows[] = [
'email' => $email,
'vars' => $vars,
'raw' => $line,
'line' => $idx + 1,
];
}
return $rows;
}
private function parseCsvFile(UploadedFile $file): array
{
$rows = [];
$seen = [];
$handle = fopen($file->getRealPath(), 'rb');
if (!$handle) return [];
while (($cols = fgetcsv($handle)) !== false) {
$cols = array_map(fn($v) => trim((string)$v), $cols);
$email = strtolower(trim($cols[0] ?? ''));
if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) continue;
if (isset($seen[$email])) continue;
$seen[$email] = true;
$tokens = array_slice($cols, 1); // 2열부터
$vars = $this->buildVarsFromTokens($tokens);
$rows[] = ['email' => $email, 'vars' => $vars];
}
fclose($handle);
return $rows;
}
}