[RAPID-micro] 메일 작성 다이얼로그에 보내는 사람 계정 선택 추가
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -390,7 +390,28 @@ class UserMailImapService {
|
||||
await client.logout();
|
||||
return { success: true, message: 'IMAP 연결 성공' };
|
||||
} catch (err) {
|
||||
return { success: false, message: err instanceof Error ? err.message : '연결 실패' };
|
||||
let message = '연결 실패';
|
||||
if (err instanceof Error) {
|
||||
const imapErr = err as any;
|
||||
const raw = imapErr.response || imapErr.responseCode || imapErr.cause?.message || err.message;
|
||||
const r = String(raw).toLowerCase();
|
||||
if (r.includes('authentication') || r.includes('invalid credentials') || r.includes('authenticationfailed') || r.includes('login failed')) {
|
||||
message = '인증 실패: 이메일 주소 또는 비밀번호가 올바르지 않습니다.';
|
||||
} else if (r.includes('econnrefused') || r.includes('connection refused')) {
|
||||
message = '연결 거부: 호스트 또는 포트를 확인하세요.';
|
||||
} else if (r.includes('enotfound') || r.includes('getaddrinfo')) {
|
||||
message = '호스트를 찾을 수 없습니다. IMAP 주소를 확인하세요.';
|
||||
} else if (r.includes('timeout') || r.includes('etimedout')) {
|
||||
message = '연결 시간 초과: 서버가 응답하지 않습니다.';
|
||||
} else if (r.includes('self signed') || r.includes('certificate')) {
|
||||
message = 'SSL 인증서 오류가 발생했습니다.';
|
||||
} else if (r.includes('econnreset')) {
|
||||
message = '연결이 강제로 끊겼습니다. TLS/SSL 설정을 확인하세요.';
|
||||
} else {
|
||||
message = raw;
|
||||
}
|
||||
}
|
||||
return { success: false, message };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEditor, EditorContent } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import LinkExtension from "@tiptap/extension-link";
|
||||
@@ -15,7 +15,15 @@ import {
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { sendUserMail } from "@/lib/api/userMail";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { toast } from "sonner";
|
||||
import { sendUserMail, UserMailAccount } from "@/lib/api/userMail";
|
||||
|
||||
interface ComposeDialogProps {
|
||||
open: boolean;
|
||||
@@ -34,6 +42,7 @@ interface ComposeDialogProps {
|
||||
sending: boolean;
|
||||
setSending: (v: boolean) => void;
|
||||
accountId: number | null;
|
||||
accounts: UserMailAccount[];
|
||||
}
|
||||
|
||||
export default function ComposeDialog({
|
||||
@@ -43,8 +52,14 @@ export default function ComposeDialog({
|
||||
initialHtml, setInitialHtml,
|
||||
inReplyTo, references,
|
||||
sending, setSending,
|
||||
accountId,
|
||||
accountId, accounts,
|
||||
}: ComposeDialogProps) {
|
||||
const [fromAccountId, setFromAccountId] = useState<number | null>(accountId);
|
||||
|
||||
useEffect(() => {
|
||||
setFromAccountId(accountId ?? (accounts[0]?.id ?? null));
|
||||
}, [accountId, accounts, open]);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [StarterKit, LinkExtension.configure({ openOnClick: false })],
|
||||
content: initialHtml,
|
||||
@@ -58,11 +73,11 @@ export default function ComposeDialog({
|
||||
}, [initialHtml, editor]);
|
||||
|
||||
async function handleSend() {
|
||||
if (!accountId || !editor) return;
|
||||
if (!fromAccountId || !editor) return;
|
||||
setSending(true);
|
||||
try {
|
||||
const html = editor.getHTML();
|
||||
const result = await sendUserMail(accountId, {
|
||||
const result = await sendUserMail(fromAccountId, {
|
||||
to,
|
||||
cc: cc || undefined,
|
||||
subject,
|
||||
@@ -74,7 +89,7 @@ export default function ComposeDialog({
|
||||
onOpenChange(false);
|
||||
setTo(""); setCc(""); setSubject(""); setInitialHtml("");
|
||||
} else {
|
||||
alert(result.message);
|
||||
toast.error(result.message);
|
||||
}
|
||||
} finally {
|
||||
setSending(false);
|
||||
@@ -89,27 +104,45 @@ export default function ComposeDialog({
|
||||
{mode === "reply" ? "답장" : mode === "forward" ? "전달" : "새 메일"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label>보내는 사람</Label>
|
||||
<Select
|
||||
value={fromAccountId?.toString() ?? ""}
|
||||
onValueChange={(v) => setFromAccountId(Number(v))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="계정을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[10002]">
|
||||
{accounts.map((acc) => (
|
||||
<SelectItem key={acc.id} value={acc.id.toString()}>
|
||||
{acc.displayName} ({acc.email})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>받는사람</Label>
|
||||
<Input value={to} onChange={(e) => setTo(e.target.value)} placeholder="to@example.com" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>참조(CC)</Label>
|
||||
<Input value={cc} onChange={(e) => setCc(e.target.value)} placeholder="cc@example.com (선택)" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>제목</Label>
|
||||
<Input value={subject} onChange={(e) => setSubject(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>내용</Label>
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>취소</Button>
|
||||
<Button onClick={handleSend} disabled={sending || !to || !subject}>
|
||||
<Button onClick={handleSend} disabled={sending || !to || !subject || !fromAccountId}>
|
||||
{sending ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <Send className="w-4 h-4 mr-2" />}
|
||||
보내기
|
||||
</Button>
|
||||
|
||||
@@ -87,7 +87,8 @@ import {
|
||||
SendMailDto,
|
||||
|
||||
} from "@/lib/api/userMail";
|
||||
const ComposeDialogDynamic = dynamic(() => import("./ComposeDialog"), { ssr: false });
|
||||
import type ComposeDialogType from "./ComposeDialog";
|
||||
const ComposeDialogDynamic = dynamic(() => import("./ComposeDialog"), { ssr: false }) as typeof ComposeDialogType;
|
||||
|
||||
const IMAP_PRESETS = [
|
||||
{ label: "직접 입력", value: "custom", host: "", port: 993, useTls: true },
|
||||
@@ -128,6 +129,7 @@ export default function ImapMailPage() {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [formErrors, setFormErrors] = useState<Partial<Record<'displayName' | 'email' | 'host' | 'username' | 'password', string>>>({});
|
||||
|
||||
// New states
|
||||
const [folders, setFolders] = useState<MailFolder[]>([]);
|
||||
@@ -431,6 +433,7 @@ export default function ImapMailPage() {
|
||||
setTestResult(null);
|
||||
setShowPassword(false);
|
||||
setHostPopoverOpen(false);
|
||||
setFormErrors({});
|
||||
setShowDialog(true);
|
||||
}
|
||||
|
||||
@@ -460,6 +463,14 @@ export default function ImapMailPage() {
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
const errors: typeof formErrors = {};
|
||||
if (!form.displayName.trim()) errors.displayName = "표시 이름을 입력하세요.";
|
||||
if (!form.email.trim()) errors.email = "이메일 주소를 입력하세요.";
|
||||
if (!form.host.trim()) errors.host = "IMAP 호스트를 입력하세요.";
|
||||
if (!form.username.trim()) errors.username = "사용자명을 입력하세요.";
|
||||
if (!editingAccount && !form.password.trim()) errors.password = "비밀번호를 입력하세요.";
|
||||
if (Object.keys(errors).length > 0) { setFormErrors(errors); return; }
|
||||
setFormErrors({});
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
@@ -897,6 +908,7 @@ export default function ImapMailPage() {
|
||||
references={composeReferences}
|
||||
sending={composeSending} setSending={setComposeSending}
|
||||
accountId={selectedAccount?.id ?? null}
|
||||
accounts={imapAccounts}
|
||||
/>
|
||||
|
||||
{/* 계정 추가/편집 다이얼로그 */}
|
||||
@@ -912,18 +924,22 @@ export default function ImapMailPage() {
|
||||
<Label className="text-xs">표시 이름</Label>
|
||||
<Input
|
||||
value={form.displayName}
|
||||
onChange={(e) => setForm((p) => ({ ...p, displayName: e.target.value }))}
|
||||
onChange={(e) => { setForm((p) => ({ ...p, displayName: e.target.value })); setFormErrors((p) => ({ ...p, displayName: undefined })); }}
|
||||
placeholder="내 Gmail"
|
||||
className={formErrors.displayName ? "border-destructive" : ""}
|
||||
/>
|
||||
{formErrors.displayName && <p className="text-xs text-destructive">{formErrors.displayName}</p>}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">이메일 주소</Label>
|
||||
<Input
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(e) => setForm((p) => ({ ...p, email: e.target.value, username: e.target.value }))}
|
||||
onChange={(e) => { setForm((p) => ({ ...p, email: e.target.value, username: e.target.value })); setFormErrors((p) => ({ ...p, email: undefined })); }}
|
||||
placeholder="user@example.com"
|
||||
className={formErrors.email ? "border-destructive" : ""}
|
||||
/>
|
||||
{formErrors.email && <p className="text-xs text-destructive">{formErrors.email}</p>}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
@@ -937,12 +953,12 @@ export default function ImapMailPage() {
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[220px] p-0" align="start">
|
||||
<PopoverContent className="w-[220px] p-0 z-[10002]" align="start">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="호스트 직접 입력..."
|
||||
value={form.host}
|
||||
onValueChange={(v) => setForm((p) => ({ ...p, host: v }))}
|
||||
onValueChange={(v) => { setForm((p) => ({ ...p, host: v })); setFormErrors((p) => ({ ...p, host: undefined })); }}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>직접 입력한 값을 사용합니다</CommandEmpty>
|
||||
@@ -968,6 +984,7 @@ export default function ImapMailPage() {
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{formErrors.host && <p className="text-xs text-destructive">{formErrors.host}</p>}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">포트</Label>
|
||||
@@ -986,9 +1003,11 @@ export default function ImapMailPage() {
|
||||
<Label className="text-xs">사용자명</Label>
|
||||
<Input
|
||||
value={form.username}
|
||||
onChange={(e) => setForm((p) => ({ ...p, username: e.target.value }))}
|
||||
onChange={(e) => { setForm((p) => ({ ...p, username: e.target.value })); setFormErrors((p) => ({ ...p, username: undefined })); }}
|
||||
placeholder="user@example.com"
|
||||
className={formErrors.username ? "border-destructive" : ""}
|
||||
/>
|
||||
{formErrors.username && <p className="text-xs text-destructive">{formErrors.username}</p>}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">비밀번호 {editingAccount && "(변경 시에만 입력)"}</Label>
|
||||
@@ -996,9 +1015,10 @@ export default function ImapMailPage() {
|
||||
<Input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={form.password}
|
||||
onChange={(e) => setForm((p) => ({ ...p, password: e.target.value }))}
|
||||
onChange={(e) => { setForm((p) => ({ ...p, password: e.target.value })); setFormErrors((p) => ({ ...p, password: undefined })); }}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") handleSave(); }}
|
||||
placeholder="••••••••"
|
||||
className="pr-9"
|
||||
className={`pr-9 ${formErrors.password ? "border-destructive" : ""}`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1008,6 +1028,7 @@ export default function ImapMailPage() {
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
{formErrors.password && <p className="text-xs text-destructive">{formErrors.password}</p>}
|
||||
</div>
|
||||
{editingAccount && (
|
||||
<div>
|
||||
|
||||
Reference in New Issue
Block a user