From e1b7951f4cd74f78dbf40fa65460097462f918d5 Mon Sep 17 00:00:00 2001 From: sungro815 Date: Fri, 6 Feb 2026 18:32:42 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=20=EB=A9=94?= =?UTF-8?q?=EC=9D=BC=20=ED=85=9C=ED=94=8C=EB=A6=BF=20=EB=B0=9C=EC=86=A1?= =?UTF-8?q?=EB=82=B4=EC=97=AD=20=EC=9E=91=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Admin/Mail/AdminMailController.php | 285 ++++++ .../Admin/Mail/AdminMailLogController.php | 75 ++ .../Mail/AdminMailTemplateController.php | 95 ++ app/Jobs/Admin/Mail/DispatchMailBatchJob.php | 33 + app/Jobs/Admin/Mail/SendMailItemJob.php | 44 + app/Providers/AppServiceProvider.php | 4 + .../Admin/Mail/AdminMailRepository.php | 161 ++++ .../Mail/AdminMailTemplateRepository.php | 58 ++ app/Services/Admin/Mail/AdminMailService.php | 498 ++++++++++ composer.json | 3 +- composer.lock | 141 ++- config/logging.php | 6 + package-lock.json | 71 +- package.json | 3 + resources/css/admin.css | 223 +++++ resources/js/admin.js | 80 ++ .../views/admin/mail/logs/index.blade.php | 178 ++++ .../views/admin/mail/logs/show.blade.php | 157 ++++ resources/views/admin/mail/send.blade.php | 875 ++++++++++++++++++ .../views/admin/mail/templates/form.blade.php | 201 ++++ .../admin/mail/templates/index.blade.php | 87 ++ .../views/admin/partials/sidebar.blade.php | 119 ++- resources/views/admin/sms/send.blade.php | 2 +- resources/views/mail/preview.blade.php | 18 + resources/views/mail/skins/clean.blade.php | 83 ++ resources/views/mail/skins/dark.blade.php | 78 ++ resources/views/mail/skins/hero.blade.php | 112 +++ resources/views/mail/skins/minimal.blade.php | 75 ++ .../views/mail/skins/newsletter.blade.php | 85 ++ routes/admin.php | 34 +- 30 files changed, 3836 insertions(+), 48 deletions(-) create mode 100644 app/Http/Controllers/Admin/Mail/AdminMailController.php create mode 100644 app/Http/Controllers/Admin/Mail/AdminMailLogController.php create mode 100644 app/Http/Controllers/Admin/Mail/AdminMailTemplateController.php create mode 100644 app/Jobs/Admin/Mail/DispatchMailBatchJob.php create mode 100644 app/Jobs/Admin/Mail/SendMailItemJob.php create mode 100644 app/Repositories/Admin/Mail/AdminMailRepository.php create mode 100644 app/Repositories/Admin/Mail/AdminMailTemplateRepository.php create mode 100644 app/Services/Admin/Mail/AdminMailService.php create mode 100644 resources/views/admin/mail/logs/index.blade.php create mode 100644 resources/views/admin/mail/logs/show.blade.php create mode 100644 resources/views/admin/mail/send.blade.php create mode 100644 resources/views/admin/mail/templates/form.blade.php create mode 100644 resources/views/admin/mail/templates/index.blade.php create mode 100644 resources/views/mail/preview.blade.php create mode 100644 resources/views/mail/skins/clean.blade.php create mode 100644 resources/views/mail/skins/dark.blade.php create mode 100644 resources/views/mail/skins/hero.blade.php create mode 100644 resources/views/mail/skins/minimal.blade.php create mode 100644 resources/views/mail/skins/newsletter.blade.php diff --git a/app/Http/Controllers/Admin/Mail/AdminMailController.php b/app/Http/Controllers/Admin/Mail/AdminMailController.php new file mode 100644 index 0000000..ead2145 --- /dev/null +++ b/app/Http/Controllers/Admin/Mail/AdminMailController.php @@ -0,0 +1,285 @@ +service->getActiveTemplates(); // service가 repo 호출 + $skins = $this->service->getSkinOptions(); + + return view('admin.mail.send', [ + 'templates' => $templates, + 'skins' => $skins, + ]); + } + + public function store(Request $request) + { + // ✅ 템플릿 선택값(옵션): Blade에서 + + @foreach(array_keys($statusLabel) as $st) + + @endforeach + + + +
+
모드
+ +
+ +
+
기간
+
+ + ~ + + +
+
+ +
+
검색
+ +
+ +
+ + 발송 + 필터 초기화 +
+ + + +
+
+
{{ $batches->total() }}
+
+ +
+ + + + + + + + + + + + + + + @forelse($batches as $b) + @php + $st = (string)$b->status; + $pillClass = match ($st) { + 'submitted' => 'pill--ok', + 'partial','submitting','queued','scheduled' => 'pill--warn', + 'failed','canceled' => 'pill--bad', + default => 'pill--muted', + }; + $stK = $statusLabel[$st] ?? $st; + $modeK = $modeLabel[(string)$b->send_mode] ?? $b->send_mode; + $sent = (int)($b->sent_count ?? 0); + $total = (int)($b->valid_count ?? $b->total_count ?? 0); + @endphp + + + + + + + + + + + @empty + + @endforelse + +
Batch생성일시작성자모드예약진행률상태제목
+ #{{ $b->id }} + {{ $b->created_at }}{{ $b->admin_name ?? ('#'.$b->admin_user_id) }}{{ $modeK }}{{ $b->scheduled_at ?? '-' }}{{ $sent }}/{{ $total }}● {{ $stK }}
{{ $b->subject_raw ?? '-' }}
데이터가 없습니다.
+
+ +
+ {{ $batches->links() }} +
+
+ + @push('scripts') + + @endpush +@endsection diff --git a/resources/views/admin/mail/logs/show.blade.php b/resources/views/admin/mail/logs/show.blade.php new file mode 100644 index 0000000..89d537e --- /dev/null +++ b/resources/views/admin/mail/logs/show.blade.php @@ -0,0 +1,157 @@ +@extends('admin.layouts.app') + +@section('title', '메일 이력 상세') +@section('page_title', '메일 이력 상세') +@section('page_desc', '배치 및 수신자별 상세') + +@push('head') + +@endpush + +@section('content') + @php + $statusLabel = [ + 'scheduled' => '예약', + 'queued' => '대기', + 'submitting' => '발송중', + 'submitted' => '완료', + 'partial' => '부분성공', + 'failed' => '실패', + 'canceled' => '취소', + ]; + $modeLabel = [ + 'one' => '단건', + 'many' => '여러건', + 'template' => '템플릿CSV', + 'db' => 'DB검색', + ]; + $sent = (int)($batch->sent_count ?? 0); + $total = (int)($batch->valid_count ?? $batch->total_count ?? 0); + + @endphp + +
+
+
+
Batch ID
+
#{{ $batch->id }}
+
+
+
상태
+
{{ $statusLabel[(string)$batch->status] ?? $batch->status }}
+
+
+
모드
+
{{ $modeLabel[(string)$batch->send_mode] ?? $batch->send_mode }}
+
+
+
예약
+
{{ $batch->scheduled_at ?? '-' }}
+
+
+
진행률
+
{{ $sent }}/{{ $total }}
+
+
+ +
+ +
제목
+
{{ $batch->subject_raw ?? '-' }}
+ +
+ +
내용
+
+ {{ $batch->body_raw ?? $batch->body ?? '' }} +
+ +
+ 목록 + 발송 + + @if(in_array((string)$batch->status, ['queued','submitting','scheduled'], true)) +
+ @csrf + +
+ @endif + + @if(in_array((string)$batch->status, ['failed','partial'], true)) +
+ @csrf + +
+ @endif +
+
+ +
+
+
+
상태
+ +
+
+
수신자
+ +
+
+
검색(제목/내용)
+ +
+ +
+ +
+ + + + + + + + + + + + + @forelse($items as $it) + + + + + + + + + @empty + + @endforelse + +
Seq수신자상태제출시간제목요약
{{ $it->seq }}{{ $it->to_email }}{{ $statusLabel[(string)$it->status] ?? $it->status }}{{ $it->sent_at ?? '-' }}{{ $it->subject_final ?? $it->subject ?? '-' }}{{ $it->body_final ?? '-' }}
상세 데이터가 없습니다.
+
+ +
+ {{ $items->links() }} +
+
+@endsection diff --git a/resources/views/admin/mail/send.blade.php b/resources/views/admin/mail/send.blade.php new file mode 100644 index 0000000..44f61df --- /dev/null +++ b/resources/views/admin/mail/send.blade.php @@ -0,0 +1,875 @@ +@extends('admin.layouts.app') + +@section('title', '관리자 메일 발송') +@section('page_title', '관리자 메일 발송') +@section('page_desc', '단건 / 여러건 / 템플릿(CSV) / DB검색 발송') + +@push('head') + +@endpush + +@section('content') +
+ @csrf + + {{-- mode --}} + + + {{-- ✅ 여러건 파싱 결과(JSON) 서버 전달용 --}} + + + {{-- ✅ 토큰 시작 컬럼: 2열부터 {_text_02_} (서버에서 참고용) --}} + + + {{-- ✅ subject/body 미러 (백엔드 키 불일치 대비) --}} + + + + + +
+
+
+
발신자
+
+ + +
+
+ +
+
+ + + + (5분 단위 권장) +
+
+ +
+ 0명 + 예상: 0자 +
+
+
+ +
+ {{-- LEFT: recipients --}} +
+
+ + + + +
+ +
+
수신자 (1명)
+
+ + +
+
+ +
+
수신자 (여러명)
+ + + +
+ - 예: sungro81@gmail.com, 홍길동, 10000, 쿠폰
+ - 매칭(공통 규칙): {_text_02_} → 2열(홍길동), {_text_03_} → 3열(10000), {_text_04_} → 4열(쿠폰) +
+ +
+ + +
+ + {{-- ✅ 파싱 미리보기 --}} +
+
+
파싱 미리보기 (상위 5줄)
+
줄바꿈=행 / 콤마=열
+
+
+
(입력 전)
+
+
+
+ +
+
템플릿 CSV (개인화 발송)
+ + + +
+
+
CSV 미리보기 (첫 5줄)
+
업로드 실수 방지
+
+
+
(업로드 전)
+
+
+ +
+ - CSV 파일 작성 요령
+
+ sungro1@naver.com,홍길동,10000,쿠폰 + sungro1@google.com,이순신,20000,상품권 + sungro1@nate.com,김개똥,30000,쿠폰 +
+
+ 1열은 수신자 이메일, 2열부터는 메시지에 끼워 넣을 사용자 데이터를 콤마로 구분해 작성합니다. +
+ +
- 발송 문구와 매칭 (공통)
+
+ 안녕하세요 {_text_02_} 고객님 + 결제 금액은 {_text_03_}원 입니다. + 상품 유형: {_text_04_} +
+ +
+ {_text_02_} → 2열(홍길동)
+ {_text_03_} → 3열(10000)
+ {_text_04_} → 4열(쿠폰) +
+ +
+ 사용자 문구는 {_text_02_}부터 최대 8개까지 사용 가능: + {_text_02_} ~ {_text_09_} +
+
+
+ +
+
회원 DB 검색 발송
+
+ * 서버에서 조건에 맞는 회원을 찾아 임시 리스트를 만든 뒤, 큐로 천천히 발송합니다. +
+ +
+ + +
+ +
+ 예) gmail.com / 홍길동 / mem_no:123 +
+
+
+ + {{-- RIGHT: message/template/preview --}} +
+
+
+
스킨 (템플릿을 선택하면 스킨은 템플릿에 고정됩니다)
+ + +
+ +
+ {{-- ✅ 템플릿 선택값 서버로도 보내기 (선택사항이지만 디버깅/로그에 도움됨) --}} + + +
+
+ +
+ +
+
+
제목
+ +
+
+ +
+ +
+
내용
+ + +
+
+ + + + 토큰 빠른 삽입 +
+ +
+ + + 발송 이력 +
+
+
+ +
+ +
+
+
미리보기
+
선택한 스킨 + 현재 입력값(간이)
+
+
+
+
(제목)
+
+
+
+
+ + +
+
+
+ + {{-- ===== 전체 메일 형태 미리보기: 레이어 팝업 + 내부 스크롤 + iframe ===== --}} + + + @push('scripts') + @php + $previewUrl = \Illuminate\Support\Facades\Route::has('admin.mail.preview') + ? route('admin.mail.preview') + : ''; + @endphp + + + @endpush +@endsection diff --git a/resources/views/admin/mail/templates/form.blade.php b/resources/views/admin/mail/templates/form.blade.php new file mode 100644 index 0000000..9adb5a8 --- /dev/null +++ b/resources/views/admin/mail/templates/form.blade.php @@ -0,0 +1,201 @@ +@extends('admin.layouts.app') + +@section('title', $mode === 'create' ? '메일 템플릿 생성' : '메일 템플릿 수정') +@section('page_title', $mode === 'create' ? '메일 템플릿 생성' : '메일 템플릿 수정') +@section('page_desc', '스킨을 선택하고 제목/본문을 작성한 뒤, 발송 화면에서 바로 적용할 수 있습니다.') + +@push('head') + +@endpush + +@section('content') +
+ @csrf + @if($mode !== 'create') + @method('PUT') + @endif + +
+
+ @if($mode === 'create') +
+
Code (unique)
+ +
영문/숫자/대시/언더바 3~60
+
+ @else +
+
Code
+
{{ $tpl->code }}
+
+ @endif + +
+
제목(관리용)
+ +
+ +
+
메일 제목(Subject)
+ +
+ +
+
스킨
+ +
발송 시 선택된 스킨으로 렌더링됩니다.
+
+ +
+
본문
+ + +
+ + + + 토큰 빠른 삽입 +
+
+ +
+
설명
+ +
+ +
+ +
+ +
+ + 목록 +
+
+ +
+
+
+
미리보기
+
스킨 + 현재 입력값
+
+
+ +
+
+ +
+ * 1차 버전은 SMTP “접수 성공” 기준으로 완료 처리합니다.
+ * 안정화 후 SES 이벤트(Delivery/Bounce) 연동해 정확도 올리면 됩니다. +
+
+
+
+ + @push('scripts') + + @endpush +@endsection diff --git a/resources/views/admin/mail/templates/index.blade.php b/resources/views/admin/mail/templates/index.blade.php new file mode 100644 index 0000000..02de95b --- /dev/null +++ b/resources/views/admin/mail/templates/index.blade.php @@ -0,0 +1,87 @@ +@extends('admin.layouts.app') + +@section('title', '메일 템플릿') +@section('page_title', '메일 템플릿') +@section('page_desc', '스킨 + 제목/본문 템플릿 관리') + +@push('head') + +@endpush + +@section('content') +
+
+
+
활성
+ +
+ +
+
검색
+ +
+ +
+ + 새 템플릿 +
+
+
+ +
+
총 {{ $templates->total() }}건
+ +
+ + + + + + + + + + + + + + + @forelse($templates as $t) + + + + + + + + + + + @empty + + @endforelse + +
ID활성CodeTitleSubjectSkinBody
{{ $t->id }}{{ (int)$t->is_active === 1 ? 'Y' : 'N' }}{{ $t->code }}{{ $t->title }} + {{ $t->subject_tpl ?? '-' }} + {{ $t->skin_key ?? '-' }}{{ $t->body_tpl }} + 수정 +
데이터가 없습니다.
+
+ +
+ {{ $templates->links() }} +
+
+@endsection diff --git a/resources/views/admin/partials/sidebar.blade.php b/resources/views/admin/partials/sidebar.blade.php index 4b20338..355baa5 100644 --- a/resources/views/admin/partials/sidebar.blade.php +++ b/resources/views/admin/partials/sidebar.blade.php @@ -8,20 +8,28 @@ ], ], [ - 'title' => '콘솔 관리', + 'title' => '관리자/MY 관리', 'items' => [ ['label' => '내 정보', 'route' => 'admin.me'], ['label' => '관리자 계정 관리', 'route' => 'admin.admins.index' ,'roles' => ['super_admin']], ], ], [ - 'title' => '알림/메시지', + 'title' => 'SMS 관리', 'items' => [ ['label' => 'SMS 발송', 'route' => 'admin.sms.send','roles' => ['super_admin','finance','product','support']], ['label' => 'SMS 발송 이력', 'route' => 'admin.sms.logs','roles' => ['super_admin','finance','product','support']], ['label' => 'SMS 템플릿', 'route' => 'admin.templates.index','roles' => ['super_admin','finance','product','support']], ], ], + [ + 'title' => 'MAIL 관리', + 'items' => [ + ['label' => 'MAIL 발송', 'route' => 'admin.mail.send', 'roles' => ['super_admin','finance','product','support']], + ['label' => 'MAIL 발송 이력', 'route' => 'admin.mail.logs', 'roles' => ['super_admin','finance','product','support']], + ['label' => 'MAIL 템플릿', 'route' => 'admin.mail.templates.index', 'roles' => ['super_admin','finance','product','support']], + ], + ], [ 'title' => '고객지원', 'items' => [ @@ -83,59 +91,94 @@ $isSuper = in_array('super_admin', $roleNames, true); @endphp -