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:
250
app/Http/Controllers/System/TenantMailConfigController.php
Normal file
250
app/Http/Controllers/System/TenantMailConfigController.php
Normal 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', []),
|
||||
]);
|
||||
}
|
||||
}
|
||||
47
app/Models/Tenants/MailLog.php
Normal file
47
app/Models/Tenants/MailLog.php
Normal 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;
|
||||
}
|
||||
}
|
||||
155
app/Models/Tenants/TenantMailConfig.php
Normal file
155
app/Models/Tenants/TenantMailConfig.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
245
app/Services/Mail/SmtpConnectionTester.php
Normal file
245
app/Services/Mail/SmtpConnectionTester.php
Normal 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 => '설정을 다시 확인하고 재시도하세요.',
|
||||
};
|
||||
}
|
||||
}
|
||||
222
app/Services/Mail/TenantMailService.php
Normal file
222
app/Services/Mail/TenantMailService.php
Normal 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
68
config/mail-presets.php
Normal 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' => '호스트, 포트, 암호화 방식을 직접 입력',
|
||||
],
|
||||
];
|
||||
509
resources/views/system/tenant-mail/edit.blade.php
Normal file
509
resources/views/system/tenant-mail/edit.blade.php
Normal 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
|
||||
132
resources/views/system/tenant-mail/index.blade.php
Normal file
132
resources/views/system/tenant-mail/index.blade.php
Normal 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
|
||||
@@ -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');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user