feat: [email] 테넌트 이메일 설정 관리 기능 추가

- TenantMailConfigController: 목록, 편집, 저장, SMTP 테스트 API
- TenantMailConfig, MailLog 모델 추가
- SmtpConnectionTester: SMTP 연결 테스트 서비스 (에러 코드, 트러블슈팅)
- TenantMailService: 테넌트 설정 기반 메일 발송 (쿼터, Fallback)
- config/mail-presets.php: Gmail/Naver/MS365 등 8개 SMTP 프리셋
- Blade 뷰: 테넌트 목록 현황 + 설정 폼 (프리셋 자동 채움, 연결 테스트)
- 라우트 추가: /system/tenant-mail/*
This commit is contained in:
김보곤
2026-03-12 07:42:17 +09:00
parent 6db6a43430
commit 5911f530a6
9 changed files with 1638 additions and 0 deletions

View File

@@ -0,0 +1,250 @@
<?php
namespace App\Http\Controllers\System;
use App\Http\Controllers\Controller;
use App\Models\Tenants\MailLog;
use App\Models\Tenants\Tenant;
use App\Models\Tenants\TenantMailConfig;
use App\Services\Mail\SmtpConnectionTester;
use App\Services\Mail\TenantMailService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\View\View;
class TenantMailConfigController extends Controller
{
/**
* 전체 테넌트 메일 설정 현황
*/
public function index(Request $request): View|Response
{
if ($request->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', []),
]);
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Models\Tenants;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class MailLog extends Model
{
use BelongsToTenant;
protected $fillable = [
'tenant_id',
'mailable_type',
'to_address',
'from_address',
'subject',
'status',
'sent_at',
'options',
];
protected $casts = [
'sent_at' => '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;
}
}

View File

@@ -0,0 +1,155 @@
<?php
namespace App\Models\Tenants;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class TenantMailConfig extends Model
{
use BelongsToTenant, SoftDeletes;
protected $fillable = [
'tenant_id',
'provider',
'from_address',
'from_name',
'reply_to',
'is_verified',
'daily_limit',
'is_active',
'options',
'created_by',
'updated_by',
'deleted_by',
];
protected $casts = [
'is_verified' => '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',
};
}
}

View File

@@ -0,0 +1,245 @@
<?php
namespace App\Services\Mail;
use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport;
use Symfony\Component\Mailer\Transport\Smtp\Stream\SocketStream;
class SmtpConnectionTester
{
/**
* SMTP 연결 테스트
*
* @return array{success: bool, message: string, response_time_ms: int, server_banner: string|null, error_code: string|null}
*/
public function test(
string $host,
int $port,
string $encryption,
string $username,
string $password,
?string $testRecipient = null
): array {
$startTime = microtime(true);
try {
// Symfony Mailer를 사용한 SMTP 연결 테스트
$transport = new EsmtpTransport($host, $port, $encryption === 'ssl');
// TLS 설정
if ($encryption === 'tls') {
/** @var SocketStream $stream */
$stream = $transport->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 => '설정을 다시 확인하고 재시도하세요.',
};
}
}

View File

@@ -0,0 +1,222 @@
<?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();
}
}

68
config/mail-presets.php Normal file
View File

@@ -0,0 +1,68 @@
<?php
return [
'gmail' => [
'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' => '호스트, 포트, 암호화 방식을 직접 입력',
],
];

View File

