Files
sam-manage/app/Services/Mail/TenantMailService.php
김보곤 a0ba7fc13f feat: [email] 테넌트 이메일 설정 관리 기능 추가
- TenantMailConfigController: 목록, 편집, 저장, SMTP 테스트 API
- TenantMailConfig, MailLog 모델 추가
- SmtpConnectionTester: SMTP 연결 테스트 서비스 (에러 코드, 트러블슈팅)
- TenantMailService: 테넌트 설정 기반 메일 발송 (쿼터, Fallback)
- config/mail-presets.php: Gmail/Naver/MS365 등 8개 SMTP 프리셋
- Blade 뷰: 테넌트 목록 현황 + 설정 폼 (프리셋 자동 채움, 연결 테스트)
- 라우트 추가: /system/tenant-mail/*
2026-03-12 07:42:17 +09:00

223 lines
6.7 KiB
PHP

<?php
namespace App\Services\Mail;
use App\Models\Tenants\MailLog;
use App\Models\Tenants\TenantMailConfig;
use Illuminate\Mail\Mailable;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
class TenantMailService
{
/**
* 테넌트 설정을 적용하여 메일 발송
*/
public function send(
Mailable $mailable,
string|array $to,
?int $tenantId = null,
bool $sync = false
): MailLog {
$tenantId = $tenantId ?? session('selected_tenant_id', 1);
$config = $this->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();
}
}