From a0ba7fc13fba1bd8fea60f2e8daa18de981d4141 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Thu, 12 Mar 2026 07:42:17 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[email]=20=ED=85=8C=EB=84=8C=ED=8A=B8?= =?UTF-8?q?=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20=EC=84=A4=EC=A0=95=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TenantMailConfigController: 목록, 편집, 저장, SMTP 테스트 API - TenantMailConfig, MailLog 모델 추가 - SmtpConnectionTester: SMTP 연결 테스트 서비스 (에러 코드, 트러블슈팅) - TenantMailService: 테넌트 설정 기반 메일 발송 (쿼터, Fallback) - config/mail-presets.php: Gmail/Naver/MS365 등 8개 SMTP 프리셋 - Blade 뷰: 테넌트 목록 현황 + 설정 폼 (프리셋 자동 채움, 연결 테스트) - 라우트 추가: /system/tenant-mail/* --- .../System/TenantMailConfigController.php | 250 +++++++++ app/Models/Tenants/MailLog.php | 47 ++ app/Models/Tenants/TenantMailConfig.php | 155 ++++++ app/Services/Mail/SmtpConnectionTester.php | 245 +++++++++ app/Services/Mail/TenantMailService.php | 222 ++++++++ config/mail-presets.php | 68 +++ .../views/system/tenant-mail/edit.blade.php | 509 ++++++++++++++++++ .../views/system/tenant-mail/index.blade.php | 132 +++++ routes/web.php | 10 + 9 files changed, 1638 insertions(+) create mode 100644 app/Http/Controllers/System/TenantMailConfigController.php create mode 100644 app/Models/Tenants/MailLog.php create mode 100644 app/Models/Tenants/TenantMailConfig.php create mode 100644 app/Services/Mail/SmtpConnectionTester.php create mode 100644 app/Services/Mail/TenantMailService.php create mode 100644 config/mail-presets.php create mode 100644 resources/views/system/tenant-mail/edit.blade.php create mode 100644 resources/views/system/tenant-mail/index.blade.php diff --git a/app/Http/Controllers/System/TenantMailConfigController.php b/app/Http/Controllers/System/TenantMailConfigController.php new file mode 100644 index 00000000..ce08e6f4 --- /dev/null +++ b/app/Http/Controllers/System/TenantMailConfigController.php @@ -0,0 +1,250 @@ +header('HX-Request')) { + return response('', 200)->header('HX-Redirect', route('system.tenant-mail.index')); + } + + $tenants = Tenant::orderBy('company_name') + ->get() + ->map(function ($tenant) { + $config = TenantMailConfig::withoutGlobalScopes() + ->where('tenant_id', $tenant->id) + ->first(); + + $todayCount = MailLog::withoutGlobalScopes() + ->where('tenant_id', $tenant->id) + ->whereDate('created_at', today()) + ->whereIn('status', ['queued', 'sent']) + ->count(); + + return (object) [ + 'tenant' => $tenant, + 'config' => $config, + 'today_count' => $todayCount, + ]; + }); + + return view('system.tenant-mail.index', compact('tenants')); + } + + /** + * 테넌트 메일 설정 편집 폼 + */ + public function edit(Request $request, int $tenantId): View|Response + { + if ($request->header('HX-Request')) { + return response('', 200)->header('HX-Redirect', route('system.tenant-mail.edit', $tenantId)); + } + + $tenant = Tenant::findOrFail($tenantId); + + $config = TenantMailConfig::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->first(); + + $presets = config('mail-presets', []); + + $mailService = app(TenantMailService::class); + $todayCount = $mailService->getTodayCount($tenantId); + $monthCount = $mailService->getMonthCount($tenantId); + + return view('system.tenant-mail.edit', compact( + 'tenant', + 'config', + 'presets', + 'todayCount', + 'monthCount' + )); + } + + /** + * 메일 설정 저장 + */ + public function update(Request $request, int $tenantId): JsonResponse + { + $tenant = Tenant::findOrFail($tenantId); + + $validated = $request->validate([ + 'provider' => 'required|string|in:platform,smtp', + 'from_name' => 'required|string|max:255', + 'from_address' => 'required|email|max:255', + 'reply_to' => 'nullable|email|max:255', + 'daily_limit' => 'required|integer|min:1|max:99999', + 'is_active' => 'boolean', + // SMTP 설정 + 'preset' => 'nullable|string', + 'smtp_host' => 'required_if:provider,smtp|nullable|string|max:255', + 'smtp_port' => 'required_if:provider,smtp|nullable|integer|min:1|max:65535', + 'smtp_encryption' => 'required_if:provider,smtp|nullable|string|in:tls,ssl', + 'smtp_username' => 'required_if:provider,smtp|nullable|string|max:255', + 'smtp_password' => 'nullable|string|max:255', + // 브랜딩 + 'branding_company_name' => 'nullable|string|max:255', + 'branding_primary_color' => 'nullable|string|max:7', + 'branding_company_address' => 'nullable|string|max:500', + 'branding_company_phone' => 'nullable|string|max:50', + 'branding_footer_text' => 'nullable|string|max:500', + ]); + + $config = TenantMailConfig::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->first(); + + $data = [ + 'tenant_id' => $tenantId, + 'provider' => $validated['provider'], + 'from_name' => $validated['from_name'], + 'from_address' => $validated['from_address'], + 'reply_to' => $validated['reply_to'] ?? null, + 'daily_limit' => $validated['daily_limit'], + 'is_active' => $validated['is_active'] ?? true, + ]; + + // Options 구성 + $options = $config?->options ?? []; + + // SMTP 설정 + if ($validated['provider'] === 'smtp') { + $options['preset'] = $validated['preset'] ?? 'custom'; + $options['smtp'] = [ + 'host' => $validated['smtp_host'], + 'port' => $validated['smtp_port'], + 'encryption' => $validated['smtp_encryption'], + 'username' => $validated['smtp_username'], + ]; + + // 비밀번호: 새로 입력된 경우에만 업데이트 + if (! empty($validated['smtp_password'])) { + $options['smtp']['password'] = encrypt($validated['smtp_password']); + } elseif (isset($config?->options['smtp']['password'])) { + $options['smtp']['password'] = $config->options['smtp']['password']; + } + } else { + // platform 모드에서는 SMTP 설정 제거 + unset($options['smtp'], $options['preset']); + } + + // 브랜딩 + $options['branding'] = array_filter([ + 'company_name' => $validated['branding_company_name'] ?? null, + 'primary_color' => $validated['branding_primary_color'] ?? '#1a56db', + 'company_address' => $validated['branding_company_address'] ?? null, + 'company_phone' => $validated['branding_company_phone'] ?? null, + 'footer_text' => $validated['branding_footer_text'] ?? 'SAM 시스템에서 발송된 메일입니다.', + ]); + + $data['options'] = $options; + $data['updated_by'] = auth()->id(); + + if ($config) { + $config->update($data); + } else { + $data['created_by'] = auth()->id(); + $config = TenantMailConfig::create($data); + } + + return response()->json([ + 'ok' => true, + 'message' => '메일 설정이 저장되었습니다.', + 'data' => $config->fresh(), + ]); + } + + /** + * SMTP 연결 테스트 + */ + public function test(Request $request, int $tenantId): JsonResponse + { + $validated = $request->validate([ + 'host' => 'required|string', + 'port' => 'required|integer', + 'encryption' => 'required|string|in:tls,ssl', + 'username' => 'required|string', + 'password' => 'required|string', + 'preset' => 'nullable|string', + 'send_test_mail' => 'boolean', + ]); + + $tester = new SmtpConnectionTester; + $result = $tester->testViaLaravel( + host: $validated['host'], + port: $validated['port'], + encryption: $validated['encryption'], + username: $validated['username'], + password: $validated['password'], + testRecipient: ($validated['send_test_mail'] ?? false) ? $validated['username'] : null + ); + + // 테스트 결과를 config에 기록 + $config = TenantMailConfig::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->first(); + + if ($config) { + $config->setOption('connection_test', [ + 'last_tested_at' => now()->toIso8601String(), + 'last_result' => $result['success'] ? 'success' : 'failed', + 'response_time_ms' => $result['response_time_ms'], + 'server_banner' => $result['server_banner'] ?? null, + 'tested_by' => auth()->user()?->email, + ]); + $config->save(); + } + + if ($result['success']) { + return response()->json([ + 'ok' => true, + 'message' => $result['message'], + 'data' => [ + 'response_time_ms' => $result['response_time_ms'], + 'server_banner' => $result['server_banner'], + 'test_mail_sent' => $result['test_mail_sent'] ?? false, + ], + ]); + } + + return response()->json([ + 'ok' => false, + 'message' => $result['message'], + 'data' => [ + 'error_code' => $result['error_code'], + 'troubleshoot' => SmtpConnectionTester::getTroubleshoot( + $result['error_code'] ?? 'UNKNOWN', + $validated['preset'] ?? null + ), + 'response_time_ms' => $result['response_time_ms'], + ], + ]); + } + + /** + * SMTP 프리셋 목록 + */ + public function presets(): JsonResponse + { + return response()->json([ + 'ok' => true, + 'data' => config('mail-presets', []), + ]); + } +} diff --git a/app/Models/Tenants/MailLog.php b/app/Models/Tenants/MailLog.php new file mode 100644 index 00000000..5bdde7f0 --- /dev/null +++ b/app/Models/Tenants/MailLog.php @@ -0,0 +1,47 @@ + 'datetime', + 'options' => 'array', + ]; + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + public function getOption(string $key, mixed $default = null): mixed + { + return data_get($this->options, $key, $default); + } + + public function setOption(string $key, mixed $value): static + { + $options = $this->options ?? []; + data_set($options, $key, $value); + $this->options = $options; + + return $this; + } +} diff --git a/app/Models/Tenants/TenantMailConfig.php b/app/Models/Tenants/TenantMailConfig.php new file mode 100644 index 00000000..5a02d80c --- /dev/null +++ b/app/Models/Tenants/TenantMailConfig.php @@ -0,0 +1,155 @@ + 'boolean', + 'daily_limit' => 'integer', + 'is_active' => 'boolean', + 'options' => 'array', + ]; + + // Options 키 상수 + public const OPTION_SMTP = 'smtp'; + + public const OPTION_PRESET = 'preset'; + + public const OPTION_BRANDING = 'branding'; + + public const OPTION_CONNECTION_TEST = 'connection_test'; + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + public function getOption(string $key, mixed $default = null): mixed + { + return data_get($this->options, $key, $default); + } + + public function setOption(string $key, mixed $value): static + { + $options = $this->options ?? []; + data_set($options, $key, $value); + $this->options = $options; + + return $this; + } + + public function getSmtpHost(): ?string + { + return $this->getOption('smtp.host'); + } + + public function getSmtpPort(): int + { + return (int) $this->getOption('smtp.port', 587); + } + + public function getSmtpEncryption(): string + { + return $this->getOption('smtp.encryption', 'tls'); + } + + public function getSmtpUsername(): ?string + { + return $this->getOption('smtp.username'); + } + + public function getSmtpPassword(): ?string + { + $encrypted = $this->getOption('smtp.password'); + if (! $encrypted) { + return null; + } + + try { + return decrypt($encrypted); + } catch (\Exception $e) { + return null; + } + } + + public function getPreset(): ?string + { + return $this->getOption('preset'); + } + + public function getPresetLabel(): string + { + $preset = $this->getPreset(); + if (! $preset) { + return '-'; + } + + $presets = config('mail-presets', []); + + return $presets[$preset]['label'] ?? $preset; + } + + public function getProviderLabel(): string + { + return match ($this->provider) { + 'platform' => 'SAM 기본', + 'smtp' => '자체 SMTP', + 'ses' => 'Amazon SES', + 'mailgun' => 'Mailgun', + default => $this->provider, + }; + } + + public function getStatusLabel(): string + { + if (! $this->is_active) { + return '비활성'; + } + + $lastTest = $this->getOption('connection_test.last_result'); + + return match ($lastTest) { + 'success' => '활성', + 'failed' => '연결 오류', + default => $this->provider === 'platform' ? '활성' : '미테스트', + }; + } + + public function getStatusColor(): string + { + if (! $this->is_active) { + return 'gray'; + } + + $lastTest = $this->getOption('connection_test.last_result'); + + return match ($lastTest) { + 'success' => 'green', + 'failed' => 'red', + default => $this->provider === 'platform' ? 'green' : 'yellow', + }; + } +} diff --git a/app/Services/Mail/SmtpConnectionTester.php b/app/Services/Mail/SmtpConnectionTester.php new file mode 100644 index 00000000..02497bb2 --- /dev/null +++ b/app/Services/Mail/SmtpConnectionTester.php @@ -0,0 +1,245 @@ +getStream(); + $stream->disableTls(); + } + + $transport->setUsername($username); + $transport->setPassword($password); + + // 연결 시도 (타임아웃 10초) + $transport->start(); + + $responseTimeMs = (int) ((microtime(true) - $startTime) * 1000); + + // 테스트 메일 발송 (선택) + $testMailSent = false; + if ($testRecipient) { + $testMailSent = $this->sendTestMail($transport, $username, $testRecipient); + } + + $transport->stop(); + + return [ + 'success' => true, + 'message' => 'SMTP 연결 성공', + 'response_time_ms' => $responseTimeMs, + 'server_banner' => $host.':'.$port, + 'error_code' => null, + 'test_mail_sent' => $testMailSent, + ]; + } catch (\Symfony\Component\Mailer\Exception\TransportExceptionInterface $e) { + $responseTimeMs = (int) ((microtime(true) - $startTime) * 1000); + + return $this->parseTransportError($e, $responseTimeMs); + } catch (\Exception $e) { + $responseTimeMs = (int) ((microtime(true) - $startTime) * 1000); + + return [ + 'success' => false, + 'message' => '연결 실패: '.$e->getMessage(), + 'response_time_ms' => $responseTimeMs, + 'server_banner' => null, + 'error_code' => 'UNKNOWN', + ]; + } + } + + /** + * Laravel Mail 파사드를 사용한 SMTP 연결 테스트 (대체 방식) + */ + public function testViaLaravel( + string $host, + int $port, + string $encryption, + string $username, + string $password, + ?string $testRecipient = null + ): array { + $startTime = microtime(true); + + try { + $config = [ + 'transport' => 'smtp', + 'host' => $host, + 'port' => $port, + 'encryption' => $encryption, + 'username' => $username, + 'password' => $password, + 'timeout' => 10, + ]; + + $transport = \Illuminate\Support\Facades\Mail::createSymfonyTransport($config); + $transport->start(); + + $responseTimeMs = (int) ((microtime(true) - $startTime) * 1000); + + $testMailSent = false; + if ($testRecipient) { + $testMailSent = $this->sendTestMailViaLaravel($config, $testRecipient, $username); + } + + $transport->stop(); + + return [ + 'success' => true, + 'message' => 'SMTP 연결 성공', + 'response_time_ms' => $responseTimeMs, + 'server_banner' => $host.':'.$port, + 'error_code' => null, + 'test_mail_sent' => $testMailSent, + ]; + } catch (\Exception $e) { + $responseTimeMs = (int) ((microtime(true) - $startTime) * 1000); + + return $this->parseError($e, $responseTimeMs); + } + } + + private function sendTestMail($transport, string $from, string $to): bool + { + try { + $email = (new \Symfony\Component\Mime\Email) + ->from($from) + ->to($to) + ->subject('[SAM] SMTP 연결 테스트') + ->text('이 메일은 SAM 시스템의 SMTP 연결 테스트입니다. 정상적으로 수신되었다면 설정이 올바릅니다.'); + + $transport->send($email); + + return true; + } catch (\Exception $e) { + return false; + } + } + + private function sendTestMailViaLaravel(array $config, string $to, string $from): bool + { + try { + config(['mail.mailers.smtp_test' => $config]); + config(['mail.mailers.smtp_test.transport' => 'smtp']); + + \Illuminate\Support\Facades\Mail::mailer('smtp_test') + ->raw('이 메일은 SAM 시스템의 SMTP 연결 테스트입니다. 정상적으로 수신되었다면 설정이 올바릅니다.', function ($message) use ($to, $from) { + $message->to($to) + ->from($from, 'SAM 시스템') + ->subject('[SAM] SMTP 연결 테스트'); + }); + + return true; + } catch (\Exception $e) { + return false; + } + } + + private function parseTransportError(\Symfony\Component\Mailer\Exception\TransportExceptionInterface $e, int $responseTimeMs): array + { + $message = $e->getMessage(); + + // 에러 패턴 매칭 + if (str_contains($message, 'Connection refused') || str_contains($message, 'Connection timed out')) { + return [ + 'success' => false, + 'message' => 'SMTP 서버에 접속할 수 없습니다 — 호스트/포트를 확인하세요', + 'response_time_ms' => $responseTimeMs, + 'server_banner' => null, + 'error_code' => 'CONN_REFUSED', + ]; + } + + if (str_contains($message, 'SSL') || str_contains($message, 'TLS') || str_contains($message, 'handshake')) { + return [ + 'success' => false, + 'message' => '암호화 연결 실패 — 암호화 방식(TLS/SSL)을 확인하세요', + 'response_time_ms' => $responseTimeMs, + 'server_banner' => null, + 'error_code' => 'TLS_FAILED', + ]; + } + + if (str_contains($message, 'Authentication') || str_contains($message, '535') || str_contains($message, 'credentials')) { + return [ + 'success' => false, + 'message' => '인증 실패 — 사용자명/비밀번호(앱 비밀번호)를 확인하세요', + 'response_time_ms' => $responseTimeMs, + 'server_banner' => null, + 'error_code' => 'AUTH_FAILED', + ]; + } + + if (str_contains($message, 'timed out') || str_contains($message, 'Timeout')) { + return [ + 'success' => false, + 'message' => '연결 시간 초과 — 잠시 후 다시 시도하세요', + 'response_time_ms' => $responseTimeMs, + 'server_banner' => null, + 'error_code' => 'TIMEOUT', + ]; + } + + return [ + 'success' => false, + 'message' => '연결 실패: '.$message, + 'response_time_ms' => $responseTimeMs, + 'server_banner' => null, + 'error_code' => 'UNKNOWN', + ]; + } + + private function parseError(\Exception $e, int $responseTimeMs): array + { + return $this->parseTransportError( + new \Symfony\Component\Mailer\Exception\TransportException($e->getMessage(), 0, $e), + $responseTimeMs + ); + } + + /** + * 에러 코드별 트러블슈팅 메시지 + */ + public static function getTroubleshoot(string $errorCode, ?string $preset = null): string + { + return match ($errorCode) { + 'CONN_REFUSED' => '호스트 주소와 포트 번호가 정확한지 확인하세요. 방화벽이 해당 포트를 차단하고 있을 수 있습니다.', + 'TLS_FAILED' => 'TLS와 SSL을 바꿔서 시도하세요. 포트 587은 보통 TLS, 포트 465는 SSL을 사용합니다.', + 'AUTH_FAILED' => match ($preset) { + 'gmail' => 'Gmail은 앱 비밀번호가 필요합니다. Google 계정 > 보안 > 2단계 인증 활성화 후 앱 비밀번호를 생성하세요.', + 'naver' => '네이버 메일 설정에서 SMTP 사용을 활성화하고, 앱 비밀번호를 생성하세요.', + 'daum' => '카카오 계정 > 보안 > 앱 비밀번호를 생성하세요.', + default => '사용자명과 비밀번호를 확인하세요. 일반 비밀번호가 아닌 앱 비밀번호가 필요할 수 있습니다.', + }, + 'SMTP_DISABLED' => '메일 서비스 설정에서 SMTP 사용을 활성화하세요.', + 'TIMEOUT' => '네트워크 상태를 확인하고 잠시 후 다시 시도하세요.', + default => '설정을 다시 확인하고 재시도하세요.', + }; + } +} diff --git a/app/Services/Mail/TenantMailService.php b/app/Services/Mail/TenantMailService.php new file mode 100644 index 00000000..d12f106b --- /dev/null +++ b/app/Services/Mail/TenantMailService.php @@ -0,0 +1,222 @@ +getConfig($tenantId); + + // 메일 로그 생성 (queued 상태) + $mailLog = MailLog::create([ + 'tenant_id' => $tenantId, + 'mailable_type' => class_basename($mailable), + 'to_address' => is_array($to) ? implode(', ', $to) : $to, + 'from_address' => $config ? $config->from_address : config('mail.from.address'), + 'subject' => $this->getSubject($mailable), + 'status' => 'queued', + 'options' => [ + 'provider_used' => $config?->provider ?? 'platform', + ], + ]); + + // 쿼터 확인 + if ($config && ! $this->checkQuota($tenantId, $config)) { + $mailLog->update([ + 'status' => 'failed', + 'options' => array_merge($mailLog->options ?? [], [ + 'error_message' => '일일 발송 한도 초과', + ]), + ]); + Log::warning("Mail quota exceeded for tenant {$tenantId}"); + + return $mailLog; + } + + try { + $this->configureMail($config); + $this->applyBranding($mailable, $config); + + if ($sync) { + Mail::to($to)->send($mailable); + } else { + Mail::to($to)->queue($mailable); + } + + $mailLog->update([ + 'status' => 'sent', + 'sent_at' => now(), + ]); + } catch (\Exception $e) { + Log::error("Mail send failed for tenant {$tenantId}: ".$e->getMessage()); + + // Fallback: 플랫폼 기본 SMTP + if ($config && $config->provider !== 'platform') { + try { + $this->resetMailConfig(); + Mail::to($to)->send($mailable); + + $mailLog->update([ + 'status' => 'sent', + 'sent_at' => now(), + 'options' => array_merge($mailLog->options ?? [], [ + 'fallback_used' => true, + 'original_error' => $e->getMessage(), + ]), + ]); + + return $mailLog; + } catch (\Exception $fallbackException) { + // Fallback도 실패 + } + } + + $mailLog->update([ + 'status' => 'failed', + 'options' => array_merge($mailLog->options ?? [], [ + 'error_message' => $e->getMessage(), + 'retry_count' => ($mailLog->getOption('retry_count', 0)) + 1, + ]), + ]); + } + + return $mailLog; + } + + /** + * 테넌트 메일 설정 조회 + */ + private function getConfig(int $tenantId): ?TenantMailConfig + { + return TenantMailConfig::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('is_active', true) + ->first(); + } + + /** + * 일일 발송 쿼터 확인 + */ + private function checkQuota(int $tenantId, TenantMailConfig $config): bool + { + $todayCount = MailLog::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->whereDate('created_at', today()) + ->whereIn('status', ['queued', 'sent']) + ->count(); + + return $todayCount < $config->daily_limit; + } + + /** + * SMTP 설정 동적 교체 + */ + private function configureMail(?TenantMailConfig $config): void + { + if (! $config || $config->provider === 'platform') { + return; + } + + if ($config->provider === 'smtp') { + config([ + 'mail.mailers.smtp.host' => $config->getSmtpHost(), + 'mail.mailers.smtp.port' => $config->getSmtpPort(), + 'mail.mailers.smtp.encryption' => $config->getSmtpEncryption(), + 'mail.mailers.smtp.username' => $config->getSmtpUsername(), + 'mail.mailers.smtp.password' => $config->getSmtpPassword(), + 'mail.from.address' => $config->from_address, + 'mail.from.name' => $config->from_name, + ]); + } + } + + /** + * 기본 메일 설정 복원 + */ + private function resetMailConfig(): void + { + config([ + 'mail.mailers.smtp.host' => env('MAIL_HOST'), + 'mail.mailers.smtp.port' => env('MAIL_PORT'), + 'mail.mailers.smtp.encryption' => env('MAIL_ENCRYPTION'), + 'mail.mailers.smtp.username' => env('MAIL_USERNAME'), + 'mail.mailers.smtp.password' => env('MAIL_PASSWORD'), + 'mail.from.address' => env('MAIL_FROM_ADDRESS'), + 'mail.from.name' => env('MAIL_FROM_NAME'), + ]); + } + + /** + * 브랜딩 데이터 적용 + */ + private function applyBranding(Mailable $mailable, ?TenantMailConfig $config): void + { + if (! $config) { + return; + } + + $mailable->from($config->from_address, $config->from_name); + + if ($config->reply_to) { + $mailable->replyTo($config->reply_to); + } + } + + /** + * Mailable 제목 추출 + */ + private function getSubject(Mailable $mailable): string + { + try { + $reflection = new \ReflectionProperty($mailable, 'subject'); + $subject = $reflection->getValue($mailable); + if ($subject) { + return $subject; + } + } catch (\ReflectionException $e) { + // subject 속성이 없는 경우 + } + + return class_basename($mailable); + } + + /** + * 오늘 발송 건수 조회 + */ + public function getTodayCount(int $tenantId): int + { + return MailLog::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->whereDate('created_at', today()) + ->whereIn('status', ['queued', 'sent']) + ->count(); + } + + /** + * 이번 달 발송 건수 조회 + */ + public function getMonthCount(int $tenantId): int + { + return MailLog::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->whereMonth('created_at', now()->month) + ->whereYear('created_at', now()->year) + ->whereIn('status', ['queued', 'sent']) + ->count(); + } +} diff --git a/config/mail-presets.php b/config/mail-presets.php new file mode 100644 index 00000000..f9931e08 --- /dev/null +++ b/config/mail-presets.php @@ -0,0 +1,68 @@ + [ + 'label' => 'Gmail / Google Workspace', + 'host' => 'smtp.gmail.com', + 'port' => 587, + 'encryption' => 'tls', + 'daily_limit' => 500, + 'notes' => '앱 비밀번호 필요 (2단계 인증 활성화 후 생성)', + ], + 'naver' => [ + 'label' => '네이버 메일', + 'host' => 'smtp.naver.com', + 'port' => 587, + 'encryption' => 'tls', + 'daily_limit' => 500, + 'notes' => '네이버 메일 설정 > POP3/SMTP > SMTP 사용 활성화 필요', + ], + 'naverworks' => [ + 'label' => '네이버웍스 (NAVER WORKS)', + 'host' => 'smtp.worksmobile.com', + 'port' => 587, + 'encryption' => 'tls', + 'daily_limit' => null, + 'notes' => '관리자 콘솔에서 SMTP 허용 설정 필요', + ], + 'daum' => [ + 'label' => '다음/카카오 메일', + 'host' => 'smtp.daum.net', + 'port' => 465, + 'encryption' => 'ssl', + 'daily_limit' => 500, + 'notes' => '카카오 계정 > 보안 > 앱 비밀번호 생성', + ], + 'microsoft365' => [ + 'label' => 'Microsoft 365 (Outlook)', + 'host' => 'smtp.office365.com', + 'port' => 587, + 'encryption' => 'tls', + 'daily_limit' => 10000, + 'notes' => 'Basic Auth 폐지 추세 — OAuth2 전환 권장', + ], + 'cafe24' => [ + 'label' => '카페24 호스팅 메일', + 'host' => '', + 'port' => 587, + 'encryption' => 'tls', + 'daily_limit' => null, + 'notes' => 'SMTP 호스트는 호스팅 계정마다 상이 — 수동 입력', + ], + 'gabia' => [ + 'label' => '가비아 호스팅 메일', + 'host' => 'smtp.gabia.com', + 'port' => 587, + 'encryption' => 'tls', + 'daily_limit' => null, + 'notes' => '가비아 메일 관리 > SMTP 설정 확인', + ], + 'custom' => [ + 'label' => '직접 입력 (자체 서버 등)', + 'host' => '', + 'port' => 587, + 'encryption' => 'tls', + 'daily_limit' => null, + 'notes' => '호스트, 포트, 암호화 방식을 직접 입력', + ], +]; diff --git a/resources/views/system/tenant-mail/edit.blade.php b/resources/views/system/tenant-mail/edit.blade.php new file mode 100644 index 00000000..1f084c94 --- /dev/null +++ b/resources/views/system/tenant-mail/edit.blade.php @@ -0,0 +1,509 @@ +@extends('layouts.app') + +@section('title', '이메일 설정 — ' . $tenant->company_name) + +@push('styles') + +@endpush + +@section('content') +
+ +
+ + + +
+

이메일 설정

+

{{ $tenant->company_name }} (ID: {{ $tenant->id }})

+
+
+ + +
+
+ + 오늘 발송: {{ $todayCount }}/{{ $config?->daily_limit ?? 500 }}건 +
+
+ + 이번 달: {{ $monthCount }} +
+ @if($config && $config->getOption('connection_test.last_tested_at')) +
+ + 마지막 테스트: {{ \Carbon\Carbon::parse($config->getOption('connection_test.last_tested_at'))->format('m/d H:i') }} +
+ @endif +
+ + +
+

기본 정보

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+

발송 방식

+
+ + +
+
+ + +
+

SMTP 설정

+ + +
+ + +
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ + +
+ + +
+
+ + +
+

브랜딩 (선택)

+
+
+ + +
+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+ + 목록으로 + + +
+
+ +@push('scripts') + +@endpush +@endsection diff --git a/resources/views/system/tenant-mail/index.blade.php b/resources/views/system/tenant-mail/index.blade.php new file mode 100644 index 00000000..51da70b1 --- /dev/null +++ b/resources/views/system/tenant-mail/index.blade.php @@ -0,0 +1,132 @@ +@extends('layouts.app') + +@section('title', '테넌트 이메일 설정') + +@push('styles') + +@endpush + +@section('content') +
+ +
+
+

테넌트 이메일 설정

+

각 테넌트의 메일 발송 설정을 관리합니다

+
+
+ + +
+ + + + + + + + + + + + + + @forelse($tenants as $item) + + + + + + + + + + @empty + + + + @endforelse + +
테넌트발송 방식프리셋발신 주소상태오늘 발송관리
+
{{ $item->tenant->company_name }}
+
ID: {{ $item->tenant->id }}
+
+ @if($item->config) + + {{ $item->config->getProviderLabel() }} + + @else + 미설정 + @endif + + {{ $item->config?->getPresetLabel() ?? '-' }} + + {{ $item->config?->from_address ?? '-' }} + + @if($item->config) + + {{ $item->config->getStatusLabel() }} + + @else + 미설정 + @endif + + @if($item->config) + {{ $item->today_count }}/{{ $item->config->daily_limit }}건 + @else + - + @endif + + + + 설정 + +
+ 등록된 테넌트가 없습니다 +
+
+ + +
+
+ +
+

메일 설정 안내

+
    +
  • 미설정 테넌트는 SAM 플랫폼 기본 SMTP로 메일이 발송됩니다.
  • +
  • "자체 SMTP" 설정 시 테넌트 회사 도메인으로 메일이 발송됩니다.
  • +
  • SMTP 설정 후 반드시 연결 테스트를 수행하세요.
  • +
+
+
+
+
+@endsection diff --git a/routes/web.php b/routes/web.php index 6396a547..e3f52de9 100644 --- a/routes/web.php +++ b/routes/web.php @@ -68,6 +68,7 @@ use App\Http\Controllers\System\HolidayController; use App\Http\Controllers\System\SystemAlertController; use App\Http\Controllers\System\SystemGuideController; +use App\Http\Controllers\System\TenantMailConfigController; use App\Http\Controllers\TenantController; use App\Http\Controllers\TenantSettingController; use App\Http\Controllers\TriggerAuditController; @@ -580,6 +581,15 @@ Route::get('/{id}/download', [AiVoiceRecordingController::class, 'download'])->name('download'); }); + // 테넌트 이메일 설정 관리 + Route::prefix('system/tenant-mail')->name('system.tenant-mail.')->group(function () { + Route::get('/', [TenantMailConfigController::class, 'index'])->name('index'); + Route::get('/presets', [TenantMailConfigController::class, 'presets'])->name('presets'); + Route::get('/{tenantId}', [TenantMailConfigController::class, 'edit'])->name('edit'); + Route::put('/{tenantId}', [TenantMailConfigController::class, 'update'])->name('update'); + Route::post('/{tenantId}/test', [TenantMailConfigController::class, 'test'])->name('test'); + }); + // 시스템 가이드 Route::get('/system/git-deploy-flow', [SystemGuideController::class, 'gitDeployFlow'])->name('system.guide.git-deploy-flow');