@@ -0,0 +1,509 @@
@extends('layouts.app')
@section('title', '이메일 설정 — ' . $tenant->company_name)
@push('styles')
<style>
[x-cloak] { display: none !important; }
.form-section {
background: white;
border-radius: 12px;
padding: 24px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
margin-bottom: 16px;
}
.form-section h3 {
font-size: 15px;
font-weight: 600;
color: #374151;
margin-bottom: 16px;
display: flex;
align-items: center;
gap: 8px;
}
.form-label {
display: block;
font-size: 13px;
font-weight: 500;
color: #4b5563;
margin-bottom: 4px;
}
.form-input {
width: 100%;
padding: 8px 12px;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 14px;
transition: border-color 0.15s;
}
.form-input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59,130,246,0.1);
}
.form-input:disabled {
background: #f9fafb;
color: #9ca3af;
}
.form-select {
width: 100%;
padding: 8px 12px;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 14px;
background: white;
}
.form-select:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59,130,246,0.1);
}
.radio-card {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 14px 16px;
border: 2px solid #e5e7eb;
border-radius: 10px;
cursor: pointer;
transition: all 0.15s;
}
.radio-card:hover {
border-color: #93c5fd;
}
.radio-card.active {
border-color: #3b82f6;
background: #eff6ff;
}
.test-result {
padding: 12px 16px;
border-radius: 8px;
font-size: 13px;
margin-top: 12px;
}
.test-result.success {
background: #dcfce7;
color: #166534;
border: 1px solid #bbf7d0;
}
.test-result.error {
background: #fee2e2;
color: #991b1b;
border: 1px solid #fecaca;
}
.status-info {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
border-radius: 8px;
font-size: 13px;
background: #f9fafb;
border: 1px solid #e5e7eb;
}
.preset-note {
font-size: 12px;
color: #6b7280;
margin-top: 4px;
display: flex;
align-items: center;
gap: 4px;
}
</style>
@endpush
@section('content')
<div x-data="tenantMailConfig()" x-cloak class="space-y-4" style="max-width: 800px;">
<!-- 헤더 -->
<div class="flex items-center gap-4">
<a href="{{ route('system.tenant-mail.index') }}"
class="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition">
<i class="ri-arrow-left-line text-xl"></i>
</a>
<div>
<h1 class="text-xl font-bold text-gray-800">이메일 설정</h1>
<p class="text-sm text-gray-500">{{ $tenant->company_name }} (ID: {{ $tenant->id }})</p>
</div>
</div>
<!-- 상태 요약 -->
<div class="flex flex-wrap gap-3">
<div class="status-info">
<i class="ri-mail-send-line text-blue-500"></i>
<span>오늘 발송: <strong>{{ $todayCount }}</strong>/{{ $config?->daily_limit ?? 500 }}</span>
</div>
<div class="status-info">
<i class="ri-calendar-line text-purple-500"></i>
<span>이번 : <strong>{{ $monthCount }}</strong></span>
</div>
@if($config && $config->getOption('connection_test.last_tested_at'))
<div class="status-info">
<i class="ri-check-double-line text-green-500"></i>
<span>마지막 테스트: {{ \Carbon\Carbon::parse($config->getOption('connection_test.last_tested_at'))->format('m/d H:i') }}</span>
</div>
@endif
</div>
<!-- 기본 정보 -->
<div class="form-section">
<h3><i class="ri-user-line text-blue-500"></i> 기본 정보</h3>
<div class="grid gap-4" style="grid-template-columns: 1fr 1fr;">
<div>
<label class="form-label">발신자명 <span class="text-red-500">*</span></label>
<input type="text" x-model="form.from_name" class="form-input" placeholder="회사명 또는 서비스명">
</div>
<div>
<label class="form-label">발신 이메일 <span class="text-red-500">*</span></label>
<input type="email" x-model="form.from_address" class="form-input" placeholder="admin@company.com">
</div>
<div>
<label class="form-label">회신 주소 (선택)</label>
<input type="email" x-model="form.reply_to" class="form-input" placeholder="reply@company.com">
</div>
<div>
<label class="form-label">일일 발송 한도</label>
<input type="number" x-model="form.daily_limit" class="form-input" min="1" max="99999">
</div>
</div>
</div>
<!-- 발송 방식 -->
<div class="form-section">
<h3><i class="ri-mail-settings-line text-purple-500"></i> 발송 방식</h3>
<div class="grid gap-3" style="grid-template-columns: 1fr 1fr;">
<label class="radio-card" :class="{ active: form.provider === 'platform' }"
@click="form.provider = 'platform'">
<input type="radio" x-model="form.provider" value="platform" class="mt-0.5">
<div>
<div class="font-medium text-gray-800">SAM 기본</div>
<div class="text-xs text-gray-500 mt-1">
플랫폼 공용 SMTP로 발송<br>
발신: noreply@sam.codebridge-x.com<br>
Reply-To에 이메일 자동 적용
</div>
</div>
</label>
<label class="radio-card" :class="{ active: form.provider === 'smtp' }"
@click="form.provider = 'smtp'">
<input type="radio" x-model="form.provider" value="smtp" class="mt-0.5">
<div>
<div class="font-medium text-gray-800">자체 SMTP</div>
<div class="text-xs text-gray-500 mt-1">
테넌트 회사 메일 서버로 발송<br>
회사 도메인으로 메일 발신<br>
비밀번호 설정 필요
</div>
</div>
</label>
</div>
</div>
<!-- SMTP 설정 (자체 SMTP 선택 ) -->
<div class="form-section" x-show="form.provider === 'smtp'" x-transition>
<h3><i class="ri-server-line text-orange-500"></i> SMTP 설정</h3>
<!-- 프리셋 선택 -->
<div class="mb-4">
<label class="form-label">메일 서비스 프리셋</label>
<select x-model="form.preset" @change="applyPreset()" class="form-select">
<template x-for="(preset, key) in presets" :key="key">
<option :value="key" x-text="preset.label"></option>
</template>
</select>
<div class="preset-note" x-show="currentPresetNote">
<i class="ri-information-line"></i>
<span x-text="currentPresetNote"></span>
</div>
</div>
<div class="grid gap-4" style="grid-template-columns: 2fr 1fr 1fr;">
<div>
<label class="form-label">SMTP 호스트 <span class="text-red-500">*</span></label>
<input type="text" x-model="form.smtp_host" class="form-input" placeholder="smtp.gmail.com"
:disabled="form.preset !== 'custom' && form.preset !== 'cafe24' && presets[form.preset]?.host">
</div>
<div>
<label class="form-label">포트 <span class="text-red-500">*</span></label>
<input type="number" x-model="form.smtp_port" class="form-input" placeholder="587"
:disabled="form.preset !== 'custom' && form.preset !== 'cafe24'">
</div>
<div>
<label class="form-label">암호화</label>
<select x-model="form.smtp_encryption" class="form-select"
:disabled="form.preset !== 'custom' && form.preset !== 'cafe24'">
<option value="tls">TLS</option>
<option value="ssl">SSL</option>
</select>
</div>
</div>
<div class="grid gap-4 mt-4" style="grid-template-columns: 1fr 1fr;">
<div>
<label class="form-label">SMTP 사용자명 (이메일) <span class="text-red-500">*</span></label>
<input type="text" x-model="form.smtp_username" class="form-input" placeholder="user@company.com">
</div>
<div>
<label class="form-label">SMTP 비밀번호 ( 비밀번호) <span class="text-red-500">*</span></label>
<input type="password" x-model="form.smtp_password" class="form-input"
placeholder="{{ $config && $config->getSmtpHost() ? '변경하려면 새로 입력' : '앱 비밀번호 입력' }}">
</div>
</div>
<!-- 연결 테스트 -->
<div class="mt-4 flex items-center gap-3">
<button type="button" @click="testConnection()"
:disabled="testing || !form.smtp_host || !form.smtp_username"
class="px-4 py-2 bg-gray-700 hover:bg-gray-800 disabled:bg-gray-300 text-white text-sm rounded-lg transition inline-flex items-center gap-2">
<template x-if="testing">
<i class="ri-loader-4-line animate-spin"></i>
</template>
<template x-if="!testing">
<i class="ri-link"></i>
</template>
<span x-text="testing ? '테스트 중...' : '연결 테스트'"></span>
</button>
<label class="flex items-center gap-2 text-sm text-gray-600">
<input type="checkbox" x-model="sendTestMail" class="rounded border-gray-300">
테스트 메일 발송
</label>
</div>
<!-- 테스트 결과 -->
<div x-show="testResult" x-transition>
<template x-if="testResult?.ok">
<div class="test-result success">
<div class="flex items-center gap-2 font-medium">
<i class="ri-check-line"></i>
<span x-text="testResult.message"></span>
</div>
<div class="mt-1 text-xs opacity-80">
응답시간: <span x-text="testResult.data?.response_time_ms"></span>ms
<template x-if="testResult.data?.test_mail_sent">
<span> | 테스트 메일 발송 완료</span>
</template>
</div>
</div>
</template>
<template x-if="testResult && !testResult.ok">
<div class="test-result error">
<div class="flex items-center gap-2 font-medium">
<i class="ri-close-line"></i>
<span x-text="testResult.message"></span>
</div>
<div class="mt-1 text-xs opacity-80" x-show="testResult.data?.troubleshoot"
x-text="testResult.data?.troubleshoot"></div>
</div>
</template>
</div>
</div>
<!-- 브랜딩 -->
<div class="form-section">
<h3><i class="ri-palette-line text-pink-500"></i> 브랜딩 (선택)</h3>
<div class="grid gap-4" style="grid-template-columns: 1fr 1fr;">
<div>
<label class="form-label">회사명</label>
<input type="text" x-model="form.branding_company_name" class="form-input"
placeholder="{{ $tenant->company_name }}">
</div>
<div>
<label class="form-label">테마 컬러</label>
<div class="flex items-center gap-2">
<input type="color" x-model="form.branding_primary_color"
class="h-9 w-12 rounded border border-gray-300 cursor-pointer">
<input type="text" x-model="form.branding_primary_color" class="form-input" style="flex:1;"
placeholder="#1a56db">
</div>
</div>
<div>
<label class="form-label">회사 주소</label>
<input type="text" x-model="form.branding_company_address" class="form-input"
placeholder="서울시 강남구...">
</div>
<div>
<label class="form-label">연락처</label>
<input type="text" x-model="form.branding_company_phone" class="form-input"
placeholder="02-1234-5678">
</div>
</div>
<div class="mt-4">
<label class="form-label">푸터 문구</label>
<input type="text" x-model="form.branding_footer_text" class="form-input"
placeholder="SAM 시스템에서 발송된 메일입니다.">
</div>
</div>
<!-- 저장 버튼 -->
<div class="flex items-center justify-between">
<a href="{{ route('system.tenant-mail.index') }}"
class="px-4 py-2 text-gray-600 hover:text-gray-800 text-sm">
<i class="ri-arrow-left-line"></i> 목록으로
</a>
<button type="button" @click="save()" :disabled="saving"
class="px-6 py-2.5 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-300 text-white text-sm font-medium rounded-lg transition inline-flex items-center gap-2">
<template x-if="saving">
<i class="ri-loader-4-line animate-spin"></i>
</template>
<template x-if="!saving">
<i class="ri-save-line"></i>
</template>
<span x-text="saving ? '저장 중...' : '저장'"></span>
</button>
</div>
</div>
@push('scripts')
<script>
function tenantMailConfig() {
const config = @json($config);
const presets = @json($presets);
const tenantId = {{ $tenant->id }};
return {
presets: presets,
testing: false,
saving: false,
sendTestMail: false,
testResult: null,
form: {
provider: config?.provider || 'platform',
from_name: config?.from_name || '{{ $tenant->company_name }}',
from_address: config?.from_address || '',
reply_to: config?.reply_to || '',
daily_limit: config?.daily_limit || 500,
is_active: config?.is_active ?? true,
// SMTP
preset: config?.options?.preset || 'gmail',
smtp_host: config?.options?.smtp?.host || '',
smtp_port: config?.options?.smtp?.port || 587,
smtp_encryption: config?.options?.smtp?.encryption || 'tls',
smtp_username: config?.options?.smtp?.username || '',
smtp_password: '',
// 브랜딩
branding_company_name: config?.options?.branding?.company_name || '',
branding_primary_color: config?.options?.branding?.primary_color || '#1a56db',
branding_company_address: config?.options?.branding?.company_address || '',
branding_company_phone: config?.options?.branding?.company_phone || '',
branding_footer_text: config?.options?.branding?.footer_text || 'SAM 시스템에서 발송된 메일입니다.',
},
get currentPresetNote() {
return this.presets[this.form.preset]?.notes || '';
},
applyPreset() {
const preset = this.presets[this.form.preset];
if (!preset) return;
if (preset.host) {
this.form.smtp_host = preset.host;
}
this.form.smtp_port = preset.port;
this.form.smtp_encryption = preset.encryption;
if (preset.daily_limit) {
this.form.daily_limit = preset.daily_limit;
}
this.testResult = null;
},
async testConnection() {
if (!this.form.smtp_host || !this.form.smtp_username) return;
// 비밀번호 확인: 신규 입력이 없고 기존 설정도 없는 경우
const hasExistingPassword = config?.options?.smtp?.password;
if (!this.form.smtp_password && !hasExistingPassword) {
this.testResult = {
ok: false,
message: 'SMTP 비밀번호를 입력하세요',
data: { troubleshoot: '앱 비밀번호를 입력한 후 테스트하세요.' }
};
return;
}
this.testing = true;
this.testResult = null;
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]').content;
const response = await fetch(`/system/tenant-mail/${tenantId}/test`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken,
'Accept': 'application/json',
},
body: JSON.stringify({
host: this.form.smtp_host,
port: parseInt(this.form.smtp_port),
encryption: this.form.smtp_encryption,
username: this.form.smtp_username,
password: this.form.smtp_password || '__EXISTING__',
preset: this.form.preset,
send_test_mail: this.sendTestMail,
}),
});
this.testResult = await response.json();
} catch (e) {
this.testResult = {
ok: false,
message: '테스트 요청 실패: ' + e.message,
data: {}
};
} finally {
this.testing = false;
}
},
async save() {
if (!this.form.from_name || !this.form.from_address) {
alert('발신자명과 발신 이메일은 필수입니다.');
return;
}
if (this.form.provider === 'smtp' && (!this.form.smtp_host || !this.form.smtp_username)) {
alert('SMTP 호스트와 사용자명은 필수입니다.');
return;
}
this.saving = true;
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]').content;
const response = await fetch(`/system/tenant-mail/${tenantId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken,
'Accept': 'application/json',
},
body: JSON.stringify(this.form),
});
const result = await response.json();
if (result.ok) {
alert('메일 설정이 저장되었습니다.');
window.location.reload();
} else {
alert(result.message || '저장 실패');
}
} catch (e) {
alert('저장 요청 실패: ' + e.message);
} finally {
this.saving = false;
}
},
};
}
</script>
@endpush
@endsection

View File

@@ -0,0 +1,132 @@
@extends('layouts.app')
@section('title', '테넌트 이메일 설정')
@push('styles')
<style>
.mail-status-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
}
.mail-status-green { background: #dcfce7; color: #16a34a; }
.mail-status-yellow { background: #fef9c3; color: #ca8a04; }
.mail-status-red { background: #fee2e2; color: #dc2626; }
.mail-status-gray { background: #f3f4f6; color: #6b7280; }
.provider-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
}
.provider-platform { background: #e0e7ff; color: #4338ca; }
.provider-smtp { background: #fef3c7; color: #d97706; }
.provider-none { background: #f3f4f6; color: #9ca3af; }
</style>
@endpush
@section('content')
<div class="space-y-6">
<!-- 페이지 헤더 -->
<div class="flex justify-between items-center">
<div>
<h1 class="text-2xl font-bold text-gray-800">테넌트 이메일 설정</h1>
<p class="text-sm text-gray-500 mt-1"> 테넌트의 메일 발송 설정을 관리합니다</p>
</div>
</div>
<!-- 테넌트 목록 -->
<div class="bg-white rounded-xl shadow-sm overflow-hidden">
<table class="w-full text-sm">
<thead>
<tr class="bg-gray-50 border-b border-gray-200">
<th class="px-4 py-3 text-left font-medium text-gray-600">테넌트</th>
<th class="px-4 py-3 text-left font-medium text-gray-600">발송 방식</th>
<th class="px-4 py-3 text-left font-medium text-gray-600">프리셋</th>
<th class="px-4 py-3 text-left font-medium text-gray-600">발신 주소</th>
<th class="px-4 py-3 text-center font-medium text-gray-600">상태</th>
<th class="px-4 py-3 text-center font-medium text-gray-600">오늘 발송</th>
<th class="px-4 py-3 text-center font-medium text-gray-600">관리</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@forelse($tenants as $item)
<tr class="hover:bg-gray-50 transition-colors">
<td class="px-4 py-3">
<div class="font-medium text-gray-800">{{ $item->tenant->company_name }}</div>
<div class="text-xs text-gray-400">ID: {{ $item->tenant->id }}</div>
</td>
<td class="px-4 py-3">
@if($item->config)
<span class="provider-badge provider-{{ $item->config->provider }}">
{{ $item->config->getProviderLabel() }}
</span>
@else
<span class="provider-badge provider-none">미설정</span>
@endif
</td>
<td class="px-4 py-3 text-gray-600">
{{ $item->config?->getPresetLabel() ?? '-' }}
</td>
<td class="px-4 py-3 text-gray-600">
{{ $item->config?->from_address ?? '-' }}
</td>
<td class="px-4 py-3 text-center">
@if($item->config)
<span class="mail-status-badge mail-status-{{ $item->config->getStatusColor() }}">
{{ $item->config->getStatusLabel() }}
</span>
@else
<span class="mail-status-badge mail-status-gray">미설정</span>
@endif
</td>
<td class="px-4 py-3 text-center text-gray-600">
@if($item->config)
{{ $item->today_count }}/{{ $item->config->daily_limit }}
@else
-
@endif
</td>
<td class="px-4 py-3 text-center">
<a href="{{ route('system.tenant-mail.edit', $item->tenant->id) }}"
class="inline-flex items-center gap-1 px-3 py-1.5 text-sm text-blue-600 hover:text-blue-800 hover:bg-blue-50 rounded-lg transition">
<i class="ri-settings-3-line"></i>
설정
</a>
</td>
</tr>
@empty
<tr>
<td colspan="7" class="px-4 py-8 text-center text-gray-400">
등록된 테넌트가 없습니다
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<!-- 안내 -->
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 text-sm text-blue-700">
<div class="flex items-start gap-2">
<i class="ri-information-line text-lg mt-0.5"></i>
<div>
<p class="font-medium">메일 설정 안내</p>
<ul class="mt-1 space-y-1 text-blue-600">
<li>미설정 테넌트는 SAM 플랫폼 기본 SMTP로 메일이 발송됩니다.</li>
<li>"자체 SMTP" 설정 테넌트 회사 도메인으로 메일이 발송됩니다.</li>
<li>SMTP 설정 반드시 연결 테스트를 수행하세요.</li>
</ul>
</div>
</div>
</div>
</div>
@endsection

View File

@@ -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');